@sema-agent/core 7.17.1 → 7.17.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.17.2 — 2026-09-13
4
+
5
+ ### 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)
6
+ - **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.
7
+ - **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.
8
+ - **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.)
9
+ - **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.
10
+ - **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.
11
+ - 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).
12
+
13
+ ### 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)
14
+ - **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).
15
+ - **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.
16
+ - Pins: `test/backlog696-read-boundary-any-doctrine.test.ts` (eight non-objection heredoc / here-string / in-root stdin forms; six mandated stdin forms).
17
+
3
18
  ## 7.17.1 — 2026-09-13
4
19
 
5
20
  ### 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
@@ -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 carries evidence that it may READ: a listed reader or a shell
393
- * re-entry program anywhere in it, a substitution where a program name stands (`$C /etc/passwd`), or a stdin
394
- * redirection from a non-literal target (`cat < $F`). The out-of-root and base-mover readings over its literal path
395
- * tokens are the containment gate's, asked separately. A refused command with none of these a write through a
396
- * redirection (`echo hi > out.txt`), a function definition names no read for the boundary to mandate: the syntax
397
- * refusal stays the classify seat's own plain ask. */
398
- export declare function refusedCommandMayRead(command: string, allow: ReadonlySet<string>): boolean;
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
@@ -260,37 +260,320 @@ 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
- for (const m of command.matchAll(/(?<![<>&\d])<(?![<&])\s*(\S*)/g)) {
265
- const target = (m[1] ?? "").replace(/^["']+|["']+$/g, "");
266
- if (target === "" || /^[$`(~]/.test(target))
267
- return true;
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
+ const split = splitSubstitutions(stripped.outer);
426
+ if (split === undefined)
427
+ return false;
428
+ const bodies = [...split.bodies];
429
+ for (const expandedBody of stripped.expandedBodies) {
430
+ const inner = splitSubstitutions(expandedBody, true);
431
+ if (inner === undefined)
432
+ return false;
433
+ bodies.push(...inner.bodies);
268
434
  }
269
435
  const fold = (w) => w.replace(/\\(.)/g, "$1").replace(/["']/g, "");
270
436
  const programOf = (w) => {
271
- const inner = fold(w).replace(/^[$`(]+|[)`]+$/g, "");
437
+ const inner = fold(w);
272
438
  return inner.includes("/") ? inner.slice(inner.lastIndexOf("/") + 1) : inner;
273
439
  };
274
- for (const word of command.split(/[\s<>|;&()]+/)) {
275
- const base = programOf(word);
276
- if (base === "")
440
+ const segments = split.outer.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/).map((segment) => {
441
+ const toks = [];
442
+ for (const word of segment.trim().split(/\s+/)) {
443
+ let rest = word;
444
+ while (rest !== "") {
445
+ const m = /^(\d*(?:<<<|<<|<|>>|>)(?:&\d*)?|&>>?)(.*)$/.exec(rest);
446
+ if (m !== null) {
447
+ toks.push(m[1].replace(/^\d+/, ""));
448
+ rest = m[2];
449
+ continue;
450
+ }
451
+ const at = rest.search(/\d*(?:<<<|<<|<|>>|>)|&>/);
452
+ if (at <= 0) {
453
+ toks.push(rest);
454
+ break;
455
+ }
456
+ toks.push(rest.slice(0, at));
457
+ rest = rest.slice(at);
458
+ }
459
+ }
460
+ const commentAt = toks.findIndex((t) => t.startsWith("#"));
461
+ return (commentAt >= 0 ? toks.slice(0, commentAt) : toks)
462
+ .map((t) => (/^[A-Za-z_][A-Za-z0-9_]*\(\)$/.test(t) ? "{" : t.replace(/^[({]+|[)}]+$/g, "")))
463
+ .filter((t) => t !== "");
464
+ });
465
+ const headIndex = (toks) => {
466
+ let i = 0;
467
+ while (i < toks.length) {
468
+ const w = fold(toks[i]);
469
+ if (ASSIGNMENT_WORD.test(w) || SHELL_CONTROL_PREFIXES.has(w) || SHELL_BLOCK_WORDS.has(w) || /^[{}]$/.test(w)) {
470
+ i++;
471
+ continue;
472
+ }
473
+ if (/^(?:<<<|<<|<|>>?|&>>?)$/.test(w)) {
474
+ i += 2;
475
+ continue;
476
+ }
477
+ if (/^(?:>>?|<)&\d*$/.test(w)) {
478
+ i++;
479
+ continue;
480
+ }
481
+ break;
482
+ }
483
+ return i;
484
+ };
485
+ const runProgramOf = (toks, i) => {
486
+ let head = programOf(toks[i] ?? "");
487
+ let guard = 0;
488
+ while (COMMAND_LAUNCHERS.has(head) && guard++ < 8) {
489
+ let j = i + 1;
490
+ while (j < toks.length && (ASSIGNMENT_WORD.test(fold(toks[j])) || /^\d+[smhd]?$/.test(fold(toks[j]))))
491
+ j++;
492
+ const next = toks[j];
493
+ if (next === undefined || fold(next).startsWith("-"))
494
+ return undefined;
495
+ i = j;
496
+ head = programOf(next);
497
+ }
498
+ return head;
499
+ };
500
+ const baseMoved = baseMovedOutside ||
501
+ segments.some((toks) => {
502
+ const i = headIndex(toks);
503
+ if (i >= toks.length)
504
+ return false;
505
+ const head = runProgramOf(toks, i);
506
+ return head === undefined || head === "cd" || UNMODELLED_BASE_MOVERS.has(head);
507
+ });
508
+ for (const body of bodies) {
509
+ const walked = classifyCompoundReadonlyDetailed(body, allow, boundary);
510
+ const fullyGreen = !baseMoved && walked.reason === undefined && walked.readDenied !== true && walked.outOfRootRead !== true && walked.refusedWhole !== true &&
511
+ (walked.undecidedPaths?.length ?? 0) === 0 && (walked.unresolvedOperands?.length ?? 0) === 0 && (walked.recursiveReadPaths?.length ?? 0) === 0;
512
+ if (fullyGreen)
277
513
  continue;
278
- if ((allow.has(base) && !NO_PATH_OPERAND_COMMANDS.has(base)) || SHELL_REENTRY_PROGRAM_RE.test(base))
279
- return true;
514
+ if (!refusedCommandExempt(body, allow, boundary, depth + 1, baseMoved))
515
+ return false;
280
516
  }
281
- for (const segment of command.split(/\n|;|&&|\|\||\||(?<![>&\d])&(?![>&])/)) {
282
- for (const raw of segment.trim().split(/\s+/)) {
283
- const word = fold(raw);
284
- if (word === "")
517
+ const stdinFileJudgedInside = (raw) => {
518
+ if (raw === undefined || raw === "" || raw.includes(SUBSTITUTION_PLACEHOLDER) || /[$`()*?[\]{}~]/.test(raw))
519
+ return false;
520
+ if (boundary === undefined || baseMoved)
521
+ return false;
522
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], fold(raw), boundary.homeDir);
523
+ if (resolved === undefined || boundary.denyMatch?.(resolved) != null)
524
+ return false;
525
+ return withinAnyRoot(boundary.roots, resolved);
526
+ };
527
+ for (const toks of segments) {
528
+ for (let j = 0; j < toks.length; j++)
529
+ if (toks[j] === "<" && !stdinFileJudgedInside(toks[j + 1]))
530
+ return false;
531
+ let i = headIndex(toks);
532
+ if (i >= toks.length)
533
+ continue;
534
+ const headWord = fold(toks[i]);
535
+ if (headWord.includes(SUBSTITUTION_PLACEHOLDER) || /[$`]/.test(headWord))
536
+ return false;
537
+ let head = programOf(headWord);
538
+ if (COMMAND_LAUNCHERS.has(head)) {
539
+ const wrapped = runProgramOf(toks, i);
540
+ if (wrapped === undefined || /[$`]/.test(wrapped) || SHELL_REENTRY_PROGRAM_RE.test(wrapped))
541
+ return false;
542
+ const unwrapped = unwrapLauncher(toks.slice(i).map(fold), allow);
543
+ if (unwrapped.kind === "refused")
544
+ return false;
545
+ if (unwrapped.kind === "unlisted")
546
+ continue;
547
+ head = unwrapped.head;
548
+ i += unwrapped.index;
549
+ }
550
+ if (SHELL_REENTRY_PROGRAM_RE.test(head))
551
+ return false;
552
+ if (head === "cd" || UNMODELLED_BASE_MOVERS.has(head))
553
+ continue;
554
+ if (!allow.has(head) || NO_PATH_OPERAND_COMMANDS.has(head))
555
+ continue;
556
+ const flags = STDIN_ONLY_READER_FLAGS.get(head);
557
+ if (flags === undefined)
558
+ return false;
559
+ for (let j = i + 1; j < toks.length; j++) {
560
+ const t = toks[j];
561
+ if (t === "<<<" || t === "<<" || t === "<") {
562
+ j++;
285
563
  continue;
286
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || SHELL_CONTROL_PREFIXES.has(word) || SHELL_BLOCK_WORDS.has(word) || word === "{" || word === "}" || COMMAND_LAUNCHERS.has(programOf(word)) || word.startsWith("-"))
564
+ }
565
+ if (/^(?:>>?|&>>?)$/.test(t)) {
566
+ j++;
287
567
  continue;
288
- if (/^[$`(]/.test(word))
289
- return true;
290
- break;
568
+ }
569
+ if (/^(?:>>?|<)&\d*$/.test(t))
570
+ continue;
571
+ if (flags.test(fold(t)))
572
+ continue;
573
+ return false;
291
574
  }
292
575
  }
293
- return false;
576
+ return true;
294
577
  }
295
578
  function unwrapLauncher(folded, allow) {
296
579
  for (let i = 1; i < folded.length; i++) {
@@ -302,7 +585,7 @@ function unwrapLauncher(folded, allow) {
302
585
  const head = t.includes("/") ? t.slice(t.lastIndexOf("/") + 1) : t;
303
586
  if (SHELL_CONTROL_PREFIXES.has(head) || SHELL_BLOCK_WORDS.has(head))
304
587
  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" };
588
+ return allow.has(head) || head === "cd" ? { kind: "reader", index: i, head } : { kind: "unlisted", head };
306
589
  }
307
590
  return { kind: "unlisted" };
308
591
  }
@@ -911,10 +1194,15 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary, opts)
911
1194
  continue;
912
1195
  }
913
1196
  nameRefusal ??= `command "${parsed.name}" is not in the read-only allowlist`;
1197
+ if (SHELL_REENTRY_PROGRAM_RE.test(name))
1198
+ return { reason: `command "${parsed.name}" runs its argument as shell text, which this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
914
1199
  if (COMMAND_LAUNCHERS.has(name)) {
915
1200
  const unwrapped = unwrapLauncher(folded.slice(from), allow);
916
1201
  if (unwrapped.kind === "refused")
917
1202
  return { reason: unwrapped.reason, refusedWhole: true };
1203
+ if (unwrapped.kind === "unlisted" && unwrapped.head !== undefined && SHELL_REENTRY_PROGRAM_RE.test(unwrapped.head)) {
1204
+ return { reason: `"${written}" runs "${unwrapped.head}", which runs its argument as shell text this walk does not judge ${NOT_AUTO_ALLOWED}`, refusedWhole: true };
1205
+ }
918
1206
  if (unwrapped.kind === "unlisted")
919
1207
  unlistedSegments.add(si);
920
1208
  else
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.17.1",
3
+ "version": "7.17.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",