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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ cordon pass revoke <pass-id>
- All commands accept `--json` for structured output. Schemas not finalised at this time.

- `<pattern>` can be a file path, folder path, glob pattern, or command pattern.
Examples: `src/main.go`, `src/`, `**/*.env`, `git push --force*`.
Examples: `src/main.go`, `src/`, `**/*.env`, `git push *--force*`.
- File globs support recursive `**` matching.
- Command rules evaluate direct commands and common wrapped forms (for example `sh -c` and `bash -lc`).


## Documentation
Expand Down
33 changes: 33 additions & 0 deletions cli/internal/hook/bash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package hook

import "testing"

func TestBashReadTargets_HeadAndDelimiters(t *testing.T) {
got := bashReadTargets("head -n 20 README.md && cat .env")
wantHas := []string{"README.md", ".env"}
for _, w := range wantHas {
found := false
for _, g := range got {
if g == w {
found = true
break
}
}
if !found {
t.Fatalf("targets %#v missing expected %q", got, w)
}
}
}

func TestReSedInPlaceTargets_Variants(t *testing.T) {
tests := []string{
`sed -i 's/a/b/' file.txt`,
`sed -i.bak 's/a/b/' file.txt`,
}
for _, cmd := range tests {
got := reSedInPlaceTargets(cmd)
if len(got) != 1 || got[0] != "file.txt" {
t.Fatalf("reSedInPlaceTargets(%q) = %#v, want [file.txt]", cmd, got)
}
}
}
58 changes: 58 additions & 0 deletions cli/internal/hook/commandrule.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,64 @@ func splitCompoundCommand(command string) []string {
return segments
}

// expandCommandRuleSegments returns the command segments to evaluate for
// command-rule enforcement, including common shell wrapper inner commands.
func expandCommandRuleSegments(segment string) []string {
return expandCommandRuleSegmentsDepth(strings.TrimSpace(segment), 0, 2)
}

func expandCommandRuleSegmentsDepth(segment string, depth, maxDepth int) []string {
if segment == "" {
return nil
}
out := []string{segment}
if depth >= maxDepth {
return out
}

argv, ok := parseShellArgv(segment)
if !ok || len(argv) == 0 {
return out
}
inner, ok := unwrapShellCommandArg(argv)
if !ok || strings.TrimSpace(inner) == "" {
return out
}
for _, innerSeg := range splitCompoundCommand(inner) {
out = append(out, expandCommandRuleSegmentsDepth(innerSeg, depth+1, maxDepth)...)
}
return out
}

func unwrapShellCommandArg(argv []string) (string, bool) {
if len(argv) < 3 {
return "", false
}
switch strings.ToLower(argv[0]) {
case "sh", "bash", "zsh", "dash", "ksh":
default:
return "", false
}

for i := 1; i < len(argv)-1; i++ {
if isShellCommandOption(argv[i]) {
return argv[i+1], true
}
}
return "", false
}

func isShellCommandOption(tok string) bool {
if tok == "-c" || tok == "--command" {
return true
}
if !strings.HasPrefix(tok, "-") || len(tok) <= 1 {
return false
}
// Combined short options where c appears, e.g. -lc or -cl.
return strings.Contains(tok[1:], "c")
}

// commandRuleDenyReason returns the denial message for a matched command rule.
func commandRuleDenyReason(rule *MatchedRule, agent string) string {
if supportsMCPElicitation(agent) {
Expand Down
79 changes: 79 additions & 0 deletions cli/internal/hook/commandrule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,82 @@ func TestCommandRuleDenyReason_OmitsMCPForGeminiAndOpenCode(t *testing.T) {
})
}
}

func TestCheckBuiltinRules_DenyCordonCommands(t *testing.T) {
rule := CheckBuiltinRules("cordon status")
if rule == nil {
t.Fatal("expected built-in deny rule for cordon status, got nil")
}
if rule.Pattern != "cordon *" {
t.Fatalf("rule.Pattern = %q, want %q", rule.Pattern, "cordon *")
}
}

func TestCheckBuiltinRules_AllowOverridesDenyForCordonHook(t *testing.T) {
rule := CheckBuiltinRules("cordon hook")
if rule != nil {
t.Fatalf("expected built-in allow override for cordon hook, got deny rule %q", rule.Pattern)
}
}

func TestExpandCommandRuleSegments_UnwrapsShellCommand(t *testing.T) {
got := expandCommandRuleSegments(`bash -lc 'git commit -m "test" && git status'`)
wantHas := []string{
`bash -lc 'git commit -m "test" && git status'`,
`git commit -m "test"`,
"git status",
}
for _, w := range wantHas {
found := false
for _, g := range got {
if g == w {
found = true
break
}
}
if !found {
t.Fatalf("segments %#v missing expected %q", got, w)
}
}
}

