@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,89 @@
1
+ /**
2
+ * Where nax's run state (`.nax/`) lives, for the Bash guard (#2490, #2493).
3
+ *
4
+ * A `.nax/` is looked for at the root, at every working-directory frame and
5
+ * between a frame and the root, and BELOW the root: a package-level `.nax`
6
+ * (monorepo workdir) is nax state just as much as the root one. The downward
7
+ * scan is bounded (depth 3 under the root, never into `node_modules` or a
8
+ * dot-directory other than `.nax`) and cached in a `NaxDirIndex` that the
9
+ * policy creates once per compile.
10
+ */
11
+ import { existsSync, readdirSync } from "node:fs";
12
+ import { join, resolve, sep } from "node:path";
13
+ /** How deep below the root a nested `.nax` is looked for. */
14
+ const MAX_NESTED_DEPTH = 3;
15
+ /** Directory names the downward scan never enters. */
16
+ function isSkipped(name) {
17
+ return name === "node_modules" || (name.startsWith(".") && name !== ".nax");
18
+ }
19
+ /** `.nax` directories in `dir` (at `depth` below the root) and in its subdirectories, within the bound. */
20
+ function scanDown(dir, depth, found) {
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return;
27
+ }
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory())
30
+ continue;
31
+ if (entry.name === ".nax")
32
+ found.push(join(dir, entry.name));
33
+ else if (depth < MAX_NESTED_DEPTH && !isSkipped(entry.name))
34
+ scanDown(join(dir, entry.name), depth + 1, found);
35
+ }
36
+ }
37
+ /** Every `.nax` directory at or below `root`, within the depth bound. */
38
+ export function scanNaxDirs(root) {
39
+ const found = [];
40
+ scanDown(root, 0, found);
41
+ return found;
42
+ }
43
+ /** How long an EMPTY scan result is trusted: a `.nax/` created later is seen within this. */
44
+ const NEGATIVE_TTL_MS = 1_000;
45
+ /**
46
+ * A found `.nax` is cached for the index's lifetime (a deleted one may keep refusing, which is safe); an
47
+ * empty result only for {@link NEGATIVE_TTL_MS}, so one created mid-run is not invisible for ever.
48
+ */
49
+ export function createNaxDirIndex(now = Date.now) {
50
+ const byRoot = new Map();
51
+ return {
52
+ below(root) {
53
+ const cached = byRoot.get(root);
54
+ if (cached !== undefined && (cached.dirs.length > 0 || now() - cached.at < NEGATIVE_TTL_MS)) {
55
+ return cached.dirs;
56
+ }
57
+ const dirs = scanNaxDirs(root);
58
+ byRoot.set(root, { dirs, at: now() });
59
+ return dirs;
60
+ },
61
+ };
62
+ }
63
+ /** The root, every frame, and every directory between a frame and the root. */
64
+ function candidateDirs(ctx) {
65
+ const candidates = new Set([ctx.root]);
66
+ for (const frame of ctx.frames) {
67
+ let dir = frame;
68
+ candidates.add(dir);
69
+ while (dir !== ctx.root && dir.startsWith(`${ctx.root}${sep}`)) {
70
+ dir = resolve(dir, "..");
71
+ candidates.add(dir);
72
+ }
73
+ }
74
+ return candidates;
75
+ }
76
+ function nearNaxDirs(ctx) {
77
+ return [...candidateDirs(ctx)].map((dir) => join(dir, ".nax")).filter((dir) => existsSync(dir));
78
+ }
79
+ function nestedNaxDirs(ctx) {
80
+ return (ctx.naxDirs ?? { below: scanNaxDirs }).below(ctx.root);
81
+ }
82
+ /** Every known `.nax` directory: on the way from a frame to the root, at the root, or nested below it. */
83
+ export function findNaxDirs(ctx) {
84
+ return [...new Set([...nearNaxDirs(ctx), ...nestedNaxDirs(ctx)])];
85
+ }
86
+ /** True when a `.nax/` exists at a frame, between a frame and the root, at the root, or nested below it. */
87
+ export function hasNaxDirNear(ctx) {
88
+ return nearNaxDirs(ctx).length > 0 || nestedNaxDirs(ctx).length > 0;
89
+ }
@@ -0,0 +1,19 @@
1
+ /** True when `text` names a guarded git verb as a word (`alias.s=stash` and `` stash` `` count). */
2
+ export declare function mentionsGuardedVerb(text: string): boolean;
3
+ export declare const CONFIG_ALIAS_REFUSAL: string;
4
+ export declare const CONFIG_ENV_REFUSAL: string;
5
+ export declare const EXPANSION_REFUSAL: string;
6
+ export declare function isExecConfigKey(key: string): boolean;
7
+ export declare const EXEC_CONFIG_REFUSAL: string;
8
+ /** `git config [scope flags] alias.<name> <value>` (also `--add`, `--replace-all`, `set`) with a dangerous VALUE, or an executing key. */
9
+ export declare function configAliasRefusal(args: readonly string[]): string | undefined;
10
+ /**
11
+ * True when some command of `command` (split coarsely on `; & |` and newlines)
12
+ * has an expansion as its head or as its git subcommand AND mentions a guarded
13
+ * verb: the verb is then probably what the expansion builds.
14
+ */
15
+ export declare function buildsVerbByExpansion(command: string): boolean;
16
+ /** The code string of `python -c`, `node -e`, `perl -e`, `ruby -e` (behind wrappers), or undefined. */
17
+ export declare function interpreterCode(words: readonly string[]): string | undefined;
18
+ /** Code reduced to shell-like words: quotes, brackets, parentheses and commas become spaces. */
19
+ export declare function flattenCode(code: string): string;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Ways a git verb is hidden from the Bash guard for nax's run state (#2493):
3
+ * an alias defined with `git config`, an interpreter's code string, and a verb
4
+ * or command head built by shell expansion. Pure text helpers; the guard
5
+ * decides, from whether a `.nax/` exists, whether a hit is a refusal.
6
+ */
7
+ import { commandHeadIndex, gitExecutableIndex, locateGit } from "./git-flags/locate.js";
8
+ /**
9
+ * The verbs the guard protects `.nax/` from. A verb inside a file name or path (`reset.test.ts`,
10
+ * `src/reset`) is not a mention.
11
+ */
12
+ const GUARDED_VERB = /(?<![\w./-])(?:stash|update-index|checkout|restore|reset|clean|switch)(?![\w./-])/;
13
+ /** True when `text` names a guarded git verb as a word (`alias.s=stash` and `` stash` `` count). */
14
+ export function mentionsGuardedVerb(text) {
15
+ return GUARDED_VERB.test(text);
16
+ }
17
+ export const CONFIG_ALIAS_REFUSAL = "Defining a git alias whose value runs stash, update-index, checkout, restore or reset is refused while the " +
18
+ "repository holds nax's run state under `.nax/`: the alias would run that verb where it cannot be inspected. " +
19
+ "Run the git verb directly.";
20
+ export const CONFIG_ENV_REFUSAL = "Git configuration injected through `GIT_CONFIG_*` cannot be inspected while the repository holds nax's run " +
21
+ "state under `.nax/`. Run the git verb directly, without `GIT_CONFIG_*`.";
22
+ export const EXPANSION_REFUSAL = "A git verb or command built by shell expansion (`$VAR`, `$(...)`, backticks) cannot be inspected while the " +
23
+ "repository holds nax's run state under `.nax/`. Write the command out literally.";
24
+ /** An alias value is dangerous when it names a guarded verb; `reset` only when it discards (`reset HEAD --` unstages). */
25
+ function aliasValueDangerous(value) {
26
+ const withoutReset = value.replace(/(?<![\w./-])reset(?![\w./-])/g, " ");
27
+ if (mentionsGuardedVerb(withoutReset))
28
+ return true;
29
+ return /(?<![\w./-])reset(?![\w./-])/.test(value) && /(?:^|\s)--(?:ha|ke|me)/.test(value);
30
+ }
31
+ /** Config keys whose value git EXECUTES (or reads more config from): an agent can run any verb through them. */
32
+ const EXEC_CONFIG_KEY = /^(?:include\.path|includeif\..+|core\.(?:fsmonitor|hookspath|pager|editor|sshcommand|askpass)|diff\.external|sequence\.editor|.+\.textconv|.+\.process|filter\..+\.(?:clean|smudge|process)|credential\.helper|gpg\.program|sendemail\.smtpserver)$/;
33
+ export function isExecConfigKey(key) {
34
+ return EXEC_CONFIG_KEY.test(key.toLowerCase());
35
+ }
36
+ export const EXEC_CONFIG_REFUSAL = "Setting a git config key whose value git executes (core.fsmonitor, core.hooksPath, core.pager, core.editor, " +
37
+ "core.sshCommand, diff.external, sequence.editor, include.path, *.textconv, filter.*.clean/smudge, ...) is refused " +
38
+ "while the repository holds nax's run state under `.nax/`: it can run any git verb where it cannot be inspected.";
39
+ /** `git config [flags] KEY VALUE` / `--add` / `set` of an executing key; a read or `--unset` (key only) is fine. */
40
+ function configWritesExecKey(args) {
41
+ const operands = args.filter((word) => !word.startsWith("-") && word !== "set");
42
+ const at = operands.findIndex(isExecConfigKey);
43
+ return at !== -1 && at < operands.length - 1 && !args.some((a) => /^--(?:unset|get)/.test(a));
44
+ }
45
+ /** `git config [scope flags] alias.<name> <value>` (also `--add`, `--replace-all`, `set`) with a dangerous VALUE, or an executing key. */
46
+ export function configAliasRefusal(args) {
47
+ if (configWritesExecKey(args))
48
+ return EXEC_CONFIG_REFUSAL;
49
+ const at = args.findIndex((word) => /^alias\./i.test(word));
50
+ if (at === -1)
51
+ return undefined;
52
+ return aliasValueDangerous(args.slice(at + 1).join(" ")) ? CONFIG_ALIAS_REFUSAL : undefined;
53
+ }
54
+ const EXPANSION = /[$`]/;
55
+ /**
56
+ * True when some command of `command` (split coarsely on `; & |` and newlines)
57
+ * has an expansion as its head or as its git subcommand AND mentions a guarded
58
+ * verb: the verb is then probably what the expansion builds.
59
+ */
60
+ export function buildsVerbByExpansion(command) {
61
+ return command.split(/[;&|\n]+/).some((part) => {
62
+ if (!GUARDED_VERB.test(part))
63
+ return false;
64
+ const words = part.trim().split(/\s+/).filter(Boolean);
65
+ // Without a git token (or an alias definition) in the segment the verb is not git's: `$RUNNER test reset.ts`.
66
+ if (!words.some((w) => w === "git" || w === "\\git" || w.endsWith("/git") || w.includes("alias.")))
67
+ return false;
68
+ const head = words[commandHeadIndex(words)];
69
+ if (head !== undefined && EXPANSION.test(head))
70
+ return true;
71
+ return gitExecutableIndex(words) !== undefined && EXPANSION.test(locateGit(words)?.subcommand ?? "");
72
+ });
73
+ }
74
+ const INTERPRETER = /^(?:python[\d.]*|node(?:js)?|perl|ruby|bun|deno|php|lua[\d.]*|osascript|pwsh|powershell)$/;
75
+ const CODE_FLAG = /^-[A-Za-z]*[ceEpr]$|^--eval$|^eval$|^-(?:Command|command)$/;
76
+ /** The code string of `python -c`, `node -e`, `perl -e`, `ruby -e` (behind wrappers), or undefined. */
77
+ export function interpreterCode(words) {
78
+ const head = commandHeadIndex(words);
79
+ const name = words[head]?.split("/").pop();
80
+ if (name === undefined || !INTERPRETER.test(name))
81
+ return undefined;
82
+ const flag = words.findIndex((word, i) => i > head && CODE_FLAG.test(word));
83
+ return flag === -1 || flag + 1 >= words.length ? undefined : words.slice(flag + 1).join(" ");
84
+ }
85
+ /** Code reduced to shell-like words: quotes, brackets, parentheses and commas become spaces. */
86
+ export function flattenCode(code) {
87
+ return code.replace(/["'`[\](),]/g, " ");
88
+ }
@@ -0,0 +1,34 @@
1
+ import { type NaxGuardContext } from "./nax-dirs.ts";
2
+ /**
3
+ * The pathspec that covers EVERY `.nax` directory AND the files inside them, named in advice texts. Real git:
4
+ * the glob must end in `/**` (the bare `.nax` glob matches only the directory entry) and be anchored at the
5
+ * top (a relative glob is taken from the working directory).
6
+ */
7
+ export declare const COVERING_EXCLUSION = "':(top,exclude,glob)**/.nax/**'";
8
+ /**
9
+ * The path an exclusion pathspec excludes, or undefined when `spec` is not one: `:!X`, `:^X`, or a long form
10
+ * with an element exactly `exclude` and no `attr:` element (an `attr:` spec is an attribute filter whatever
11
+ * else it says).
12
+ */
13
+ export declare function exclusionBody(spec: string): string | undefined;
14
+ export declare const isExclusionSpec: (spec: string) => boolean;
15
+ /** Exactly the covering form, magic in any order. */
16
+ export declare function isCoveringExclusion(spec: string): boolean;
17
+ /** `--force` -> true for `--f`, `--fo`, `--force`: any option abbreviation of `long` (`--x=v` too). */
18
+ export declare function isLongPrefix(word: string, long: string): boolean;
19
+ /** True when `.nax` is a path segment of `path`. */
20
+ export declare function hasNaxSegment(path: string): boolean;
21
+ /** True when `operand`, resolved from any frame, lands on or under a `.nax` directory. */
22
+ export declare function operandTouchesNax(operand: string, ctx: NaxGuardContext): boolean;
23
+ /**
24
+ * An exclusion that covers EVERY `.nax` the guard knows of. The covering glob always does. The literal forms
25
+ * (`:!.nax`, `:^.nax/`, `:(exclude).nax`) name only `<root>/.nax` (resolved from EVERY frame, or from the root
26
+ * under `top`), so they count only while that is the single `.nax` there is.
27
+ */
28
+ export declare function isFullNaxExclusion(spec: string, ctx: NaxGuardContext): boolean;
29
+ /** The refusal for `git checkout` / `git restore` with these arguments, or undefined. */
30
+ export declare function revertRefusal(verb: string, args: readonly string[], ctx: NaxGuardContext): string | undefined;
31
+ /** `git reset --hard` / `--keep` / `--merge` (any abbreviation git accepts) while a `.nax/` exists. */
32
+ export declare function resetRefusal(args: readonly string[], ctx: NaxGuardContext): string | undefined;
33
+ /** `switch -f`, `clean`, `checkout-index -a -f`, `read-tree -u --reset`: other ways to wipe the tree. */
34
+ export declare function discardRefusal(verb: string, args: readonly string[], ctx: NaxGuardContext): string | undefined;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Reverts of nax's run state (`.nax/`) through git, for the Bash guard
3
+ * (#2490, #2493).
4
+ *
5
+ * Three ways a revert reaches `.nax/`:
6
+ * 1. an operand that names a `.nax` path (`git checkout -- .nax/x`);
7
+ * 2. a WHOLE-TREE pathspec (`git checkout .`, `git restore --source=HEAD~1 .`,
8
+ * `:/`, `*`, the root directory itself, a pathspec made only of exclusions),
9
+ * unless a full `.nax` exclusion follows `--`;
10
+ * 3. `git reset --hard`, which has no pathspec form at all.
11
+ *
12
+ * For `git checkout` an operand is a PATH only after `--`, when it exists on
13
+ * disk relative to a frame, or when it resolves into `.nax` from the root; so
14
+ * `git checkout feature/.nax` (a branch whose name has a `.nax` segment) is
15
+ * not a revert. `git restore` has no branch operand, so all its operands are paths.
16
+ */
17
+ import { existsSync } from "node:fs";
18
+ import { join, relative, resolve, sep } from "node:path";
19
+ import { NAX_OWNED_RUN_STATE_EXPLANATION } from "./denial-redirect.js";
20
+ import { findNaxDirs } from "./nax-dirs.js";
21
+ /**
22
+ * The pathspec that covers EVERY `.nax` directory AND the files inside them, named in advice texts. Real git:
23
+ * the glob must end in `/**` (the bare `.nax` glob matches only the directory entry) and be anchored at the
24
+ * top (a relative glob is taken from the working directory).
25
+ */
26
+ export const COVERING_EXCLUSION = "':(top,exclude,glob)**/.nax/**'";
27
+ /** `:(a,b)body` split on commas; git does not abbreviate long magic names. */
28
+ function parseMagic(spec) {
29
+ const match = /^:\(([^)]*)\)(.*)$/s.exec(spec);
30
+ return match === null ? undefined : { elements: match[1].split(","), body: match[2] };
31
+ }
32
+ /**
33
+ * The path an exclusion pathspec excludes, or undefined when `spec` is not one: `:!X`, `:^X`, or a long form
34
+ * with an element exactly `exclude` and no `attr:` element (an `attr:` spec is an attribute filter whatever
35
+ * else it says).
36
+ */
37
+ export function exclusionBody(spec) {
38
+ const short = /^:[!^](.*)$/s.exec(spec);
39
+ if (short !== null)
40
+ return short[1];
41
+ const magic = parseMagic(spec);
42
+ if (magic === undefined || !magic.elements.includes("exclude"))
43
+ return undefined;
44
+ return magic.elements.some((e) => e.startsWith("attr")) ? undefined : magic.body;
45
+ }
46
+ export const isExclusionSpec = (spec) => exclusionBody(spec) !== undefined;
47
+ /** Exactly the covering form, magic in any order. */
48
+ export function isCoveringExclusion(spec) {
49
+ const magic = parseMagic(spec);
50
+ if (magic === undefined || magic.body !== "**/.nax/**" || magic.elements.length !== 3)
51
+ return false;
52
+ return ["top", "exclude", "glob"].every((e) => magic.elements.includes(e));
53
+ }
54
+ /** `--force` -> true for `--f`, `--fo`, `--force`: any option abbreviation of `long` (`--x=v` too). */
55
+ export function isLongPrefix(word, long) {
56
+ const name = word.split("=")[0];
57
+ return name.startsWith("--") && name.length >= 3 && long.startsWith(name);
58
+ }
59
+ /** True when `.nax` is a path segment of `path`. */
60
+ export function hasNaxSegment(path) {
61
+ return path.split(/[\\/]/).includes(".nax");
62
+ }
63
+ /** True when `operand`, resolved from any frame, lands on or under a `.nax` directory. */
64
+ export function operandTouchesNax(operand, ctx) {
65
+ if (hasNaxSegment(operand))
66
+ return true;
67
+ return ctx.frames.some((frame) => {
68
+ const rel = relative(ctx.root, resolve(frame, operand));
69
+ return !rel.startsWith("..") && hasNaxSegment(rel);
70
+ });
71
+ }
72
+ /**
73
+ * An exclusion that covers EVERY `.nax` the guard knows of. The covering glob always does. The literal forms
74
+ * (`:!.nax`, `:^.nax/`, `:(exclude).nax`) name only `<root>/.nax` (resolved from EVERY frame, or from the root
75
+ * under `top`), so they count only while that is the single `.nax` there is.
76
+ */
77
+ export function isFullNaxExclusion(spec, ctx) {
78
+ if (isCoveringExclusion(spec))
79
+ return true;
80
+ const target = exclusionBody(spec);
81
+ if (target === undefined)
82
+ return false;
83
+ const rootNax = join(ctx.root, ".nax");
84
+ if (findNaxDirs(ctx).some((dir) => dir !== rootNax))
85
+ return false;
86
+ const path = target.replace(/\/(?:\*\*)?$/, "");
87
+ const top = parseMagic(spec)?.elements.includes("top") === true;
88
+ return (top ? [ctx.root] : ctx.frames).every((frame) => resolve(frame, path) === rootNax);
89
+ }
90
+ /** Options whose value occupies the next word, per verb. */
91
+ const VALUE_FLAGS = {
92
+ checkout: new Set(["-b", "-B", "--orphan", "--pathspec-from-file"]),
93
+ restore: new Set(["-s", "--source", "--pathspec-from-file"]),
94
+ clean: new Set(["-e", "--exclude"]),
95
+ };
96
+ function splitOperands(verb, args) {
97
+ const valueFlags = VALUE_FLAGS[verb] ?? new Set();
98
+ const before = [];
99
+ const after = [];
100
+ const flags = [];
101
+ let hasSeparator = false;
102
+ for (let i = 0; i < args.length; i += 1) {
103
+ const word = args[i];
104
+ if (hasSeparator)
105
+ after.push(word);
106
+ else if (word === "--")
107
+ hasSeparator = true;
108
+ else if (word.startsWith("-") && word !== "-") {
109
+ flags.push(word);
110
+ if (valueFlags.has(word))
111
+ i += 1;
112
+ }
113
+ else
114
+ before.push(word);
115
+ }
116
+ return { before, after, hasSeparator, flags };
117
+ }
118
+ /** A checkout operand that is a path although no `--` marks it: it exists on disk, or lands in `.nax` from the root. */
119
+ function isUnmarkedCheckoutPath(word, ctx) {
120
+ return ctx.frames.some((frame) => {
121
+ const target = resolve(frame, word);
122
+ if (existsSync(target))
123
+ return true;
124
+ return relative(ctx.root, target).split(/[\\/]/)[0] === ".nax";
125
+ });
126
+ }
127
+ function pathOperands(verb, ops, ctx) {
128
+ if (verb === "restore")
129
+ return [...ops.before, ...ops.after];
130
+ return [...ops.after, ...ops.before.filter((word) => isUnmarkedCheckoutPath(word, ctx))];
131
+ }
132
+ /** True when `spec` selects a directory that holds one of the `.nax` directories (or all of them, from the top). */
133
+ function coversNax(spec, ctx, nax) {
134
+ if (isExclusionSpec(spec))
135
+ return false;
136
+ const magic = /^:(?:\(([^)]*)\)|\/)?(.*)$/s.exec(spec);
137
+ const top = magic !== null && (spec.startsWith(":/") || /(?:^|,)top(?:,|$)/.test(magic[1] ?? ""));
138
+ const body = (magic === null ? spec : magic[2]).replace(/(?:^|\/)\*{1,2}$/, "");
139
+ const bases = top ? [ctx.root] : ctx.frames;
140
+ return bases.some((base) => {
141
+ const dir = resolve(base, body === "" ? "." : body);
142
+ return nax.some((candidate) => candidate === dir || candidate.startsWith(`${dir}${sep}`));
143
+ });
144
+ }
145
+ /** `--staged` and every unambiguous abbreviation git accepts (`--st`), or a short cluster of only `S`. */
146
+ const STAGED_FLAG = /^(?:--st|-S+$)/;
147
+ /** Flags that change nothing about WHAT is restored. */
148
+ const NEUTRAL_RESTORE_FLAG = /^(?:-q|--quiet|--progress|--no-progress)$/;
149
+ /**
150
+ * `restore --staged` touches only the index. Fails closed: every flag must be `--staged` (or an
151
+ * abbreviation) or a neutral one, so `--wor` / `-SW` / `--source=...` / an unknown flag all count as
152
+ * touching the worktree.
153
+ */
154
+ function touchesIndexOnly(flags) {
155
+ return (flags.some((f) => STAGED_FLAG.test(f)) && flags.every((f) => STAGED_FLAG.test(f) || NEUTRAL_RESTORE_FLAG.test(f)));
156
+ }
157
+ const treeRefusal = (verb) => `${NAX_OWNED_RUN_STATE_EXPLANATION} A whole-tree \`git ${verb}\` reverts it too. ` +
158
+ `Exclude it: \`git ${verb} -- . ${COVERING_EXCLUSION}\`.`;
159
+ const RESET_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} \`git reset --hard\` (and \`--keep\` / \`--merge\`) resets the whole tree, ` +
160
+ "`.nax/` included, and has no pathspec form. " +
161
+ `To discard changes outside it use \`git checkout -- . ${COVERING_EXCLUSION}\`; to unstage use plain \`git reset\`.`;
162
+ const DISCARD_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} This command discards or deletes working-tree changes across the whole tree, ` +
163
+ `\`.nax/\` included. Name the files, or exclude it: \`-- . ${COVERING_EXCLUSION}\` (not available for forced checkouts and switches).`;
164
+ function wholeTreeRefusal(verb, ops, ctx) {
165
+ const nax = findNaxDirs(ctx);
166
+ if (nax.length === 0 || (verb === "restore" && touchesIndexOnly(ops.flags)))
167
+ return undefined;
168
+ if (ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx)))
169
+ return undefined;
170
+ const specs = ops.hasSeparator ? ops.after : ops.before;
171
+ const exclusionOnly = specs.length > 0 && specs.every(isExclusionSpec);
172
+ return exclusionOnly || specs.some((spec) => coversNax(spec, ctx, nax)) ? treeRefusal(verb) : undefined;
173
+ }
174
+ /** `-f` (alone or in a cluster) or `--force` and its abbreviations. */
175
+ const isForceFlag = (word) => isLongPrefix(word, "--force") || (/^-[A-Za-z]+$/.test(word) && word.includes("f"));
176
+ /** A forced checkout throws local changes away whatever it names. */
177
+ function forcedCheckoutRefusal(verb, ops, ctx) {
178
+ if (verb !== "checkout" || !ops.flags.some(isForceFlag) || findNaxDirs(ctx).length === 0)
179
+ return undefined;
180
+ return ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx)) ? undefined : DISCARD_REFUSAL;
181
+ }
182
+ /** The refusal for `git checkout` / `git restore` with these arguments, or undefined. */
183
+ export function revertRefusal(verb, args, ctx) {
184
+ const ops = splitOperands(verb, args);
185
+ // An exclusion NAMES `.nax` to keep it out; it is not an operand that touches it.
186
+ const names = pathOperands(verb, ops, ctx).some((word) => !isExclusionSpec(word) && operandTouchesNax(word, ctx));
187
+ if (names)
188
+ return NAX_OWNED_RUN_STATE_EXPLANATION;
189
+ return wholeTreeRefusal(verb, ops, ctx) ?? forcedCheckoutRefusal(verb, ops, ctx);
190
+ }
191
+ /** `git reset --hard` / `--keep` / `--merge` (any abbreviation git accepts) while a `.nax/` exists. */
192
+ export function resetRefusal(args, ctx) {
193
+ const dangerous = ["--hard", "--keep", "--merge"];
194
+ if (!args.some((word) => dangerous.some((long) => isLongPrefix(word, long))))
195
+ return undefined;
196
+ return findNaxDirs(ctx).length > 0 ? RESET_REFUSAL : undefined;
197
+ }
198
+ const hasFlag = (flags, long, short) => flags.some((f) => isLongPrefix(f, long) || (/^-[A-Za-z]+$/.test(f) && f.includes(short)));
199
+ /**
200
+ * `git clean -n` / `--dry-run`. In a short cluster `-e` takes the rest of the cluster as its value
201
+ * (`-fdenx` is `-f -d -e nx`, a real clean), so only an `n` before any `e` counts.
202
+ */
203
+ const isCleanDryRun = (flags) => flags.some((f) => isLongPrefix(f, "--dry-run") || (/^-[A-Za-z]+$/.test(f) && f.slice(1).split("e")[0].includes("n")));
204
+ /** Operands (and `--opt=value` values) of a discard verb that name a `.nax` path. */
205
+ function namesNax(ops, ctx) {
206
+ const values = ops.flags.filter((f) => f.includes("=")).map((f) => f.slice(f.indexOf("=") + 1));
207
+ return [...ops.before, ...ops.after, ...values].some((word) => !isExclusionSpec(word) && operandTouchesNax(word, ctx));
208
+ }
209
+ /** Does this switch / clean / checkout-index / read-tree invocation wipe the tree (given no covering exclusion)? */
210
+ function wipesTree(verb, ops, ctx) {
211
+ const f = ops.flags;
212
+ if (verb === "switch")
213
+ return f.some(isForceFlag) || f.some((w) => isLongPrefix(w, "--discard-changes"));
214
+ if (verb === "clean") {
215
+ const covered = ops.hasSeparator && ops.after.some((spec) => isFullNaxExclusion(spec, ctx));
216
+ return !covered && !isCleanDryRun(f);
217
+ }
218
+ if (verb === "checkout-index")
219
+ return hasFlag(f, "--all", "a") && hasFlag(f, "--force", "f");
220
+ if (verb === "read-tree")
221
+ return f.some((w) => isLongPrefix(w, "--reset")) && hasFlag(f, "--update", "u");
222
+ return false;
223
+ }
224
+ const NAMED_PATH_VERBS = new Set(["checkout-index", "read-tree", "clean"]);
225
+ /** `switch -f`, `clean`, `checkout-index -a -f`, `read-tree -u --reset`: other ways to wipe the tree. */
226
+ export function discardRefusal(verb, args, ctx) {
227
+ const ops = splitOperands(verb, args);
228
+ if (NAMED_PATH_VERBS.has(verb) && namesNax(ops, ctx))
229
+ return NAX_OWNED_RUN_STATE_EXPLANATION;
230
+ if (findNaxDirs(ctx).length === 0)
231
+ return undefined;
232
+ return wipesTree(verb, ops, ctx) ? DISCARD_REFUSAL : undefined;
233
+ }
@@ -0,0 +1,7 @@
1
+ import type { NaxGuardContext } from "./nax-dirs.ts";
2
+ export declare const _naxStateGuardDeps: {
3
+ /** stdout on success, undefined on a spawn error, timeout or non-zero exit. */
4
+ git(cwd: string, args: readonly string[]): string | undefined;
5
+ };
6
+ /** Stash mutations that can be proved not to capture or replay nax state. */
7
+ export declare function stashIsExempt(args: readonly string[], ctx: NaxGuardContext): boolean;
@@ -0,0 +1,92 @@
1
+ // Synchronous policy checks cannot use the asynchronous runtime spawn slot (#2539).
2
+ import { spawnSync } from "node:child_process";
3
+ import { lstatSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { gitSpawnEnv } from "#src/internal/git-env";
6
+ import { GIT_TIMEOUT_MS } from "#src/internal/git-exec";
7
+ import { isFullNaxExclusion, operandTouchesNax } from "./nax-revert.js";
8
+ export const _naxStateGuardDeps = {
9
+ /** stdout on success, undefined on a spawn error, timeout or non-zero exit. */
10
+ git(cwd, args) {
11
+ const out = spawnSync("git", [...args], {
12
+ cwd,
13
+ env: gitSpawnEnv(),
14
+ timeout: GIT_TIMEOUT_MS,
15
+ maxBuffer: 16 * 1024 * 1024,
16
+ encoding: "utf8",
17
+ });
18
+ return out.status === 0 && out.error === undefined ? out.stdout : undefined;
19
+ },
20
+ };
21
+ /** Parse only [--index] [-q|--quiet] [<numeric stash ref>], before doing any I/O. */
22
+ function restoreRequest(args) {
23
+ const index = args[0] === "--index";
24
+ let i = index ? 1 : 0;
25
+ if (args[i] === "-q" || args[i] === "--quiet")
26
+ i += 1;
27
+ if (i === args.length)
28
+ return { ref: "stash@{0}", index };
29
+ const ref = args[i];
30
+ return i + 1 === args.length && ref !== undefined && /^(?:stash@\{\d+\}|\d+)$/.test(ref) ? { ref, index } : undefined;
31
+ }
32
+ /** Git -z output is unquoted; a .nax segment at ANY depth protects the whole operation. */
33
+ function listingIsClean(cwd, args) {
34
+ const listing = _naxStateGuardDeps.git(cwd, args);
35
+ return listing !== undefined && !listing.split("\0").some((path) => path.split("/").includes(".nax"));
36
+ }
37
+ /** The listing of the stash's WORKING-TREE snapshot: what a plain `pop` / `apply` writes back. */
38
+ function worktreeListingIsClean(cwd, ref) {
39
+ return listingIsClean(cwd, ["stash", "show", "-z", "--name-only", "--no-renames", "--include-untracked", ref]);
40
+ }
41
+ /**
42
+ * The stash's INDEX snapshot (B1): a stash commit has one parent for the base and
43
+ * one for the index, and `--index` writes that second parent back into the index.
44
+ * It is NOT the tree `stash show` lists — a `.nax` change that was staged and then
45
+ * reverted in the working tree appears in the index parent alone, so a listing-only
46
+ * check would let `pop --index` replay stale nax state over what nax has written
47
+ * since. Any failure to read it refuses.
48
+ */
49
+ function indexSnapshotIsClean(cwd, ref) {
50
+ // A bare `N` resolves inside `git stash` only, so the diff — a plain revision —
51
+ // needs the `stash@{N}` spelling git itself resolves the number to.
52
+ const rev = ref.includes("@{") ? ref : `stash@{${ref}}`;
53
+ return listingIsClean(cwd, ["diff", "-z", "--name-only", "--no-renames", `${rev}^1`, `${rev}^2`]);
54
+ }
55
+ /** Every frame must list the stash successfully and clean, index snapshot included for `--index`. */
56
+ function restoreIsExempt(args, ctx) {
57
+ const request = restoreRequest(args);
58
+ if (request === undefined)
59
+ return false;
60
+ return ctx.frames.every((frame) => worktreeListingIsClean(frame, request.ref) && (!request.index || indexSnapshotIsClean(frame, request.ref)));
61
+ }
62
+ /** Literal existing files or directories only; never symlinks, magic or globs. */
63
+ function pathIsClean(spec, ctx) {
64
+ if (spec.startsWith(":") || /[*?[]/.test(spec) || operandTouchesNax(spec, ctx))
65
+ return false;
66
+ return ctx.frames.every((frame) => {
67
+ try {
68
+ const path = resolve(frame, spec);
69
+ const stat = lstatSync(path);
70
+ if (stat.isFile())
71
+ return true;
72
+ if (!stat.isDirectory() || path === ctx.root)
73
+ return false;
74
+ return listingIsClean(frame, ["ls-files", "-z", "--cached", "--others", "--", spec]);
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ });
80
+ }
81
+ /** Stash mutations that can be proved not to capture or replay nax state. */
82
+ export function stashIsExempt(args, ctx) {
83
+ if (args[0] === "pop" || args[0] === "apply")
84
+ return restoreIsExempt(args.slice(1), ctx);
85
+ const separator = args.indexOf("--");
86
+ if (args[0] !== "push" || separator === -1)
87
+ return false;
88
+ const specs = args.slice(separator + 1);
89
+ if (specs.some((spec) => isFullNaxExclusion(spec, ctx)))
90
+ return true;
91
+ return specs.length > 0 && specs.every((spec) => pathIsClean(spec, ctx));
92
+ }
@@ -0,0 +1,18 @@
1
+ import { type NaxGuardContext } from "./nax-dirs.ts";
2
+ export type { NaxDirIndex, NaxGuardContext } from "./nax-dirs.ts";
3
+ export { createNaxDirIndex } from "./nax-dirs.ts";
4
+ /**
5
+ * The refusal for one command segment (its words, `git` possibly behind
6
+ * wrappers and global options), or undefined. A segment whose git invocation
7
+ * cannot be located is NOT judged here; see the BACKSTOP in the header. One
8
+ * that CAN be located but met a global option this reader does not know is
9
+ * judged by the structured rules and then, with a `.nax/` present, by the BACKSTOP.
10
+ */
11
+ export declare function naxStateRefusal(segment: readonly string[], ctx: NaxGuardContext): string | undefined;
12
+ /**
13
+ * `naxStateRefusal` over command TEXT: the raw command string in every Bash
14
+ * mode, and the string behind `bash -c` / `eval`. Tracks `cd` itself, applies
15
+ * the structured rules to each segment, and the coarse BACKSTOP (see the
16
+ * header) to segments whose git invocation it could not read.
17
+ */
18
+ export declare function naxStateRefusalInText(command: string, ctx: NaxGuardContext): string | undefined;