Diff
1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
2index 6f6616772b15e643860372e7b9fb0c7637c3faa7..3784f7ca4f7b745da8b6ef2fd1a977f4443b4b11 100644
3--- a/src/core/deterministic.ts
4+++ b/src/core/deterministic.ts
5@@ -1,33 +1,11 @@
6 import type { Decision } from "./types";
7-import {
8- HARD_ALLOW_PATTERNS,
9- CONFIG_ALLOW_PATTERNS,
10- ASK_PATTERNS,
11- SHELL_CONTROL_RE,
12- STDERR_REDIRECT_RE,
13- DEVNULL_REDIRECT_RE,
14-} from "./rules";
15+import { ALLOW_PATTERNS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
16 import { stripRtkPrefix, expandHome } from "./normalize";
17
18 function hasShellControl(cmd: string): boolean {
19 return SHELL_CONTROL_RE.test(cmd);
20 }
21
22-function checkHardAllow(command: string): Decision | undefined {
23- const cmd = command.trim();
24- if (!cmd || hasShellControl(cmd)) return undefined;
25- for (const pat of HARD_ALLOW_PATTERNS) {
26- if (pat.test(cmd)) {
27- return {
28- decision: "allow",
29- reason: `Matched safe pattern: ${pat.source.slice(0, 50)}`,
30- category: "read_only",
31- };
32- }
33- }
34- return undefined;
35-}
36-
37 function shellWords(command: string): string[] {
38 return (command.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+/g) ?? []).map((word) => {
39 if ((word.startsWith('"') && word.endsWith('"')) || (word.startsWith("'") && word.endsWith("'"))) {
40@@ -248,14 +226,14 @@ function checkAskPatterns(command: string): Decision | undefined {
41 return undefined;
42 }
43
44-function checkConfigAllow(command: string): Decision | undefined {
45+function checkAllow(command: string): Decision | undefined {
46 const cmd = command.trim();
47 if (!cmd || hasShellControl(cmd)) return undefined;
48- for (const pat of CONFIG_ALLOW_PATTERNS) {
49- if (pat.test(cmd)) {
50+ for (const pattern of ALLOW_PATTERNS) {
51+ if (pattern.test(cmd)) {
52 return {
53 decision: "allow",
54- reason: `Matched config pattern: ${pat.source.slice(0, 50)}`,
55+ reason: `Matched allow pattern: ${pattern.source.slice(0, 50)}`,
56 category: "config_allow",
57 };
58 }
59@@ -270,13 +248,7 @@ function checkBash(command: string): Decision | undefined {
60 // decisions apply to the proxied command. Expand `$HOME` so home-relative
61 // paths match the same way `~` paths do.
62 const cleaned = expandHome(stripRtkPrefix(command.replace(STDERR_REDIRECT_RE, "").replace(DEVNULL_REDIRECT_RE, "")));
63- return (
64- checkSecretPath(cleaned) ??
65- checkDocker(cleaned) ??
66- checkHardAllow(cleaned) ??
67- checkConfigAllow(cleaned) ??
68- checkAskPatterns(cleaned)
69- );
70+ return checkSecretPath(cleaned) ?? checkDocker(cleaned) ?? checkAllow(cleaned) ?? checkAskPatterns(cleaned);
71 }
72
73 export function checkDeterministic(toolType: string, input: Record<string, unknown>): Decision | undefined {
74diff --git a/src/core/rules.ts b/src/core/rules.ts
75index 922dc10b03bcd20de191938cf86f959bc2faf2b1..dd0d4942f0c4e4323cf8ddab4cf1e17d03f95fcc 100644
76--- a/src/core/rules.ts
77+++ b/src/core/rules.ts
78@@ -12,9 +12,9 @@ export const STDERR_REDIRECT_RE = /\s+2>(?:&1|\/dev\/null)\s*$/;
79 // would otherwise trip SHELL_CONTROL_RE's `>` check.
80 export const DEVNULL_REDIRECT_RE = /\s+>\s*\/dev\/null\s*$/;
81
82-// Matched with test() on anchored patterns (equivalent to Python fullmatch).
83-// Purely a performance optimization — these would pass LLM review anyway.
84-export const HARD_ALLOW_PATTERNS: RegExp[] = [
85+// Deterministic Bash allow patterns. Strict patterns are anchored; broader
86+// command prefixes remain guarded by SHELL_CONTROL_RE.
87+export const ALLOW_PATTERNS: RegExp[] = [
88 // Git read-only (no mutation, no network)
89 /^git\s+status(?:\s+--porcelain)?\s*$/,
90 /^git\s+diff(?:\s+(--staged|--cached))?\s*$/,
91@@ -66,14 +66,12 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
92 /^sed\s+-n\s+["']?\d{1,7}(?:,\d{1,7})?p(?:;\d{1,7}(?:,\d{1,7})?p)*["']?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
93 // find read-only on relative paths
94 /^find\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?:-name|-iname)\s+["'][^"']+["']|\s+-type\s+(?:["']?[fdlbcps]["']?))*\s*$/,
95- // grep and bare rg searches on guarded local paths.
96+ // grep searches on guarded local paths.
97 /^grep(?:\s+-[A-Za-z]+)*\s+(?:["'][^"']+["']|[A-Za-z0-9._-]+)(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
98- /^rg\s+(?:["'][^"']+["']|[A-Za-z0-9._:-]+)(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
99 // `cut -f` without a file operand reads stdin from an already-evaluated pipe.
100 /^cut\s+-f\s*\d[\d,-]*\s*$/,
101 /^cut\s+-d(?:'[^']*'|"[^"]*"|[^\s])\s+-f\s*\d[\d,-]*\s*$/,
102 /^tr\s+-d\s+(?:'[^']*'|"[^"]*"|[^\s])\s*$/,
103- /^ps\s*$/,
104 /^lsof\s*$/,
105
106 // Package metadata queries.
107@@ -81,8 +79,6 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
108 /^pacman\s+-Qo\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
109 /^rpm\s+-qa\s*$/,
110 /^dpkg-query\s+-W(?:\s+[A-Za-z0-9@._+:-]+)*\s*$/,
111- /^npm\s+ls(?:\s+(?:--(?:all|json|long|parseable|global)|--depth(?:=\d+|\s+\d+)|[A-Za-z0-9@._+/-]+))*\s*$/,
112- /^npm\s+view\s+[A-Za-z0-9@._+/-]+(?:\s+(?:version|versions|dist-tags|description|repository|license|engines|dependencies|peerDependencies|devDependencies))?(?:\s+--json)?\s*$/,
113
114 // Local metadata and manifest inspection.
115 /^id\s+-(?:u|g)(?:\s+[A-Za-z0-9._-]+)?\s*$/,
116@@ -91,18 +87,8 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
117 /^jar\s+tf\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
118 /^getent\s+group(?:\s+[A-Za-z0-9._-]+)?\s*$/,
119
120- // Docker query forms only.
121- /^docker\s+--version\s*$/,
122- /^docker\s+manifest\s+inspect(?:\s+--verbose)?\s+[A-Za-z0-9][A-Za-z0-9._/@:-]*\s*$/,
123- /^docker\s+compose\s+ps(?:\s+(?:-a|--all|-q|--quiet))?\s*$/,
124- /^docker\s+compose\s+logs(?:\s+(?:--tail\s+\d{1,6}|--timestamps))?\s*$/,
125- /^docker\s+(?:image\s+inspect|inspect)\s+[A-Za-z0-9][A-Za-z0-9._/@:-]*\s*$/,
126 /^systemctl\s+--user\s+is-(?:active|enabled)\s+[A-Za-z0-9@._-]+\s*$/,
127
128- // Version checks
129- /^(node|npm|pnpm|yarn|bun|python|python3|go|cargo|rustc|java|javac)\s+(--version|-v|-V)\s*$/,
130- /^uv\s+(--version|version)\s*$/,
131-
132 // gofmt -w on relative/project paths — formatting only, no logic change.
133 /^gofmt\s+-w(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)+\s*$/,
134
135@@ -112,11 +98,7 @@ export const HARD_ALLOW_PATTERNS: RegExp[] = [
136
137 // `-n` checks syntax only; it never executes the script.
138 /^(ba)?sh\s+-n(?:\s+\S+)*\s*$/,
139-];
140
141-// Broader prefix patterns remain guarded by SHELL_CONTROL_RE (no >, <, ${).
142-export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
143- // Tools without stricter HARD_ALLOW equivalents
144 /^echo\s/,
145 /^jq\s/,
146 /^sort(?:\s|$)/,
147@@ -135,10 +117,11 @@ export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
148 /^netstat\s/,
149 /^pgrep\s/,
150 /^pg_isready(?:\s|$)/,
151- /^ps\s/,
152+ /^ps(?:\s|$)/,
153
154- // Go toolchain
155+ // Go tooling
156 /^(?:TZ=\S+\s+)?go\s+(doc|env|get|list|mod|test|vet)(?:\s|$)/,
157+ /^golangci-lint(?:\s|$)/,
158
159 // Make targets
160 /^(?:TZ=\S+\s+)?make\s+\S*check(?:\s|$)/,
161@@ -146,6 +129,7 @@ export const CONFIG_ALLOW_PATTERNS: RegExp[] = [
162
163 // grep with recursion/include-exclude globs across relative project paths.
164 /^grep\s+(?:-[A-Za-z]+\s+)+(?:"[^"]*"|'[^']*'|\S+)(?:\s+(?:--(?:include|exclude)=\S+|(?!\/)(?!\.\.)[A-Za-z0-9*][A-Za-z0-9._*/-]*))*\s*$/,
165+ /^rg(?:\s|$)/,
166
167 // gh pr diff is read-only.
168 /^gh\s+pr\s+diff\s+\d+(?:\s+--repo\s+\S+)?(?:\s+--\s+.+)?\s*$/,
169diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
170index f0af442c05c9dea36c10a31759898a6f859e61a8..4871132a0286d0f464d498d48971ba1f911175b7 100644
171--- a/test/core/deterministic.test.ts
172+++ b/test/core/deterministic.test.ts
173@@ -17,7 +17,7 @@ test("keeps shell execution subject to confirmation", () => {
174 expect(checkDeterministic("bash", { command: "bash script.sh -n" })).toMatchObject({ decision: "ask" });
175 });
176
177-test("allows recognized read-only command grammars", () => {
178+test("allows recognized deterministic command grammars", () => {
179 for (const command of [
180 "command -v bun node",
181 "bun test",
182@@ -52,6 +52,9 @@ test("allows recognized read-only command grammars", () => {
183 "base64 --decode",
184 "tr -d '\\n'",
185 "cut -d: -f1,3",
186+ "golangci-lint run --fix",
187+ "rg --hidden --glob '*.ts' policy src",
188+ "rg --files --hidden",
189 ]) {
190 expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "allow" });
191 }