@nathapp/nax-agent 0.88.0-canary.2 → 0.89.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 +8 -0
- package/README.md +11 -10
- 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,14 @@ 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.89.0] - 2026-10-17
|
|
8
|
+
|
|
9
|
+
- No changes in this package. Released in lockstep with the other nax packages.
|
|
10
|
+
|
|
11
|
+
## [0.88.0] - 2026-10-16
|
|
12
|
+
|
|
13
|
+
- No changes in this package. Released in lockstep with the other nax packages.
|
|
14
|
+
|
|
7
15
|
## [0.88.0-canary.2] - 2026-10-15
|
|
8
16
|
|
|
9
17
|
- No changes in this package. Released in lockstep with the other nax packages.
|
package/README.md
CHANGED
|
@@ -34,9 +34,9 @@ The host installs these once, near startup. They are module-level, so they apply
|
|
|
34
34
|
|
|
35
35
|
Three pieces of host knowledge are passed in as data or functions, because the package cannot know them. Their behaviour when you omit them differs, so check each one.
|
|
36
36
|
|
|
37
|
-
- **`runDeclaredCommand`** runs a command your project declared (a test or lint command), never one the model wrote. If you do not supply it, the `RunCommand` tool answers `exit 1` with "no declared-command runner is configured for this session" and starts no process. It fails closed.
|
|
38
|
-
- **`ProtectedPathsPolicy`** names the paths you own and want kept away from the agent: git pathspecs the Git tool excludes from its default view, gitignore patterns `GitCommit` refuses to stage, the project state directory, the credential directory and the trust-store file the sandbox protects. If you do not supply it, the Git tool excludes nothing from its default view, and `GitCommit` fails closed: it refuses every path and stages nothing until the policy supplies a non-empty `gitIgnorePatterns` (an empty list refuses the same way). Supply it whenever the agent works in a directory that holds files you own. Building a sandboxed session requires it.
|
|
39
|
-
- **`commandInterceptor`** may rewrite a command before it runs (for example to prefix a wrapper binary). Rewrites are validated: an argv rewrite may only prefix the original argv with the provider's own binary, and a shell rewrite goes through the same narrowing. If you do not supply one, commands run unchanged. An interceptor that throws, or returns a rewrite that fails validation, is treated as a decline, and the original command runs.
|
|
37
|
+
- **`runDeclaredCommand`** runs a command your project declared (a test or lint command), never one the model wrote. If you do not supply it, the `RunCommand` tool answers `exit 1` with "no declared-command runner is configured for this session" and starts no process. It fails closed. `createAgentSession` does not accept a runner yet: `hostPorts.runDeclaredCommand` throws `AGENT_SESSION_INVALID_OPTIONS`, so a session's `RunCommand` always refuses.
|
|
38
|
+
- **`ProtectedPathsPolicy`** (`hostPorts.protectedPaths` on the facade) names the paths you own and want kept away from the agent: git pathspecs the Git tool excludes from its default view, gitignore patterns `GitCommit` refuses to stage, the project state directory, the credential directory and the trust-store file the sandbox protects. If you do not supply it, the Git tool excludes nothing from its default view, and `GitCommit` fails closed: it refuses every path and stages nothing until the policy supplies a non-empty `gitIgnorePatterns` (an empty list refuses the same way). Supply it whenever the agent works in a directory that holds files you own. Building a sandboxed session requires it.
|
|
39
|
+
- **`commandInterceptor`** (`hostPorts.commandInterceptor` on the facade) may rewrite a command before it runs (for example to prefix a wrapper binary). Rewrites are validated: an argv rewrite may only prefix the original argv with the provider's own binary, and a shell rewrite goes through the same narrowing. If you do not supply one, commands run unchanged. An interceptor that throws, or returns a rewrite that fails validation, is treated as a decline, and the original command runs.
|
|
40
40
|
|
|
41
41
|
## Repository instructions
|
|
42
42
|
|
|
@@ -62,7 +62,7 @@ const lookupOrder: EmbedderTool = {
|
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
const session = await createAgentSession({
|
|
65
|
-
backend: nativeBackend({ model: "
|
|
65
|
+
backend: nativeBackend({ model: "openai/gpt-5.4-mini" }),
|
|
66
66
|
profile: "ask",
|
|
67
67
|
workdir: "/abs/project",
|
|
68
68
|
tools: [lookupOrder],
|
|
@@ -82,8 +82,9 @@ Profiles set what a session may do:
|
|
|
82
82
|
| Profile | Side effects |
|
|
83
83
|
|:--------|:-------------|
|
|
84
84
|
| `none` | no side effects; |
|
|
85
|
-
| `read` | read-only; |
|
|
86
|
-
| `
|
|
85
|
+
| `read` | read-only tools (Read, Glob, Grep, Git); |
|
|
86
|
+
| `review` | the read tools plus Bash; `bashApproval` and the sandbox floor apply; |
|
|
87
|
+
| `ask` | Write, Edit, Delete and GitCommit are put to `answer()`; Bash follows `bashApproval`; |
|
|
87
88
|
| `full` | no prompts; `bashApproval` and the sandbox floor apply. |
|
|
88
89
|
|
|
89
90
|
`createAgentSession` takes a `SessionBackend`. `nativeBackend(opts)` takes `instructionFileName` (default `AGENTS.md`), `model`, `credentials`, `catalogOverrides`, `loopHandlers`, `hostPorts`, `bashApproval`, `allowUnsandboxed` and `compaction`. The ACP backend ships in `@nathapp/nax-agent-acp`.
|
|
@@ -97,7 +98,7 @@ Profiles set what a session may do:
|
|
|
97
98
|
- **Approvals and questions.** Answer them with `session.answer(requestId, { decision: "allow" | "deny" })` or `{ text }`. An unanswered one is denied after `approvalTimeoutMs` (default 600000, range 30000..3600000). `answer` returns `"accepted"`, `"expired"`, `"cancelled"` or `"unknown"`.
|
|
98
99
|
- **Credentials.** A session's `credentials` (`memory` or `exec`) and `catalogOverrides` give it its own client. Without them it uses the process-wide `configureCredentials` slot.
|
|
99
100
|
- **History and restarts.** The store holds one document per session, saved at the end of every turn. `close()` keeps it. After a restart, `resumeAgentSession(sessionId, options)` reopens it with the same options you created it with; pass `instructions` and `tools` again, since they are not stored. If the process died mid-turn, `session.lastTurn` is `{ turnId, status: "interrupted" }`, and that turn's message is not in history. A resume with a different model throws `AGENT_SESSION_MODEL_MISMATCH`; a different reasoning effort (`[high]`) is the same model. Resuming with a different backend kind throws `AGENT_SESSION_BACKEND_MISMATCH`; a stored document with no `backend` field is native. Do not open one session id twice at once: the store has no lock.
|
|
100
|
-
- **Errors.** Every error is an `AgentSessionError` with a code `AGENT_SESSION_*`: `INVALID_OPTIONS`, `EXISTS`, `BUSY`, `CLOSED`, `INVALID_ANSWER`, `NOT_FOUND`, `SCHEMA_UNSUPPORTED`, `MODEL_MISMATCH`, `SANDBOX_UNAVAILABLE`, `TOOL_NAME_RESERVED`, `BACKEND_UNAVAILABLE`, `AUTH_REQUIRED`, `CAPABILITY_UNSUPPORTED` or `BACKEND_MISMATCH`. A stored document that cannot be read is a `NaxError` with `TRANSCRIPT_CORRUPT`.
|
|
101
|
+
- **Errors.** Every error is an `AgentSessionError` with a code `AGENT_SESSION_*`: `INVALID_OPTIONS`, `EXISTS`, `BUSY`, `CLOSED`, `INVALID_ANSWER`, `NOT_FOUND`, `SCHEMA_UNSUPPORTED`, `MODEL_MISMATCH`, `SANDBOX_UNAVAILABLE`, `TOOL_NAME_RESERVED`, `BACKEND_UNAVAILABLE`, `AUTH_REQUIRED`, `RATE_LIMITED`, `CAPABILITY_UNSUPPORTED` or `BACKEND_MISMATCH`. A stored document that cannot be read is a `NaxError` with `TRANSCRIPT_CORRUPT`.
|
|
101
102
|
|
|
102
103
|
## MCP connections (`@nathapp/nax-agent/mcp`)
|
|
103
104
|
|
|
@@ -127,10 +128,10 @@ await connection.close(); // waits for the process; SI
|
|
|
127
128
|
|
|
128
129
|
## Status and roadmap
|
|
129
130
|
|
|
130
|
-
`0.x` may reshape `.` with a minor bump. `@nathapp/nax-agent-acp` provides an ACP backend for the same API
|
|
131
|
+
`0.x` may reshape `.` with a minor bump. `@nathapp/nax-agent-acp` provides an ACP backend for the same API. Both packages share one version. See [`CHANGELOG.md`](CHANGELOG.md).
|
|
131
132
|
|
|
132
|
-
Maintainers: see the [release procedure](https://github.com/nathapp-io/nax/blob/main/
|
|
133
|
-
|
|
133
|
+
Maintainers: releases are lockstep and tag-driven; see the [repo-root release procedure](https://github.com/nathapp-io/nax/blob/main/RELEASING.md).
|
|
134
|
+
The package's [`RELEASING.md`](RELEASING.md) keeps its acceptance records.
|
|
134
135
|
|
|
135
136
|
## License
|
|
136
137
|
|
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.
|
|
3
|
+
"version": "0.89.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.
|
|
50
|
+
"@nathapp/nax-ai": "0.89.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
|
}
|