@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
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refusals that protect nax's run state (`.nax/`) from git commands an agent
|
|
3
|
+
* runs through Bash (#2490, #2493).
|
|
4
|
+
*
|
|
5
|
+
* Rules, evaluated PER SEGMENT of a compound command by the gated and raw
|
|
6
|
+
* Bash screens alike. Every one is a hard, non-escalatable denial; the ones
|
|
7
|
+
* that say "while a `.nax/` exists" do nothing in a repository without one.
|
|
8
|
+
*
|
|
9
|
+
* 1. `git checkout` / `git restore` of a `.nax` path (the nax#2007 gate that
|
|
10
|
+
* used to protect only a standalone command), and of a WHOLE-TREE pathspec
|
|
11
|
+
* (`.`, `:/`, `*`, the root directory, exclusion-only) unless a full `.nax`
|
|
12
|
+
* exclusion follows `--`; `git reset --hard|--keep|--merge`; forced
|
|
13
|
+
* `checkout -f` / `switch -f`; `git clean`; `checkout-index -a -f`;
|
|
14
|
+
* `read-tree -u --reset` (see nax-revert.ts). A "covering" exclusion is
|
|
15
|
+
* the top-anchored glob ending in `/.nax/` plus two stars, quoted as in
|
|
16
|
+
* COVERING_EXCLUSION (any depth, and the files inside: a glob without the
|
|
17
|
+
* trailing stars matches only the directory entry); the literal `:!.nax`
|
|
18
|
+
* covers only the root one, so it counts only while that is the sole
|
|
19
|
+
* `.nax`. A `:(...)` spec with an `attr:` element is never an exclusion.
|
|
20
|
+
* Long options are matched by prefix, as git accepts (`--k`, `--f`, `--a`).
|
|
21
|
+
* `git clean -n` / `--dry-run` is allowed.
|
|
22
|
+
* 2. `git update-index` with `--skip-worktree`, `--assume-unchanged` or their
|
|
23
|
+
* `--no-` forms (git accepts any unambiguous abbreviation, so the whole
|
|
24
|
+
* `--skip*` / `--assume*` / `--no-skip*` / `--no-assume*` families are
|
|
25
|
+
* refused) on a `.nax` path, or with no path operand at all (paths arrive
|
|
26
|
+
* on stdin or from `xargs`, which cannot be inspected).
|
|
27
|
+
* 3. `git stash` in any form other than the read-only/bookkeeping allowlist
|
|
28
|
+
* (`list`, `show`, `create`, `store`, `drop`, `clear`) when a `.nax/`
|
|
29
|
+
* directory exists at a live working directory, between it and the root,
|
|
30
|
+
* at the root, or nested below it (depth <= 3, see nax-dirs.ts). Policy
|
|
31
|
+
* time has no cheap way to know whether a `.nax/` path is DIRTY, so the
|
|
32
|
+
* rule is conservative. A stash is allowed when its pathspec excludes the
|
|
33
|
+
* whole of `.nax` (`-- . ':!.nax'`), or when it is a `push` whose every
|
|
34
|
+
* pathspec lies outside `.nax` (directories are listed with git). A `pop`
|
|
35
|
+
* or `apply` is allowed only when git lists no `.nax` path in the stash, and
|
|
36
|
+
* an `--index` restore must also list none in the stash's index snapshot
|
|
37
|
+
* (a `.nax` change staged and then reverted in the working tree lives there
|
|
38
|
+
* alone, and `--index` writes it back into the index).
|
|
39
|
+
* 4. Hiding the verb (nax-evasion.ts): `git config alias.X <value>` whose
|
|
40
|
+
* value names stash / update-index / checkout / restore / reset (so even
|
|
41
|
+
* `git config alias.co checkout` is refused while a `.nax/` exists);
|
|
42
|
+
* `GIT_CONFIG_*` set in leading assignments or `env` args next to such a
|
|
43
|
+
* verb; a command head or git subcommand built by expansion
|
|
44
|
+
* (`git $X reset`) next to such a verb and a `git` token; interpreter code
|
|
45
|
+
* (`python|node|bun|deno|php|lua|perl|ruby|osascript|pwsh -c|-e|-r|eval`)
|
|
46
|
+
* and a quoted string with whitespace in a segment whose head is a shell,
|
|
47
|
+
* an interpreter or an exec-style wrapper (`ssh`, `env`, `xargs`, `sudo`,
|
|
48
|
+
* `nohup`, `time`, `watch`, ...) that mentions such a verb (data commands
|
|
49
|
+
* such as `grep`, `gh`, `sed`, `printf`, `echo` are not scanned); git config keys git EXECUTES
|
|
50
|
+
* (`core.fsmonitor`, `core.hooksPath`, `core.pager`, `diff.external`,
|
|
51
|
+
* `include.path`, `*.textconv`, `filter.*.clean`, ...) set with `-c`,
|
|
52
|
+
* `--config-env` or `git config`. Aliases are never expanded over a git
|
|
53
|
+
* builtin, chains have no depth limit, and a cycle is refused.
|
|
54
|
+
*
|
|
55
|
+
* Operands are resolved against every live working directory and judged by
|
|
56
|
+
* the `.nax` path SEGMENT of the root-relative result, never a substring, so
|
|
57
|
+
* `cd .nax && git update-index --skip-worktree prd.json` and `foo/../.nax`
|
|
58
|
+
* are caught while `src/nax-helpers.ts` is not. `-C`, `--work-tree`,
|
|
59
|
+
* `--git-dir`, `GIT_WORK_TREE` and `GIT_DIR` all move the frames.
|
|
60
|
+
*
|
|
61
|
+
* `locateGit` reads the invocation: `\git`, wrappers, global options (an
|
|
62
|
+
* unknown one is "uncertain" and sends the segment to the BACKSTOP), and
|
|
63
|
+
* aliases (expanded, then the global-option skip is run again; a `!` alias is
|
|
64
|
+
* guarded as shell text).
|
|
65
|
+
*
|
|
66
|
+
* BACKSTOP. The structured rules above only fire on a segment whose git
|
|
67
|
+
* invocation `locateGit` can read. `naxStateRefusalInText` therefore also runs
|
|
68
|
+
* a deliberately COARSE check over the whole command string (and every
|
|
69
|
+
* `bash -c` / `eval` string, recursively) for segments the structured rules
|
|
70
|
+
* could not classify (`eval git stash`, `time git stash`, `nohup`, `stdbuf`,
|
|
71
|
+
* an unknown wrapper, an unknown global option): when such a segment names
|
|
72
|
+
* `git` and one of `stash`, `reset --hard`, `update-index` with a hiding flag,
|
|
73
|
+
* or `checkout`/`restore` of a `.nax` path or the whole tree, it is refused.
|
|
74
|
+
* It exists to catch wrappers and aliases nobody can enumerate, so it trades
|
|
75
|
+
* precision for coverage; a segment the structured rules DID classify keeps
|
|
76
|
+
* their (precise) verdict, which is why
|
|
77
|
+
* `git update-index --assume-unchanged src/a.ts` stays allowed.
|
|
78
|
+
*
|
|
79
|
+
* KNOWN, NOT FIXED (by design, #2497): this guard is a first line that redirects
|
|
80
|
+
* common accidents with a helpful message. It is not the boundary and is not
|
|
81
|
+
* extended toward completeness. The guarantee is detect-and-repair: nax's
|
|
82
|
+
* `src/runstate/` restores any nax-owned run-state file, and clears its index
|
|
83
|
+
* flags, after every agent turn and before every auto-commit, whatever command
|
|
84
|
+
* changed it (bypass matrix: test/integration/runstate/bypass-matrix.test.ts).
|
|
85
|
+
* Gaps:
|
|
86
|
+
* - Stash without explicit safe pathspecs or an inspectable restore ref is
|
|
87
|
+
* refused while a `.nax/` exists, even when that directory is clean.
|
|
88
|
+
* - Prose that reads as a command (`echo git stash`, `python -c 'print("git
|
|
89
|
+
* stash")'`, `echo "git stash"`) is refused when a `.nax/` exists: the guard
|
|
90
|
+
* works on words. A scratch fixture `.nax/` within three levels also makes
|
|
91
|
+
* stash refused.
|
|
92
|
+
* - Git subcommands that execute a string (`rebase -x`, `submodule foreach`,
|
|
93
|
+
* `bisect run`, `difftool -x`, `fetch --upload-pack=`), executing
|
|
94
|
+
* environment variables (`GIT_EXTERNAL_DIFF`, `GIT_PAGER`, `GIT_EDITOR`,
|
|
95
|
+
* `GIT_SSH_COMMAND`) and executing config keys not in the list above
|
|
96
|
+
* (`remote.*.uploadpack`, `uploadpack.packObjectsHook`,
|
|
97
|
+
* `core.alternateRefsCommand`, `merge.*.driver`, `core.gitProxy`,
|
|
98
|
+
* `protocol.ext.allow` with `ext::` URLs) are not read.
|
|
99
|
+
* - `deno eval` is an interpreter only in gated mode's quoted-string scan;
|
|
100
|
+
* code built by string arithmetic inside any interpreter is not read.
|
|
101
|
+
* - A verb built by a bare expansion head with no `git` token (`$G stash`) is
|
|
102
|
+
* not seen, nor is one assembled by string arithmetic inside an interpreter.
|
|
103
|
+
* - Under `raw` Bash approval an operand BUILT by expansion (`.na?/x`,
|
|
104
|
+
* `$D/prd.json`) is not resolved; raw mode is advisory. `git $X` alone,
|
|
105
|
+
* naming no guarded verb, is not refused.
|
|
106
|
+
* - A heredoc body fed to an interpreter (`python3 <<EOF`) is data to this
|
|
107
|
+
* reader, as is `--pathspec-from-file`, `GIT_CONFIG_GLOBAL` pointing at a
|
|
108
|
+
* file, and `export GIT_CONFIG_*` set on an earlier line.
|
|
109
|
+
* - The nested `.nax` scan is bounded (depth 3) and an empty result is
|
|
110
|
+
* trusted for one second; a `.nax` deeper or created within that second is
|
|
111
|
+
* not seen.
|
|
112
|
+
* - From inside `.nax` (`cd .nax`), every unmarked `git checkout` operand is
|
|
113
|
+
* read as a path.
|
|
114
|
+
*/
|
|
115
|
+
import { resolve } from "node:path";
|
|
116
|
+
import { NAX_OWNED_RUN_STATE_EXPLANATION } from "./denial-redirect.js";
|
|
117
|
+
import { commandHeadIndex, gitExecutableIndex, locateGit } from "./git-flags/locate.js";
|
|
118
|
+
import { hasNaxDirNear } from "./nax-dirs.js";
|
|
119
|
+
import { buildsVerbByExpansion, CONFIG_ENV_REFUSAL, configAliasRefusal, EXEC_CONFIG_REFUSAL, EXPANSION_REFUSAL, flattenCode, interpreterCode, isExecConfigKey, mentionsGuardedVerb, } from "./nax-evasion.js";
|
|
120
|
+
import { COVERING_EXCLUSION, discardRefusal, operandTouchesNax, resetRefusal, revertRefusal } from "./nax-revert.js";
|
|
121
|
+
import { stashIsExempt } from "./nax-stash-restore.js";
|
|
122
|
+
export { createNaxDirIndex } from "./nax-dirs.js";
|
|
123
|
+
/** Stash subcommands that neither capture nor replay the working tree. */
|
|
124
|
+
const STASH_SAFE_SUBCOMMANDS = new Set(["list", "show", "create", "store", "drop", "clear"]);
|
|
125
|
+
/** Abbreviations git really accepts: `--sk`, `--as`, `--no-sk`, `--no-as` and everything longer. */
|
|
126
|
+
const INDEX_HIDING_FLAG = /^--(?:no-)?(?:sk|as)/;
|
|
127
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "eval"]);
|
|
128
|
+
const UPDATE_INDEX_REFUSAL = `${NAX_OWNED_RUN_STATE_EXPLANATION} ` +
|
|
129
|
+
"Marking it with `git update-index --skip-worktree` or `--assume-unchanged` hides it from git and from nax's own commits, " +
|
|
130
|
+
"and the flag outlives the run. To see whether something pre-existed, use `git diff HEAD -- <file>` or `git show HEAD:<file>`.";
|
|
131
|
+
const LITERAL_PATHSPECS_REFUSAL = "`--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` turn every `:(...)` and `:!` exclusion into a literal path, " +
|
|
132
|
+
"so nothing keeps nax's run state under `.nax/` out of this command. Drop the literal-pathspec setting.";
|
|
133
|
+
const STASH_REFUSAL = "`git stash` is refused while the repository holds nax's run state under `.nax/`: the stash would capture " +
|
|
134
|
+
"nax's uncommitted state and a later pop would replay stale copies over what nax has written since. " +
|
|
135
|
+
"To check whether something pre-existed, use `git diff HEAD -- <file>` or `git show HEAD:<file>`. " +
|
|
136
|
+
`A \`git stash push -- . ${COVERING_EXCLUSION}\` is allowed. ` +
|
|
137
|
+
"`push` of files or directories outside `.nax` is allowed, and so is `pop` or `apply` of a stash that holds no `.nax` path (quote the ref, e.g. `'stash@{1}'`).";
|
|
138
|
+
const ALIAS_REFUSAL = "A git alias defined through `--config-env`, or one that loops back on itself, cannot be inspected while the repository holds nax's run state under `.nax/`. " +
|
|
139
|
+
"Run the git verb directly, or define the alias with `-c alias.NAME=VALUE`.";
|
|
140
|
+
/**
|
|
141
|
+
* The frames plus where `-C` / `--work-tree` / `--git-dir` / `GIT_WORK_TREE` /
|
|
142
|
+
* `GIT_DIR` lead from each: every directory alone, and the whole chain in order.
|
|
143
|
+
*/
|
|
144
|
+
function withGitDirs(ctx, dirs) {
|
|
145
|
+
if (dirs.length === 0)
|
|
146
|
+
return ctx;
|
|
147
|
+
const chained = ctx.frames.map((frame) => dirs.reduce((dir, next) => resolve(dir, next), frame));
|
|
148
|
+
const single = ctx.frames.flatMap((frame) => dirs.map((dir) => resolve(frame, dir)));
|
|
149
|
+
return { ...ctx, frames: [...ctx.frames, ...single, ...chained] };
|
|
150
|
+
}
|
|
151
|
+
function updateIndexRefusal(args, ctx, viaXargs) {
|
|
152
|
+
if (!args.some((word) => INDEX_HIDING_FLAG.test(word)))
|
|
153
|
+
return undefined;
|
|
154
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
155
|
+
if (operands.some((word) => operandTouchesNax(word, ctx)))
|
|
156
|
+
return UPDATE_INDEX_REFUSAL;
|
|
157
|
+
// Paths that arrive on stdin, from xargs or from an expansion cannot be inspected, with or without other operands.
|
|
158
|
+
const uninspectable = viaXargs ||
|
|
159
|
+
operands.length === 0 ||
|
|
160
|
+
operands.some((word) => /[$`]/.test(word)) ||
|
|
161
|
+
args.includes("--stdin") ||
|
|
162
|
+
args.includes("--index-info");
|
|
163
|
+
return uninspectable && hasNaxDirNear(ctx) ? UPDATE_INDEX_REFUSAL : undefined;
|
|
164
|
+
}
|
|
165
|
+
function stashRefusal(args, ctx) {
|
|
166
|
+
const first = args[0];
|
|
167
|
+
if (first !== undefined && STASH_SAFE_SUBCOMMANDS.has(first))
|
|
168
|
+
return undefined;
|
|
169
|
+
if (!hasNaxDirNear(ctx))
|
|
170
|
+
return undefined;
|
|
171
|
+
return stashIsExempt(args, ctx) ? undefined : STASH_REFUSAL;
|
|
172
|
+
}
|
|
173
|
+
/** The string a `bash -c <string>` / `eval <string>` style segment runs, or undefined. */
|
|
174
|
+
function shellCommandString(words) {
|
|
175
|
+
const head = commandHeadIndex(words);
|
|
176
|
+
const shell = words[head]?.split("/").pop();
|
|
177
|
+
if (shell === undefined || !SHELLS.has(shell))
|
|
178
|
+
return undefined;
|
|
179
|
+
const flag = shell === "eval" ? head : words.findIndex((word, i) => i > head && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
|
|
180
|
+
if (flag === -1 || flag + 1 >= words.length)
|
|
181
|
+
return undefined;
|
|
182
|
+
return words
|
|
183
|
+
.slice(flag + 1)
|
|
184
|
+
.join(" ")
|
|
185
|
+
.replace(/^\s*['"]|['"]\s*$/g, "");
|
|
186
|
+
}
|
|
187
|
+
/** The structured rules for one located git invocation. */
|
|
188
|
+
const DISCARD_VERBS = new Set(["switch", "clean", "checkout-index", "read-tree"]);
|
|
189
|
+
/** Verbs whose exclusion pathspecs `--literal-pathspecs` turns into literal paths. */
|
|
190
|
+
const EXCLUDING_VERBS = new Set(["checkout", "restore", "stash", "clean"]);
|
|
191
|
+
function verbRefusal(git, segment, where) {
|
|
192
|
+
if (git.literalPathspecs && EXCLUDING_VERBS.has(git.subcommand) && git.args.some((a) => a.startsWith(":"))) {
|
|
193
|
+
if (hasNaxDirNear(where))
|
|
194
|
+
return LITERAL_PATHSPECS_REFUSAL;
|
|
195
|
+
}
|
|
196
|
+
if (git.subcommand === "checkout" || git.subcommand === "restore") {
|
|
197
|
+
return revertRefusal(git.subcommand, git.args, where);
|
|
198
|
+
}
|
|
199
|
+
if (git.subcommand === "reset")
|
|
200
|
+
return resetRefusal(git.args, where);
|
|
201
|
+
if (DISCARD_VERBS.has(git.subcommand))
|
|
202
|
+
return discardRefusal(git.subcommand, git.args, where);
|
|
203
|
+
if (git.subcommand === "stash")
|
|
204
|
+
return stashRefusal(git.args, where);
|
|
205
|
+
if (git.subcommand === "config")
|
|
206
|
+
return hasNaxDirNear(where) ? configAliasRefusal(git.args) : undefined;
|
|
207
|
+
if (git.subcommand !== "update-index")
|
|
208
|
+
return undefined;
|
|
209
|
+
const viaXargs = segment.slice(0, gitExecutableIndex(segment)).includes("xargs");
|
|
210
|
+
return updateIndexRefusal(git.args, where, viaXargs);
|
|
211
|
+
}
|
|
212
|
+
/** Refusals that depend on HOW git was invoked, not on its verb: environment config, `--config-env` and `!` aliases. */
|
|
213
|
+
function invocationRefusal(git, segment, where) {
|
|
214
|
+
if (git.unresolvedAlias && hasNaxDirNear(where))
|
|
215
|
+
return ALIAS_REFUSAL;
|
|
216
|
+
if (git.configKeys.some(isExecConfigKey) && hasNaxDirNear(where))
|
|
217
|
+
return EXEC_CONFIG_REFUSAL;
|
|
218
|
+
if (git.configEnv && mentionsGuardedVerb(segment.join(" ")) && hasNaxDirNear(where))
|
|
219
|
+
return CONFIG_ENV_REFUSAL;
|
|
220
|
+
return git.shellAlias === undefined ? undefined : naxStateRefusalInText(git.shellAlias, where);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The refusal for one command segment (its words, `git` possibly behind
|
|
224
|
+
* wrappers and global options), or undefined. A segment whose git invocation
|
|
225
|
+
* cannot be located is NOT judged here; see the BACKSTOP in the header. One
|
|
226
|
+
* that CAN be located but met a global option this reader does not know is
|
|
227
|
+
* judged by the structured rules and then, with a `.nax/` present, by the BACKSTOP.
|
|
228
|
+
*/
|
|
229
|
+
export function naxStateRefusal(segment, ctx) {
|
|
230
|
+
const inner = shellCommandString(segment);
|
|
231
|
+
if (inner !== undefined)
|
|
232
|
+
return naxStateRefusalInText(inner, ctx);
|
|
233
|
+
const code = interpreterCode(segment);
|
|
234
|
+
if (code !== undefined)
|
|
235
|
+
return naxStateRefusalInText(flattenCode(code), ctx);
|
|
236
|
+
const git = locateGit(segment);
|
|
237
|
+
if (git === undefined)
|
|
238
|
+
return undefined;
|
|
239
|
+
const where = withGitDirs(ctx, git.dirs);
|
|
240
|
+
const refusal = invocationRefusal(git, segment, where) ?? verbRefusal(git, segment, where);
|
|
241
|
+
if (refusal !== undefined || !git.uncertain || !hasNaxDirNear(where))
|
|
242
|
+
return refusal;
|
|
243
|
+
return backstopRefusal(segment, where);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* What a backslash at `i` contributes: outside quotes `\X` is `X` (so `g\it` is `git`), inside double quotes
|
|
247
|
+
* only before `"` `\` `$` or a backtick, and `\<newline>` is a line continuation (nothing).
|
|
248
|
+
*/
|
|
249
|
+
function readEscape(chars, i, quote) {
|
|
250
|
+
const next = chars[i + 1];
|
|
251
|
+
if (next === "\n")
|
|
252
|
+
return { text: "", consumed: 1 };
|
|
253
|
+
if (next !== undefined && (quote === undefined || '"\\$`'.includes(next)))
|
|
254
|
+
return { text: next, consumed: 1 };
|
|
255
|
+
return { text: "\\", consumed: 0 };
|
|
256
|
+
}
|
|
257
|
+
/** Split command text into segments of tokens: quotes are removed and kept atomic; `; & | ( ) { }` and newlines end a segment. */
|
|
258
|
+
function shellSegments(command) {
|
|
259
|
+
const segments = [[]];
|
|
260
|
+
let token = "";
|
|
261
|
+
let quote;
|
|
262
|
+
let inToken = false;
|
|
263
|
+
const endToken = () => {
|
|
264
|
+
if (inToken)
|
|
265
|
+
segments[segments.length - 1].push(token);
|
|
266
|
+
token = "";
|
|
267
|
+
inToken = false;
|
|
268
|
+
};
|
|
269
|
+
const chars = [...command];
|
|
270
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
271
|
+
const ch = chars[i];
|
|
272
|
+
if (ch === "\\" && quote !== "'") {
|
|
273
|
+
const esc = readEscape(chars, i, quote);
|
|
274
|
+
token += esc.text;
|
|
275
|
+
if (esc.text !== "")
|
|
276
|
+
inToken = true;
|
|
277
|
+
i += esc.consumed;
|
|
278
|
+
}
|
|
279
|
+
else if (quote !== undefined) {
|
|
280
|
+
if (ch === quote)
|
|
281
|
+
quote = undefined;
|
|
282
|
+
else
|
|
283
|
+
token += ch;
|
|
284
|
+
}
|
|
285
|
+
else if (ch === "'" || ch === '"') {
|
|
286
|
+
quote = ch;
|
|
287
|
+
inToken = true;
|
|
288
|
+
}
|
|
289
|
+
else if (/\s/.test(ch) && ch !== "\n") {
|
|
290
|
+
endToken();
|
|
291
|
+
}
|
|
292
|
+
else if (";&|(){}\n".includes(ch)) {
|
|
293
|
+
endToken();
|
|
294
|
+
segments.push([]);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
token += ch;
|
|
298
|
+
inToken = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
endToken();
|
|
302
|
+
return segments.map((words) => words.filter((word) => !/^\d*[<>]/.test(word))).filter((words) => words.length > 0);
|
|
303
|
+
}
|
|
304
|
+
const HEREDOC_MARKER = /(?<!<)<<(?!<)-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][\w-]*))/g;
|
|
305
|
+
/** The heredoc terminators a command line opens, in order. */
|
|
306
|
+
function heredocTerminators(line) {
|
|
307
|
+
return [...line.matchAll(HEREDOC_MARKER)].map((match) => (match[1] ?? match[2] ?? match[3]));
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Drop heredoc bodies (the lines after `<<WORD`, `<<'WORD'`, `<<"WORD"` or
|
|
311
|
+
* `<<-WORD` up to the terminator line): they are data, not commands, so a
|
|
312
|
+
* commit message that says "git stash" is not a stash.
|
|
313
|
+
*/
|
|
314
|
+
function withoutHeredocBodies(command) {
|
|
315
|
+
const kept = [];
|
|
316
|
+
let pending = [];
|
|
317
|
+
for (const line of command.split("\n")) {
|
|
318
|
+
if (pending.length > 0) {
|
|
319
|
+
if (line.trim() === pending[0])
|
|
320
|
+
pending = pending.slice(1);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
kept.push(line);
|
|
324
|
+
pending = heredocTerminators(line);
|
|
325
|
+
}
|
|
326
|
+
return kept.join("\n");
|
|
327
|
+
}
|
|
328
|
+
/** `cd X` / `pushd X` widens the frames to also include X resolved from each (the union stays conservative). */
|
|
329
|
+
function framesAfterCd(words, frames) {
|
|
330
|
+
const head = words[commandHeadIndex(words)];
|
|
331
|
+
if (head !== "cd" && head !== "pushd")
|
|
332
|
+
return undefined;
|
|
333
|
+
const target = words.slice(commandHeadIndex(words) + 1).find((word) => !word.startsWith("-"));
|
|
334
|
+
if (target === undefined || /^[$~]/.test(target))
|
|
335
|
+
return frames;
|
|
336
|
+
return [...frames, ...frames.map((frame) => resolve(frame, target))];
|
|
337
|
+
}
|
|
338
|
+
function aliasTarget(word) {
|
|
339
|
+
return /^alias\.[^=]+=(.*)$/is.exec(word)?.[1];
|
|
340
|
+
}
|
|
341
|
+
/** True when `word` is a git verb token, or an alias definition that expands to one. */
|
|
342
|
+
function namesVerb(word, verb) {
|
|
343
|
+
return word === verb || (aliasTarget(word)?.includes(verb) ?? false);
|
|
344
|
+
}
|
|
345
|
+
/** `git`, `\git` or a path ending in `/git`. */
|
|
346
|
+
function isGitToken(word) {
|
|
347
|
+
return word === "git" || word === "\\git" || word.endsWith("/git");
|
|
348
|
+
}
|
|
349
|
+
/** The coarse revert check: whole-tree and `.nax` operands after the first word naming `verb`. */
|
|
350
|
+
function backstopRevert(words, verb, ctx) {
|
|
351
|
+
const at = words.findIndex((word) => namesVerb(word, verb));
|
|
352
|
+
if (at === -1)
|
|
353
|
+
return undefined;
|
|
354
|
+
const refusal = revertRefusal(verb, words.slice(at + 1), ctx);
|
|
355
|
+
if (refusal !== undefined)
|
|
356
|
+
return refusal;
|
|
357
|
+
return words.some((word) => operandTouchesNax(word, ctx)) ? NAX_OWNED_RUN_STATE_EXPLANATION : undefined;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The coarse check for a segment `locateGit` could not read. Requires a `git`
|
|
361
|
+
* token in the same segment, so `echo stash` and a commit message that merely
|
|
362
|
+
* contains the word never match.
|
|
363
|
+
*/
|
|
364
|
+
function backstopRefusal(words, ctx) {
|
|
365
|
+
if (!words.some(isGitToken))
|
|
366
|
+
return undefined;
|
|
367
|
+
const mentions = (verb) => words.some((word) => namesVerb(word, verb));
|
|
368
|
+
if (mentions("update-index") && words.some((word) => INDEX_HIDING_FLAG.test(word)))
|
|
369
|
+
return UPDATE_INDEX_REFUSAL;
|
|
370
|
+
if (mentions("stash") && !stashIsSafeWord(words))
|
|
371
|
+
return STASH_REFUSAL;
|
|
372
|
+
if (mentions("reset")) {
|
|
373
|
+
const reset = resetRefusal(words, ctx);
|
|
374
|
+
if (reset !== undefined)
|
|
375
|
+
return reset;
|
|
376
|
+
}
|
|
377
|
+
return backstopRevert(words, "checkout", ctx) ?? backstopRevert(words, "restore", ctx);
|
|
378
|
+
}
|
|
379
|
+
function stashIsSafeWord(words) {
|
|
380
|
+
const next = words[words.indexOf("stash") + 1];
|
|
381
|
+
return words.includes("stash") && next !== undefined && STASH_SAFE_SUBCOMMANDS.has(next);
|
|
382
|
+
}
|
|
383
|
+
const baseName = (word) => (word ?? "").split("/").pop();
|
|
384
|
+
/** Commands whose string arguments are run or interpreted: wrappers, and interpreters (their program text). */
|
|
385
|
+
const EXEC_STYLE_HEADS = new Set([
|
|
386
|
+
"ssh",
|
|
387
|
+
"env",
|
|
388
|
+
"xargs",
|
|
389
|
+
"nohup",
|
|
390
|
+
"time",
|
|
391
|
+
"stdbuf",
|
|
392
|
+
"sudo",
|
|
393
|
+
"doas",
|
|
394
|
+
"watch",
|
|
395
|
+
"timeout",
|
|
396
|
+
"nice",
|
|
397
|
+
"command",
|
|
398
|
+
"exec",
|
|
399
|
+
"awk",
|
|
400
|
+
"gawk",
|
|
401
|
+
"mawk",
|
|
402
|
+
"tclsh",
|
|
403
|
+
"expect",
|
|
404
|
+
"osascript",
|
|
405
|
+
"pwsh",
|
|
406
|
+
"powershell",
|
|
407
|
+
"bun",
|
|
408
|
+
"deno",
|
|
409
|
+
"php",
|
|
410
|
+
"perl",
|
|
411
|
+
"ruby",
|
|
412
|
+
"node",
|
|
413
|
+
"nodejs",
|
|
414
|
+
]);
|
|
415
|
+
const isInterpreterName = (name) => /^(?:python[\d.]*|lua[\d.]*)$/.test(name);
|
|
416
|
+
/** The first word that is not a `VAR=x` assignment. */
|
|
417
|
+
const leadWord = (words) => words.find((w) => !/^[A-Za-z_]\w*=/.test(w));
|
|
418
|
+
/**
|
|
419
|
+
* Is a quoted string in this segment possibly a command? Only when the head is a shell, an interpreter or an
|
|
420
|
+
* exec-style wrapper (`ssh host "git stash"`); data commands (`grep "git stash"`, `gh ... --body "..."`, `sed`,
|
|
421
|
+
* `printf`, `echo`) never run their arguments, so they are not scanned.
|
|
422
|
+
*/
|
|
423
|
+
/** A shell name or a `-c` cluster anywhere in the segment (`if sh -c "..."`, `find -exec sh -c`, `su -c "..."`). */
|
|
424
|
+
const runsNestedShell = (words) => words.some((word) => SHELLS.has(baseName(word)) || /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
|
|
425
|
+
function mayRunItsStrings(words) {
|
|
426
|
+
const head = baseName(words[commandHeadIndex(words)]);
|
|
427
|
+
const lead = baseName(leadWord(words));
|
|
428
|
+
return [head, lead].some((n) => SHELLS.has(n) || EXEC_STYLE_HEADS.has(n) || isInterpreterName(n));
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Recurse into every multi-word token of a segment that runs a nested shell, or that is not a readable git
|
|
432
|
+
* call but mentions a guarded verb (`ssh host "git stash"`, `awk 'BEGIN{system("git stash")}'`): whatever the
|
|
433
|
+
* head, a quoted string with whitespace may be a command. A certain git call keeps its structured verdict, so
|
|
434
|
+
* `git commit -m "fix: git stash handling"` stays allowed.
|
|
435
|
+
*/
|
|
436
|
+
function nestedRefusal(words, ctx) {
|
|
437
|
+
const git = locateGit(words);
|
|
438
|
+
const readableGit = git !== undefined && !git.uncertain;
|
|
439
|
+
if (!runsNestedShell(words) && !mayRunItsStrings(words))
|
|
440
|
+
return undefined;
|
|
441
|
+
const shellHead = SHELLS.has(baseName(words[commandHeadIndex(words)]));
|
|
442
|
+
if (!shellHead && (readableGit || !mentionsGuardedVerb(words.join(" "))))
|
|
443
|
+
return undefined;
|
|
444
|
+
for (const word of words) {
|
|
445
|
+
if (!/\s/.test(word))
|
|
446
|
+
continue;
|
|
447
|
+
const refusal = naxStateRefusalInText(word, ctx) ?? naxStateRefusalInText(flattenCode(word), ctx);
|
|
448
|
+
if (refusal !== undefined)
|
|
449
|
+
return refusal;
|
|
450
|
+
}
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* `naxStateRefusal` over command TEXT: the raw command string in every Bash
|
|
455
|
+
* mode, and the string behind `bash -c` / `eval`. Tracks `cd` itself, applies
|
|
456
|
+
* the structured rules to each segment, and the coarse BACKSTOP (see the
|
|
457
|
+
* header) to segments whose git invocation it could not read.
|
|
458
|
+
*/
|
|
459
|
+
export function naxStateRefusalInText(command, ctx) {
|
|
460
|
+
let frames = ctx.frames;
|
|
461
|
+
const code = withoutHeredocBodies(command);
|
|
462
|
+
if (buildsVerbByExpansion(code) && hasNaxDirNear(ctx))
|
|
463
|
+
return EXPANSION_REFUSAL;
|
|
464
|
+
for (const words of shellSegments(code)) {
|
|
465
|
+
const moved = framesAfterCd(words, frames);
|
|
466
|
+
if (moved !== undefined) {
|
|
467
|
+
frames = moved;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const here = { ...ctx, frames };
|
|
471
|
+
const refusal = naxStateRefusal(words, here) ??
|
|
472
|
+
nestedRefusal(words, here) ??
|
|
473
|
+
(locateGit(words) === undefined && hasNaxDirNear(here) ? backstopRefusal(words, here) : undefined);
|
|
474
|
+
if (refusal !== undefined)
|
|
475
|
+
return refusal;
|
|
476
|
+
}
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NaxDirIndex } from "./nax-dirs.ts";
|
|
1
2
|
import type { OwnedPathsPolicy } from "./owned-paths.ts";
|
|
2
3
|
import type { BashCheck } from "./policy-bash.ts";
|
|
3
4
|
export interface RawScreenArgs {
|
|
@@ -9,6 +10,8 @@ export interface RawScreenArgs {
|
|
|
9
10
|
readonly resolvePath: (candidate: string, cwd: string) => string | null;
|
|
10
11
|
/** The permitted root, used to relativise a resolved path. */
|
|
11
12
|
readonly root: string;
|
|
13
|
+
/** Per-policy cache of the `.nax` directories below the root (#2493). */
|
|
14
|
+
readonly naxDirs?: NaxDirIndex;
|
|
12
15
|
/**
|
|
13
16
|
* US-002: true when the command runs inside the OS sandbox (an available
|
|
14
17
|
* launcher). A token that only NAMES a feature PRD is then no longer refused
|
|
@@ -53,6 +53,7 @@ import { relative, resolve, sep } from "node:path";
|
|
|
53
53
|
import { realOrRaw } from "#src/internal/realpath";
|
|
54
54
|
import { lexBashCommand } from "#src/permissions/index";
|
|
55
55
|
import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
|
|
56
|
+
import { naxStateRefusalInText } from "./nax-state-guard.js";
|
|
56
57
|
/**
|
|
57
58
|
* US-002: the start paths that mean "search the whole filesystem". An exact
|
|
58
59
|
* token comparison, never a prefix one: `~/proj` and `~user` are scoped and
|
|
@@ -136,6 +137,34 @@ function ownedCandidates(args, candidate, cwd) {
|
|
|
136
137
|
};
|
|
137
138
|
});
|
|
138
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* US-002 COST GUARD: a `find` rooted at the whole filesystem walks every mount
|
|
142
|
+
* and runs into the 300s Bash timeout. Raw has no containment, so nothing else
|
|
143
|
+
* stops it. Not a boundary -- a cost guard.
|
|
144
|
+
*/
|
|
145
|
+
function segmentGuard(args, segment) {
|
|
146
|
+
const findStart = wholeFilesystemFind(segment.tokens);
|
|
147
|
+
if (findStart !== undefined) {
|
|
148
|
+
return deny(`\`find ${findStart}\` searches the whole filesystem and runs into the 300s Bash timeout. ` +
|
|
149
|
+
`Search within the repository root instead: ${args.root}`);
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* #2490: nax's run state is screened even for a command the lexer cannot read
|
|
155
|
+
* (`... 2>&1; git stash pop`), using the guard's own text reader. Then the
|
|
156
|
+
* inversion: unreadable means unscreened, and unscreened means allowed.
|
|
157
|
+
*/
|
|
158
|
+
function earlyVerdict(args, command, lexKind) {
|
|
159
|
+
const stateRefusal = naxStateRefusalInText(command, {
|
|
160
|
+
frames: [args.initialPath],
|
|
161
|
+
root: args.root,
|
|
162
|
+
...(args.naxDirs !== undefined ? { naxDirs: args.naxDirs } : {}),
|
|
163
|
+
});
|
|
164
|
+
if (stateRefusal !== undefined)
|
|
165
|
+
return deny(stateRefusal);
|
|
166
|
+
return lexKind === "refused" ? { kind: "allow" } : undefined;
|
|
167
|
+
}
|
|
139
168
|
export function screenRawBashCommand(args) {
|
|
140
169
|
const { command, tool } = args;
|
|
141
170
|
if (typeof command !== "string")
|
|
@@ -143,20 +172,19 @@ export function screenRawBashCommand(args) {
|
|
|
143
172
|
if (command.trim() === "")
|
|
144
173
|
return deny(`"command" must not be empty`);
|
|
145
174
|
const lexed = lexBashCommand(command);
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
return
|
|
175
|
+
const early = earlyVerdict(args, command, lexed.kind);
|
|
176
|
+
if (early !== undefined)
|
|
177
|
+
return early;
|
|
178
|
+
// Reaching here means the lexer succeeded: `earlyVerdict` allows a refused command.
|
|
179
|
+
const { segments = [] } = lexed;
|
|
149
180
|
let cwd = [args.initialPath];
|
|
150
181
|
const sandboxWrapped = args.sandboxWrapped === true;
|
|
151
|
-
for (const segment of
|
|
152
|
-
// US-002 COST GUARD (see the file header)
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return deny(`\`find ${findStart}\` searches the whole filesystem and runs into the 300s Bash timeout. ` +
|
|
158
|
-
`Search within the repository root instead: ${args.root}`);
|
|
159
|
-
}
|
|
182
|
+
for (const segment of segments) {
|
|
183
|
+
// US-002 COST GUARD (see the file header) and the #2490 nax-run-state
|
|
184
|
+
// rules (revert, index-hiding, stash), applied to every segment.
|
|
185
|
+
const segmentDeny = segmentGuard(args, segment);
|
|
186
|
+
if (segmentDeny !== undefined)
|
|
187
|
+
return segmentDeny;
|
|
160
188
|
// US-002: sandbox-wrapped, a token that only NAMES a PRD is allowed -- the
|
|
161
189
|
// policy now applies that exemption (`bashRefusal` receives
|
|
162
190
|
// `sandboxWrapped` and skips the PRD read itself). A redirect WRITES, so
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* this module, so importing `resolveWithin` back would be a cycle. The single
|
|
26
26
|
* owner of the root boundary and the `.git/` refusal therefore remains `policy.ts`.
|
|
27
27
|
*/
|
|
28
|
+
import type { NaxDirIndex } from "./nax-dirs.ts";
|
|
28
29
|
import type { CompiledEntry } from "./policy-match.ts";
|
|
29
30
|
export type BashCheck = {
|
|
30
31
|
readonly kind: "allow";
|
|
@@ -60,5 +61,7 @@ export interface BashCheckArgs {
|
|
|
60
61
|
readonly resolvePath: (candidate: string, cwd: string) => string | null;
|
|
61
62
|
/** The shell's initial working directory. */
|
|
62
63
|
readonly initialPath: string;
|
|
64
|
+
/** Per-policy cache of the `.nax` directories below the root (#2493). */
|
|
65
|
+
readonly naxDirs?: NaxDirIndex;
|
|
63
66
|
}
|
|
64
67
|
export declare function checkBashCommand(args: BashCheckArgs): BashCheck;
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import { lexBashCommand } from "#src/permissions/index";
|
|
29
29
|
import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
|
|
30
30
|
import { deniedFlag } from "./exec-guard.js";
|
|
31
|
+
import { isCoveringExclusion } from "./nax-revert.js";
|
|
32
|
+
import { naxStateRefusalInText } from "./nax-state-guard.js";
|
|
31
33
|
function deny(reason, breach = false, escalatable = false) {
|
|
32
34
|
return { kind: "deny", reason, breach, escalatable };
|
|
33
35
|
}
|
|
@@ -119,7 +121,8 @@ function checkPayload(args, segment, cwd) {
|
|
|
119
121
|
if (flag !== undefined)
|
|
120
122
|
return { refusal: deny(`flag ${flag} is not permitted in a Bash command`) };
|
|
121
123
|
for (const token of segment.tokens) {
|
|
122
|
-
|
|
124
|
+
// The exact covering pathspec (a quoted glob) is git pathspec magic, not shell expansion (#2493).
|
|
125
|
+
if (token.opaque || (hasUnmodelledExpansion(token.text) && !isCoveringExclusion(token.text))) {
|
|
123
126
|
return { refusal: deny(`token "${token.text}" depends on shell expansion this gate cannot resolve`) };
|
|
124
127
|
}
|
|
125
128
|
const target = containmentTarget(token.text);
|
|
@@ -188,12 +191,39 @@ function checkPayload(args, segment, cwd) {
|
|
|
188
191
|
}
|
|
189
192
|
return { cdTargets };
|
|
190
193
|
}
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
/** The shape guards: a non-string or blank command is refused before anything is lexed. */
|
|
195
|
+
function invalidCommandDenial(command) {
|
|
193
196
|
if (typeof command !== "string")
|
|
194
197
|
return deny(`"command" must be a string`);
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
return command.trim() === "" ? deny(`"command" must not be empty`) : undefined;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* nax's run state (#2490): revert, index-hiding and stash in any segment,
|
|
202
|
+
* nested shell or unreadable construct. Never escalatable, and checked BEFORE
|
|
203
|
+
* any escalatable refusal so a user approval can never run it.
|
|
204
|
+
*/
|
|
205
|
+
function stateDenial(args) {
|
|
206
|
+
const refusal = naxStateRefusalInText(String(args.command), {
|
|
207
|
+
frames: [args.initialPath],
|
|
208
|
+
root: args.initialPath,
|
|
209
|
+
...(args.naxDirs !== undefined ? { naxDirs: args.naxDirs } : {}),
|
|
210
|
+
});
|
|
211
|
+
return refusal === undefined ? undefined : deny(refusal);
|
|
212
|
+
}
|
|
213
|
+
function lexicalDenial(lexed) {
|
|
214
|
+
if (lexed.kind !== "refused")
|
|
215
|
+
return undefined;
|
|
216
|
+
return deny(`command contains ${lexed.construct}, which cannot be analysed and is therefore refused -- ` +
|
|
217
|
+
"rewrite it without that construct, or use a structured tool", false, true);
|
|
218
|
+
}
|
|
219
|
+
export function checkBashCommand(args) {
|
|
220
|
+
const { command, tool } = args;
|
|
221
|
+
const invalid = invalidCommandDenial(command);
|
|
222
|
+
if (invalid !== undefined)
|
|
223
|
+
return invalid;
|
|
224
|
+
const state = stateDenial(args);
|
|
225
|
+
if (state !== undefined)
|
|
226
|
+
return state;
|
|
197
227
|
const lexed = lexBashCommand(command);
|
|
198
228
|
// On a refusal the gate still runs its deny and payload checks -- over the
|
|
199
229
|
// lexable prefix, the part before the unreadable construct. The prefix is
|
|
@@ -212,10 +242,9 @@ export function checkBashCommand(args) {
|
|
|
212
242
|
return result.refusal;
|
|
213
243
|
cwd = nextWorkingDirectories(segment, cwd, result.cdTargets);
|
|
214
244
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
245
|
+
const lexical = lexicalDenial(lexed);
|
|
246
|
+
if (lexical !== undefined)
|
|
247
|
+
return lexical;
|
|
219
248
|
for (const segment of segments) {
|
|
220
249
|
if (args.grant.unconditional || matchesSegment(args.grant, segment))
|
|
221
250
|
continue;
|