@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 +4 -0
- package/dist/mcp/connect.js +12 -0
- package/dist/mcp/types.d.ts +2 -0
- package/dist/native/session/turn-retry.d.ts +3 -3
- package/dist/native/session/turn-retry.js +25 -6
- package/dist/native/session/turn-types.d.ts +1 -1
- package/dist/tools/bash-canonical.d.ts +10 -0
- package/dist/tools/bash-canonical.js +207 -0
- package/dist/tools/nax-revert.d.ts +12 -0
- package/dist/tools/nax-revert.js +2 -2
- package/dist/tools/nax-stash-restore.d.ts +2 -0
- package/dist/tools/nax-stash-restore.js +16 -3
- package/dist/tools/nax-state-guard.js +4 -0
- package/dist/tools/nax-verb-gaps.d.ts +3 -0
- package/dist/tools/nax-verb-gaps.js +32 -0
- package/dist/tools/policy-bash.js +39 -6
- package/package.json +3 -3
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.
|
package/dist/mcp/connect.js
CHANGED
|
@@ -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, {
|
package/dist/mcp/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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 {};
|
package/dist/tools/nax-revert.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
57
|
+
"tag": "latest"
|
|
58
58
|
}
|
|
59
59
|
}
|