feat(constraints): 操作约束抽取→注入 planner/worker 提示→总览管理
- 新表 task_constraints(allow/deny),CREATE TABLE IF NOT EXISTS 无需迁移 - 新工具 set_constraints:目标拆解器 round-0 先抽约束再拆目标、主 agent 运行时可补; 拆解器提示词要求约束自包含(把「当前目标」等指代替换成具体域名/端口) - constraintBlock 按最高优先级注入 planner+worker 系统提示;去偏:多样性/拓面显式服从约束 - 注入范围可配置(constraints_inject_planner/worker,默认都开,每轮读、即时生效) - 总览「约束管理」卡片 + CRUD 接口(增删改不通知 planner,下轮规划自然读到) - goals 提示词把抽约束步并入可编辑正文;旧库经 flag 守卫的一次性 reseed 追加新默认版本
This commit is contained in:
1 parent
d8f34cc79d
commit
27c3248844
19 files changed
+820
-15
No files matched your search
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// constraintBlock renders this task's operation constraints (task_constraints) as a
|
||||
// high-priority block appended to the planner/worker system prompt. allow/deny are
|
||||
// grouped; empty string when there are no constraints (or ts is nil). The framing
|
||||
// deliberately puts these ABOVE the exploration/expansion heuristics so a declared
|
||||
// boundary wins the tug-of-war against "chase another entry surface".
|
||||
func constraintBlock(ts *db.ExplorationStore) string {
|
||||
if ts == nil {
|
||||
return ""
|
||||
}
|
||||
rows, err := ts.ListConstraints()
|
||||
if err != nil || len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
var allow, deny []string
|
||||
for _, c := range rows {
|
||||
text := strings.TrimSpace(c.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if c.Kind == "allow" {
|
||||
allow = append(allow, "- "+text)
|
||||
} else {
|
||||
deny = append(deny, "- "+text)
|
||||
}
|
||||
}
|
||||
if len(allow) == 0 && len(deny) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n【操作约束(最高优先级,凌驾于下方一切探索/拓面启发式;每生成一条意图、每执行一个动作前都必须先自检是否违反,违反即不得进行)】:")
|
||||
if len(allow) > 0 {
|
||||
b.WriteString("\n允许的操作:\n")
|
||||
b.WriteString(strings.Join(allow, "\n"))
|
||||
}
|
||||
if len(deny) > 0 {
|
||||
b.WriteString("\n禁止的操作:\n")
|
||||
b.WriteString(strings.Join(deny, "\n"))
|
||||
}
|
||||
b.WriteString("\n(发现约束之外的新目标/新端口/新主机,不等于获得授权:除非它落在上述允许范围内,否则记为 out-of-scope 事实并跳过,不得为其派生意图或执行动作。)")
|
||||
return b.String()
|
||||
}
|
||||
+16
-2
@@ -16,6 +16,17 @@ import (
|
||||
// prompt, seeded into agent_prompts. No template vars are used today.
|
||||
const goalsDefaultTmpl = `你是渗透测试目标分解器。你的职责是从用户输入中识别出**最终要达成的结果**,而不是规划攻击步骤。
|
||||
|
||||
**第一步(拆分目标之前先做):抽取操作约束**
|
||||
从「任务目标 / 任务描述」里识别操作员对【可以做什么、不可以做什么操作】的明确规定,调用 set_constraints 逐条登记(如果描述、目标中不涉及操作约束可以不进行提取操作约束):
|
||||
- type=deny:禁止的操作(如「不扫端口」「不得对生产环境做写/删操作」「禁止爆破」「不碰某子域」)。
|
||||
- type=allow:明确允许/限定的操作范围(如「只允许被动侦察」「仅针对某域名」)。
|
||||
- 约束 ≠ 目标,也 ≠ 攻击步骤:它是对操作行为边界的规定。
|
||||
- **约束必须【自包含、写死具体目标】**:把「当前目标/当前端口/当前IP/当前域名/本站」这类**指代词**替换成任务目标/描述里的**具体值**。约束会被单独注入到执行阶段的提示里,脱离上下文后指代词无法判断指谁。
|
||||
例:目标是 https://abc.example.net → 写「只允许测试 abc.example.net」而不是「只允许测试当前目标」;「仅测目标端口 443,不扫其他端口」而不是「只测当前端口」。若原文只说「当前目标」但目标地址已明确,就把地址填进去。
|
||||
- **只登记目标/描述里【明确写出或强调】的约束,严禁臆造**;拿不准类型时用 deny(更保守)。
|
||||
- 若目标/描述里确实没有任何操作约束,则**不要**调用 set_constraints。
|
||||
登记完约束(如有)后,再进行下面的目标拆分。
|
||||
|
||||
**目标 = 最终可交付/可核验的结果**
|
||||
|
||||
**不是目标的内容(禁止列为子目标)**:
|
||||
@@ -105,7 +116,9 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir,
|
||||
// {{.EngagementDescription}} template var — else a prompt that references the var
|
||||
// would inject the description twice. System prompt stays pure static instructions.
|
||||
sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{DataDir: dataDir, Now: nowStr()})
|
||||
tools := []actool.CoreTool{tsx.setGoals()}
|
||||
// set_constraints 始终可用(不依赖 asset store):正文已含「先抽操作约束再拆目标」这步
|
||||
// (可在 agent 编辑页改措辞),这里只需接上工具。
|
||||
tools := []actool.CoreTool{tsx.setGoals(), tsx.setConstraints()}
|
||||
// Wire add_task_scope only when we have a real asset store + task to write to.
|
||||
// The scope-extraction tail is appended in lockstep so the prompt never asks for
|
||||
// a tool that isn't present.
|
||||
@@ -131,7 +144,8 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir,
|
||||
Tools: tools,
|
||||
PermissionMode: acperm.ModeBypass,
|
||||
DisableBackgroundTasks: true,
|
||||
MaxTurns: 6,
|
||||
// 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。
|
||||
MaxTurns: 8,
|
||||
}, userMsg, captureEmit)
|
||||
// set_goals persisted the goals directly; read them back so the caller sees what
|
||||
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
|
||||
|
||||
@@ -65,6 +65,7 @@ const mainAgentDefaultTmpl = `你是一个授权渗透测试系统的"主 agent"
|
||||
- 人想"立刻测某个具体目标" → 用 add_intent 直接注入一条高优先级意图(priority 8-10)。
|
||||
- 人想"对某条正在运行的意图(work)实时纠偏(别再走 X、聚焦 Y)" → 用 steer_work(不打断、不丢已有进展,worker 下一步动作前生效);先用 get_worker_output 看它在干嘛。方向整个错了则改用 add_intent 另下新意图。
|
||||
- 人想"新增一个要达成的最终目标" → 用 set_goals 增补目标。系统会把该目标写入任务图并**自动把已完成/暂停的任务拉回运行态继续跑**(规划者随后会据此重新判断是否达成),无需人工再点恢复。
|
||||
- 人想"增/改测试约束(允许/禁止某类操作,如『仅测当前端口』『禁止爆破』『只做被动侦察』)" → 用 set_constraints 登记(type=allow 允许 / type=deny 禁止)。约束会在下一轮规划时注入 planner/worker 的提示词以框定探索边界;也可在总览「约束管理」里增删改。
|
||||
3. 用人话简洁回复,说明你做了什么。
|
||||
|
||||
当前任务目标:{{.Goal}}
|
||||
|
||||
+15
-2
@@ -32,6 +32,7 @@ type Planner struct {
|
||||
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
|
||||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||||
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
|
||||
|
||||
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
|
||||
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
|
||||
@@ -61,6 +62,14 @@ func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = a
|
||||
// SetWebSearch selects the web_search backend for the planner (off by default).
|
||||
func (p *Planner) SetWebSearch(o WebSearchOpts) { p.webSearch = o }
|
||||
|
||||
// SetConstraintInject wires a resolver deciding whether this task's operation
|
||||
// constraints get injected into the planner system prompt. Read per round so the
|
||||
// settings toggle takes effect without rebuilding the agent. nil = inject (default).
|
||||
func (p *Planner) SetConstraintInject(fn func() bool) { p.injectConstraints = fn }
|
||||
|
||||
// wantConstraints reports whether constraint injection is enabled (default yes).
|
||||
func (p *Planner) wantConstraints() bool { return p.injectConstraints == nil || p.injectConstraints() }
|
||||
|
||||
// todoFor returns the task's persistent planning todo store, creating it on first
|
||||
// use. Shared across all of this task's planner wake-ups.
|
||||
func (p *Planner) todoFor(expID int64) *actool.TodoStore {
|
||||
@@ -245,7 +254,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
|
||||
- 该方向在 recent_done_intents 里已尝试过(即使没产出)→ **不要原样重试**。把它当作【已封锁路线(blocked)】:仅当出现【材料性的新机理】——新事实、新资产、新参数、或一种明显不同的打法/构造——才重新派,且新意图的 summary 里要写清"这次和上次不同在哪"。**只是换个措辞、或"再试一次说不定行"都不算新机理,禁止重试。** 反之,若某条否定结论是 confidence=inferred 的弱证据、且该方向对目标关键,用【一条复核意图】去证实或推翻它是正当的(这属于新理由)。
|
||||
- 仅对【当前完全没有任何意图覆盖的全新方向】生成。
|
||||
- 所有已知方向都已被现有意图覆盖 → **不生成任何意图,直接结束本轮。**
|
||||
- **保持路线多样、别过早收敛到一条**:当目标尚未达成时,若现有意图全都挤在【同一条攻击路线/同一类入口】上,而还存在【本质不同】的未覆盖方向(如另一种入口面、另一类资产、另一条利用链),优先补一条那样的分歧方向,而不是在同一条线上再加同义意图。理想状态是让 2–3 条彼此独立、机理不同的路线同时存活(如"从上传链打"与"从认证绕过打");只有当某条路线已交出【目标逼近】的证据时,才值得把资源集中过去。判断多样性看方向的实质差异,不看措辞。(这不与"0 意图正常"冲突:只有确存在本质不同且未覆盖的方向才补;空白路线若已被现有意图覆盖,仍旧 0 意图。)
|
||||
- **保持路线多样、别过早收敛到一条**:当目标尚未达成时,若现有意图全都挤在【同一条攻击路线/同一类入口】上,而还存在【本质不同】的未覆盖方向(如另一种入口面、另一类资产、另一条利用链),优先补一条那样的分歧方向,而不是在同一条线上再加同义意图。**但这条【多样性】永远服从于顶部的【操作约束】**:只在不违反约束的前提下才拓展方向——被约束排除的入口面/端口/主机/操作,即使"本质不同"也绝不生成意图。理想状态是让 2–3 条彼此独立、机理不同的路线同时存活(如"从上传链打"与"从认证绕过打");只有当某条路线已交出【目标逼近】的证据时,才值得把资源集中过去。判断多样性看方向的实质差异,不看措辞。(这不与"0 意图正常"冲突:只有确存在本质不同且未覆盖的方向才补;空白路线若已被现有意图覆盖,仍旧 0 意图。)
|
||||
3.5. **串行利用链:分步派,别一次拆成并行。** 很多利用是一条**强依赖串行链**(如:①→ ② → ③)——后一步依赖前一步的**实际产出**。这种情况:
|
||||
- **不要**把 ①②③ 一次性作为多个并行意图下发(下游 worker 拿不到还不存在的前置产出,只会重复/空转);
|
||||
- **用 TodoWrite 把整条链记成待办**(每步一条),然后**本轮只派"前置已满足"的那一步**(通常是第一步);
|
||||
@@ -303,7 +312,11 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
}
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||||
system, boundary := deferredSystem(plannerSystem(goal, p.workDir, taskDir), def)
|
||||
sysBody := plannerSystem(goal, p.workDir, taskDir)
|
||||
if p.wantConstraints() {
|
||||
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界
|
||||
}
|
||||
system, boundary := deferredSystem(sysBody, def)
|
||||
// planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在
|
||||
// 任务到点时进收尾(因超时→任务超时词,因步数→per-run 词)。
|
||||
maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, 0)
|
||||
|
||||
@@ -37,9 +37,9 @@ func builtinToolsByAgent() map[string][]actool.CoreTool {
|
||||
"mainagent": ts.MainAgentTools(),
|
||||
"planner": ts.PlannerTools(),
|
||||
"worker": ts.WorkerTools(),
|
||||
// goals(目标拆解器)默认绑 set_goals:它靠这个工具把拆出的目标写进图。
|
||||
// 与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。
|
||||
"goals": {ts.setGoals()},
|
||||
// goals(目标拆解器)默认绑 set_goals + set_constraints:靠它们把拆出的目标、
|
||||
// 抽出的操作约束写进库。与 mainagent 共用同一受管工具,web 端可改描述/schema、按 agent 勾选。
|
||||
"goals": {ts.setGoals(), ts.setConstraints()},
|
||||
// auto 默认绑漏洞上报 + 资产管理工具,其他域工具可在 UI 按需勾选。
|
||||
// 新库由此 seed 写入;老库由 seedAutoDefaultBindings 迁移。
|
||||
"auto": {ts.addFinding(), ts.insertAssets(), ts.addCompanyScope(), ts.listAssets(), ts.listCompanies()},
|
||||
|
||||
@@ -1402,6 +1402,80 @@ func (t *ToolSet) setGoals() actool.CoreTool {
|
||||
})
|
||||
}
|
||||
|
||||
type constraintItem struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"` // allow | deny
|
||||
}
|
||||
|
||||
// addOneConstraint 落一条操作约束到 task_constraints。origin 取 t.worker(缺省 system):
|
||||
// 拆解器写 "goals"、主 agent 写 "human"。
|
||||
func (t *ToolSet) addOneConstraint(it constraintItem) (int64, error) {
|
||||
text := strings.TrimSpace(it.Text)
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("text 不能为空")
|
||||
}
|
||||
kind := strings.TrimSpace(strings.ToLower(it.Type))
|
||||
if kind == "" {
|
||||
kind = "deny" // 默认按禁止处理:未标注类型时更保守
|
||||
}
|
||||
if kind != "allow" && kind != "deny" {
|
||||
return 0, fmt.Errorf("type 必须是 allow 或 deny")
|
||||
}
|
||||
return t.ts.AddConstraint(kind, text, t.worker)
|
||||
}
|
||||
|
||||
// setConstraints 给【本任务】新增操作约束(allow=允许做什么 / deny=禁止做什么)。既是目标
|
||||
// 拆解器 round-0 抽约束的提交工具,也是主 agent 运行时补约束的工具——同一受管工具,可在 web
|
||||
// 端改描述/schema、按 agent 绑定。约束会被注入 planner/worker 的系统提示以约束探索边界。
|
||||
func (t *ToolSet) setConstraints() actool.CoreTool {
|
||||
return writeTool("set_constraints",
|
||||
"给【本任务】新增操作约束,用来框定探索边界:type=allow(允许做的操作)或 deny(禁止做的操作)。\n"+
|
||||
"约束=对『可以/不可以做哪些操作』的规定(如『仅测当前端口,不扫其他端口』『禁止对生产库做写操作』『只允许被动侦察』),不是目标、也不是攻击步骤。\n"+
|
||||
"★优先批量:多条放进 constraints 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 constraints 直接给顶层 text/type。\n"+
|
||||
"只登记任务目标/描述里【明确写出】的约束,不要臆造;拿不准类型时用 deny(更保守)。",
|
||||
obj(map[string]any{
|
||||
"constraints": map[string]any{"type": "array", "description": "【优先用这个】要新增的约束数组,按顺序处理。每个元素:text(必填,一条约束)+ type(allow|deny)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
|
||||
"text": str("[单条] 一条操作约束的内容"),
|
||||
"type": str("[单条] allow(允许)或 deny(禁止);缺省按 deny 处理"),
|
||||
}),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
if t.ts == nil {
|
||||
return actool.Errorf("set_constraints 未启用: ExplorationStore 未初始化"), nil
|
||||
}
|
||||
var a struct {
|
||||
Constraints []constraintItem `json:"constraints"`
|
||||
constraintItem // 单条模式:顶层 text/type
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
batch := len(a.Constraints) > 0
|
||||
items := a.Constraints
|
||||
if !batch {
|
||||
items = []constraintItem{a.constraintItem}
|
||||
}
|
||||
ids := make([]int64, len(items))
|
||||
errs := map[string]string{}
|
||||
for i, it := range items {
|
||||
id, err := t.addOneConstraint(it)
|
||||
if err != nil {
|
||||
errs[strconv.Itoa(i)] = err.Error()
|
||||
continue
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
if !batch { // 单条:保持简单返回
|
||||
if e, bad := errs["0"]; bad {
|
||||
return actool.Errorf(e), nil
|
||||
}
|
||||
return actool.Text(fmt.Sprintf("constraint added: %d", ids[0])), nil
|
||||
}
|
||||
out := map[string]any{"ids": ids}
|
||||
if len(errs) > 0 {
|
||||
out["errors"] = errs
|
||||
}
|
||||
return jsonResult(out)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ToolSet) addHint() actool.CoreTool {
|
||||
return writeTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+
|
||||
"★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。",
|
||||
|
||||
@@ -615,6 +615,8 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool {
|
||||
t.steerWorkTool(),
|
||||
// set_goals:人可在运行时给本任务补一个新的最终目标(规划者据此重判是否达成)。
|
||||
t.setGoals(),
|
||||
// set_constraints:人可在运行时给本任务补/改操作约束(allow/deny),约束 planner/worker 的探索边界。
|
||||
t.setConstraints(),
|
||||
// asset management (handlers guard nil store internally)
|
||||
t.insertAssets(), t.addCompanyScope(), t.listAssets(),
|
||||
t.addFinding(), t.recordFact(),
|
||||
|
||||
+17
-2
@@ -58,8 +58,19 @@ type Worker struct {
|
||||
// extraTools are host-provided tools (e.g. traffic query, oast) appended to
|
||||
// the worker's graph write-back tools.
|
||||
extraTools []actool.CoreTool
|
||||
// injectConstraints resolves whether this task's operation constraints get
|
||||
// injected into the worker system prompt. Read per run so the settings toggle
|
||||
// takes effect without rebuilding the agent. nil = inject (default).
|
||||
injectConstraints func() bool
|
||||
}
|
||||
|
||||
// SetConstraintInject wires a resolver deciding whether this task's operation
|
||||
// constraints get injected into the worker system prompt. nil = inject (default).
|
||||
func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn }
|
||||
|
||||
// wantConstraints reports whether constraint injection is enabled (default yes).
|
||||
func (w *Worker) wantConstraints() bool { return w.injectConstraints == nil || w.injectConstraints() }
|
||||
|
||||
// SetRunTimeout configures the per-intent wall-clock budget for the main
|
||||
// exploration (0 = unlimited). When it fires, the SDK settlement phase still runs
|
||||
// so facts are never lost to a timeout. Safe to call before Execute.
|
||||
@@ -151,7 +162,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo
|
||||
- report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。
|
||||
- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。
|
||||
|
||||
只在授权范围内操作。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
|
||||
只在授权范围内操作;若系统提示顶部附有【操作约束】,那是最高优先级红线——任何命令/探测在执行前先自检是否违反,违反即不做(哪怕它落在你领到的意图里)。完成本意图后用一句话总结你做了什么、写回了哪些事实。务实、克制、聚焦这一条意图。`
|
||||
|
||||
// workerTrafficBlock is 段 [B]: the traffic-tool note, code-injected only when
|
||||
// traffic capture is on (proxyAddr set). Not stored, not editable.
|
||||
@@ -278,7 +289,11 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// 压缩。本次意图的专属工作目录 <workDir>/tasks/<taskID>/i<intentID>,引擎侧先建好。
|
||||
runDir := ensureRunDir(w.workDir, taskID, intent.ID)
|
||||
overview := renderWorkerGraphOverview(tsx.graphOverviewData())
|
||||
system, boundary := deferredSystem(workerSystem(w.proxyAddr, w.workDir, runDir), def)
|
||||
sysBody := workerSystem(w.proxyAddr, w.workDir, runDir)
|
||||
if w.wantConstraints() {
|
||||
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,worker 执行时严格遵守
|
||||
}
|
||||
system, boundary := deferredSystem(sysBody, def)
|
||||
// 任务级 deadline(经 ctx 注入)夹逼本 run 的墙钟预算 + 决定收尾词(见 taskclock.go)。
|
||||
tc := taskClockFrom(ctx)
|
||||
maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, w.runTimeout)
|
||||
|
||||
Reference in new issue
Block a user