Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,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`
Expand Down Expand Up @@ -1398,6 +1398,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) |
Expand Down Expand Up @@ -1935,6 +1936,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:
Expand Down
4 changes: 2 additions & 2 deletions COVERAGE.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions internal/analysis/subagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
43 changes: 42 additions & 1 deletion internal/analysis/subagents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: ---
Expand Down
9 changes: 9 additions & 0 deletions internal/models/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion internal/rules/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,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
}

Expand Down Expand Up @@ -526,7 +529,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,
Expand Down Expand Up @@ -646,6 +650,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")
Expand Down
16 changes: 16 additions & 0 deletions internal/rules/policies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4767,6 +4767,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-*).
Expand Down
10 changes: 10 additions & 0 deletions internal/rules/predicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/rules/predicates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/rules/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions internal/rules/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,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)
Expand Down
4 changes: 3 additions & 1 deletion internal/rules/schema_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ package rules
//
// Schema 28 adds the CI workflow predicates (repo_ci_agent_*), the `ci`
// category and the `github_actions` repo applies_to token.
const SupportedSchemaVersion = 28
//
// Schema 29 adds the subagent_inherits_all_tools predicate (subagent scope).
const SupportedSchemaVersion = 29
28 changes: 28 additions & 0 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,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")
Expand Down
1 change: 1 addition & 0 deletions testdata/corpus/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ These were authored in this repository as test fixtures.
| `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 |
| `ci-agent-workflow-fixture/` | Synthetic GitHub Actions workflows (`.github/workflows/issue-triage.yml`, `gated-comment.yml`) exercising the CI-001..CI-005 rules. Shapes follow the AGS-07 rule table; secret names are 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)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: denylist
description: "Synthetic subagent with a denylist."
disallowedTools: Bash, Write, Edit
---

You review text.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: empty-tools
description: "Synthetic subagent with an empty tools list."
tools: []
---

You answer questions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: inheritor
description: "Synthetic subagent with no tools key."
---

You help with tasks.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: reader
description: "Synthetic read-only subagent."
tools: Read, Grep, Glob
---

You read files.
3 changes: 3 additions & 0 deletions testdata/corpus/subagent-tools-inherit/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from claude_agent_sdk import AgentDefinition

helper = AgentDefinition(description="Synthetic in-code agent with no tools", prompt="Help.")
7 changes: 7 additions & 0 deletions testdata/corpus/subagent-tools-inherit/agents/planner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: planner
description: "Synthetic non-canonical agent markdown."
model: sonnet
---

You plan work.
10 changes: 6 additions & 4 deletions testdata/rules-fixture/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,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"

Expand Down
36 changes: 36 additions & 0 deletions testdata/rules-fixture/claude_sdk/subagent_safety.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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__*`.
Loading
Loading