@nathapp/nax-agent 0.87.1 → 0.88.0-canary.2
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 +14 -0
- package/dist/coding-tools/coding-tool-support.d.ts +16 -0
- package/dist/coding-tools/coding-tool-support.js +12 -0
- package/dist/command-safety/rule-scorer.d.ts +1 -1
- package/dist/command-safety/rule-scorer.js +11 -113
- package/dist/config/schemas-sandbox.d.ts +1 -0
- package/dist/config/schemas-sandbox.js +9 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/internal/process-kill.d.ts +6 -2
- package/dist/internal/process-kill.js +6 -2
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/mcp/connect.js +15 -1
- package/dist/mcp/types.d.ts +6 -0
- package/dist/native/auth.d.ts +2 -2
- package/dist/native/auth.js +4 -4
- package/dist/native/errors.js +7 -0
- package/dist/native/failed-round-trip-spend.d.ts +16 -0
- package/dist/native/failed-round-trip-spend.js +33 -0
- package/dist/native/session/partial-spend.d.ts +20 -0
- package/dist/native/session/partial-spend.js +14 -0
- package/dist/native/session/session.js +11 -4
- package/dist/native/session/turn-complete-step.js +23 -3
- package/dist/native/session-adapter.js +3 -2
- package/dist/permissions/bash-lex.js +36 -4
- package/dist/sandbox/claude-settings.d.ts +46 -0
- package/dist/sandbox/claude-settings.js +76 -0
- package/dist/sandbox/index.d.ts +2 -1
- package/dist/sandbox/index.js +1 -0
- package/dist/sandbox/launcher.js +4 -0
- package/dist/sandbox/messages.d.ts +1 -1
- package/dist/sandbox/messages.js +15 -3
- package/dist/sandbox/policy-builder.js +27 -2
- package/dist/sandbox/session-sandbox.d.ts +3 -0
- package/dist/sandbox/session-sandbox.js +9 -0
- package/dist/sandbox/srt-backend.d.ts +10 -1
- package/dist/sandbox/srt-backend.js +14 -4
- package/dist/sandbox/types.d.ts +23 -0
- package/dist/session/agent-session-options.js +1 -1
- package/dist/session/agent-session-types.d.ts +1 -1
- package/dist/session/native-backend-options.js +5 -3
- package/dist/session/session-backend.d.ts +9 -0
- package/dist/session/session-tool-support.d.ts +1 -1
- package/dist/session/session-tool-support.js +4 -2
- package/dist/session/session-types.d.ts +10 -0
- package/dist/tools/bash.d.ts +7 -1
- package/dist/tools/bash.js +18 -3
- package/dist/tools/denial-redirect.d.ts +22 -7
- package/dist/tools/denial-redirect.js +29 -13
- package/dist/tools/git-flags/locate.d.ts +47 -0
- package/dist/tools/git-flags/locate.js +361 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/nax-dirs.d.ts +22 -0
- package/dist/tools/nax-dirs.js +89 -0
- package/dist/tools/nax-evasion.d.ts +19 -0
- package/dist/tools/nax-evasion.js +88 -0
- package/dist/tools/nax-revert.d.ts +34 -0
- package/dist/tools/nax-revert.js +233 -0
- package/dist/tools/nax-stash-restore.d.ts +7 -0
- package/dist/tools/nax-stash-restore.js +92 -0
- package/dist/tools/nax-state-guard.d.ts +18 -0
- package/dist/tools/nax-state-guard.js +478 -0
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +40 -12
- package/dist/tools/policy-bash.d.ts +3 -0
- package/dist/tools/policy-bash.js +38 -9
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +3 -1
- package/dist/tools/policy.js +13 -0
- package/dist/tools/provider-advertise.d.ts +13 -0
- package/dist/tools/provider-advertise.js +41 -1
- package/dist/tools/provider-instructions.d.ts +33 -0
- package/dist/tools/provider-instructions.js +63 -0
- package/dist/tools/provider-types.d.ts +12 -0
- package/dist/tools/run-command.d.ts +15 -0
- package/dist/tools/run-command.js +16 -1
- package/dist/tools/tool-audit.d.ts +1 -1
- package/package.json +3 -3
package/dist/tools/bash.d.ts
CHANGED
|
@@ -62,8 +62,14 @@ export interface BashToolOptions {
|
|
|
62
62
|
*/
|
|
63
63
|
readonly launcher?: CommandLauncher;
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Injectable spawn seam, mirroring `_argvExecDeps`, plus the platform seam
|
|
67
|
+
* US-006 added: it decides whether a description carries the macOS ps note, and
|
|
68
|
+
* exists so a test can pin both platforms without touching the global
|
|
69
|
+
* `process.platform`.
|
|
70
|
+
*/
|
|
66
71
|
export declare const _bashToolDeps: {
|
|
67
72
|
runArgv: typeof runArgv;
|
|
73
|
+
platform: () => NodeJS.Platform;
|
|
68
74
|
};
|
|
69
75
|
export declare function createBashTool(opts?: BashToolOptions): CodingTool;
|
package/dist/tools/bash.js
CHANGED
|
@@ -37,9 +37,15 @@ export const BASH_TIMEOUT_MS = 300_000;
|
|
|
37
37
|
* even start, and a 0 would disable the deadline entirely. */
|
|
38
38
|
const MIN_BASH_TIMEOUT_MS = 1_000;
|
|
39
39
|
export const DEFAULT_BASH_SHELL = "/bin/sh";
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Injectable spawn seam, mirroring `_argvExecDeps`, plus the platform seam
|
|
42
|
+
* US-006 added: it decides whether a description carries the macOS ps note, and
|
|
43
|
+
* exists so a test can pin both platforms without touching the global
|
|
44
|
+
* `process.platform`.
|
|
45
|
+
*/
|
|
41
46
|
export const _bashToolDeps = {
|
|
42
47
|
runArgv,
|
|
48
|
+
platform: () => process.platform,
|
|
43
49
|
};
|
|
44
50
|
function describeGrants(patterns) {
|
|
45
51
|
const named = (patterns ?? []).filter((pattern) => pattern !== "*");
|
|
@@ -124,6 +130,12 @@ function rawDescription(shell, containment = RAW_UNCONTAINED) {
|
|
|
124
130
|
"A `find` whose start path is `/`, `~` or `$HOME` is refused -- search within the repository instead. " +
|
|
125
131
|
BACKGROUND_PROCESSES_KILLED_SENTENCE);
|
|
126
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* US-006: no `sandboxSentence` here, deliberately. This variant describes a
|
|
135
|
+
* sandbox that is NOT running, so the darwin ps note -- which explains a
|
|
136
|
+
* refusal an AVAILABLE sandbox makes -- would be a false statement about this
|
|
137
|
+
* machine. The refusal names its own reason and that every call is refused.
|
|
138
|
+
*/
|
|
127
139
|
function rawUnavailableDescription(shell, reason) {
|
|
128
140
|
return (`Run one shell command string under ${shell} -- but on this machine ${rawBashRefusalReason(reason)} ` +
|
|
129
141
|
"Under raw mode every call is refused; use the structured tools (Read, Glob, Grep, Git, RunCommand) instead. " +
|
|
@@ -131,9 +143,12 @@ function rawUnavailableDescription(shell, reason) {
|
|
|
131
143
|
}
|
|
132
144
|
function bashToolDescription(shell, opts) {
|
|
133
145
|
const state = opts.launcher?.state ?? { kind: "disabled" };
|
|
146
|
+
// US-006: read ONCE, so every `sandboxSentence` call site in this description
|
|
147
|
+
// agrees on the host's platform.
|
|
148
|
+
const platform = _bashToolDeps.platform();
|
|
134
149
|
if (opts.bashApproval === "raw") {
|
|
135
150
|
if (state.kind === "available")
|
|
136
|
-
return rawDescription(shell, `The command runs from the repository root ${sandboxSentence(state.network, state.sharedTmp !== false)} `);
|
|
151
|
+
return rawDescription(shell, `The command runs from the repository root ${sandboxSentence(state.network, state.sharedTmp !== false, platform)} `);
|
|
137
152
|
if (state.kind === "unavailable")
|
|
138
153
|
return rawUnavailableDescription(shell, state.reason);
|
|
139
154
|
return rawDescription(shell);
|
|
@@ -148,7 +163,7 @@ function bashToolDescription(shell, opts) {
|
|
|
148
163
|
? escalateDescription(shell, opts.patterns)
|
|
149
164
|
: gatedDescription(shell, opts.patterns);
|
|
150
165
|
if (state.kind === "available") {
|
|
151
|
-
return `${policyDescription} Commands that pass run ${sandboxSentence(state.network, state.sharedTmp !== false)}`;
|
|
166
|
+
return `${policyDescription} Commands that pass run ${sandboxSentence(state.network, state.sharedTmp !== false, platform)}`;
|
|
152
167
|
}
|
|
153
168
|
if (state.kind === "unavailable")
|
|
154
169
|
return `${policyDescription} ${unsandboxedSentence(state.reason)}`;
|
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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`
|
|
45
|
-
*
|
|
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
|
-
*
|
|
215
|
-
* later segment's intent is not what to teach when the call
|
|
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
|
-
|
|
221
|
-
|
|
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;
|
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
/** `sudo` options whose values occupy the next shell word. */
|
|
10
|
+
const SUDO_VALUE_OPTIONS = new Set([
|
|
11
|
+
"-u",
|
|
12
|
+
"-g",
|
|
13
|
+
"-h",
|
|
14
|
+
"-D",
|
|
15
|
+
"-C",
|
|
16
|
+
"-p",
|
|
17
|
+
"-r",
|
|
18
|
+
"-t",
|
|
19
|
+
"-U",
|
|
20
|
+
"--user",
|
|
21
|
+
"--group",
|
|
22
|
+
"--host",
|
|
23
|
+
"--chdir",
|
|
24
|
+
"--close-from",
|
|
25
|
+
"--prompt",
|
|
26
|
+
"--role",
|
|
27
|
+
"--type",
|
|
28
|
+
"--other-user",
|
|
29
|
+
]);
|
|
30
|
+
/** Skip a sudo/doas prefix, including options with a separate value. */
|
|
31
|
+
function afterPrivilegeWrapper(words) {
|
|
32
|
+
if (words[0] !== "sudo" && words[0] !== "doas")
|
|
33
|
+
return 0;
|
|
34
|
+
let i = 1;
|
|
35
|
+
while (words[i]?.startsWith("-")) {
|
|
36
|
+
const option = words[i++];
|
|
37
|
+
if (option === "--")
|
|
38
|
+
break;
|
|
39
|
+
if (option !== undefined && SUDO_VALUE_OPTIONS.has(option))
|
|
40
|
+
i += 1;
|
|
41
|
+
}
|
|
42
|
+
return i;
|
|
43
|
+
}
|
|
44
|
+
/** Skip an env prefix and its assignments and options. */
|
|
45
|
+
function afterEnvWrapper(words, start) {
|
|
46
|
+
if (words[start] !== "env")
|
|
47
|
+
return start;
|
|
48
|
+
let i = start + 1;
|
|
49
|
+
while (i < words.length) {
|
|
50
|
+
const word = words[i];
|
|
51
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || word === "-i" || word === "--ignore-environment") {
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
else if (word === "-u" || word === "--unset") {
|
|
55
|
+
i += 2;
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
return i;
|
|
61
|
+
}
|
|
62
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
63
|
+
/** `xargs` options that take a separate value. */
|
|
64
|
+
const XARGS_VALUE_OPTIONS = new Set(["-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a"]);
|
|
65
|
+
function afterNice(words, start) {
|
|
66
|
+
let i = start + 1;
|
|
67
|
+
if (words[i] === "-n" || words[i] === "--adjustment")
|
|
68
|
+
i += 2;
|
|
69
|
+
else if (words[i]?.startsWith("-") && words[i] !== "--")
|
|
70
|
+
i += 1;
|
|
71
|
+
return i;
|
|
72
|
+
}
|
|
73
|
+
function afterTimeout(words, start) {
|
|
74
|
+
let i = start + 1;
|
|
75
|
+
while (words[i]?.startsWith("-"))
|
|
76
|
+
i += words[i] === "-k" || words[i] === "-s" ? 2 : 1;
|
|
77
|
+
return i + 1;
|
|
78
|
+
}
|
|
79
|
+
function afterXargs(words, start) {
|
|
80
|
+
let i = start + 1;
|
|
81
|
+
while (words[i]?.startsWith("-") && words[i] !== "--")
|
|
82
|
+
i += XARGS_VALUE_OPTIONS.has(words[i]) ? 2 : 1;
|
|
83
|
+
return i;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Index of the word that actually names the command, after leading `VAR=x`
|
|
87
|
+
* assignments and the wrappers that pass through to it (`sudo`, `env`,
|
|
88
|
+
* `command`, `exec`, `nice`, `timeout`, `xargs`).
|
|
89
|
+
*/
|
|
90
|
+
export function commandHeadIndex(words) {
|
|
91
|
+
let i = 0;
|
|
92
|
+
for (let guard = 0; guard < words.length; guard += 1) {
|
|
93
|
+
const before = i;
|
|
94
|
+
while (words[i] !== undefined && ASSIGNMENT.test(words[i]))
|
|
95
|
+
i += 1;
|
|
96
|
+
const word = words[i];
|
|
97
|
+
if (word === "sudo" || word === "doas")
|
|
98
|
+
i = afterPrivilegeWrapper(words.slice(i)) + i;
|
|
99
|
+
else if (word === "env")
|
|
100
|
+
i = afterEnvWrapper(words, i);
|
|
101
|
+
else if (word === "command" || word === "exec")
|
|
102
|
+
i += 1;
|
|
103
|
+
else if (word === "nice")
|
|
104
|
+
i = afterNice(words, i);
|
|
105
|
+
else if (word === "timeout")
|
|
106
|
+
i = afterTimeout(words, i);
|
|
107
|
+
else if (word === "xargs")
|
|
108
|
+
i = afterXargs(words, i);
|
|
109
|
+
if (i === before)
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return i;
|
|
113
|
+
}
|
|
114
|
+
/** `git`, or `\\git` (a leading backslash only suppresses shell aliases, so it still runs git). */
|
|
115
|
+
function isGitWord(word) {
|
|
116
|
+
return word === "git" || word === "\\git";
|
|
117
|
+
}
|
|
118
|
+
/** Find `git` in the executable position after supported wrappers. */
|
|
119
|
+
export function gitExecutableIndex(words) {
|
|
120
|
+
const i = commandHeadIndex(words);
|
|
121
|
+
return isGitWord(words[i]) ? i : undefined;
|
|
122
|
+
}
|
|
123
|
+
/** Long global options whose value is the NEXT word (`--git-dir X`); `--key=value` forms are skipped as one word. */
|
|
124
|
+
const GIT_SEPARATE_VALUE_LONG = new Set([
|
|
125
|
+
"--git-dir",
|
|
126
|
+
"--work-tree",
|
|
127
|
+
"--namespace",
|
|
128
|
+
"--super-prefix",
|
|
129
|
+
"--config-env",
|
|
130
|
+
"--attr-source",
|
|
131
|
+
]);
|
|
132
|
+
/** Global options that take no value, or only an `=value` (`git help git`). Anything else before the subcommand is unknown. */
|
|
133
|
+
const GIT_KNOWN_FLAG_GLOBALS = new Set([
|
|
134
|
+
"-v",
|
|
135
|
+
"--version",
|
|
136
|
+
"-h",
|
|
137
|
+
"--help",
|
|
138
|
+
"--exec-path",
|
|
139
|
+
"--html-path",
|
|
140
|
+
"--man-path",
|
|
141
|
+
"--info-path",
|
|
142
|
+
"-p",
|
|
143
|
+
"--paginate",
|
|
144
|
+
"-P",
|
|
145
|
+
"--no-pager",
|
|
146
|
+
"--no-replace-objects",
|
|
147
|
+
"--no-lazy-fetch",
|
|
148
|
+
"--no-optional-locks",
|
|
149
|
+
"--no-advice",
|
|
150
|
+
"--bare",
|
|
151
|
+
"--literal-pathspecs",
|
|
152
|
+
"--glob-pathspecs",
|
|
153
|
+
"--noglob-pathspecs",
|
|
154
|
+
"--icase-pathspecs",
|
|
155
|
+
"--list-cmds",
|
|
156
|
+
]);
|
|
157
|
+
/** Global options whose value redirects git to another directory. */
|
|
158
|
+
const GIT_DIR_OPTIONS = new Set(["-C", "--git-dir", "--work-tree"]);
|
|
159
|
+
/**
|
|
160
|
+
* Subcommands git runs itself: an alias of the same name is NEVER expanded (`git -c alias.stash=status
|
|
161
|
+
* stash` really stashes). Every guarded verb plus the common builtins; a name missing here is expanded,
|
|
162
|
+
* which can only over-refuse.
|
|
163
|
+
*/
|
|
164
|
+
const GIT_BUILTINS = new Set([
|
|
165
|
+
"add",
|
|
166
|
+
"am",
|
|
167
|
+
"apply",
|
|
168
|
+
"archive",
|
|
169
|
+
"bisect",
|
|
170
|
+
"blame",
|
|
171
|
+
"branch",
|
|
172
|
+
"bundle",
|
|
173
|
+
"checkout",
|
|
174
|
+
"checkout-index",
|
|
175
|
+
"cherry",
|
|
176
|
+
"cherry-pick",
|
|
177
|
+
"clean",
|
|
178
|
+
"clone",
|
|
179
|
+
"commit",
|
|
180
|
+
"config",
|
|
181
|
+
"describe",
|
|
182
|
+
"diff",
|
|
183
|
+
"fetch",
|
|
184
|
+
"format-patch",
|
|
185
|
+
"gc",
|
|
186
|
+
"grep",
|
|
187
|
+
"init",
|
|
188
|
+
"log",
|
|
189
|
+
"ls-files",
|
|
190
|
+
"ls-tree",
|
|
191
|
+
"merge",
|
|
192
|
+
"mv",
|
|
193
|
+
"notes",
|
|
194
|
+
"pull",
|
|
195
|
+
"push",
|
|
196
|
+
"rebase",
|
|
197
|
+
"reflog",
|
|
198
|
+
"remote",
|
|
199
|
+
"reset",
|
|
200
|
+
"restore",
|
|
201
|
+
"revert",
|
|
202
|
+
"rm",
|
|
203
|
+
"show",
|
|
204
|
+
"sparse-checkout",
|
|
205
|
+
"stash",
|
|
206
|
+
"status",
|
|
207
|
+
"submodule",
|
|
208
|
+
"switch",
|
|
209
|
+
"tag",
|
|
210
|
+
"update-index",
|
|
211
|
+
"update-ref",
|
|
212
|
+
"worktree",
|
|
213
|
+
"read-tree",
|
|
214
|
+
"write-tree",
|
|
215
|
+
"rev-parse",
|
|
216
|
+
"rev-list",
|
|
217
|
+
"symbolic-ref",
|
|
218
|
+
"show-ref",
|
|
219
|
+
"for-each-ref",
|
|
220
|
+
"hash-object",
|
|
221
|
+
"cat-file",
|
|
222
|
+
"help",
|
|
223
|
+
]);
|
|
224
|
+
/** Skip global options from `start`; returns the index of the first word that is not one. */
|
|
225
|
+
function skipGlobals(words, start, state) {
|
|
226
|
+
let i = start;
|
|
227
|
+
while (i < words.length && words[i].startsWith("-") && words[i] !== "--") {
|
|
228
|
+
i = consumeGlobal(words, i, state);
|
|
229
|
+
}
|
|
230
|
+
return i;
|
|
231
|
+
}
|
|
232
|
+
/** Alias value words, with a leading `!` and a leading `git` removed. */
|
|
233
|
+
function aliasWords(value) {
|
|
234
|
+
const words = value
|
|
235
|
+
.replace(/^!/, "")
|
|
236
|
+
.split(/\s+/)
|
|
237
|
+
.filter((word) => word.length > 0);
|
|
238
|
+
if (isGitWord(words[0]))
|
|
239
|
+
words.shift();
|
|
240
|
+
return words;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Expand `-c alias.NAME=VALUE` while `subcommand` names such an alias (never a builtin, see GIT_BUILTINS).
|
|
244
|
+
* A name met twice is a cycle: `cycle` is set and the chain stops. The
|
|
245
|
+
* expanded words are run through the global-option skip again (an alias may
|
|
246
|
+
* start with `-C .` or `--no-pager`), and a `!` value is also returned as shell text.
|
|
247
|
+
*/
|
|
248
|
+
function resolveAliases(subcommand, args, state) {
|
|
249
|
+
let current = { subcommand, args, shellAlias: undefined, cycle: false };
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (;;) {
|
|
252
|
+
const name = current.subcommand.toLowerCase();
|
|
253
|
+
const value = GIT_BUILTINS.has(name) ? undefined : state.aliases.get(name);
|
|
254
|
+
if (value === undefined)
|
|
255
|
+
break;
|
|
256
|
+
if (seen.has(name))
|
|
257
|
+
return { ...current, cycle: true };
|
|
258
|
+
seen.add(name);
|
|
259
|
+
const shellAlias = value.startsWith("!") ? `${value.slice(1)} ${current.args.join(" ")}`.trim() : undefined;
|
|
260
|
+
const merged = [...aliasWords(value), ...current.args];
|
|
261
|
+
const next = skipGlobals(merged, 0, state);
|
|
262
|
+
const head = merged[next];
|
|
263
|
+
if (head === undefined)
|
|
264
|
+
break;
|
|
265
|
+
current = {
|
|
266
|
+
subcommand: head,
|
|
267
|
+
args: merged.slice(next + 1),
|
|
268
|
+
shellAlias: current.shellAlias ?? shellAlias,
|
|
269
|
+
cycle: false,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return current;
|
|
273
|
+
}
|
|
274
|
+
/** Directory and config environment set by leading assignments (also those of an `env` wrapper). */
|
|
275
|
+
function leadingGitEnv(words, gitIdx) {
|
|
276
|
+
const dirs = [];
|
|
277
|
+
let configEnv = false;
|
|
278
|
+
let literalPathspecs = false;
|
|
279
|
+
for (const word of words.slice(0, gitIdx)) {
|
|
280
|
+
const literal = /^GIT_LITERAL_PATHSPECS=(.*)$/.exec(word);
|
|
281
|
+
if (literal !== null)
|
|
282
|
+
literalPathspecs = literal[1] !== "" && literal[1] !== "0";
|
|
283
|
+
const match = /^(GIT_WORK_TREE|GIT_DIR|GIT_CONFIG[A-Z0-9_]*)=(.*)$/.exec(word);
|
|
284
|
+
if (match === null)
|
|
285
|
+
continue;
|
|
286
|
+
if (match[1]?.startsWith("GIT_CONFIG"))
|
|
287
|
+
configEnv = true;
|
|
288
|
+
else
|
|
289
|
+
dirs.push(match[2]);
|
|
290
|
+
}
|
|
291
|
+
return { dirs, configEnv, literalPathspecs };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Locate executable `git` in `words` and advance past its global options.
|
|
295
|
+
* Returns the subcommand (alias-expanded), its arguments, and the directories
|
|
296
|
+
* the global options point git at, or `undefined` when `git` isn't there or has
|
|
297
|
+
* no subcommand. Handles `sudo git`, `env VAR=x git`, a backslash-prefixed
|
|
298
|
+
* `git`, `git --no-pager checkout`, and an UNKNOWN `-x` / `--xxx` global
|
|
299
|
+
* (skipped as one word and flagged `uncertain`, so a new git option cannot
|
|
300
|
+
* hide the subcommand silently).
|
|
301
|
+
*/
|
|
302
|
+
export function locateGit(words) {
|
|
303
|
+
const gitIdx = gitExecutableIndex(words);
|
|
304
|
+
if (gitIdx === undefined)
|
|
305
|
+
return undefined;
|
|
306
|
+
const env = leadingGitEnv(words, gitIdx);
|
|
307
|
+
const state = { dirs: [...env.dirs], aliases: new Map(), configKeys: [], uncertain: false };
|
|
308
|
+
const i = skipGlobals(words, gitIdx + 1, state);
|
|
309
|
+
const unresolvedAlias = words.slice(gitIdx + 1, i).some((word, k, globals) => isConfigEnvAlias(word, globals[k + 1]));
|
|
310
|
+
const subcommand = words[i];
|
|
311
|
+
if (subcommand === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
const resolved = resolveAliases(subcommand, words.slice(i + 1), state);
|
|
314
|
+
return {
|
|
315
|
+
subcommand: resolved.subcommand,
|
|
316
|
+
args: resolved.args,
|
|
317
|
+
dirs: state.dirs,
|
|
318
|
+
unresolvedAlias: unresolvedAlias || resolved.cycle,
|
|
319
|
+
configKeys: state.configKeys,
|
|
320
|
+
uncertain: state.uncertain,
|
|
321
|
+
shellAlias: resolved.shellAlias,
|
|
322
|
+
configEnv: env.configEnv,
|
|
323
|
+
literalPathspecs: env.literalPathspecs || words.slice(gitIdx + 1, i).includes("--literal-pathspecs"),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** `--config-env=alias.x=VAR` or `--config-env alias.x=VAR` (git config keys are case-insensitive). */
|
|
327
|
+
function isConfigEnvAlias(word, next) {
|
|
328
|
+
if (word.startsWith("--config-env="))
|
|
329
|
+
return /^alias\./i.test(word.slice("--config-env=".length));
|
|
330
|
+
return word === "--config-env" && /^alias\./i.test(next ?? "");
|
|
331
|
+
}
|
|
332
|
+
/** Consume one global option at `i`, recording directory values and `-c alias.*` definitions; returns the next index. */
|
|
333
|
+
function consumeGlobal(words, i, state) {
|
|
334
|
+
const w = words[i];
|
|
335
|
+
if (w === "-c") {
|
|
336
|
+
const configKey = /^([^=]+)=/.exec(words[i + 1] ?? "")?.[1];
|
|
337
|
+
if (configKey !== undefined)
|
|
338
|
+
state.configKeys.push(configKey.toLowerCase());
|
|
339
|
+
const alias = /^alias\.([^=]+)=(.*)$/is.exec(words[i + 1] ?? "");
|
|
340
|
+
if (alias !== null)
|
|
341
|
+
state.aliases.set(alias[1].toLowerCase(), alias[2]);
|
|
342
|
+
return i + 2;
|
|
343
|
+
}
|
|
344
|
+
const eq = w.indexOf("=");
|
|
345
|
+
const key = eq === -1 ? w : w.slice(0, eq);
|
|
346
|
+
const separate = GIT_DIR_OPTIONS.has(key) || GIT_SEPARATE_VALUE_LONG.has(key);
|
|
347
|
+
if (!separate) {
|
|
348
|
+
if (!GIT_KNOWN_FLAG_GLOBALS.has(key))
|
|
349
|
+
state.uncertain = true;
|
|
350
|
+
return i + 1;
|
|
351
|
+
}
|
|
352
|
+
const value = eq === -1 ? words[i + 1] : w.slice(eq + 1);
|
|
353
|
+
if (key === "--config-env") {
|
|
354
|
+
const envKey = /^([^=]+)=/.exec(value ?? "")?.[1];
|
|
355
|
+
if (envKey !== undefined)
|
|
356
|
+
state.configKeys.push(envKey.toLowerCase());
|
|
357
|
+
}
|
|
358
|
+
if (GIT_DIR_OPTIONS.has(key) && value !== undefined)
|
|
359
|
+
state.dirs.push(value);
|
|
360
|
+
return eq === -1 ? i + 2 : i + 1;
|
|
361
|
+
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { gitExcludePathspecsOf, gitIgnorePatternsOf } from "./protected-paths.ts
|
|
|
15
15
|
export * from "./provider-adapt.ts";
|
|
16
16
|
export * from "./provider-advertise.ts";
|
|
17
17
|
export * from "./provider-grants.ts";
|
|
18
|
+
export * from "./provider-instructions.ts";
|
|
18
19
|
export * from "./provider-sanitize.ts";
|
|
19
20
|
export * from "./provider-types.ts";
|
|
20
21
|
export { readTool } from "./read.ts";
|
package/dist/tools/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { gitExcludePathspecsOf, gitIgnorePatternsOf } from "./protected-paths.js
|
|
|
12
12
|
export * from "./provider-adapt.js";
|
|
13
13
|
export * from "./provider-advertise.js";
|
|
14
14
|
export * from "./provider-grants.js";
|
|
15
|
+
export * from "./provider-instructions.js";
|
|
15
16
|
export * from "./provider-sanitize.js";
|
|
16
17
|
export * from "./provider-types.js";
|
|
17
18
|
export { readTool } from "./read.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Where a segment runs: every live working-directory frame, and the permitted root. */
|
|
2
|
+
export interface NaxGuardContext {
|
|
3
|
+
readonly frames: readonly string[];
|
|
4
|
+
readonly root: string;
|
|
5
|
+
/** Shared downward-scan cache; absent means every lookup scans afresh. */
|
|
6
|
+
readonly naxDirs?: NaxDirIndex;
|
|
7
|
+
}
|
|
8
|
+
/** Every `.nax` directory at or below `root`, within the depth bound. */
|
|
9
|
+
export declare function scanNaxDirs(root: string): readonly string[];
|
|
10
|
+
/** Lazily scanned, per-root cache of the `.nax` directories at or below a root. */
|
|
11
|
+
export interface NaxDirIndex {
|
|
12
|
+
below(root: string): readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A found `.nax` is cached for the index's lifetime (a deleted one may keep refusing, which is safe); an
|
|
16
|
+
* empty result only for {@link NEGATIVE_TTL_MS}, so one created mid-run is not invisible for ever.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createNaxDirIndex(now?: () => number): NaxDirIndex;
|
|
19
|
+
/** Every known `.nax` directory: on the way from a frame to the root, at the root, or nested below it. */
|
|
20
|
+
export declare function findNaxDirs(ctx: NaxGuardContext): readonly string[];
|
|
21
|
+
/** True when a `.nax/` exists at a frame, between a frame and the root, at the root, or nested below it. */
|
|
22
|
+
export declare function hasNaxDirNear(ctx: NaxGuardContext): boolean;
|