@nathapp/nax-agent 0.87.1 → 0.88.0-canary.2

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/coding-tools/coding-tool-support.d.ts +16 -0
  3. package/dist/coding-tools/coding-tool-support.js +12 -0
  4. package/dist/command-safety/rule-scorer.d.ts +1 -1
  5. package/dist/command-safety/rule-scorer.js +11 -113
  6. package/dist/config/schemas-sandbox.d.ts +1 -0
  7. package/dist/config/schemas-sandbox.js +9 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +2 -2
  10. package/dist/internal/process-kill.d.ts +6 -2
  11. package/dist/internal/process-kill.js +6 -2
  12. package/dist/internal.d.ts +1 -0
  13. package/dist/internal.js +1 -0
  14. package/dist/mcp/connect.js +15 -1
  15. package/dist/mcp/types.d.ts +6 -0
  16. package/dist/native/auth.d.ts +2 -2
  17. package/dist/native/auth.js +4 -4
  18. package/dist/native/errors.js +7 -0
  19. package/dist/native/failed-round-trip-spend.d.ts +16 -0
  20. package/dist/native/failed-round-trip-spend.js +33 -0
  21. package/dist/native/session/partial-spend.d.ts +20 -0
  22. package/dist/native/session/partial-spend.js +14 -0
  23. package/dist/native/session/session.js +11 -4
  24. package/dist/native/session/turn-complete-step.js +23 -3
  25. package/dist/native/session-adapter.js +3 -2
  26. package/dist/permissions/bash-lex.js +36 -4
  27. package/dist/sandbox/claude-settings.d.ts +46 -0
  28. package/dist/sandbox/claude-settings.js +76 -0
  29. package/dist/sandbox/index.d.ts +2 -1
  30. package/dist/sandbox/index.js +1 -0
  31. package/dist/sandbox/launcher.js +4 -0
  32. package/dist/sandbox/messages.d.ts +1 -1
  33. package/dist/sandbox/messages.js +15 -3
  34. package/dist/sandbox/policy-builder.js +27 -2
  35. package/dist/sandbox/session-sandbox.d.ts +3 -0
  36. package/dist/sandbox/session-sandbox.js +9 -0
  37. package/dist/sandbox/srt-backend.d.ts +10 -1
  38. package/dist/sandbox/srt-backend.js +14 -4
  39. package/dist/sandbox/types.d.ts +23 -0
  40. package/dist/session/agent-session-options.js +1 -1
  41. package/dist/session/agent-session-types.d.ts +1 -1
  42. package/dist/session/native-backend-options.js +5 -3
  43. package/dist/session/session-backend.d.ts +9 -0
  44. package/dist/session/session-tool-support.d.ts +1 -1
  45. package/dist/session/session-tool-support.js +4 -2
  46. package/dist/session/session-types.d.ts +10 -0
  47. package/dist/tools/bash.d.ts +7 -1
  48. package/dist/tools/bash.js +18 -3
  49. package/dist/tools/denial-redirect.d.ts +22 -7
  50. package/dist/tools/denial-redirect.js +29 -13
  51. package/dist/tools/git-flags/locate.d.ts +47 -0
  52. package/dist/tools/git-flags/locate.js +361 -0
  53. package/dist/tools/index.d.ts +1 -0
  54. package/dist/tools/index.js +1 -0
  55. package/dist/tools/nax-dirs.d.ts +22 -0
  56. package/dist/tools/nax-dirs.js +89 -0
  57. package/dist/tools/nax-evasion.d.ts +19 -0
  58. package/dist/tools/nax-evasion.js +88 -0
  59. package/dist/tools/nax-revert.d.ts +34 -0
  60. package/dist/tools/nax-revert.js +233 -0
  61. package/dist/tools/nax-stash-restore.d.ts +7 -0
  62. package/dist/tools/nax-stash-restore.js +92 -0
  63. package/dist/tools/nax-state-guard.d.ts +18 -0
  64. package/dist/tools/nax-state-guard.js +478 -0
  65. package/dist/tools/policy-bash-raw.d.ts +3 -0
  66. package/dist/tools/policy-bash-raw.js +40 -12
  67. package/dist/tools/policy-bash.d.ts +3 -0
  68. package/dist/tools/policy-bash.js +38 -9
  69. package/dist/tools/policy-command-branch.d.ts +3 -0
  70. package/dist/tools/policy-command-branch.js +3 -1
  71. package/dist/tools/policy.js +13 -0
  72. package/dist/tools/provider-advertise.d.ts +13 -0
  73. package/dist/tools/provider-advertise.js +41 -1
  74. package/dist/tools/provider-instructions.d.ts +33 -0
  75. package/dist/tools/provider-instructions.js +63 -0
  76. package/dist/tools/provider-types.d.ts +12 -0
  77. package/dist/tools/run-command.d.ts +15 -0
  78. package/dist/tools/run-command.js +16 -1
  79. package/dist/tools/tool-audit.d.ts +1 -1
  80. package/package.json +3 -3
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Refusals that protect nax's run state (`.nax/`) from git commands an agent
3
+ * runs through Bash (#2490, #2493).
4
+ *
5
+ * Rules, evaluated PER SEGMENT of a compound command by the gated and raw
6
+ * Bash screens alike. Every one is a hard, non-escalatable denial; the ones
7
+ * that say "while a `.nax/` exists" do nothing in a repository without one.
8
+ *
9
+ * 1. `git checkout` / `git restore` of a `.nax` path (the nax#2007 gate that
10
+ * used to protect only a standalone command), and of a WHOLE-TREE pathspec
11
+ * (`.`, `:/`, `*`, the root directory, exclusion-only) unless a full `.nax`
12
+ * exclusion follows `--`; `git reset --hard|--keep|--merge`; forced
13
+ * `checkout -f` / `switch -f`; `git clean`; `checkout-index -a -f`;
14
+ * `read-tree -u --reset` (see nax-revert.ts). A "covering" exclusion is
15
+ * the top-anchored glob ending in `/.nax/` plus two stars, quoted as in
16
+ * COVERING_EXCLUSION (any depth, and the files inside: a glob without the
17
+ * trailing stars matches only the directory entry); the literal `:!.nax`
18
+ * covers only the root one, so it counts only while that is the sole
19
+ * `.nax`. A `:(...)` spec with an `attr:` element is never an exclusion.
20
+ * Long options are matched by prefix, as git accepts (`--k`, `--f`, `--a`).
21
+ * `git clean -n` / `--dry-run` is allowed.
22
+ * 2. `git update-index` with `--skip-worktree`, `--assume-unchanged` or their
23
+ * `--no-` forms (git accepts any unambiguous abbreviation, so the whole
24
+ * `--skip*` / `--assume*` / `--no-skip*` / `--no-assume*` families are
25
+ * refused) on a `.nax` path, or with no path operand at all (paths arrive
26
+ * on stdin or from `xargs`, which cannot be inspected).
27
+ * 3. `git stash` in any form other than the read-only/bookkeeping allowlist
28
+ * (`list`, `show`, `create`, `store`, `drop`, `clear`) when a `.nax/`
29
+ * directory exists at a live working directory, between it and the root,
30
+ * at the root, or nested below it (depth <= 3, see nax-dirs.ts). Policy
31
+ * time has no cheap way to know whether a `.nax/` path is DIRTY, so the
32
+ * rule is conservative. A stash is allowed when its pathspec excludes the
33
+ * whole of `.nax` (`-- . ':!.nax'`), or when it is a `push` whose every
34
+ * pathspec lies outside `.nax` (directories are listed with git). A `pop`
35
+ * or `apply` is allowed only when git lists no `.nax` path in the stash, and
36
+ * an `--index` restore must also list none in the stash's index snapshot
37
+ * (a `.nax` change staged and then reverted in the working tree lives there
38
+ * alone, and `--index` writes it back into the index).
39
+ * 4. Hiding the verb (nax-evasion.ts): `git config alias.X <value>` whose
40
+ * value names stash / update-index / checkout / restore / reset (so even
41
+ * `git config alias.co checkout` is refused while a `.nax/` exists);
42
+ * `GIT_CONFIG_*` set in leading assignments or `env` args next to such a
43
+ * verb; a command head or git subcommand built by expansion
44
+ * (`git $X reset`) next to such a verb and a `git` token; interpreter code
45
+ * (`python|node|bun|deno|php|lua|perl|ruby|osascript|pwsh -c|-e|-r|eval`)
46
+ * and a quoted string with whitespace in a segment whose head is a shell,
47
+ * an interpreter or an exec-style wrapper (`ssh`, `env`, `xargs`, `sudo`,
48
+ * `nohup`, `time`, `watch`, ...) that mentions such a verb (data commands
49
+ * such as `grep`, `gh`, `sed`, `printf`, `echo` are not scanned); git config keys git EXECUTES
50
+ * (`core.fsmonitor`, `core.hooksPath`, `core.pager`, `diff.external`,
51
+ * `include.path`, `*.textconv`, `filter.*.clean`, ...) set with `-c`,
52
+ * `--config-env` or `git config`. Aliases are never expanded over a git
53
+ * builtin, chains have no depth limit, and a cycle is refused.
54
+ *
55
+ * Operands are resolved against every live working directory and judged by
56
+ * the `.nax` path SEGMENT of the root-relative result, never a substring, so
57
+ * `cd .nax && git update-index --skip-worktree prd.json` and `foo/../.nax`
58
+ * are caught while `src/nax-helpers.ts` is not. `-C`, `--work-tree`,
59
+ * `--git-dir`, `GIT_WORK_TREE` and `GIT_DIR` all move the frames.
60
+ *
61
+ * `locateGit` reads the invocation: `\git`, wrappers, global options (an
62
+ * unknown one is "uncertain" and sends the segment to the BACKSTOP), and
63
+ * aliases (expanded, then the global-option skip is run again; a `!` alias is
64
+ * guarded as shell text).
65
+ *
66
+ * BACKSTOP. The structured rules above only fire on a segment whose git
67
+ * invocation `locateGit` can read. `naxStateRefusalInText` therefore also runs
68
+ * a deliberately COARSE check over the whole command string (and every
69
+ * `bash -c` / `eval` string, recursively) for segments the structured rules
70
+ * could not classify (`eval git stash`, `time git stash`, `nohup`, `stdbuf`,
71
+ * an unknown wrapper, an unknown global option): when such a segment names
72
+ * `git` and one of `stash`, `reset --hard`, `update-index` with a hiding flag,
73
+ * or `checkout`/`restore` of a `.nax` path or the whole tree, it is refused.
74
+ * It exists to catch wrappers and aliases nobody can enumerate, so it trades
75
+ * precision for coverage; a segment the structured rules DID classify keeps
76
+ * their (precise) verdict, which is why
77
+ * `git update-index --assume-unchanged src/a.ts` stays allowed.
78
+ *
79
+ * KNOWN, NOT FIXED (by design, #2497): this guard is a first line that redirects
80
+ * common accidents with a helpful message. It is not the boundary and is not
81
+ * extended toward completeness. The guarantee is detect-and-repair: nax's
82
+ * `src/runstate/` restores any nax-owned run-state file, and clears its index
83
+ * flags, after every agent turn and before every auto-commit, whatever command
84
+ * changed it (bypass matrix: test/integration/runstate/bypass-matrix.test.ts).
85
+ * Gaps:
86
+ * - Stash without explicit safe pathspecs or an inspectable restore ref is
87
+ * refused while a `.nax/` exists, even when that directory is clean.
88
+ * - Prose that reads as a command (`echo git stash`, `python -c 'print("git
89
+ * stash")'`, `echo "git stash"`) is refused when a `.nax/` exists: the guard
90
+ * works on words. A scratch fixture `.nax/` within three levels also makes
91
+ * stash refused.
92
+ * - Git subcommands that execute a string (`rebase -x`, `submodule foreach`,
93
+ * `bisect run`, `difftool -x`, `fetch --upload-pack=`), executing
94
+ * environment variables (`GIT_EXTERNAL_DIFF`, `GIT_PAGER`, `GIT_EDITOR`,
95
+ * `GIT_SSH_COMMAND`) and executing config keys not in the list above
96
+ * (`remote.*.uploadpack`, `uploadpack.packObjectsHook`,
97
+ * `core.alternateRefsCommand`, `merge.*.driver`, `core.gitProxy`,
98
+ * `protocol.ext.allow` with `ext::` URLs) are not read.
99
+ * - `deno eval` is an interpreter only in gated mode's quoted-string scan;
100
+ * code built by string arithmetic inside any interpreter is not read.
101
+ * - A verb built by a bare expansion head with no `git` token (`$G stash`) is
102
+ * not seen, nor is one assembled by string arithmetic inside an interpreter.
103
+ * - Under `raw` Bash approval an operand BUILT by expansion (`.na?/x`,
104
+ * `$D/prd.json`) is not resolved; raw mode is advisory. `git $X` alone,
105
+ * naming no guarded verb, is not refused.
106
+ * - A heredoc body fed to an interpreter (`python3 <<EOF`) is data to this
107
+ * reader, as is `--pathspec-from-file`, `GIT_CONFIG_GLOBAL` pointing at a
108
+ * file, and `export GIT_CONFIG_*` set on an earlier line.
109
+ * - The nested `.nax` scan is bounded (depth 3) and an empty result is
110
+ * trusted for one second; a `.nax` deeper or created within that second is
111
+ * not seen.
112
+ * - From inside `.nax` (`cd .nax`), every unmarked `git checkout` operand is
113
+ * read as a path.
114
+ */
115
+ import { resolve } from "node:path";
116
+ import { NAX_OWNED_RUN_STATE_EXPLANATION } from "./denial-redirect.js";
117
+ import { commandHeadIndex, gitExecutableIndex, locateGit } from "./git-flags/locate.js";
118
+ import { hasNaxDirNear } from "./nax-dirs.js";
119
+ import { buildsVerbByExpansion, CONFIG_ENV_REFUSAL, configAliasRefusal, EXEC_CONFIG_REFUSAL, EXPANSION_REFUSAL, flattenCode, interpreterCode, isExecConfigKey, mentionsGuardedVerb, } from "./nax-evasion.js";
120
+ import { COVERING_EXCLUSION, discardRefusal, operandTouchesNax, resetRefusal, revertRefusal } from "./nax-revert.js";
121
+ import { stashIsExempt } from "./nax-stash-restore.js";
122
+ export { createNaxDirIndex } from "./nax-dirs.js";
123
+ /** Stash subcommands that neither capture nor replay the working tree. */
124
+ const STASH_SAFE_SUBCOMMANDS = new Set(["list", "show", "create", "store", "drop", "clear"]);
125
+ /** Abbreviations git really accepts: `--sk`, `--as`, `--no-sk`, `--no-as` and everything longer. */
126
+ const INDEX_HIDING_FLAG = /^--(?:no-)?(?:sk|as)/;
127
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "eval"]);
128
+ const UPDATE_INDEX_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} ` +
129
+ "Marking it with `git update-index --skip-worktree` or `--assume-unchanged` hides it from git and from nax's own commits, " +
130
+ "and the flag outlives the run. To see whether something pre-existed, use `git diff HEAD -- <file>` or `git show HEAD:<file>`.";
131
+ const LITERAL_PATHSPECS_REFUSAL = "`--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` turn every `:(...)` and `:!` exclusion into a literal path, " +
132
+ "so nothing keeps nax's run state under `.nax/` out of this command. Drop the literal-pathspec setting.";
133
+ const STASH_REFUSAL = "`git stash` is refused while the repository holds nax's run state under `.nax/`: the stash would capture " +
134
+ "nax's uncommitted state and a later pop would replay stale copies over what nax has written since. " +
135
+ "To check whether something pre-existed, use `git diff HEAD -- <file>` or `git show HEAD:<file>`. " +
136
+ `A \`git stash push -- . ${COVERING_EXCLUSION}\` is allowed. ` +
137
+ "`push` of files or directories outside `.nax` is allowed, and so is `pop` or `apply` of a stash that holds no `.nax` path (quote the ref, e.g. `'stash@{1}'`).";
138
+ const ALIAS_REFUSAL = "A git alias defined through `--config-env`, or one that loops back on itself, cannot be inspected while the repository holds nax's run state under `.nax/`. " +
139
+ "Run the git verb directly, or define the alias with `-c alias.NAME=VALUE`.";
140
+ /**
141
+ * The frames plus where `-C` / `--work-tree` / `--git-dir` / `GIT_WORK_TREE` /
142
+ * `GIT_DIR` lead from each: every directory alone, and the whole chain in order.
143
+ */
144
+ function withGitDirs(ctx, dirs) {
145
+ if (dirs.length === 0)
146
+ return ctx;
147
+ const chained = ctx.frames.map((frame) => dirs.reduce((dir, next) => resolve(dir, next), frame));
148
+ const single = ctx.frames.flatMap((frame) => dirs.map((dir) => resolve(frame, dir)));
149
+ return { ...ctx, frames: [...ctx.frames, ...single, ...chained] };
150
+ }
151
+ function updateIndexRefusal(args, ctx, viaXargs) {
152
+ if (!args.some((word) => INDEX_HIDING_FLAG.test(word)))
153
+ return undefined;
154
+ const operands = args.filter((word) => !word.startsWith("-"));
155
+ if (operands.some((word) => operandTouchesNax(word, ctx)))
156
+ return UPDATE_INDEX_REFUSAL;
157
+ // Paths that arrive on stdin, from xargs or from an expansion cannot be inspected, with or without other operands.
158
+ const uninspectable = viaXargs ||
159
+ operands.length === 0 ||
160
+ operands.some((word) => /[$`]/.test(word)) ||
161
+ args.includes("--stdin") ||
162
+ args.includes("--index-info");
163
+ return uninspectable && hasNaxDirNear(ctx) ? UPDATE_INDEX_REFUSAL : undefined;
164
+ }
165
+ function stashRefusal(args, ctx) {
166
+ const first = args[0];
167
+ if (first !== undefined && STASH_SAFE_SUBCOMMANDS.has(first))
168
+ return undefined;
169
+ if (!hasNaxDirNear(ctx))
170
+ return undefined;
171
+ return stashIsExempt(args, ctx) ? undefined : STASH_REFUSAL;
172
+ }
173
+ /** The string a `bash -c <string>` / `eval <string>` style segment runs, or undefined. */
174
+ function shellCommandString(words) {
175
+ const head = commandHeadIndex(words);
176
+ const shell = words[head]?.split("/").pop();
177
+ if (shell === undefined || !SHELLS.has(shell))
178
+ return undefined;
179
+ const flag = shell === "eval" ? head : words.findIndex((word, i) => i > head && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
180
+ if (flag === -1 || flag + 1 >= words.length)
181
+ return undefined;
182
+ return words
183
+ .slice(flag + 1)
184
+ .join(" ")
185
+ .replace(/^\s*['"]|['"]\s*$/g, "");
186
+ }
187
+ /** The structured rules for one located git invocation. */
188
+ const DISCARD_VERBS = new Set(["switch", "clean", "checkout-index", "read-tree"]);
189
+ /** Verbs whose exclusion pathspecs `--literal-pathspecs` turns into literal paths. */
190
+ const EXCLUDING_VERBS = new Set(["checkout", "restore", "stash", "clean"]);
191
+ function verbRefusal(git, segment, where) {
192
+ if (git.literalPathspecs && EXCLUDING_VERBS.has(git.subcommand) && git.args.some((a) => a.startsWith(":"))) {
193
+ if (hasNaxDirNear(where))
194
+ return LITERAL_PATHSPECS_REFUSAL;
195
+ }
196
+ if (git.subcommand === "checkout" || git.subcommand === "restore") {
197
+ return revertRefusal(git.subcommand, git.args, where);
198
+ }
199
+ if (git.subcommand === "reset")
200
+ return resetRefusal(git.args, where);
201
+ if (DISCARD_VERBS.has(git.subcommand))
202
+ return discardRefusal(git.subcommand, git.args, where);
203
+ if (git.subcommand === "stash")
204
+ return stashRefusal(git.args, where);
205
+ if (git.subcommand === "config")
206
+ return hasNaxDirNear(where) ? configAliasRefusal(git.args) : undefined;
207
+ if (git.subcommand !== "update-index")
208
+ return undefined;
209
+ const viaXargs = segment.slice(0, gitExecutableIndex(segment)).includes("xargs");
210
+ return updateIndexRefusal(git.args, where, viaXargs);
211
+ }
212
+ /** Refusals that depend on HOW git was invoked, not on its verb: environment config, `--config-env` and `!` aliases. */
213
+ function invocationRefusal(git, segment, where) {
214
+ if (git.unresolvedAlias && hasNaxDirNear(where))
215
+ return ALIAS_REFUSAL;
216
+ if (git.configKeys.some(isExecConfigKey) && hasNaxDirNear(where))
217
+ return EXEC_CONFIG_REFUSAL;
218
+ if (git.configEnv && mentionsGuardedVerb(segment.join(" ")) && hasNaxDirNear(where))
219
+ return CONFIG_ENV_REFUSAL;
220
+ return git.shellAlias === undefined ? undefined : naxStateRefusalInText(git.shellAlias, where);
221
+ }
222
+ /**
223
+ * The refusal for one command segment (its words, `git` possibly behind
224
+ * wrappers and global options), or undefined. A segment whose git invocation
225
+ * cannot be located is NOT judged here; see the BACKSTOP in the header. One
226
+ * that CAN be located but met a global option this reader does not know is
227
+ * judged by the structured rules and then, with a `.nax/` present, by the BACKSTOP.
228
+ */
229
+ export function naxStateRefusal(segment, ctx) {
230
+ const inner = shellCommandString(segment);
231
+ if (inner !== undefined)
232
+ return naxStateRefusalInText(inner, ctx);
233
+ const code = interpreterCode(segment);
234
+ if (code !== undefined)
235
+ return naxStateRefusalInText(flattenCode(code), ctx);
236
+ const git = locateGit(segment);
237
+ if (git === undefined)
238
+ return undefined;
239
+ const where = withGitDirs(ctx, git.dirs);
240
+ const refusal = invocationRefusal(git, segment, where) ?? verbRefusal(git, segment, where);
241
+ if (refusal !== undefined || !git.uncertain || !hasNaxDirNear(where))
242
+ return refusal;
243
+ return backstopRefusal(segment, where);
244
+ }
245
+ /**
246
+ * What a backslash at `i` contributes: outside quotes `\X` is `X` (so `g\it` is `git`), inside double quotes
247
+ * only before `"` `\` `$` or a backtick, and `\<newline>` is a line continuation (nothing).
248
+ */
249
+ function readEscape(chars, i, quote) {
250
+ const next = chars[i + 1];
251
+ if (next === "\n")
252
+ return { text: "", consumed: 1 };
253
+ if (next !== undefined && (quote === undefined || '"\\$`'.includes(next)))
254
+ return { text: next, consumed: 1 };
255
+ return { text: "\\", consumed: 0 };
256
+ }
257
+ /** Split command text into segments of tokens: quotes are removed and kept atomic; `; & | ( ) { }` and newlines end a segment. */
258
+ function shellSegments(command) {
259
+ const segments = [[]];
260
+ let token = "";
261
+ let quote;
262
+ let inToken = false;
263
+ const endToken = () => {
264
+ if (inToken)
265
+ segments[segments.length - 1].push(token);
266
+ token = "";
267
+ inToken = false;
268
+ };
269
+ const chars = [...command];
270
+ for (let i = 0; i < chars.length; i += 1) {
271
+ const ch = chars[i];
272
+ if (ch === "\\" && quote !== "'") {
273
+ const esc = readEscape(chars, i, quote);
274
+ token += esc.text;
275
+ if (esc.text !== "")
276
+ inToken = true;
277
+ i += esc.consumed;
278
+ }
279
+ else if (quote !== undefined) {
280
+ if (ch === quote)
281
+ quote = undefined;
282
+ else
283
+ token += ch;
284
+ }
285
+ else if (ch === "'" || ch === '"') {
286
+ quote = ch;
287
+ inToken = true;
288
+ }
289
+ else if (/\s/.test(ch) && ch !== "\n") {
290
+ endToken();
291
+ }
292
+ else if (";&|(){}\n".includes(ch)) {
293
+ endToken();
294
+ segments.push([]);
295
+ }
296
+ else {
297
+ token += ch;
298
+ inToken = true;
299
+ }
300
+ }
301
+ endToken();
302
+ return segments.map((words) => words.filter((word) => !/^\d*[<>]/.test(word))).filter((words) => words.length > 0);
303
+ }
304
+ const HEREDOC_MARKER = /(?<!<)<<(?!<)-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][\w-]*))/g;
305
+ /** The heredoc terminators a command line opens, in order. */
306
+ function heredocTerminators(line) {
307
+ return [...line.matchAll(HEREDOC_MARKER)].map((match) => (match[1] ?? match[2] ?? match[3]));
308
+ }
309
+ /**
310
+ * Drop heredoc bodies (the lines after `<<WORD`, `<<'WORD'`, `<<"WORD"` or
311
+ * `<<-WORD` up to the terminator line): they are data, not commands, so a
312
+ * commit message that says "git stash" is not a stash.
313
+ */
314
+ function withoutHeredocBodies(command) {
315
+ const kept = [];
316
+ let pending = [];
317
+ for (const line of command.split("\n")) {
318
+ if (pending.length > 0) {
319
+ if (line.trim() === pending[0])
320
+ pending = pending.slice(1);
321
+ continue;
322
+ }
323
+ kept.push(line);
324
+ pending = heredocTerminators(line);
325
+ }
326
+ return kept.join("\n");
327
+ }
328
+ /** `cd X` / `pushd X` widens the frames to also include X resolved from each (the union stays conservative). */
329
+ function framesAfterCd(words, frames) {
330
+ const head = words[commandHeadIndex(words)];
331
+ if (head !== "cd" && head !== "pushd")
332
+ return undefined;
333
+ const target = words.slice(commandHeadIndex(words) + 1).find((word) => !word.startsWith("-"));
334
+ if (target === undefined || /^[$~]/.test(target))
335
+ return frames;
336
+ return [...frames, ...frames.map((frame) => resolve(frame, target))];
337
+ }
338
+ function aliasTarget(word) {
339
+ return /^alias\.[^=]+=(.*)$/is.exec(word)?.[1];
340
+ }
341
+ /** True when `word` is a git verb token, or an alias definition that expands to one. */
342
+ function namesVerb(word, verb) {
343
+ return word === verb || (aliasTarget(word)?.includes(verb) ?? false);
344
+ }
345
+ /** `git`, `\git` or a path ending in `/git`. */
346
+ function isGitToken(word) {
347
+ return word === "git" || word === "\\git" || word.endsWith("/git");
348
+ }
349
+ /** The coarse revert check: whole-tree and `.nax` operands after the first word naming `verb`. */
350
+ function backstopRevert(words, verb, ctx) {
351
+ const at = words.findIndex((word) => namesVerb(word, verb));
352
+ if (at === -1)
353
+ return undefined;
354
+ const refusal = revertRefusal(verb, words.slice(at + 1), ctx);
355
+ if (refusal !== undefined)
356
+ return refusal;
357
+ return words.some((word) => operandTouchesNax(word, ctx)) ? NAX_OWNED_RUN_STATE_EXPLANATION : undefined;
358
+ }
359
+ /**
360
+ * The coarse check for a segment `locateGit` could not read. Requires a `git`
361
+ * token in the same segment, so `echo stash` and a commit message that merely
362
+ * contains the word never match.
363
+ */
364
+ function backstopRefusal(words, ctx) {
365
+ if (!words.some(isGitToken))
366
+ return undefined;
367
+ const mentions = (verb) => words.some((word) => namesVerb(word, verb));
368
+ if (mentions("update-index") && words.some((word) => INDEX_HIDING_FLAG.test(word)))
369
+ return UPDATE_INDEX_REFUSAL;
370
+ if (mentions("stash") && !stashIsSafeWord(words))
371
+ return STASH_REFUSAL;
372
+ if (mentions("reset")) {
373
+ const reset = resetRefusal(words, ctx);
374
+ if (reset !== undefined)
375
+ return reset;
376
+ }
377
+ return backstopRevert(words, "checkout", ctx) ?? backstopRevert(words, "restore", ctx);
378
+ }
379
+ function stashIsSafeWord(words) {
380
+ const next = words[words.indexOf("stash") + 1];
381
+ return words.includes("stash") && next !== undefined && STASH_SAFE_SUBCOMMANDS.has(next);
382
+ }
383
+ const baseName = (word) => (word ?? "").split("/").pop();
384
+ /** Commands whose string arguments are run or interpreted: wrappers, and interpreters (their program text). */
385
+ const EXEC_STYLE_HEADS = new Set([
386
+ "ssh",
387
+ "env",
388
+ "xargs",
389
+ "nohup",
390
+ "time",
391
+ "stdbuf",
392
+ "sudo",
393
+ "doas",
394
+ "watch",
395
+ "timeout",
396
+ "nice",
397
+ "command",
398
+ "exec",
399
+ "awk",
400
+ "gawk",
401
+ "mawk",
402
+ "tclsh",
403
+ "expect",
404
+ "osascript",
405
+ "pwsh",
406
+ "powershell",
407
+ "bun",
408
+ "deno",
409
+ "php",
410
+ "perl",
411
+ "ruby",
412
+ "node",
413
+ "nodejs",
414
+ ]);
415
+ const isInterpreterName = (name) => /^(?:python[\d.]*|lua[\d.]*)$/.test(name);
416
+ /** The first word that is not a `VAR=x` assignment. */
417
+ const leadWord = (words) => words.find((w) => !/^[A-Za-z_]\w*=/.test(w));
418
+ /**
419
+ * Is a quoted string in this segment possibly a command? Only when the head is a shell, an interpreter or an
420
+ * exec-style wrapper (`ssh host "git stash"`); data commands (`grep "git stash"`, `gh ... --body "..."`, `sed`,
421
+ * `printf`, `echo`) never run their arguments, so they are not scanned.
422
+ */
423
+ /** A shell name or a `-c` cluster anywhere in the segment (`if sh -c "..."`, `find -exec sh -c`, `su -c "..."`). */
424
+ const runsNestedShell = (words) => words.some((word) => SHELLS.has(baseName(word)) || /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
425
+ function mayRunItsStrings(words) {
426
+ const head = baseName(words[commandHeadIndex(words)]);
427
+ const lead = baseName(leadWord(words));
428
+ return [head, lead].some((n) => SHELLS.has(n) || EXEC_STYLE_HEADS.has(n) || isInterpreterName(n));
429
+ }
430
+ /**
431
+ * Recurse into every multi-word token of a segment that runs a nested shell, or that is not a readable git
432
+ * call but mentions a guarded verb (`ssh host "git stash"`, `awk 'BEGIN{system("git stash")}'`): whatever the
433
+ * head, a quoted string with whitespace may be a command. A certain git call keeps its structured verdict, so
434
+ * `git commit -m "fix: git stash handling"` stays allowed.
435
+ */
436
+ function nestedRefusal(words, ctx) {
437
+ const git = locateGit(words);
438
+ const readableGit = git !== undefined && !git.uncertain;
439
+ if (!runsNestedShell(words) && !mayRunItsStrings(words))
440
+ return undefined;
441
+ const shellHead = SHELLS.has(baseName(words[commandHeadIndex(words)]));
442
+ if (!shellHead && (readableGit || !mentionsGuardedVerb(words.join(" "))))
443
+ return undefined;
444
+ for (const word of words) {
445
+ if (!/\s/.test(word))
446
+ continue;
447
+ const refusal = naxStateRefusalInText(word, ctx) ?? naxStateRefusalInText(flattenCode(word), ctx);
448
+ if (refusal !== undefined)
449
+ return refusal;
450
+ }
451
+ return undefined;
452
+ }
453
+ /**
454
+ * `naxStateRefusal` over command TEXT: the raw command string in every Bash
455
+ * mode, and the string behind `bash -c` / `eval`. Tracks `cd` itself, applies
456
+ * the structured rules to each segment, and the coarse BACKSTOP (see the
457
+ * header) to segments whose git invocation it could not read.
458
+ */
459
+ export function naxStateRefusalInText(command, ctx) {
460
+ let frames = ctx.frames;
461
+ const code = withoutHeredocBodies(command);
462
+ if (buildsVerbByExpansion(code) && hasNaxDirNear(ctx))
463
+ return EXPANSION_REFUSAL;
464
+ for (const words of shellSegments(code)) {
465
+ const moved = framesAfterCd(words, frames);
466
+ if (moved !== undefined) {
467
+ frames = moved;
468
+ continue;
469
+ }
470
+ const here = { ...ctx, frames };
471
+ const refusal = naxStateRefusal(words, here) ??
472
+ nestedRefusal(words, here) ??
473
+ (locateGit(words) === undefined && hasNaxDirNear(here) ? backstopRefusal(words, here) : undefined);
474
+ if (refusal !== undefined)
475
+ return refusal;
476
+ }
477
+ return undefined;
478
+ }
@@ -1,3 +1,4 @@
1
+ import type { NaxDirIndex } from "./nax-dirs.ts";
1
2
  import type { OwnedPathsPolicy } from "./owned-paths.ts";
2
3
  import type { BashCheck } from "./policy-bash.ts";
3
4
  export interface RawScreenArgs {
@@ -9,6 +10,8 @@ export interface RawScreenArgs {
9
10
  readonly resolvePath: (candidate: string, cwd: string) => string | null;
10
11
  /** The permitted root, used to relativise a resolved path. */
11
12
  readonly root: string;
13
+ /** Per-policy cache of the `.nax` directories below the root (#2493). */
14
+ readonly naxDirs?: NaxDirIndex;
12
15
  /**
13
16
  * US-002: true when the command runs inside the OS sandbox (an available
14
17
  * launcher). A token that only NAMES a feature PRD is then no longer refused
@@ -53,6 +53,7 @@ import { relative, resolve, sep } from "node:path";
53
53
  import { realOrRaw } from "#src/internal/realpath";
54
54
  import { lexBashCommand } from "#src/permissions/index";
55
55
  import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
56
+ import { naxStateRefusalInText } from "./nax-state-guard.js";
56
57
  /**
57
58
  * US-002: the start paths that mean "search the whole filesystem". An exact
58
59
  * token comparison, never a prefix one: `~/proj` and `~user` are scoped and
@@ -136,6 +137,34 @@ function ownedCandidates(args, candidate, cwd) {
136
137
  };
137
138
  });
138
139
  }
140
+ /**
141
+ * US-002 COST GUARD: a `find` rooted at the whole filesystem walks every mount
142
+ * and runs into the 300s Bash timeout. Raw has no containment, so nothing else
143
+ * stops it. Not a boundary -- a cost guard.
144
+ */
145
+ function segmentGuard(args, segment) {
146
+ const findStart = wholeFilesystemFind(segment.tokens);
147
+ if (findStart !== undefined) {
148
+ return deny(`\`find ${findStart}\` searches the whole filesystem and runs into the 300s Bash timeout. ` +
149
+ `Search within the repository root instead: ${args.root}`);
150
+ }
151
+ return undefined;
152
+ }
153
+ /**
154
+ * #2490: nax's run state is screened even for a command the lexer cannot read
155
+ * (`... 2>&1; git stash pop`), using the guard's own text reader. Then the
156
+ * inversion: unreadable means unscreened, and unscreened means allowed.
157
+ */
158
+ function earlyVerdict(args, command, lexKind) {
159
+ const stateRefusal = naxStateRefusalInText(command, {
160
+ frames: [args.initialPath],
161
+ root: args.root,
162
+ ...(args.naxDirs !== undefined ? { naxDirs: args.naxDirs } : {}),
163
+ });
164
+ if (stateRefusal !== undefined)
165
+ return deny(stateRefusal);
166
+ return lexKind === "refused" ? { kind: "allow" } : undefined;
167
+ }
139
168
  export function screenRawBashCommand(args) {
140
169
  const { command, tool } = args;
141
170
  if (typeof command !== "string")
@@ -143,20 +172,19 @@ export function screenRawBashCommand(args) {
143
172
  if (command.trim() === "")
144
173
  return deny(`"command" must not be empty`);
145
174
  const lexed = lexBashCommand(command);
146
- // The inversion: unreadable means unscreened, and unscreened means allowed.
147
- if (lexed.kind === "refused")
148
- return { kind: "allow" };
175
+ const early = earlyVerdict(args, command, lexed.kind);
176
+ if (early !== undefined)
177
+ return early;
178
+ // Reaching here means the lexer succeeded: `earlyVerdict` allows a refused command.
179
+ const { segments = [] } = lexed;
149
180
  let cwd = [args.initialPath];
150
181
  const sandboxWrapped = args.sandboxWrapped === true;
151
- for (const segment of lexed.segments) {
152
- // US-002 COST GUARD (see the file header): a `find` rooted at the whole
153
- // filesystem walks every mount and runs into the 300s Bash timeout. Raw has
154
- // no containment, so nothing else stops it. Not a boundary -- a cost guard.
155
- const findStart = wholeFilesystemFind(segment.tokens);
156
- if (findStart !== undefined) {
157
- return deny(`\`find ${findStart}\` searches the whole filesystem and runs into the 300s Bash timeout. ` +
158
- `Search within the repository root instead: ${args.root}`);
159
- }
182
+ for (const segment of segments) {
183
+ // US-002 COST GUARD (see the file header) and the #2490 nax-run-state
184
+ // rules (revert, index-hiding, stash), applied to every segment.
185
+ const segmentDeny = segmentGuard(args, segment);
186
+ if (segmentDeny !== undefined)
187
+ return segmentDeny;
160
188
  // US-002: sandbox-wrapped, a token that only NAMES a PRD is allowed -- the
161
189
  // policy now applies that exemption (`bashRefusal` receives
162
190
  // `sandboxWrapped` and skips the PRD read itself). A redirect WRITES, so
@@ -25,6 +25,7 @@
25
25
  * this module, so importing `resolveWithin` back would be a cycle. The single
26
26
  * owner of the root boundary and the `.git/` refusal therefore remains `policy.ts`.
27
27
  */
28
+ import type { NaxDirIndex } from "./nax-dirs.ts";
28
29
  import type { CompiledEntry } from "./policy-match.ts";
29
30
  export type BashCheck = {
30
31
  readonly kind: "allow";
@@ -60,5 +61,7 @@ export interface BashCheckArgs {
60
61
  readonly resolvePath: (candidate: string, cwd: string) => string | null;
61
62
  /** The shell's initial working directory. */
62
63
  readonly initialPath: string;
64
+ /** Per-policy cache of the `.nax` directories below the root (#2493). */
65
+ readonly naxDirs?: NaxDirIndex;
63
66
  }
64
67
  export declare function checkBashCommand(args: BashCheckArgs): BashCheck;
@@ -28,6 +28,8 @@
28
28
  import { lexBashCommand } from "#src/permissions/index";
29
29
  import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
30
30
  import { deniedFlag } from "./exec-guard.js";
31
+ import { isCoveringExclusion } from "./nax-revert.js";
32
+ import { naxStateRefusalInText } from "./nax-state-guard.js";
31
33
  function deny(reason, breach = false, escalatable = false) {
32
34
  return { kind: "deny", reason, breach, escalatable };
33
35
  }
@@ -119,7 +121,8 @@ function checkPayload(args, segment, cwd) {
119
121
  if (flag !== undefined)
120
122
  return { refusal: deny(`flag ${flag} is not permitted in a Bash command`) };
121
123
  for (const token of segment.tokens) {
122
- if (token.opaque || hasUnmodelledExpansion(token.text)) {
124
+ // The exact covering pathspec (a quoted glob) is git pathspec magic, not shell expansion (#2493).
125
+ if (token.opaque || (hasUnmodelledExpansion(token.text) && !isCoveringExclusion(token.text))) {
123
126
  return { refusal: deny(`token "${token.text}" depends on shell expansion this gate cannot resolve`) };
124
127
  }
125
128
  const target = containmentTarget(token.text);
@@ -188,12 +191,39 @@ function checkPayload(args, segment, cwd) {
188
191
  }
189
192
  return { cdTargets };
190
193
  }
191
- export function checkBashCommand(args) {
192
- const { command, tool } = args;
194
+ /** The shape guards: a non-string or blank command is refused before anything is lexed. */
195
+ function invalidCommandDenial(command) {
193
196
  if (typeof command !== "string")
194
197
  return deny(`"command" must be a string`);
195
- if (command.trim() === "")
196
- return deny(`"command" must not be empty`);
198
+ return command.trim() === "" ? deny(`"command" must not be empty`) : undefined;
199
+ }
200
+ /**
201
+ * nax's run state (#2490): revert, index-hiding and stash in any segment,
202
+ * nested shell or unreadable construct. Never escalatable, and checked BEFORE
203
+ * any escalatable refusal so a user approval can never run it.
204
+ */
205
+ function stateDenial(args) {
206
+ const refusal = naxStateRefusalInText(String(args.command), {
207
+ frames: [args.initialPath],
208
+ root: args.initialPath,
209
+ ...(args.naxDirs !== undefined ? { naxDirs: args.naxDirs } : {}),
210
+ });
211
+ return refusal === undefined ? undefined : deny(refusal);
212
+ }
213
+ function lexicalDenial(lexed) {
214
+ if (lexed.kind !== "refused")
215
+ return undefined;
216
+ return deny(`command contains ${lexed.construct}, which cannot be analysed and is therefore refused -- ` +
217
+ "rewrite it without that construct, or use a structured tool", false, true);
218
+ }
219
+ export function checkBashCommand(args) {
220
+ const { command, tool } = args;
221
+ const invalid = invalidCommandDenial(command);
222
+ if (invalid !== undefined)
223
+ return invalid;
224
+ const state = stateDenial(args);
225
+ if (state !== undefined)
226
+ return state;
197
227
  const lexed = lexBashCommand(command);
198
228
  // On a refusal the gate still runs its deny and payload checks -- over the
199
229
  // lexable prefix, the part before the unreadable construct. The prefix is
@@ -212,10 +242,9 @@ export function checkBashCommand(args) {
212
242
  return result.refusal;
213
243
  cwd = nextWorkingDirectories(segment, cwd, result.cdTargets);
214
244
  }
215
- if (lexed.kind === "refused") {
216
- return deny(`command contains ${lexed.construct}, which cannot be analysed and is therefore refused -- ` +
217
- "rewrite it without that construct, or use a structured tool", false, true);
218
- }
245
+ const lexical = lexicalDenial(lexed);
246
+ if (lexical !== undefined)
247
+ return lexical;
219
248
  for (const segment of segments) {
220
249
  if (args.grant.unconditional || matchesSegment(args.grant, segment))
221
250
  continue;