@scopebond/hook 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/map.js CHANGED
@@ -5,72 +5,729 @@
5
5
  // A shell tool call can carry several commands (`a && b`, `$(c)`, `bash -c '…'`);
6
6
  // the mapper decomposes it and returns one intent per simple command, so the
7
7
  // runtime evaluates every one and denies if any is out of policy. Non-shell tools
8
- // map to a single intent (a one-element array).
8
+ // map to a single intent (a one-element array), or one per spelling of an
9
+ // ambiguous Windows short path.
9
10
  import { digest, redactCommand, scrubParam, scrubSecrets } from "./minimize.js";
10
- import { decomposeShell, parseGitPush } from "./shell.js";
11
+ import { canonProgram, decomposeShell, gitArgs, parseGitPush } from "./shell.js";
11
12
  const basename = (t) => t.replace(/^.*[\\/]/, "");
12
- // Normalize Windows backslash separators to "/" so a path guard written with "/"
13
- // (the hook's own .scopebond/**, .claude/settings*, *.key rules) cannot be bypassed
14
- // on Windows by a backslash path. The taxonomy path space is "/"-separated.
15
- const normPath = (s) => s.replace(/\\/g, "/");
13
+ // Canonicalize a path before any guard sees it:
14
+ // - Windows backslash separators become "/" (the taxonomy path space), so a guard
15
+ // written with "/" cannot be bypassed by `.scopebond\agent.key`;
16
+ // - NTFS alternate-stream suffixes (`.env::$DATA`) and the trailing dots/spaces
17
+ // Windows silently drops from a name (`.env.`, `agent.key `) are removed, so they
18
+ // cannot disguise the file they open;
19
+ // - `//` and `/./` collapse.
20
+ // Case is preserved (it is evidence); the starter policy matches case-insensitively.
21
+ // Linear (no regex backtracking on long runs of dots/spaces).
22
+ const trimDotsSpaces = (seg) => {
23
+ let end = seg.length;
24
+ while (end > 0 && (seg[end - 1] === "." || seg[end - 1] === " "))
25
+ end--;
26
+ return end === 0 ? seg : seg.slice(0, end);
27
+ };
28
+ const normPath = (s) => s.replace(/\\/g, "/")
29
+ .replace(/::?\$data$/i, "")
30
+ .split("/").map((seg) => (seg === "." || seg === ".." ? seg : trimDotsSpaces(seg))).join("/")
31
+ .replace(/\/(?:\.\/)+/g, "/").replace(/\/{2,}/g, "/");
16
32
  const rel = (value, cwd) => {
17
33
  const s = normPath(String(value ?? ""));
18
34
  const c = cwd ? normPath(cwd) : undefined;
19
- if (c && s.startsWith(c))
20
- return s.slice(c.length).replace(/^\/+/, "");
35
+ if (c && s.toLowerCase().startsWith(c.toLowerCase() + "/"))
36
+ return s.slice(c.length + 1);
37
+ if (c && s.toLowerCase() === c.toLowerCase())
38
+ return ".";
21
39
  return s;
22
40
  };
