You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: add allow_flags whitelist and rename decision constants for clarity
Rename NoOpinion->Prompt and DenyFlags->PromptFlags so names match actual
behavior, add AllowFlags whitelist to prompt on any unrecognized flag,
and add gofmt/gofumpt/goimports rules using the new whitelist.
Copy file name to clipboardExpand all lines: AGENTS.md
+7-7Lines changed: 7 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -33,28 +33,28 @@ stdin (hook JSON, Crush or Claude Code format)
33
33
|`internal/bash`| Tree-sitter–based bash parser. Extracts `Command` structs from a bash AST. Detects "complex" constructs (command substitution `$()`, subshells `()`, process substitution `<()`, arithmetic `$(())`) and parse errors, both will not auto-allow. |
34
34
|`internal/checker`| Orchestrates parse + rule check. Tracks `cd` to ensure the working directory stays within `RootDir`. Resolves `~/...` paths against `HomeDir`. Any `cd` that would escape the root causes a deny. |
35
35
|`internal/config`| Layered config loading. Reads global (`$XDG_CONFIG_HOME/crushout/crushout.{yml,yaml}`) and repo (`<rootDir>/.crushout.{yml,yaml}`) config, deep-merges both over built-in defaults. Repo config wins over global. Supports `overwrite_defaults: true` per-layer to replace the accumulated base entirely. |
36
-
|`internal/rules`| Recursive rule engine (`Rule` struct with `Subcommands`, `DenyFlags`, `Default`). `defaults.go` contains the full built-in ruleset mapping command names to their allow/deny rules. |
36
+
|`internal/rules`| Recursive rule engine (`Rule` struct with `Subcommands`, `PromptFlags`, `AllowFlags`, `Default`). `defaults.go` contains the full built-in ruleset mapping command names to their allow/deny rules. |
37
37
38
38
## Key Design Decisions
39
39
40
-
-**Fail-closed**: anything ambiguous or unknown falls through to the normal permission prompt. Parse errors, complex bash constructs, unknown commands, commands with `$` in the name, and missing rules all produce a "no opinion" / "ask" result.
41
-
-**Three outcomes**: `allow` (auto-approve), `deny` (hard-block with reason), and the default "no opinion" (fall through to normal permission flow). The built-in rules never deny; deny rules come from `.crushout.yml` config only.
40
+
-**Fail-closed**: anything ambiguous or unknown falls through to the normal permission prompt. Parse errors, complex bash constructs, unknown commands, commands with `$` in the name, and missing rules all produce a `prompt` result.
41
+
-**Three outcomes**: `allow` (auto-approve), `deny` (hard-block with reason), and `prompt` (fall through to normal permission flow). The built-in rules never deny; deny rules come from `.crushout.yml` config only.
42
42
-**"Complex" bash is rejected outright**: command substitution, process substitution, subshells, and arithmetic expansion set `IsComplex=true` and skip command extraction entirely.
43
43
-**Layered config**: crushout reads two config layers merged in order — global (`$XDG_CONFIG_HOME/crushout/crushout.{yml,yaml}`) then repo (`<rootDir>/.crushout.{yml,yaml}`). Each layer is deep-merged over the accumulated base, with later layers winning. `overwrite_defaults: true` in a layer replaces the accumulated base entirely (so a global `overwrite_defaults: true` drops built-ins that no later layer can recover). Scalar fields like `rtk_rewrite` use `*bool` internally so an unset value is distinguishable from an explicit `false`.
44
44
-**`cd` tracking**: the checker tracks the current working directory across `cd` commands in a chain. `cd` with `$VAR`, `~` (if home is outside root), `-`, or any path resolving outside `RootDir` denies the whole command.
45
-
-**Output convention**: returning a protocol-specific "no opinion" payload (`{}` for Crush, `ask` for Claude Code) means fall through to normal prompt. `allow` auto-approves. `deny` hard-blocks with a reason string.
45
+
-**Output convention**: returning a protocol-specific `prompt` payload (`{}` for Crush, `ask` for Claude Code) means fall through to normal prompt. `allow` auto-approves. `deny` hard-blocks with a reason string.
46
46
47
47
## Testing Patterns
48
48
49
49
- Standard `testing` package only, no assertion libraries. Tests define local helpers (`assertNoError`, `assertCommand`).
50
50
-`checker_test.go` uses `newTestChecker()` with `RootDir: "/home/user/project"` and `HomeDir: "/home/user"`.
- Rule tests in `rule_test.go` construct minimal `Rule` trees to test subcommand resolution, deny flags, and nesting, then also test the full `Default` ruleset.
52
+
- Rule tests in `rule_test.go` construct minimal `Rule` trees to test subcommand resolution, `PromptFlags`, `AllowFlags`, and nesting, then also test the full `Default` ruleset.
53
53
- E2E tests in `tests/e2e/` run the full binary against JSONL test cases (`cases_crush.jsonl`, `cases_claude.jsonl`) via `run.sh`.
54
54
- The `cmd/crushout` and `internal/hook` packages have no unit tests.
55
55
56
56
## Gotchas
57
57
58
-
- The tree-sitter bash grammar treats `git -C /tmp status` with `-C` as an anonymous (un-named) child node. This means `-C` does **not** appear in `cmd.Args`. The deny works via `DenyFlags` on the rule which checks the raw args, but the tree-sitter parse won't include it in the structured args. This is why `checker.isReadOnly` uses `filepath.Base` on names with `/` and rejects names containing `$` or backticks.
59
-
-`Rule.resolve` walks args as a subcommand chain. The first arg matching a subcommand key descends into that sub-rule, consuming the arg. Remaining args are then checked against `DenyFlags` at the new level. This means flag position matters: `git -C /tmp status` has `-C` checked at the top-level git rule, but `git branch -l` descends into the `branch` sub-rule.
58
+
- The tree-sitter bash grammar treats `git -C /tmp status` with `-C` as an anonymous (un-named) child node. This means `-C` does **not** appear in `cmd.Args`. The deny works via `PromptFlags` on the rule which checks the raw args, but the tree-sitter parse won't include it in the structured args. This is why `checker.isReadOnly` uses `filepath.Base` on names with `/` and rejects names containing `$` or backticks.
59
+
-`Rule.resolve` walks args as a subcommand chain. The first arg matching a subcommand key descends into that sub-rule, consuming the arg. Remaining args are then checked against `PromptFlags` and `AllowFlags` at the new level. This means flag position matters: `git -C /tmp status` has `-C` checked at the top-level git rule, but `git branch -l` descends into the `branch` sub-rule.
60
60
-`cd` with no arguments (bare `cd`) resolves to `$HOME`. It's only allowed if `HomeDir` is within `RootDir`.
0 commit comments