@nathapp/nax-agent 0.87.0 → 0.88.0-canary.1

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 (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/coding-tools/coding-tool-support.d.ts +15 -1
  3. package/dist/coding-tools/coding-tool-support.js +19 -8
  4. package/dist/command-safety/rule-scorer.d.ts +1 -1
  5. package/dist/command-safety/rule-scorer.js +11 -113
  6. package/dist/index.d.ts +1 -1
  7. package/dist/internal.d.ts +1 -0
  8. package/dist/internal.js +1 -0
  9. package/dist/native/errors.js +7 -0
  10. package/dist/native/session/session.d.ts +3 -0
  11. package/dist/native/session/session.js +7 -0
  12. package/dist/native/session/transcript-archive.d.ts +28 -0
  13. package/dist/native/session/transcript-archive.js +43 -0
  14. package/dist/session/session-types.d.ts +19 -0
  15. package/dist/tools/denial-redirect.d.ts +22 -7
  16. package/dist/tools/denial-redirect.js +29 -13
  17. package/dist/tools/git-flags/locate.d.ts +47 -0
  18. package/dist/tools/git-flags/locate.js +361 -0
  19. package/dist/tools/index.d.ts +1 -1
  20. package/dist/tools/nax-dirs.d.ts +22 -0
  21. package/dist/tools/nax-dirs.js +89 -0
  22. package/dist/tools/nax-evasion.d.ts +19 -0
  23. package/dist/tools/nax-evasion.js +88 -0
  24. package/dist/tools/nax-revert.d.ts +34 -0
  25. package/dist/tools/nax-revert.js +233 -0
  26. package/dist/tools/nax-state-guard.d.ts +18 -0
  27. package/dist/tools/nax-state-guard.js +496 -0
  28. package/dist/tools/policy-bash-raw.d.ts +3 -0
  29. package/dist/tools/policy-bash-raw.js +40 -12
  30. package/dist/tools/policy-bash.d.ts +3 -0
  31. package/dist/tools/policy-bash.js +38 -9
  32. package/dist/tools/policy-command-branch.d.ts +3 -0
  33. package/dist/tools/policy-command-branch.js +3 -1
  34. package/dist/tools/policy.js +4 -0
  35. package/dist/tools/provider-advertise.d.ts +18 -0
  36. package/dist/tools/provider-advertise.js +16 -5
  37. package/dist/tools/tool-audit.d.ts +42 -0
  38. package/dist/tools/tool-audit.js +5 -1
  39. package/package.json +3 -3
@@ -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
+ }
@@ -28,7 +28,7 @@ export type { CodingToolOutcome, CodingToolRuntime, ToolCallContext } from "./ru
28
28
  export { _codingToolDeps, _resetBuiltinsForTest, createCodingToolRuntime, DEFAULT_TOOL_MAX_BYTES, DEFAULT_TOOL_MAX_FILE_BYTES, registerBuiltinCodingTools, } from "./runtime.ts";
29
29
  export { SCRATCHPAD_DIR, scratchpadListTool, scratchpadReadTool, scratchpadWriteTool } from "./scratchpad.ts";
30
30
  export { _spillDeps, applyModelTruncationPolicy, type ModelTruncationOptions, SPILL_DIR, type SpillRequest, spillRelativePath, writeSpill, } from "./spill.ts";
31
- export type { RegisteredSink, ToolAuditHeader, ToolAuditSink, ToolCallRecord } from "./tool-audit.ts";
31
+ export type { RegisteredSink, ToolAuditHeader, ToolAuditSink, ToolCallRecord, ToolSurfaceDraft, ToolSurfaceRecord, } from "./tool-audit.ts";
32
32
  export { createNoOpToolAuditSink, createToolAuditSink, flushOpenToolAuditSinks, registerToolAuditSink, unregisterToolAuditSink, } from "./tool-audit.ts";
33
33
  export { cutBufferToByteCap, MODEL_MAX_BYTES, MODEL_MAX_LINE_CHARS, MODEL_MAX_LINES, READ_CEILING, type TruncateForModelOptions, type TruncationDirection, type TruncationResult, truncateForModel, truncationDirectionFor, } from "./truncate.ts";
34
34
  export type { CodingToolName, PolicyVerdict, ToolGrant, ToolPolicy, ToolScope } from "./types.ts";
@@ -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;
@@ -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;