@sema-agent/core 7.17.1 → 7.17.3
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 +25 -0
- package/dist/core/fs-write-gate-policy.js +4 -0
- package/dist/core/runner/contracts.d.ts +2 -0
- package/dist/core/runner/prepare-ask-lane.d.ts +3 -0
- package/dist/core/runner/prepare-ask-lane.js +3 -3
- package/dist/core/runner/prepare-park-ask.d.ts +3 -0
- package/dist/core/runner/prepare-park-ask.js +3 -2
- package/dist/core/runner/prepare-task.js +3 -3
- package/dist/core/runner/run-leg.js +4 -1
- package/dist/core/tool-policy.d.ts +41 -0
- package/dist/core/tool-policy.js +3 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +19 -14
- package/dist/tools/fs/bash-readonly-classifier.js +371 -27
- package/dist/tools/fs/fs-bash.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.17.3 — 2026-09-13
|
|
4
|
+
|
|
5
|
+
### Narrowing — "not on the read-only allowlist" is not "reads no path": the read boundary judges every segment (#758; test [7138]; @server @cli @test)
|
|
6
|
+
- **Before.** The read boundary judged the path operands of a shell segment only when that segment's PROGRAM was on the read-only allowlist. Any other program's operands were not judged at all — not for containment, not against the sensitive-path deny table — so an out-of-root read and a deny-row read both ran with ZERO asks under `shellGate` absent AND `"classify"`, while the same read spelled `cat` was one mandated ask. Measured on the real load path (Runner prepare → NodeExecutionEnv → the gate): `strings <outside>`, `nl <outside>`, `od -c <outside>`, `rev`, `file`, and `sort ~/.ssh/id_rsa` — asks=0, command executed, both doctrines. A name table was doing double duty as a claim about behaviour, and the set it silenced is the one set a name table cannot enumerate.
|
|
7
|
+
- **After (one rule, three sites).** A segment whose program is not a listed reader is judged WHOLE and VERB-BLIND: every token from the program onward is offered to the boundary as a path candidate, under a head with no per-verb behaviour of its own. That is the form the containment face already used for LAUNCHER segments (`env cat <outside>`), promoted to the general rule — so the launcher special case disappears into it rather than being joined by a second one. Three sites carried the old assumption and all three now read the same way: the boundary walk of `classifyCompoundReadonlyDetailed` (which alone can answer for an in-root DENY row — the containment face deliberately drops the deny judge), `classifyOutOfRootReadGate` (which alone answers for a command the compound face refuses WHOLE), and the refused-command whitelist `refusedCommandExempt`, whose `!allow.has(head)` disjunct read an unlisted head as "a program that reads no path" and exempted it. Verb-blind is load-bearing in the safe direction: a per-verb table can silence a whole segment, skip a slot or move the base, and a program the deployment never declared must borrow none of that.
|
|
8
|
+
- **Behaviour, for consumers — the RANGE, not a footnote.** Under BOTH doctrines a shell command whose program is not on the reader table now asks ONCE, mandated, when any of its words resolves outside the roots or onto the deny table, or is a word the walk could not read at all (an expansion, a subshell/glob/brace character, a `~` with no declared home). Every word, not only the path-shaped ones: a deny row names bare filenames (`.npmrc`, `.env`) and an expansion names what the text does not contain. A stored allow rule is disclosed as shadowed and does not clear it. **Under the default doctrine every one of these ran with ZERO asks before**, so on a headless deployment — no approver wired — the new card is a REFUSAL, not a prompt: `node -e "console.log(1)"`, `python3 -c "print(1)"`, `npm run "$SCRIPT"`, `npm install /opt/pkg`, `git -C /outside status`, `curl -o <outside> …`, `rm -rf /etc`, `touch <outside>`. The floor is unchanged and pinned: `npm test`, `timeout 300 npm test`, `env FOO=1 npm test`, `git status`, `npm run build`, `node scripts/build.mjs`, `python3 -m pytest tests/`, `docker ps`, `make`, `sort data.txt`, `curl https://…` keep their zero-ask verdict. **Stated over-fire** (the price of not carrying per-program option tables, which this file refuses to start doing): a token that is a WRITE target rather than a read gates too; a quoted literal that spells an out-of-root path gates even where the program only prints it (`printf '/etc/hosts'`); a program run by absolute path from outside the roots gates on its own path (`sudo /bin/cat <in-root>`, unchanged); an unfollowable base move now gates in its operand-less form as well (`pushd <anywhere>`); a refused-whole command carrying a line continuation, an escaped shell operator (`\>`), or a `#` alongside a quote character is a command the refused-command whitelist's positional splitter does not read the way bash does, and gets no exemption (one predicate, `whitelistReadsTheSameWords`, and one open design item behind all three — the exemption is a second hand-written parser beside the compound lexer, #753); and a non-canonical spelling of an in-root path (a symlinked `/var` → `/private/var` prefix) reads as out-of-root for an unlisted program exactly as it already did for a listed one. @server @cli: expect one more mandated-ask source on both tiers, and on a headless lane expect refusals where commands used to run — a probe that pinned zero asks for an unlisted program naming an absolute path outside the workspace, or carrying a `-e`/`-c` program body, reds. @test: the class criterion is a synthetic name (`zzunlisted`), not a tool list — a verification that enumerates names repeats the defect.
|
|
9
|
+
- **Known residual, NOT closed here (a different class, pre-existing, and it is not keyed on the allowlist).** An option's ATTACHED payload is offered as a path candidate only when it is path-shaped, so a bare filename attached to a short or long option escapes the boundary walk for EVERY head, listed or not: `grep -f.npmrc`, `grep --file=.npmrc` and `awk -f.npmrc` are as silent as `base64 -i.npmrc`, while their separated (`-f .npmrc`) and path-shaped (`-i./.npmrc`) spellings mandate. Measured on this tree; filed rather than folded in, because the fix moves every head's candidate set and belongs with its own pins.
|
|
10
|
+
- **Also.** `CompoundReadonlyVerdict.outOfRootRead` is now carried by a NAME-refused compound too (`rm -rf /etc`); the grant-offer reading is unchanged and remains the containment SENTENCE's, never the member's — a name-refused verdict's sentence is the name refusal, so no "allow reading from …" option is offered for it. The governed-agent example canonicalizes its temp workspace at the one minting point (a demo handing an unresolved workspace to an engine that canonicalizes its roots made every absolute operand read as out-of-root).
|
|
11
|
+
- Pins: `test/backlog758-unlisted-reader-boundary.test.ts` (the real load path, two doctrines × out-of-root and in-root deny row × mandated-ask and zero-ask controls, on a synthetic unlisted name with real tools as corroboration; the over-fire floor at the seat face; the launcher promises unmoved; the third site's exempt/may-read sets with and without a wired deny judge; the `bash_readonly` face untouched; both mutation directions measured in the header), `test/backlog696-read-boundary-any-doctrine.test.ts` (appended unlisted-program rows on both seats), `test/backlog502-out-of-root-read-mandate.test.ts` / `test/rb412-bash-readonly-path-boundary.test.ts` / `test/rb474-glob-verdict-undecided.test.ts` (five cells re-pinned, each stating whether the old answer was a correct silence or the defect).
|
|
12
|
+
|
|
13
|
+
## 7.17.2 — 2026-09-13
|
|
14
|
+
|
|
15
|
+
### Fix — the directories the prompt advertises as writable feed the write gate's exemption through the request (#749; cli B-102; server S-261; @server @cli)
|
|
16
|
+
- **Before.** Two seats judged one fact. The root fence's advertised-writable-directory seat (#691) admits the memory engine's write home — that admission is exactly what licenses the `# Memory` instruction's "write to it directly with the Write tool". The deployment's write gate (`createFsWriteGatePolicy`) knew nothing about it: the seat is minted inside prepare and its class is not exported, so under the manual-mode default (`defaultWrite: "ask"`, i.e. the `default` / `auto` / `acceptEdits` tiers) every write the prompt had just invited raised an approval card — an entry plus its index, two cards for one remembered fact. The only workaround open to a deployment was to copy the memory home into `exemptDirs`, making it a SECOND author of a list the fence already owns.
|
|
17
|
+
- **After (one rule).** The seat is the single source and it travels on the call. `ToolCallRequest.advertisedWritableRoots` (optional, additive, read-only) carries the seat's admitted CANONICAL roots — the memory write home, the deployment's scratchpad — stamped by the Runner beside the live-cwd stamp at every adjudication seat and read live off the seat's own getter. `createFsWriteGatePolicy` checks it ahead of `exemptDirs` / `acceptDirs`: same verdict (allow, never asked), same containment comparison, same fold. The advertised roots are compared **frozen** — the seat admits only canonical spellings and `resolveKey`'s root fence compares its admitted roots exactly as they stand, so this gate must too. Re-resolving them per call would follow whatever the name points at AT ADJUDICATION TIME: replacing an admitted memory home with a symlink to the workspace (no race needed — any moment between two calls) would move the exemption domain onto the whole workspace and turn every ordinary source write from `ask` into `allow`. The CONFIGURED domains keep their per-call resolution, because they are deployment-authored text (a relative spelling, a path that did not exist at wiring time) rather than admitted keys. Nothing else changes — a sensitive-path `deny` composed alongside still outranks it, an unresolvable target still fails closed to `ask`, and an ABSENT or EMPTY list is NO exemption (never a wildcard). `FsWriteGatePolicyOptions` gains no option: the seat belongs to the request, not to the construction.
|
|
18
|
+
- **The stamp is a frozen copy.** One helper mints the field at every station (`advertisedWritableRootsSeat`, beside the field's own declaration — the `callFaceSeat` shape), and it hands out `Object.freeze([...seat.roots])`. The seat's getter returns its own admission ledger and `combinePolicies` walks every layer with ONE request object, so a layer handed the live array could append to the very domain the gate exempts — for that call and every later one. `readonly` is a compile-time word; the freeze is the runtime one. (A layer REPLACING the whole field on the shared request is the pre-existing shape `cwd`, `budget` and `face` already live with; that belongs to the fold, not to this seat.)
|
|
19
|
+
- **Stamp seats (five).** `prepare-ask-lane.ts` (the live gate), `prepare-park-ask.ts` (the parked call's projection re-adjudication), and `run-leg.ts` ×3 (the resume belts: the approver-edit recheck, the deny-narrowing lane, the persisted-rule lane). The last two are tightening-only lanes that never read the field; they are stamped so the field's own sentence — the Runner stamps it on every policy adjudication of the run — is true and cannot drift. `Prepared` carries the seat so the resume belts adjudicate against the same list the live gate used.
|
|
20
|
+
- **For consumers.** @server: **zero changes to take this** — a run whose prompt advertises a memory home stops asking about writes into it, on your existing gate wiring. Your `exemptDirs: [scratchpadDir]` stays legal and correct; it is now redundant for any directory the seat already admits, and `isExempt` is untouched (it is still consulted after the domain checks). Do NOT add the memory home to `exemptDirs` — that is the second-writer shape this closes. @cli: the counter-control for B-102 is that an interactive `default`-tier run that stores a memory entry no longer raises a card for it; a probe that pinned that card reds.
|
|
21
|
+
- Pins: `test/backlog749-advertised-roots-feed-write-gate.test.ts` (the gate unit: inside/outside/absent/empty/symlink-escape/unresolvable, an admitted root REPLACED by a symlink after admission not moving the domain, and the sensitive-path deny still outranking; the Runner end-to-end on a real memory wiring: the memory write lands with zero asks while an ordinary in-root write still asks exactly once, with the stamp positively observed on the adjudicated request; the structural invariant that every Runner line stamping `cwd` also stamps the seat, five stations, all through the one helper; the helper's frozen copy, and a greedy policy layer composed ahead of the gate failing to widen it).
|
|
22
|
+
|
|
23
|
+
### Fix — a here-document or here-string is a reader's stdin, not a file: the read-evidence scan counts operands (#751; test [7111]; @cli @server)
|
|
24
|
+
- **Before.** The read-evidence scan behind the shell read boundary (the rule that a command the grammar refused whole is mandated only when it may read) counted a listed path reader by NAME: `cat <<'EOF' > out.txt` — the everyday heredoc write — and `wc -l <<< "x"` raised a mandated ask under the default doctrine while `tr … <<< hello` did not (`tr` reads no path).
|
|
25
|
+
- **After (one rule, a whitelist).** A refused command is exempt from the mandate only when every part of it is a shape the scan fully understands and none of them reads a file outside the walk's reach — everything else, including any token the scan does not understand, is read evidence. Exempt shapes, per segment: a program that reads no path (`echo`, `tee out.txt <<EOF`, `python3 - <<'EOF'`, an unlisted program); `cat` / `wc` with a payload-free flag cluster whose input is a here-document, a here-string or a literal in-root stdin file (`cat <<'EOF' > out.txt`, `wc -l <<< "x"`, `cat < secret`); a directory change (after which every relative stdin literal in the command is unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own — green under the compound walk (`echo $(cat src/a)`, `VAR=$(cat src/a)`) or exempt under the whitelist (`echo $(date)`) — and a here-document body is text. Mandated: a stdin source outside the root, deny-listed, expanded anywhere in its spelling or relative after a `cd`/`pushd` (`cat < /etc/passwd`, `cat < .${K}rc`, `pushd /etc; cat < hosts`); any listed reader other than `cat`/`wc` fed by a here-string (`sed -e'r/etc/passwd' <<< x`, `grep -f x <<< y`); an option that may carry a file (`cat -`, `sed -fsecret`); a reader inside a substitution that reads outside the root (`echo x$(cat /etc/hosts)`); a re-entry (`bash -c "$CMD"`, `eval`); an unterminated here-document. A shell re-entry program (`sh -c …`, `bash script.sh`, `eval …`, `source x`, `. x`), bare or behind a launcher, runs its argument as commands the walk never sees: refused whole and mandated on both seats (the 7.17.0–7.17.1 walk read it as an unlisted program and the boundary seat vouched it). `refusedCommandMayRead(command, allow, boundary)` is the complement of `refusedCommandExempt`. **Residual, disclosed (#753):** the exemption is a hand-written parser beside the compound walk's lexer; five adversarial rounds closed twenty-one corners (heredoc delimiters and bodies, quoting, substitutions, launchers, moved bases, option payloads); the class is open by construction until the whitelist runs over the compound lexer itself.
|
|
26
|
+
- Pins: `test/backlog696-read-boundary-any-doctrine.test.ts` (eight non-objection heredoc / here-string / in-root stdin forms; six mandated stdin forms).
|
|
27
|
+
|
|
3
28
|
## 7.17.1 — 2026-09-13
|
|
4
29
|
|
|
5
30
|
### Narrowing — the shell read boundary never vouches a read it did not judge (#737; test [7074] G12 / G14; @server @cli @test)
|
|
@@ -31,6 +31,10 @@ export function createFsWriteGatePolicy(opts) {
|
|
|
31
31
|
if (!canon.ok) {
|
|
32
32
|
return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
|
|
33
33
|
}
|
|
34
|
+
for (const root of req.advertisedWritableRoots ?? []) {
|
|
35
|
+
if (isWithin(root, canon.key))
|
|
36
|
+
return { action: "allow" };
|
|
37
|
+
}
|
|
34
38
|
for (const dirs of [exemptDirs, acceptDirs]) {
|
|
35
39
|
if (!dirs)
|
|
36
40
|
continue;
|
|
@@ -291,6 +291,8 @@ export interface Prepared {
|
|
|
291
291
|
/** The per-task logical cwd ref when a real shell is mounted (else undefined). The Runner
|
|
292
292
|
* reads `cwdRef.current` after each tool to detect a `cd` move and emit `workspace_changed`. */
|
|
293
293
|
cwdRef?: CwdRef;
|
|
294
|
+
/** The run's advertised-writable-directory seat (present with hands): `roots` is what the prompt advertised as writable and the fence admitted. Read live wherever a policy is adjudicated off a resumed leg, so the write gate's exemption domain is that same list. */
|
|
295
|
+
advertisedWritableDirs?: Pick<import("./advertised-writable-dirs.js").AdvertisedWritableDirs, "roots">;
|
|
294
296
|
/** The ACTIVE EnterWorktree session ref (mounted with the worktree tools; undefined without
|
|
295
297
|
* real write hands). runtask's settle write reads `current` for the workspace-state entry. */
|
|
296
298
|
worktreeSessionRef?: {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* the orchestrator grows no branch.
|
|
19
19
|
*/
|
|
20
20
|
import type { AgentTool } from "../../internal/harness.js";
|
|
21
|
+
import type { AdvertisedWritableDirs } from "./advertised-writable-dirs.js";
|
|
21
22
|
import type { AutoModeDenialTracker } from "../auto-mode.js";
|
|
22
23
|
import { type Hooks } from "../hooks.js";
|
|
23
24
|
import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-policy.js";
|
|
@@ -44,6 +45,8 @@ export interface PrepareAskLaneInput {
|
|
|
44
45
|
/** borrowed-readonly — the hand's live tracked cwd, or undefined (hands-less). Read per call at RUN time (`current`),
|
|
45
46
|
* never captured: the fs hand tools resolve their relative paths against this very ref. Not written here. */
|
|
46
47
|
handsCwdRef: CwdRef | undefined;
|
|
48
|
+
/** borrowed-readonly — the run's advertised-writable-directory seat, or undefined (hands-less / nothing advertised). `roots` is read at RUN time, never captured: it is the fence's own admitted list and the write gate's exemption domain must be that same list. Not written here. */
|
|
49
|
+
advertisedWritableDirs: Pick<AdvertisedWritableDirs, "roots"> | undefined;
|
|
47
50
|
/** borrowed-readonly — the shared mount roster; read at RUN time by the approval-preview projection (alias-aware
|
|
48
51
|
* lookup over whatever the roster holds when the ask is minted). Never mutated here. */
|
|
49
52
|
tools: readonly AgentTool[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { primaryActivityArg } from "../arg-summary.js";
|
|
2
2
|
import { hookSeatExpiredError, runHookSeat } from "../hooks.js";
|
|
3
|
-
import { isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
|
|
3
|
+
import { advertisedWritableRootsSeat, isLiveApproverSeat, resolveAsk } from "../tool-policy.js";
|
|
4
4
|
import { composeCallSignal, raceAbort } from "./abort-race.js";
|
|
5
5
|
import { gateAskCarry, lateAskSettlementObserver } from "./denial-limit-arms.js";
|
|
6
6
|
import { consumeInheritedAskGrant } from "./inherited-ask-grants.js";
|
|
@@ -55,14 +55,14 @@ function resolveApprovalPreview(tools, toolName, args) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
export function prepareAskLane(input) {
|
|
58
|
-
const { gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash } = input;
|
|
58
|
+
const { gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, advertisedWritableDirs, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash } = input;
|
|
59
59
|
if (!gateMachineryActive)
|
|
60
60
|
return { askLane: undefined };
|
|
61
61
|
const composedCallSignal = (callSignal) => composeCallSignal(abortController.signal, callSignal);
|
|
62
62
|
const adjudicate = effectivePolicy
|
|
63
63
|
? (req, callSignal) => {
|
|
64
64
|
const signal = composedCallSignal(callSignal);
|
|
65
|
-
return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}) }, signal)), signal, () => ({
|
|
65
|
+
return raceAbort(Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot, ...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}), ...advertisedWritableRootsSeat(advertisedWritableDirs) }, signal)), signal, () => ({
|
|
66
66
|
action: "deny",
|
|
67
67
|
message: "policy check aborted (task timed out or cancelled)",
|
|
68
68
|
}));
|
|
@@ -27,6 +27,7 @@ import { type OnAsk, type ToolCallRequest, type ToolPolicy } from "../tool-polic
|
|
|
27
27
|
import { type ToolResultStore } from "../tool-result-store.js";
|
|
28
28
|
import type { RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskSpec } from "../types.js";
|
|
29
29
|
import type { CwdRef } from "../../tools/fs/fs-shared.js";
|
|
30
|
+
import type { AdvertisedWritableDirs } from "./advertised-writable-dirs.js";
|
|
30
31
|
import { type ContentAskBinding } from "./content-ask-bindings.js";
|
|
31
32
|
import type { AskLane, ParkAsk, Prepared, SuspendSaga } from "./contracts.js";
|
|
32
33
|
import type { createRuleOffersOf } from "./permission-rule-lanes.js";
|
|
@@ -92,6 +93,8 @@ export interface PrepareParkAskInput {
|
|
|
92
93
|
/** borrowed-readonly — the hand's live tracked cwd, or undefined; `current` is read at park time for the re-adjudication.
|
|
93
94
|
* Not written here. */
|
|
94
95
|
handsCwdRef: CwdRef | undefined;
|
|
96
|
+
/** borrowed-readonly — the SAME seat the live gate stamps from ({@link import("./prepare-ask-lane.js").PrepareAskLaneInput.advertisedWritableDirs}): a parked call's projection re-adjudication asks one question about one call, so it must ask it against the same admitted list. Read at park time, not written here. */
|
|
97
|
+
advertisedWritableDirs: Pick<AdvertisedWritableDirs, "roots"> | undefined;
|
|
95
98
|
/** borrowed-readonly — the run's offload store, or undefined (a volatile store holding results refuses the park). */
|
|
96
99
|
offloadStore: ToolResultStore | undefined;
|
|
97
100
|
/** borrowed-readonly — the per-task owned env, or undefined (a non-remote per-task env refuses the park). Never destroyed. */
|
|
@@ -7,7 +7,7 @@ import { BINDING_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtF
|
|
|
7
7
|
import { pauseOf } from "../pause-registry.js";
|
|
8
8
|
import { askCarryRowMembers } from "../hooks.js";
|
|
9
9
|
import { defaultTaskRegistry } from "../task-registry.js";
|
|
10
|
-
import { isLiveApproverSeat, carriesBidiControls, describeThrown, refuseOutOfContractDecision, tryCloneArgs } from "../tool-policy.js";
|
|
10
|
+
import { advertisedWritableRootsSeat, isLiveApproverSeat, carriesBidiControls, describeThrown, refuseOutOfContractDecision, tryCloneArgs } from "../tool-policy.js";
|
|
11
11
|
import { InMemoryToolResultStore, ScopedToolResultStore, isVolatileOffloadStore } from "../tool-result-store.js";
|
|
12
12
|
import { raceAbort, raceSettlementAgainstSignal } from "./abort-race.js";
|
|
13
13
|
import { checkpointScopeOf } from "./checkpoint-scope.js";
|
|
@@ -22,7 +22,7 @@ class ParkRefusal extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
export function prepareParkAsk(input) {
|
|
25
|
-
const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
|
|
25
|
+
const { askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, advertisedWritableDirs, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef } = input;
|
|
26
26
|
if (askLane === undefined || saga === undefined)
|
|
27
27
|
return { parkAsk: undefined };
|
|
28
28
|
const { composedCallSignal, approvalPreviewOf } = askLane;
|
|
@@ -160,6 +160,7 @@ export function prepareParkAsk(input) {
|
|
|
160
160
|
budget: budgetSnapshot,
|
|
161
161
|
...callFaceSeat(req.face),
|
|
162
162
|
...(handsCwdRef !== undefined ? { cwd: handsCwdRef.current } : {}),
|
|
163
|
+
...advertisedWritableRootsSeat(advertisedWritableDirs),
|
|
163
164
|
}, cutSignal)), cutSignal);
|
|
164
165
|
if (reprojectedRaced.tag === "aborted")
|
|
165
166
|
return undefined;
|
|
@@ -690,16 +690,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
690
690
|
return undefined;
|
|
691
691
|
});
|
|
692
692
|
}
|
|
693
|
-
const { askLane } = prepareAskLane({ gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash });
|
|
693
|
+
const { askLane } = prepareAskLane({ gateMachineryActive, abortController, effectivePolicy, budgetSnapshot, handsCwdRef, advertisedWritableDirs, tools, inheritedUnavailableAsks, inheritedAskGrants, onAsk, humanReviewRef, now, ruleOffersOf, askSourceIdentity, riskAxesOf, autoModeDenialTracking, spec, deps, sessionId, runId, hooks, hookTimeoutMs, notifyOwnHookCrash });
|
|
694
694
|
const { saga } = prepareSuspendSaga({ gateMachineryActive, abortController, harness, checkpointStore, deps, sessionId, hostTaskId, taskScope, liveSpendRef, resume, nestedStats, internals, activeTools, outputRef, readFileStateForCheckpoint, faceCheckpointSection, reminderMark, handsCwdRef, worktreeSessionRef, inheritedParentConstraints, seedInheritedGate, inheritedAncestorRules, inheritedShellGate, autoModeIntent, inheritedAdmittedOrgScopes, ownOrgVerdictRef, orgGovernedProvenance, announcedListingsRef, gitStatusRef, hookIdentity, placementRootResolved, externalContentTargetActive, remoteEnvFailures, memoryEngineSession, suspendLoopRef, ownedEnv, incompleteSuspendAdapter });
|
|
695
695
|
const { suspendForResource, suspendForPlatformLimit, suspendForReview } = prepareBoundaryParks({ saga, spec, checkpointStore, abortController, harness, session, sessions, sessionId, deps, priorLedger, maxSlices, maxSuspends, resourceTotal, priorSuspendCount, suspendChainBase, humanReviewRef, liveSpendRef, now, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, pausedRef, remoteEnvFailures, resourceSuspendEligible, durableSuspendInfraReady, incompleteSuspendAdapter });
|
|
696
|
-
const { parkAsk } = prepareParkAsk({ askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas: rosterSeat, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef });
|
|
696
|
+
const { parkAsk } = prepareParkAsk({ askLane, saga, spec, deps, sessionId, checkpointStore, toolRosterDeltas: rosterSeat, parkLaneArmed, contentAskRoutable, liveQuestionFace, mountedQuestionTool, contentAskBindings, lateStrandedAnswers, discloseStrandedAnswers, onAsk, runtimeCaps, inheritedUnavailableAsks, basePolicyForResumeEdit, budgetSnapshot, handsCwdRef, advertisedWritableDirs, offloadStore, ownedEnv, incompleteSuspendAdapter, session, sessions, suspendChainBase, maxSuspends, remoteEnvFailures, shellGatedTools, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef });
|
|
697
697
|
prepareGateStations({ askLane, parkAsk, tools, toolRosterDeltas: rosterSeat, toolEffects, deps, toolCallGateArmedRef, effectivePolicy, hooks, egressTools, irreversibleTools, spec, complianceDenies, harness, blockedToolCalls, inheritedAskGrants, inheritedUnavailableAsks, foldAskClasses, ancestorSandboxAdmissions, preToolContexts, gateOutcomes, batchHaltRef, blockedTracked, hookIdentity, reminderMark, planModeRef, hostTaskId, sessionId, ownGatePreToolUse, hookTimeoutMs, handsCwdRef, hookEnvFace, irreversibilityTier, reversibilityProbes, abortController, shellGatedTools, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash });
|
|
698
698
|
const { microCompact, charsPerToken } = prepareContextLane({ model, deps, sessionId, runId, hostTaskId, offloadStore, microCompactKnob, harness, batchHaltRef, gitStatusRef, requestLossyRef, trimPressureRef });
|
|
699
699
|
const preparedHolder = {};
|
|
700
700
|
const { cacheBreakDetector, cacheFingerprint, promptOverheadTokens, readTaskFile, normalizeAttachmentPath, isDedupStubResult, recentlyReadFiles, onCompactionApplied, detectExternalChanges, listBackgroundTasks, centerCompactionCandidate, effectiveReadFaceObserved, effectiveReadDenyObserved } = prepareTurnWiring({ deps, systemPromptSeat, harnessTools, fpRef, model, epochArtifactDigestForSnapshot, promptProfile, fableMitigations, systemBlocks, thinking, spec, sessionId, turnSnapshotRef, promptManifest, charsPerToken, handsEnabled, executionEnv, attachmentRootCanonical, additionalRootsCanonical, additionalReadRootsCanonical, readDenyMatcher, resolvedReadFace, handsCwdRef, readFileStateForCheckpoint, denyNarrowingPolicy, abortController, hostTaskId, taskScope, buildAssembleInputs, centerAdoptionRef, harnessRef, epochDeclaredSections, carrierReadFace, readDenyAdditionsNormalized, preparedHolder });
|
|
701
701
|
await onceLedger.settle(session, announcedListingsRef);
|
|
702
|
-
const buildPrepared = () => ({ harness, session, sessionId, runId, reminderMark, reminderDisclosureCounts, editedFilesSnapshot, taskRootPath: taskRootFinal, model, thinking, compModel: effectiveCompModel, mcp, ...(a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, gateOutcomes, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(fileHistoryBoundary !== undefined ? { fileHistoryBoundary } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), ...(sealReadStateSeat !== undefined ? { sealReadStateSeat } : {}), denyNarrowingPolicy, ...(permissionRuleLane !== undefined ? { persistedRuleLane: permissionRuleLane } : {}), ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, pausedRef, suspendProgressRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, workCutRef, brainCallGuardrailRef, gateStopRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolRoster, toolRosterDeltas: rosterSeat, structuredProjector, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, microCompact, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, stopRequestedRef, announcedSnapshotRecovered: onceLedger.recovered, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(projectInstructionContent !== undefined ? { projectInstructionContent } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, hookTimeoutMs, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
|
|
702
|
+
const buildPrepared = () => ({ harness, session, sessionId, runId, reminderMark, reminderDisclosureCounts, editedFilesSnapshot, taskRootPath: taskRootFinal, model, thinking, compModel: effectiveCompModel, mcp, ...(a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, gateOutcomes, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(fileHistoryBoundary !== undefined ? { fileHistoryBoundary } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(advertisedWritableDirs !== undefined ? { advertisedWritableDirs } : {}), ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), ...(sealReadStateSeat !== undefined ? { sealReadStateSeat } : {}), denyNarrowingPolicy, ...(permissionRuleLane !== undefined ? { persistedRuleLane: permissionRuleLane } : {}), ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, pausedRef, suspendProgressRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, workCutRef, brainCallGuardrailRef, gateStopRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolRoster, toolRosterDeltas: rosterSeat, structuredProjector, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, microCompact, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, stopRequestedRef, announcedSnapshotRecovered: onceLedger.recovered, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(projectInstructionContent !== undefined ? { projectInstructionContent } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, hookTimeoutMs, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
|
|
703
703
|
rollback.commit();
|
|
704
704
|
const prepared = buildPrepared();
|
|
705
705
|
preparedHolder.current = prepared;
|
|
@@ -10,7 +10,7 @@ import { applyPersistedTightening, disclosedRuleSet } from "../persisted-rule-ar
|
|
|
10
10
|
import { computeCostMicroUsd } from "../pricing.js";
|
|
11
11
|
import { mintSystemReminder } from "../reminder-mint.js";
|
|
12
12
|
import { hasDestroy } from "../remote-env.js";
|
|
13
|
-
import { refuseOutOfContractDecision } from "../tool-policy.js";
|
|
13
|
+
import { advertisedWritableRootsSeat, refuseOutOfContractDecision } from "../tool-policy.js";
|
|
14
14
|
import { callFaceSeat, judgeParkedToolIdentity, rosterEntryNamed, toolCallFaceOf } from "../tool-roster.js";
|
|
15
15
|
import { deliverEngineNotice } from "../types.js";
|
|
16
16
|
import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
|
|
@@ -571,6 +571,7 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
571
571
|
toolCallId: pendingAction.toolCallId,
|
|
572
572
|
budget: beltBudget,
|
|
573
573
|
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
574
|
+
...advertisedWritableRootsSeat(prepared.advertisedWritableDirs),
|
|
574
575
|
...callFaceSeat(toolCallFaceOf(prepared.toolRosterDeltas.current, pendingAction.toolName)),
|
|
575
576
|
}, prepared.abortController.signal));
|
|
576
577
|
const beltUnsafe = rechecked.action === "deny" ||
|
|
@@ -632,6 +633,7 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
632
633
|
...callFaceSeat(toolCallFaceOf(prepared.toolRosterDeltas.current, pendingAction.toolName)),
|
|
633
634
|
budget: beltBudget,
|
|
634
635
|
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
636
|
+
...advertisedWritableRootsSeat(prepared.advertisedWritableDirs),
|
|
635
637
|
}, prepared.abortController.signal));
|
|
636
638
|
if (narrowed.action === "deny") {
|
|
637
639
|
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by a session rule and was not executed${narrowed.message ? `: ${narrowed.message}` : ""}.`, prepared.reminderMark);
|
|
@@ -651,6 +653,7 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
651
653
|
...callFaceSeat(toolCallFaceOf(prepared.toolRosterDeltas.current, pendingAction.toolName)),
|
|
652
654
|
budget: beltBudget,
|
|
653
655
|
...(prepared.cwdRef !== undefined ? { cwd: prepared.cwdRef.current } : {}),
|
|
656
|
+
...advertisedWritableRootsSeat(prepared.advertisedWritableDirs),
|
|
654
657
|
}))
|
|
655
658
|
.then((answer) => normalizePersistedRuleHit(answer)), unreadable, { signal: prepared.abortController.signal, timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
|
|
656
659
|
const tightened = applyPersistedTightening({ action: "allow" }, read);
|
|
@@ -58,6 +58,30 @@ export interface ToolCallRequest {
|
|
|
58
58
|
* window — at that declaration, not here.
|
|
59
59
|
*/
|
|
60
60
|
cwd?: string;
|
|
61
|
+
/**
|
|
62
|
+
* The CANONICAL directories THIS RUN'S PROMPT ADVERTISES AS WRITABLE — the admitted list of the root
|
|
63
|
+
* fence's advertised-writable-directory seat (the memory engine's write home, the deployment's
|
|
64
|
+
* scratchpad), in admission order, deduplicated. Stamped by the Runner on every policy adjudication of
|
|
65
|
+
* the run, read live at adjudication time (a directory admitted by a later phase is on the list the
|
|
66
|
+
* next call carries).
|
|
67
|
+
*
|
|
68
|
+
* WHY IT EXISTS: the fence and the write gate were judging one fact twice. The prompt tells the model
|
|
69
|
+
* "write to it directly with the Write tool" and the fence opens the hole for exactly the directories
|
|
70
|
+
* this list names — but a deployment-authored write gate knew nothing of them, so every advertised
|
|
71
|
+
* write still asked (a memory entry plus its index = two questions the person was told would not be
|
|
72
|
+
* asked). Reading THIS field makes the gate's exemption domain the same list the fence admitted,
|
|
73
|
+
* instead of a second copy the deployment maintains by hand.
|
|
74
|
+
*
|
|
75
|
+
* A containment gate reads it EXACTLY as {@link import("./fs-write-gate-policy.js").FsWriteGatePolicyOptions.exemptDirs}:
|
|
76
|
+
* a covered write whose canonical target lands inside one of these directories is allowed and not
|
|
77
|
+
* asked. It grants nothing else — a DENY composed alongside (sensitive-path, an org rule) still
|
|
78
|
+
* outranks it under the deny > ask > allow fold, and an unresolvable target still fails closed.
|
|
79
|
+
*
|
|
80
|
+
* ABSENT outside a Runner, or when the run has no hands, or when nothing was advertised. Absence is NO
|
|
81
|
+
* exemption, never a wildcard (an empty list reads the same). Read-only — a policy cannot advertise a
|
|
82
|
+
* directory by writing one back.
|
|
83
|
+
*/
|
|
84
|
+
advertisedWritableRoots?: readonly string[];
|
|
61
85
|
/**
|
|
62
86
|
* design/80 D-E-core: read-only per-run budget snapshot so a STATELESS policy can self-limit against
|
|
63
87
|
* state that survives suspend/resume (a fresh in-memory counter would reset every leg — the supervisor
|
|
@@ -104,6 +128,23 @@ export interface ToolCallRequest {
|
|
|
104
128
|
/** `read_only` (#619): the shell tool's command was PROVABLY read-only by the engine's transcribed upstream
|
|
105
129
|
* tables (`readOnlyShellVerdict`) and the allow layer let it run without a question — after every
|
|
106
130
|
* deny/ask lane and the person's own allow rules, never over a mandated ask. */
|
|
131
|
+
/**
|
|
132
|
+
* Stamp {@link ToolCallRequest.advertisedWritableRoots} from the run's advertised-writable-directory seat
|
|
133
|
+
* — the ONE place the seat becomes a request field, so every adjudication station stamps it alike (the
|
|
134
|
+
* `callFaceSeat` shape).
|
|
135
|
+
*
|
|
136
|
+
* The list is COPIED AND FROZEN here, and that is load-bearing rather than tidy. The seat's `roots` getter
|
|
137
|
+
* hands back its own admission ledger, and {@link combinePolicies} walks every layer with ONE request
|
|
138
|
+
* object: a layer handed the live array could append to the very domain the write gate exempts, widening
|
|
139
|
+
* it for that call and for every later one. `readonly` is a compile-time word; this is the runtime one.
|
|
140
|
+
* (What a frozen array does not close is a layer REPLACING the whole field on the shared request — the
|
|
141
|
+
* pre-existing shape that `cwd`, `budget` and `face` live with; it belongs to the fold, not to this seat.)
|
|
142
|
+
*/
|
|
143
|
+
export declare function advertisedWritableRootsSeat(seat: {
|
|
144
|
+
readonly roots: readonly string[];
|
|
145
|
+
} | undefined): {
|
|
146
|
+
advertisedWritableRoots?: readonly string[];
|
|
147
|
+
};
|
|
107
148
|
declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
|
|
108
149
|
export type DecisionReason = (typeof DECISION_REASONS)[number];
|
|
109
150
|
/** Carry every engine attestation (settlement, classifier cause) from a decision onto a reconstruction of it — the
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -10,6 +10,9 @@ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./unt
|
|
|
10
10
|
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
|
|
11
11
|
import { protocolOf } from "./protocol-table.js";
|
|
12
12
|
import { isAbsoluteForFamily, isAbsolutePathForm, joinForFamily, normalizeAbsPathLexically, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
|
|
13
|
+
export function advertisedWritableRootsSeat(seat) {
|
|
14
|
+
return seat === undefined ? {} : { advertisedWritableRoots: Object.freeze([...seat.roots]) };
|
|
15
|
+
}
|
|
13
16
|
const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
|
|
14
17
|
const DECISION_REASON_SET = new Set(DECISION_REASONS);
|
|
15
18
|
function settledByNobody(kind) {
|
|
@@ -389,13 +389,15 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
|
|
|
389
389
|
* has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its
|
|
390
390
|
* `canonicalPath` is an RPC per candidate). */
|
|
391
391
|
export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined;
|
|
392
|
-
/** Whether a command the grammar gate REFUSED WHOLE
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
|
|
392
|
+
/** Whether a command the grammar gate REFUSED WHOLE may READ a file the boundary never judged. The answer is the
|
|
393
|
+
* complement of a WHITELIST: the command is exempt only when every part of it is a shape the scan fully understands
|
|
394
|
+
* and none of those shapes reads a file outside the walk's reach — everything else is read evidence, so a token the
|
|
395
|
+
* scan does not understand mandates rather than passes. Exempt shapes, per segment: a program that reads no path
|
|
396
|
+
* (`echo`, `python3 -`, `tee out.txt`, an unlisted program); `cat` / `wc` with payload-free flags whose input is a
|
|
397
|
+
* here-document, a here-string or a literal in-root stdin file; a directory change (which then makes every relative
|
|
398
|
+
* stdin literal in the command unjudged). A substitution body (`$(…)`, backticks, `<(…)`) is judged on its own: green
|
|
399
|
+
* under the compound walk, or exempt under this whitelist; a here-document body is text, not commands. */
|
|
400
|
+
export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): boolean;
|
|
399
401
|
/**
|
|
400
402
|
* The home directory's VARIABLE spellings, SUBSTITUTED with the declared value before a read face segments the
|
|
401
403
|
* command: `$HOME` / `${HOME}` at the START of a word (an empty quote pair before it included), unquoted or
|
|
@@ -491,13 +493,16 @@ export declare const COMMAND_LAUNCHERS_FOR_TEST: ReadonlySet<string>;
|
|
|
491
493
|
* reason for a demotion has one predicate to read.
|
|
492
494
|
*
|
|
493
495
|
* What it judges and what it does not:
|
|
494
|
-
* ·
|
|
495
|
-
* the reference implementation's path-restricted command
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* a
|
|
499
|
-
*
|
|
500
|
-
*
|
|
496
|
+
* · EVERY segment, under one of two readings. A segment whose PROGRAM is in `allow` — the deployment's
|
|
497
|
+
* own read-command table, the analogue of the reference implementation's path-restricted command
|
|
498
|
+
* list — is read with that verb's own slots (a pattern operand, a recursive form). The program is
|
|
499
|
+
* read after the two decorations the shell itself strips before choosing it (leading `NAME=value`
|
|
500
|
+
* assignments, a path prefix), so `/bin/cat` is judged as `cat`: recognizing readers by their literal
|
|
501
|
+
* spelling alone let a rule the persisted grammar admits (`Bash(/bin/cat:*)`) carry the exact read
|
|
502
|
+
* this gate exists for. Every OTHER segment is judged whole and verb-blind — its tokens are offered
|
|
503
|
+
* as path candidates under a head with no per-verb behaviour — because a program not on the table is
|
|
504
|
+
* not a program known to read nothing: skipping it made the gate silent for every reader the
|
|
505
|
+
* deployment had not enumerated, which is the one thing a name table cannot enumerate.
|
|
501
506
|
* · `cd` is judged whether or not the deployment listed it, because the walk MODELS it (it threads the
|
|
502
507
|
* base across segments) and dropping it would place every later relative operand against a base bash
|
|
503
508
|
* has already left. A move the walk cannot model (`pushd`/`popd`) is not silently tolerated either —
|
|
@@ -260,37 +260,365 @@ function tokensMoveBase(toks, allow) {
|
|
|
260
260
|
return unwrapped.kind === "refused" || (unwrapped.kind === "reader" && unwrapped.head === "cd");
|
|
261
261
|
}
|
|
262
262
|
const SHELL_REENTRY_PROGRAM_RE = /^(?:(?:ba|z|da|k|fi)?sh|eval|source|\.)$/;
|
|
263
|
-
export function refusedCommandMayRead(command, allow) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
263
|
+
export function refusedCommandMayRead(command, allow, boundary) {
|
|
264
|
+
return !refusedCommandExempt(command, allow, boundary, 0);
|
|
265
|
+
}
|
|
266
|
+
const STDIN_ONLY_READER_FLAGS = new Map([
|
|
267
|
+
["cat", /^-[AbeEnstTuv]+$/],
|
|
268
|
+
["wc", /^-[clmwL]+$/],
|
|
269
|
+
]);
|
|
270
|
+
const SUBSTITUTION_PLACEHOLDER = "\u0000subst";
|
|
271
|
+
function withoutHeredocBodies(text) {
|
|
272
|
+
const lines = text.split("\n");
|
|
273
|
+
const out = [];
|
|
274
|
+
const expandedBodies = [];
|
|
275
|
+
let terminator;
|
|
276
|
+
let expanded = false;
|
|
277
|
+
let body = [];
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
if (terminator !== undefined) {
|
|
280
|
+
if (line.trim() === terminator) {
|
|
281
|
+
if (expanded)
|
|
282
|
+
expandedBodies.push(body.join("\n"));
|
|
283
|
+
terminator = undefined;
|
|
284
|
+
body = [];
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
body.push(line);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
out.push(line);
|
|
291
|
+
const header = heredocHeaderOf(line);
|
|
292
|
+
if (header === null)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (header !== undefined) {
|
|
295
|
+
terminator = header.word;
|
|
296
|
+
expanded = !header.quoted;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return terminator === undefined ? { outer: out.join("\n"), expandedBodies } : undefined;
|
|
300
|
+
}
|
|
301
|
+
function heredocHeaderOf(line) {
|
|
302
|
+
let inSingle = false;
|
|
303
|
+
let inDouble = false;
|
|
304
|
+
for (let i = 0; i < line.length; i++) {
|
|
305
|
+
const ch = line[i];
|
|
306
|
+
if (ch === "\\" && !inSingle) {
|
|
307
|
+
i++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === "'" && !inDouble) {
|
|
311
|
+
inSingle = !inSingle;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (ch === '"' && !inSingle) {
|
|
315
|
+
inDouble = !inDouble;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (inSingle || inDouble)
|
|
319
|
+
continue;
|
|
320
|
+
if (ch === "#")
|
|
321
|
+
return undefined;
|
|
322
|
+
if (ch === "<" && line[i + 1] === "<") {
|
|
323
|
+
if (line[i + 2] === "<") {
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const m = /^<<-?\s*([^\s;|&<>()]+)/.exec(line.slice(i));
|
|
328
|
+
if (m === null)
|
|
329
|
+
return null;
|
|
330
|
+
const word = m[1];
|
|
331
|
+
if (/[$`]/.test(word))
|
|
332
|
+
return null;
|
|
333
|
+
const delimiter = word.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
334
|
+
if (delimiter === "")
|
|
335
|
+
return null;
|
|
336
|
+
return { word: delimiter, quoted: /["'\\]/.test(word) };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
function splitSubstitutions(text, quotesAreLiteral = false) {
|
|
342
|
+
let outer = "";
|
|
343
|
+
const bodies = [];
|
|
344
|
+
let i = 0;
|
|
345
|
+
let inSingle = false;
|
|
346
|
+
let inDouble = false;
|
|
347
|
+
while (i < text.length) {
|
|
348
|
+
const ch = text[i];
|
|
349
|
+
if (ch === "\\" && !inSingle) {
|
|
350
|
+
outer += text.slice(i, i + 2);
|
|
351
|
+
i += 2;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (ch === "'" && !inDouble && !quotesAreLiteral) {
|
|
355
|
+
inSingle = !inSingle;
|
|
356
|
+
outer += ch;
|
|
357
|
+
i++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (ch === '"' && !inSingle && !quotesAreLiteral) {
|
|
361
|
+
inDouble = !inDouble;
|
|
362
|
+
outer += ch;
|
|
363
|
+
i++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (inSingle) {
|
|
367
|
+
outer += ch;
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (ch === "`") {
|
|
372
|
+
const close = text.indexOf("`", i + 1);
|
|
373
|
+
if (close < 0)
|
|
374
|
+
return undefined;
|
|
375
|
+
bodies.push(text.slice(i + 1, close));
|
|
376
|
+
outer += SUBSTITUTION_PLACEHOLDER;
|
|
377
|
+
i = close + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if ((ch === "$" || ch === "<" || ch === ">") && text[i + 1] === "(") {
|
|
381
|
+
let depth = 0;
|
|
382
|
+
let j = i + 1;
|
|
383
|
+
let quoted;
|
|
384
|
+
for (; j < text.length; j++) {
|
|
385
|
+
const c = text[j];
|
|
386
|
+
if (quoted !== undefined) {
|
|
387
|
+
if (c === "\\" && quoted === '"')
|
|
388
|
+
j++;
|
|
389
|
+
else if (c === quoted)
|
|
390
|
+
quoted = undefined;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (c === "'" || c === '"') {
|
|
394
|
+
quoted = c;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (c === "(")
|
|
398
|
+
depth++;
|
|
399
|
+
else if (c === ")") {
|
|
400
|
+
depth--;
|
|
401
|
+
if (depth === 0)
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (j >= text.length)
|
|
406
|
+
return undefined;
|
|
407
|
+
bodies.push(text.slice(i + 2, j));
|
|
408
|
+
outer += SUBSTITUTION_PLACEHOLDER;
|
|
409
|
+
i = j + 1;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
outer += ch;
|
|
413
|
+
i++;
|
|
414
|
+
}
|
|
415
|
+
if (inSingle)
|
|
416
|
+
return undefined;
|
|
417
|
+
return { outer, bodies };
|
|
418
|
+
}
|
|
419
|
+
function refusedCommandExempt(command, allow, boundary, depth, baseMovedOutside = false) {
|
|
420
|
+
if (depth > 4)
|
|
421
|
+
return false;
|
|
422
|
+
const stripped = withoutHeredocBodies(command);
|
|
423
|
+
if (stripped === undefined)
|
|
424
|
+
return false;
|
|
425
|
+
if (!whitelistReadsTheSameWords(command, stripped.outer))
|
|
426
|
+
return false;
|
|
427
|
+
const split = splitSubstitutions(stripped.outer);
|
|
428
|
+
if (split === undefined)
|
|
429
|
+
return false;
|
|
430
|
+
const bodies = [...split.bodies];
|
|
431
|
+
for (const expandedBody of stripped.expandedBodies) {
|
|
432
|
+
const inner = splitSubstitutions(expandedBody, true);
|
|
433
|
+
if (inner === undefined)
|
|
434
|
+
return false;
|
|
435
|
+
bodies.push(...inner.bodies);
|
|
268
436
|
}
|
|
269
437
|
const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
|
|
270
438
|
const programOf = (w) => {
|
|
271
|
-
const inner = fold(w)
|
|
439
|
+
const inner = fold(w);
|
|
272
440
|
return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
|
|
273
441
|
};
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
442
|
+
const segments = split.outer.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/).map((segment) => {
|
|
443
|
+
const toks = [];
|
|
444
|
+
for (const word of segment.trim().split(/\s+/)) {
|
|
445
|
+
let rest = word;
|
|
446
|
+
while (rest !== "") {
|
|
447
|
+
const m = /^(\d*(?:<<<|<<|<|>>|>)(?:&\d*)?|&>>?)(.*)$/.exec(rest);
|
|
448
|
+
if (m !== null) {
|
|
449
|
+
toks.push(m[1].replace(/^\d+/, ""));
|
|
450
|
+
rest = m[2];
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const at = rest.search(/\d*(?:<<<|<<|<|>>|>)|&>/);
|
|
454
|
+
if (at <= 0) {
|
|
455
|
+
toks.push(rest);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
toks.push(rest.slice(0, at));
|
|
459
|
+
rest = rest.slice(at);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const commentAt = toks.findIndex((t) => t.startsWith("#"));
|
|
463
|
+
return (commentAt >= 0 ? toks.slice(0, commentAt) : toks)
|
|
464
|
+
.map((t) => (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(t) ? "{" : t.replace(/^[({]+|[)}]+$/g, "")))
|
|
465
|
+
.filter((t) => t !== "");
|
|
466
|
+
});
|
|
467
|
+
const headIndex = (toks) => {
|
|
468
|
+
let i = 0;
|
|
469
|
+
while (i < toks.length) {
|
|
470
|
+
const w = fold(toks[i]);
|
|
471
|
+
if (ASSIGNMENT_WORD.test(w) || SHELL_CONTROL_PREFIXES.has(w) || SHELL_BLOCK_WORDS.has(w) || /^[{}]$/.test(w)) {
|
|
472
|
+
i++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (/^(?:<<<|<<|<|>>?|&>>?)$/.test(w)) {
|
|
476
|
+
i += 2;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (/^(?:>>?|<)&\d*$/.test(w)) {
|
|
480
|
+
i++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
return i;
|
|
486
|
+
};
|
|
487
|
+
const runProgramOf = (toks, i) => {
|
|
488
|
+
let head = programOf(toks[i] ?? "");
|
|
489
|
+
let guard = 0;
|
|
490
|
+
while (COMMAND_LAUNCHERS.has(head) && guard++ < 8) {
|
|
491
|
+
let j = i + 1;
|
|
492
|
+
while (j < toks.length && (ASSIGNMENT_WORD.test(fold(toks[j])) || /^\d+[smhd]?$/.test(fold(toks[j]))))
|
|
493
|
+
j++;
|
|
494
|
+
const next = toks[j];
|
|
495
|
+
if (next === undefined || fold(next).startsWith("-"))
|
|
496
|
+
return undefined;
|
|
497
|
+
i = j;
|
|
498
|
+
head = programOf(next);
|
|
499
|
+
}
|
|
500
|
+
return head;
|
|
501
|
+
};
|
|
502
|
+
const baseMoved = baseMovedOutside ||
|
|
503
|
+
segments.some((toks) => {
|
|
504
|
+
const i = headIndex(toks);
|
|
505
|
+
if (i >= toks.length)
|
|
506
|
+
return false;
|
|
507
|
+
const head = runProgramOf(toks, i);
|
|
508
|
+
return head === undefined || head === "cd" || UNMODELLED_BASE_MOVERS.has(head);
|
|
509
|
+
});
|
|
510
|
+
for (const body of bodies) {
|
|
511
|
+
const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
|
|
512
|
+
const fullyGreen = !baseMoved && walked.reason === undefined && walked.readDenied !== true && walked.outOfRootRead !== true && walked.refusedWhole !== true &&
|
|
513
|
+
(walked.undecidedPaths?.length ?? 0) === 0 && (walked.unresolvedOperands?.length ?? 0) === 0 && (walked.recursiveReadPaths?.length ?? 0) === 0;
|
|
514
|
+
if (fullyGreen)
|
|
277
515
|
continue;
|
|
278
|
-
if ((allow
|
|
279
|
-
return
|
|
516
|
+
if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
|
|
517
|
+
return false;
|
|
280
518
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
519
|
+
const stdinFileJudgedInside = (raw) => {
|
|
520
|
+
if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || UNREADABLE_WHITELIST_WORD.test(raw))
|
|
521
|
+
return false;
|
|
522
|
+
if (boundary === undefined || baseMoved)
|
|
523
|
+
return false;
|
|
524
|
+
const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], fold(raw), boundary.homeDir);
|
|
525
|
+
if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
|
|
526
|
+
return false;
|
|
527
|
+
return withinAnyRoot(boundary.roots, resolved);
|
|
528
|
+
};
|
|
529
|
+
const unlistedSegmentJudgedInside = (toks, from) => {
|
|
530
|
+
if (boundary === undefined)
|
|
531
|
+
return true;
|
|
532
|
+
let endOfOptions = false;
|
|
533
|
+
for (let j = from; j < toks.length; j++) {
|
|
534
|
+
const t = toks[j];
|
|
535
|
+
if (t === "<<<" || t === "<<" || t === "<") {
|
|
536
|
+
j++;
|
|
285
537
|
continue;
|
|
286
|
-
|
|
538
|
+
}
|
|
539
|
+
if (/^(?:>>?|&>>?)$/.test(t)) {
|
|
540
|
+
j++;
|
|
287
541
|
continue;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
542
|
+
}
|
|
543
|
+
if (/^(?:>>?|<)&\d*$/.test(t))
|
|
544
|
+
continue;
|
|
545
|
+
const word = fold(t);
|
|
546
|
+
if (j > from && !endOfOptions && word === "--") {
|
|
547
|
+
endOfOptions = true;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (j === from && !isPathShapedToken(word)) {
|
|
551
|
+
if (UNREADABLE_WHITELIST_WORD.test(word) || word.includes(SUBSTITUTION_PLACEHOLDER))
|
|
552
|
+
return false;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const optionShaped = !endOfOptions && word.startsWith("-") && word !== "-";
|
|
556
|
+
const candidates = optionShaped ? [...attachedOptionPayloads(word), ...(isPathShapedToken(word) ? [word] : [])] : [word];
|
|
557
|
+
for (const candidate of candidates) {
|
|
558
|
+
if (!stdinFileJudgedInside(candidate))
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return true;
|
|
563
|
+
};
|
|
564
|
+
for (const toks of segments) {
|
|
565
|
+
for (let j = 0; j < toks.length; j++)
|
|
566
|
+
if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
|
|
567
|
+
return false;
|
|
568
|
+
let i = headIndex(toks);
|
|
569
|
+
if (i >= toks.length)
|
|
570
|
+
continue;
|
|
571
|
+
const headWord = fold(toks[i]);
|
|
572
|
+
if (headWord.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(headWord))
|
|
573
|
+
return false;
|
|
574
|
+
let head = programOf(headWord);
|
|
575
|
+
if (COMMAND_LAUNCHERS.has(head)) {
|
|
576
|
+
const wrapped = runProgramOf(toks, i);
|
|
577
|
+
if (wrapped === undefined || /[$`]/.test(wrapped) || SHELL_REENTRY_PROGRAM_RE.test(wrapped))
|
|
578
|
+
return false;
|
|
579
|
+
const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
|
|
580
|
+
if (unwrapped.kind === "refused")
|
|
581
|
+
return false;
|
|
582
|
+
if (unwrapped.kind === "unlisted") {
|
|
583
|
+
if (!unlistedSegmentJudgedInside(toks, i))
|
|
584
|
+
return false;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
head = unwrapped.head;
|
|
588
|
+
i += unwrapped.index;
|
|
589
|
+
}
|
|
590
|
+
if (SHELL_REENTRY_PROGRAM_RE.test(head))
|
|
591
|
+
return false;
|
|
592
|
+
if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
|
|
593
|
+
continue;
|
|
594
|
+
if (!allow.has(head)) {
|
|
595
|
+
if (!unlistedSegmentJudgedInside(toks, i))
|
|
596
|
+
return false;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (NO_PATH_OPERAND_COMMANDS.has(head))
|
|
600
|
+
continue;
|
|
601
|
+
const flags = STDIN_ONLY_READER_FLAGS.get(head);
|
|
602
|
+
if (flags === undefined)
|
|
603
|
+
return false;
|
|
604
|
+
for (let j = i + 1; j < toks.length; j++) {
|
|
605
|
+
const t = toks[j];
|
|
606
|
+
if (t === "<<<" || t === "<<" || t === "<") {
|
|
607
|
+
j++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (/^(?:>>?|&>>?)$/.test(t)) {
|
|
611
|
+
j++;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (/^(?:>>?|<)&\d*$/.test(t))
|
|
615
|
+
continue;
|
|
616
|
+
if (flags.test(fold(t)))
|
|
617
|
+
continue;
|
|
618
|
+
return false;
|
|
291
619
|
}
|
|
292
620
|
}
|
|
293
|
-
return
|
|
621
|
+
return true;
|
|
294
622
|
}
|
|
295
623
|
function unwrapLauncher(folded, allow) {
|
|
296
624
|
for (let i = 1; i < folded.length; i++) {
|
|
@@ -302,7 +630,7 @@ function unwrapLauncher(folded, allow) {
|
|
|
302
630
|
const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
|
|
303
631
|
if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
|
|
304
632
|
return { kind: "refused", reason: `"${folded[0]}" is given shell control syntax ("${head}") where the program it runs would be ${NOT_AUTO_ALLOWED}` };
|
|
305
|
-
return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted" };
|
|
633
|
+
return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
|
|
306
634
|
}
|
|
307
635
|
return { kind: "unlisted" };
|
|
308
636
|
}
|
|
@@ -911,10 +1239,15 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
911
1239
|
continue;
|
|
912
1240
|
}
|
|
913
1241
|
nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
|
|
1242
|
+
if (SHELL_REENTRY_PROGRAM_RE.test(name))
|
|
1243
|
+
return { reason: `command "${parsed.name}" runs its argument as shell text, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
|
|
914
1244
|
if (COMMAND_LAUNCHERS.has(name)) {
|
|
915
1245
|
const unwrapped = unwrapLauncher(folded.slice(from), allow);
|
|
916
1246
|
if (unwrapped.kind === "refused")
|
|
917
1247
|
return { reason: unwrapped.reason, refusedWhole: true };
|
|
1248
|
+
if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
|
|
1249
|
+
return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
|
|
1250
|
+
}
|
|
918
1251
|
if (unwrapped.kind === "unlisted")
|
|
919
1252
|
unlistedSegments.add(si);
|
|
920
1253
|
else
|
|
@@ -955,8 +1288,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
|
|
|
955
1288
|
const foldedSegments = [];
|
|
956
1289
|
let shapeRefusal = nameRefusal;
|
|
957
1290
|
for (let si = 0; si < segments.length; si++) {
|
|
958
|
-
if (unlistedSegments.has(si))
|
|
1291
|
+
if (unlistedSegments.has(si)) {
|
|
1292
|
+
const unlisted = tokenizeSegment(segments[si]);
|
|
1293
|
+
if (unlisted.folded.length === 0)
|
|
1294
|
+
continue;
|
|
1295
|
+
foldedSegments.push({ folded: [LAUNCHER_SCAN_HEAD, ...unlisted.folded], raw: [LAUNCHER_SCAN_HEAD, ...unlisted.raw] });
|
|
959
1296
|
continue;
|
|
1297
|
+
}
|
|
960
1298
|
const rehead = reheaded.get(si);
|
|
961
1299
|
const segmentTokens = rehead === undefined ? tokenizeSegment(segments[si]) : reheadSegment(tokenizeSegment(segments[si]), rehead.head, rehead.from);
|
|
962
1300
|
const toks = segmentTokens.folded;
|
|
@@ -1151,6 +1489,14 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
|
|
|
1151
1489
|
}
|
|
1152
1490
|
const NOT_GATED = { gated: false, paths: [] };
|
|
1153
1491
|
const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
1492
|
+
const UNREADABLE_WHITELIST_WORD = /[$`()*?[\]{}~]/;
|
|
1493
|
+
function whitelistReadsTheSameWords(command, outsideHeredocBodies) {
|
|
1494
|
+
if (/\\\r?\n/.test(command))
|
|
1495
|
+
return false;
|
|
1496
|
+
if (/\\[<>&|;()]/.test(outsideHeredocBodies))
|
|
1497
|
+
return false;
|
|
1498
|
+
return !(outsideHeredocBodies.includes("#") && /["']/.test(outsideHeredocBodies));
|
|
1499
|
+
}
|
|
1154
1500
|
const UNMODELLED_BASE_MOVERS = new Set(["pushd", "popd"]);
|
|
1155
1501
|
const COMMAND_LAUNCHERS = new Set([
|
|
1156
1502
|
"env", "command", "builtin", "exec", "noglob", "xargs",
|
|
@@ -1188,14 +1534,12 @@ export function classifyOutOfRootReadGate(command, allow, boundary) {
|
|
|
1188
1534
|
if (written === undefined)
|
|
1189
1535
|
continue;
|
|
1190
1536
|
const name = programBasename(written);
|
|
1191
|
-
if (!allow.has(name) && COMMAND_LAUNCHERS.has(name)) {
|
|
1192
|
-
readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
|
|
1193
|
-
continue;
|
|
1194
|
-
}
|
|
1195
1537
|
if (UNMODELLED_BASE_MOVERS.has(name))
|
|
1196
1538
|
baseMoved = true;
|
|
1197
|
-
if (!allow.has(name) && name !== "cd")
|
|
1539
|
+
if (!allow.has(name) && name !== "cd") {
|
|
1540
|
+
readers.push({ folded: [LAUNCHER_SCAN_HEAD, ...toks.folded.slice(i)], raw: [LAUNCHER_SCAN_HEAD, ...toks.raw.slice(i)] });
|
|
1198
1541
|
continue;
|
|
1542
|
+
}
|
|
1199
1543
|
readers.push(i === 0 && name === written ? toks : { folded: [name, ...toks.folded.slice(i + 1)], raw: [toks.raw[i] ?? name, ...toks.raw.slice(i + 1)] });
|
|
1200
1544
|
}
|
|
1201
1545
|
if (readers.length === 0)
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -86,7 +86,7 @@ function unjudgedRead(command, allowSet, resolved, verdict) {
|
|
|
86
86
|
return true;
|
|
87
87
|
if (verdict.refusedWhole !== true)
|
|
88
88
|
return false;
|
|
89
|
-
if (refusedCommandMayRead(command, allowSet))
|
|
89
|
+
if (refusedCommandMayRead(command, allowSet, resolved))
|
|
90
90
|
return true;
|
|
91
91
|
return resolved !== undefined && classifyOutOfRootReadGate(command, allowSet, resolved).gated;
|
|
92
92
|
}
|