23
- // Shell programs that READ a file given as an operand. A read of the signing keys
24
- // or a secret file through the shell (`cat .scopebond/attester.key`) must reach the
25
- // same file.read guard as the Read tool, or the cooperative protection is a fiction.
41
+ // Windows 8.3 short names: `CLAUDE~1` opens `.claude`, `SCOPEB~1` opens `.scopebond`,
42
+ // `SETTIN~1.JSO` opens `settings.json`. A short segment that could abbreviate a
43
+ // protected name is expanded to every protected name it could stand for, and the
44
+ // path is checked in each spelling. Only the names below are expanded; any other
45
+ // short name is left as written.
46
+ const SHORT_SEGMENT = /^([^.~\s/]{1,6})~\d{1,6}(?:\.([^.\s/]{1,3}))?$/;
47
+ const SHORT_TARGETS = [
48
+ ".scopebond", ".claude", ".cursor", ".codex", ".github", ".git", ".husky", ".circleci", ".gitlab-ci.yml", ".gitlab-ci.yaml",
49
+ ".mcp.json", "azure-pipelines.yml", "Jenkinsfile", "workflows", "actions", "settings.json", "settings.local.json", "hooks.json",
50
+ "config.toml", "agents", ".ssh", ".aws", ".kube", ".docker", ".azure", ".gnupg", ".config", ".env", ".env.local", ".env.production",
51
+ ".envrc", ".npmrc", ".pypirc", ".netrc", ".git-credentials", "credentials", ".credentials.json", "id_ed25519", "config.json",
52
+ "accessTokens.json", "credentials.db", "attester.key", "policy.json", "cloud.json",
53
+ ];
54
+ function abbreviates(prefix, ext, long) {
55
+ const name = long.replace(/^\.+/, "");
56
+ const dot = name.lastIndexOf(".");
57
+ const base = (dot > 0 ? name.slice(0, dot) : name).replace(/[.\s]/g, "").toUpperCase();
58
+ const longExt = dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
59
+ return base.length > 0 && base.startsWith(prefix.toUpperCase()) && longExt.slice(0, 3) === ext.toUpperCase()
60
+ && (base.length > 8 || long.startsWith(".") || longExt.length > 3 || name.includes(" "));
61
+ }
62
+ /** Every spelling a path could have with its short segments expanded (at most 16). */
63
+ function pathVariants(path) {
64
+ if (!path.includes("~"))
65
+ return [path];
66
+ let variants = [[]];
67
+ for (const seg of path.split("/")) {
68
+ const m = SHORT_SEGMENT.exec(seg);
69
+ const long = m ? SHORT_TARGETS.filter((t) => abbreviates(m[1], m[2] ?? "", t)) : [];
70
+ const choices = long.length ? long : [seg];
71
+ const next = [];
72
+ for (const v of variants)
73
+ for (const c of choices)
74
+ if (next.length < 16)
75
+ next.push([...v, c]);
76
+ variants = next;
77
+ }
78
+ return [...new Set(variants.map((v) => v.join("/")))];
79
+ }
80
+ /** One file intent per spelling of `path` (normally one). */
81
+ function pathIntents(action, path, source, extra = {}) {
82
+ return pathVariants(path).map((p) => ({ intent: { action_type: action, params: { path: p, ...extra } }, evaluated: true, source }));
83
+ }
84
+ // Locations the starter policy protects, as concrete sample paths (files, and the
85
+ // protected directories themselves). An operand the mapper cannot resolve
86
+ // statically — a glob (`.scope*/agent.key`, `.*/*`), a variable (`$D/agent.key`), a
87
+ // brace list or a command substitution — is tested against these: if it could name
88
+ // one, the intent carries that protected path so the guard sees it (the literal
89
+ // operand is kept as `pattern` for the record). Otherwise the literal is used. This
90
+ // keeps shell expansion from hiding a protected file from the path guard.
91
+ const PROTECTED_SAMPLES = [
92
+ ".scopebond/agent.key", ".scopebond/attester.key", ".scopebond/policy.json", ".scopebond/cloud.json",
93
+ "agent.key", "attester.key", "server.key", "cert.pem", "id.p12", "id.pfx",
94
+ ".env", ".env.local", ".env.production", ".envrc",
95
+ ".ssh/id_rsa", ".ssh/id_ed25519", ".aws/credentials", ".npmrc", ".pypirc", ".netrc", ".git-credentials",
96
+ ".kube/config", ".docker/config.json", ".config/gcloud/credentials.db", ".azure/accessTokens.json",
97
+ ".config/gh/hosts.yml", ".claude/.credentials.json",
98
+ ".claude/settings.json", ".claude/settings.local.json", ".claude/hooks/hook.sh", ".claude/agents/agent.md",
99
+ ".cursor/hooks.json", ".codex/hooks.json", ".codex/config.toml", ".mcp.json",
100
+ ".git/hooks/pre-commit", ".git/config", ".husky/pre-commit", ".github/workflows/ci.yml", ".github/actions/a/action.yml",
101
+ ".gitlab-ci.yml", ".gitlab-ci.yaml", ".circleci/config.yml", "azure-pipelines.yml", "Jenkinsfile",
102
+ // the protected directories themselves (a recursive reader or copier given the directory)
103
+ ".scopebond", ".ssh", ".aws", ".kube", ".docker", ".azure", ".gnupg", ".config/gcloud", ".config/gh",
104
+ ];
105
+ const UNRESOLVED = /[*?[\]{}$`]/;
106
+ const EXPANSION = /\$\{[^}]*\}|\$\([^)]*\)|`[^`]*`|\$[A-Za-z0-9_]+|[*?[\]{}]/g;
107
+ /** Convert a shell word with globs/variables/braces into an anchored matcher. Glob
108
+ * metacharacters stay within one path segment; a variable or substitution may span
109
+ * segments. Like the shell, a segment wildcard does not match a leading dot. Linear:
110
+ * no nested quantifiers are generated. */
111
+ function wordMatcher(word) {
112
+ let re = "";
113
+ let segStart = true;
114
+ for (let i = 0; i < word.length; i++) {
115
+ const c = word[i];
116
+ const leadingDot = segStart ? "(?!\\.)" : "";
117
+ if (c === "*")
118
+ re += leadingDot + "[^/]*";
119
+ else if (c === "?")
120
+ re += leadingDot + "[^/]";
121
+ else if (c === "[") {
122
+ const end = word.indexOf("]", i + 1);
123
+ re += leadingDot + "[^/]";
124
+ if (end > i)
125
+ i = end;
126
+ }
127
+ else if (c === "{") {
128
+ const end = word.indexOf("}", i + 1);
129
+ re += "[^/]*";
130
+ if (end > i)
131
+ i = end;
132
+ }
133
+ else if (c === "$" || c === "`") {
134
+ // $VAR, ${VAR…}, $(…) or `…`: any text, possibly several segments.
135
+ if (word[i + 1] === "{" || word[i + 1] === "(") {
136
+ const close = word[i + 1] === "{" ? "}" : ")";
137
+ const end = word.indexOf(close, i + 2);
138
+ if (end > i)
139
+ i = end;
140
+ }
141
+ else if (c === "`") {
142
+ const end = word.indexOf("`", i + 1);
143
+ if (end > i)
144
+ i = end;
145
+ }
146
+ else
147
+ while (i + 1 < word.length && /[A-Za-z0-9_]/.test(word[i + 1]))
148
+ i++;
149
+ re += ".*";
150
+ }
151
+ else
152
+ re += c.replace(/[.+^()|\\]/g, "\\$&");
153
+ segStart = c === "/";
154
+ }
155
+ return new RegExp("^(?:" + re + ")$", "i");
156
+ }
157
+ // The protected sample an unresolvable operand could name, if any. The operand's
158
+ // last N segments are compared with each N-segment sample, so any prefix — `x/../`,
159
+ // `$HOME/`, an absolute path — cannot move the tail out of view. A tail made only of
160
+ // wildcards or variables (`*`, the last segment of `src/*`, `$FILE`) names nothing and
161
+ // is not compared — `cat logs/*` is not a read of `agent.key` — but a tail with any
162
+ // literal text, even a lone dot, is: `.*/*` can reach `.scopebond/agent.key`.
163
+ const MAX_WORD = 4096;
164
+ function protectedCandidate(word) {
165
+ // An absurdly long unresolvable operand is not worth analysing: treat it as if it
166
+ // could name the hook's own files (fail closed) rather than scan it.
167
+ if (word.length > MAX_WORD)
168
+ return PROTECTED_SAMPLES[0];
169
+ const segs = word.split("/");
170
+ const cache = new Map();
171
+ for (const sample of PROTECTED_SAMPLES) {
172
+ const n = sample.split("/").length;
173
+ if (segs.length < n)
174
+ continue;
175
+ let m = cache.get(n);
176
+ if (m === undefined) {
177
+ const tail = segs.slice(-n).join("/");
178
+ m = /[^/]/.test(tail.replace(EXPANSION, "")) ? wordMatcher(tail) : null;
179
+ cache.set(n, m);
180
+ }
181
+ if (m && m.test(sample))
182
+ return sample;
183
+ }
184
+ return undefined;
185
+ }
186
+ /** The file intents for one path operand, resolving it against a `cd` prefix. */
187
+ function fileIntent(action, word, dir, cwd) {
188
+ let w = word;
189
+ if (dir && !/^(?:[\\/]|~|[A-Za-z]:|\$)/.test(w))
190
+ w = dir.replace(/\/+$/, "") + "/" + w;
191
+ const path = rel(scrubParam(w), cwd);
192
+ if (UNRESOLVED.test(w)) {
193
+ const hit = protectedCandidate(normPath(w));
194
+ if (hit)
195
+ return pathIntents(action, hit, "shell", { pattern: path });
196
+ }
197
+ return pathIntents(action, path, "shell");
198
+ }
199
+ // Shell programs whose operands are files whose CONTENT they output, copy or send. A
200
+ // read of the signing keys or a secret file through the shell (`cat .scopebond/attester.key`,
201
+ // `grep -r . .scopebond`) must reach the same file.read guard as the Read tool.
202
+ // Metadata-only programs (`ls`, `stat`, `test`, `[`, `file`) are not readers.
26
203
  const READERS = new Set([
27
- "cat", "tac", "less", "more", "head", "tail", "nl", "od", "xxd", "hexdump",
28
- "strings", "base64", "bat", "type", "get-content", "gc",
204
+ "cat", "tac", "less", "more", "head", "tail", "nl", "od", "xxd", "hexdump", "strings", "base64", "base32",
205
+ "bat", "type", "get-content", "gc", "grep", "egrep", "fgrep", "rg", "ag", "ack", "awk", "gawk", "mawk", "nawk", "sort",
206
+ "uniq", "cut", "paste", "diff", "cmp", "comm", "wc", "jq", "yq", "sed", "iconv", "openssl", "gpg", "md5sum",
207
+ "sha1sum", "sha256sum", "sha512sum", "shasum", "source", ".", "select-string", "sls",
208
+ "import-csv", "tar", "bsdtar", "zip", "7z", "7za", "7zz", "gzip", "bzip2", "xz", "zstd", "zcat", "view", "code",
209
+ ]);
210
+ // Editors read their operands and can write them (`vim -es -c ':wq' f`, `ed f`).
211
+ const EDITORS = new Set(["vi", "vim", "nvim", "ex", "ed", "nano", "pico", "emacs", "micro", "joe", "kak", "hx", "helix", "mcedit"]);
212
+ // Programs that copy or move: sources are read, the destination is written; `mv`
213
+ // also removes its sources, so they are writes too.
214
+ const COPIERS = new Set(["cp", "mv", "rsync", "scp", "install", "ln", "copy-item", "cpi", "copy", "move-item", "mi", "move", "robocopy", "xcopy", "rename-item", "ren"]);
215
+ const MOVERS = new Set(["mv", "move-item", "mi", "move", "rename-item", "ren"]);
216
+ // Programs whose operands are files they WRITE (or whose metadata they change).
217
+ const WRITERS = new Set(["tee", "touch", "truncate", "set-content", "sc", "add-content", "ac", "out-file", "new-item", "ni", "clear-content", "clc", "chmod", "chown", "chattr", "attrib", "icacls", "set-acl", "shred", "unlink", "sponge"]);
218
+ // Programs that send what they are given elsewhere (network, clipboard, mail): an
219
+ // operand naming a secret location is a read of it.
220
+ const UPLOADERS = new Set([
221
+ "curl", "wget", "http", "https", "xh", "httpie", "nc", "ncat", "netcat", "socat", "telnet", "ftp", "sftp", "lftp", "smbclient",
222
+ "aws", "gsutil", "gcloud", "az", "rclone", "s3cmd", "gh", "glab", "mail", "mailx", "sendmail", "mutt", "xclip", "xsel",
223
+ "pbcopy", "wl-copy", "clip", "set-clipboard", "croc", "wormhole",
29
224
  ]);
30
- /** Derive the additional file.read / file.write intents a simple shell command
31
- * implies: operands of a reader program, and the targets of `>`/`>>` redirections.
32
- * These flow through the same protect-read / protect-write clauses as the native
33
- * file tools. A false positive (a non-protected operand) is a harmless extra
34
- * receipt that the starter policy allows. */
35
- function fileOpsFromShell(sc, cwd) {
225
+ // Flags whose value is a file the program reads (`curl -T f`, `curl -d @f`) or writes (`-o f`).
226
+ // Read flags apply to readers, copiers and uploaders only (`test -f .env` reads nothing).
227
+ const READ_FLAGS = new Set(["-t", "--upload-file", "-k", "--config", "--input-file", "--post-file", "--body-file", "-in", "-inkey", "-key", "-infile", "--key", "--cert", "-f", "--file", "-literalpath", "-path", "-filepath", "-inputobject"]);
228
+ const WRITE_FLAGS = new Set(["-o", "--output", "-out", "--output-document", "-destination", "-outfile", "--target-directory"]);
229
+ // curl/wget request-body and form flags carry inline data, not a filename — a quoted
230
+ // JSON body must not be read as a glob over a protected path. Their value names a file
231
+ // only through `@`: `-d @.env`, `-F upload=@secret`, `--data-urlencode key@file`.
232
+ const CURL_DATA = new Set(["-d", "--data", "--data-ascii", "--data-binary", "--data-raw", "--json", "--post-data", "--body-data"]);
233
+ const CURL_URLENCODE = "--data-urlencode";
234
+ const CURL_FORM = new Set(["-F", "--form", "--form-string"]);
235
+ /** The file a curl/wget body/form value points at with `@`, or undefined for inline data. */
236
+ function curlFileRef(flag, value) {
237
+ if (value.startsWith("@"))
238
+ return value.slice(1).split(";")[0];
239
+ if (flag === CURL_URLENCODE) {
240
+ const m = /^[^=@]*@(.+)$/.exec(value);
241
+ if (m)
242
+ return m[1];
243
+ }
244
+ if (CURL_FORM.has(flag)) {
245
+ const m = /=@([^;]+)/.exec(value);
246
+ if (m)
247
+ return m[1];
248
+ }
249
+ return undefined;
250
+ }
251
+ // Names an uploader's operand, or `git add`'s, is checked against: a secret path sent
252
+ // or staged (`gh gist create .env`, `git add .env`) is a read of that path.
253
+ const SENSITIVE = /(?:^|[/\\])(?:\.scopebond(?:[/\\]|$)|\.env(?:\.[^/\\]*)?$|\.envrc$|\.ssh(?:[/\\]|$)|\.aws(?:[/\\]|$)|\.npmrc$|\.pypirc$|_?\.?netrc$|\.git-credentials$|\.kube(?:[/\\]|$)|\.docker(?:[/\\]|$)|\.azure(?:[/\\]|$)|\.gnupg(?:[/\\]|$)|\.config[/\\](?:gcloud|gh)(?:[/\\]|$)|\.credentials\.json$)|\.(?:key|pem|p12|pfx|jks|keystore)$/i;
254
+ const INTERPRETERS = new Set(["node", "deno", "bun", "python", "python3", "py", "ruby", "perl", "php", "osascript", "lua", "tclsh"]);
255
+ // Path literals inside inline code. Each alternative starts at a boundary (not after
256
+ // a word character — so `process.env` is not `.env`) and has no overlapping repeats,
257
+ // keeping the scan linear; input is capped per argument.
258
+ const SENSITIVE_IN_CODE = /(?<![\w$])(?:\.scopebond[\\/][\w.-]*|\.env(?:\.[\w-]+)?(?![\w.-])|\.envrc|\.ssh[\\/][\w.-]+|\.aws[\\/]credentials|\.claude[\\/]settings[\w.-]*|\.cursor[\\/]hooks\.json|\.codex[\\/](?:hooks\.json|config\.toml)|\.git[\\/](?:hooks[\\/][\w.-]*|config)|\.github[\\/](?:workflows|actions)[\\/][\w./-]*|\.npmrc|\.git-credentials)|(?<![\w.-])[\w-]+\.(?:key|pem|p12|pfx)(?![\w])/gi;
259
+ // A file or process API in the same inline snippet, required in call or member form
260
+ // (not a bare English word — "your .env file" must not read like `File`). Without one,
261
+ // a protected path in a string literal opens nothing. Covers Node (fs/child_process/
262
+ // streams), Python (open, os, subprocess, pathlib, shutil), Ruby/PHP (File/IO/fopen/
263
+ // file_get_contents) and PowerShell (Get-/Set-Content, Out-File, Invoke-*).
264
+ const FILE_API = /\b(?:open|fopen|readlink|read_file|readfile|read_to_string|readfilesync|writefile|writefilesync|appendfile|appendfilesync|createreadstream|createwritestream|openfile|opensync|copyfile|copyfilesync|rename|renamesync|unlink|unlinksync|popen|spawn|spawnsync|exec|execsync|execfile|execfilesync|system|shell_exec|proc_open|file_get_contents|file_put_contents|urlopen)\s*\(|\b(?:fs|io|os|subprocess|child_process|pathlib|shutil|File|Dir|IO|Pathname|Path|FileUtils)\s*\.\s*\w|\bimport\s+(?:os|subprocess|shutil|pathlib|io)\b|\brequire\s*\(\s*['"`](?:node:)?(?:fs|child_process)|\b(?:Get-Content|Set-Content|Out-File|Add-Content|Import-Csv|Invoke-\w+)\b/i;
265
+ const CODE_SCAN_LIMIT = 20000;
266
+ const GREP = { short: "efmABCdD", long: ["--regexp", "--file", "--max-count", "--after-context", "--before-context", "--context", "--include", "--exclude", "--exclude-dir", "--exclude-from", "--directories", "--devices", "--label", "--binary-files", "--group-separator"], read: ["-f", "--file", "--exclude-from"] };
267
+ const AWK = { short: "fvF", long: ["--file", "--assign", "--field-separator", "--include", "--load"], read: ["-f", "--file"] };
268
+ const VIM = { short: "cSuiTwWtq", long: ["--cmd"], read: ["-S", "-u"], write: ["-w", "-W"] };
269
+ const OPTIONS = new Map([
270
+ ["grep", GREP], ["egrep", GREP], ["fgrep", GREP],
271
+ ["rg", { short: "efgtTmABCjMErd", long: ["--regexp", "--file", "--glob", "--iglob", "--type", "--type-not", "--type-add", "--type-clear", "--max-count", "--after-context", "--before-context", "--context", "--threads", "--max-columns", "--encoding", "--replace", "--max-depth", "--max-filesize", "--pre", "--pre-glob", "--ignore-file", "--sort", "--sortr", "--colors", "--path-separator", "--context-separator", "--engine"], read: ["-f", "--file", "--ignore-file"] }],
272
+ ["ag", { short: "ABCGgmp", long: ["--ignore", "--ignore-dir", "--file-search-regex", "--depth", "--pager", "--after", "--before", "--context", "--max-count", "--path-to-ignore"], read: ["-p", "--path-to-ignore"] }],
273
+ ["ack", { short: "ABCm", long: ["--type", "--ignore-dir", "--ignore-file", "--match", "--max-count", "--output", "--after-context", "--before-context", "--context"] }],
274
+ ["awk", AWK], ["gawk", AWK], ["mawk", AWK], ["nawk", AWK],
275
+ ["jq", { short: "fL", long: ["--from-file", "--indent"], read: ["-f", "--from-file"] }],
276
+ ["yq", { short: "Iop", long: ["--indent", "--output-format", "--input-format", "--expression", "--front-matter"] }],
277
+ ["head", { short: "nc", long: ["--lines", "--bytes"] }],
278
+ ["tail", { short: "nc", long: ["--lines", "--bytes", "--pid", "--sleep-interval", "--max-unchanged-stats"] }],
279
+ ["sort", { short: "ktoST", long: ["--key", "--field-separator", "--output", "--buffer-size", "--temporary-directory", "--files0-from", "--batch-size", "--parallel"], read: ["--files0-from"] }],
280
+ ["cut", { short: "bcdf", long: ["--bytes", "--characters", "--delimiter", "--fields", "--output-delimiter"] }],
281
+ ["od", { short: "AjNStw" }], ["xxd", { short: "cglos" }], ["hexdump", { short: "efns", read: ["-f"] }],
282
+ ["base64", { short: "w" }], ["base32", { short: "w" }],
283
+ ["diff", { short: "CUFILXx", long: ["--label", "--ignore-matching-lines", "--show-function-line", "--exclude", "--exclude-from", "--from-file", "--to-file", "--horizon-lines"], read: ["-X", "--exclude-from", "--from-file", "--to-file"] }],
284
+ ["nl", { short: "bdfhilnsvw" }],
285
+ ["strings", { short: "nte", long: ["--bytes", "--radix", "--encoding", "--output-separator"] }],
286
+ ["tar", { short: "fCTXbHKLNVg", long: ["--file", "--directory", "--files-from", "--exclude-from", "--exclude", "--transform", "--owner", "--group", "--mode", "--mtime", "--newer", "--after-date", "--label", "--format", "--blocking-factor"], read: ["-T", "-X", "--files-from", "--exclude-from"] }],
287
+ ["unzip", { short: "dxP" }],
288
+ ["sed", { short: "efl", optional: "i", long: ["--expression", "--file", "--line-length"], read: ["-f", "--file"] }],
289
+ ["vi", VIM], ["vim", VIM], ["nvim", VIM], ["view", VIM], ["ex", VIM],
290
+ ["ed", { short: "p" }],
291
+ ["gpg", { short: "orRu", long: ["--output", "--recipient", "--local-user", "--homedir", "--default-key"] }],
292
+ ]);
293
+ // Programs whose first operand is a pattern or program text, not a file — unless the
294
+ // pattern came from an option (`grep -e p`, `awk -f prog`, `jq -f filter`).
295
+ const PATTERN_FIRST = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack", "awk", "gawk", "mawk", "nawk", "jq", "yq", "select-string", "sls"]);
296
+ const PATTERN_OPTIONS = new Set(["-e", "-f", "--regexp", "--file", "--from-file", "--expression"]);
297
+ /** If `t` is an option that takes a value under `spec`, its name and any attached value. */
298
+ function valuedOption(spec, t) {
299
+ if (!spec)
300
+ return undefined;
301
+ if (t.startsWith("--"))
302
+ return spec.long?.includes(t) ? { name: t } : undefined;
303
+ for (let k = 1; k < t.length; k++) {
304
+ if (spec.optional?.includes(t[k]))
305
+ return undefined;
306
+ if (spec.short?.includes(t[k]))
307
+ return { name: "-" + t[k], attached: t.slice(k + 1) || undefined };
308
+ }
309
+ return undefined;
310
+ }
311
+ // `git config` keys whose value runs a program, redirects a push or reroutes a remote:
312
+ // setting one is a write to the protected git config.
313
+ const DANGEROUS_GIT_KEY = /^(?:core\.(?:hookspath|fsmonitor|sshcommand|pager|editor|askpass|gitproxy)|alias\..+|include\.path|includeif\..+|remote\..+\.(?:push|pushurl|url|receivepack|uploadpack)|url\..+\.(?:insteadof|pushinsteadof)|push\.default|remote\.pushdefault|credential\..*helper|filter\..+\.(?:clean|smudge|process)|diff\..+\.(?:textconv|command)|merge\..+\.driver|sequence\.editor|gpg\.(?:.+\.)?program)$/i;
314
+ /** The file a `git config …` invocation writes, or undefined when it only reads (or
315
+ * sets a harmless key). `--file F` writes F for any key; otherwise setting a
316
+ * dangerous key (or `--edit`) writes the git config (`--global` included, recorded as
317
+ * `.git/config` so the protected-write guard applies). */
318
+ function gitConfigWrite(args) {
319
+ let file;
320
+ let edit = false;
321
+ let mutate = false;
322
+ let query = false;
323
+ const positional = [];
324
+ const valued = new Set(["-f", "--file", "--blob", "--type", "--default", "--comment", "--value"]);
325
+ for (let i = 0; i < args.length; i++) {
326
+ const a = args[i];
327
+ if (a === "-f" || a === "--file") {
328
+ file = args[++i];
329
+ continue;
330
+ }
331
+ if (a.startsWith("--file=")) {
332
+ file = a.slice(7);
333
+ continue;
334
+ }
335
+ if (valued.has(a)) {
336
+ i++;
337
+ continue;
338
+ }
339
+ if (a === "-e" || a === "--edit") {
340
+ edit = true;
341
+ continue;
342
+ }
343
+ if (/^--(?:add|replace-all|unset|unset-all|rename-section|remove-section)$/.test(a)) {
344
+ mutate = true;
345
+ continue;
346
+ }
347
+ if (/^(?:--get|--get-all|--get-regexp|--get-urlmatch|-l|--list|--get-color|--get-colorbool)$/.test(a)) {
348
+ query = true;
349
+ continue;
350
+ }
351
+ if (a.startsWith("-"))
352
+ continue;
353
+ positional.push(a);
354
+ }
355
+ const verb = positional[0]?.toLowerCase();
356
+ if (verb === "edit")
357
+ edit = true;
358
+ else if (verb === "set" || verb === "unset" || verb === "rename-section" || verb === "remove-section") {
359
+ mutate = true;
360
+ positional.shift();
361
+ }
362
+ else if (verb === "get" || verb === "list")
363
+ query = true;
364
+ const key = positional[0] ?? "";
365
+ const writes = edit || (!query && (mutate || positional.length >= 2));
366
+ if (!writes)
367
+ return undefined;
368
+ if (file !== undefined)
369
+ return file;
370
+ const section = /^(?:alias|core|include|includeif|remote|url|filter|diff|merge|credential|push|gpg|sequence)(?:\.|$)/i;
371
+ return edit || DANGEROUS_GIT_KEY.test(key) || (mutate && positional.some((p) => section.test(p))) ? ".git/config" : undefined;
372
+ }
373
+ /** Split git subcommand arguments into positionals, honoring `--` and skipping the
374
+ * values of the given options. */
375
+ function gitPositionals(args, valued = []) {
376
+ const before = [];
377
+ for (let i = 0; i < args.length; i++) {
378
+ const a = args[i];
379
+ if (a === "--")
380
+ return { before, after: args.slice(i + 1) };
381
+ if (valued.includes(a)) {
382
+ i++;
383
+ continue;
384
+ }
385
+ if (a.startsWith("-"))
386
+ continue;
387
+ before.push(a);
388
+ }
389
+ return { before, after: null };
390
+ }
391
+ const isPathWord = (t) => t !== "" && !/^\d+$/.test(t) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(t);
392
+ const isSensitiveOperand = (w) => SENSITIVE.test(w) || (UNRESOLVED.test(w) && protectedCandidate(normPath(w)) !== undefined);
393
+ /** Derive the file.read / file.write intents a simple shell command implies —
394
+ * operands of reader, copier, writer and editor programs, flag values that name
395
+ * files, redirection targets, the sensitive operands of uploaders and `git add`, and
396
+ * the paths git writes (`checkout -- p`, `restore`, `mv`, `rm`, `config`) — resolved
397
+ * against the directory an earlier `cd` in the same call moved to. These flow through
398
+ * the same protect-read / protect-write clauses as the native file tools; a false
399
+ * positive (a non-protected operand) is a harmless extra receipt the starter policy
400
+ * allows. `unknownTarget` is set when the command writes files its text does not
401
+ * name (`patch`, `git apply`, `tar x`, `unzip`). */
402
+ function fileOpsFromShell(sc, dir, cwd) {
36
403
  const ops = [];
37
- if (READERS.has(sc.program.toLowerCase())) {
38
- for (const t of sc.argv) {
39
- if (t.startsWith("-") || /^\d+$/.test(t))
40
- continue; // skip flags and flag values (head -n 5)
41
- ops.push({ intent: { action_type: "file.read", params: { path: rel(scrubParam(t), cwd) } }, evaluated: true, source: "shell" });
404
+ let unknownTarget = false;
405
+ const prog = canonProgram(sc.program);
406
+ const read = (w) => { if (w !== undefined && isPathWord(w))
407
+ ops.push(...fileIntent("file.read", w, dir, cwd)); };
408
+ const write = (w) => { if (w !== undefined && isPathWord(w))
409
+ ops.push(...fileIntent("file.write", w, dir, cwd)); };
410
+ const args = sc.argv;
411
+ const spec = OPTIONS.get(prog);
412
+ const flagFiles = READERS.has(prog) || EDITORS.has(prog) || COPIERS.has(prog) || UPLOADERS.has(prog);
413
+ // Operands (non-flag words) and flag values (`--flag=value`, `-T value`, `@file`, `if=f`).
414
+ const operands = [];
415
+ const given = new Set();
416
+ const flagValue = (name, value) => {
417
+ const lower = name.toLowerCase();
418
+ given.add(name);
419
+ if (spec?.write?.includes(name) || WRITE_FLAGS.has(lower))
420
+ write(value);
421
+ else if (spec?.read?.includes(name) || (flagFiles && READ_FLAGS.has(lower)))
422
+ read(value.replace(/^@/, ""));
423
+ else if (value.startsWith("@"))
424
+ read(value.slice(1));
425
+ };
426
+ for (let i = 0; i < args.length; i++) {
427
+ const t = args[i];
428
+ if (t === "--") {
429
+ operands.push(...args.slice(i + 1));
430
+ break;
431
+ }
432
+ const lower = t.toLowerCase();
433
+ const eq = t.indexOf("=");
434
+ if (prog === "curl" || prog === "wget") {
435
+ const flag = eq > 0 && t.startsWith("--") ? t.slice(0, eq) : t;
436
+ if (CURL_DATA.has(flag) || CURL_FORM.has(flag) || flag === CURL_URLENCODE) {
437
+ let value;
438
+ if (eq > 0 && t.startsWith("--"))
439
+ value = t.slice(eq + 1);
440
+ else
441
+ value = args[++i];
442
+ given.add(flag);
443
+ if (value !== undefined) {
444
+ const f = curlFileRef(flag, value);
445
+ if (f !== undefined)
446
+ read(f);
447
+ }
448
+ continue;
449
+ }
450
+ }
451
+ if (t.startsWith("-") && eq > 0) { // --flag=value
452
+ const flag = t.slice(0, eq);
453
+ const value = t.slice(eq + 1);
454
+ given.add(flag);
455
+ if (spec?.write?.includes(flag) || WRITE_FLAGS.has(flag.toLowerCase()))
456
+ write(value.replace(/^@/, ""));
457
+ else if (spec?.read?.includes(flag) || (flagFiles && READ_FLAGS.has(flag.toLowerCase())) || value.startsWith("@") || (UPLOADERS.has(prog) && SENSITIVE.test(value)))
458
+ read(value.replace(/^@/, ""));
459
+ continue;
460
+ }
461
+ if (t.startsWith("-") && t.length > 1) {
462
+ // jq's two-valued options: `--arg name value`, `--slurpfile name file`.
463
+ if (prog === "jq" && /^--(?:arg|argjson|slurpfile|rawfile)$/.test(t)) {
464
+ if (/file$/.test(t))
465
+ read(args[i + 2]);
466
+ i += 2;
467
+ continue;
468
+ }
469
+ const opt = valuedOption(spec, t);
470
+ if (opt) {
471
+ let value = opt.attached;
472
+ if (value === undefined && i + 1 < args.length)
473
+ value = args[++i];
474
+ if (value !== undefined)
475
+ flagValue(opt.name, value);
476
+ continue;
477
+ }
478
+ const value = args[i + 1];
479
+ if (value !== undefined && !value.startsWith("-")) {
480
+ if (COPIERS.has(prog) && t === "-t") {
481
+ write(value);
482
+ i++;
483
+ continue;
484
+ } // cp -t DIR
485
+ if (WRITERS.has(prog) && /^-(?:path|literalpath|filepath)$/.test(lower)) {
486
+ write(value);
487
+ i++;
488
+ continue;
489
+ } // Set-Content -Path f
490
+ if (WRITE_FLAGS.has(lower)) {
491
+ write(value);
492
+ i++;
493
+ continue;
494
+ }
495
+ if (flagFiles && READ_FLAGS.has(lower)) {
496
+ read(value.replace(/^@/, ""));
497
+ i++;
498
+ continue;
499
+ }
500
+ if (value.startsWith("@")) {
501
+ read(value.slice(1));
502
+ i++;
503
+ continue;
504
+ }
505
+ }
506
+ continue;
507
+ }
508
+ if (prog === "dd" && /^(?:if|of)=/.test(t)) {
509
+ (t.startsWith("of=") ? write : read)(t.slice(3));
510
+ continue;
511
+ }
512
+ if (t.startsWith("@")) {
513
+ read(t.slice(1));
514
+ continue;
42
515
  }
516
+ operands.push(t);
517
+ }
518
+ if (PATTERN_FIRST.has(prog) && ![...given].some((g) => PATTERN_OPTIONS.has(g)) && !(prog === "yq" && operands.length === 1))
519
+ operands.shift();
520
+ const git = gitArgs(sc);
521
+ const inPlace = (a) => a === "--inplace" || a.startsWith("--in-place") || /^-[a-hj-z]*i/.test(a);
522
+ if (git) {
523
+ const sub = git.sub?.toLowerCase();
524
+ const a = git.args;
525
+ if (sub === "add") {
526
+ // Staging a secret is one commit away from publishing it.
527
+ const { before, after } = gitPositionals(a, ["--pathspec-from-file"]);
528
+ [...before, ...(after ?? [])].filter(isSensitiveOperand).forEach(read);
529
+ }
530
+ else if (sub === "checkout") {
531
+ const { before, after } = gitPositionals(a, ["-b", "-B", "--orphan", "--conflict", "--pathspec-from-file"]);
532
+ (after ?? before).forEach(write);
533
+ }
534
+ else if (sub === "restore") {
535
+ const { before, after } = gitPositionals(a, ["-s", "--source", "--pathspec-from-file"]);
536
+ [...before, ...(after ?? [])].forEach(write);
537
+ }
538
+ else if (sub === "mv") {
539
+ const { before, after } = gitPositionals(a);
540
+ const all = [...before, ...(after ?? [])];
541
+ all.slice(0, -1).forEach(read);
542
+ all.forEach(write);
543
+ }
544
+ else if (sub === "rm" && !a.includes("--cached")) {
545
+ const { before, after } = gitPositionals(a, ["--pathspec-from-file"]);
546
+ [...before, ...(after ?? [])].forEach(write);
547
+ }
548
+ else if (sub === "config")
549
+ write(gitConfigWrite(a));
550
+ else if (sub === "show" || sub === "cat-file") {
551
+ // `git show REV:path` prints that file's content from history.
552
+ for (const w of a) {
553
+ const m = /^(?:[^-:][^:]*)?:(?:\d:)?(.+)$/.exec(w);
554
+ if (m && !/^[a-z][a-z0-9+.-]*:\/\//i.test(w))
555
+ read(m[1]);
556
+ }
557
+ }
558
+ else if (sub === "apply" || sub === "am")
559
+ unknownTarget = true;
560
+ }
561
+ else if (prog === "find") {
562
+ // `find DIR … -exec cat {} +` reads DIR's files; `-fprint F` writes F.
563
+ const starts = [];
564
+ for (const w of args) {
565
+ if (/^[-(!]/.test(w) || w === "\\(")
566
+ break;
567
+ starts.push(w);
568
+ }
569
+ if (args.some((w) => /^-(?:exec|execdir|ok|okdir)$/.test(w)))
570
+ starts.forEach(read);
571
+ args.forEach((w, k) => { if (/^-(?:fprint0?|fprintf|fls)$/.test(w))
572
+ write(args[k + 1]); });
573
+ }
574
+ else if ((prog === "docker" || prog === "podman" || prog === "kubectl") && operands[0] === "cp") {
575
+ const local = (p) => p !== undefined && (!/^[^/\\]+:/.test(p) || /^[A-Za-z]:[\\/]/.test(p));
576
+ if (local(operands[1]))
577
+ read(operands[1]);
578
+ if (local(operands[2]))
579
+ write(operands[2]);
580
+ }
581
+ else if (prog === "patch") {
582
+ // The file a patch changes is named inside the patch; an explicit operand is written.
583
+ unknownTarget = true;
584
+ write(operands[0]);
43
585
  }
44
- for (let i = 0; i < sc.argv.length; i++) {
45
- // `> file`, `>>file`, `2>file`, `&>file` — the token after the redirection op is a write target.
46
- const m = /^(?:\d|&)?(>>?)(.*)$/.exec(sc.argv[i]);
47
- if (!m)
586
+ else if ((prog === "tar" || prog === "bsdtar") && (args.some((w) => w === "--extract" || w === "--get" || /^-[A-Za-z]*x/.test(w)) || /^[A-Za-z]*x[A-Za-z]*$/.test(args[0] ?? ""))) {
587
+ unknownTarget = true;
588
+ args.forEach((w, k) => { if (w === "-C" || w === "--directory")
589
+ write(args[k + 1]);
590
+ else if (w.startsWith("--directory="))
591
+ write(w.slice(12)); });
592
+ operands.forEach(read);
593
+ }
594
+ else if (prog === "unzip" && !args.some((w) => /^-[ltvZpcz]$/.test(w))) {
595
+ unknownTarget = true;
596
+ args.forEach((w, k) => { if (w === "-d")
597
+ write(args[k + 1]); });
598
+ read(operands[0]);
599
+ }
600
+ else if (/^7z[arz]?$/.test(prog) && /^[xe]$/.test(operands[0] ?? "")) {
601
+ unknownTarget = true;
602
+ args.forEach((w) => { if (/^-o./.test(w))
603
+ write(w.slice(2)); });
604
+ read(operands[1]);
605
+ }
606
+ else if (prog === "cpio" && args.some((w) => w === "--extract" || /^-[A-Za-z]*i/.test(w))) {
607
+ unknownTarget = true;
608
+ }
609
+ else if (prog === "gpg" && args.some((w) => /^--export-secret-(?:sub)?keys$/.test(w))) {
610
+ read(".gnupg/private-keys-v1.d");
611
+ }
612
+ else if (READERS.has(prog)) {
613
+ operands.forEach(read);
614
+ if (prog === "yq" && args.some(inPlace))
615
+ operands.forEach(write);
616
+ // `sed -i 's/x/y/' f…` rewrites its operands in place; the first operand is the
617
+ // script unless `-e`/`-f` supplied one.
618
+ if (prog === "sed" && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
619
+ const scriptGiven = args.some((a) => a === "-e" || a === "-f" || a.startsWith("--expression")) || given.has("-e") || given.has("-f");
620
+ (scriptGiven ? operands : operands.slice(1)).forEach(write);
621
+ }
622
+ }
623
+ else if (EDITORS.has(prog)) {
624
+ operands.forEach(read);
625
+ operands.forEach(write);
626
+ }
627
+ else if (COPIERS.has(prog) && operands.length) {
628
+ const hasTarget = args.some((a) => /^(?:-t|--target-directory(?:=.*)?|-destination)$/i.test(a) || /^--target-directory=/.test(a));
629
+ const sources = hasTarget ? operands : operands.slice(0, -1);
630
+ sources.forEach(read);
631
+ if (MOVERS.has(prog))
632
+ sources.forEach(write);
633
+ if (!hasTarget)
634
+ write(operands[operands.length - 1]);
635
+ }
636
+ else if (WRITERS.has(prog)) {
637
+ // A permission/owner spec is not a path: `chmod +x f`, `chmod 0755 f`,
638
+ // `chattr +i f`, `chown root:wheel f`, `attrib +r f` write f, not "+x".
639
+ let targets = operands;
640
+ if (prog === "chmod" || prog === "chattr" || prog === "attrib")
641
+ targets = targets.filter((o) => !/^[+\-=][rwxstugoa+-]*$|^[ugoa]+[+\-=][rwxXst]*$|^[0-7]{3,4}$/i.test(o));
642
+ else if (prog === "chown" || prog === "chgrp")
643
+ targets = targets.slice(1);
644
+ targets.forEach(write);
645
+ }
646
+ else if ((prog === "perl" || prog === "ruby") && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) {
647
+ // `perl -i -pe 's/x/y/' f…`: the first operand is the script (unless -e/-f gave it).
648
+ const scriptGiven = args.some((a) => a === "-e" || a === "-f" || a.startsWith("--expression")) || given.has("-e") || given.has("-f");
649
+ (scriptGiven ? operands : operands.slice(1)).forEach(write);
650
+ }
651
+ else if (INTERPRETERS.has(prog)) {
652
+ // Inline code (`node -e "…"`, `python -c "…"`): best effort — a protected-looking
653
+ // path literal is recorded as a read and a write only when the same code also calls
654
+ // a file or process API, so a path merely named in a log string is not a finding.
655
+ // Code that assembles a path at run time is beyond a cooperative hook (use a gateway).
656
+ for (const w of args) {
657
+ const code = w.slice(0, CODE_SCAN_LIMIT);
658
+ if (!FILE_API.test(code))
659
+ continue;
660
+ for (const m of code.match(SENSITIVE_IN_CODE) ?? []) {
661
+ read(m);
662
+ write(m);
663
+ }
664
+ }
665
+ }
666
+ else if (UPLOADERS.has(prog)) {
667
+ operands.filter(isSensitiveOperand).forEach(read);
668
+ }
669
+ for (const r of sc.redirects) {
670
+ if (r.op.startsWith(">"))
671
+ write(r.target);
672
+ else if (r.op === "<>") {
673
+ read(r.target);
674
+ write(r.target);
675
+ }
676
+ else
677
+ read(r.target);
678
+ }
679
+ return { ops, unknownTarget };
680
+ }
681
+ // The hook's own CLI run by the agent to switch itself off or re-scope itself
682
+ // (`npx @scopebond/hook uninstall`, `scopebond trust`, `scopebond-hook init --force`).
683
+ const SELF_SUBCOMMANDS = new Set(["uninstall", "trust", "init", "install", "connect"]);
684
+ const isHookCli = (w) => /^scopebond(?:-hook)?(?:\.js)?$/.test(canonProgram(w)) || /^@scopebond\/hook(?:@[^/\s]*)?$/i.test(w) || /@scopebond[\\/]hook[\\/]dist[\\/]cli\.js$/i.test(w);
685
+ function selfDisable(sc) {
686
+ const all = [sc.programRaw, ...sc.argv];
687
+ for (let k = 0; k < all.length; k++) {
688
+ if (!isHookCli(all[k]))
48
689
  continue;
49
- const target = m[2] || sc.argv[i + 1];
50
- if (target && !target.startsWith("-") && !/^(?:\d|&)?>>?/.test(target))
51
- ops.push({ intent: { action_type: "file.write", params: { path: rel(scrubParam(target), cwd) } }, evaluated: true, source: "shell" });
690
+ const sub = all.slice(k + 1).find((a) => !a.startsWith("-"));
691
+ if (sub && SELF_SUBCOMMANDS.has(sub.toLowerCase()))
692
+ return true;
52
693
  }
53
- return ops;
694
+ return false;
54
695
  }
55
696
  /** Map one parsed simple command to the intents it implies: the git.push or
56
- * shell.exec itself, plus any file reads/writes it performs. An opaque
57
- * (unparseable) command becomes a single un-evaluated shell.exec so it fails closed. */
58
- function mapSimpleCommand(sc, cwd) {
697
+ * shell.exec itself, plus any file reads/writes it performs. An opaque command
698
+ * (unparseable, or a program known only at run time) becomes a shell.exec with an
699
+ * empty program, evaluated, which the starter policy denies. */
700
+ function mapSimpleCommand(sc, dir, cwd) {
59
701
  if (sc.opaque) {
60
702
  return [{
61
703
  intent: { action_type: "shell.exec", params: { command: redactCommand(sc.raw), program: "", ...(cwd ? { cwd } : {}) } },
62
- evaluated: false, source: "shell",
704
+ evaluated: true, source: "shell",
63
705
  }];
64
706
  }
65
707
  const push = parseGitPush(sc);
66
708
  if (push) {
67
- const params = { force: push.force };
68
- if (push.remote !== undefined)
69
- params.remote = scrubParam(push.remote);
70
- if (push.ref !== undefined)
71
- params.ref = scrubParam(push.ref);
72
- return [{ intent: { action_type: "git.push", params }, evaluated: true, source: "shell" }];
709
+ // One intent per pushed destination, so a protected branch cannot ride behind
710
+ // an allowed one (`git push origin feature main`).
711
+ return push.targets.map((t) => {
712
+ const params = { force: t.force };
713
+ if (push.remote !== undefined)
714
+ params.remote = scrubParam(push.remote);
715
+ if (t.ref !== undefined)
716
+ params.ref = scrubParam(t.ref);
717
+ if (t.del)
718
+ params.delete = true;
719
+ if (t.all)
720
+ params.all = true;
721
+ return { intent: { action_type: "git.push", params }, evaluated: true, source: "shell" };
722
+ });
73
723
  }
724
+ const { ops: files, unknownTarget } = fileOpsFromShell(sc, dir, cwd);
725
+ // The hook disabling itself is a write to its own policy, which the starter protects.
726
+ if (selfDisable(sc))
727
+ files.push(...pathIntents("file.write", ".scopebond/policy.json", "shell"));
728
+ // A bare redirection (`> file`) runs no program: only its file effect is recorded.
729
+ if (!sc.program)
730
+ return files;
74
731
  const exec = {
75
732
  intent: {
76
733
  action_type: "shell.exec",
@@ -81,16 +738,61 @@ function mapSimpleCommand(sc, cwd) {
81
738
  },
82
739
  evaluated: true, source: "shell",
83
740
  };
84
- return [exec, ...fileOpsFromShell(sc, cwd)];
741
+ // A command that writes files its text does not name (`patch`, `git apply`, `tar x`)
742
+ // cannot be judged here: its writes are recorded as a file.write with no path, not
743
+ // evaluated — observed in normal mode, denied by strict mode (like an apply_patch
744
+ // with no recognizable path).
745
+ if (unknownTarget)
746
+ files.push({ intent: { action_type: "file.write", params: { path: "" } }, evaluated: false, source: "shell" });
747
+ return [exec, ...files];
85
748
  }
86
- /** Decompose a shell command into the intents it will run. An empty command yields
87
- * a single un-evaluated placeholder (nothing to grant). */
88
- function mapShell(command, cwd) {
89
- const commands = decomposeShell(command);
749
+ const CD = new Set(["cd", "pushd", "chdir", "set-location", "sl"]);
750
+ /** Decompose a shell command into the intents it will run. A `cd` earlier in the same
751
+ * call changes the directory later relative operands resolve against
752
+ * (`cd .scopebond && cp x policy.json` writes `.scopebond/policy.json`). An empty
753
+ * command yields a single un-evaluated placeholder (nothing to grant). */
754
+ function mapShell(command, cwd, dialect = "posix") {
755
+ // PowerShell: `\` is a path separator and the backtick is the escape character
756
+ // (``Re`move-Item`` runs Remove-Item). Normalize both before the POSIX parser sees
757
+ // them, so neither hides a program or mangles a Windows path.
758
+ const src = dialect === "powershell" ? command.replace(/`(.)/g, "$1").replace(/\\/g, "/") : command;
759
+ const commands = decomposeShell(src);
90
760
  if (commands.length === 0) {
91
761
  return [{ intent: { action_type: "shell.exec", params: { command: redactCommand(command), program: "" } }, evaluated: false, source: "shell" }];
92
762
  }
93
- return commands.flatMap((sc) => mapSimpleCommand(sc, cwd));
763
+ const walk = (list, filesOnly) => {
764
+ let dir = "";
765
+ const out = [];
766
+ for (const sc of list) {
767
+ const mapped = mapSimpleCommand(sc, dir, cwd);
768
+ out.push(...(filesOnly ? mapped.filter((m) => m.intent.action_type.startsWith("file.")) : mapped));
769
+ if (!sc.opaque && CD.has(canonProgram(sc.program))) {
770
+ const target = sc.argv.find((a) => !a.startsWith("-"));
771
+ if (target === undefined || target === "~")
772
+ dir = "";
773
+ else if (/^(?:[\\/]|~[\\/]|[A-Za-z]:)/.test(target))
774
+ dir = normPath(target);
775
+ else
776
+ dir = normPath((dir ? dir + "/" : "") + target);
777
+ }
778
+ }
779
+ return out;
780
+ };
781
+ const out = walk(commands, false);
782
+ // A "Bash" command may really run under PowerShell or cmd (Codex and Cursor on
783
+ // Windows), where `\` separates paths instead of escaping. Read it both ways for
784
+ // file effects, so `.scopebond\policy.json` is not lost as `.scopebondpolicy.json`.
785
+ if (dialect === "posix" && src.includes("\\")) {
786
+ const seen = new Set(out.map((m) => `${m.intent.action_type} ${String(m.intent.params.path)}`));
787
+ for (const m of walk(decomposeShell(src.replace(/\\/g, "/")), true)) {
788
+ const key = `${m.intent.action_type} ${String(m.intent.params.path)}`;
789
+ if (!seen.has(key)) {
790
+ seen.add(key);
791
+ out.push(m);
792
+ }
793
+ }
794
+ }
795
+ return out;
94
796
  }
95
797
  function parseMcpName(name) {
96
798
  if (!name.startsWith("mcp__"))
@@ -131,11 +833,7 @@ function mapApplyPatch(command, cwd) {
131
833
  if (paths.length === 0) {
132
834
  return one({ action_type: "file.write", params: { path: "" } }, false, "apply_patch");
133
835
  }
134
- return paths.map((path) => ({
135
- intent: { action_type: "file.write", params: { path } },
136
- evaluated: true,
137
- source: "apply_patch",
138
- }));
836
+ return paths.flatMap((path) => pathIntents("file.write", path, "apply_patch"));
139
837
  }
140
838
  const one = (intent, evaluated, source) => [{ intent, evaluated, source }];
141
839
  /** A bare `git push` (no ref) pushes the current branch. The mapper cannot know it,
@@ -158,14 +856,16 @@ export function mapClaudeToolUse(input) {
158
856
  // some hosts/agents expose PowerShell or a generic Shell tool — mapping only Bash
159
857
  // let a PowerShell command (e.g. `Remove-Item -Recurse -Force .`) fall through to
160
858
  // an un-evaluated tool.<name> and be allowed.
161
- if (name === "Bash" || name === "PowerShell" || name === "Shell")
859
+ if (name === "Bash" || name === "Shell")
162
860
  return mapShell(String(ti.command ?? ""), cwd);
861
+ if (name === "PowerShell")
862
+ return mapShell(String(ti.command ?? ""), cwd, "powershell");
163
863
  if (name === "Write" || name === "Edit" || name === "MultiEdit")
164
- return one({ action_type: "file.write", params: { path: rel(ti.file_path, cwd) } }, true, name);
864
+ return pathIntents("file.write", rel(ti.file_path, cwd), name);
165
865
  if (name === "NotebookEdit")
166
- return one({ action_type: "file.write", params: { path: rel(ti.notebook_path ?? ti.file_path, cwd) } }, true, name);
866
+ return pathIntents("file.write", rel(ti.notebook_path ?? ti.file_path, cwd), name);
167
867
  if (name === "Read")
168
- return one({ action_type: "file.read", params: { path: rel(ti.file_path, cwd) } }, true, name);
868
+ return pathIntents("file.read", rel(ti.file_path, cwd), name);
169
869
  if (name === "WebFetch") {
170
870
  const { host, path } = splitUrl(String(ti.url ?? ""));
171
871
  return one({ action_type: "net.fetch", params: { host, path, method: "GET" } }, true, name);
@@ -182,7 +882,9 @@ export function mapCodexToolUse(input) {
182
882
  const name = String(input?.tool_name ?? "");
183
883
  const ti = (input?.tool_input ?? {});
184
884
  const cwd = input?.cwd ? String(input.cwd) : undefined;
185
- if (name === "Bash" || name === "PowerShell" || name === "Shell" || name === "exec_command" || name === "unified_exec")
885
+ if (name === "PowerShell")
886
+ return mapShell(String(ti.command ?? ti.cmd ?? ""), cwd, "powershell");
887
+ if (name === "Bash" || name === "Shell" || name === "exec_command" || name === "unified_exec")
186
888
  return mapShell(String(ti.command ?? ti.cmd ?? ""), cwd);
187
889
  if (name === "apply_patch" || name === "Edit" || name === "Write")
188
890
  return mapApplyPatch(String(ti.command ?? ti.patch ?? ""), cwd);
@@ -199,9 +901,9 @@ export function mapCursorEvent(event, payload) {
199
901
  case "beforeShellExecution":
200
902
  return mapShell(String(p.command ?? ""), cwd);
201
903
  case "beforeReadFile":
202
- return one({ action_type: "file.read", params: { path: rel(p.path ?? p.file_path, cwd) } }, true, event);
904
+ return pathIntents("file.read", rel(p.path ?? p.file_path, cwd), event);
203
905
  case "afterFileEdit":
204
- return one({ action_type: "file.write", params: { path: rel(p.path ?? p.file_path, cwd) } }, true, event);
906
+ return pathIntents("file.write", rel(p.path ?? p.file_path, cwd), event);
205
907
  case "beforeMCPExecution": {
206
908
  const server = String(p.server ?? p.server_name ?? "");
207
909
  const tool = String(p.tool ?? p.tool_name ?? "");