@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
@@ -62,8 +62,14 @@ export interface BashToolOptions {
62
62
  */
63
63
  readonly launcher?: CommandLauncher;
64
64
  }
65
- /** Injectable spawn seam, mirroring `_argvExecDeps`. */
65
+ /**
66
+ * Injectable spawn seam, mirroring `_argvExecDeps`, plus the platform seam
67
+ * US-006 added: it decides whether a description carries the macOS ps note, and
68
+ * exists so a test can pin both platforms without touching the global
69
+ * `process.platform`.
70
+ */
66
71
  export declare const _bashToolDeps: {
67
72
  runArgv: typeof runArgv;
73
+ platform: () => NodeJS.Platform;
68
74
  };
69
75
  export declare function createBashTool(opts?: BashToolOptions): CodingTool;
@@ -37,9 +37,15 @@ export const BASH_TIMEOUT_MS = 300_000;
37
37
  * even start, and a 0 would disable the deadline entirely. */
38
38
  const MIN_BASH_TIMEOUT_MS = 1_000;
39
39
  export const DEFAULT_BASH_SHELL = "/bin/sh";
40
- /** Injectable spawn seam, mirroring `_argvExecDeps`. */
40
+ /**
41
+ * Injectable spawn seam, mirroring `_argvExecDeps`, plus the platform seam
42
+ * US-006 added: it decides whether a description carries the macOS ps note, and
43
+ * exists so a test can pin both platforms without touching the global
44
+ * `process.platform`.
45
+ */
41
46
  export const _bashToolDeps = {
42
47
  runArgv,
48
+ platform: () => process.platform,
43
49
  };
44
50
  function describeGrants(patterns) {
45
51
  const named = (patterns ?? []).filter((pattern) => pattern !== "*");
@@ -124,6 +130,12 @@ function rawDescription(shell, containment = RAW_UNCONTAINED) {
124
130
  "A `find` whose start path is `/`, `~` or `$HOME` is refused -- search within the repository instead. " +
125
131
  BACKGROUND_PROCESSES_KILLED_SENTENCE);
126
132
  }
