From 75b7f3852f3a2e4878b4020fe8098ef3472c75b9 Mon Sep 17 00:00:00 2001 From: ivanpaghubasan Date: Wed, 21 Oct 2026 13:21:00 +0800 Subject: [PATCH 1/3] feat(analysis): record whether a subagent omits tools --- internal/analysis/subagents.go | 7 +++++ internal/analysis/subagents_test.go | 43 ++++++++++++++++++++++++++++- internal/models/agent.go | 9 ++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/internal/analysis/subagents.go b/internal/analysis/subagents.go index 714ce003..722164fa 100644 --- a/internal/analysis/subagents.go +++ b/internal/analysis/subagents.go @@ -41,6 +41,7 @@ func DiscoverSubagents(manifest models.ScanManifest) []models.SubagentDef { } seen[c.Path] = true if def, ok := parseSubagentFile(manifest.RepoRoot, c.Path); ok { + def.Canonical = true out = append(out, def) } } @@ -81,6 +82,11 @@ func parseSubagentFile(repoRoot, relPath string) (models.SubagentDef, bool) { if parsed.Name == "" { return models.SubagentDef{}, false } + // Key presence, not value: an omitted `tools` inherits the subagent tool + // pool, while `tools: []` / `tools:` decode to the same nil Tools. + var keys map[string]yaml.Node + _ = yaml.Unmarshal(fm, &keys) + _, hasTools := keys["tools"] tokens := splitToolsTokens([]string(parsed.Tools)) return models.SubagentDef{ Name: parsed.Name, @@ -94,6 +100,7 @@ func parseSubagentFile(repoRoot, relPath string) (models.SubagentDef, bool) { Skills: splitToolsTokens([]string(parsed.Skills)), HasHooks: parsed.Hooks.Kind == yaml.MappingNode && len(parsed.Hooks.Content) > 0, Isolation: parsed.Isolation, + ToolsOmitted: !hasTools, Location: models.Location{ FilePath: relPath, Line: startLine, diff --git a/internal/analysis/subagents_test.go b/internal/analysis/subagents_test.go index 9a5bc872..05a79ced 100644 --- a/internal/analysis/subagents_test.go +++ b/internal/analysis/subagents_test.go @@ -52,7 +52,8 @@ tools: Read, Bash, Glob, Grep, mcp__email__search_inbox {Tool: "Grep", Raw: "Grep"}, {Tool: "MCP", Pattern: "email__search_inbox", Raw: "mcp__email__search_inbox"}, }, - Location: models.Location{FilePath: ".claude/agents/inbox-searcher.md", Line: 1, EndLine: 5}, + Location: models.Location{FilePath: ".claude/agents/inbox-searcher.md", Line: 1, EndLine: 5}, + Canonical: true, } if !reflect.DeepEqual(got[0], want) { t.Errorf("got %+v\nwant %+v", got[0], want) @@ -367,6 +368,46 @@ func TestSubagents_CanonicalWithoutToolsStillDiscovered(t *testing.T) { } } +func TestSubagents_ToolsOmittedAndCanonical(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, ".claude/agents/omitted.md", "---\nname: omitted\ndescription: D\n---\n") + writeFixture(t, dir, ".claude/agents/emptylist.md", "---\nname: emptylist\ndescription: D\ntools: []\n---\n") + writeFixture(t, dir, ".claude/agents/nulltools.md", "---\nname: nulltools\ndescription: D\ntools:\n---\n") + writeFixture(t, dir, ".claude/agents/listed.md", "---\nname: listed\ndescription: D\ntools: Read\n---\n") + writeFixture(t, dir, "agents/planner.md", "---\nname: planner\ndescription: D\nmodel: sonnet\n---\n") + manifest := models.ScanManifest{ + RepoRoot: dir, + Components: []models.AgentComponent{ + {Kind: models.ComponentSubagent, Path: ".claude/agents/omitted.md"}, + {Kind: models.ComponentSubagent, Path: ".claude/agents/emptylist.md"}, + {Kind: models.ComponentSubagent, Path: ".claude/agents/nulltools.md"}, + {Kind: models.ComponentSubagent, Path: ".claude/agents/listed.md"}, + }, + MarkdownFiles: []string{"agents/planner.md"}, + } + want := map[string][2]bool{ // name -> {ToolsOmitted, Canonical} + "omitted": {true, true}, + "emptylist": {false, true}, + "nulltools": {false, true}, + "listed": {false, true}, + "planner": {true, false}, + } + got := analysis.DiscoverSubagents(manifest) + if len(got) != len(want) { + t.Fatalf("subagents = %d, want %d: %+v", len(got), len(want), got) + } + for _, s := range got { + w, ok := want[s.Name] + if !ok { + t.Errorf("unexpected subagent %q", s.Name) + continue + } + if s.ToolsOmitted != w[0] || s.Canonical != w[1] { + t.Errorf("%s: ToolsOmitted=%v Canonical=%v, want %v %v", s.Name, s.ToolsOmitted, s.Canonical, w[0], w[1]) + } + } +} + func TestSubagentDef_LocationLineRange(t *testing.T) { // Fixture: // line 1: --- diff --git a/internal/models/agent.go b/internal/models/agent.go index 2842cfb8..34df7878 100644 --- a/internal/models/agent.go +++ b/internal/models/agent.go @@ -206,6 +206,15 @@ type SubagentDef struct { HasHooks bool `json:"has_hooks,omitempty"` Isolation string `json:"isolation,omitempty"` Location // file_path / start_line / end_line (flat in JSON via anonymous embed) + + // ToolsOmitted is true when the frontmatter has no `tools` key. A `tools:` + // key with an empty or null value is not omitted. In-memory only; read by + // subagent_inherits_all_tools (CSDK-115). + ToolsOmitted bool `json:"-"` + // Canonical is true when the file was found in a .claude/agents/ directory + // (DiscoverSubagents pass 1), false for the frontmatter-shape fallback. + // In-memory only. + Canonical bool `json:"-"` } // SkillDef is one parsed Claude Code skill (a SKILL.md file). allowed-tools and From b4b84de7b892382c9eb5e817171ea6a1792cc05c Mon Sep 17 00:00:00 2001 From: ivanpaghubasan Date: Wed, 21 Oct 2026 13:21:00 +0800 Subject: [PATCH 2/3] feat(rules): add subagent_inherits_all_tools and CSDK-115 (schema 27) A .claude/agents/ subagent with no tools: key and no disallowedTools: entries inherits all tools available to subagents. Schema 27 is provisional. --- internal/rules/evaluator.go | 7 +++- internal/rules/policies_test.go | 16 +++++++++ internal/rules/predicates.go | 10 ++++++ internal/rules/predicates_test.go | 20 +++++++++++ internal/rules/schema.go | 3 +- internal/rules/schema.yaml | 8 +++++ internal/rules/schema_version.go | 2 +- testdata/rules-fixture/CLAUDE.md | 10 +++--- .../claude_sdk/subagent_safety.yaml | 36 +++++++++++++++++++ testdata/rules-fixture/manifest.yaml | 2 +- 10 files changed, 106 insertions(+), 8 deletions(-) diff --git a/internal/rules/evaluator.go b/internal/rules/evaluator.go index f2eb6f71..2dffd6b1 100644 --- a/internal/rules/evaluator.go +++ b/internal/rules/evaluator.go @@ -252,6 +252,9 @@ func (e MatchExpr) EvaluateSubagent(s models.SubagentDef, inv models.RepoInvento if len(e.SubagentGrantsTool) > 0 && !PredSubagentGrantsTool(s, e.SubagentGrantsTool) { return false } + if e.SubagentInheritsAllTools != nil && PredSubagentInheritsAllTools(s) != *e.SubagentInheritsAllTools { + return false + } return true } @@ -499,7 +502,8 @@ var predicatesByScope = map[models.Scope]map[string]bool{ "repo_langgraph_platform_config_present": true, }, models.ScopeSubagent: { - "subagent_grants_tool": true, + "subagent_grants_tool": true, + "subagent_inherits_all_tools": true, }, models.ScopeSkill: { "skill_allows_unrestricted_shell": true, @@ -610,6 +614,7 @@ func (e MatchExpr) setPredicateNames() []string { add(e.AgentServerReachable != nil, "agent_server_reachable") // Subagent scope add(len(e.SubagentGrantsTool) > 0, "subagent_grants_tool") + add(e.SubagentInheritsAllTools != nil, "subagent_inherits_all_tools") // Skill scope add(e.SkillAllowsUnrestrictedShell != nil, "skill_allows_unrestricted_shell") add(len(e.SkillAllowsTool) > 0, "skill_allows_tool") diff --git a/internal/rules/policies_test.go b/internal/rules/policies_test.go index d9371abc..8c190eee 100644 --- a/internal/rules/policies_test.go +++ b/internal/rules/policies_test.go @@ -4640,6 +4640,22 @@ var policySubagentRuleCases = []policySubagentCase{ {"CSDK-112 silent without WebSearch", "CSDK-112", models.SubagentDef{Name: "reader3", Location: models.Location{FilePath: ".claude/agents/reader3.md"}, Tools: []string{"Read", "Grep", "Glob"}}, models.RepoInventory{}, false}, + + {"CSDK-115 fires when canonical subagent omits tools", "CSDK-115", + models.SubagentDef{Name: "inheritor", Location: models.Location{FilePath: ".claude/agents/inheritor.md"}, + ToolsOmitted: true, Canonical: true}, models.RepoInventory{}, true}, + {"CSDK-115 silent when tools listed", "CSDK-115", + models.SubagentDef{Name: "reader", Location: models.Location{FilePath: ".claude/agents/reader.md"}, + Tools: []string{"Read", "Grep"}, Canonical: true}, models.RepoInventory{}, false}, + {"CSDK-115 silent on empty tools key", "CSDK-115", + models.SubagentDef{Name: "empty", Location: models.Location{FilePath: ".claude/agents/empty.md"}, + Canonical: true}, models.RepoInventory{}, false}, + {"CSDK-115 silent with disallowedTools denylist", "CSDK-115", + models.SubagentDef{Name: "denylist", Location: models.Location{FilePath: ".claude/agents/denylist.md"}, + ToolsOmitted: true, Canonical: true, DisallowedTools: []string{"Bash", "Write"}}, models.RepoInventory{}, false}, + {"CSDK-115 silent on shape-fallback file", "CSDK-115", + models.SubagentDef{Name: "planner", Location: models.Location{FilePath: "agents/planner.md"}, + ToolsOmitted: true, Model: "sonnet"}, models.RepoInventory{}, false}, } // policySkillRuleCases covers skill-scoped rules (CSKILL-*). diff --git a/internal/rules/predicates.go b/internal/rules/predicates.go index 40d9dd57..fd2f4bb8 100644 --- a/internal/rules/predicates.go +++ b/internal/rules/predicates.go @@ -1050,6 +1050,16 @@ func PredSubagentGrantsTool(s models.SubagentDef, names []string) bool { return false } +// PredSubagentInheritsAllTools reports whether a .claude/agents/ subagent leaves +// its tool pool unrestricted: no `tools` key and no disallowedTools entries. +// Claude Code gives such a subagent all tools available to subagents. Files +// found by the shape fallback (Canonical false) are excluded, since a markdown +// file outside .claude/agents/ may belong to a harness with different +// omitted-tools semantics. +func PredSubagentInheritsAllTools(s models.SubagentDef) bool { + return s.Canonical && s.ToolsOmitted && len(s.Tools) == 0 && len(s.DisallowedTools) == 0 +} + // ─── skill predicates ───────────────────────────────────────────────────────── // PredSkillModelInvocable reports whether the model can auto-invoke the skill diff --git a/internal/rules/predicates_test.go b/internal/rules/predicates_test.go index 671f69a6..18f9a9a3 100644 --- a/internal/rules/predicates_test.go +++ b/internal/rules/predicates_test.go @@ -1609,6 +1609,26 @@ func TestPredSubagentGrantsTool(t *testing.T) { } } +func TestPredSubagentInheritsAllTools(t *testing.T) { + cases := []struct { + name string + s models.SubagentDef + want bool + }{ + {"canonical, tools omitted", models.SubagentDef{Canonical: true, ToolsOmitted: true}, true}, + {"omitted but not canonical", models.SubagentDef{ToolsOmitted: true}, false}, + {"canonical, omitted, disallowedTools set", models.SubagentDef{Canonical: true, ToolsOmitted: true, DisallowedTools: []string{"Bash"}}, false}, + {"tools key present but empty", models.SubagentDef{Canonical: true}, false}, + {"hand-built Tools with ToolsOmitted set", models.SubagentDef{Canonical: true, ToolsOmitted: true, Tools: []string{"Read"}}, false}, + {"zero value", models.SubagentDef{}, false}, + } + for _, c := range cases { + if got := rules.PredSubagentInheritsAllTools(c.s); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + // TestPredSkillAllowsUnrestrictedTool exercises the per-tool "unrestricted" // table: Bash/PowerShell and WebFetch/Edit have real pattern-argument // restrictions Claude Code enforces, while Write and NotebookEdit accept a diff --git a/internal/rules/schema.go b/internal/rules/schema.go index 47bc5844..e1d91433 100644 --- a/internal/rules/schema.go +++ b/internal/rules/schema.go @@ -108,7 +108,8 @@ type MatchExpr struct { AgentServerReachable *bool `yaml:"agent_server_reachable,omitempty"` // Subagent-scope predicates - SubagentGrantsTool []string `yaml:"subagent_grants_tool,omitempty"` + SubagentGrantsTool []string `yaml:"subagent_grants_tool,omitempty"` + SubagentInheritsAllTools *bool `yaml:"subagent_inherits_all_tools,omitempty"` // Skill-scope predicates SkillAllowsUnrestrictedShell *bool `yaml:"skill_allows_unrestricted_shell,omitempty"` diff --git a/internal/rules/schema.yaml b/internal/rules/schema.yaml index 917582ee..0306fa15 100644 --- a/internal/rules/schema.yaml +++ b/internal/rules/schema.yaml @@ -447,6 +447,14 @@ rules: # in the list — e.g. tools: [Bash, Write, WebSearch]). Use for subagents # that call system tools or manipulate files. # +# subagent_inherits_all_tools: true | false +# True when a subagent found in a .claude/agents/ directory has no +# `tools:` key in its frontmatter and no disallowedTools entries. +# Claude Code gives such a subagent all tools available to subagents. +# A `tools:` key with an empty or null value counts as declared (false). +# Files found by the frontmatter-shape fallback are excluded. Used by +# CSDK-115. +# # # ━━━ SKILL-SCOPE PREDICATES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # (used only in scope: skill rules — Claude Code Agent Skills, SKILL.md) diff --git a/internal/rules/schema_version.go b/internal/rules/schema_version.go index 449cee8c..e7e44dba 100644 --- a/internal/rules/schema_version.go +++ b/internal/rules/schema_version.go @@ -27,4 +27,4 @@ package rules // that rule (safe) instead of mis-evaluating a predicate whose meaning changed. // That rename discipline is what keeps lenient loading safe without a hard // version gate. -const SupportedSchemaVersion = 26 +const SupportedSchemaVersion = 27 diff --git a/testdata/rules-fixture/CLAUDE.md b/testdata/rules-fixture/CLAUDE.md index d9129530..1bc364c8 100644 --- a/testdata/rules-fixture/CLAUDE.md +++ b/testdata/rules-fixture/CLAUDE.md @@ -135,12 +135,14 @@ against a fixed token: | `claude_subagent` | A `.claude/agents/*.md` subagent declaration (any path depth) | Subagent rules use the `subagent_grants_tool` predicate (true when -`SubagentDef.Tools` contains a listed tool name). They carry **no `language:` +`SubagentDef.Tools` contains a listed tool name) or `subagent_inherits_all_tools` +(true for a `.claude/agents/` file with no `tools` key and no `disallowedTools` +entries; CSDK-115). They carry **no `language:` field** — subagents are markdown frontmatter, not code, and the `subagentRuleDetector.Applies` method does not gate on language. The shipped -rules are CSDK-110 ("Subagent granted the built-in Bash tool") and CSDK-111 -("Subagent granted filesystem-write or web-fetch built-ins"), both in -`claude_sdk/subagent_safety.yaml`. +rules are CSDK-110 ("Subagent granted the built-in Bash tool"), CSDK-111 +("Subagent granted filesystem-write or web-fetch built-ins"), CSDK-112 and +CSDK-115, all in `claude_sdk/subagent_safety.yaml`. ## "Add a rule for X" diff --git a/testdata/rules-fixture/claude_sdk/subagent_safety.yaml b/testdata/rules-fixture/claude_sdk/subagent_safety.yaml index 5bf73ef4..361408e6 100644 --- a/testdata/rules-fixture/claude_sdk/subagent_safety.yaml +++ b/testdata/rules-fixture/claude_sdk/subagent_safety.yaml @@ -76,3 +76,39 @@ rules: genuinely research-oriented. If the subagent must search, keep its other grants read-only so injected search content cannot escalate into writes or shell, and treat its output as untrusted in the parent loop. + + - id: CSDK-115 + title: Subagent omits tools and inherits the full subagent tool pool + severity: high + confidence: 0.75 + applies_to: + - claude_subagent + scope: subagent + match: + subagent_inherits_all_tools: true + explanation: > + This subagent file sits in a `.claude/agents/` directory and its + frontmatter has no `tools:` key and no `disallowedTools:` entries. Claude + Code documents that a subagent with `tools` omitted inherits all tools + available to subagents: the built-in tools and MCP tools of the main + conversation, less a short list Claude Code removes from all subagents. + The subagent therefore gets whichever of Bash, file-write, web-fetch and + MCP tools the main conversation has, whatever its description says its + job is. Claude Code documents that Claude decides when to delegate to a + subagent and composes the delegation message the subagent works from, so + instructions that steer the main conversation can reach this subagent + and the tools it holds. + Limits of this check: the rule reads the frontmatter alone and cannot see + which tools the main conversation has, so it cannot tell how wide the + inherited pool is. A `tools:` key with an empty or null value counts as + declared and stays silent. Any `disallowedTools:` entry silences the + rule, whatever it removes. Markdown files found outside `.claude/agents/` + (plugin `agents/` directories, flat collections) are not checked. + In-code `AgentDefinition` subagents are covered by CSDK-103 and CSDK-120 + when they also set bypassPermissions, and are not checked here. + fix: > + Add a `tools:` list naming the tools the role needs, for example + `tools: Read, Grep, Glob` for a review or search role. If the subagent + needs most of the pool, keep `tools` omitted and add a `disallowedTools:` + list for the tools it must not reach, such as `Bash`, `Write`, `Edit` or + `mcp__*`. diff --git a/testdata/rules-fixture/manifest.yaml b/testdata/rules-fixture/manifest.yaml index 92f97d39..232ca8d9 100644 --- a/testdata/rules-fixture/manifest.yaml +++ b/testdata/rules-fixture/manifest.yaml @@ -11,4 +11,4 @@ # # This file is metadata, not a rule: the engine's loader skips manifest.yaml # when walking the pack for policy files. -schema_version: 26 +schema_version: 27 From 307df30c75b3d1dfd5600dafb8dbdad23daaca62 Mon Sep 17 00:00:00 2001 From: ivanpaghubasan Date: Wed, 21 Oct 2026 13:21:00 +0800 Subject: [PATCH 3/3] test(scanner): add the subagent-tools-inherit corpus fixture and docs --- ARCHITECTURE.md | 5 +++- COVERAGE.md | 4 +-- internal/scanner/scanner_test.go | 28 +++++++++++++++++++ testdata/corpus/PROVENANCE.md | 1 + .../.claude/agents/denylist.md | 7 +++++ .../.claude/agents/empty-tools.md | 7 +++++ .../.claude/agents/inheritor.md | 6 ++++ .../.claude/agents/reader.md | 7 +++++ .../corpus/subagent-tools-inherit/agent.py | 3 ++ .../subagent-tools-inherit/agents/planner.md | 7 +++++ 10 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 testdata/corpus/subagent-tools-inherit/.claude/agents/denylist.md create mode 100644 testdata/corpus/subagent-tools-inherit/.claude/agents/empty-tools.md create mode 100644 testdata/corpus/subagent-tools-inherit/.claude/agents/inheritor.md create mode 100644 testdata/corpus/subagent-tools-inherit/.claude/agents/reader.md create mode 100644 testdata/corpus/subagent-tools-inherit/agent.py create mode 100644 testdata/corpus/subagent-tools-inherit/agents/planner.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3176b447..551007db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -644,7 +644,7 @@ For each language recon cleared, do the AST work and produce a `RepoInventory`: the permission grammar — bare, parametered `Bash(...)`, or `mcp__server__tool` forms), `disallowedTools`, `permissionMode` (incl. `bypassPermissions`), `mcpServers`, `skills`, `isolation`, and `HasHooks` (true only when `hooks:` - is a non-empty mapping). Files without frontmatter, with malformed YAML, or + is a non-empty mapping). Two in-memory fields (`json:"-"`) record `ToolsOmitted` (the `tools` key is absent; `tools: []` and `tools:` count as declared) and `Canonical` (found in pass 1, a `.claude/agents/` path) for `subagent_inherits_all_tools` (CSDK-115). Files without frontmatter, with malformed YAML, or with no `name` are skipped silently. The `tools:` field accepts both the comma/space-separated scalar form and the YAML-list form. - **DiscoverSkills** (`skills.go`) — emits one `SkillDef` per `SKILL.md` @@ -1336,6 +1336,7 @@ Shipped rules (one row per YAML rule entry): | CSDK-108 | tool | claude_sdk | high | `claude_sdk/shell_safety.yaml` | Tool body spawns a subprocess | | CSDK-110 | subagent | claude_sdk | high | `claude_sdk/subagent_safety.yaml` | Subagent granted the built-in Bash tool | | CSDK-111 | subagent | claude_sdk | high | `claude_sdk/subagent_safety.yaml` | Subagent granted filesystem-write or web-fetch built-ins | +| CSDK-115 | subagent | claude_sdk | high | `claude_sdk/subagent_safety.yaml` | Subagent omits tools and inherits the full subagent tool pool | | CSDK-201 | repo | claude_sdk | high | `claude_sdk/repo.yaml` | Project default permission mode bypasses approvals | | CSDK-202 | repo | claude_sdk | high | `claude_sdk/repo.yaml` | Session permission mode bypasses approvals with no tool deny-list | | CSDK-203 | repo | claude_sdk | low | `claude_sdk/repo_hygiene.yaml` | Claude Agent SDK code with no agent-guidance doc (AGENTS.md/CLAUDE.md) | @@ -1873,6 +1874,8 @@ SubagentDef { HasHooks bool // true only when hooks: is a non-empty mapping Isolation string // "" | "worktree" Location // file_path = path to .md; Line = opening "---"; EndLine = closing "---" + ToolsOmitted bool // in-memory (json:"-"): frontmatter has no `tools` key + Canonical bool // in-memory (json:"-"): found under .claude/agents/ (discovery pass 1) } // ToolGrant is one parsed entry from a markdown agent's tools: / allowed-tools: diff --git a/COVERAGE.md b/COVERAGE.md index febb17c6..5fe72029 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -4,7 +4,7 @@ Coverage matrix for Trustabl's static analysis: which agent SDKs (and which languages) we currently scan, analyse, and detect against. This file is the at-a-glance reference; `ARCHITECTURE.md` has the implementation detail. -_Last reviewed: 2026-10-13 (branch `feat/agcfg-preauth-config`, merged with `main` 32d845e) — **Pre-trust agent config (`schema_version` 22 → 23; +5 rules, 339 total):** new unconditionally-loaded category `agent_config` with **AGCFG-004** (`enableAllProjectMcpServers` / `enabledMcpjsonServers` in `.claude/settings.json`), **AGCFG-005** (project hooks running repo scripts / curl / `bash -c` / credential paths; Claude and Gemini settings), **AGCFG-006** (sandbox / proxy / endpoint / loader override keys in `.gemini/.env`, `.codex/config.toml`, narrowly root `.env`; key names only), **AGCFG-007** (`*autoApprove*` / `*yolo*` = true in `.vscode/` and `.cursor/` settings) and **MCPCFG-002** (MCP client config launching a local command). The walk now enters `.gemini/`, `.vscode/`, `.cursor/`, `.codex/`; `.mcp.json` is an `mcp_config`. The Gemini CLI hooks shape and Codex project-config trust behavior are not verified against vendor docs. Prior review: 2026-10-05 (HEAD d8a4f57 + uncommitted working tree, branch `feat/server-entrypoint-detection`) — **Server-entrypoint fact (Python; no schema change, stays 20; no rule, no predicate):** `RepoInventory.Entrypoints` (also `ScanResult.entrypoints`) records FastAPI / Flask route handlers (`http`) and Celery tasks / Dramatiq actors (`worker`), import-gated, skipping `examples/`, `demo/`, `docs/`, `samples/`, `cookbook/`, `tutorial(s)/`; `AgentRunCallDef.ServerReachable` is stamped in memory when a run call is in the entrypoint, a same-file helper, or an imported helper (one hop each; relative imports resolved within the scan set). Nothing consumes it yet; ranks 4 and 6 build on it in follow-up PRs. Deferred: Django, Starlette `Route()`, RQ, Temporal, serverless handlers, `add_api_route` / `add_url_rule`, class-based views, TypeScript. Corpus baseline: 2 entrypoints (Flask), 40 run calls, 0 stamped — all run calls are standalone scripts. Prior review: 2026-10-05 (HEAD 05c44c9 + uncommitted working tree, branch `fix/obs-global-instrumentor-credit`) — **Observability false-positive fix: process-wide instrumentor calls are credited (no schema change; `schema_version` stays 20, no new rules, no fixture change).** PYD-107's fix text recommends `logfire.instrument_pydantic_ai()`, but its match (`repo_has_observability` + `agent_kwarg_missing: [instrument]`) never read that call, so following the fix still produced one finding per agent; LC-112's "configure a global handler" advice had the same gap. Crediting the call as repo-level init evidence alone cannot fix this (the kwarg check does not read init evidence) and would make it worse for `Agent.instrument_all()`, so the credit is agent-scoped: new `AgentDef.InstrumentedBy` (`instrumented_by` in the JSON report, omitted when unset), stamped by `analysis.ApplyGlobalInstrumentation` from the init signals, and `PredAgentKwargMissing` treats the exact kwarg it names as set. Covered: `logfire.instrument_pydantic_ai` and `Agent.instrument_all()` (→ `instrument`, Pydantic AI, Python), `LangChainInstrumentor().instrument()` (→ `callbacks`, LangChain, Python). Also recognized as init evidence but crediting no agent: `logfire.instrument_openai_agents`, `logfire.instrument_mcp`, any other `Instrumentor(...).instrument(...)`. `.instrument_all()` counts only in a file importing `pydantic_ai` or `logfire`; the instrumentor shape only in a file importing `openinference`, `opentelemetry` or `traceloop` (the two-step `i = XInstrumentor(); i.instrument()` is not matched). Behavior changes beyond PYD-107/LC-112: a repo whose only signal is one of these calls now counts as observed (the repo absence rules go quiet, OBS-001 stays silent). VAI-101 is unchanged because the Vercel AI SDK has no process-wide telemetry switch — `experimental_telemetry` is enabled per call. **Opik** added as a vendor (Python `opik` import, `opik.configure` / `OpikTracer` / `track_openai`; TS `opik` import, `new Opik()` / `OpikExporter` / `OpikCallbackHandler`; recon dep needle `opik`). Known risk: a repo that uses Opik only through the `@track` decorator plus env configuration imports `opik` but makes no init call, so it reads as imported-never-initialized and OBS-001 fires; Langfuse `@observe` already behaves the same way, and decorator detection (`@observe`, `@track`, `@traceable`) is deliberately deferred until a rule consumes it. **Observability backlog items investigated and deliberately not shipped.** **Tool result not recorded on a span (P0 rank 3): no rule.** Frameworks span every tool call once tracing is enabled (OpenAI Agents by default, Google ADK, LangChain `BaseTool`, Pydantic AI `instrument`, Vercel `experimental_telemetry`, CrewAI instrumentors), and enablement is often env- or CLI-only (LangSmith, Claude Code, ADK), so a repo-level "tracing present" gate cannot say whether a given tool is spanned. Unlike the HTTPS-only rules it is not same-body evidence, and a rule would fire on correctly traced tools. **Required span attributes (P0 rank 2): no rule.** In every audited SDK the attributes are written by the SDK or by the OTel / OpenInference instrumentor, so a well-instrumented repo contains no attribute literals and a static rule cannot tell "the instrumentor sets them" from "nobody sets them". The OTel GenAI semantic conventions are still Development stability and have renamed keys (`gen_ai.usage.prompt_tokens` → `gen_ai.usage.input_tokens`, `gen_ai.system` → `gen_ai.provider.name`). The backlog's `thread.id` is the OS-thread attribute, not a conversation id; GenAI's conversation attribute is `gen_ai.conversation.id`, but the registry page now marks it deprecated / moved to the GenAI conventions repo — verify the current name before citing it. The session / `group_id` correlation idea (OpenAI `Runner.run` without `RunConfig(group_id=)`, Langfuse / LangChain without `session_id`) belongs to rank 4 (run-level correlation id), which needs a run-call kwarg predicate and is tracked with the HTTP-entrypoint design ticket. Prior review 2026-10-02 (HEAD 2acaf88 + uncommitted working tree, branch `feat/p0-gap-10-fetch-allowlist`) — **P0 rank #10, fetch tool HTTPS-only (`schema_version` 19 → 20): 14 new rules, one per SSRF rule.** The host allow-list half of the gap already shipped as the allow-list credit on the 14 SSRF rules; what was missing was the scheme. New tool-scope predicate **`has_unpinned_scheme_url_call`** (a recognized HTTP call whose URL is dynamic and does not begin with a literal `https://`; Python via `analysis.URLArgSchemePinned`, TS via the new `url_scheme_unpinned` handler fact next to `dynamic_url`; f-string / template / `+` / `%` / `.format` forms with a leading `https://` are pinned). New rules, medium / 0.6: **CSDK-024/025, OAI-032/033, MCP-031/032, ADK-022/023, LC-026/LC-027, CREW-015, PYD-015, AG2-022, VAI-021** — each fires only when its SSRF sibling has been silenced by an allow-list and no scheme check (`.scheme ==`, `.protocol ===`, `startswith("https://")`) is present, so SSRF and HTTPS never fire on the same tool. **PYD-103** fix text now notes `WebFetchTool(allowed_domains=)` is Anthropic-only and not a substitute for egress controls. Not built: LangGraph nodes (state is not a model-chosen argument, no SSRF base rule), `raw_llm_sdk` (repo scope), a Pydantic `WebFetchTool` `allowed_domains` rule (provider-dependent), scheme validation in another function/module, fully literal `http://` URLs (not dynamic). Rule count **330** (316 + 14; re-derive). Prior review: 2026-10-01 (HEAD 1d24b98 + uncommitted working tree, branch `feat/p0-partial-gaps`) — **P0 partial-gaps batch (no new schema field; `schema_version` stays 19): wall-clock timeout extended to Google ADK and AutoGen; cost cap and general tool-output trust investigated and deliberately not shipped.** New rules **ADK-113** and **AG2-021** (run call with no wall-clock timeout) ride on the existing `agent_run_call_wall_clock_timeout_missing` predicate, widened to ADK and AutoGen: `DiscoverAgentRunCalls` now also discovers ADK `Runner.run` / `run_async` (runner variable resolved to its agent through a same-file `Runner(agent=)` / `InMemoryRunner(agent)` pre-pass; `run_live` and `app=`-built runners are not run calls here) and AutoGen `initiate_chat` / `a_initiate_chat` (receiver **and** recipient) / `run` / `a_run` / `run_stream`; the predicate also credits ADK `abort_signal` and AutoGen `cancellation_token` (non-None). **VAI-020** now also requires `timeout` to be absent (the Vercel AI SDK's native `timeout` option, number or `{ totalMs, stepMs, ... }`, was a false positive); Vercel needed no new TS discovery. **Per-run token/cost cap (P0 rank 8): no further rule.** Checked against upstream: OpenAI Agents (`Runner.run*`/`RunConfig`), LangChain/LangGraph (`max_iterations`, `ModelCallLimitMiddleware` counts calls not tokens), Google ADK (`RunConfig.max_llm_calls` counts calls) and Vercel AI (`maxOutputTokens` is per step) have no run-level token/cost parameter; CrewAI's `Agent.max_tokens` is declared but never read by the agent or executor (usage reaches only `TokenCalcHandler`, which tracks and does not enforce), so an absence rule would point at a no-op. AutoGen v0.4's `TokenUsageTermination` is a real primitive but is a composable termination object on team constructors, which are not discovered — not built. **Untrusted tool output (P0 rank 11): no general rule.** Framework loops return tool output through the structured tool-result channel (no user-code concatenation site), user-code `f"...{result}"` needs inter-procedural taint to call `result` a tool result, and a "delimiter present" check would reward cosmetic tags; RAW-001/002 stay the only statically defensible slice. Rule count **316** (314 + 2; re-derive). Prior review: **P0 remaining-gaps, engine batch (`schema_version` 18 → 19): six rules that needed new predicates or discovery.** **LC-114** (raw `StateGraph` compiled with no `checkpointer`; new `agent_kwargs_observed` + dual-scope `repo_langgraph_platform_config_present`, so it stays silent on unlinked/chained/opaque/subgraph compiles and on repos with a `langgraph.json`), **LC-115** (a function registered via `add_node` that shells out / executes code / writes / fetches a dynamic URL with no `interrupt(` — new `langgraph_node` ToolKind via `DiscoverLangGraphNodes`; same-file undecorated top-level functions only, confidence 0.45), **OAI-120** / **PYD-108** (run call with no wall-clock timeout — new `AgentRunCallDef.WallClockTimeoutWrapped` from a structural ancestor walk of `asyncio.wait_for` / `asyncio.timeout` / `anyio.move_on_after` / `anyio.fail_after`, new predicate `agent_run_call_wall_clock_timeout_missing`), and **RAW-001** / **RAW-002** (new `raw_llm_sdk` category, loaded unconditionally: the bare `anthropic` / `openai` client in a manual tool loop that builds a dynamic system prompt/message from tool-call-derived values — a deliberately narrow same-function heuristic via `DetectRawLLMToolOutputInSystem`). Rule count **314** (308 + 6; re-derive). Prior review 2026-09-30 (branch `feat/p0-remaining-gaps-batch1`, merged with Batch 2 on `main`) — **P0 remaining-gaps batch, rules-layer only (no discovery, predicate or `schema_version` change; stays 18).** New rules **CSDK-114** (TS `query()` main agent with inline `options` and no `options.maxBudgetUsd`), **CREW-111** (`Agent` with no `max_execution_time`, exempting `config=`), **LC-113** (Python `AgentExecutor` with no `max_execution_time`) and **VAI-020** (`generateText`/`streamText` with a `stopWhen`/`maxSteps` loop and no `abortSignal`; `ToolLoopAgent` excluded). The 14 SSRF rules (CSDK-009/013, OAI-018/024, LC-005/013, CREW-005, PYD-005, ADK-012/016, MCP-008/013, AG2-011, VAI-003) now **credit a host allow-list** in the tool body (`not: has_body_text`) instead of firing on any non-literal URL; IDs/severity/confidence unchanged. Deferred because they need new predicates or discovery, which this batch was barred from adding: OAI/PYD whole-run wall-clock deadline (needs a run-call deadline-wrapper fact), Python `ClaudeAgentOptions(max_budget_usd)`, LangGraph no-checkpointer and mutating-node-without-HITL (need a Platform/`langgraph.json` exemption, opaque-`compile()` handling and an `interrupt()` fact), LangChain.js `maxExecutionTime` (option not verified to exist), and untrusted-tool-output concat (no raw-API-loop discovery). Rule count **308** (302 + Batch 2's 6); re-derive with `grep -rhoE '^\s*-\s*id:\s*\S+' testdata/rules-fixture/*/*.yaml | wc -l`. Prior review 2026-09-29 (branch `feat/p0-side-effect-bounds-batch2`, off `main` e5cef47) — Batch 2 of the side-effect-bounds rule: **CREW-014** (CrewAI), **ADK-020** (Python) / **ADK-021** (TypeScript), **AG2-020** (AutoGen / AG2), **VAI-019** (Vercel AI SDK), and **CSDK-023** (Claude Agent SDK TypeScript) — same detection as Batch 1, adapted to each SDK's tool shape (one new `side_effect_bounds.yaml` per SDK; severity high, confidence 0.5). Approval gates differ per SDK and are honest about it: Vercel keys on `needsApproval` (as VAI-018); ADK Python on `require_confirmation` or a `request_confirmation(` call; ADK TypeScript has **no** declarative confirmation option on `FunctionTool`, so ADK-021 treats a `requestConfirmation(` call in the handler as the gate; CrewAI, AutoGen and Claude TS have no tool-level gate (`Task(human_input=True)`, `human_input_mode`, and `canUseTool`/`permissionMode` live on the task/agent/`query()` options, which a tool-scope rule cannot see), so those rules can fire on a tool gated elsewhere. One small discovery change: `DiscoverADKTools` now copies `FunctionTool(...)` keyword arguments into `ToolDef.Config` (previously dropped), which is what lets ADK-020 see `require_confirmation`. Known discovery limits carried into the rules: Vercel tools are matched only through a `const x = tool(...)` binding (inline `tools: { x: tool(...) }` has no name); Claude TS reads parameter names only from an inline object-literal schema; CrewAI `BaseTool` subclasses, autogen_core `FunctionTool`, and ADK bare functions in `tools=[...]` are not discovered. No schema/predicate/`schema_version` change. Rule count **304** (298 + 6; re-derive rather than trust this figure). Prior review 2026-09-29 (pending merge commit; `origin/main` merged into `feat/p0-side-effect-bounds-batch1`) — Batch 1 of the +_Last reviewed: 2026-10-21 (branch `feat/csdk-115-subagent-tools-inherit`, from `main` c047646) — **Subagent inherits all tools (`schema_version` 26 → 27, provisional; +1 rule):** **CSDK-115** (subagent scope, high, 0.75) on the new predicate `subagent_inherits_all_tools`: a `.claude/agents/` subagent with no `tools:` key and no `disallowedTools:` entries. In-memory `SubagentDef.ToolsOmitted` / `Canonical` carry key presence and pass-1 provenance; `tools:` with an empty or null value counts as declared, and shape-fallback files are skipped. Prior review: 2026-10-13 (branch `feat/agcfg-preauth-config`, merged with `main` 32d845e) — **Pre-trust agent config (`schema_version` 22 → 23; +5 rules, 339 total):** new unconditionally-loaded category `agent_config` with **AGCFG-004** (`enableAllProjectMcpServers` / `enabledMcpjsonServers` in `.claude/settings.json`), **AGCFG-005** (project hooks running repo scripts / curl / `bash -c` / credential paths; Claude and Gemini settings), **AGCFG-006** (sandbox / proxy / endpoint / loader override keys in `.gemini/.env`, `.codex/config.toml`, narrowly root `.env`; key names only), **AGCFG-007** (`*autoApprove*` / `*yolo*` = true in `.vscode/` and `.cursor/` settings) and **MCPCFG-002** (MCP client config launching a local command). The walk now enters `.gemini/`, `.vscode/`, `.cursor/`, `.codex/`; `.mcp.json` is an `mcp_config`. The Gemini CLI hooks shape and Codex project-config trust behavior are not verified against vendor docs. Prior review: 2026-10-05 (HEAD d8a4f57 + uncommitted working tree, branch `feat/server-entrypoint-detection`) — **Server-entrypoint fact (Python; no schema change, stays 20; no rule, no predicate):** `RepoInventory.Entrypoints` (also `ScanResult.entrypoints`) records FastAPI / Flask route handlers (`http`) and Celery tasks / Dramatiq actors (`worker`), import-gated, skipping `examples/`, `demo/`, `docs/`, `samples/`, `cookbook/`, `tutorial(s)/`; `AgentRunCallDef.ServerReachable` is stamped in memory when a run call is in the entrypoint, a same-file helper, or an imported helper (one hop each; relative imports resolved within the scan set). Nothing consumes it yet; ranks 4 and 6 build on it in follow-up PRs. Deferred: Django, Starlette `Route()`, RQ, Temporal, serverless handlers, `add_api_route` / `add_url_rule`, class-based views, TypeScript. Corpus baseline: 2 entrypoints (Flask), 40 run calls, 0 stamped — all run calls are standalone scripts. Prior review: 2026-10-05 (HEAD 05c44c9 + uncommitted working tree, branch `fix/obs-global-instrumentor-credit`) — **Observability false-positive fix: process-wide instrumentor calls are credited (no schema change; `schema_version` stays 20, no new rules, no fixture change).** PYD-107's fix text recommends `logfire.instrument_pydantic_ai()`, but its match (`repo_has_observability` + `agent_kwarg_missing: [instrument]`) never read that call, so following the fix still produced one finding per agent; LC-112's "configure a global handler" advice had the same gap. Crediting the call as repo-level init evidence alone cannot fix this (the kwarg check does not read init evidence) and would make it worse for `Agent.instrument_all()`, so the credit is agent-scoped: new `AgentDef.InstrumentedBy` (`instrumented_by` in the JSON report, omitted when unset), stamped by `analysis.ApplyGlobalInstrumentation` from the init signals, and `PredAgentKwargMissing` treats the exact kwarg it names as set. Covered: `logfire.instrument_pydantic_ai` and `Agent.instrument_all()` (→ `instrument`, Pydantic AI, Python), `LangChainInstrumentor().instrument()` (→ `callbacks`, LangChain, Python). Also recognized as init evidence but crediting no agent: `logfire.instrument_openai_agents`, `logfire.instrument_mcp`, any other `Instrumentor(...).instrument(...)`. `.instrument_all()` counts only in a file importing `pydantic_ai` or `logfire`; the instrumentor shape only in a file importing `openinference`, `opentelemetry` or `traceloop` (the two-step `i = XInstrumentor(); i.instrument()` is not matched). Behavior changes beyond PYD-107/LC-112: a repo whose only signal is one of these calls now counts as observed (the repo absence rules go quiet, OBS-001 stays silent). VAI-101 is unchanged because the Vercel AI SDK has no process-wide telemetry switch — `experimental_telemetry` is enabled per call. **Opik** added as a vendor (Python `opik` import, `opik.configure` / `OpikTracer` / `track_openai`; TS `opik` import, `new Opik()` / `OpikExporter` / `OpikCallbackHandler`; recon dep needle `opik`). Known risk: a repo that uses Opik only through the `@track` decorator plus env configuration imports `opik` but makes no init call, so it reads as imported-never-initialized and OBS-001 fires; Langfuse `@observe` already behaves the same way, and decorator detection (`@observe`, `@track`, `@traceable`) is deliberately deferred until a rule consumes it. **Observability backlog items investigated and deliberately not shipped.** **Tool result not recorded on a span (P0 rank 3): no rule.** Frameworks span every tool call once tracing is enabled (OpenAI Agents by default, Google ADK, LangChain `BaseTool`, Pydantic AI `instrument`, Vercel `experimental_telemetry`, CrewAI instrumentors), and enablement is often env- or CLI-only (LangSmith, Claude Code, ADK), so a repo-level "tracing present" gate cannot say whether a given tool is spanned. Unlike the HTTPS-only rules it is not same-body evidence, and a rule would fire on correctly traced tools. **Required span attributes (P0 rank 2): no rule.** In every audited SDK the attributes are written by the SDK or by the OTel / OpenInference instrumentor, so a well-instrumented repo contains no attribute literals and a static rule cannot tell "the instrumentor sets them" from "nobody sets them". The OTel GenAI semantic conventions are still Development stability and have renamed keys (`gen_ai.usage.prompt_tokens` → `gen_ai.usage.input_tokens`, `gen_ai.system` → `gen_ai.provider.name`). The backlog's `thread.id` is the OS-thread attribute, not a conversation id; GenAI's conversation attribute is `gen_ai.conversation.id`, but the registry page now marks it deprecated / moved to the GenAI conventions repo — verify the current name before citing it. The session / `group_id` correlation idea (OpenAI `Runner.run` without `RunConfig(group_id=)`, Langfuse / LangChain without `session_id`) belongs to rank 4 (run-level correlation id), which needs a run-call kwarg predicate and is tracked with the HTTP-entrypoint design ticket. Prior review 2026-10-02 (HEAD 2acaf88 + uncommitted working tree, branch `feat/p0-gap-10-fetch-allowlist`) — **P0 rank #10, fetch tool HTTPS-only (`schema_version` 19 → 20): 14 new rules, one per SSRF rule.** The host allow-list half of the gap already shipped as the allow-list credit on the 14 SSRF rules; what was missing was the scheme. New tool-scope predicate **`has_unpinned_scheme_url_call`** (a recognized HTTP call whose URL is dynamic and does not begin with a literal `https://`; Python via `analysis.URLArgSchemePinned`, TS via the new `url_scheme_unpinned` handler fact next to `dynamic_url`; f-string / template / `+` / `%` / `.format` forms with a leading `https://` are pinned). New rules, medium / 0.6: **CSDK-024/025, OAI-032/033, MCP-031/032, ADK-022/023, LC-026/LC-027, CREW-015, PYD-015, AG2-022, VAI-021** — each fires only when its SSRF sibling has been silenced by an allow-list and no scheme check (`.scheme ==`, `.protocol ===`, `startswith("https://")`) is present, so SSRF and HTTPS never fire on the same tool. **PYD-103** fix text now notes `WebFetchTool(allowed_domains=)` is Anthropic-only and not a substitute for egress controls. Not built: LangGraph nodes (state is not a model-chosen argument, no SSRF base rule), `raw_llm_sdk` (repo scope), a Pydantic `WebFetchTool` `allowed_domains` rule (provider-dependent), scheme validation in another function/module, fully literal `http://` URLs (not dynamic). Rule count **330** (316 + 14; re-derive). Prior review: 2026-10-01 (HEAD 1d24b98 + uncommitted working tree, branch `feat/p0-partial-gaps`) — **P0 partial-gaps batch (no new schema field; `schema_version` stays 19): wall-clock timeout extended to Google ADK and AutoGen; cost cap and general tool-output trust investigated and deliberately not shipped.** New rules **ADK-113** and **AG2-021** (run call with no wall-clock timeout) ride on the existing `agent_run_call_wall_clock_timeout_missing` predicate, widened to ADK and AutoGen: `DiscoverAgentRunCalls` now also discovers ADK `Runner.run` / `run_async` (runner variable resolved to its agent through a same-file `Runner(agent=)` / `InMemoryRunner(agent)` pre-pass; `run_live` and `app=`-built runners are not run calls here) and AutoGen `initiate_chat` / `a_initiate_chat` (receiver **and** recipient) / `run` / `a_run` / `run_stream`; the predicate also credits ADK `abort_signal` and AutoGen `cancellation_token` (non-None). **VAI-020** now also requires `timeout` to be absent (the Vercel AI SDK's native `timeout` option, number or `{ totalMs, stepMs, ... }`, was a false positive); Vercel needed no new TS discovery. **Per-run token/cost cap (P0 rank 8): no further rule.** Checked against upstream: OpenAI Agents (`Runner.run*`/`RunConfig`), LangChain/LangGraph (`max_iterations`, `ModelCallLimitMiddleware` counts calls not tokens), Google ADK (`RunConfig.max_llm_calls` counts calls) and Vercel AI (`maxOutputTokens` is per step) have no run-level token/cost parameter; CrewAI's `Agent.max_tokens` is declared but never read by the agent or executor (usage reaches only `TokenCalcHandler`, which tracks and does not enforce), so an absence rule would point at a no-op. AutoGen v0.4's `TokenUsageTermination` is a real primitive but is a composable termination object on team constructors, which are not discovered — not built. **Untrusted tool output (P0 rank 11): no general rule.** Framework loops return tool output through the structured tool-result channel (no user-code concatenation site), user-code `f"...{result}"` needs inter-procedural taint to call `result` a tool result, and a "delimiter present" check would reward cosmetic tags; RAW-001/002 stay the only statically defensible slice. Rule count **316** (314 + 2; re-derive). Prior review: **P0 remaining-gaps, engine batch (`schema_version` 18 → 19): six rules that needed new predicates or discovery.** **LC-114** (raw `StateGraph` compiled with no `checkpointer`; new `agent_kwargs_observed` + dual-scope `repo_langgraph_platform_config_present`, so it stays silent on unlinked/chained/opaque/subgraph compiles and on repos with a `langgraph.json`), **LC-115** (a function registered via `add_node` that shells out / executes code / writes / fetches a dynamic URL with no `interrupt(` — new `langgraph_node` ToolKind via `DiscoverLangGraphNodes`; same-file undecorated top-level functions only, confidence 0.45), **OAI-120** / **PYD-108** (run call with no wall-clock timeout — new `AgentRunCallDef.WallClockTimeoutWrapped` from a structural ancestor walk of `asyncio.wait_for` / `asyncio.timeout` / `anyio.move_on_after` / `anyio.fail_after`, new predicate `agent_run_call_wall_clock_timeout_missing`), and **RAW-001** / **RAW-002** (new `raw_llm_sdk` category, loaded unconditionally: the bare `anthropic` / `openai` client in a manual tool loop that builds a dynamic system prompt/message from tool-call-derived values — a deliberately narrow same-function heuristic via `DetectRawLLMToolOutputInSystem`). Rule count **314** (308 + 6; re-derive). Prior review 2026-09-30 (branch `feat/p0-remaining-gaps-batch1`, merged with Batch 2 on `main`) — **P0 remaining-gaps batch, rules-layer only (no discovery, predicate or `schema_version` change; stays 18).** New rules **CSDK-114** (TS `query()` main agent with inline `options` and no `options.maxBudgetUsd`), **CREW-111** (`Agent` with no `max_execution_time`, exempting `config=`), **LC-113** (Python `AgentExecutor` with no `max_execution_time`) and **VAI-020** (`generateText`/`streamText` with a `stopWhen`/`maxSteps` loop and no `abortSignal`; `ToolLoopAgent` excluded). The 14 SSRF rules (CSDK-009/013, OAI-018/024, LC-005/013, CREW-005, PYD-005, ADK-012/016, MCP-008/013, AG2-011, VAI-003) now **credit a host allow-list** in the tool body (`not: has_body_text`) instead of firing on any non-literal URL; IDs/severity/confidence unchanged. Deferred because they need new predicates or discovery, which this batch was barred from adding: OAI/PYD whole-run wall-clock deadline (needs a run-call deadline-wrapper fact), Python `ClaudeAgentOptions(max_budget_usd)`, LangGraph no-checkpointer and mutating-node-without-HITL (need a Platform/`langgraph.json` exemption, opaque-`compile()` handling and an `interrupt()` fact), LangChain.js `maxExecutionTime` (option not verified to exist), and untrusted-tool-output concat (no raw-API-loop discovery). Rule count **308** (302 + Batch 2's 6); re-derive with `grep -rhoE '^\s*-\s*id:\s*\S+' testdata/rules-fixture/*/*.yaml | wc -l`. Prior review 2026-09-29 (branch `feat/p0-side-effect-bounds-batch2`, off `main` e5cef47) — Batch 2 of the side-effect-bounds rule: **CREW-014** (CrewAI), **ADK-020** (Python) / **ADK-021** (TypeScript), **AG2-020** (AutoGen / AG2), **VAI-019** (Vercel AI SDK), and **CSDK-023** (Claude Agent SDK TypeScript) — same detection as Batch 1, adapted to each SDK's tool shape (one new `side_effect_bounds.yaml` per SDK; severity high, confidence 0.5). Approval gates differ per SDK and are honest about it: Vercel keys on `needsApproval` (as VAI-018); ADK Python on `require_confirmation` or a `request_confirmation(` call; ADK TypeScript has **no** declarative confirmation option on `FunctionTool`, so ADK-021 treats a `requestConfirmation(` call in the handler as the gate; CrewAI, AutoGen and Claude TS have no tool-level gate (`Task(human_input=True)`, `human_input_mode`, and `canUseTool`/`permissionMode` live on the task/agent/`query()` options, which a tool-scope rule cannot see), so those rules can fire on a tool gated elsewhere. One small discovery change: `DiscoverADKTools` now copies `FunctionTool(...)` keyword arguments into `ToolDef.Config` (previously dropped), which is what lets ADK-020 see `require_confirmation`. Known discovery limits carried into the rules: Vercel tools are matched only through a `const x = tool(...)` binding (inline `tools: { x: tool(...) }` has no name); Claude TS reads parameter names only from an inline object-literal schema; CrewAI `BaseTool` subclasses, autogen_core `FunctionTool`, and ADK bare functions in `tools=[...]` are not discovered. No schema/predicate/`schema_version` change. Rule count **304** (298 + 6; re-derive rather than trust this figure). Prior review 2026-09-29 (pending merge commit; `origin/main` merged into `feat/p0-side-effect-bounds-batch1`) — Batch 1 of the _Last reviewed: 2026-10-12 (branch `feat/langgraph-inmemory-checkpointer-server`, merged with `main` 44b2062) — **LC-116: in-memory LangGraph checkpointer run from a server entrypoint (`schema_version` 21 → 22; 1 rule, 2 predicates).** High / 0.6, agent scope, Python, `langchain_state_graph` + `langchain_agent`: `checkpointer=` resolves to `MemorySaver` / `InMemorySaver` (call, `MemorySaver(serde=...)`, aliased, or a same-file variable bound to one), a run call of that graph is reachable from a FastAPI / Flask / Celery / Dramatiq entrypoint, and the repo has no `langgraph.json`. New predicates `agent_checkpointer_in_memory`, `agent_server_reachable`; new in-memory fields `AgentDef.CheckpointerClass`, `AgentRunCallDef.AgentFilePath`. Run-call discovery now anchors LangGraph `invoke`/`ainvoke`/`stream`/`astream`/`astream_events`/`batch`/`abatch` on compiled-graph variables, the direct `builder.compile(...).invoke(...)` chain and prebuilt-agent variables only (never on an arbitrary `.invoke`), cross-file for module-level graphs. Test paths (`tests/`, `test_*.py`, `conftest.py`) never satisfy `agent_server_reachable`. **Not covered:** a helper of a helper; a graph built/compiled in a factory function; a saver chosen at runtime; `import graph; graph.app.invoke()`; Django/Starlette/RQ/serverless entrypoints; TypeScript. **Recall on real server repos is unmeasured:** the corpus has 40 run calls, 0 LangGraph, 0 server-reachable, and its full findings output (185) is identical before and after. Rule count **334** (333 + 1; re-derive). Prior review: 2026-10-11 (branch `feat/otel-labels-rules`, rebased onto 9fc2c39) — **OBS-006..008 credit fixes (no schema change, stays 21; no rule change).** Each file's own imports of agent-reliability-otel-labels are followed, so a renamed import (`mark_tool_span as mts`, `markToolSpan as label`) still counts. `trustabl.*` label keys written to a span by hand (`set_attribute` / `setAttribute` first argument, or a key of the literal passed to `set_attributes` / `setAttributes`) are recorded as `attr_literal` signals and credit the ladder rung by rung, so a repo that writes the labels without the library no longer draws OBS-006..008. Every signal carries `TestOrigin`; test-file signals still credit OBS-006..008. Rule counts re-derived: **333** fixture. Prior review 2026-10-07 (HEAD 8d0fb76 + uncommitted working tree, branch `feat/forge-runtime-tracing`) — **agent-reliability-otel-labels rules renumbered to `schema_version` 21.** The OBS-006..008 batch below was built in parallel with the HTTPS-only batch, and both took 19 → 20; after merging main this batch takes 20 → 21 (engine constant and both manifests). Rule counts re-derived: **333** fixture / **333** production (330 + OBS-006..008); `scripts/check-rules-sync.sh` passes. No coverage change. Prior review 2026-10-05 (HEAD d8a4f57 + uncommitted working tree, branch `feat/server-entrypoint-detection`) — **Server-entrypoint fact (Python; no schema change, stays 20; no rule, no predicate):** `RepoInventory.Entrypoints` (also `ScanResult.entrypoints`) records FastAPI / Flask route handlers (`http`) and Celery tasks / Dramatiq actors (`worker`), import-gated, skipping `examples/`, `demo/`, `docs/`, `samples/`, `cookbook/`, `tutorial(s)/`; `AgentRunCallDef.ServerReachable` is stamped in memory when a run call is in the entrypoint, a same-file helper, or an imported helper (one hop each; relative imports resolved within the scan set). Nothing consumes it yet; ranks 4 and 6 build on it in follow-up PRs. Deferred: Django, Starlette `Route()`, RQ, Temporal, serverless handlers, `add_api_route` / `add_url_rule`, class-based views, TypeScript. Corpus baseline: 2 entrypoints (Flask), 40 run calls, 0 stamped — all run calls are standalone scripts. Prior review 2026-10-05 (HEAD 05c44c9 + uncommitted working tree, branch `fix/obs-global-instrumentor-credit`) — **Observability false-positive fix: process-wide instrumentor calls are credited (no schema change; `schema_version` stays 20, no new rules, no fixture change).** PYD-107's fix text recommends `logfire.instrument_pydantic_ai()`, but its match (`repo_has_observability` + `agent_kwarg_missing: [instrument]`) never read that call, so following the fix still produced one finding per agent; LC-112's "configure a global handler" advice had the same gap. Crediting the call as repo-level init evidence alone cannot fix this (the kwarg check does not read init evidence) and would make it worse for `Agent.instrument_all()`, so the credit is agent-scoped: new `AgentDef.InstrumentedBy` (`instrumented_by` in the JSON report, omitted when unset), stamped by `analysis.ApplyGlobalInstrumentation` from the init signals, and `PredAgentKwargMissing` treats the exact kwarg it names as set. Covered: `logfire.instrument_pydantic_ai` and `Agent.instrument_all()` (→ `instrument`, Pydantic AI, Python), `LangChainInstrumentor().instrument()` (→ `callbacks`, LangChain, Python). Also recognized as init evidence but crediting no agent: `logfire.instrument_openai_agents`, `logfire.instrument_mcp`, any other `Instrumentor(...).instrument(...)`. `.instrument_all()` counts only in a file importing `pydantic_ai` or `logfire`; the instrumentor shape only in a file importing `openinference`, `opentelemetry` or `traceloop` (the two-step `i = XInstrumentor(); i.instrument()` is not matched). Behavior changes beyond PYD-107/LC-112: a repo whose only signal is one of these calls now counts as observed (the repo absence rules go quiet, OBS-001 stays silent). VAI-101 is unchanged because the Vercel AI SDK has no process-wide telemetry switch — `experimental_telemetry` is enabled per call. **Opik** added as a vendor (Python `opik` import, `opik.configure` / `OpikTracer` / `track_openai`; TS `opik` import, `new Opik()` / `OpikExporter` / `OpikCallbackHandler`; recon dep needle `opik`). Known risk: a repo that uses Opik only through the `@track` decorator plus env configuration imports `opik` but makes no init call, so it reads as imported-never-initialized and OBS-001 fires; Langfuse `@observe` already behaves the same way, and decorator detection (`@observe`, `@track`, `@traceable`) is deliberately deferred until a rule consumes it. **Observability backlog items investigated and deliberately not shipped.** **Tool result not recorded on a span (P0 rank 3): no rule.** Frameworks span every tool call once tracing is enabled (OpenAI Agents by default, Google ADK, LangChain `BaseTool`, Pydantic AI `instrument`, Vercel `experimental_telemetry`, CrewAI instrumentors), and enablement is often env- or CLI-only (LangSmith, Claude Code, ADK), so a repo-level "tracing present" gate cannot say whether a given tool is spanned. Unlike the HTTPS-only rules it is not same-body evidence, and a rule would fire on correctly traced tools. **Required span attributes (P0 rank 2): no rule.** In every audited SDK the attributes are written by the SDK or by the OTel / OpenInference instrumentor, so a well-instrumented repo contains no attribute literals and a static rule cannot tell "the instrumentor sets them" from "nobody sets them". The OTel GenAI semantic conventions are still Development stability and have renamed keys (`gen_ai.usage.prompt_tokens` → `gen_ai.usage.input_tokens`, `gen_ai.system` → `gen_ai.provider.name`). The backlog's `thread.id` is the OS-thread attribute, not a conversation id; GenAI's conversation attribute is `gen_ai.conversation.id`, but the registry page now marks it deprecated / moved to the GenAI conventions repo — verify the current name before citing it. The session / `group_id` correlation idea (OpenAI `Runner.run` without `RunConfig(group_id=)`, Langfuse / LangChain without `session_id`) belongs to rank 4 (run-level correlation id), which needs a run-call kwarg predicate and is tracked with the HTTP-entrypoint design ticket. Prior review 2026-10-04 (HEAD 4f8f6bd + uncommitted working tree, branch `feat/forge-runtime-tracing`) — **agent-reliability-otel-labels process-label rules (`schema_version` 20 → 21; recorded as 19 → 20 before main's HTTPS-only batch was merged).** New inventory pass `analysis.DiscoverOtelLabels` emits typed `OtelLabelsSignal` records (Kind `import` / `run_start` / `tool_span` / `run_end`) for Python and TypeScript/JavaScript; the Go binding is not inspected. Recon gains `RepoProfile.OtelLabelsDeclaredIn` (`agent-reliability-otel-labels` / `agent_reliability_otel_labels` in the Python root manifests, `@trustabl/agent-reliability-otel-labels` in `package.json`). Both are kept out of `ObservabilitySignals` / `ObsDeps` because the library never creates a span and must not satisfy its own prerequisite. Three new repo-scope predicates (`repo_otel_labels_installed`, `repo_otel_labels_initialized`, `repo_otel_labels_implemented`) back three new rules in `observability/quality.yaml`, all gated on `repo_observability_initialized: true` and mutually exclusive: **OBS-006** (instrumented, library neither declared nor imported; low), **OBS-007** (installed, no `mark_start`; medium), **OBS-008** (run started, no `mark_run_end`, or tools exist and no `mark_tool_span`; medium). They check that the calls exist, not that labels land or are correct: a missing current span makes every call a silent no-op, and a shared `Run`, a constant `attempt`, labelling outside `finally`, a `side_effect` that contradicts the tool body, and a missing `mark_handoff` are deferred. Rule counts at the time: 319 fixture / 319 production, before main's 14 HTTPS-only rules were merged (333 after). Prior review 2026-10-04 (HEAD 5ba476d, branch `feat/forge-runtime-tracing`) — **Recon now detects Google ADK for Go (`go.mod`: `google.golang.org/adk` → recon-only dep name `google-adk-go`, not mapped to an SDK for `scan`; used by `trustabl forge` to select the Go runtime-tracing recipe).** Prior review 2026-10-02 (HEAD 2acaf88 + uncommitted working tree, branch `feat/p0-gap-10-fetch-allowlist`) — **P0 rank #10, fetch tool HTTPS-only (`schema_version` 19 → 20): 14 new rules, one per SSRF rule.** The host allow-list half of the gap already shipped as the allow-list credit on the 14 SSRF rules; what was missing was the scheme. New tool-scope predicate **`has_unpinned_scheme_url_call`** (a recognized HTTP call whose URL is dynamic and does not begin with a literal `https://`; Python via `analysis.URLArgSchemePinned`, TS via the new `url_scheme_unpinned` handler fact next to `dynamic_url`; f-string / template / `+` / `%` / `.format` forms with a leading `https://` are pinned). New rules, medium / 0.6: **CSDK-024/025, OAI-032/033, MCP-031/032, ADK-022/023, LC-026/LC-027, CREW-015, PYD-015, AG2-022, VAI-021** — each fires only when its SSRF sibling has been silenced by an allow-list and no scheme check (`.scheme ==`, `.protocol ===`, `startswith("https://")`) is present, so SSRF and HTTPS never fire on the same tool. **PYD-103** fix text now notes `WebFetchTool(allowed_domains=)` is Anthropic-only and not a substitute for egress controls. Not built: LangGraph nodes (state is not a model-chosen argument, no SSRF base rule), `raw_llm_sdk` (repo scope), a Pydantic `WebFetchTool` `allowed_domains` rule (provider-dependent), scheme validation in another function/module, fully literal `http://` URLs (not dynamic). Rule count **330** (316 + 14; re-derive). Prior review: 2026-10-01 (HEAD 1d24b98 + uncommitted working tree, branch `feat/p0-partial-gaps`) — **P0 partial-gaps batch (no new schema field; `schema_version` stays 19): wall-clock timeout extended to Google ADK and AutoGen; cost cap and general tool-output trust investigated and deliberately not shipped.** New rules **ADK-113** and **AG2-021** (run call with no wall-clock timeout) ride on the existing `agent_run_call_wall_clock_timeout_missing` predicate, widened to ADK and AutoGen: `DiscoverAgentRunCalls` now also discovers ADK `Runner.run` / `run_async` (runner variable resolved to its agent through a same-file `Runner(agent=)` / `InMemoryRunner(agent)` pre-pass; `run_live` and `app=`-built runners are not run calls here) and AutoGen `initiate_chat` / `a_initiate_chat` (receiver **and** recipient) / `run` / `a_run` / `run_stream`; the predicate also credits ADK `abort_signal` and AutoGen `cancellation_token` (non-None). **VAI-020** now also requires `timeout` to be absent (the Vercel AI SDK's native `timeout` option, number or `{ totalMs, stepMs, ... }`, was a false positive); Vercel needed no new TS discovery. **Per-run token/cost cap (P0 rank 8): no further rule.** Checked against upstream: OpenAI Agents (`Runner.run*`/`RunConfig`), LangChain/LangGraph (`max_iterations`, `ModelCallLimitMiddleware` counts calls not tokens), Google ADK (`RunConfig.max_llm_calls` counts calls) and Vercel AI (`maxOutputTokens` is per step) have no run-level token/cost parameter; CrewAI's `Agent.max_tokens` is declared but never read by the agent or executor (usage reaches only `TokenCalcHandler`, which tracks and does not enforce), so an absence rule would point at a no-op. AutoGen v0.4's `TokenUsageTermination` is a real primitive but is a composable termination object on team constructors, which are not discovered — not built. **Untrusted tool output (P0 rank 11): no general rule.** Framework loops return tool output through the structured tool-result channel (no user-code concatenation site), user-code `f"...{result}"` needs inter-procedural taint to call `result` a tool result, and a "delimiter present" check would reward cosmetic tags; RAW-001/002 stay the only statically defensible slice. Rule count **316** (314 + 2; re-derive). Prior review: **P0 remaining-gaps, engine batch (`schema_version` 18 → 19): six rules that needed new predicates or discovery.** **LC-114** (raw `StateGraph` compiled with no `checkpointer`; new `agent_kwargs_observed` + dual-scope `repo_langgraph_platform_config_present`, so it stays silent on unlinked/chained/opaque/subgraph compiles and on repos with a `langgraph.json`), **LC-115** (a function registered via `add_node` that shells out / executes code / writes / fetches a dynamic URL with no `interrupt(` — new `langgraph_node` ToolKind via `DiscoverLangGraphNodes`; same-file undecorated top-level functions only, confidence 0.45), **OAI-120** / **PYD-108** (run call with no wall-clock timeout — new `AgentRunCallDef.WallClockTimeoutWrapped` from a structural ancestor walk of `asyncio.wait_for` / `asyncio.timeout` / `anyio.move_on_after` / `anyio.fail_after`, new predicate `agent_run_call_wall_clock_timeout_missing`), and **RAW-001** / **RAW-002** (new `raw_llm_sdk` category, loaded unconditionally: the bare `anthropic` / `openai` client in a manual tool loop that builds a dynamic system prompt/message from tool-call-derived values — a deliberately narrow same-function heuristic via `DetectRawLLMToolOutputInSystem`). Rule count **314** (308 + 6; re-derive). Prior review 2026-09-30 (branch `feat/p0-remaining-gaps-batch1`, merged with Batch 2 on `main`) — **P0 remaining-gaps batch, rules-layer only (no discovery, predicate or `schema_version` change; stays 18).** New rules **CSDK-114** (TS `query()` main agent with inline `options` and no `options.maxBudgetUsd`), **CREW-111** (`Agent` with no `max_execution_time`, exempting `config=`), **LC-113** (Python `AgentExecutor` with no `max_execution_time`) and **VAI-020** (`generateText`/`streamText` with a `stopWhen`/`maxSteps` loop and no `abortSignal`; `ToolLoopAgent` excluded). The 14 SSRF rules (CSDK-009/013, OAI-018/024, LC-005/013, CREW-005, PYD-005, ADK-012/016, MCP-008/013, AG2-011, VAI-003) now **credit a host allow-list** in the tool body (`not: has_body_text`) instead of firing on any non-literal URL; IDs/severity/confidence unchanged. Deferred because they need new predicates or discovery, which this batch was barred from adding: OAI/PYD whole-run wall-clock deadline (needs a run-call deadline-wrapper fact), Python `ClaudeAgentOptions(max_budget_usd)`, LangGraph no-checkpointer and mutating-node-without-HITL (need a Platform/`langgraph.json` exemption, opaque-`compile()` handling and an `interrupt()` fact), LangChain.js `maxExecutionTime` (option not verified to exist), and untrusted-tool-output concat (no raw-API-loop discovery). Rule count **308** (302 + Batch 2's 6); re-derive with `grep -rhoE '^\s*-\s*id:\s*\S+' testdata/rules-fixture/*/*.yaml | wc -l`. Prior review 2026-09-29 (branch `feat/p0-side-effect-bounds-batch2`, off `main` e5cef47) — Batch 2 of the side-effect-bounds rule: **CREW-014** (CrewAI), **ADK-020** (Python) / **ADK-021** (TypeScript), **AG2-020** (AutoGen / AG2), **VAI-019** (Vercel AI SDK), and **CSDK-023** (Claude Agent SDK TypeScript) — same detection as Batch 1, adapted to each SDK's tool shape (one new `side_effect_bounds.yaml` per SDK; severity high, confidence 0.5). Approval gates differ per SDK and are honest about it: Vercel keys on `needsApproval` (as VAI-018); ADK Python on `require_confirmation` or a `request_confirmation(` call; ADK TypeScript has **no** declarative confirmation option on `FunctionTool`, so ADK-021 treats a `requestConfirmation(` call in the handler as the gate; CrewAI, AutoGen and Claude TS have no tool-level gate (`Task(human_input=True)`, `human_input_mode`, and `canUseTool`/`permissionMode` live on the task/agent/`query()` options, which a tool-scope rule cannot see), so those rules can fire on a tool gated elsewhere. One small discovery change: `DiscoverADKTools` now copies `FunctionTool(...)` keyword arguments into `ToolDef.Config` (previously dropped), which is what lets ADK-020 see `require_confirmation`. Known discovery limits carried into the rules: Vercel tools are matched only through a `const x = tool(...)` binding (inline `tools: { x: tool(...) }` has no name); Claude TS reads parameter names only from an inline object-literal schema; CrewAI `BaseTool` subclasses, autogen_core `FunctionTool`, and ADK bare functions in `tools=[...]` are not discovered. No schema/predicate/`schema_version` change. Rule count **304** (298 + 6; re-derive rather than trust this figure). Prior review 2026-09-29 (pending merge commit; `origin/main` merged into `feat/p0-side-effect-bounds-batch1`) — Batch 1 of the side-effect-bounds rule (competitive-analysis backlog rank 12, P0): new rules **OAI-030** (Python), **OAI-031** (TypeScript), **PYD-014**, **MCP-030**, and @@ -119,7 +119,7 @@ Discovery sources: `internal/analysis/discovery.go`, `agents.go`, `subagents.go` |---|---| | Tools | Decorators matched by resolved callee path: `@tool`, `@claude_tool`, `@agent.tool`, or a decorator whose callee contains `claude_agent_sdk`. Exact-callee matching (not substring), so unrelated decorators like `@toolbar` / `@tool_registry.register` no longer misclassify as tools. Captures: function name, params, type annotations, docstring, decorator kwargs | | Agents | `AgentDefinition(...)` constructor calls. Captures every kwarg into a typed `KwargTree`: `name`, `description`, `prompt`, `tools`, `disallowedTools`, `permissionMode`, `mcpServers`, `skills`, `memory`, `maxTurns`, `background`, `effort`, `initialPrompt`. Typed accessors expose `tools`/`disallowedTools`/`permissionMode`/`mcpServers` without reaching into the tree | -| Subagents | **Hybrid** discovery: canonical `.claude/agents/*.md` (any path depth, monorepo-safe) PLUS a frontmatter-shape fallback over all markdown files (gate: `name` + `tools`/`model`, excluding `SKILL.md` and `.claude/commands/`) that catches flat collections like `VoltAgent/awesome-claude-code-subagents` (subagents under `categories//*.md`). Captures `name`, `description`, `tools` (verbatim) + `ToolGrants` (parsed grammar: bare / `Bash(...)` / `mcp__server__tool`), `disallowedTools`, `model`, `permissionMode`, `mcpServers`, `skills`, `isolation`, `HasHooks`. Files without frontmatter or without a `name:` are skipped. Audited by subagent-scope rules — CSDK-110 ("Subagent granted the built-in Bash tool") and CSDK-111 ("Subagent granted filesystem-write or web-fetch built-ins") are the shipped rules; predicate `subagent_grants_tool: [Bash]` matches parsed grants (so `Bash(...)` matches `Bash`). Subagent presence alone marks the repo as `claude_agent_sdk` (no SDK code required), so the pack loads and CSDK-110/111 fire on pure-markdown collections. Subagent rules carry no `language:` field (markdown frontmatter, language-agnostic) | +| Subagents | **Hybrid** discovery: canonical `.claude/agents/*.md` (any path depth, monorepo-safe) PLUS a frontmatter-shape fallback over all markdown files (gate: `name` + `tools`/`model`, excluding `SKILL.md` and `.claude/commands/`) that catches flat collections like `VoltAgent/awesome-claude-code-subagents` (subagents under `categories//*.md`). Captures `name`, `description`, `tools` (verbatim) + `ToolGrants` (parsed grammar: bare / `Bash(...)` / `mcp__server__tool`), `disallowedTools`, `model`, `permissionMode`, `mcpServers`, `skills`, `isolation`, `HasHooks`. Files without frontmatter or without a `name:` are skipped. Audited by subagent-scope rules — CSDK-110 ("Subagent granted the built-in Bash tool"), CSDK-111 ("Subagent granted filesystem-write or web-fetch built-ins"), CSDK-112 (WebSearch) and CSDK-115 ("Subagent omits tools and inherits the full subagent tool pool") are the shipped rules; predicate `subagent_grants_tool: [Bash]` matches parsed grants (so `Bash(...)` matches `Bash`), and `subagent_inherits_all_tools: true` matches a `.claude/agents/` file with no `tools` key and no `disallowedTools` entries. Subagent presence alone marks the repo as `claude_agent_sdk` (no SDK code required), so the pack loads and CSDK-110/111 fire on pure-markdown collections. Subagent rules carry no `language:` field (markdown frontmatter, language-agnostic) | | Skills | `SKILL.md` (basename, any depth: `.claude/skills//SKILL.md`, plugin `skills/`, nested). Parses `name`, `description`, `allowed-tools` (space-separated or YAML-list → `ToolGrants`), `argument-hint`, `disable-model-invocation`, plus **body facts** (dynamic-context exec commands, external URLs, prompt-injection markers incl. hidden-Unicode — zero-width, the Tags block U+E0000–E007F, and bidi overrides), a **bundled-file inventory** (script/markdown/binary/resource by extension; non-binary files are content-scanned, size-capped — scripts for network egress and credential reads, any file for committed secret literals), and `disallowed-tools` / `context` / `agent` (fork) / `HasHooks` / `user-invocable`. Audited by skill-scope rules: CSKILL-001 (auto-approved unrestricted shell), CSKILL-002/003 (dynamic-context execution + network/secret egress), CSKILL-010/011 (bundled-script egress + credential-read — the payload-in-aux-file surface body-only scanners miss), CSKILL-030 (hardcoded secret committed in a bundled file), CSKILL-020 (external-URL fetch), CSKILL-040 (injection markers incl. hidden-Unicode), CSKILL-050 (model-invocable + side-effecting tools), CSKILL-060 (description claims read-only but grants side-effecting tools — OWASP AST04 metadata mismatch). Skill rules carry no `language:` field (markdown, language-agnostic); the `claude_skill` pack loads unconditionally (skills are not an SDK import). Dependency manifests are parsed **repo-wide** (not just skill-bundled) into `ScanResult.Dependencies` — a deterministic supply-chain BOM across all six supported languages (`requirements.txt`/`pyproject.toml`/`Pipfile` pip, `package.json` npm, `go.mod` Go, `composer.json` Composer, `*.csproj` NuGet, `Cargo.toml` Cargo; `DepRef{name, version, ecosystem, source}`, sorted + deduped, **not** folded into `ScanID`), with an optional CycloneDX 1.5 export via `--bom-out`. Pure inventory by default: no network, no CVE matching (hands off to OSV-Scanner / Dependabot / syft). The opt-in `--vuln-scan` layer (`internal/vulndb`, TR-271) adds the CVE verdict — it matches each concretely-pinned dep against a pinned OSV snapshot (per-ecosystem PEP 440 / semver comparison + the OSV range algorithm, CVSS 3.x → severity) and emits both `ScanResult.Vulnerabilities` and synthesized advisory-ID findings that hit the normal exit codes and SARIF; the snapshot is fetched from osv.dev on first use, cached with an offline fallback (`trustabl vulndb pull` pre-warms it), and its version is folded into `ScanID` only when enabled. Declared ranges (no lockfile) are skipped, not guessed. See TR-278, TR-271. | | Slash commands | **Two path shapes**: canonical `.claude/commands/*.md` AND `/commands/*.md` whenever `` has a sibling `.claude-plugin/plugin.json` (the layout used by plugin-distribution repos like `wshobson/agents` — `plugins//commands/*.md`). Command name = file basename. Parses frontmatter `description`, `allowed-tools`, `model`, `argument-hint`, `disable-model-invocation`; a command without frontmatter is still emitted (body is the prompt). No rules yet — surfaced in inventory/JSON | | Plugin manifests | `.claude-plugin/plugin.json` and `marketplace.json` JSON-parsed into `PluginManifest` (`kind` plugin/marketplace, `name`, catalog `plugins[]` with `name` + normalized `source`). The `source` field accepts both forms in the wild: a plain string (`"./local-foo"`) or an object (`{"source":"git-subdir","url":"…","path":"…"}` for external git refs); object forms are normalized to `:#` so the trust category survives the round-trip, unknown shapes fall back to raw JSON. A previous typed-string parser dropped the entire manifest on the object form. The recon walk descends into `.claude-plugin/`. No rules yet — surfaced in inventory/JSON | diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 76c90b29..85fc04bb 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -517,6 +517,34 @@ func TestScan_SkillNameMatch(t *testing.T) { } } +func TestScan_SubagentToolsInherit(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + target := filepath.Join(filepath.Dir(thisFile), "..", "..", "testdata", "corpus", "subagent-tools-inherit") + res, err := scanner.Run(scanner.Config{Target: target, RulesFS: rulesFixture(t)}) + if err != nil { + t.Fatalf("scan: %v", err) + } + + if len(res.Subagents) != 5 { + t.Errorf("subagents = %d, want 5", len(res.Subagents)) + } + var hits []models.Finding + for _, f := range res.Findings { + switch f.RuleID { + case "CSDK-115": + hits = append(hits, f) + case "CSDK-110", "CSDK-111", "CSDK-112": + t.Errorf("unexpected %s fired on subagent-tools-inherit", f.RuleID) + } + } + if len(hits) != 1 { + t.Fatalf("CSDK-115 findings = %d, want 1: %+v", len(hits), hits) + } + if hits[0].FilePath != ".claude/agents/inheritor.md" || hits[0].StartLine != 1 { + t.Errorf("CSDK-115 at %s:%d, want .claude/agents/inheritor.md:1", hits[0].FilePath, hits[0].StartLine) + } +} + func TestScan_SkillAgentSpecific(t *testing.T) { _, thisFile, _, _ := runtime.Caller(0) target := filepath.Join(filepath.Dir(thisFile), "..", "..", "testdata", "corpus", "skill-agent-specific") diff --git a/testdata/corpus/PROVENANCE.md b/testdata/corpus/PROVENANCE.md index 8199c8f6..389c23f0 100644 --- a/testdata/corpus/PROVENANCE.md +++ b/testdata/corpus/PROVENANCE.md @@ -24,6 +24,7 @@ These were authored in this repository as test fixtures. | `skill-vuln-fixtures/` | Synthetic Claude Code Agent Skills (a deliberately-vulnerable `leak-helper` + a benign `safe-reader`) exercising enriched skill discovery and the CSKILL-* rule tests. No real payload; all hosts are `example.*` placeholders | | `preauth-config-fixture/` | Synthetic pre-trust harness config (`.claude/settings.json`, `.mcp.json`, `.gemini/.env`, `.vscode/settings.json`, `.codex/config.toml`) exercising the agent_config rules (AGCFG-004..007, MCPCFG-002). All values are fake placeholders | | `codex-hooks-fixture/` | Synthetic `.codex/hooks.json` in the shape OpenAI documents for Codex hooks (a repo script via the documented `$(git rev-parse --show-toplevel)` form, and a network call) exercising AGCFG-008. All values are fake placeholders | +| `subagent-tools-inherit/` | Synthetic `.claude/agents/*.md` subagents (no `tools`, a listed `tools`, an empty `tools: []`, a `disallowedTools` denylist), a non-canonical `agents/planner.md`, and an in-code `AgentDefinition` with no `tools`, exercising CSDK-115. All values are fake placeholders | ## Vendored from `openai/openai-agents-python` (MIT) diff --git a/testdata/corpus/subagent-tools-inherit/.claude/agents/denylist.md b/testdata/corpus/subagent-tools-inherit/.claude/agents/denylist.md new file mode 100644 index 00000000..799a8972 --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/.claude/agents/denylist.md @@ -0,0 +1,7 @@ +--- +name: denylist +description: "Synthetic subagent with a denylist." +disallowedTools: Bash, Write, Edit +--- + +You review text. diff --git a/testdata/corpus/subagent-tools-inherit/.claude/agents/empty-tools.md b/testdata/corpus/subagent-tools-inherit/.claude/agents/empty-tools.md new file mode 100644 index 00000000..7e5fc7d9 --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/.claude/agents/empty-tools.md @@ -0,0 +1,7 @@ +--- +name: empty-tools +description: "Synthetic subagent with an empty tools list." +tools: [] +--- + +You answer questions. diff --git a/testdata/corpus/subagent-tools-inherit/.claude/agents/inheritor.md b/testdata/corpus/subagent-tools-inherit/.claude/agents/inheritor.md new file mode 100644 index 00000000..cd13dd5f --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/.claude/agents/inheritor.md @@ -0,0 +1,6 @@ +--- +name: inheritor +description: "Synthetic subagent with no tools key." +--- + +You help with tasks. diff --git a/testdata/corpus/subagent-tools-inherit/.claude/agents/reader.md b/testdata/corpus/subagent-tools-inherit/.claude/agents/reader.md new file mode 100644 index 00000000..b7055958 --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/.claude/agents/reader.md @@ -0,0 +1,7 @@ +--- +name: reader +description: "Synthetic read-only subagent." +tools: Read, Grep, Glob +--- + +You read files. diff --git a/testdata/corpus/subagent-tools-inherit/agent.py b/testdata/corpus/subagent-tools-inherit/agent.py new file mode 100644 index 00000000..8db4223c --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/agent.py @@ -0,0 +1,3 @@ +from claude_agent_sdk import AgentDefinition + +helper = AgentDefinition(description="Synthetic in-code agent with no tools", prompt="Help.") diff --git a/testdata/corpus/subagent-tools-inherit/agents/planner.md b/testdata/corpus/subagent-tools-inherit/agents/planner.md new file mode 100644 index 00000000..6e5932ab --- /dev/null +++ b/testdata/corpus/subagent-tools-inherit/agents/planner.md @@ -0,0 +1,7 @@ +--- +name: planner +description: "Synthetic non-canonical agent markdown." +model: sonnet +--- + +You plan work.