func TestExpandCommandRuleSegmentsDepth_RespectsMaxDepth(t *testing.T) {
got := expandCommandRuleSegmentsDepth(`bash -lc 'sh -c "git status"'`, 0, 1)
wantHas := []string{
`bash -lc 'sh -c "git status"'`,
`sh -c "git status"`,
}
for _, w := range wantHas {
found := false
for _, g := range got {
if g == w {
found = true
break
}
}
if !found {
t.Fatalf("segments %#v missing expected %q", got, w)
}
}
for _, g := range got {
if g == "git status" {
t.Fatalf("segments %#v unexpectedly contains depth-limited inner command", got)
}
}
}

func TestUnwrapShellCommandArg_LongCommandOption(t *testing.T) {
got, ok := unwrapShellCommandArg([]string{"bash", "--command", "git status"})
if !ok {
t.Fatal("expected unwrap success for --command")
}
if got != "git status" {
t.Fatalf("unwrap = %q, want git status", got)
}
}

func TestIsShellCommandOption_CombinedShortFlags(t *testing.T) {
if !isShellCommandOption("-lc") {
t.Fatal("expected -lc to be recognized as command-carrying option")
}
}
75 changes: 39 additions & 36 deletions cli/internal/hook/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,38 +372,12 @@ func evaluateBash(payload hookPayload, w io.Writer, errW io.Writer, checker Poli
commandPassID := ""
commandPassNotify := false

// Check each segment of the command against built-in and custom command rules.
// Check each segment of the command (and unwrapped wrapper segments) against
// built-in and custom command rules.
for i, seg := range analysis.Commands {
// Built-in rules are always checked (no DB needed).
if matched := CheckBuiltinRules(seg); matched != nil {
reason := commandRuleDenyReason(matched, agent)
event := &Event{
ToolName: payload.ToolName,
ToolInput: payload.ToolInput,
CommandRaw: analysis.CommandRaw,
CommandParsed: analysis.ParsedOK,
CommandParseError: analysis.ParseError,
CommandParser: analysis.Parser,
CommandParserVersion: analysis.ParserVersion,
CommandOpsJSON: analysis.opsJSON(),
DeniedOpIndex: i,
DeniedOpReason: "prevent-command rule violation",
MatchedRulePattern: matched.Pattern,
MatchedRuleType: matched.RuleType,
Ambiguity: analysis.ambiguityText(),
Decision: DecisionDeny,
Cwd: payload.Cwd,
}
if err := encodeClaudeDeny(w, reason); err != nil {
return nil, err
}
fmt.Fprintf(errW, "%s\n", reason)
return event, ErrDenied
}

// Custom rules from the policy database.
if cmdChecker != nil {
if allowed, passID, matched, cmdNotify := cmdChecker(seg, payload.Cwd); !allowed && matched != nil {
for _, candidate := range expandCommandRuleSegments(seg) {
// Built-in rules are always checked (no DB needed).
if matched := CheckBuiltinRules(candidate); matched != nil {
reason := commandRuleDenyReason(matched, agent)
event := &Event{
ToolName: payload.ToolName,
Expand All @@ -421,17 +395,46 @@ func evaluateBash(payload hookPayload, w io.Writer, errW io.Writer, checker Poli
Ambiguity: analysis.ambiguityText(),
Decision: DecisionDeny,
Cwd: payload.Cwd,
Notify: cmdNotify,
}
if err := encodeClaudeDeny(w, reason); err != nil {
return nil, err
}
fmt.Fprintf(errW, "%s\n", reason)
return event, ErrDenied
} else if allowed && passID != "" {
commandPassID = passID
if cmdNotify {
commandPassNotify = true
}

// Custom rules from the policy database.
if cmdChecker != nil {
if allowed, passID, matched, cmdNotify := cmdChecker(candidate, payload.Cwd); !allowed && matched != nil {
reason := commandRuleDenyReason(matched, agent)
event := &Event{
ToolName: payload.ToolName,
ToolInput: payload.ToolInput,
CommandRaw: analysis.CommandRaw,
CommandParsed: analysis.ParsedOK,
CommandParseError: analysis.ParseError,
CommandParser: analysis.Parser,
CommandParserVersion: analysis.ParserVersion,
CommandOpsJSON: analysis.opsJSON(),
DeniedOpIndex: i,
DeniedOpReason: "prevent-command rule violation",
MatchedRulePattern: matched.Pattern,
MatchedRuleType: matched.RuleType,
Ambiguity: analysis.ambiguityText(),
Decision: DecisionDeny,
Cwd: payload.Cwd,
Notify: cmdNotify,
}
if err := encodeClaudeDeny(w, reason); err != nil {
return nil, err
}
fmt.Fprintf(errW, "%s\n", reason)
return event, ErrDenied
} else if allowed && passID != "" {
commandPassID = passID
if cmdNotify {
commandPassNotify = true
}
}
}
}
Expand Down
Loading
Loading