316dc0ae0c6699c29f839018475024018f3b2164
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/src/core/deterministic.ts b/src/core/deterministic.ts
2index 3784f7ca4f7b745da8b6ef2fd1a977f4443b4b11..257422432e44234e867e5d8ebc4c5ddd8083cac2 100644
3--- a/src/core/deterministic.ts
4+++ b/src/core/deterministic.ts
5@@ -1,7 +1,27 @@
6 import type { Decision } from "./types";
7-import { ALLOW_PATTERNS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
8+import { ALLOW_COMMANDS, ASK_PATTERNS, SHELL_CONTROL_RE, STDERR_REDIRECT_RE, DEVNULL_REDIRECT_RE } from "./rules";
9 import { stripRtkPrefix, expandHome } from "./normalize";
10
11+const DESTRUCTIVE_RM_TARGETS = new Set(["/", "/*", "~", "~/*", "/etc", "/usr", "/var", "/home", "/root"]);
12+const SYSTEM_DIRECTORIES = [
13+ "/",
14+ "/bin",
15+ "/boot",
16+ "/dev",
17+ "/etc",
18+ "/lib",
19+ "/lib64",
20+ "/opt",
21+ "/proc",
22+ "/root",
23+ "/run",
24+ "/sbin",
25+ "/sys",
26+ "/usr",
27+ "/var",
28+];
29+const LOCAL_MUTATION_COMMANDS = new Set(["chmod", "chown", "cp", "ln", "mkdir", "mv", "rm", "rmdir", "tee", "touch"]);
30+
31 function hasShellControl(cmd: string): boolean {
32 return SHELL_CONTROL_RE.test(cmd);
33 }
34@@ -15,10 +35,20 @@ function shellWords(command: string): string[] {
35 });
36 }
37
38+function localPath(word: string): string {
39+ const path = word.replace(/^@/, "").replace(/^file:\/\//, "");
40+ return (path === "/" ? path : path.replace(/\/+$/, "")).replace(/[;,)]+$/, "");
41+}
42+
43 function isSecretPath(word: string): boolean {
44- const path = word.replace(/\/+$/, "");
45- if (/(?:^|\/)\.env[A-Za-z0-9._-]*(?:\/|$)/i.test(path)) return true;
46- if (/(?:^|\/)(?:credentials?|tokens?)(?:[._-][A-Za-z0-9_-]+)*(?:\/|$)/i.test(path)) return true;
47+ const path = localPath(word);
48+ if (
49+ /(?:^|\/)(?:\.env[A-Za-z0-9._-]*|(?:credential|token|secret|password|passwd|api[-_]?key)s?(?:[._-][A-Za-z0-9_-]+)*|id_(?:rsa|dsa|ecdsa|ed25519)|[^/]+\.(?:pem|key|p12|pfx))(?:\/|$)/i.test(
50+ path,
51+ )
52+ ) {
53+ return true;
54+ }
55
56 const home = process.env.HOME;
57 const homePrefix = home ? `(?:~|${home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})` : "~";
58@@ -57,94 +87,26 @@ function hasSecretOptionValue(arguments_: string[], optionNames: ReadonlySet<str
59 return false;
60 }
61
62+function hasOption(arguments_: string[], optionNames: ReadonlySet<string>): boolean {
63+ return arguments_.some((word) => optionNames.has(word) || optionNames.has(word.split("=", 1)[0]));
64+}
65+
66 function hasSecretPathOperand(command: string): boolean {
67 const [program, ...arguments_] = shellWords(command);
68 if (!program) return false;
69
70- const trustedLanguageToolchains = new Set([
71- "bun",
72- "bunx",
73- "node",
74- "npm",
75- "npx",
76- "pnpm",
77- "yarn",
78- "yarnpkg",
79- "deno",
80- "python",
81- "python3",
82- "pip",
83- "pip3",
84- "uv",
85- "poetry",
86- "go",
87- "cargo",
88- "rustc",
89- "mvn",
90- "gradle",
91- "./gradlew",
92- "java",
93- "javac",
94- "kotlinc",
95- "dotnet",
96- "ruby",
97- "bundle",
98- "rails",
99- "php",
100- "composer",
101- "elixir",
102- "mix",
103- "swift",
104- "scala",
105diff --git a/src/core/rules.ts b/src/core/rules.ts
106index dd0d4942f0c4e4323cf8ddab4cf1e17d03f95fcc..aad9bd762c4f227568189658a9881452595c7921 100644
107--- a/src/core/rules.ts
108+++ b/src/core/rules.ts
109@@ -1,7 +1,7 @@
110 import { DECISION_CATEGORIES } from "./types";
111
112 // Bump to invalidate all cached decisions when rules change.
113-export const POLICY_VERSION = 34;
114+export const POLICY_VERSION = 35;
115
116 // Trailing stderr redirections that are safe to strip before pattern matching.
117 // `2>&1` and `2>/dev/null` have no security implication but would otherwise
118@@ -12,150 +12,107 @@ export const STDERR_REDIRECT_RE = /\s+2>(?:&1|\/dev\/null)\s*$/;
119 // would otherwise trip SHELL_CONTROL_RE's `>` check.
120 export const DEVNULL_REDIRECT_RE = /\s+>\s*\/dev\/null\s*$/;
121
122-// Deterministic Bash allow patterns. Strict patterns are anchored; broader
123-// command prefixes remain guarded by SHELL_CONTROL_RE.
124-export const ALLOW_PATTERNS: RegExp[] = [
125- // Git read-only (no mutation, no network)
126- /^git\s+status(?:\s+--porcelain)?\s*$/,
127- /^git\s+diff(?:\s+(--staged|--cached))?\s*$/,
128- /^git\s+log(?:\s+--oneline)?(?:\s+-(?:\d{1,3})|\s+-n\s+\d{1,3})?\s*$/,
129- /^git\s+show(?:\s+(HEAD(?:[~^]\d+)?|[0-9a-f]{7,40}))?\s*$/,
130- /^git\s+branch(?:\s+(-a|--all|-vv|-l|--show-current))?\s*$/,
131- /^git\s+remote(?:\s+-v)?\s*$/,
132- /^git\s+rev-parse\s+(?:--abbrev-ref\s+HEAD|HEAD)\s*$/,
133- /^git\s+reflog(?:\s+-\d+)?\s*$/,
134- /^git\s+describe(?:\s+--tags)?\s*$/,
135- /^git\s+tag\s+-l\s*$/,
136- /^git\s+stash\s+list\s*$/,
137- /^git\s+stash\s+show\s+stash@\{\d+\}(?:\s+--stat)?\s*$/,
138-
139- // Read-only git subcommands with optional `-C <path>` (multi-repo workflows).
140- // Destructive subcommands (branch -d, tag -d, stash drop, fetch --force, etc.)
141- // are intentionally excluded from this broad prefix — they have narrow rules.
142- /^git(?:\s+-C\s+\S+)?\s+(status|diff|log|show|rev-parse|reflog|shortlog|blame|describe|ls-tree|cat-file|rev-list|ls-files|merge-base|grep)(?:\s|$)/,
143-
144- // `git apply --check` is a dry run — it never touches the working tree.
145- /^git\s+apply\s+--check(?:\s+--reverse)?\s+\S.*$/,
146-
147- // Filesystem + process inspection
148- /^pwd\s*$/,
149- /^whoami\s*$/,
150- /^date\s*$/,
151- // `break` / `continue` outside a loop are no-op shell builtins (warning, exit 1) — harmless.
152- /^break\s*$/,
153- /^continue\s*$/,
154- /^which\s+[A-Za-z0-9._-]+\s*$/,
155- /^whereis\s+[A-Za-z0-9._-]+\s*$/,
156- /^type\s+[A-Za-z0-9._-]+\s*$/,
157- /^command\s+-v\s+[A-Za-z0-9][A-Za-z0-9._+-]*(?:\s+[A-Za-z0-9][A-Za-z0-9._+-]*)*\s*$/,
158- // ls with relative or absolute paths (block hidden files, traversal).
159- // ls only prints filenames, no content leakage.
160- /^ls(?:\s+-[A-Za-z]+)*(?:\s+(?!.*\/\.)(?!.*\.\.)\/?[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
161- // cat with relative paths only
162- /^cat\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
163- // base64 with relative paths only
164- /^base64\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
165- // Bare base64 reads from an already-evaluated pipe.
166- /^base64(?:\s+(?:-d|--decode))?\s*$/,
167- // Bounded head/tail reads from stdin or guarded local paths.
168- /^(head|tail)(?:\s+-(?:n\s*)?\d{1,6})?(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
169- /^head\s+-c\s+\d{1,6}(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
170- /^tail\s+-n\s+\+\d{1,6}(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
171- /^nl\s+-ba(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)?\s*$/,
172- // `sed -n` with numeric print ranges only; no file writes or command execution.
173- /^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*$/,
174- // find read-only on relative paths
175- /^find\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*(?:\s+(?:-name|-iname)\s+["'][^"']+["']|\s+-type\s+(?:["']?[fdlbcps]["']?))*\s*$/,
176- // grep searches on guarded local paths.
177- /^grep(?:\s+-[A-Za-z]+)*\s+(?:["'][^"']+["']|[A-Za-z0-9._-]+)(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)*\s*$/,
178- // `cut -f` without a file operand reads stdin from an already-evaluated pipe.
179- /^cut\s+-f\s*\d[\d,-]*\s*$/,
180- /^cut\s+-d(?:'[^']*'|"[^"]*"|[^\s])\s+-f\s*\d[\d,-]*\s*$/,
181- /^tr\s+-d\s+(?:'[^']*'|"[^"]*"|[^\s])\s*$/,
182- /^lsof\s*$/,
183-
184- // Package metadata queries.
185- /^pacman\s+-(?:Q|Ql|Si)(?:\s+[A-Za-z0-9@._+:-]+)*\s*$/,
186- /^pacman\s+-Qo\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
187- /^rpm\s+-qa\s*$/,
188- /^dpkg-query\s+-W(?:\s+[A-Za-z0-9@._+:-]+)*\s*$/,
189-
190- // Local metadata and manifest inspection.
191- /^id\s+-(?:u|g)(?:\s+[A-Za-z0-9._-]+)?\s*$/,
192- /^date\s+\+[A-Za-z0-9%:_-]+\s*$/,
193- /^(?:file|readlink\s+-f|dirname|strings)\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
194- /^jar\s+tf\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*\s*$/,
195- /^getent\s+group(?:\s+[A-Za-z0-9._-]+)?\s*$/,
196-
197- /^systemctl\s+--user\s+is-(?:active|enabled)\s+[A-Za-z0-9@._-]+\s*$/,
198-
199- // gofmt -w on relative/project paths — formatting only, no logic change.
200- /^gofmt\s+-w(?:\s+(?!.*\/\.)(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*)+\s*$/,
201-
202- // No-op shell builtins.
203- /^exit(?:\s+\d+)?\s*$/,
204- /^sleep\s+[\d.]+\s*$/,
205-
206- // `-n` checks syntax only; it never executes the script.
207- /^(ba)?sh\s+-n(?:\s+\S+)*\s*$/,
208-
209diff --git a/test/core/deterministic.test.ts b/test/core/deterministic.test.ts
210index 4871132a0286d0f464d498d48971ba1f911175b7..a15f51e0ccb548840eb0d4dfa0c613139f3ddcc3 100644
211--- a/test/core/deterministic.test.ts
212+++ b/test/core/deterministic.test.ts
213@@ -20,6 +20,16 @@ test("keeps shell execution subject to confirmation", () => {
214 test("allows recognized deterministic command grammars", () => {
215 for (const command of [
216 "command -v bun node",
217+ "git add src/core/rules.ts",
218+ "git commit -m 'Allow deterministic Git changes'",
219+ "git commit -m 'sudo rm -rf /'",
220+ "cp README.md README.copy",
221+ "mv README.copy README.old",
222+ "rm README.old",
223+ "mkdir -p build/output",
224+ "touch build/output/result.txt",
225+ "chmod u+x build/output/result.txt",
226+ "tee build/output/summary.txt",
227 "bun test",
228 "bunx eslint src",
229 "npx tsc --noEmit",
230@@ -99,6 +109,12 @@ test("asks before allowing reads from recognized secret paths", () => {
231 "bun --env-file=.env run script.ts",
232 "node .env",
233 "npm --prefix .env test",
234+ "git add .env",
235+ "cp .env .env.backup",
236+ "touch .env",
237+ "rg -e token .env",
238+ "unknown-tool .env",
239+ "cat .env; pwd",
240 ]) {
241 expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "ask" });
242 }
243@@ -108,8 +124,19 @@ test("does not treat search terms as secret paths", () => {
244 expect(checkDeterministic("bash", { command: "rg '.env' README.md" })).toMatchObject({ decision: "allow" });
245 });
246
247-test("asks before stopping the Herdr server", () => {
248- expect(checkDeterministic("bash", { command: "herdr server stop" })).toMatchObject({ decision: "ask" });
249+test("asks before destructive operations", () => {
250+ for (const command of [
251+ "git reset --hard",
252+ "git checkout -- src/core/rules.ts",
253+ "git clean -fd",
254+ "rm -rf /",
255+ "rm -rf ~",
256+ "rm -rf $HOME",
257+ "touch /etc/policy-engine",
258+ "herdr server stop",
259+ ]) {
260+ expect(checkDeterministic("bash", { command })).toMatchObject({ decision: "ask" });
261+ }
262 });
263
264 test("does not deterministically allow non-toolchain mutation or shell-control variants", () => {