From 37fab188096578dec48f6a76958d5a952d9bad40 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:37:58 +0100 Subject: [PATCH 1/3] fix(coverage): count auto-scope only under a declared root (F8) Auto task_scope rows (source='auto') come from insert_assets and intent asset_ids, i.e. from target-derived content. They now count toward the coverage denominator and list_untested_assets only when they sit under a root the same task declared (a non-auto row: goal target registered via add_task_scope, or manual scope): the same or a deeper domain of a declared root_domain/subdomain, or a net inside a declared ip/cidr. The direct-source coverage view also stops counting an asset from an intent anchor alone; fact anchors still count, so tested evidence is unchanged. Without this, insert_assets put a planted host back into the backlog through its intent anchor. task_scope is described as "coverage scope, not enforced" in the code comments, the add_task_scope description, the goal-decomposer tail and the task overview UI, instead of "authorization boundary". Audit F8, 0.7.0. --- agent/auto_scope_hygiene_test.go | 106 ++++++++++++++++++ agent/goals.go | 2 +- agent/tools_insert.go | 15 ++- agent/worker.go | 3 + db/schema.sql | 3 +- db/task_assets_context.go | 9 +- db/task_scope.go | 36 ++++-- .../tasks/detail/_tabs/overview-tab.tsx | 4 +- web/src/lib/types.ts | 2 +- 9 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 agent/auto_scope_hygiene_test.go diff --git a/agent/auto_scope_hygiene_test.go b/agent/auto_scope_hygiene_test.go new file mode 100644 index 0000000..b4e9943 --- /dev/null +++ b/agent/auto_scope_hygiene_test.go @@ -0,0 +1,106 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +// TestTaskScopeLabelIsHonest pins the task_scope wording while the RoE is not +// back: the tool text and the goals tail call it coverage scope, not enforced, and +// no longer claim an authorization boundary. +func TestTaskScopeLabelIsHonest(t *testing.T) { + texts := map[string]string{ + "add_task_scope": NewToolSet(nil, "").addTaskScope().Description(), + "goalsScopeTail": goalsScopeTail, + } + for name, text := range texts { + if !strings.Contains(text, taskScopeLabel) { + t.Errorf("%s lacks %q", name, taskScopeLabel) + } + if strings.Contains(text, "authorization boundary") { + t.Errorf("%s still claims an authorization boundary", name) + } + } +} + +// TestAutoScopeOutsideDeclaredRootExcluded: a worker inserts one host under the +// declared root and one host outside it. Both get an auto scope row and an intent +// anchor, but only the host under the declared root enters the coverage +// denominator and list_untested_assets. +func TestAutoScopeOutsideDeclaredRootExcluded(t *testing.T) { + d := testDB(t) + defer d.Close() + + stamp := time.Now().UnixNano() + declared := fmt.Sprintf("app.f8-%d.invalid", stamp) + inside := "v1." + declared + outsideRoot := fmt.Sprintf("f8-planted-%d.invalid", stamp) + outside := "x." + outsideRoot + + task, err := d.CreateTask("auto scope hygiene", "test "+declared, nil, 0, 0) + if err != nil { + t.Fatal(err) + } + assets := d.Assets() + t.Cleanup(func() { + _ = d.DeleteTask(task.ID) + _, _ = d.Exec(`DELETE FROM assets WHERE domain IN ($1,$2,$3) OR root_domain IN ($4,$5)`, + declared, inside, outside, fmt.Sprintf("f8-%d.invalid", stamp), outsideRoot) + }) + // The goal target, as the goal decomposer registers it through add_task_scope. + if _, err := assets.AddAgentScope(task.ID, "subdomain", declared, "goal target", "agent"); err != nil { + t.Fatal(err) + } + store := d.Exploration(task.ExplorationID) + intentID, err := store.AddNode(db.KindIntent, map[string]any{"summary": "recon"}, 0, "open", "planner", nil) + if err != nil { + t.Fatal(err) + } + + worker := NewToolSet(store, "worker") + worker.SetTaskID(task.ID) + worker.SetAssetStore(assets, d.Companies()) + worker.SetOwnerNode(intentID) + out := callInsertAssets(t, worker, map[string]any{"assets": []any{ + map[string]any{"type": "subdomain", "domain": inside}, + map[string]any{"type": "subdomain", "domain": outside}, + }}) + if errs, _ := out["errors"].([]any); len(errs) > 0 { + t.Fatalf("insert_assets errors: %v", errs) + } + scope, err := assets.ListTaskScope(task.ID) + if err != nil { + t.Fatal(err) + } + autoRows := map[string]bool{} + for _, row := range scope { + if row.Source == "auto" { + autoRows[row.Domain] = true + } + } + if !autoRows[inside] || !autoRows[outside] { + t.Fatalf("both hosts should be auto-scoped: %+v", scope) + } + + planner := NewToolSet(store, "planner") + planner.SetTaskID(task.ID) + planner.SetAssetStore(assets, d.Companies()) + coverage, ok := planner.graphOverviewData()["coverage"].(map[string]any) + if !ok { + t.Fatal("coverage block missing from graph overview") + } + if got := coverage["denominator"]; got != 1 { + t.Fatalf("coverage denominator = %v, want 1 (only %s)", got, inside) + } + untested := fmt.Sprint(callReadJSON(t, planner.listUntestedAssets(), `{"page":1,"page_size":50}`)) + if !strings.Contains(untested, inside) { + t.Fatalf("host under the declared root missing from list_untested_assets: %s", untested) + } + if strings.Contains(untested, outsideRoot) { + t.Fatalf("host outside the declared root listed as untested: %s", untested) + } +} diff --git a/agent/goals.go b/agent/goals.go index e8a7e03..2836c0e 100644 --- a/agent/goals.go +++ b/agent/goals.go @@ -53,7 +53,7 @@ Call set_goals to submit the results.` const goalsScopeTail = ` **Extra duty: register the test asset scope** -Besides decomposing goals, you must also identify the **explicitly stated test asset scope** from the "task goal / task description" and register it via add_task_scope (this task's authorization boundary, and also the denominator of asset test coverage). **Minimal-scope principle: register only the one target the user explicitly named — never enlarge it on your own.** +Besides decomposing goals, you must also identify the **explicitly stated test asset scope** from the "task goal / task description" and register it via add_task_scope (this task's ` + taskScopeLabel + `: the denominator of asset test coverage, and the root under which auto-added hosts count). **Minimal-scope principle: register only the one target the user explicitly named — never enlarge it on your own.** - The goal is a URL or a host-bearing address (e.g. https://xxx.example.com/path, app.example.com) → take its **full hostname**, kind=subdomain, value=full hostname. Example: goal https://a1b2c3.lab.example.net/path → kind=subdomain, value=a1b2c3.lab.example.net (**not** example.net). **Never** shrink a subdomain-bearing hostname to its root domain — registering all of example.com upon seeing xxx.example.com expands the scope beyond the user's target, violating the minimal-scope principle. diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 99b61a1..ffbd353 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -364,6 +364,11 @@ func (t *ToolSet) insertAssets() actool.CoreTool { // inserted by the worker at top level, adding a conservative scope per its // type; side-effect-derived assets don't go through here, so the scope // doesn't widen blindly. No-op when taskID=0. + // The item may come from target content, so this auto row counts toward + // coverage and list_untested_assets only under a declared root (a + // non-auto row: goal target, add_task_scope or manual scope, see + // db.declaredScopeFilter). task_scope is + // coverage scope, not enforced: nothing blocks a call outside it. // Independent of the coverage switch: task_scope is the task's range // boundary (the filter basis for list/query); the coverage switch only // decides whether to use it as the denominator for metrics, not whether @@ -436,16 +441,20 @@ func (t *ToolSet) addCompanyScope() actool.CoreTool { ) } +// taskScopeLabel is the honest description of task_scope while the RoE is not back: +// it drives coverage and asset filters, and nothing enforces it on tool calls. +const taskScopeLabel = "coverage scope, not enforced" + // addTaskScope lets the plan agent add test scope to THE CURRENT TASK — the coverage -// denominator and the task's authorization edge. Worker discoveries are auto-scoped +// denominator (coverage scope, not enforced). Worker discoveries are auto-scoped // (precise host) by insertAssets; this tool is for DELIBERATELY WIDENING: pull a whole // root domain or whole company into scope, or add a specific subdomain / ip. func (t *ToolSet) addTaskScope() actool.CoreTool { return writeTool( "add_task_scope", - "Add test scope to [this task] — this is the task's authorization boundary, and also the denominator of asset test coverage.\n"+ + "Add test scope to [this task] — "+taskScopeLabel+": it is the denominator of asset test coverage; nothing blocks a tool call outside it.\n"+ "Supported kinds: company (all assets under the company) / root_domain (a whole root domain, including all subdomains) / subdomain (a single precise subdomain) / ip / cidr / icp / keyword.\n"+ - "Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n"+ + "Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains), but they count toward coverage and list_untested_assets only under a declared root (a goal target or scope added here or manually); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n"+ "value: for company pass the company name or id (the company must already exist); for root_domain/subdomain pass a domain; for ip/cidr pass an IP or range; for icp/keyword pass an ICP number or company keyword.\n"+ "Always give reason explaining the basis (auditable). Use the entries array for multiple entries.", obj(map[string]any{ diff --git a/agent/worker.go b/agent/worker.go index 9cf8485..88d280b 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -454,6 +454,9 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. // test scope (same conservative granularity as insertAssets). upsertTaskScope's // ON CONFLICT DO NOTHING + the uq_task_scope unique index guarantee no // duplicate additions; reruns/retries are idempotent no-ops as well. + // asset_ids can trace back to target content, so these auto rows count + // toward coverage only under a declared root (db.declaredScopeFilter); + // coverage scope, not enforced. // When the asset-coverage feature is off, the test scope (denominator) is no // longer accumulated. if coverageEnabled { diff --git a/db/schema.sql b/db/schema.sql index c76b735..92bc174 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -754,12 +754,13 @@ WHERE t.active_llm_profile_id IS NULL AND t.llm_profile_id IS NOT NULL AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id); --- Task test scope (the asset-coverage denominator + authorization boundary). +-- Task test scope (the asset-coverage denominator; coverage scope, not enforced). -- Auto-filled (source='auto'): insertAssets' top level adds a conservative scope per asset type explicitly -- inserted by the worker (root_domain→root_domain, subdomain/service/endpoint→subdomain(host), ip→ip); -- side-effect-derived assets do not enter the scope (the hook sits at the handler top level; derivation is internal to the db layer). -- Agent-filled (source='agent'): add_task_scope adds company/root_domain/subdomain/ip. -- Coverage = among assets matching active rows (denominator), the share anchored by fact nodes (numerator). +-- An auto row is active only under a declared (non-auto) root of the same task (declaredScopeFilter in task_scope.go). CREATE TABLE IF NOT EXISTS task_scope ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, diff --git a/db/task_assets_context.go b/db/task_assets_context.go index af911fe..8ac524b 100644 --- a/db/task_assets_context.go +++ b/db/task_assets_context.go @@ -37,12 +37,13 @@ target AS ( )) ) JOIN context_tasks ctx ON ctx.task_id=ts.task_id + WHERE ` + declaredScopeFilter + ` UNION SELECT a.id, a.type, COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label FROM assets a JOIN exploration_anchors ea ON ea.asset_id=a.id - JOIN exploration_nodes en ON en.id=ea.node_id + JOIN exploration_nodes en ON en.id=ea.node_id AND en.kind='fact' JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id ), tested AS ( @@ -118,8 +119,10 @@ ORDER BY CASE WHEN ts.task_id=$1 THEN 0 ELSE 1 END, ts.id`, taskID) } // TaskCoverageWithSources computes one coverage view over the union of the -// current task and its direct sources: source scopes and anchored assets extend -// the denominator, while fact anchors count as tested. No row is copied. +// current task and its direct sources: source scopes and fact-anchored assets +// extend the denominator, while fact anchors count as tested. Auto scope rows +// count only under a declared root (declaredScopeFilter), and an intent anchor +// alone (insert_assets, asset_ids) no longer adds an asset. No row is copied. func (s *AssetStore) TaskCoverageWithSources(taskID int64) (*Coverage, error) { cov := &Coverage{ByType: []CoverageByType{}} _ = s.db.QueryRow(`WITH `+directTaskContextCTE+` diff --git a/db/task_scope.go b/db/task_scope.go index c7f0525..bafdf38 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -8,10 +8,13 @@ import ( "strings" ) -// TaskScope is one row of a task's test scope — the coverage denominator and the -// per-task authorization edge. Rows come either from insertAssets (source='auto', -// conservative, one per explicitly-inserted asset) or from the add_task_scope tool -// (source='agent', for company / domain / network / ICP / keyword scope). +// TaskScope is one row of a task's test scope — coverage scope, not enforced: it is +// the coverage denominator and the filter for asset queries, and nothing blocks a +// tool call outside it. Rows come from insertAssets / intent asset_ids (source='auto', +// conservative, one per explicitly-inserted asset), from the add_task_scope tool +// (source='agent': the goal targets the decomposer registers, or planner widening), +// or from the operator (source='manual'). An auto row counts toward coverage only +// under a declared (non-auto) root, see declaredScopeFilter. type TaskScope struct { ID int64 `json:"id"` TaskID int64 `json:"task_id"` @@ -105,6 +108,8 @@ func (s *AssetStore) upsertTaskScope(ts TaskScope) error { // asset item (source='auto'). MUST be called only from insertAssets' top-level loop — // never from a db-layer side effect (linkHostAssets), so derived assets do not blindly expand the scope. // Rule: scope granularity follows the asset's own type. taskID<=0 → no-op. +// The asset comes from target-derived content, so the row counts toward coverage and +// list_untested_assets only under a declared root (declaredScopeFilter). func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error { if taskID <= 0 { return nil @@ -274,8 +279,9 @@ type CoverageByType struct { } // Coverage is a task's rough asset test coverage — a reference figure for the agent, -// NOT a precise metric. Denominator = assets matching any active task_scope row; -// Tested = those anchored to at least one fact node in the exploration. +// NOT a precise metric. Denominator = assets matching a declared task_scope row or an +// auto row under a declared root (declaredScopeFilter); Tested = those anchored to at +// least one fact node in the exploration. type Coverage struct { Enabled bool `json:"enabled"` // whether the asset coverage feature is on; when false the other fields are zero ScopeRows int `json:"scope_rows"` // 0 → scope not anchored @@ -299,6 +305,22 @@ func (s *AssetStore) CoverageEnabled(taskID int64) bool { return enabled } +// declaredScopeFilter keeps a task_scope row ts in the coverage match only when it is +// declared (source<>'auto': goal targets registered through add_task_scope, or manual +// scope) or when it is an auto row under a root the same task declared: the same or +// a deeper domain of a declared root_domain/subdomain, or a net inside a declared +// ip/cidr. Auto rows come from target-derived content (insert_assets, intent +// asset_ids), so anything else stays out of the coverage denominator and +// list_untested_assets. +const declaredScopeFilter = `(ts.source <> 'auto' OR EXISTS ( + SELECT 1 FROM task_scope d + WHERE d.task_id = ts.task_id AND d.source <> 'auto' AND ( + (d.kind IN ('root_domain','subdomain') AND ts.domain IS NOT NULL + AND (ts.domain = d.domain OR right(ts.domain, length(d.domain)+1) = '.' || d.domain)) + OR (d.kind IN ('ip','cidr') AND ts.net IS NOT NULL AND ts.net <<= d.net) + ) + ))` + // task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID. const covTargetCTE = ` target AS ( @@ -314,7 +336,7 @@ target AS ( lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value )) - ) + ) AND ` + declaredScopeFilter + ` ), tested AS ( SELECT DISTINCT ea.asset_id diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 321b4b4..961938a 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -805,13 +805,13 @@ export function OverviewTab({ taskId }: { taskId: string }) { )} - {/* Test scope: coverage denominator + authorization boundary; can be edited manually. */} + {/* Test scope: coverage denominator (coverage scope, not enforced); can be edited manually. */} Test scope - (coverage denominator + authorization boundary, {scope.length} in total) + (coverage scope, not enforced, {scope.length} in total) diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 24a5e06..475366e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -350,7 +350,7 @@ export interface WorkspaceFile { content?: string; } -// One row of a task's test scope (coverage denominator + authorization boundary). +// One row of a task's test scope (coverage denominator; coverage scope, not enforced). export interface TaskScopeRow { id: number; task_id: number; From cd8a830b53ca467439b7caf46d8ee1966ffa16dd Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:38:52 +0100 Subject: [PATCH 2/3] docs(coverage): say "declared domain or IP root" for auto-scope roots (F8) declaredScopeFilter treats only root_domain, subdomain, ip and cidr rows as roots. A declared company, icp or keyword row does not make an auto row count, but the add_task_scope description said "a goal target or scope added here or manually". The description and the code comments now say "declared domain or IP root" and name the kinds that act as roots. --- agent/tools_insert.go | 8 ++++---- agent/worker.go | 2 +- db/schema.sql | 2 +- db/task_assets_context.go | 2 +- db/task_scope.go | 17 +++++++++-------- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/agent/tools_insert.go b/agent/tools_insert.go index ffbd353..8f8ae55 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -365,9 +365,9 @@ func (t *ToolSet) insertAssets() actool.CoreTool { // type; side-effect-derived assets don't go through here, so the scope // doesn't widen blindly. No-op when taskID=0. // The item may come from target content, so this auto row counts toward - // coverage and list_untested_assets only under a declared root (a - // non-auto row: goal target, add_task_scope or manual scope, see - // db.declaredScopeFilter). task_scope is + // coverage and list_untested_assets only under a declared domain or IP + // root (a non-auto root_domain/subdomain/ip/cidr row: goal target, + // add_task_scope or manual scope, see db.declaredScopeFilter). task_scope is // coverage scope, not enforced: nothing blocks a call outside it. // Independent of the coverage switch: task_scope is the task's range // boundary (the filter basis for list/query); the coverage switch only @@ -454,7 +454,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool { "add_task_scope", "Add test scope to [this task] — "+taskScopeLabel+": it is the denominator of asset test coverage; nothing blocks a tool call outside it.\n"+ "Supported kinds: company (all assets under the company) / root_domain (a whole root domain, including all subdomains) / subdomain (a single precise subdomain) / ip / cidr / icp / keyword.\n"+ - "Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains), but they count toward coverage and list_untested_assets only under a declared root (a goal target or scope added here or manually); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n"+ + "Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains), but they count toward coverage and list_untested_assets only under a declared domain or IP root (a root_domain/subdomain/ip/cidr goal target or scope added here or manually; a company/icp/keyword entry is not such a root); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n"+ "value: for company pass the company name or id (the company must already exist); for root_domain/subdomain pass a domain; for ip/cidr pass an IP or range; for icp/keyword pass an ICP number or company keyword.\n"+ "Always give reason explaining the basis (auditable). Use the entries array for multiple entries.", obj(map[string]any{ diff --git a/agent/worker.go b/agent/worker.go index 88d280b..093c3cc 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -455,7 +455,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db. // ON CONFLICT DO NOTHING + the uq_task_scope unique index guarantee no // duplicate additions; reruns/retries are idempotent no-ops as well. // asset_ids can trace back to target content, so these auto rows count - // toward coverage only under a declared root (db.declaredScopeFilter); + // toward coverage only under a declared domain or IP root (db.declaredScopeFilter); // coverage scope, not enforced. // When the asset-coverage feature is off, the test scope (denominator) is no // longer accumulated. diff --git a/db/schema.sql b/db/schema.sql index 92bc174..aa24e93 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -760,7 +760,7 @@ WHERE t.active_llm_profile_id IS NULL -- side-effect-derived assets do not enter the scope (the hook sits at the handler top level; derivation is internal to the db layer). -- Agent-filled (source='agent'): add_task_scope adds company/root_domain/subdomain/ip. -- Coverage = among assets matching active rows (denominator), the share anchored by fact nodes (numerator). --- An auto row is active only under a declared (non-auto) root of the same task (declaredScopeFilter in task_scope.go). +-- An auto row is active only under a declared (non-auto) domain or IP root of the same task (declaredScopeFilter in task_scope.go). CREATE TABLE IF NOT EXISTS task_scope ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, diff --git a/db/task_assets_context.go b/db/task_assets_context.go index 8ac524b..2e6b85f 100644 --- a/db/task_assets_context.go +++ b/db/task_assets_context.go @@ -121,7 +121,7 @@ ORDER BY CASE WHEN ts.task_id=$1 THEN 0 ELSE 1 END, ts.id`, taskID) // TaskCoverageWithSources computes one coverage view over the union of the // current task and its direct sources: source scopes and fact-anchored assets // extend the denominator, while fact anchors count as tested. Auto scope rows -// count only under a declared root (declaredScopeFilter), and an intent anchor +// count only under a declared domain or IP root (declaredScopeFilter), and an intent anchor // alone (insert_assets, asset_ids) no longer adds an asset. No row is copied. func (s *AssetStore) TaskCoverageWithSources(taskID int64) (*Coverage, error) { cov := &Coverage{ByType: []CoverageByType{}} diff --git a/db/task_scope.go b/db/task_scope.go index bafdf38..ae98628 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -14,7 +14,8 @@ import ( // conservative, one per explicitly-inserted asset), from the add_task_scope tool // (source='agent': the goal targets the decomposer registers, or planner widening), // or from the operator (source='manual'). An auto row counts toward coverage only -// under a declared (non-auto) root, see declaredScopeFilter. +// under a declared domain or IP root (a non-auto root_domain/subdomain/ip/cidr row), +// see declaredScopeFilter. type TaskScope struct { ID int64 `json:"id"` TaskID int64 `json:"task_id"` @@ -109,7 +110,7 @@ func (s *AssetStore) upsertTaskScope(ts TaskScope) error { // never from a db-layer side effect (linkHostAssets), so derived assets do not blindly expand the scope. // Rule: scope granularity follows the asset's own type. taskID<=0 → no-op. // The asset comes from target-derived content, so the row counts toward coverage and -// list_untested_assets only under a declared root (declaredScopeFilter). +// list_untested_assets only under a declared domain or IP root (declaredScopeFilter). func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error { if taskID <= 0 { return nil @@ -280,7 +281,7 @@ type CoverageByType struct { // Coverage is a task's rough asset test coverage — a reference figure for the agent, // NOT a precise metric. Denominator = assets matching a declared task_scope row or an -// auto row under a declared root (declaredScopeFilter); Tested = those anchored to at +// auto row under a declared domain or IP root (declaredScopeFilter); Tested = those anchored to at // least one fact node in the exploration. type Coverage struct { Enabled bool `json:"enabled"` // whether the asset coverage feature is on; when false the other fields are zero @@ -307,11 +308,11 @@ func (s *AssetStore) CoverageEnabled(taskID int64) bool { // declaredScopeFilter keeps a task_scope row ts in the coverage match only when it is // declared (source<>'auto': goal targets registered through add_task_scope, or manual -// scope) or when it is an auto row under a root the same task declared: the same or -// a deeper domain of a declared root_domain/subdomain, or a net inside a declared -// ip/cidr. Auto rows come from target-derived content (insert_assets, intent -// asset_ids), so anything else stays out of the coverage denominator and -// list_untested_assets. +// scope) or when it is an auto row under a domain or IP root the same task declared: +// the same or a deeper domain of a declared root_domain/subdomain, or a net inside a +// declared ip/cidr. A declared company, icp or keyword row is not a root here. Auto +// rows come from target-derived content (insert_assets, intent asset_ids), so +// anything else stays out of the coverage denominator and list_untested_assets. const declaredScopeFilter = `(ts.source <> 'auto' OR EXISTS ( SELECT 1 FROM task_scope d WHERE d.task_id = ts.task_id AND d.source <> 'auto' AND ( From 88306a05679bb6b175b9512dcaa3c6cb3501422f Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:38:52 +0100 Subject: [PATCH 3/3] fix(tools): migrate the add_task_scope description on existing databases (F8) add_task_scope is seeded through BuiltinToolSeeds, SeedTool inserts only once, and ToolResolve serves the stored row, so existing installs kept the old "this is the task's authorization boundary" text. seedTaskScopeDescription runs from seedOrchestrationTools and updates the description only: UPDATE tools SET description=$new, updated_at=now() WHERE key='add_task_scope' AND system AND description=$old $old is the description as seeded at 0dc8bdf, kept verbatim; $new is the code description from BuiltinToolSeeds. It is idempotent, a no-op when the row is absent, and leaves an operator-edited description alone. It does not use RefreshToolDefaults, which overwrites without condition. TestTaskScopeDescriptionUpgrade (server, PG) covers the replacement, the preserved custom text, an unchanged updated_at on a second run, and the description ToolResolve serves to the planner. --- server/orchestration.go | 29 ++++++++ server/task_scope_description_test.go | 95 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 server/task_scope_description_test.go diff --git a/server/orchestration.go b/server/orchestration.go index 2f184d9..d03dc4a 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -572,6 +572,7 @@ func (s *Server) seedOrchestrationTools() { s.seedFindingVerificationSchema() // S1: poe_level / evidence_quotes on update_finding_report, added once per DB s.seedFindingEvidenceSchema() // optional counterevidence / confidence_rationale on report_finding, added once per DB s.seedFactSourceSchema() // source enum on record_fact, added once per DB + s.seedTaskScopeDescription() // add_task_scope: "coverage scope, not enforced" replaces the authorization-boundary text // Note: pentest's default tool bindings need no migration — BuiltinToolSeeds already seeds // list_assets/insert_assets/report_finding/list_findings/list_companies together with // pentest at fresh initialization (no old DBs exist in this project, so no migration). @@ -612,6 +613,34 @@ WHERE key='record_fact' AND system AND NOT(COALESCE(schema->'properties','{}'::j } } +// oldTaskScopeDescription is the add_task_scope description as seeded up to commit +// 0dc8bdf, which called task_scope the task's authorization boundary. Kept verbatim: +// seedTaskScopeDescription replaces only a row that still holds exactly this text. +const oldTaskScopeDescription = "Add test scope to [this task] — this is the task's authorization boundary, and also the denominator of asset test coverage.\n" + + "Supported kinds: company (all assets under the company) / root_domain (a whole root domain, including all subdomains) / subdomain (a single precise subdomain) / ip / cidr / icp / keyword.\n" + + "Note: hosts a worker touches one by one are [automatically] added to the scope by the system (precise subdomains); this tool is for [deliberate widening] — pulling in a whole root domain / whole company, or adding a specific subdomain/IP.\n" + + "value: for company pass the company name or id (the company must already exist); for root_domain/subdomain pass a domain; for ip/cidr pass an IP or range; for icp/keyword pass an ICP number or company keyword.\n" + + "Always give reason explaining the basis (auditable). Use the entries array for multiple entries." + +// seedTaskScopeDescription brings the add_task_scope description of existing +// databases to the code text (SeedTool is first-insert only, and ToolResolve serves +// the stored description, so without this the model would keep reading +// "authorization boundary"). Idempotent: only a system row still holding the old +// seeded text is replaced; an absent row or an operator-edited text is left alone. +func (s *Server) seedTaskScopeDescription() { + for _, seed := range agent.BuiltinToolSeeds() { + if seed.Key != "add_task_scope" { + continue + } + _, err := s.m.pg.Exec(`UPDATE tools SET description=$1,updated_at=now() +WHERE key='add_task_scope' AND system AND description=$2`, seed.Desc, oldTaskScopeDescription) + if err != nil { + log.Printf("[tools] upgrade add_task_scope description: %v", err) + } + return + } +} + // refreshBuiltinToolSchemas propagates code schema/description changes on the // orchestration + platform tools into already-seeded rows ONCE per version flag — // SeedTool is first-insert-only, so a new param (e.g. spawn_task's llm_profile) never diff --git a/server/task_scope_description_test.go b/server/task_scope_description_test.go new file mode 100644 index 0000000..d943385 --- /dev/null +++ b/server/task_scope_description_test.go @@ -0,0 +1,95 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Autumn-27/artex/agent" +) + +// PG: on an existing database whose add_task_scope row still holds the old +// "authorization boundary" text, the startup seeders replace it with the code +// description; an operator-edited description is preserved; a second run changes +// nothing; and ToolResolve then serves the new description to the model. +func TestTaskScopeDescriptionUpgrade(t *testing.T) { + skipWithoutDSN(t) + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.Close() }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir()) + t.Cleanup(func() { agent.ToolResolve = nil }) + pg := m.pg + + var want string + for _, seed := range agent.BuiltinToolSeeds() { + if seed.Key == "add_task_scope" { + want = seed.Desc + } + } + if want == "" || want == oldTaskScopeDescription { + t.Fatalf("precondition: code description must exist and differ from the old text: %q", want) + } + + old, err := pg.GetTool("add_task_scope") + if err != nil || old == nil { + t.Fatal("missing add_task_scope", err) + } + bindings, _ := json.Marshal(old.Agents) + t.Cleanup(func() { pg.UpdateTool(old.Key, old.Description, old.Schema, bindings, old.Enabled) }) + + snapshot := func() (string, time.Time) { + var desc string + var upd time.Time + if err := pg.QueryRow(`SELECT description, updated_at FROM tools WHERE key='add_task_scope'`).Scan(&desc, &upd); err != nil { + t.Fatal(err) + } + return desc, upd + } + + // An existing install still holding the old seeded text is upgraded. + if err := pg.UpdateTool("add_task_scope", oldTaskScopeDescription, old.Schema, bindings, true); err != nil { + t.Fatal(err) + } + s.seedOrchestrationTools() + desc1, upd1 := snapshot() + if desc1 != want { + t.Fatalf("old description not replaced: %q", desc1) + } + s.seedOrchestrationTools() + desc2, upd2 := snapshot() + if desc2 != desc1 || !upd2.Equal(upd1) { + t.Fatalf("second run changed the row: %q %v -> %q %v", desc1, upd1, desc2, upd2) + } + if got := resolvedTaskScopeDescription(t); got != want { + t.Fatalf("ToolResolve serves %q, want the new description", got) + } + + // An operator-edited description is left alone. + if err := pg.UpdateTool("add_task_scope", "operator description", old.Schema, bindings, true); err != nil { + t.Fatal(err) + } + s.seedOrchestrationTools() + if desc, _ := snapshot(); desc != "operator description" { + t.Fatalf("operator description overwritten: %q", desc) + } +} + +// resolvedTaskScopeDescription returns add_task_scope's description as +// ToolResolve serves it to the planner. +func resolvedTaskScopeDescription(t *testing.T) string { + t.Helper() + tools := agent.ToolResolve(context.Background(), "planner", agent.NewToolSet(nil, "").PlannerTools()) + for _, tl := range tools { + if tl.Name() == "add_task_scope" { + return tl.Description() + } + } + t.Fatal("planner lost add_task_scope") + return "" +}