@nathapp/nax-agent 0.91.6 → 0.91.7-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/dist/internal/argv-exec.d.ts +2 -0
- package/dist/internal/argv-exec.js +72 -7
- package/dist/native/session/loop-events/types.d.ts +5 -0
- package/dist/native/session-adapter.js +16 -5
- package/dist/sandbox/claude-settings.d.ts +2 -1
- package/dist/sandbox/claude-settings.js +7 -3
- package/dist/sandbox/defaults.js +1 -0
- package/dist/tools/policy-match.d.ts +2 -2
- package/dist/tools/policy-match.js +9 -6
- package/dist/tools/policy-paths-branch.js +4 -1
- 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.91.7-canary.1] - 2026-10-24
|
|
8
|
+
|
|
9
|
+
- No changes in this package. Released in lockstep with the other nax packages.
|
|
10
|
+
|
|
7
11
|
## [0.91.6] - 2026-10-24
|
|
8
12
|
|
|
9
13
|
- No changes in this package. Released in lockstep with the other nax packages.
|
|
@@ -45,6 +45,8 @@ export declare const _argvExecDeps: {
|
|
|
45
45
|
* stays at the DRAIN_GRACE_MS constant.
|
|
46
46
|
*/
|
|
47
47
|
drainGraceMs: number;
|
|
48
|
+
/** Per-stream capture cap (STREAM_CAPTURE_BYTES); a test shrinks it to exercise the cut. */
|
|
49
|
+
streamCaptureBytes: number;
|
|
48
50
|
/**
|
|
49
51
|
* Injectable timer pair — lets the BUG-13 "hung install" test drive the
|
|
50
52
|
* timeout off a virtual clock instead of waiting the full 1s schema minimum.
|
|
@@ -23,6 +23,15 @@ import { killProcessGroup } from "./process-kill.js";
|
|
|
23
23
|
* Injectable for tests; not exposed as user configuration.
|
|
24
24
|
*/
|
|
25
25
|
export const DRAIN_GRACE_MS = 500;
|
|
26
|
+
/**
|
|
27
|
+
* Most bytes of ONE stream runArgv keeps in memory: its first half and its last half, with the
|
|
28
|
+
* middle replaced by a marker naming how much was dropped. Callers cap what they show far below
|
|
29
|
+
* this (Bash at READ_CEILING, 2 MB), so it bounds the work rather than the answer: before it, a
|
|
30
|
+
* `yes` or a looping test runner was buffered whole for up to the timeout and could exhaust the
|
|
31
|
+
* orchestrator's memory. The stream is still read to EOF, so the child never blocks or takes a
|
|
32
|
+
* SIGPIPE on a full pipe; the tail survives because that is where a failure's error usually is.
|
|
33
|
+
*/
|
|
34
|
+
const STREAM_CAPTURE_BYTES = 8_000_000;
|
|
26
35
|
/** Injectable seam, mirroring _worktreeDependencyDeps. */
|
|
27
36
|
export const _argvExecDeps = {
|
|
28
37
|
spawn: runtimeSpawn,
|
|
@@ -33,6 +42,8 @@ export const _argvExecDeps = {
|
|
|
33
42
|
* stays at the DRAIN_GRACE_MS constant.
|
|
34
43
|
*/
|
|
35
44
|
drainGraceMs: DRAIN_GRACE_MS,
|
|
45
|
+
/** Per-stream capture cap (STREAM_CAPTURE_BYTES); a test shrinks it to exercise the cut. */
|
|
46
|
+
streamCaptureBytes: STREAM_CAPTURE_BYTES,
|
|
36
47
|
/**
|
|
37
48
|
* Injectable timer pair — lets the BUG-13 "hung install" test drive the
|
|
38
49
|
* timeout off a virtual clock instead of waiting the full 1s schema minimum.
|
|
@@ -75,11 +86,10 @@ function buildEnv(options) {
|
|
|
75
86
|
*/
|
|
76
87
|
async function drainToEof(stream, signal) {
|
|
77
88
|
const reader = stream.getReader();
|
|
78
|
-
const
|
|
79
|
-
let text = "";
|
|
89
|
+
const capture = new BoundedCapture(_argvExecDeps.streamCaptureBytes);
|
|
80
90
|
const onAbort = () => {
|
|
81
91
|
// Closing the stream forces the in-flight `read()` below to resolve with
|
|
82
|
-
// `{done: true}`; any bytes already captured stay in `
|
|
92
|
+
// `{done: true}`; any bytes already captured stay in `capture`.
|
|
83
93
|
reader.cancel().catch(() => {
|
|
84
94
|
// Cancelling after the pipe already closed is a no-op race; ignore.
|
|
85
95
|
});
|
|
@@ -91,15 +101,14 @@ async function drainToEof(stream, signal) {
|
|
|
91
101
|
if (done === true)
|
|
92
102
|
break;
|
|
93
103
|
if (value !== undefined)
|
|
94
|
-
|
|
104
|
+
capture.push(value);
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
catch {
|
|
98
108
|
// Stream cancelled by SIGKILL on the process group; the partial text
|
|
99
|
-
// is still in `
|
|
109
|
+
// is still in `capture`, so resolve with what it holds and let the caller
|
|
100
110
|
// decide.
|
|
101
111
|
}
|
|
102
|
-
text += decoder.decode();
|
|
103
112
|
if (signal !== undefined)
|
|
104
113
|
signal.removeEventListener("abort", onAbort);
|
|
105
114
|
try {
|
|
@@ -108,7 +117,63 @@ async function drainToEof(stream, signal) {
|
|
|
108
117
|
catch {
|
|
109
118
|
// Already released; nothing to do.
|
|
110
119
|
}
|
|
111
|
-
return { text, closed: true };
|
|
120
|
+
return { text: capture.text(), closed: true };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Keeps the first and the last `maxBytes / 2` bytes of a stream and counts what falls between
|
|
124
|
+
* (STREAM_CAPTURE_BYTES). The head is decoded as it arrives; the tail is kept as bytes, trimmed from
|
|
125
|
+
* the front, and decoded once at the end from a codepoint boundary.
|
|
126
|
+
*/
|
|
127
|
+
class BoundedCapture {
|
|
128
|
+
half;
|
|
129
|
+
decoder = new TextDecoder();
|
|
130
|
+
head = "";
|
|
131
|
+
headBytes = 0;
|
|
132
|
+
tail = [];
|
|
133
|
+
tailBytes = 0;
|
|
134
|
+
omitted = 0;
|
|
135
|
+
constructor(maxBytes) {
|
|
136
|
+
this.half = Math.max(1, Math.floor(maxBytes / 2));
|
|
137
|
+
}
|
|
138
|
+
push(chunk) {
|
|
139
|
+
let rest = chunk;
|
|
140
|
+
if (this.headBytes < this.half) {
|
|
141
|
+
const take = Math.min(this.half - this.headBytes, rest.length);
|
|
142
|
+
this.head += this.decoder.decode(rest.subarray(0, take), { stream: true });
|
|
143
|
+
this.headBytes += take;
|
|
144
|
+
rest = rest.subarray(take);
|
|
145
|
+
}
|
|
146
|
+
if (rest.length === 0)
|
|
147
|
+
return;
|
|
148
|
+
this.tail.push(rest);
|
|
149
|
+
this.tailBytes += rest.length;
|
|
150
|
+
while (this.tailBytes > this.half) {
|
|
151
|
+
const first = this.tail[0];
|
|
152
|
+
const excess = this.tailBytes - this.half;
|
|
153
|
+
if (first.length <= excess) {
|
|
154
|
+
this.tail.shift();
|
|
155
|
+
this.tailBytes -= first.length;
|
|
156
|
+
this.omitted += first.length;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.tail[0] = first.subarray(excess);
|
|
160
|
+
this.tailBytes -= excess;
|
|
161
|
+
this.omitted += excess;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
text() {
|
|
166
|
+
const tail = Buffer.concat(this.tail);
|
|
167
|
+
if (this.omitted === 0)
|
|
168
|
+
return this.head + this.decoder.decode(tail);
|
|
169
|
+
// The cut can land inside a codepoint: skip its continuation bytes so the tail opens cleanly.
|
|
170
|
+
let start = 0;
|
|
171
|
+
while (start < tail.length && (tail[start] & 0xc0) === 0x80)
|
|
172
|
+
start += 1;
|
|
173
|
+
const head = this.head + this.decoder.decode();
|
|
174
|
+
const marker = `\n[... ${this.omitted + start} bytes of output omitted ...]\n`;
|
|
175
|
+
return head + marker + new TextDecoder().decode(tail.subarray(start));
|
|
176
|
+
}
|
|
112
177
|
}
|
|
113
178
|
export async function runArgv(options) {
|
|
114
179
|
const signal = options.signal;
|
|
@@ -128,6 +128,11 @@ export interface BeforeRequestPatch {
|
|
|
128
128
|
* parameter, not a new concept.
|
|
129
129
|
*/
|
|
130
130
|
export interface CompleteCallOptions {
|
|
131
|
+
/**
|
|
132
|
+
* `false` asks for no thinking on this request; on a model that cannot
|
|
133
|
+
* disable thinking it gets the least thinking the model supports instead.
|
|
134
|
+
* `true` keeps the session's resolved level.
|
|
135
|
+
*/
|
|
131
136
|
readonly thinking?: boolean;
|
|
132
137
|
readonly temperature?: number;
|
|
133
138
|
}
|
|
@@ -271,6 +271,12 @@ export class NativeSessionAdapter {
|
|
|
271
271
|
messages: [...span, { role: "user", content: summaryPrompt(previousSummary) }],
|
|
272
272
|
sessionId,
|
|
273
273
|
signal,
|
|
274
|
+
// A summary restates the transcript; it does not need reasoning.
|
|
275
|
+
// "off", not an absent level: absent means the backend default,
|
|
276
|
+
// which on a model that cannot disable thinking is adaptive at
|
|
277
|
+
// effort high (#2686). The client clamps "off" to the least
|
|
278
|
+
// thinking such a model allows.
|
|
279
|
+
thinking: "off",
|
|
274
280
|
});
|
|
275
281
|
const summaryUsage = res.usage;
|
|
276
282
|
const { costUsd, resolvedRates } = priceCall(summaryUsage, rates);
|
|
@@ -299,11 +305,16 @@ export class NativeSessionAdapter {
|
|
|
299
305
|
const signal = AbortSignal.any(opts.signal !== undefined
|
|
300
306
|
? [opts.signal, controller.signal, turnController.signal, deadlineController.signal]
|
|
301
307
|
: [controller.signal, turnController.signal, deadlineController.signal]);
|
|
302
|
-
// The loop-event bag speaks in booleans: `false`
|
|
303
|
-
// the session's
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
308
|
+
// The loop-event bag speaks in booleans: `false` turns thinking off
|
|
309
|
+
// for this request, while `true` leaves the session's resolved level
|
|
310
|
+
// in place. nax-ai accepts a thinking LEVEL rather than a boolean, so
|
|
311
|
+
// normalize at this boundary. `false` must become "off", not an
|
|
312
|
+
// absent level: absent means the backend default, which on a model
|
|
313
|
+
// that cannot disable thinking is adaptive at effort high (#2686).
|
|
314
|
+
// nax-ai clamps "off" to the lowest level such a model supports, so
|
|
315
|
+
// there `false` still enables thinking, and a `temperature` set
|
|
316
|
+
// alongside it is dropped by the backend as for any thinking request.
|
|
317
|
+
const requestThinking = requestOptions?.thinking === false ? "off" : thinking;
|
|
307
318
|
try {
|
|
308
319
|
instructionInteraction.acknowledge();
|
|
309
320
|
const res = await billedOnFailure(rates, () => streamComplete(client, resolved, {
|
|
@@ -41,6 +41,7 @@ export declare const CLAUDE_SETTINGS_FILES: readonly string[];
|
|
|
41
41
|
* policy compile time, so a symlink created mid-session is still seen.
|
|
42
42
|
*
|
|
43
43
|
* Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
|
|
44
|
-
* other file under `.claude/`, is an ordinary path a tool must still reach.
|
|
44
|
+
* other file under `.claude/`, is an ordinary path a tool must still reach. The
|
|
45
|
+
* compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
|
|
45
46
|
*/
|
|
46
47
|
export declare function claudeSettingsWriteRefusal(tool: string, rel: string, resolvedRoot: string): string | undefined;
|
|
@@ -53,15 +53,19 @@ const CLAUDE_SETTINGS_WRITE_TOOLS = new Set(["Write", "Edit", "Delete"]);
|
|
|
53
53
|
* policy compile time, so a symlink created mid-session is still seen.
|
|
54
54
|
*
|
|
55
55
|
* Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
|
|
56
|
-
* other file under `.claude/`, is an ordinary path a tool must still reach.
|
|
56
|
+
* other file under `.claude/`, is an ordinary path a tool must still reach. The
|
|
57
|
+
* compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
|
|
57
58
|
*/
|
|
58
59
|
export function claudeSettingsWriteRefusal(tool, rel, resolvedRoot) {
|
|
59
60
|
if (!CLAUDE_SETTINGS_WRITE_TOOLS.has(tool))
|
|
60
61
|
return undefined;
|
|
61
|
-
|
|
62
|
+
// Case-folded for the same reason as the `.git/` ban: on a case-insensitive filesystem
|
|
63
|
+
// `.Claude/settings.json` is the settings file, under a spelling a literal compare misses.
|
|
64
|
+
const folded = rel.toLowerCase();
|
|
65
|
+
const entry = CLAUDE_SETTINGS_FILES.find((e) => e.toLowerCase() === folded || resolvedSettingsRel(resolvedRoot, e).toLowerCase() === folded);
|
|
62
66
|
if (entry === undefined)
|
|
63
67
|
return undefined;
|
|
64
|
-
const subject = entry ===
|
|
68
|
+
const subject = entry.toLowerCase() === folded
|
|
65
69
|
? `"${rel}" is a Claude Code settings file`
|
|
66
70
|
: `"${rel}" is the target of the Claude Code settings file "${entry}"`;
|
|
67
71
|
return (`${subject}: Claude loads allow rules, hooks and MCP servers from it for every ` +
|
package/dist/sandbox/defaults.js
CHANGED
|
@@ -20,7 +20,7 @@ import type { ToolGrant } from "./types.ts";
|
|
|
20
20
|
* -- but it made a *scoped* grant wider than its author wrote, which is the one
|
|
21
21
|
* thing a scoped profile exists to prevent.
|
|
22
22
|
*/
|
|
23
|
-
export declare function globToRegExp(pattern: string): RegExp;
|
|
23
|
+
export declare function globToRegExp(pattern: string, ignoreCase?: boolean): RegExp;
|
|
24
24
|
/** A compiled glob, keeping its source so verb names can be told from paths. */
|
|
25
25
|
export interface CompiledPattern {
|
|
26
26
|
readonly source: string;
|
|
@@ -55,7 +55,7 @@ export declare function matchedGlobSource(patterns: readonly CompiledPattern[],
|
|
|
55
55
|
* is exactly bun, argv[1] starts with add", never "the space-joined argv
|
|
56
56
|
* matches this glob".
|
|
57
57
|
*/
|
|
58
|
-
export declare function compileArgvPattern(pattern: string): readonly CompiledPattern[];
|
|
58
|
+
export declare function compileArgvPattern(pattern: string, ignoreCase?: boolean): readonly CompiledPattern[];
|
|
59
59
|
/** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
|
|
60
60
|
* (the package name, flags, ...) are the payload a prefix grant like
|
|
61
61
|
* `bun add*` does not itself constrain. */
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* -- but it made a *scoped* grant wider than its author wrote, which is the one
|
|
20
20
|
* thing a scoped profile exists to prevent.
|
|
21
21
|
*/
|
|
22
|
-
export function globToRegExp(pattern) {
|
|
22
|
+
export function globToRegExp(pattern, ignoreCase = false) {
|
|
23
23
|
let out = "";
|
|
24
24
|
for (let i = 0; i < pattern.length; i += 1) {
|
|
25
25
|
const char = pattern[i];
|
|
@@ -46,7 +46,7 @@ export function globToRegExp(pattern) {
|
|
|
46
46
|
}
|
|
47
47
|
out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
48
48
|
}
|
|
49
|
-
return new RegExp(`^${out}
|
|
49
|
+
return new RegExp(`^${out}$`, ignoreCase ? "i" : "");
|
|
50
50
|
}
|
|
51
51
|
export function matchesAny(patterns, value) {
|
|
52
52
|
return patterns.some((p) => p.re.test(value));
|
|
@@ -64,11 +64,11 @@ export function matchedGlobSource(patterns, value) {
|
|
|
64
64
|
* is exactly bun, argv[1] starts with add", never "the space-joined argv
|
|
65
65
|
* matches this glob".
|
|
66
66
|
*/
|
|
67
|
-
export function compileArgvPattern(pattern) {
|
|
67
|
+
export function compileArgvPattern(pattern, ignoreCase = false) {
|
|
68
68
|
return pattern
|
|
69
69
|
.split(/\s+/)
|
|
70
70
|
.filter((token) => token.length > 0)
|
|
71
|
-
.map((token) => ({ source: token, re: globToRegExp(token) }));
|
|
71
|
+
.map((token) => ({ source: token, re: globToRegExp(token, ignoreCase) }));
|
|
72
72
|
}
|
|
73
73
|
/** A grant pattern's tokens are a PREFIX of argv — trailing argv tokens
|
|
74
74
|
* (the package name, flags, ...) are the payload a prefix grant like
|
|
@@ -96,8 +96,11 @@ export function compileRuleMap(rules) {
|
|
|
96
96
|
const nonWildcard = patterns.filter((p) => p !== "*");
|
|
97
97
|
map.set(rule.tool, {
|
|
98
98
|
unconditional: patterns.includes("*"),
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Case-insensitive, unlike grants: on a case-insensitive filesystem `.ENV` and `.env` are one
|
|
100
|
+
// file, and a deny/ask rule must not be walked past by respelling it (nax#1972, as denyPaths).
|
|
101
|
+
// Over-matching only asks or refuses more, the direction these lists should fail in.
|
|
102
|
+
matchers: nonWildcard.map((source) => ({ source, re: globToRegExp(source, true) })),
|
|
103
|
+
argvPatterns: nonWildcard.map((source) => compileArgvPattern(source, true)),
|
|
101
104
|
raw: patterns,
|
|
102
105
|
rulePatterns: [...(existing?.rulePatterns ?? []), rule.patterns],
|
|
103
106
|
});
|
|
@@ -46,7 +46,10 @@ function entersGitMetadata(root, resolved) {
|
|
|
46
46
|
const rel = relative(realOrRaw(root), resolved);
|
|
47
47
|
if (rel === "" || rel.startsWith(".."))
|
|
48
48
|
return false;
|
|
49
|
-
|
|
49
|
+
// Case-folded: on a case-insensitive filesystem (macOS, Windows defaults) `.GIT/config` IS
|
|
50
|
+
// `.git/config`, and the canonical path keeps the caller's spelling. Over-refusing a genuinely
|
|
51
|
+
// distinct `.GIT` on a case-sensitive filesystem is the direction this ban should fail in.
|
|
52
|
+
return rel.split(sep).some((segment) => segment.toLowerCase() === ".git");
|
|
50
53
|
}
|
|
51
54
|
/**
|
|
52
55
|
* Absolute, symlink-resolved form of `candidate` if it lies inside `root`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nathapp/nax-agent",
|
|
3
|
-
"version": "0.91.
|
|
3
|
+
"version": "0.91.7-canary.1",
|
|
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.91.
|
|
50
|
+
"@nathapp/nax-ai": "0.91.7-canary.1",
|
|
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": "canary"
|
|
58
58
|
}
|
|
59
59
|
}
|