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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ All notable changes to `@nathapp/nax-agent` are recorded here. The format follow
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). While the version is `0.x`, a minor
5
5
  release may change the public API.
6
6
 
7
+ ## [0.88.0] - 2026-10-16
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
7
11
  ## [0.88.0-canary.2] - 2026-10-15
8
12
 
9
13
  - No changes in this package. Released in lockstep with the other nax packages.
@@ -102,6 +102,18 @@ export async function connectMcp(config, opts) {
102
102
  try {
103
103
  if (signal.aborted)
104
104
  throw new McpConnectError("MCP connect failed: aborted");
105
+ if (config.kind === "stdio" && opts.onSpawn !== undefined) {
106
+ const start = handle.transport.start;
107
+ let notified = false;
108
+ handle.transport.start = async function () {
109
+ await start.call(this);
110
+ const pid = handle.pid();
111
+ if (pid !== null && !notified) {
112
+ notified = true;
113
+ opts.onSpawn?.(pid);
114
+ }
115
+ };
116
+ }
105
117
  await client.connect(handle.transport, { signal, timeout: opts.timeoutMs });
106
118
  const tools = await listAll(client, signal, opts.timeoutMs);
107
119
  return liveConnection(config, client, handle, tools, {
@@ -51,6 +51,8 @@ export interface ConnectMcpOptions {
51
51
  readonly name: string;
52
52
  readonly version: string;
53
53
  };
54
+ /** stdio only: called once after spawn, before the initialize request is sent. */
55
+ readonly onSpawn?: (pid: number) => void;
54
56
  /** stdio: how long close() waits for the process before SIGKILL. Default 3000. */
55
57
  readonly closeGraceMs?: number;
56
58
  }
@@ -80,9 +80,9 @@ export declare function turnRetryFitsBudget(delayMs: number, remainingMs: number
80
80
  * nax-ai's retry.ts `abortableSleep` so a pending backoff cannot outlive the
81
81
  * caller's own cancellation and go on to start a request nobody wants.
82
82
  */
83
- export declare function abortableSleep(ms: number, sleep: (ms: number) => Promise<void>, signal?: AbortSignal): Promise<void>;
83
+ export declare function abortableSleep(ms: number, sleep: (ms: number, signal?: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<void>;
84
84
  /** The real timer. Named and exported so the only production wiring of it is one reference. */
85
- export declare function realSleep(ms: number): Promise<void>;
85
+ export declare function realSleep(ms: number, signal?: AbortSignal): Promise<void>;
86
86
  export interface TurnRetryDeps<T> {
87
87
  /** Re-issues the round trip. Called once per retry, never for the triggering attempt. */
88
88
  readonly attempt: () => Promise<T>;
@@ -99,7 +99,7 @@ export interface TurnRetryDeps<T> {
99
99
  * and leaving it optional let a test omit it and silently wait out a real
100
100
  * multi-second backoff. Required makes that a compile error instead.
101
101
  */
102
- readonly sleep: (ms: number) => Promise<void>;
102
+ readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
103
103
  /** Injectable jitter source, for deterministic tests. Defaults to Math.random. */
104
104
  readonly random?: () => number;
105
105
  /** Fired once per retry, before the backoff delay, with a 1-based retry number. */
@@ -104,7 +104,7 @@ export function turnRetryFitsBudget(delayMs, remainingMs) {
104
104
  return remainingMs === undefined || delayMs <= remainingMs;
105
105
  }
106
106
  function abortError(signal) {
107
- return signal.reason ?? new DOMException("Aborted", "AbortError");
107
+ return signal?.reason ?? new DOMException("Aborted", "AbortError");
108
108
  }
109
109
  /**
110
110
  * A sleep that resolves early, and rejects, when `signal` fires — mirrors
@@ -113,21 +113,40 @@ function abortError(signal) {
113
113
  */
114
114
  export function abortableSleep(ms, sleep, signal) {
115
115
  if (signal === undefined)
116
- return sleep(ms);
116
+ return sleep(ms, signal);
117
117
  if (signal.aborted)
118
118
  return Promise.reject(abortError(signal));
119
119
  return new Promise((resolve, reject) => {
120
120
  const onAbort = () => reject(abortError(signal));
121
121
  signal.addEventListener("abort", onAbort, { once: true });
122
- sleep(ms).then(() => {
122
+ // Keep the race for injected sleeps that do not honour cancellation.
123
+ Promise.resolve()
124
+ .then(() => sleep(ms, signal))
125
+ .then(() => {
123
126
  signal.removeEventListener("abort", onAbort);
124
127
  resolve();
125
- }, reject);
128
+ }, (error) => {
129
+ signal.removeEventListener("abort", onAbort);
130
+ reject(error);
131
+ });
126
132
  });
127
133
  }
128
134
  /** The real timer. Named and exported so the only production wiring of it is one reference. */
129
- export function realSleep(ms) {
130
- return new Promise((resolve) => setTimeout(resolve, ms));
135
+ export function realSleep(ms, signal) {
136
+ if (signal?.aborted)
137
+ return Promise.reject(abortError(signal));
138
+ return new Promise((resolve, reject) => {
139
+ const onAbort = () => {
140
+ clearTimeout(timer);
141
+ reject(abortError(signal));
142
+ };
143
+ // Cancellable timer: abort must release the pending backoff immediately.
144
+ const timer = setTimeout(() => {
145
+ signal?.removeEventListener("abort", onAbort);
146
+ resolve();
147
+ }, ms);
148
+ signal?.addEventListener("abort", onAbort, { once: true });
149
+ });
131
150
  }
132
151
  /**
133
152
  * Retries `attempt` while the thrown error is a transport/overloaded fault,
@@ -97,7 +97,7 @@ export interface TurnDeps {
97
97
  * actually wait (forbidden-patterns-tests.md). Absent uses a real timer —
98
98
  * a real session always wants to actually wait.
99
99
  */
100
- sleep?: (ms: number) => Promise<void>;
100
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
101
101
  /**
102
102
  * Live repetition-breaker instance (nax#2013, session-lifetime since #2047). Absent disables the breaker.
103
103
  */
@@ -0,0 +1,10 @@
1
+ /** Deny/ask-only views of Bash commands. Never use these views to grant or check paths. */
2
+ import { type BashSegment } from "#src/permissions/index";
3
+ export type CanonicalResult = {
4
+ readonly kind: "ok";
5
+ readonly segments: readonly BashSegment[];
6
+ } | {
7
+ readonly kind: "refused";
8
+ readonly reason: string;
9
+ };
10
+ export declare function canonicalSegments(segment: BashSegment): CanonicalResult;
@@ -0,0 +1,207 @@
1
+ /** Deny/ask-only views of Bash commands. Never use these views to grant or check paths. */
2
+ import { lexBashCommand } from "#src/permissions/index";
3
+ // Deliberately separate from commandHeadIndex: this policy must refuse unknown
4
+ // wrapper options rather than guess whether the next word names the executable.
5
+ const WRAPPERS = {
6
+ env: { flags: ["-i", "--ignore-environment"], values: ["-u", "--unset"] },
7
+ command: { flags: ["-p"], values: [] },
8
+ exec: { flags: ["-c", "-l"], values: ["-a"] },
9
+ time: { flags: ["-p"], values: [] },
10
+ nohup: { flags: [], values: [] },
11
+ nice: { flags: [], values: ["-n", "--adjustment"] },
12
+ timeout: {
13
+ flags: ["--foreground", "--preserve-status", "-v", "--verbose"],
14
+ values: ["-k", "--kill-after", "-s", "--signal"],
15
+ },
16
+ stdbuf: { flags: [], values: ["-i", "-o", "-e", "--input", "--output", "--error"] },
17
+ xargs: {
18
+ flags: ["-0", "--null", "-r", "--no-run-if-empty", "-t", "--verbose", "-p", "--interactive", "-x", "--exit"],
19
+ values: [
20
+ "-I",
21
+ "-n",
22
+ "-P",
23
+ "-d",
24
+ "-E",
25
+ "-L",
26
+ "-s",
27
+ "-a",
28
+ "--replace",
29
+ "--max-args",
30
+ "--max-procs",
31
+ "--delimiter",
32
+ "--eof",
33
+ "--max-lines",
34
+ "--max-chars",
35
+ "--arg-file",
36
+ ],
37
+ },
38
+ busybox: { flags: [], values: [] },
39
+ sudo: {
40
+ flags: [
41
+ "-A",
42
+ "-b",
43
+ "-E",
44
+ "-H",
45
+ "-K",
46
+ "-k",
47
+ "-n",
48
+ "-S",
49
+ "--askpass",
50
+ "--background",
51
+ "--preserve-env",
52
+ "--set-home",
53
+ "--non-interactive",
54
+ "--stdin",
55
+ ],
56
+ values: [
57
+ "-u",
58
+ "-g",
59
+ "-h",
60
+ "-D",
61
+ "-C",
62
+ "-p",
63
+ "-r",
64
+ "-t",
65
+ "-U",
66
+ "--user",
67
+ "--group",
68
+ "--host",
69
+ "--chdir",
70
+ "--close-from",
71
+ "--prompt",
72
+ "--role",
73
+ "--type",
74
+ "--other-user",
75
+ ],
76
+ },
77
+ doas: { flags: ["-n", "-s", "-L"], values: ["-u", "-C"] },
78
+ };
79
+ const GIT_OPTIONS = {
80
+ flags: ["--no-pager", "-p", "--paginate", "--bare", "--literal-pathspecs", "--no-replace-objects"],
81
+ values: ["-C", "-c", "--git-dir", "--work-tree", "--namespace"],
82
+ };
83
+ const KEYWORDS = new Set(["if", "then", "else", "elif", "do", "while", "until", "{"]);
84
+ const CLOSERS = new Set(["fi", "done", "}"]);
85
+ const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh"]);
86
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
87
+ function refused(reason) {
88
+ return { kind: "refused", reason: `Bash canonical analysis refused: ${reason}` };
89
+ }
90
+ /** Number of tokens an option consumes; undefined means it is unknown. */
91
+ function optionWidth(word, options) {
92
+ if (options.flags.includes(word))
93
+ return 1;
94
+ if (options.values.includes(word))
95
+ return 2;
96
+ if (options.values.some((flag) => flag.startsWith("--") ? word.startsWith(`${flag}=`) : word.startsWith(flag) && word.length > flag.length))
97
+ return 1;
98
+ // Short boolean options may be clustered (sudo -nE, for example).
99
+ if (/^-[A-Za-z0-9]+$/.test(word) && [...word.slice(1)].every((letter) => options.flags.includes(`-${letter}`)))
100
+ return 1;
101
+ return undefined;
102
+ }
103
+ function afterOptions(tokens, start, options) {
104
+ let i = start;
105
+ while (tokens[i]?.text.startsWith("-")) {
106
+ const token = tokens[i];
107
+ if (token.opaque)
108
+ return "opaque wrapper option";
109
+ if (token.text === "--")
110
+ return i + 1;
111
+ const width = optionWidth(token.text, options);
112
+ if (width === undefined)
113
+ return `unknown option ${token.text}`;
114
+ if (width === 2 && tokens[i + 1] === undefined)
115
+ return `missing value for ${token.text}`;
116
+ i += width;
117
+ }
118
+ return i;
119
+ }
120
+ /** Locate only the shell's command-string operand, never a script's arguments. */
121
+ function shellPayload(tokens) {
122
+ for (let i = 1; i < tokens.length; i += 1) {
123
+ const token = tokens[i];
124
+ const word = token.text;
125
+ if ((!word.startsWith("-") && !word.startsWith("+")) || word === "--")
126
+ return undefined;
127
+ if (token.opaque)
128
+ return "opaque shell option";
129
+ if (word === "-o" || word === "+o" || word === "-O" || word === "--rcfile" || word === "--init-file") {
130
+ i += 1;
131
+ continue;
132
+ }
133
+ if (/^-[A-Za-z]*c[A-Za-z]*$/.test(word))
134
+ return tokens[i + 1] ?? "missing shell command string";
135
+ }
136
+ return undefined;
137
+ }
138
+ function stripPrefixes(input) {
139
+ let tokens = input;
140
+ for (;;) {
141
+ const head = tokens[0];
142
+ if (head === undefined)
143
+ return [];
144
+ if (head.opaque)
145
+ return "opaque command head";
146
+ if (KEYWORDS.has(head.text) || ASSIGNMENT.test(head.text)) {
147
+ tokens = tokens.slice(1);
148
+ continue;
149
+ }
150
+ const wrapper = Object.hasOwn(WRAPPERS, head.text) ? WRAPPERS[head.text] : undefined;
151
+ if (wrapper === undefined)
152
+ break;
153
+ const next = afterOptions(tokens, 1, wrapper);
154
+ if (typeof next === "string")
155
+ return `${head.text}: ${next}`;
156
+ const command = head.text === "timeout" ? next + 1 : next;
157
+ if (head.text === "timeout" && tokens[next] === undefined)
158
+ return "missing timeout duration";
159
+ tokens = tokens.slice(command);
160
+ }
161
+ return tokens;
162
+ }
163
+ function expandShell(payload, depth) {
164
+ if (depth >= 3)
165
+ return refused("shell command strings nest deeper than 3 levels");
166
+ if (payload.opaque)
167
+ return refused("opaque shell command string");
168
+ const lexed = lexBashCommand(payload.text);
169
+ if (lexed.kind === "refused")
170
+ return refused(`shell command string contains ${lexed.construct}`);
171
+ const segments = [];
172
+ for (const nested of lexed.segments) {
173
+ const result = canonicalise(nested, depth + 1);
174
+ if (result.kind === "refused")
175
+ return result;
176
+ segments.push(...result.segments);
177
+ }
178
+ return { kind: "ok", segments };
179
+ }
180
+ function canonicalise(segment, depth) {
181
+ const stripped = stripPrefixes(segment.tokens);
182
+ if (typeof stripped === "string")
183
+ return refused(stripped);
184
+ let tokens = stripped;
185
+ if (tokens.length === 0)
186
+ return { kind: "ok", segments: [] };
187
+ if (tokens.length === 1 && CLOSERS.has(tokens[0]?.text ?? ""))
188
+ return { kind: "ok", segments: [] };
189
+ if (tokens[0]?.text === "git") {
190
+ const next = afterOptions(tokens, 1, GIT_OPTIONS);
191
+ if (typeof next === "string")
192
+ return refused(`git: ${next}`);
193
+ tokens = [tokens[0], ...tokens.slice(next)];
194
+ }
195
+ const head = tokens[0]?.text.split("/").pop() ?? "";
196
+ if (SHELLS.has(head)) {
197
+ const payload = shellPayload(tokens);
198
+ if (typeof payload === "string")
199
+ return refused(payload);
200
+ if (payload !== undefined)
201
+ return expandShell(payload, depth);
202
+ }
203
+ return { kind: "ok", segments: [{ ...segment, tokens }] };
204
+ }
205
+ export function canonicalSegments(segment) {
206
+ return canonicalise(segment, 0);
207
+ }
@@ -26,9 +26,21 @@ export declare function operandTouchesNax(operand: string, ctx: NaxGuardContext)
26
26
  * under `top`), so they count only while that is the single `.nax` there is.
27
27
  */
28
28
  export declare function isFullNaxExclusion(spec: string, ctx: NaxGuardContext): boolean;
29
+ interface Operands {
30
+ /** Non-option words before `--`. */
31
+ readonly before: readonly string[];
32
+ /** Words after `--`. */
33
+ readonly after: readonly string[];
34
+ readonly hasSeparator: boolean;
35
+ readonly flags: readonly string[];
36
+ }
37
+ export declare function splitOperands(verb: string, args: readonly string[]): Operands;
38
+ /** True when `spec` selects a directory that holds one of the `.nax` directories (or all of them, from the top). */
39
+ export declare function coversNax(spec: string, ctx: NaxGuardContext, nax: readonly string[]): boolean;
29
40
  /** The refusal for `git checkout` / `git restore` with these arguments, or undefined. */
30
41
  export declare function revertRefusal(verb: string, args: readonly string[], ctx: NaxGuardContext): string | undefined;
31
42
  /** `git reset --hard` / `--keep` / `--merge` (any abbreviation git accepts) while a `.nax/` exists. */
32
43
  export declare function resetRefusal(args: readonly string[], ctx: NaxGuardContext): string | undefined;
33
44
  /** `switch -f`, `clean`, `checkout-index -a -f`, `read-tree -u --reset`: other ways to wipe the tree. */
34
45
  export declare function discardRefusal(verb: string, args: readonly string[], ctx: NaxGuardContext): string | undefined;
46
+ export {};
@@ -93,7 +93,7 @@ const VALUE_FLAGS = {
93
93
  restore: new Set(["-s", "--source", "--pathspec-from-file"]),
94
94
  clean: new Set(["-e", "--exclude"]),
95
95
  };
96
- function splitOperands(verb, args) {
96
+ export function splitOperands(verb, args) {
97
97
  const valueFlags = VALUE_FLAGS[verb] ?? new Set();
98
98
  const before = [];
99
99
  const after = [];
@@ -130,7 +130,7 @@ function pathOperands(verb, ops, ctx) {
130
130
  return [...ops.after, ...ops.before.filter((word) => isUnmarkedCheckoutPath(word, ctx))];
131
131
  }
132
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) {
133
+ export function coversNax(spec, ctx, nax) {
134
134
  if (isExclusionSpec(spec))
135
135
  return false;
136
136
  const magic = /^:(?:\(([^)]*)\)|\/)?(.*)$/s.exec(spec);
@@ -3,5 +3,7 @@ export declare const _naxStateGuardDeps: {
3
3
  /** stdout on success, undefined on a spawn error, timeout or non-zero exit. */
4
4
  git(cwd: string, args: readonly string[]): string | undefined;
5
5
  };
6
+ /** Literal existing files or directories only; never symlinks, magic or globs. */
7
+ export declare function pathIsClean(spec: string, ctx: NaxGuardContext): boolean;
6
8
  /** Stash mutations that can be proved not to capture or replay nax state. */
7
9
  export declare function stashIsExempt(args: readonly string[], ctx: NaxGuardContext): boolean;
@@ -60,7 +60,7 @@ function restoreIsExempt(args, ctx) {
60
60
  return ctx.frames.every((frame) => worktreeListingIsClean(frame, request.ref) && (!request.index || indexSnapshotIsClean(frame, request.ref)));
61
61
  }
62
62
  /** Literal existing files or directories only; never symlinks, magic or globs. */
63
- function pathIsClean(spec, ctx) {
63
+ export function pathIsClean(spec, ctx) {
64
64
  if (spec.startsWith(":") || /[*?[]/.test(spec) || operandTouchesNax(spec, ctx))
65
65
  return false;
66
66
  return ctx.frames.every((frame) => {
@@ -82,10 +82,23 @@ function pathIsClean(spec, ctx) {
82
82
  export function stashIsExempt(args, ctx) {
83
83
  if (args[0] === "pop" || args[0] === "apply")
84
84
  return restoreIsExempt(args.slice(1), ctx);
85
+ if (args[0] !== "push")
86
+ return false;
85
87
  const separator = args.indexOf("--");
86
- if (args[0] !== "push" || separator === -1)
88
+ const options = args.slice(1, separator === -1 ? args.length : separator);
89
+ if (options.some((word) => word === "--pathspec-from-file" || word.startsWith("--pathspec-from-file="))) {
87
90
  return false;
88
- const specs = args.slice(separator + 1);
91
+ }
92
+ const specs = [];
93
+ for (let i = 0; i < options.length; i += 1) {
94
+ const word = options[i];
95
+ if (word === "-m" || word === "--message")
96
+ i += 1;
97
+ else if (!word.startsWith("-"))
98
+ specs.push(word);
99
+ }
100
+ if (separator !== -1)
101
+ specs.push(...args.slice(separator + 1));
89
102
  if (specs.some((spec) => isFullNaxExclusion(spec, ctx)))
90
103
  return true;
91
104
  return specs.length > 0 && specs.every((spec) => pathIsClean(spec, ctx));
@@ -119,6 +119,7 @@ import { hasNaxDirNear } from "./nax-dirs.js";
119
119
  import { buildsVerbByExpansion, CONFIG_ENV_REFUSAL, configAliasRefusal, EXEC_CONFIG_REFUSAL, EXPANSION_REFUSAL, flattenCode, interpreterCode, isExecConfigKey, mentionsGuardedVerb, } from "./nax-evasion.js";
120
120
  import { COVERING_EXCLUSION, discardRefusal, operandTouchesNax, resetRefusal, revertRefusal } from "./nax-revert.js";
121
121
  import { stashIsExempt } from "./nax-stash-restore.js";
122
+ import { verbGapsRefusal } from "./nax-verb-gaps.js";
122
123
  export { createNaxDirIndex } from "./nax-dirs.js";
123
124
  /** Stash subcommands that neither capture nor replay the working tree. */
124
125
  const STASH_SAFE_SUBCOMMANDS = new Set(["list", "show", "create", "store", "drop", "clear"]);
@@ -189,6 +190,9 @@ const DISCARD_VERBS = new Set(["switch", "clean", "checkout-index", "read-tree"]
189
190
  /** Verbs whose exclusion pathspecs `--literal-pathspecs` turns into literal paths. */
190
191
  const EXCLUDING_VERBS = new Set(["checkout", "restore", "stash", "clean"]);
191
192
  function verbRefusal(git, segment, where) {
193
+ const gaps = verbGapsRefusal(git.subcommand, git.args, where);
194
+ if (gaps !== undefined)
195
+ return gaps;
192
196
  if (git.literalPathspecs && EXCLUDING_VERBS.has(git.subcommand) && git.args.some((a) => a.startsWith(":"))) {
193
197
  if (hasNaxDirNear(where))
194
198
  return LITERAL_PATHSPECS_REFUSAL;
@@ -0,0 +1,3 @@
1
+ import { type NaxGuardContext } from "./nax-dirs.ts";
2
+ /** No new refusal applies when no run-state directory is near the invocation. */
3
+ export declare function verbGapsRefusal(verb: string, args: readonly string[], ctx: NaxGuardContext): string | undefined;
@@ -0,0 +1,32 @@
1
+ /** Additional destructive git forms, evaluated with locateGit's effective frames. */
2
+ import { NAX_OWNED_RUN_STATE_EXPLANATION } from "./denial-redirect.js";
3
+ import { findNaxDirs } from "./nax-dirs.js";
4
+ import { coversNax, isExclusionSpec, operandTouchesNax, splitOperands } from "./nax-revert.js";
5
+ const PATH_VERBS = new Set(["checkout", "restore", "rm", "reset"]);
6
+ const AUTOSTASH_VERBS = new Set(["rebase", "pull", "merge"]);
7
+ /** No new refusal applies when no run-state directory is near the invocation. */
8
+ export function verbGapsRefusal(verb, args, ctx) {
9
+ if (!PATH_VERBS.has(verb) && !AUTOSTASH_VERBS.has(verb) && verb !== "sparse-checkout")
10
+ return undefined;
11
+ const nax = findNaxDirs(ctx);
12
+ if (nax.length === 0)
13
+ return undefined;
14
+ if (AUTOSTASH_VERBS.has(verb)) {
15
+ const flags = splitOperands(verb, args).flags;
16
+ return flags.includes("--autostash") ? NAX_OWNED_RUN_STATE_EXPLANATION : undefined;
17
+ }
18
+ if (verb === "sparse-checkout") {
19
+ return args[0] === "list" ? undefined : NAX_OWNED_RUN_STATE_EXPLANATION;
20
+ }
21
+ const ops = splitOperands(verb, args);
22
+ const specs = [...ops.before, ...ops.after].filter((spec) => !isExclusionSpec(spec));
23
+ if (specs.some((spec) => /[*?[]/.test(spec))) {
24
+ return `${NAX_OWNED_RUN_STATE_EXPLANATION} Glob pathspecs may also select files under \`.nax/\`; name literal paths instead.`;
25
+ }
26
+ if (verb === "rm" || verb === "reset") {
27
+ if (specs.some((spec) => operandTouchesNax(spec, ctx) || coversNax(spec, ctx, nax))) {
28
+ return NAX_OWNED_RUN_STATE_EXPLANATION;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
@@ -26,6 +26,7 @@
26
26
  * owner of the root boundary and the `.git/` refusal therefore remains `policy.ts`.
27
27
  */
28
28
  import { lexBashCommand } from "#src/permissions/index";
29
+ import { canonicalSegments } from "./bash-canonical.js";
29
30
  import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
30
31
  import { deniedFlag } from "./exec-guard.js";
31
32
  import { isCoveringExclusion } from "./nax-revert.js";
@@ -216,6 +217,34 @@ function lexicalDenial(lexed) {
216
217
  return deny(`command contains ${lexed.construct}, which cannot be analysed and is therefore refused -- ` +
217
218
  "rewrite it without that construct, or use a structured tool", false, true);
218
219
  }
220
+ function matchingDenial(args, segments) {
221
+ const entry = args.denyEntry;
222
+ if (entry === undefined)
223
+ return undefined;
224
+ for (const segment of segments) {
225
+ if (segment.tokens[0]?.text === "eval")
226
+ return deny("Bash canonical analysis refused: eval cannot be analysed");
227
+ if (matchesSegment(entry, segment)) {
228
+ return deny(`${args.tool} segment "${render(segment)}" is denied for this stage by rule ${ruleExpr(args.tool, entry, segment)}`);
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+ function ruleSegments(segments, refuseUnreadable) {
234
+ const views = [];
235
+ for (const segment of segments) {
236
+ views.push(segment);
237
+ const canonical = canonicalSegments(segment);
238
+ if (canonical.kind === "refused") {
239
+ if (refuseUnreadable)
240
+ return canonical;
241
+ }
242
+ else {
243
+ views.push(...canonical.segments);
244
+ }
245
+ }
246
+ return { kind: "ok", segments: views };
247
+ }
219
248
  export function checkBashCommand(args) {
220
249
  const { command, tool } = args;
221
250
  const invalid = invalidCommandDenial(command);
@@ -230,11 +259,15 @@ export function checkBashCommand(args) {
230
259
  // empty when the refusal preceded any completed word, leaving the checks a
231
260
  // no-op and the escalatable lexical refusal intact (Category A).
232
261
  const segments = lexed.kind === "ok" ? lexed.segments : lexed.prefix;
233
- for (const segment of segments) {
234
- if (args.denyEntry !== undefined && matchesSegment(args.denyEntry, segment)) {
235
- return deny(`${tool} segment "${render(segment)}" is denied for this stage by rule ${ruleExpr(tool, args.denyEntry, segment)}`);
236
- }
237
- }
262
+ // Only deny/ask see these additional views. Payloads, cwd and grants below
263
+ // continue to evaluate the original tokens (canonicalization cannot grant).
264
+ const views = ruleSegments(segments, args.denyEntry !== undefined);
265
+ if (views.kind === "refused")
266
+ return deny(views.reason);
267
+ const matchingSegments = views.segments;
268
+ const denied = matchingDenial(args, matchingSegments);
269
+ if (denied !== undefined)
270
+ return denied;
238
271
  let cwd = [args.initialPath];
239
272
  for (const segment of segments) {
240
273
  const result = checkPayload(args, segment, cwd);
@@ -252,7 +285,7 @@ export function checkBashCommand(args) {
252
285
  const alternatives = granted === "" ? "no command forms are granted for this stage" : `granted forms: ${granted}`;
253
286
  return deny(`${tool} is not granted "${render(segment)}" -- ${alternatives}`, false, true);
254
287
  }
255
- for (const segment of segments) {
288
+ for (const segment of matchingSegments) {
256
289
  if (args.askEntry !== undefined && matchesSegment(args.askEntry, segment)) {
257
290
  return { kind: "ask", rule: ruleExpr(tool, args.askEntry, segment) };
258
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.88.0-canary.2",
3
+ "version": "0.88.0",
4
4
  "description": "nax's native coding agent: session contract, native loop, tools, permissions, sandbox, command-safety.",
5
5
  "license": "MIT",
6
6
  "author": "William Khoo",
@@ -47,13 +47,13 @@
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
49
  "@modelcontextprotocol/sdk": "^1.32.1",
50
- "@nathapp/nax-ai": "0.88.0-canary.2",
50
+ "@nathapp/nax-ai": "0.88.0",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public",
55
55
  "registry": "https://registry.npmjs.org/",
56
56
  "provenance": true,
57
- "tag": "canary"
57
+ "tag": "latest"
58
58
  }
59
59
  }