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
2 changes: 2 additions & 0 deletions internal/forge/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func matchCondition(expr rules.MatchExpr) string {
return "Any skill containing instruction-override phrasing, invisible Unicode, or encoded blobs."
case len(expr.SkillAllowsTool) > 0:
return "Any skill pre-approving Bash, Write, Edit, WebFetch, or NotebookEdit in allowed-tools."
case len(expr.SkillAllowsUnrestrictedTool) > 0:
return "Any skill pre-approving Bash, Write, Edit, WebFetch, or NotebookEdit in allowed-tools with a genuinely unrestricted grant."
case expr.SkillModelInvocable != nil:
return "Any skill where disable-model-invocation is not set to true."
case expr.SkillBundledScriptNetworkEgress != nil:
Expand Down
5 changes: 5 additions & 0 deletions internal/rules/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ func (e MatchExpr) EvaluateSkill(s models.SkillDef, inv models.RepoInventory) bo
if len(e.SkillAllowsTool) > 0 && !PredSkillAllowsTool(s, e.SkillAllowsTool) {
return false
}
if len(e.SkillAllowsUnrestrictedTool) > 0 && !PredSkillAllowsUnrestrictedTool(s, e.SkillAllowsUnrestrictedTool) {
return false
}
if e.SkillModelInvocable != nil && PredSkillModelInvocable(s) != *e.SkillModelInvocable {
return false
}
Expand Down Expand Up @@ -398,6 +401,7 @@ var predicatesByScope = map[models.Scope]map[string]bool{
models.ScopeSkill: {
"skill_allows_unrestricted_shell": true,
"skill_allows_tool": true,
"skill_allows_unrestricted_tool": true,
"skill_model_invocable": true,
"skill_body_has_dynamic_exec": true,
"skill_dynamic_exec_touches_network_or_secrets": true,
Expand Down Expand Up @@ -478,6 +482,7 @@ func (e MatchExpr) setPredicateNames() []string {
// Skill scope
add(e.SkillAllowsUnrestrictedShell != nil, "skill_allows_unrestricted_shell")
add(len(e.SkillAllowsTool) > 0, "skill_allows_tool")
add(len(e.SkillAllowsUnrestrictedTool) > 0, "skill_allows_unrestricted_tool")
add(e.SkillModelInvocable != nil, "skill_model_invocable")
add(e.SkillBodyHasDynamicExec != nil, "skill_body_has_dynamic_exec")
add(e.SkillDynamicExecTouchesNetworkOrSecrets != nil, "skill_dynamic_exec_touches_network_or_secrets")
Expand Down
30 changes: 30 additions & 0 deletions internal/rules/policies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3509,6 +3509,9 @@ var policySkillRuleCases = []policySkillCase{
{"CSKILL-001 fires on Bash(*) grant", "CSKILL-001",
models.SkillDef{Name: "leak", Location: models.Location{FilePath: ".claude/skills/leak/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "*"}}}, models.RepoInventory{}, true},
{"CSKILL-001 fires on Bash(:*) grant", "CSKILL-001",
models.SkillDef{Name: "leak2", Location: models.Location{FilePath: ".claude/skills/leak2/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: ":*"}}}, models.RepoInventory{}, true},
{"CSKILL-001 silent on scoped Bash grant", "CSKILL-001",
models.SkillDef{Name: "safe", Location: models.Location{FilePath: ".claude/skills/safe/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "git status *"}}}, models.RepoInventory{}, false},
Expand Down Expand Up @@ -3544,10 +3547,25 @@ var policySkillRuleCases = []policySkillCase{
{"CSKILL-050 fires on model-invocable skill granting Write", "CSKILL-050",
models.SkillDef{Name: "auto", Location: models.Location{FilePath: ".claude/skills/auto/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Write"}}}, models.RepoInventory{}, true},
{"CSKILL-050 fires on unrestricted Bash(*) grant", "CSKILL-050",
models.SkillDef{Name: "auto2", Location: models.Location{FilePath: ".claude/skills/auto2/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "*"}}}, models.RepoInventory{}, true},
{"CSKILL-050 fires on Write with a path pattern (Claude Code never enforces it)", "CSKILL-050",
models.SkillDef{Name: "auto3", Location: models.Location{FilePath: ".claude/skills/auto3/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Write", Pattern: "src/**"}}}, models.RepoInventory{}, true},
{"CSKILL-050 silent when manual-only", "CSKILL-050",
models.SkillDef{Name: "manual", Location: models.Location{FilePath: ".claude/skills/manual/SKILL.md"},
DisableModelInvocation: true,
ToolGrants: []models.ToolGrant{{Tool: "Write"}}}, models.RepoInventory{}, false},
{"CSKILL-050 silent on scoped Bash grant", "CSKILL-050",
models.SkillDef{Name: "scoped", Location: models.Location{FilePath: ".claude/skills/scoped/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "git status:*"}}}, models.RepoInventory{}, false},
{"CSKILL-050 silent on scoped Edit and WebFetch grants", "CSKILL-050",
models.SkillDef{Name: "scoped2", Location: models.Location{FilePath: ".claude/skills/scoped2/SKILL.md"},
ToolGrants: []models.ToolGrant{
{Tool: "Edit", Pattern: "docs/**"},
{Tool: "WebFetch", Pattern: "domain:example.com"},
}}, models.RepoInventory{}, false},

{"CSKILL-010 fires on bundled script network egress", "CSKILL-010",
models.SkillDef{Name: "boot", Location: models.Location{FilePath: ".claude/skills/boot/SKILL.md"},
Expand Down Expand Up @@ -3582,6 +3600,10 @@ var policySkillRuleCases = []policySkillCase{
models.SkillDef{Name: "reader", Description: "Read-only summarizer; does not modify anything.",
Location: models.Location{FilePath: ".claude/skills/reader/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Read"}, {Tool: "Grep"}}}, models.RepoInventory{}, false},
{"CSKILL-060 silent when the read-only claim's Bash grant is scoped", "CSKILL-060",
models.SkillDef{Name: "scoped-reader", Description: "Read-only: summarizes git history. Cannot modify anything.",
Location: models.Location{FilePath: ".claude/skills/scoped-reader/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "git status:*"}}}, models.RepoInventory{}, false},
{"CSKILL-070 fires on skill with empty description", "CSKILL-070",
models.SkillDef{Name: "helper", Location: models.Location{FilePath: ".claude/skills/helper/SKILL.md"},
Description: ""},
Expand Down Expand Up @@ -3627,10 +3649,18 @@ var policySkillRuleCases = []policySkillCase{
models.SkillDef{Name: "security-audit",
Location: models.Location{FilePath: ".claude/skills/security-audit/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "*"}}}, models.RepoInventory{}, true},
{"CSKILL-082 fires when a security-purpose skill grants bare Bash", "CSKILL-082",
models.SkillDef{Name: "security-audit2",
Location: models.Location{FilePath: ".claude/skills/security-audit2/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash"}}}, models.RepoInventory{}, true},
{"CSKILL-082 silent when a security-purpose skill only reads", "CSKILL-082",
models.SkillDef{Name: "security-audit",
Location: models.Location{FilePath: ".claude/skills/security-audit/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Read"}, {Tool: "Grep"}}}, models.RepoInventory{}, false},
{"CSKILL-082 silent when a security-purpose skill's Bash grant is scoped", "CSKILL-082",
models.SkillDef{Name: "security-audit3",
Location: models.Location{FilePath: ".claude/skills/security-audit3/SKILL.md"},
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "git status:*"}}}, models.RepoInventory{}, false},

{"CSKILL-083 fires when the body has no error-handling language", "CSKILL-083",
models.SkillDef{Name: "helper",
Expand Down
87 changes: 79 additions & 8 deletions internal/rules/predicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,10 @@ func PredSkillBodyHasInjectionMarker(s models.SkillDef) bool {
// PredSkillAllowsTool reports whether the skill pre-approves any of names via
// allowed-tools. It matches the parsed grant's Tool first (so "Bash(git *)"
// matches "Bash") then the raw allowed-tools tokens.
//
// This predicate does NOT consult the grant's Pattern — "Bash(git status:*)"
// matches identically to a bare "Bash". Use PredSkillAllowsUnrestrictedTool
// when a narrowly-scoped grant should not count.
func PredSkillAllowsTool(s models.SkillDef, names []string) bool {
want := make(map[string]bool, len(names))
for _, n := range names {
Expand All @@ -1007,17 +1011,82 @@ func PredSkillAllowsTool(s models.SkillDef, names []string) bool {
return false
}

// skillGrantIsUnrestricted reports whether a parsed ToolGrant places no real
// constraint on what it authorizes, per Claude Code's own permission-rule
// semantics (see code.claude.com/docs/en/permissions):
//
// - Bash / PowerShell: unrestricted only for an empty, "*", or ":*" pattern
// — Bash(git status:*) is a genuine command-prefix restriction.
// - Edit (the tool Claude Code actually consults for file-path rules; see
// below): unrestricted only for an empty pattern or a catch-all glob
// ("*", "**", "/**", "//**"), never a real gitignore-style path pattern
// like "docs/**".
// - WebFetch: unrestricted only for an empty or "*" pattern — a
// "domain:example.com" specifier is a genuine restriction.
// - Write and NotebookEdit: ALWAYS unrestricted, regardless of any pattern
// written in the grant. Claude Code accepts a path specifier on these
// tools but never consults it — only an Edit rule is checked for file
// writes (it warns at startup that a Write/NotebookEdit path rule is
// dead). So "Write(src/**)" grants exactly as much as bare "Write".
func skillGrantIsUnrestricted(g models.ToolGrant) bool {
pattern := strings.TrimSpace(g.Pattern)
switch g.Tool {
case "Write", "NotebookEdit":
return true
case "Bash", "PowerShell":
switch pattern {
case "", "*", ":*":
return true
}
return false
case "Edit":
switch pattern {
case "", "*", "**", "/**", "//**":
return true
}
return false
case "WebFetch":
switch pattern {
case "", "*":
return true
}
return false
default:
return pattern == ""
}
}

// PredSkillAllowsUnrestrictedShell reports whether the skill pre-approves
// unrestricted shell via allowed-tools — a bare `Bash` grant or a wildcard
// pattern (`Bash(*)` / `Bash(:*)`). allowed-tools is an auto-approval list, not a
// sandbox, so this lets the skill run any shell command without prompting.
func PredSkillAllowsUnrestrictedShell(s models.SkillDef) bool {
for _, g := range s.ToolGrants {
if g.Tool != "Bash" {
continue
if g.Tool == "Bash" && skillGrantIsUnrestricted(g) {
return true
}
switch strings.TrimSpace(g.Pattern) {
case "", "*", ":*":
}
return false
}

// PredSkillAllowsUnrestrictedTool reports whether the skill pre-approves any
// of names via allowed-tools with a genuinely unrestricted grant — unlike
// PredSkillAllowsTool, a narrowly-scoped grant (Bash(git status:*),
// Edit(docs/**), WebFetch(domain:example.com)) does not count. A raw
// allowed-tools token with no parsed grant (s.AllowedTools fallback) is
// treated as unrestricted, since a bare token is unrestricted by definition.
func PredSkillAllowsUnrestrictedTool(s models.SkillDef, names []string) bool {
want := make(map[string]bool, len(names))
for _, n := range names {
want[n] = true
}
for _, g := range s.ToolGrants {
if want[g.Tool] && skillGrantIsUnrestricted(g) {
return true
}
}
for _, t := range s.AllowedTools {
if want[t] {
return true
}
}
Expand Down Expand Up @@ -1090,14 +1159,16 @@ func PredSkillBundledFileHasHardcodedSecret(s models.SkillDef) bool {
var sideEffectingSkillTools = []string{"Bash", "Write", "Edit", "WebFetch", "NotebookEdit"}

// PredSkillDescriptionToolMismatch reports whether the skill's description
// explicitly claims to be read-only / side-effect-free while it pre-approves a
// side-effecting tool (or unrestricted shell) — the metadata then understates
// the real capability, which is the signal a reviewer relies on.
// explicitly claims to be read-only / side-effect-free while it pre-approves an
// unrestricted side-effecting tool grant — the metadata then understates the
// real capability, which is the signal a reviewer relies on. A narrowly-scoped
// grant (Bash(git status:*), Edit(docs/**)) does not misrepresent the
// description, so it is not counted.
func PredSkillDescriptionToolMismatch(s models.SkillDef) bool {
if !skillReadOnlyClaimRe.MatchString(s.Description) {
return false
}
return PredSkillAllowsUnrestrictedShell(s) || PredSkillAllowsTool(s, sideEffectingSkillTools)
return PredSkillAllowsUnrestrictedTool(s, sideEffectingSkillTools)
}

func PredSkillHasDescription(s models.SkillDef) bool {
Expand Down
86 changes: 86 additions & 0 deletions internal/rules/predicates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1608,3 +1608,89 @@ func TestPredSubagentGrantsTool(t *testing.T) {
t.Errorf("expected true: Bash(npm run *) grants Bash via ToolGrants")
}
}

// 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
// path specifier but never consult it — so any grant on those two tools is
// unrestricted regardless of pattern.
func TestPredSkillAllowsUnrestrictedTool(t *testing.T) {
names := []string{"Bash", "Write", "Edit", "WebFetch", "NotebookEdit"}
skillWith := func(grant models.ToolGrant) models.SkillDef {
return models.SkillDef{Name: "x", ToolGrants: []models.ToolGrant{grant}}
}

cases := []struct {
name string
grant models.ToolGrant
want bool
}{
// Bash: unrestricted only for "", "*", ":*".
{"bare Bash", models.ToolGrant{Tool: "Bash"}, true},
{"Bash(*)", models.ToolGrant{Tool: "Bash", Pattern: "*"}, true},
{"Bash(:*)", models.ToolGrant{Tool: "Bash", Pattern: ":*"}, true},
{"Bash(git status:*)", models.ToolGrant{Tool: "Bash", Pattern: "git status:*"}, false},
{"Bash(npm run *)", models.ToolGrant{Tool: "Bash", Pattern: "npm run *"}, false},

// Edit: unrestricted only for "" or a catch-all glob.
{"bare Edit", models.ToolGrant{Tool: "Edit"}, true},
{"Edit(*)", models.ToolGrant{Tool: "Edit", Pattern: "*"}, true},
{"Edit(**)", models.ToolGrant{Tool: "Edit", Pattern: "**"}, true},
{"Edit(/**)", models.ToolGrant{Tool: "Edit", Pattern: "/**"}, true},
{"Edit(//**)", models.ToolGrant{Tool: "Edit", Pattern: "//**"}, true},
{"Edit(docs/**)", models.ToolGrant{Tool: "Edit", Pattern: "docs/**"}, false},
{"Edit(src/**/*.ts)", models.ToolGrant{Tool: "Edit", Pattern: "src/**/*.ts"}, false},

// WebFetch: unrestricted only for "" or "*".
{"bare WebFetch", models.ToolGrant{Tool: "WebFetch"}, true},
{"WebFetch(*)", models.ToolGrant{Tool: "WebFetch", Pattern: "*"}, true},
{"WebFetch(domain:example.com)", models.ToolGrant{Tool: "WebFetch", Pattern: "domain:example.com"}, false},

// Write / NotebookEdit: ALWAYS unrestricted — Claude Code never
// consults a path rule written for these tools.
{"bare Write", models.ToolGrant{Tool: "Write"}, true},
{"Write(src/**)", models.ToolGrant{Tool: "Write", Pattern: "src/**"}, true},
{"bare NotebookEdit", models.ToolGrant{Tool: "NotebookEdit"}, true},
{"NotebookEdit(notebooks/**)", models.ToolGrant{Tool: "NotebookEdit", Pattern: "notebooks/**"}, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := rules.PredSkillAllowsUnrestrictedTool(skillWith(tc.grant), names)
if got != tc.want {
t.Errorf("PredSkillAllowsUnrestrictedTool(%+v) = %v, want %v", tc.grant, got, tc.want)
}
})
}

// A skill with only a raw allowed-tools token (no parsed ToolGrants) is
// treated as unrestricted, since a bare token is unrestricted by
// definition — mirrors PredSkillAllowsTool's raw-token fallback.
raw := models.SkillDef{Name: "raw", AllowedTools: []string{"Bash"}}
if !rules.PredSkillAllowsUnrestrictedTool(raw, names) {
t.Errorf("expected true: raw AllowedTools token %q falls back to unrestricted", "Bash")
}
}

// TestPredSkillDescriptionToolMismatch_ScopedGrantIsSilent guards the
// CSKILL-060 fix end-to-end: a scoped grant must not trip the description
// mismatch even though the description makes a read-only claim.
func TestPredSkillDescriptionToolMismatch_ScopedGrantIsSilent(t *testing.T) {
scoped := models.SkillDef{
Name: "scoped-reader",
Description: "Read-only: summarizes git history. Cannot modify anything.",
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "git status:*"}},
}
if rules.PredSkillDescriptionToolMismatch(scoped) {
t.Errorf("expected false: Bash(git status:*) is a scoped grant, not a mismatch")
}

unrestricted := models.SkillDef{
Name: "sneaky",
Description: "Read-only: summarizes the diff. Cannot run commands.",
ToolGrants: []models.ToolGrant{{Tool: "Bash", Pattern: "*"}},
}
if !rules.PredSkillDescriptionToolMismatch(unrestricted) {
t.Errorf("expected true: Bash(*) is unrestricted and contradicts the read-only claim")
}
}
1 change: 1 addition & 0 deletions internal/rules/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type MatchExpr struct {
// Skill-scope predicates
SkillAllowsUnrestrictedShell *bool `yaml:"skill_allows_unrestricted_shell,omitempty"`
SkillAllowsTool []string `yaml:"skill_allows_tool,omitempty"`
SkillAllowsUnrestrictedTool []string `yaml:"skill_allows_unrestricted_tool,omitempty"`
SkillModelInvocable *bool `yaml:"skill_model_invocable,omitempty"`
SkillBodyHasDynamicExec *bool `yaml:"skill_body_has_dynamic_exec,omitempty"`
SkillDynamicExecTouchesNetworkOrSecrets *bool `yaml:"skill_dynamic_exec_touches_network_or_secrets,omitempty"`
Expand Down
Loading
Loading