316dc0ae0c6699c29f839018475024018f3b2164

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Simplify deterministic policy rules

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", () => {