Skip to content
Open
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
40 changes: 40 additions & 0 deletions cmd/numbat/hook_enforce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,46 @@ func TestEnforceOnMediumEnforceMatchDenies(t *testing.T) {
}
}

func TestEnforceCompoundCommandsPreservesInterpreterBoundary(t *testing.T) {
dir := writeEnforceRuleFile(t, criticalEnforceRule)
for _, test := range []struct {
name, command string
deny bool
}{
{"compound", "true; cat .env", true},
{"safe sibling of interpreter", "sh -c 'true'; cat .env", true},
{"unsafe pipeline", `cat .env | "$sink"; true`, false},
{"heredoc script", "sh <<'EOF'\ncat .env\nEOF", false},
{"invalid zsh option", "zsh -odefinitely_invalid <<'EOF'\ncat .env\nEOF", false},
{"missing bash option value", "bash --rcfile <<'EOF'\ncat .env\nEOF", false},
{"invalid option after startup file", "bash --rcfile /dev/fd/3 -i -Z 3<<'EOF'\ncat .env\nEOF", false},
{"consumed inherited input", "{ cat >/dev/null; sh; } <<'EOF'\ncat .env\nEOF", false},
{"function body", `f(){ cat .env; }; f`, false},
{"unset function body", `f(){ cat .env; }; unset -f f; f`, false},
{"conditional function body", `if true; then f(){ :; }; else f(){ cat .env; }; fi; f`, false},
} {
t.Run(test.name, func(t *testing.T) {
command, err := json.Marshal(test.command)
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"session_id":"s1","cwd":"/proj","tool_name":"Bash","tool_input":{"command":%s}}`, command)
out, errb, code := runCLIStdin(payload,
enforceHookArgs(t, "hook", "pre-tool", "--agent", "claude", "--enforce", "--no-builtin-rules", "--rules-dir", dir)...)
if code != 0 || errb != "" {
t.Fatalf("hook exit=%d stderr=%q", code, errb)
}
if test.deny {
if decodeDecision(t, out) != "deny" {
t.Fatalf("hook output=%q, want deny", out)
}
} else {
assertAllow(t, out, code)
}
})
}
}

func TestEnforceUsesCustomDenyMessage(t *testing.T) {
dir := writeEnforceRuleFile(t, customMessageEnforceRule)
out, _, code := runCLIStdin(catEnvPayload,
Expand Down
41 changes: 27 additions & 14 deletions docs/enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,33 @@ literal input and can include comments, quoted examples, or other text the
shell would not execute. Use the parsed `shell_commands` view when a deny
depends on executable command semantics.

Detection parses a broad set of shell structures. When a match is derived from
`shell_commands`, blocking uses a smaller static subset:

- POSIX shells: one simple command or one pipeline of simple commands
- PowerShell and `cmd.exe`: one simple command
- supported transparent launchers, only when the final child command meets the
same rules

Every projected command must have static arguments, assignments, and redirect
targets. Multiple statements, conditionals, loops, shell background syntax,
substitutions, same-script functions, inline child interpreters, `eval`,
`Invoke-Expression`, PowerShell or CMD pipelines, previews, parser diagnostics,
and truncated projections stay detection-only. This event-wide gate avoids
denying an action based on a command that may not execute.
For POSIX input, a candidate contains the projections for one shell statement,
or for all direct members of one `|` or `|&` pipeline. Each candidate must meet
the existing checks for static arguments, assignments, redirect targets,
wrappers, and previews. An unsafe pipeline also excludes its nested
substitutions. Malformed top-level input and truncated command lists stay
detection-only.

Statement lists, groups, subshells, background commands, negation, and
substitutions do not disable an otherwise eligible candidate. numbat does not
predict POSIX control flow: static commands in conditional branches and loop
bodies count as requested intent, as do both sides of `&&` and `||`. Function
calls and commands recovered from same-script function bodies remain
detection-only because shell state can replace or remove the definition before
the call.

Detection always evaluates the complete command list. For an `enforce: true`
rule that uses `shell_commands`, that evaluation must first match cleanly. When
the complete input is not enforcement-safe, numbat evaluates the same expression
against each eligible candidate to confirm that at least one candidate also
matches. Candidate evaluation can suppress a deny; it cannot create a finding
or change the rule's detection result. Other event fields retain their full
values. Rules without `shell_commands` keep their existing behavior.

Scripts parsed from `eval` or child interpreter input, including heredocs, remain
detection-only. Compound PowerShell and `cmd.exe` input also remain
detection-only. An independent eligible POSIX command can still enforce beside
an interpreter invocation.

numbat recognizes explicit `-WhatIf` and a statically visible
`$WhatIfPreference = $true` for known cmdlets. It does not infer ambient
Expand Down
19 changes: 7 additions & 12 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,16 +389,12 @@ visible `$WhatIfPreference = $true` for known cmdlet names and exact
module-qualified forms. Ambient preference and command-resolution state are not
inferred.

For a shell-derived match, blocking has a narrower eligibility boundary than
detection: the complete shell program must be one static simple command or one
static POSIX pipeline. Supported transparent launchers are allowed only when
their final child command is also in that subset. Multiple statements, control
flow, same-script functions, inline child interpreters, `eval` or
`Invoke-Expression`, substitutions, runtime-dependent values, PowerShell or CMD
pipelines, previews, parser diagnostics, and truncated projections remain
detection-only. A rule may still use `shell_commands` alongside structured
For shell-derived blocking, `numbat` first evaluates the rule against the
complete command list. An already-matching rule must then match an eligible
parser-derived candidate. This safety check cannot create a detection. Both
sides of `&&` and `||` are checked. A rule can still use `shell_commands` with
fields such as `event.file_path`; a matching commandless structured event does
not require a shell projection. See [Enforcement](enforcement.md).
not need a shell projection. See [Enforcement](enforcement.md).

## Enforcement rules

Expand All @@ -410,9 +406,8 @@ Enforcement uses the same CEL expressions as detection; there is no separate
rule language or required predicate shape. Raw `event.command` remains
available, but it matches literal input and can therefore match text that the
shell would not execute. Use `shell_commands` when blocking depends on parsed
command semantics. A shell-derived match can detect broad shell syntax, but a
deny requires the complete shell program to be inside the static subset
described above.
command semantics. A shell-derived deny requires one eligible parser-derived
candidate as described above.

All built-ins ship monitor-only. To enforce one, copy its complete YAML into an
operator directory, keep the same ID, set `enforce: true`, and bump the rule
Expand Down
2 changes: 1 addition & 1 deletion internal/rule/checked.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func BuildCheckedExpressions(sources []Source) (CheckedExpressions, error) {
if err != nil {
return nil, fmt.Errorf("rule %q: %w", r.ID, err)
}
if _, err := programExpr(env, ast); err != nil {
if _, err := programExpr(env, ast, false); err != nil {
return nil, fmt.Errorf("rule %q: %w", r.ID, err)
}
pb, err := cel.AstToCheckedExpr(ast)
Expand Down
1 change: 1 addition & 0 deletions internal/rule/checked_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func TestCheckedExpressionsPreserveEvaluation(t *testing.T) {
}
for _, event := range []model.Event{
{EventType: model.EventCommandExec, Command: "echo ready"},
{EventType: model.EventCommandExec, Command: "true; echo ready"},
{EventType: model.EventCommandExec, Command: "go test ./..."},
{EventType: model.EventFileRead, FilePath: "README.md"},
} {
Expand Down
Loading