133
+ /**
134
+ * US-006: no `sandboxSentence` here, deliberately. This variant describes a
135
+ * sandbox that is NOT running, so the darwin ps note -- which explains a
136
+ * refusal an AVAILABLE sandbox makes -- would be a false statement about this
137
+ * machine. The refusal names its own reason and that every call is refused.
138
+ */
127
139
  function rawUnavailableDescription(shell, reason) {
128
140
  return (`Run one shell command string under ${shell} -- but on this machine ${rawBashRefusalReason(reason)} ` +
129
141
  "Under raw mode every call is refused; use the structured tools (Read, Glob, Grep, Git, RunCommand) instead. " +
@@ -131,9 +143,12 @@ function rawUnavailableDescription(shell, reason) {
131
143
  }
132
144
  function bashToolDescription(shell, opts) {
133
145
  const state = opts.launcher?.state ?? { kind: "disabled" };
146
+ // US-006: read ONCE, so every `sandboxSentence` call site in this description
147
+ // agrees on the host's platform.
148
+ const platform = _bashToolDeps.platform();
134
149
  if (opts.bashApproval === "raw") {
135
150
  if (state.kind === "available")
136
- return rawDescription(shell, `The command runs from the repository root ${sandboxSentence(state.network, state.sharedTmp !== false)} `);
151
+ return rawDescription(shell, `The command runs from the repository root ${sandboxSentence(state.network, state.sharedTmp !== false, platform)} `);
137
152
  if (state.kind === "unavailable")
138
153
  return rawUnavailableDescription(shell, state.reason);
139
154
  return rawDescription(shell);
@@ -148,7 +163,7 @@ function bashToolDescription(shell, opts) {
148
163
  ? escalateDescription(shell, opts.patterns)
149
164
  : gatedDescription(shell, opts.patterns);
150
165
  if (state.kind === "available") {
151
- return `${policyDescription} Commands that pass run ${sandboxSentence(state.network, state.sharedTmp !== false)}`;
166
+ return `${policyDescription} Commands that pass run ${sandboxSentence(state.network, state.sharedTmp !== false, platform)}`;
152
167
  }
153
168
  if (state.kind === "unavailable")
154
169
  return `${policyDescription} ${unsandboxedSentence(state.reason)}`;
@@ -19,16 +19,31 @@
19
19
  * `grep -n x y` did not. 20 of 34 denials in the two runs after #1971 carried
20
20
  * no redirect (nax#1999). A single table cannot drift against itself.
21
21
  */
22
- export declare function redirectForArgv(argv: readonly string[], available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
22
+ export declare const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
23
23
  /**
24
- * Name the tool that serves the intent behind a denied Bash COMMAND.
24
+ * The sentence explaining a revert of nax's own run state, or undefined.
25
+ *
26
+ * Deliberately NOT an `Intent`: `render()` wraps every hit as "this session
27
+ * already has `X`", which is false here -- no tool reverts `.nax/`, and the
28
+ * module's header forbids naming one it does not hold. So this predicate is
29
+ * checked BEFORE `intentFor` in both entry points and returns its own sentence.
25
30
  *
26
- * Reads the first segment only: that is what the model reached for, and a
27
- * later segment's intent is not what to teach when the call is refused. The
28
- * split is deliberately its own crude one rather than the lexer's
29
- * (`lexBashCommand`): a command REFUSED for an unanalysable construct must
30
- * still get a redirect, and the lexer returns nothing to read in that case.
31
+ * Verb scope is `checkout` and `restore` only; `git stash` has no operands to
32
+ * gate on and is handled by `nax-state-guard.ts` instead. The gate is a `.nax`
33
+ * path SEGMENT (split on `/`), never a substring, so `src/nax-helpers.ts` and
34
+ * `docs/.naxignore` stay unanswered. `--` is a separator, not an operand, and a
35
+ * `timeout N` wrapper is stripped the same way `intentFor` strips it.
36
+ */
37
+ export declare function naxOwnedRunStateExplanation(tokens: readonly string[]): string | undefined;
38
+ /** A `timeout N ...` prefix wraps the real command; look past it. */
39
+ export declare function withoutTimeoutPrefix(tokens: readonly string[]): readonly string[];
40
+ export declare function redirectForArgv(argv: readonly string[], available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
41
+ /**
42
+ * The crude segment split shared by the redirect and the raw screen's fallback
43
+ * for a command the lexer refused. Deliberately not the lexer: it must still
44
+ * produce segments when the lexer returns nothing to read.
31
45
  */
46
+ export declare function crudeCommandSegments(command: string): string[][];
32
47
  export declare function redirectForCommand(command: string, available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
33
48
  /**
34
49
  * Name the tool that serves the intent behind a denied VERB call.
@@ -32,7 +32,7 @@ const GIT_COMMIT_VERBS = new Set(["add", "commit"]);
32
32
  * intent, so the refusal teaches instead of naming one (nax#2007).
33
33
  */
34
34
  const GIT_REVERT_VERBS = new Set(["checkout", "restore"]);
35
- const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
35
+ export const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
36
36
  /**
37
37
  * The sentence explaining a revert of nax's own run state, or undefined.
38
38
  *
@@ -41,13 +41,13 @@ const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written
41
41
  * module's header forbids naming one it does not hold. So this predicate is
42
42
  * checked BEFORE `intentFor` in both entry points and returns its own sentence.
43
43
  *
44
- * Verb scope is `checkout` and `restore` only; `git stash` is the agent's own
45
- * WIP, not nax's state, and has no operands to gate on. The gate is a `.nax`
44
+ * Verb scope is `checkout` and `restore` only; `git stash` has no operands to
45
+ * gate on and is handled by `nax-state-guard.ts` instead. The gate is a `.nax`
46
46
  * path SEGMENT (split on `/`), never a substring, so `src/nax-helpers.ts` and
47
47
  * `docs/.naxignore` stay unanswered. `--` is a separator, not an operand, and a
48
48
  * `timeout N` wrapper is stripped the same way `intentFor` strips it.
49
49
  */
50
- function naxOwnedRunStateExplanation(tokens) {
50
+ export function naxOwnedRunStateExplanation(tokens) {
51
51
  const av = withoutTimeoutPrefix(tokens);
52
52
  const hasGitPrefix = av[0] === "git";
53
53
  const verb = hasGitPrefix ? av[1] : av[0];
@@ -142,7 +142,7 @@ function isSingleFileDelete(tokens, from) {
142
142
  return tokens.length === from + 1 && !tokens[from]?.startsWith("-");
143
143
  }
144
144
  /** A `timeout N ...` prefix wraps the real command; look past it. */
145
- function withoutTimeoutPrefix(tokens) {
145
+ export function withoutTimeoutPrefix(tokens) {
146
146
  return tokens[0] === "timeout" ? tokens.slice(2) : tokens;
147
147
  }
148
148
  /**
@@ -211,23 +211,39 @@ export function redirectForArgv(argv, available, declaredCommands) {
211
211
  /**
212
212
  * Name the tool that serves the intent behind a denied Bash COMMAND.
213
213
  *
214
- * Reads the first segment only: that is what the model reached for, and a
215
- * later segment's intent is not what to teach when the call is refused. The
214
+ * The intent redirect reads the first segment only: that is what the model
215
+ * reached for, and a later segment's intent is not what to teach when the call
216
+ * is refused. The
216
217
  * split is deliberately its own crude one rather than the lexer's
217
218
  * (`lexBashCommand`): a command REFUSED for an unanalysable construct must
218
219
  * still get a redirect, and the lexer returns nothing to read in that case.
219
220
  */
220
- export function redirectForCommand(command, available, declaredCommands) {
221
- const [first = ""] = command.split(/&&|\|\||;|\||\n/);
222
- const tokens = first
221
+ function tokenize(segment) {
222
+ return segment
223
223
  .trim()
224
224
  .split(/\s+/)
225
225
  .filter((token) => token.length > 0);
226
+ }
227
+ /**
228
+ * The crude segment split shared by the redirect and the raw screen's fallback
229
+ * for a command the lexer refused. Deliberately not the lexer: it must still
230
+ * produce segments when the lexer returns nothing to read.
231
+ */
232
+ export function crudeCommandSegments(command) {
233
+ return command.split(/&&|\|\||;|\||\n/).map(tokenize);
234
+ }
235
+ export function redirectForCommand(command, available, declaredCommands) {
236
+ const segments = crudeCommandSegments(command);
237
+ // A revert of nax's run state is explained wherever it sits in a compound
238
+ // command (#2490); only the intent redirect below is first-segment-only.
239
+ for (const segment of segments) {
240
+ const ownedRunState = naxOwnedRunStateExplanation(segment);
241
+ if (ownedRunState !== undefined)
242
+ return ownedRunState;
243
+ }
244
+ const tokens = segments[0] ?? [];
226
245
  if (tokens.length === 0)
227
246
  return undefined;
228
- const ownedRunState = naxOwnedRunStateExplanation(tokens);
229
- if (ownedRunState !== undefined)
230
- return ownedRunState;
231
247
  const hit = intentFor(tokens);
232
248
  if (hit === undefined)
233
249
  return taskRunnerFallback(tokens, available, declaredCommands);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Locating `git` and its subcommand inside a command line.
3
+ *
4
+ * A leaf module with NO imports, like `./index.ts`, so both `command-safety`
5
+ * and `tools` can use it without a cycle. Shared by the command-safety scorer
6
+ * and the Bash guard for nax's run state (#2490), so a wrapper or global
7
+ * option that hides `git` from one cannot hide it from the other.
8
+ */
9
+ /**
10
+ * Index of the word that actually names the command, after leading `VAR=x`
11
+ * assignments and the wrappers that pass through to it (`sudo`, `env`,
12
+ * `command`, `exec`, `nice`, `timeout`, `xargs`).
13
+ */
14
+ export declare function commandHeadIndex(words: readonly string[]): number;
15
+ /** Find `git` in the executable position after supported wrappers. */
16
+ export declare function gitExecutableIndex(words: readonly string[]): number | undefined;
17
+ export interface LocatedGit {
18
+ readonly subcommand: string;
19
+ readonly args: readonly string[];
20
+ /**
21
+ * Values of `-C`, `--work-tree` and `--git-dir` (both `--key value` and
22
+ * `--key=value`) and of leading `GIT_WORK_TREE=` / `GIT_DIR=` assignments, in order.
23
+ */
24
+ readonly dirs: readonly string[];
25
+ /** True when an alias is defined through `--config-env=alias.*`: its value lives in the environment and cannot be read. */
26
+ readonly unresolvedAlias: boolean;
27
+ /** True when a global option this reader does not know sat before the subcommand: the subcommand found may be that option's value. */
28
+ readonly uncertain: boolean;
29
+ /** The shell text a `!` alias runs (its value without the `!`, then the call's remaining arguments), or undefined. */
30
+ readonly shellAlias: string | undefined;
31
+ /** Lower-cased keys set by `-c key=value` and `--config-env=key=VAR`, in order. */
32
+ readonly configKeys: readonly string[];
33
+ /** True when a leading assignment sets a `GIT_CONFIG*` variable (config, including aliases, injected through the environment). */
34
+ readonly configEnv: boolean;
35
+ /** True under `--literal-pathspecs` or a non-empty, non-`0` `GIT_LITERAL_PATHSPECS`: every `:(...)` / `:!` exclusion is a literal path. */
36
+ readonly literalPathspecs: boolean;
37
+ }
38
+ /**
39
+ * Locate executable `git` in `words` and advance past its global options.
40
+ * Returns the subcommand (alias-expanded), its arguments, and the directories
41
+ * the global options point git at, or `undefined` when `git` isn't there or has
42
+ * no subcommand. Handles `sudo git`, `env VAR=x git`, a backslash-prefixed
43
+ * `git`, `git --no-pager checkout`, and an UNKNOWN `-x` / `--xxx` global
44
+ * (skipped as one word and flagged `uncertain`, so a new git option cannot
45
+ * hide the subcommand silently).
46
+ */
47
+ export declare function locateGit(words: readonly string[]): LocatedGit | undefined;
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Locating `git` and its subcommand inside a command line.
3
+ *
4
+ * A leaf module with NO imports, like `./index.ts`, so both `command-safety`
5
+ * and `tools` can use it without a cycle. Shared by the command-safety scorer
6
+ * and the Bash guard for nax's run state (#2490), so a wrapper or global
7
+ * option that hides `git` from one cannot hide it from the other.
8
+ */
9
+ /** `sudo` options whose values occupy the next shell word. */
10
+ const SUDO_VALUE_OPTIONS = new Set([
11
+ "-u",
12
+ "-g",
13
+ "-h",
14
+ "-D",
15
+ "-C",
16
+ "-p",
17
+ "-r",
18
+ "-t",
19
+ "-U",
20
+ "--user",
21
+ "--group",
22
+ "--host",
23
+ "--chdir",
24
+ "--close-from",
25
+ "--prompt",
26
+ "--role",
27
+ "--type",
28
+ "--other-user",
29
+ ]);
30
+ /** Skip a sudo/doas prefix, including options with a separate value. */
31
+ function afterPrivilegeWrapper(words) {
32
+ if (words[0] !== "sudo" && words[0] !== "doas")
33
+ return 0;
34
+ let i = 1;
35
+ while (words[i]?.startsWith("-")) {
36
+ const option = words[i++];
37
+ if (option === "--")
38
+ break;
39
+ if (option !== undefined && SUDO_VALUE_OPTIONS.has(option))
40
+ i += 1;
41
+ }
42
+ return i;
43
+ }
44
+ /** Skip an env prefix and its assignments and options. */
45
+ function afterEnvWrapper(words, start) {
46
+ if (words[start] !== "env")
47
+ return start;
48
+ let i = start + 1;
49
+ while (i < words.length) {
50
+ const word = words[i];
51
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || word === "-i" || word === "--ignore-environment") {
52
+ i += 1;
53
+ }
54
+ else if (word === "-u" || word === "--unset") {
55
+ i += 2;
56
+ }
57
+ else
58
+ break;
59
+ }
60
+ return i;
61
+ }
62
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
63
+ /** `xargs` options that take a separate value. */
64
+ const XARGS_VALUE_OPTIONS = new Set(["-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a"]);
65
+ function afterNice(words, start) {
66
+ let i = start + 1;
67
+ if (words[i] === "-n" || words[i] === "--adjustment")
68
+ i += 2;
69
+ else if (words[i]?.startsWith("-") && words[i] !== "--")
70
+ i += 1;
71
+ return i;
72
+ }
73
+ function afterTimeout(words, start) {
74
+ let i = start + 1;
75
+ while (words[i]?.startsWith("-"))
76
+ i += words[i] === "-k" || words[i] === "-s" ? 2 : 1;
77
+ return i + 1;
78
+ }
79
+ function afterXargs(words, start) {
80
+ let i = start + 1;
81
+ while (words[i]?.startsWith("-") && words[i] !== "--")
82
+ i += XARGS_VALUE_OPTIONS.has(words[i]) ? 2 : 1;
83
+ return i;
84
+ }
85
+ /**
86
+ * Index of the word that actually names the command, after leading `VAR=x`
87
+ * assignments and the wrappers that pass through to it (`sudo`, `env`,
88
+ * `command`, `exec`, `nice`, `timeout`, `xargs`).
89
+ */
90
+ export function commandHeadIndex(words) {
91
+ let i = 0;
92
+ for (let guard = 0; guard < words.length; guard += 1) {
93
+ const before = i;
94
+ while (words[i] !== undefined && ASSIGNMENT.test(words[i]))
95
+ i += 1;
96
+ const word = words[i];
97
+ if (word === "sudo" || word === "doas")
98
+ i = afterPrivilegeWrapper(words.slice(i)) + i;
99
+ else if (word === "env")
100
+ i = afterEnvWrapper(words, i);
101
+ else if (word === "command" || word === "exec")
102
+ i += 1;
103
+ else if (word === "nice")
104
+ i = afterNice(words, i);
105
+ else if (word === "timeout")
106
+ i = afterTimeout(words, i);
107
+ else if (word === "xargs")
108
+ i = afterXargs(words, i);
109
+ if (i === before)
110
+ break;
111
+ }
112
+ return i;
113
+ }
114
+ /** `git`, or `\\git` (a leading backslash only suppresses shell aliases, so it still runs git). */
115
+ function isGitWord(word) {
116
+ return word === "git" || word === "\\git";
117
+ }
118
+ /** Find `git` in the executable position after supported wrappers. */
119
+ export function gitExecutableIndex(words) {
120
+ const i = commandHeadIndex(words);
121
+ return isGitWord(words[i]) ? i : undefined;
122
+ }
123
+ /** Long global options whose value is the NEXT word (`--git-dir X`); `--key=value` forms are skipped as one word. */
124
+ const GIT_SEPARATE_VALUE_LONG = new Set([
125
+ "--git-dir",
126
+ "--work-tree",
127
+ "--namespace",
128
+ "--super-prefix",
129
+ "--config-env",
130
+ "--attr-source",
131
+ ]);
132
+ /** Global options that take no value, or only an `=value` (`git help git`). Anything else before the subcommand is unknown. */
133
+ const GIT_KNOWN_FLAG_GLOBALS = new Set([
134
+ "-v",
135
+ "--version",
136
+ "-h",
137
+ "--help",
138
+ "--exec-path",
139
+ "--html-path",
140
+ "--man-path",
141
+ "--info-path",
142
+ "-p",
143
+ "--paginate",
144
+ "-P",
145
+ "--no-pager",
146
+ "--no-replace-objects",
147
+ "--no-lazy-fetch",
148
+ "--no-optional-locks",
149
+ "--no-advice",
150
+ "--bare",
151
+ "--literal-pathspecs",
152
+ "--glob-pathspecs",
153
+ "--noglob-pathspecs",
154
+ "--icase-pathspecs",
155
+ "--list-cmds",
156
+ ]);
157
+ /** Global options whose value redirects git to another directory. */
158
+ const GIT_DIR_OPTIONS = new Set(["-C", "--git-dir", "--work-tree"]);
159
+ /**
160
+ * Subcommands git runs itself: an alias of the same name is NEVER expanded (`git -c alias.stash=status
161
+ * stash` really stashes). Every guarded verb plus the common builtins; a name missing here is expanded,
162
+ * which can only over-refuse.
163
+ */
164
+ const GIT_BUILTINS = new Set([
165
+ "add",
166
+ "am",
167
+ "apply",
168
+ "archive",
169
+ "bisect",
170
+ "blame",
171
+ "branch",
172
+ "bundle",
173
+ "checkout",
174
+ "checkout-index",
175
+ "cherry",
176
+ "cherry-pick",
177
+ "clean",
178
+ "clone",
179
+ "commit",
180
+ "config",
181
+ "describe",
182
+ "diff",
183
+ "fetch",
184
+ "format-patch",
185
+ "gc",
186
+ "grep",
187
+ "init",
188
+ "log",
189
+ "ls-files",
190
+ "ls-tree",
191
+ "merge",
192
+ "mv",
193
+ "notes",
194
+ "pull",
195
+ "push",
196
+ "rebase",
197
+ "reflog",
198
+ "remote",
199
+ "reset",
200
+ "restore",
201
+ "revert",
202
+ "rm",
203
+ "show",
204
+ "sparse-checkout",
205
+ "stash",
206
+ "status",
207
+ "submodule",
208
+ "switch",
209
+ "tag",
210
+ "update-index",
211
+ "update-ref",
212
+ "worktree",
213
+ "read-tree",
214
+ "write-tree",
215
+ "rev-parse",
216
+ "rev-list",
217
+ "symbolic-ref",
218
+ "show-ref",
219
+ "for-each-ref",
220
+ "hash-object",
221
+ "cat-file",
222
+ "help",
223
+ ]);
224
+ /** Skip global options from `start`; returns the index of the first word that is not one. */
225
+ function skipGlobals(words, start, state) {
226
+ let i = start;
227
+ while (i < words.length && words[i].startsWith("-") && words[i] !== "--") {
228
+ i = consumeGlobal(words, i, state);
229
+ }
230
+ return i;
231
+ }
232
+ /** Alias value words, with a leading `!` and a leading `git` removed. */
233
+ function aliasWords(value) {
234
+ const words = value
235
+ .replace(/^!/, "")
236
+ .split(/\s+/)
237
+ .filter((word) => word.length > 0);
238
+ if (isGitWord(words[0]))
239
+ words.shift();
240
+ return words;
241
+ }
242
+ /**
243
+ * Expand `-c alias.NAME=VALUE` while `subcommand` names such an alias (never a builtin, see GIT_BUILTINS).
244
+ * A name met twice is a cycle: `cycle` is set and the chain stops. The
245
+ * expanded words are run through the global-option skip again (an alias may
246
+ * start with `-C .` or `--no-pager`), and a `!` value is also returned as shell text.
247
+ */
248
+ function resolveAliases(subcommand, args, state) {
249
+ let current = { subcommand, args, shellAlias: undefined, cycle: false };
250
+ const seen = new Set();
251
+ for (;;) {
252
+ const name = current.subcommand.toLowerCase();
253
+ const value = GIT_BUILTINS.has(name) ? undefined : state.aliases.get(name);
254
+ if (value === undefined)
255
+ break;
256
+ if (seen.has(name))
257
+ return { ...current, cycle: true };
258
+ seen.add(name);
259
+ const shellAlias = value.startsWith("!") ? `${value.slice(1)} ${current.args.join(" ")}`.trim() : undefined;
260
+ const merged = [...aliasWords(value), ...current.args];
261
+ const next = skipGlobals(merged, 0, state);
262
+ const head = merged[next];
263
+ if (head === undefined)
264
+ break;
265
+ current = {
266
+ subcommand: head,
267
+ args: merged.slice(next + 1),
268
+ shellAlias: current.shellAlias ?? shellAlias,
269
+ cycle: false,
270
+ };
271
+ }
272
+ return current;
273
+ }
274
+ /** Directory and config environment set by leading assignments (also those of an `env` wrapper). */
275
+ function leadingGitEnv(words, gitIdx) {
276
+ const dirs = [];
277
+ let configEnv = false;
278
+ let literalPathspecs = false;
279
+ for (const word of words.slice(0, gitIdx)) {
280
+ const literal = /^GIT_LITERAL_PATHSPECS=(.*)$/.exec(word);
281
+ if (literal !== null)
282
+ literalPathspecs = literal[1] !== "" && literal[1] !== "0";
283
+ const match = /^(GIT_WORK_TREE|GIT_DIR|GIT_CONFIG[A-Z0-9_]*)=(.*)$/.exec(word);
284
+ if (match === null)
285
+ continue;
286
+ if (match[1]?.startsWith("GIT_CONFIG"))
287
+ configEnv = true;
288
+ else
289
+ dirs.push(match[2]);
290
+ }
291
+ return { dirs, configEnv, literalPathspecs };
292
+ }
293
+ /**
294
+ * Locate executable `git` in `words` and advance past its global options.
295
+ * Returns the subcommand (alias-expanded), its arguments, and the directories
296
+ * the global options point git at, or `undefined` when `git` isn't there or has
297
+ * no subcommand. Handles `sudo git`, `env VAR=x git`, a backslash-prefixed
298
+ * `git`, `git --no-pager checkout`, and an UNKNOWN `-x` / `--xxx` global
299
+ * (skipped as one word and flagged `uncertain`, so a new git option cannot
300
+ * hide the subcommand silently).
301
+ */
302
+ export function locateGit(words) {
303
+ const gitIdx = gitExecutableIndex(words);
304
+ if (gitIdx === undefined)
305
+ return undefined;
306
+ const env = leadingGitEnv(words, gitIdx);
307
+ const state = { dirs: [...env.dirs], aliases: new Map(), configKeys: [], uncertain: false };
308
+ const i = skipGlobals(words, gitIdx + 1, state);
309
+ const unresolvedAlias = words.slice(gitIdx + 1, i).some((word, k, globals) => isConfigEnvAlias(word, globals[k + 1]));
310
+ const subcommand = words[i];
311
+ if (subcommand === undefined)
312
+ return undefined;
313
+ const resolved = resolveAliases(subcommand, words.slice(i + 1), state);
314
+ return {
315
+ subcommand: resolved.subcommand,
316
+ args: resolved.args,
317
+ dirs: state.dirs,
318
+ unresolvedAlias: unresolvedAlias || resolved.cycle,
319
+ configKeys: state.configKeys,
320
+ uncertain: state.uncertain,
321
+ shellAlias: resolved.shellAlias,
322
+ configEnv: env.configEnv,
323
+ literalPathspecs: env.literalPathspecs || words.slice(gitIdx + 1, i).includes("--literal-pathspecs"),
324
+ };
325
+ }
326
+ /** `--config-env=alias.x=VAR` or `--config-env alias.x=VAR` (git config keys are case-insensitive). */
327
+ function isConfigEnvAlias(word, next) {
328
+ if (word.startsWith("--config-env="))
329
+ return /^alias\./i.test(word.slice("--config-env=".length));
330
+ return word === "--config-env" && /^alias\./i.test(next ?? "");
331
+ }
332
+ /** Consume one global option at `i`, recording directory values and `-c alias.*` definitions; returns the next index. */
333
+ function consumeGlobal(words, i, state) {
334
+ const w = words[i];
335
+ if (w === "-c") {
336
+ const configKey = /^([^=]+)=/.exec(words[i + 1] ?? "")?.[1];
337
+ if (configKey !== undefined)
338
+ state.configKeys.push(configKey.toLowerCase());
339
+ const alias = /^alias\.([^=]+)=(.*)$/is.exec(words[i + 1] ?? "");
340
+ if (alias !== null)
341
+ state.aliases.set(alias[1].toLowerCase(), alias[2]);
342
+ return i + 2;
343
+ }
344
+ const eq = w.indexOf("=");
345
+ const key = eq === -1 ? w : w.slice(0, eq);
346
+ const separate = GIT_DIR_OPTIONS.has(key) || GIT_SEPARATE_VALUE_LONG.has(key);
347
+ if (!separate) {
348
+ if (!GIT_KNOWN_FLAG_GLOBALS.has(key))
349
+ state.uncertain = true;
350
+ return i + 1;
351
+ }
352
+ const value = eq === -1 ? words[i + 1] : w.slice(eq + 1);
353
+ if (key === "--config-env") {
354
+ const envKey = /^([^=]+)=/.exec(value ?? "")?.[1];
355
+ if (envKey !== undefined)
356
+ state.configKeys.push(envKey.toLowerCase());
357
+ }
358
+ if (GIT_DIR_OPTIONS.has(key) && value !== undefined)
359
+ state.dirs.push(value);
360
+ return eq === -1 ? i + 2 : i + 1;
361
+ }
@@ -15,6 +15,7 @@ export { gitExcludePathspecsOf, gitIgnorePatternsOf } from "./protected-paths.ts
15
15
  export * from "./provider-adapt.ts";
16
16
  export * from "./provider-advertise.ts";
17
17
  export * from "./provider-grants.ts";
18
+ export * from "./provider-instructions.ts";
18
19
  export * from "./provider-sanitize.ts";
19
20
  export * from "./provider-types.ts";
20
21
  export { readTool } from "./read.ts";
@@ -12,6 +12,7 @@ export { gitExcludePathspecsOf, gitIgnorePatternsOf } from "./protected-paths.js
12
12
  export * from "./provider-adapt.js";
13
13
  export * from "./provider-advertise.js";
14
14
  export * from "./provider-grants.js";
15
+ export * from "./provider-instructions.js";
15
16
  export * from "./provider-sanitize.js";
16
17
  export * from "./provider-types.js";
17
18
  export { readTool } from "./read.js";
@@ -0,0 +1,22 @@
1
+ /** Where a segment runs: every live working-directory frame, and the permitted root. */
2
+ export interface NaxGuardContext {
3
+ readonly frames: readonly string[];
4
+ readonly root: string;
5
+ /** Shared downward-scan cache; absent means every lookup scans afresh. */
6
+ readonly naxDirs?: NaxDirIndex;
7
+ }
8
+ /** Every `.nax` directory at or below `root`, within the depth bound. */
9
+ export declare function scanNaxDirs(root: string): readonly string[];
10
+ /** Lazily scanned, per-root cache of the `.nax` directories at or below a root. */
11
+ export interface NaxDirIndex {
12
+ below(root: string): readonly string[];
13
+ }
14
+ /**
15
+ * A found `.nax` is cached for the index's lifetime (a deleted one may keep refusing, which is safe); an
16
+ * empty result only for {@link NEGATIVE_TTL_MS}, so one created mid-run is not invisible for ever.
17
+ */
18
+ export declare function createNaxDirIndex(now?: () => number): NaxDirIndex;
19
+ /** Every known `.nax` directory: on the way from a frame to the root, at the root, or nested below it. */
20
+ export declare function findNaxDirs(ctx: NaxGuardContext): readonly string[];
21
+ /** True when a `.nax/` exists at a frame, between a frame and the root, at the root, or nested below it. */
22
+ export declare function hasNaxDirNear(ctx: NaxGuardContext): boolean;