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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ 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-canary.1] - 2026-10-13
8
+
9
+ - Bash guard for nax run state (#2490): `git update-index --skip-worktree` / `--assume-unchanged` (and the `--no-` forms) on a `.nax` path, `git stash` / `push` / `pop` / `apply` in a repo that holds a `.nax/` directory (unless the pathspec excludes `.nax`), and `git checkout` / `restore` of a `.nax` path are now refused in every segment of a compound command, in gated and raw mode, also behind `git -C`/`-c`, env assignments, `env`/`command`/`nice`/`timeout`/`xargs` wrappers and `bash -c`/`eval`, plus a coarse text backstop for wrappers it cannot enumerate. `RULE_SET_VERSION` is now 4 (git global options, `-c alias.*` and more wrappers are resolved when locating the subcommand).
10
+ - Bash guard gaps closed (#2493): whole-tree reverts (`git reset --hard`, `git checkout .`, `git restore [--source=REV] .`, `:/`, `*`, the root, exclusion-only pathspecs) are refused unless a full `.nax` exclusion follows `--`; the `.nax` presence check also finds a `.nax/` up to three levels below the root (cached per compiled policy); `\git`, `GIT_WORK_TREE` / `GIT_DIR` / `GIT_CONFIG_*` assignments, `git config alias.X <verb>`, `!` aliases and aliases that begin with global options, unknown git global options (fail closed to the text backstop; `--attr-source` is now a known separate-value global), `python|node|perl|ruby -c|-e` code and verbs built by shell expansion are all read; `git checkout feature/.nax` (a branch name) is no longer a false positive. `RULE_SET_VERSION` is now 6 (`locateGit` reads `\git`, re-skips globals after an alias expansion, follows alias chains of any length and never expands an alias over a git builtin).
11
+ - Bash guard, final pass (#2493): a shell name or `-c` cluster anywhere in a segment triggers the quoted-string scan again (`if sh -c "..."`, `find -exec sh -c`, `su -c`, `setsid bash -c` were let through by round 2); `git clean` counts as a dry run only for an `n` before any `e` in a short cluster (`-fdenx` is `-e nx`, a real clean); `--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` with an exclusion pathspec on checkout, restore, stash or clean is refused (it turns every exclusion into a literal path). The command guard stops here; detect-and-repair of nax run state is tracked in #2497.
12
+ - Bash guard, review round 2 (#2493): the covering exclusion must be the top-anchored `/**` form (real git: a glob without the `/**` matches only the `.nax` directory entry, not the files in it, and a relative glob is taken from the working directory); a `:(...)` pathspec with an `attr:` element, or whose magic is not exactly `exclude`, is no exclusion; `checkout-index`, `read-tree` and `clean` operands that name `.nax` are refused; long options are matched by any prefix git accepts (`--k`, `--f`, `--a`); the gated screen accepts the exact covering pathspec instead of rejecting its quoted `*`; the quoted-string scan covers only shells, interpreters and exec-style wrappers (`grep "git stash"`, `gh ... --body "..."`, `printf`, `sed` are no longer refused); `git clean -n` / `--dry-run` is allowed. Not covered, documented: git subcommands that execute a string (`rebase -x`, `submodule foreach`, `bisect run`, `difftool -x`, `fetch --upload-pack=`), executing environment variables (`GIT_EXTERNAL_DIFF`, `GIT_PAGER`, `GIT_EDITOR`, `GIT_SSH_COMMAND`), executing config keys beyond the listed ones (`remote.*.uploadpack`, `uploadpack.packObjectsHook`, `core.alternateRefsCommand`, `merge.*.driver`, `core.gitProxy`, `protocol.ext.allow` with `ext::` URLs), `deno eval` in raw mode, and commands built by string arithmetic in an interpreter.
13
+ - Bash guard, review round 1 (#2493): alias chains have no depth limit (a cycle is refused); backslash-built words (`g\it`, `st\ash`) are read; `restore --staged` is index-only only when every flag is known (`--wor`, `-SW` count as worktree); more interpreters and any quoted string next to a guarded verb are scanned; git config keys that execute commands (`core.fsmonitor`, `core.hooksPath`, `diff.external`, `include.path`, ...) are refused through `-c`, `--config-env` and `git config`; an alias over a git builtin is not expanded; the covering exclusion is now `':(top,exclude,glob)**/.nax/**'` (the literal `:!.nax` only counts while the root `.nax` is the only one); `checkout -f`, `switch -f`, `reset --keep|--merge`, `clean`, `checkout-index -a -f` and `read-tree -u --reset` are refused while a `.nax/` exists; an expansion head needs a `git` token in the segment. A `.nax/` below the root, for example a test fixture within three levels, makes `git stash` refused for the whole repository. The nested scan caches a found `.nax` but re-scans an empty result after one second.
14
+ - Native errors: a `quota` kind from `@nathapp/nax-ai` (usage-limit and token-plan 429s) now maps to
15
+ `fail-quota` instead of a transient rate limit.
16
+
7
17
  ## [0.87.1] - 2026-10-12
8
18
 
9
19
  - No changes in this package. Released in lockstep with the other nax packages.
@@ -1,5 +1,5 @@
1
1
  import { type RuleResult } from "./types.ts";
2
- export declare const RULE_SET_VERSION = 3;
2
+ export declare const RULE_SET_VERSION = 6;
3
3
  /** Context the scorer may use; every field is optional and absent means v1 behaviour. */
4
4
  export interface RuleContext {
5
5
  /** The project root the command runs against (the Bash call's cwd). */
@@ -27,14 +27,24 @@
27
27
  * hit. The existing eleven regex families stay; the new patterns live in a
28
28
  * per-segment pass.
29
29
  *
30
+ * v5: `locateGit` (shared with the nax-state guard, #2493) reads a
31
+ * backslash-prefixed `\git`, runs the global-option skip again over an
32
+ * expanded alias, and follows alias chains, so a discard behind any of those
33
+ * is now located by the v3 per-segment pass.
34
+ *
35
+ * v6: `locateGit` never expands an alias that shadows a git builtin (git ignores it) and follows alias
36
+ * chains of any length (a cycle is reported, not looped on), so `git -c alias.checkout=status checkout -f`
37
+ * is the discard it really is.
38
+ *
30
39
  * Frozen before the red-team corpus was written. Any pattern or behaviour
31
40
  * change bumps RULE_SET_VERSION.
32
41
  */
33
42
  import { posix } from "node:path";
34
43
  import { errorMessage } from "#src/infra/errors";
35
44
  import { lexBashCommand } from "#src/permissions/index";
45
+ import { locateGit } from "#src/tools/git-flags/locate";
36
46
  import { QUESTION_IDS } from "./types.js";
37
- export const RULE_SET_VERSION = 3;
47
+ export const RULE_SET_VERSION = 6;
38
48
  const RULES = {
39
49
  deletes_data: [
40
50
  /\brm\s+(?:\S+\s+)*?(?:-[a-zA-Z]*[rRf][a-zA-Z]*|--recursive|--force)\b/,
@@ -140,118 +150,6 @@ const FORCE_FLAGS = new Set(["-f", "--force", "--discard-changes"]);
140
150
  const STAGED_FLAGS = new Set(["--staged", "-S"]);
141
151
  /** True when `word` is a worktree-restore flag — its own pattern handles these. */
142
152
  const WORKTREE_FLAGS = new Set(["--worktree", "-W"]);
143
- /** `git`'s global options that take no value — pass-through, then look for
144
- * the subcommand. */
145
- const GIT_BOOLEAN_GLOBALS = new Set([
146
- "--no-pager",
147
- "--no-replace-objects",
148
- "--bare",
149
- "--literal-pathspecs",
150
- "--glob-pathspecs",
151
- "--noglob-pathspecs",
152
- "--icase-pathspecs",
153
- ]);
154
- /** `git`'s global options that take a separate value (the next token). */
155
- const GIT_VALUE_GLOBALS = new Set(["-c", "-C"]);
156
- /** `git`'s global long options that embed their value (`--key=value`). */
157
- const GIT_KEY_VALUE_GLOBALS = new Set([
158
- "--git-dir=",
159
- "--exec-path=",
160
- "--work-tree=",
161
- "--namespace=",
162
- "--super-prefix=",
163
- ]);
164
- /** `sudo` options whose values occupy the next shell word. */
165
- const SUDO_VALUE_OPTIONS = new Set([
166
- "-u",
167
- "-g",
168
- "-h",
169
- "-D",
170
- "-C",
171
- "-p",
172
- "-r",
173
- "-t",
174
- "-U",
175
- "--user",
176
- "--group",
177
- "--host",
178
- "--chdir",
179
- "--close-from",
180
- "--prompt",
181
- "--role",
182
- "--type",
183
- "--other-user",
184
- ]);
185
- /** Skip a sudo/doas prefix, including options with a separate value. */
186
- function afterPrivilegeWrapper(words) {
187
- if (words[0] !== "sudo" && words[0] !== "doas")
188
- return 0;
189
- let i = 1;
190
- while (words[i]?.startsWith("-")) {
191
- const option = words[i++];
192
- if (option === "--")
193
- break;
194
- if (option !== undefined && SUDO_VALUE_OPTIONS.has(option))
195
- i += 1;
196
- }
197
- return i;
198
- }
199
- /** Skip an env prefix and its assignments and options. */
200
- function afterEnvWrapper(words, start) {
201
- if (words[start] !== "env")
202
- return start;
203
- let i = start + 1;
204
- while (i < words.length) {
205
- const word = words[i];
206
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || word === "-i" || word === "--ignore-environment") {
207
- i += 1;
208
- }
209
- else if (word === "-u" || word === "--unset") {
210
- i += 2;
211
- }
212
- else
213
- break;
214
- }
215
- return i;
216
- }
217
- /** Find `git` in the executable position after supported shell wrappers. */
218
- function gitExecutableIndex(words) {
219
- let i = afterEnvWrapper(words, afterPrivilegeWrapper(words));
220
- if (words[i] === "command" || words[i] === "exec")
221
- i += 1;
222
- return words[i] === "git" ? i : undefined;
223
- }
224
- /** Locate executable `git` in `words` and advance past its global options. Returns the
225
- subcommand and the index of the first argument (or `undefined` when `git`
226
- isn't there or has no subcommand). Handles `sudo git`, `env VAR=x git`,
227
- `git --no-pager checkout`, etc. */
228
- function locateGit(words) {
229
- const gitIdx = gitExecutableIndex(words);
230
- if (gitIdx === undefined)
231
- return undefined;
232
- let i = gitIdx + 1;
233
- while (i < words.length) {
234
- const w = words[i];
235
- if (GIT_BOOLEAN_GLOBALS.has(w)) {
236
- i += 1;
237
- continue;
238
- }
239
- if (GIT_VALUE_GLOBALS.has(w)) {
240
- // The value is a separate token; skip it.
241
- i += 2;
242
- continue;
243
- }
244
- if ([...GIT_KEY_VALUE_GLOBALS].some((prefix) => w.startsWith(prefix))) {
245
- i += 1;
246
- continue;
247
- }
248
- break;
249
- }
250
- const subcommand = words[i];
251
- if (subcommand === undefined)
252
- return undefined;
253
- return { subcommand, args: words.slice(i + 1) };
254
- }
255
153
  /** True when `token` is a short-flag bundle that includes `f` (so `-f`,
256
154
  * `-fq`, `-fb`, `-fqH`, ...). Stops at `--` so a long flag like
257
155
  * `--force-something` is not misread as a bundle. */
@@ -17,6 +17,13 @@ const FAILURES = Object.freeze({
17
17
  outcome: "fail-rate-limit",
18
18
  retriable: true,
19
19
  },
20
+ // The account or plan is exhausted: waiting does not help, so not retriable.
21
+ quota: {
22
+ message: "nax-ai account or plan quota exhausted",
23
+ category: "availability",
24
+ outcome: "fail-quota",
25
+ retriable: false,
26
+ },
20
27
  auth: {
21
28
  message: "nax-ai authentication failed",
22
29
  category: "availability",
@@ -19,16 +19,31 @@
19
19
  * `grep -n x y` did not. 20 of 34 denials in the two runs after #1971 carried
20
20
  * no redirect (nax#1999). A single table cannot drift against itself.
21
21
  */
22
- export declare function redirectForArgv(argv: readonly string[], available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
22
+ export declare const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
23
23
  /**
24
- * Name the tool that serves the intent behind a denied Bash COMMAND.
24
+ * The sentence explaining a revert of nax's own run state, or undefined.
25
+ *
26
+ * Deliberately NOT an `Intent`: `render()` wraps every hit as "this session
27
+ * already has `X`", which is false here -- no tool reverts `.nax/`, and the
28
+ * module's header forbids naming one it does not hold. So this predicate is
29
+ * checked BEFORE `intentFor` in both entry points and returns its own sentence.
25
30
  *
26
- * Reads the first segment only: that is what the model reached for, and a
27
- * later segment's intent is not what to teach when the call is refused. The
28
- * split is deliberately its own crude one rather than the lexer's
29
- * (`lexBashCommand`): a command REFUSED for an unanalysable construct must
30
- * still get a redirect, and the lexer returns nothing to read in that case.
31
+ * Verb scope is `checkout` and `restore` only; `git stash` has no operands to
32
+ * gate on and is handled by `nax-state-guard.ts` instead. The gate is a `.nax`
33
+ * path SEGMENT (split on `/`), never a substring, so `src/nax-helpers.ts` and
34
+ * `docs/.naxignore` stay unanswered. `--` is a separator, not an operand, and a
35
+ * `timeout N` wrapper is stripped the same way `intentFor` strips it.
36
+ */
37
+ export declare function naxOwnedRunStateExplanation(tokens: readonly string[]): string | undefined;
38
+ /** A `timeout N ...` prefix wraps the real command; look past it. */
39
+ export declare function withoutTimeoutPrefix(tokens: readonly string[]): readonly string[];
40
+ export declare function redirectForArgv(argv: readonly string[], available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
41
+ /**
42
+ * The crude segment split shared by the redirect and the raw screen's fallback
43
+ * for a command the lexer refused. Deliberately not the lexer: it must still
44
+ * produce segments when the lexer returns nothing to read.
31
45
  */
46
+ export declare function crudeCommandSegments(command: string): string[][];
32
47
  export declare function redirectForCommand(command: string, available: ReadonlySet<string>, declaredCommands: ReadonlySet<string>): string | undefined;
33
48
  /**
34
49
  * Name the tool that serves the intent behind a denied VERB call.
@@ -32,7 +32,7 @@ const GIT_COMMIT_VERBS = new Set(["add", "commit"]);
32
32
  * intent, so the refusal teaches instead of naming one (nax#2007).
33
33
  */
34
34
  const GIT_REVERT_VERBS = new Set(["checkout", "restore"]);
35
- const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
35
+ export const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written by the harness during this run. It is not part of your diff and must not be reverted.";
36
36
  /**
37
37
  * The sentence explaining a revert of nax's own run state, or undefined.
38
38
  *
@@ -41,13 +41,13 @@ const NAX_OWNED_RUN_STATE_EXPLANATION = "`.nax/` is nax's own run state, written
41
41
  * module's header forbids naming one it does not hold. So this predicate is
42
42
  * checked BEFORE `intentFor` in both entry points and returns its own sentence.
43
43
  *
44
- * Verb scope is `checkout` and `restore` only; `git stash` is the agent's own
45
- * WIP, not nax's state, and has no operands to gate on. The gate is a `.nax`
44
+ * Verb scope is `checkout` and `restore` only; `git stash` has no operands to
45
+ * gate on and is handled by `nax-state-guard.ts` instead. The gate is a `.nax`
46
46
  * path SEGMENT (split on `/`), never a substring, so `src/nax-helpers.ts` and
47
47
  * `docs/.naxignore` stay unanswered. `--` is a separator, not an operand, and a
48
48
  * `timeout N` wrapper is stripped the same way `intentFor` strips it.
49
49
  */
50
- function naxOwnedRunStateExplanation(tokens) {
50
+ export function naxOwnedRunStateExplanation(tokens) {
51
51
  const av = withoutTimeoutPrefix(tokens);
52
52
  const hasGitPrefix = av[0] === "git";
53
53
  const verb = hasGitPrefix ? av[1] : av[0];
@@ -142,7 +142,7 @@ function isSingleFileDelete(tokens, from) {
142
142
  return tokens.length === from + 1 && !tokens[from]?.startsWith("-");
143
143
  }
144
144
  /** A `timeout N ...` prefix wraps the real command; look past it. */
145
- function withoutTimeoutPrefix(tokens) {
145
+ export function withoutTimeoutPrefix(tokens) {
146
146
  return tokens[0] === "timeout" ? tokens.slice(2) : tokens;
147
147
  }
148
148
  /**
@@ -211,23 +211,39 @@ export function redirectForArgv(argv, available, declaredCommands) {
211
211
  /**
212
212
  * Name the tool that serves the intent behind a denied Bash COMMAND.
213
213
  *
214
- * Reads the first segment only: that is what the model reached for, and a
215
- * later segment's intent is not what to teach when the call is refused. The
214
+ * The intent redirect reads the first segment only: that is what the model
215
+ * reached for, and a later segment's intent is not what to teach when the call
216
+ * is refused. The
216
217
  * split is deliberately its own crude one rather than the lexer's
217
218
  * (`lexBashCommand`): a command REFUSED for an unanalysable construct must
218
219
  * still get a redirect, and the lexer returns nothing to read in that case.
219
220
  */
220
- export function redirectForCommand(command, available, declaredCommands) {
221
- const [first = ""] = command.split(/&&|\|\||;|\||\n/);
222
- const tokens = first
221
+ function tokenize(segment) {
222
+ return segment
223
223
  .trim()
224
224
  .split(/\s+/)
225
225
  .filter((token) => token.length > 0);
226
+ }
227
+ /**
228
+ * The crude segment split shared by the redirect and the raw screen's fallback
229
+ * for a command the lexer refused. Deliberately not the lexer: it must still
230
+ * produce segments when the lexer returns nothing to read.
231
+ */
232
+ export function crudeCommandSegments(command) {
233
+ return command.split(/&&|\|\||;|\||\n/).map(tokenize);
234
+ }
235
+ export function redirectForCommand(command, available, declaredCommands) {
236
+ const segments = crudeCommandSegments(command);
237
+ // A revert of nax's run state is explained wherever it sits in a compound
238
+ // command (#2490); only the intent redirect below is first-segment-only.
239
+ for (const segment of segments) {
240
+ const ownedRunState = naxOwnedRunStateExplanation(segment);
241
+ if (ownedRunState !== undefined)
242
+ return ownedRunState;
243
+ }
244
+ const tokens = segments[0] ?? [];
226
245
  if (tokens.length === 0)
227
246
  return undefined;
228
- const ownedRunState = naxOwnedRunStateExplanation(tokens);
229
- if (ownedRunState !== undefined)
230
- return ownedRunState;
231
247
  const hit = intentFor(tokens);
232
248
  if (hit === undefined)
233
249
  return taskRunnerFallback(tokens, available, declaredCommands);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Locating `git` and its subcommand inside a command line.
3
+ *
4
+ * A leaf module with NO imports, like `./index.ts`, so both `command-safety`
5
+ * and `tools` can use it without a cycle. Shared by the command-safety scorer
6
+ * and the Bash guard for nax's run state (#2490), so a wrapper or global
7
+ * option that hides `git` from one cannot hide it from the other.
8
+ */
9
+ /**
10
+ * Index of the word that actually names the command, after leading `VAR=x`
11
+ * assignments and the wrappers that pass through to it (`sudo`, `env`,
12
+ * `command`, `exec`, `nice`, `timeout`, `xargs`).
13
+ */
14
+ export declare function commandHeadIndex(words: readonly string[]): number;
15
+ /** Find `git` in the executable position after supported wrappers. */
16
+ export declare function gitExecutableIndex(words: readonly string[]): number | undefined;
17
+ export interface LocatedGit {
18
+ readonly subcommand: string;
19
+ readonly args: readonly string[];
20
+ /**
21
+ * Values of `-C`, `--work-tree` and `--git-dir` (both `--key value` and
22
+ * `--key=value`) and of leading `GIT_WORK_TREE=` / `GIT_DIR=` assignments, in order.
23
+ */
24
+ readonly dirs: readonly string[];
25
+ /** True when an alias is defined through `--config-env=alias.*`: its value lives in the environment and cannot be read. */
26
+ readonly unresolvedAlias: boolean;
27
+ /** True when a global option this reader does not know sat before the subcommand: the subcommand found may be that option's value. */
28
+ readonly uncertain: boolean;
29
+ /** The shell text a `!` alias runs (its value without the `!`, then the call's remaining arguments), or undefined. */
30
+ readonly shellAlias: string | undefined;
31
+ /** Lower-cased keys set by `-c key=value` and `--config-env=key=VAR`, in order. */
32
+ readonly configKeys: readonly string[];
33
+ /** True when a leading assignment sets a `GIT_CONFIG*` variable (config, including aliases, injected through the environment). */
34
+ readonly configEnv: boolean;
35
+ /** True under `--literal-pathspecs` or a non-empty, non-`0` `GIT_LITERAL_PATHSPECS`: every `:(...)` / `:!` exclusion is a literal path. */
36
+ readonly literalPathspecs: boolean;
37
+ }
38
+ /**
39
+ * Locate executable `git` in `words` and advance past its global options.
40
+ * Returns the subcommand (alias-expanded), its arguments, and the directories
41
+ * the global options point git at, or `undefined` when `git` isn't there or has
42
+ * no subcommand. Handles `sudo git`, `env VAR=x git`, a backslash-prefixed
43
+ * `git`, `git --no-pager checkout`, and an UNKNOWN `-x` / `--xxx` global
44
+ * (skipped as one word and flagged `uncertain`, so a new git option cannot
45
+ * hide the subcommand silently).
46
+ */
47
+ export declare function locateGit(words: readonly string[]): LocatedGit | undefined;