@sema-agent/core 7.14.0 → 7.15.0

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/core/ask-origin.d.ts +2 -2
  3. package/dist/core/checkpoint-store.d.ts +14 -15
  4. package/dist/core/hooks.d.ts +4 -3
  5. package/dist/core/runner/contracts.d.ts +2 -2
  6. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  7. package/dist/core/runner/permission-rule-lanes.js +1 -1
  8. package/dist/core/runner/prepare-defer-classify.d.ts +1 -1
  9. package/dist/core/runner/prepare-defer-classify.js +12 -10
  10. package/dist/core/runner/prepare-gate-stations.d.ts +3 -4
  11. package/dist/core/runner/prepare-gate-stations.js +2 -2
  12. package/dist/core/runner/prepare-hands-readface.d.ts +8 -8
  13. package/dist/core/runner/prepare-hands-readface.js +27 -29
  14. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -2
  15. package/dist/core/runner/prepare-park-ask.d.ts +5 -6
  16. package/dist/core/runner/prepare-park-ask.js +3 -3
  17. package/dist/core/runner/prepare-policy-chain.d.ts +2 -2
  18. package/dist/core/runner/prepare-policy-chain.js +2 -2
  19. package/dist/core/runner/prepare-task.js +5 -7
  20. package/dist/core/runner/tool-defer-gate.d.ts +86 -0
  21. package/dist/core/runner/tool-defer-gate.js +57 -0
  22. package/dist/core/runner/tool-disclosure.d.ts +0 -36
  23. package/dist/core/runner/tool-disclosure.js +0 -43
  24. package/dist/core/runner-deps.d.ts +16 -9
  25. package/dist/core/task-spec.d.ts +8 -2
  26. package/dist/core/tool-spec.d.ts +7 -4
  27. package/dist/core/types.d.ts +1 -1
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.js +1 -1
  30. package/dist/tools/fs/fs-bash.d.ts +41 -0
  31. package/dist/tools/fs/fs-bash.js +86 -29
  32. package/package.json +1 -1
  33. package/test/export-surface.snapshot.json +5 -1
@@ -265,8 +265,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
265
265
  announceToolModelGate(deps.onNotice, model.id, doors.modelGate);
266
266
  const modelGateManifest = toolModelGateManifest(doors.modelGate);
267
267
  const { toolEffects, egressTools, irreversibleTools, irreversibilityTier, axisExplicitNegatives, reversibilityProbes, ownToolNames } = prepareSafetyScan({ spec, deps });
268
- let shellGatedBash = false;
269
- let shellGatedMonitor = false;
268
+ let shellGatedTools = new Set();
270
269
  refuseRequireExistingWithoutSession(spec);
271
270
  const { acquired, session, conflictRef, wakeRecovered, resumeAtBeforeParentId } = await prepareAcquireReconcile({ sessions, spec, resume, toolEffects, ...(internals?.sessionPlacement !== undefined ? { placement: internals.sessionPlacement } : {}), ...(() => { const g = durableParkGapFor(deps, spec); return g !== undefined ? { durableParkGap: g } : {}; })() });
272
271
  const sessionId = acquired.sessionId;
@@ -512,8 +511,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
512
511
  resolvedReadFace = handsReadFace.resolvedReadFace;
513
512
  readDenyAdditionsNormalized = handsReadFace.readDenyAdditionsNormalized;
514
513
  handsLessResolvedFace = handsReadFace.handsLessResolvedFace;
515
- shellGatedBash = handsReadFace.shellGatedBash;
516
- shellGatedMonitor = handsReadFace.shellGatedMonitor;
514
+ shellGatedTools = handsReadFace.shellGatedTools;
517
515
  const { worktreeSessionRef } = prepareDelegationSurface({ spec, deps, internals, resume, sessionId, hostTaskId, taskScope, taskRootFinal, executionEnv, runnerSelf, backgroundTaskToolsActive, workflowToolsActive, peerLaneActive, peerSendMessageBuiltIn, peerLaneRefs, roster, toolEffects, axisExplicitNegatives, envHandToolNames, toolFaceSnapshot, firstPartyOffload, offloadStore, subagentRetain, enrichSpecToolCtx, handsCwdRef, wsSnapshot, rebaseWsPath, rebaseRestoredPath, workspaceStateSettle });
518
516
  const { liveQuestionFace, contentAskBindings, contentAskRoutable, lateStrandedAnswers, discloseStrandedAnswers, settleContentAskBindings, durableQuestionFace, questionToolMounted, mountedQuestionTool } = prepareQuestionFace({ frozenOnQuestion, spec, deps, checkpointStore, runtimeCaps, resolvedInteractionPosture, resume, sessionId, roster, inheritedUnavailableAsks: () => inheritedUnavailableAsks });
519
517
  if (spec.handsReadOnly !== true) {
@@ -683,7 +681,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
683
681
  releaseSignal = () => sig.removeEventListener("abort", onExternalAbort);
684
682
  }
685
683
  }
686
- const { hooks, hookTimeoutMs, preToolUseObservational, ownGatePreToolUse, basePolicyForResumeEdit, denyNarrowingPolicy, policyLayers, effectivePolicy, inheritedUnavailableAsks, inheritedAskGrants, askSourceIdentity, riskAxesOf, ruleOffersOf, permissionRuleLane, permissionRuleOrgLane, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, foldAskClasses, ancestorSandboxAdmissions } = await preparePolicyChain({ spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, ownToolNames, shellGatedBash, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef });
684
+ const { hooks, hookTimeoutMs, preToolUseObservational, ownGatePreToolUse, basePolicyForResumeEdit, denyNarrowingPolicy, policyLayers, effectivePolicy, inheritedUnavailableAsks, inheritedAskGrants, askSourceIdentity, riskAxesOf, ruleOffersOf, permissionRuleLane, permissionRuleOrgLane, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, foldAskClasses, ancestorSandboxAdmissions } = await preparePolicyChain({ spec, deps, internals, sessionId, runId, hostTaskId, taskRootFinal, executionEnv, lockedPreflight, tools, harnessTools, toolEffects, egressTools, irreversibilityTier, axisExplicitNegatives, ownToolNames, shellGatedTools, handsCwdRef, readDenyMatcher, questionToolMounted, runtimeCaps, inheritedAncestorRules, inheritedParentConstraints, seedInheritedGate, hasSkillManifest, skillScope, frozenOnAsk, hookEnvSource, notifyOwnHookCrash, autoModeDecider, stopForDenialLimit, delegation, abortController, now, ownSessionRulesRef });
687
685
  const { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, durableApproval, wiringManifest, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace } = prepareWiringManifest({ spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, peerLaneActive, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster });
688
686
  if (fileHistoryBoundary !== undefined) {
689
687
  harness.on("tool_call", async () => {
@@ -694,8 +692,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
694
692
  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 });
695
693
  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 });
696
694
  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 });
697
- 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, shellGatedBash, shellGatedMonitor, effectiveShellGate, durableApproval, priorLedger, liveSpendRef, resourceTotal, faceCheckpointState, f012CheckpointState, orgAdmissionCheckpointState, ruleOffersOf, now, humanReviewRef, abortController, harness, pausedRef });
698
- 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, shellGatedBash, shellGatedMonitor, autoModeDecider, autoModeDenialTracking, stopForDenialLimit, permissionRuleLane, permissionRuleOrgLane, questionToolMounted, sandboxAdmissionArmed, sandboxBoundaryCapable, emitSandboxAdmitted, delegation, notifyOwnHookCrash });
695
+ 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
+ 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 });
699
697
  const { microCompact, charsPerToken } = prepareContextLane({ model, deps, sessionId, runId, hostTaskId, offloadStore, microCompactKnob, harness, batchHaltRef, gitStatusRef, requestLossyRef, trimPressureRef });
700
698
  const preparedHolder = {};
701
699
  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 });
@@ -0,0 +1,86 @@
1
+ import type { Model } from "../../internal/llm.js";
2
+ import type { ToolSpec } from "../types.js";
3
+ import type { ToolFingerprintInput } from "../cache-break-detector.js";
4
+ /**
5
+ * The deferral GATE (design/36): which mounted tool names ship as placeholders on a request. Pure
6
+ * registry logic over names and sizes — no harness, no I/O. `tool-disclosure.ts` owns the placeholder
7
+ * and ToolSearch mechanics that act on the set this module decides; `prepare-defer-classify.ts` is the
8
+ * phase that assembles the inputs from the mounted face.
9
+ */
10
+ /** The deferral gate's threshold: the engine's candidate set is deferred when its inlined size reaches
11
+ * this fraction of the model's context window (that set measured, never the rest of the face). */
12
+ export declare const DEFER_THRESHOLD_FRACTION = 0.1;
13
+ /** The typed refusal a `deferMode` outside its declared domain (`"auto"` or absent) raises at the classifier —
14
+ * a bad value never selects an arm silently. */
15
+ export declare const DEFER_MODE_INVALID_CODE = "config.defer_mode_invalid";
16
+ /** The deferral gate's verdict — the two deferral lists, what the gate measured, and whether it opened. */
17
+ export interface DeferralGate {
18
+ /** The caller's LITERAL list: `TaskSpec.deferTools` names mounted on this face, minus the inline pins.
19
+ * Deferred on every request, whatever the gate says — an intentional deferral is not a size question. */
20
+ explicit: Set<string>;
21
+ /** The engine's SELF-SELECTED candidates: `ToolSpec.defer === true`, every remote-protocol name, built-ins
22
+ * that declare their own `defer`, and — under `deferMode: "auto"` — every caller spec; minus the inline
23
+ * pins and minus `explicit`. Deferred iff the gate opens. */
24
+ gated: Set<string>;
25
+ /** Σ inlined chars (name + description + serialized schema) over `gated` — the bytes the gate could reclaim. */
26
+ gatedChars: number;
27
+ /** `floor(contextTokens × fraction) × CHARS_PER_TOKEN`; the char reading of the token threshold. */
28
+ thresholdChars: number;
29
+ /** Whether `gated` is deferred on this request: `gatedChars ≥ thresholdChars`. */
30
+ open: boolean;
31
+ }
32
+ /**
33
+ * Deferral has TWO rules, and only two.
34
+ *
35
+ * ① THE CALLER'S LIST IS LITERAL. `TaskSpec.deferTools` (arriving as `callerDeferNames`, pre-filtered to
36
+ * mounted wire names, built-ins included) is deferred on every request; `alwaysLoad` (`ToolSpec.alwaysLoad`,
37
+ * `alwaysLoadNames`) is inlined on every request and is judged FIRST — a pinned name is in neither list
38
+ * and is never measured. Neither depends on the gate: they are the deployment's stated intent.
39
+ *
40
+ * ② THE ENGINE'S OWN CANDIDATES SIT BEHIND ONE THRESHOLD GATE. The candidates are declared: explicit
41
+ * `ToolSpec.defer === true` on a caller spec, every REMOTE-PROTOCOL tool name (MCP servers AND A2A peers —
42
+ * the caller unions them), and `builtinDeferNames` (mounted BUILT-INS that declare their own `defer`; a
43
+ * built-in ToolSpec never reaches `specs`, so its declaration arrives as a mounted name). `deferMode:
44
+ * "auto"` WIDENS the candidate set to every caller spec — undeclared caller tools included — and nothing
45
+ * else: it is not a second gate, and engine built-ins the deployment did not name are candidates under no
46
+ * setting. The gate decides the set all or nothing: deferred iff `gatedChars ≥ thresholdChars`, where the
47
+ * threshold is {@link DEFER_THRESHOLD_FRACTION} of the model's context window (`contextTokens ??
48
+ * contextWindow`, in chars at {@link CHARS_PER_TOKEN}). The measure is the candidate set ONLY — the bytes
49
+ * the gate could reclaim — never the caller's list, the pins, or non-candidates: a request whose weight
50
+ * sits elsewhere gains nothing from a ToolSearch round-trip, so nothing about it argues for one. A window
51
+ * of zero reads as a zero threshold (every candidate byte is over budget), so the candidates defer.
52
+ *
53
+ * Invariants: `explicit ∩ gated = ∅`; `deferred = explicit ∪ (open ? gated : ∅)`; a pinned name is in no
54
+ * list; the same inputs always yield the same verdict (a pure function of the request).
55
+ */
56
+ export declare function deferralGate(opts: {
57
+ specs: ReadonlyArray<ToolSpec>;
58
+ /** Every namespaced tool a remote protocol materialized for this task (MCP servers AND A2A peers —
59
+ * the caller unions them). The remote-roster shape (many tools, schemas the deployment does not
60
+ * control, drift between tasks) is the same for every protocol, so every protocol's names are
61
+ * candidates by being remote. Only an inline pin (or the caller's own list) takes a name out. */
62
+ protocolToolNames: ReadonlyArray<string>;
63
+ /** The MOUNTED face — the tools whose full definitions the request would carry. The measure reads
64
+ * each candidate's inlined size from here; a candidate absent from it weighs nothing. */
65
+ fullTools: ReadonlyArray<ToolFingerprintInput>;
66
+ /** `"auto"` widens the candidate set to every caller spec (see above). Any other value refuses. */
67
+ deferMode?: "auto";
68
+ model: Model;
69
+ /** Rule ① — `TaskSpec.deferTools`: ALREADY-MOUNTED wire names (built-ins included; `ToolSpec.defer`
70
+ * only reaches caller specs) the caller defers on every request. Caller pre-filters to mounted names. */
71
+ callerDeferNames?: ReadonlyArray<string>;
72
+ /** Rule ② — mounted BUILT-INS that declare their own `defer` (the engine's declaration, not the
73
+ * caller's), so they are candidates. Caller pre-filters to mounted names. */
74
+ builtinDeferNames?: ReadonlyArray<string>;
75
+ /** Rule ① — inline-pin names (`TaskSpec.alwaysLoadTools` + the MCP tools whose server declared
76
+ * `_meta["anthropic/alwaysLoad"]`, caller-collected; A2A has no such per-skill declaration, so the
77
+ * task-level list is a peer's only inline-keep channel): exempt from BOTH lists.
78
+ * `ToolSpec.alwaysLoad` rides the specs directly and needs no entry here. */
79
+ alwaysLoadNames?: ReadonlyArray<string>;
80
+ }): DeferralGate;
81
+ /**
82
+ * Decide which tool names are deferred: the caller's literal list, plus the gated candidates when the gate
83
+ * opens. THIS CONTRACT (with {@link deferralGate}) IS THE SINGLE AUTHORITY on the deferral sources and the
84
+ * gate; the `TaskSpec` / `RunnerDeps` JSDoc points here.
85
+ */
86
+ export declare function classifyDeferred(opts: Parameters<typeof deferralGate>[0]): Set<string>;
@@ -0,0 +1,57 @@
1
+ import { DEFAULT_CHARS_PER_TOKEN } from "../../internal/harness.js";
2
+ export const DEFER_THRESHOLD_FRACTION = 0.1;
3
+ const CHARS_PER_TOKEN = DEFAULT_CHARS_PER_TOKEN;
4
+ function inlinedChars(t) {
5
+ let schema = "";
6
+ try {
7
+ schema = JSON.stringify(t.parameters) ?? "";
8
+ }
9
+ catch {
10
+ schema = "";
11
+ }
12
+ return t.name.length + t.description.length + schema.length;
13
+ }
14
+ export const DEFER_MODE_INVALID_CODE = "config.defer_mode_invalid";
15
+ export function deferralGate(opts) {
16
+ if (opts.deferMode !== undefined && opts.deferMode !== "auto") {
17
+ const e = new Error(`RunnerDeps.deferMode: invalid value ${JSON.stringify(opts.deferMode)} — expected "auto" or omitted; a bad value refuses loudly, nothing falls back to a default.`);
18
+ e.code = DEFER_MODE_INVALID_CODE;
19
+ throw e;
20
+ }
21
+ const pinned = new Set(opts.alwaysLoadNames ?? []);
22
+ for (const s of opts.specs) {
23
+ if (s.alwaysLoad === true)
24
+ pinned.add(s.name);
25
+ }
26
+ const explicit = new Set();
27
+ for (const name of opts.callerDeferNames ?? [])
28
+ if (!pinned.has(name))
29
+ explicit.add(name);
30
+ const gated = new Set();
31
+ const select = (name) => {
32
+ if (!pinned.has(name) && !explicit.has(name))
33
+ gated.add(name);
34
+ };
35
+ for (const s of opts.specs)
36
+ if (s.defer === true || opts.deferMode === "auto")
37
+ select(s.name);
38
+ for (const name of opts.protocolToolNames)
39
+ select(name);
40
+ for (const name of opts.builtinDeferNames ?? [])
41
+ select(name);
42
+ let gatedChars = 0;
43
+ for (const t of opts.fullTools)
44
+ if (gated.has(t.name))
45
+ gatedChars += inlinedChars(t);
46
+ const windowTokens = Math.max(0, opts.model.contextTokens ?? opts.model.contextWindow ?? 0);
47
+ const thresholdChars = Math.floor(windowTokens * DEFER_THRESHOLD_FRACTION) * CHARS_PER_TOKEN;
48
+ return { explicit, gated, gatedChars, thresholdChars, open: gatedChars >= thresholdChars };
49
+ }
50
+ export function classifyDeferred(opts) {
51
+ const gate = deferralGate(opts);
52
+ const deferred = new Set(gate.explicit);
53
+ if (gate.open)
54
+ for (const n of gate.gated)
55
+ deferred.add(n);
56
+ return deferred;
57
+ }
@@ -1,8 +1,5 @@
1
1
  import { type TSchema } from "typebox";
2
2
  import type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode } from "../../internal/harness-types.js";
3
- import type { Model } from "../../internal/llm.js";
4
- import type { ToolSpec } from "../types.js";
5
- import type { ToolFingerprintInput } from "../cache-break-detector.js";
6
3
  /**
7
4
  * Deferred-tool dynamic disclosure (design/36). A deployment with hundreds of tools / several MCP
8
5
  * servers can't inline every full JSON Schema into every request — turn-1 cache-creation tokens
@@ -56,39 +53,6 @@ export declare function deferHint(description: string, max?: number): string;
56
53
  * announcement, anywhere. A bare interpolation is a prompt-injection hole (council BUG, design/36).
57
54
  */
58
55
  export declare function safeName(name: string): string;
59
- /**
60
- * Decide which tool names are deferred. THIS CONTRACT IS THE SINGLE AUTHORITY on the default-mode
61
- * source list (TaskSpec/RunnerDeps JSDoc points here), so every arm the body implements is named:
62
- * deterministic by default = explicit `ToolSpec.defer === true`, plus every REMOTE-PROTOCOL tool name
63
- * (MCP servers AND A2A peers — most numerous / most drift-prone), plus `deferNames`, i.e. the
64
- * already-mounted wire names from `TaskSpec.deferTools` and from BUILT-INS that declare their own
65
- * `defer` (a built-in ToolSpec never reaches `specs`, so its declared deferral has to arrive here as a
66
- * mounted name — this is why the shared-memory pair defaults to deferred). With `deferMode: "auto"`, ADDITIONALLY
67
- * defer the remaining user tools when their combined inlined schema would exceed ~10% of the model's
68
- * context window — an explicit opt-in (council minor #6), because an implicit threshold makes the same
69
- * TaskSpec yield different tools[] across models/contexts. Explicit `defer` always wins — except
70
- * against an alwaysLoad pin (RB-400-a), which exempts a name from every deferral source.
71
- */
72
- export declare function classifyDeferred(opts: {
73
- specs: ReadonlyArray<ToolSpec>;
74
- /** Every namespaced tool a remote protocol materialized for this task (MCP servers AND A2A peers —
75
- * the caller unions them). The constant-defer arm is about the SHAPE of a remote roster (many tools,
76
- * schemas the deployment does not control, drift between tasks), which is the same for every protocol
77
- * in the table: naming one protocol here left the next one inlined by omission, with nobody deciding
78
- * that. Only an alwaysLoad pin exempts a name. */
79
- protocolToolNames: ReadonlyArray<string>;
80
- fullTools: ReadonlyArray<ToolFingerprintInput>;
81
- deferMode?: "auto";
82
- model: Model;
83
- /** TaskSpec.deferTools — defer ALREADY-MOUNTED tools by wire name (built-ins included;
84
- * `ToolSpec.defer` only reaches caller specs). Caller pre-filters to mounted names. */
85
- deferNames?: ReadonlyArray<string>;
86
- /** RB-400-a — inline-pin names (`TaskSpec.alwaysLoadTools` + the MCP tools whose server declared
87
- * `_meta["anthropic/alwaysLoad"]`, caller-collected; A2A has no such per-skill declaration, so the
88
- * task-level list is a peer's only inline-keep channel): exempt from EVERY deferral source below.
89
- * `ToolSpec.alwaysLoad` rides the specs directly and needs no entry here. */
90
- alwaysLoadNames?: ReadonlyArray<string>;
91
- }): Set<string>;
92
56
  /** Build the registry the search ranks over (deferred tools only — non-deferred are already visible). */
93
57
  export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, tools: ReadonlyArray<{
94
58
  name: string;
@@ -2,11 +2,8 @@ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { defineTool, errorResult } from "../tools.js";
4
4
  import { formatZodValidationError, truncateError } from "../tool-errors.js";
5
- import { DEFAULT_CHARS_PER_TOKEN } from "../../internal/harness.js";
6
5
  import { toolFace } from "../tool-catalog-entries.js";
7
6
  export const TOOL_SEARCH_NAME = "ToolSearch";
8
- const DEFER_AUTO_FRACTION = 0.1;
9
- const CHARS_PER_TOKEN = DEFAULT_CHARS_PER_TOKEN;
10
7
  export const TOOL_SEARCH_DEFAULT_MAX_RESULTS = 5;
11
8
  const MAX_QUERY_RESULTS = 25;
12
9
  export const DEFERRED_NO_PROGRESS_LIMIT = 3;
@@ -19,46 +16,6 @@ export function deferHint(description, max = 120) {
19
16
  export function safeName(name) {
20
17
  return name.replace(/[\x00-\x1f<>"`]/g, "");
21
18
  }
22
- function inlinedChars(t) {
23
- let schema = "";
24
- try {
25
- schema = JSON.stringify(t.parameters) ?? "";
26
- }
27
- catch {
28
- schema = "";
29
- }
30
- return t.description.length + schema.length;
31
- }
32
- export function classifyDeferred(opts) {
33
- const pinned = new Set(opts.alwaysLoadNames ?? []);
34
- for (const s of opts.specs) {
35
- if (s.alwaysLoad === true)
36
- pinned.add(s.name);
37
- }
38
- const deferred = new Set();
39
- for (const s of opts.specs) {
40
- if (s.defer === true && !pinned.has(s.name))
41
- deferred.add(s.name);
42
- }
43
- for (const name of opts.protocolToolNames)
44
- if (!pinned.has(name))
45
- deferred.add(name);
46
- for (const name of opts.deferNames ?? [])
47
- if (!pinned.has(name))
48
- deferred.add(name);
49
- if (opts.deferMode === "auto") {
50
- const callerNames = new Set(opts.specs.map((s) => s.name));
51
- const candidates = opts.fullTools.filter((t) => !deferred.has(t.name) && !pinned.has(t.name) && callerNames.has(t.name));
52
- const inlineFace = opts.fullTools.filter((t) => !deferred.has(t.name));
53
- const total = inlineFace.reduce((n, t) => n + inlinedChars(t), 0);
54
- const window = (opts.model.contextTokens ?? opts.model.contextWindow ?? 0) * CHARS_PER_TOKEN;
55
- if (window > 0 && total > DEFER_AUTO_FRACTION * window) {
56
- for (const t of candidates)
57
- deferred.add(t.name);
58
- }
59
- }
60
- return deferred;
61
- }
62
19
  export function buildDeferredRegistry(deferred, tools) {
63
20
  const reg = new Map();
64
21
  for (const t of tools) {
@@ -1370,15 +1370,22 @@ export interface RunnerDeps {
1370
1370
  removedBytes: number;
1371
1371
  }) => void;
1372
1372
  /**
1373
- * Deferred-tool disclosure policy (design/36). Default (omitted): deterministic per-tool, so the same
1374
- * `TaskSpec` always yields the same `tools[]`. The default deferral sources are `ToolSpec.defer === true`,
1375
- * every REMOTE-PROTOCOL tool name (MCP **and** A2A peer tools), {@link TaskSpec.deferTools}, and built-in
1376
- * tools that declare their own `defer` (today the shared-memory pair) — minus anything pinned inline by
1377
- * `ToolSpec.alwaysLoad` / {@link TaskSpec.alwaysLoadTools}. `classifyDeferred`'s own contract is the
1378
- * single authority if this list ever drifts again. `"auto"`: additionally auto-defer tools when the total
1379
- * inlined schema size would exceed a fraction of the model's context window. Auto is an explicit opt-in
1380
- * (council minor #6) because an implicit threshold makes `tools[]` depend on the model/context, which
1381
- * callers find hard to predict; explicit `defer` flags always win regardless of this setting.
1373
+ * Deferred-tool disclosure policy (design/36). Two rules govern deferral. The caller's list is
1374
+ * literal: {@link TaskSpec.deferTools} names defer on every request, `ToolSpec.alwaysLoad` /
1375
+ * {@link TaskSpec.alwaysLoadTools} pins inline on every request (judged first) neither reads this
1376
+ * knob. The engine's own candidates — `ToolSpec.defer === true`, every REMOTE-PROTOCOL tool name
1377
+ * (MCP **and** A2A peer tools), and built-in tools that declare their own `defer` sit behind ONE
1378
+ * threshold gate, all or nothing: deferred iff their inlined size (name + description + schema; the
1379
+ * candidate set only the caller's list, pins and non-candidates are not measured) reaches 10% of the
1380
+ * model's context window; below that they ship inline in full and no ToolSearch is mounted.
1381
+ *
1382
+ * `"auto"` is a CANDIDATE-WIDENING knob, not a second gate: it adds every caller spec — undeclared
1383
+ * caller tools included — to the candidate set, measured and decided by the same threshold. Engine
1384
+ * built-ins the deployment did not name are candidates under no setting. An explicit opt-in (council
1385
+ * minor #6) because it makes `tools[]` depend on the caller roster's total weight, which a deployment
1386
+ * with many thin tools may not want. Any other value refuses at prepare (`config.defer_mode_invalid`).
1387
+ * `classifyDeferred` / `deferralGate` in `src/core/runner/tool-disclosure.ts` are the single authority
1388
+ * if this text drifts.
1382
1389
  */
1383
1390
  deferMode?: "auto";
1384
1391
  /**
@@ -31,6 +31,10 @@ export type ImageInput = {
31
31
  url: string;
32
32
  };
33
33
  /** A single self-contained task with its full configuration. */
34
+ /** The shell-gate doctrine vocabulary — {@link TaskSpec.shellGate}'s value set, and the word the engine persists on a
35
+ * shell-gated ask (`RiskDescriptor.shellGateDoctrine`) and folds across a delegation chain (max-rank: `off` <
36
+ * `classify` < `always`). One closed set; every seat that names a doctrine spells it through this alias. */
37
+ export type ShellGateDoctrine = "off" | "classify" | "always";
34
38
  export interface TaskSpec {
35
39
  taskId?: string;
36
40
  /** The instruction / latest user message for this turn. */
@@ -400,6 +404,8 @@ export interface TaskSpec {
400
404
  * included — `ToolSpec.defer` only covers caller specs): wire names listed here ship as
401
405
  * lightweight placeholders (name + one-line hint, schema bytes OFF the cache prefix) and are
402
406
  * activated on demand via the injected ToolSearch (design/36 materialization — prefix untouched).
407
+ * This list is LITERAL — an intentional deferral, deferred on every request regardless of the
408
+ * deferral gate ({@link RunnerDeps.deferMode}) and not counted toward its threshold.
403
409
  * "Default-on but not exposed" = the deployment lists the tool here on requests where no
404
410
  * activation source fired (e.g. Workflow unless the user said "workflow"/"ultracode"); omitting
405
411
  * the name on a later request mounts it fully (activation = not deferring). Unknown names are
@@ -445,7 +451,7 @@ export interface TaskSpec {
445
451
  * direction; CC 220 `alwaysLoad` 对位): names listed here are NEVER deferred — not by
446
452
  * `ToolSpec.defer`, not by the MCP constant-defer arm (besides the server's own
447
453
  * `_meta["anthropic/alwaysLoad"]` declaration this list is the only channel that keeps a chosen
448
- * MCP tool's full schema inlined), not by `deferTools`, not by `deferMode:"auto"`. Unknown names
454
+ * MCP tool's full schema inlined), not by `deferTools`, not by the deferral gate under any `deferMode`. Unknown names
449
455
  * are ignored (the valve only subtracts). Names also in `excludeTools` stay excluded (exclusion
450
456
  * wins — an unmounted tool has no schema to keep inline).
451
457
  */
@@ -862,7 +868,7 @@ export interface TaskSpec {
862
868
  * task config — a resume that omits it leaves the resumed run's SUBSEQUENT `bash` calls ungated (the approved
863
869
  * pending call itself runs once, having been adjudicated). Re-pass the same value the original task used.
864
870
  */
865
- shellGate?: "off" | "always" | "classify";
871
+ shellGate?: ShellGateDoctrine;
866
872
  /**
867
873
  * The caller's AUTO-MODE INTENT for this task — the "user turned auto on" half of the classifier
868
874
  * arming (CC 2.1.250 polarity: auto mode is something the USER enables at the permission-mode
@@ -363,9 +363,12 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
363
363
  * for this tool — it is not a "threshold so low that everything offloads". */
364
364
  offloadThresholdChars?: number;
365
365
  /**
366
- * Deferred disclosure (design/36): when `true`, this tool is NOT inlined with its full JSON Schema in
367
- * every request. It appears as a lightweight placeholder (`{name, one-line hint, empty params}`) so
368
- * the model knows it exists. Activation is a DISCLOSURE fact (has the model seen the schema?), not an
366
+ * Deferred disclosure (design/36): when `true`, this tool is a CANDIDATE of the engine's deferral gate.
367
+ * When the gate opens (the candidate set's inlined size reaching 10% of the model's context window; see
368
+ * {@link RunnerDeps.deferMode}) it is NOT inlined with its full JSON Schema: it appears as a lightweight
369
+ * placeholder (`{name, one-line hint, empty params}`) so the model knows it exists; when the gate stays
370
+ * shut it ships inline in full. (A caller that wants a tool deferred on every request names it in
371
+ * {@link TaskSpec.deferTools} — that list is literal.) Activation is a DISCLOSURE fact (has the model seen the schema?), not an
369
372
  * execution precondition: the model activates the tool via the injected ToolSearch, or by making a call
370
373
  * whose arguments already match the real parameters (see {@link TaskSpec.deferSelfResolve}, on by
371
374
  * default). Where the schema then lands depends on {@link TaskSpec.toolMaterializeStrategy} — the next
@@ -386,7 +389,7 @@ export interface ToolSpec<TParams extends TSchema = TSchema> {
386
389
  * `alwaysLoad`, declared per MCP tool via `_meta["anthropic/alwaysLoad"]`, whose `isDeferredTool`
387
390
  * checks it before every deferral arm): `true` pins this tool's full schema inline in every
388
391
  * request — it is never deferred, regardless of its own {@link defer}, {@link TaskSpec.deferTools},
389
- * or `deferMode:"auto"`. The deferral sources only ever ADD; this is the explicit subtract valve.
392
+ * or the deferral gate. The deferral sources only ever ADD; this is the explicit subtract valve.
390
393
  * Exclusion still wins ({@link TaskSpec.excludeTools} unmounts — nothing left to keep inline).
391
394
  */
392
395
  alwaysLoad?: boolean;
@@ -12,7 +12,7 @@
12
12
  import type { AgentTool, ThinkingLevel, ToolInputValidationContext, ToolInputVerdict } from "../internal/harness.js";
13
13
  import type { Model, StreamFn } from "../internal/llm.js";
14
14
  export type { ModelRef, ModelRole, RoleSpec, ModelRoles, Brain } from "./model-seat.js";
15
- export type { StaleToolResultOffloadOptions, ImageInput, TaskSpec } from "./task-spec.js";
15
+ export type { StaleToolResultOffloadOptions, ImageInput, TaskSpec, ShellGateDoctrine } from "./task-spec.js";
16
16
  export type { ToolEffect, ToolContentOrigin, ReversibilityVerdict, ToolSpec, ToolReturn, NestedUsage, NestedUsageAccum, ToolExecuteContext } from "./tool-spec.js";
17
17
  export type { BeforeWriteRequest, BeforeWriteResult, BeforeWriteHook, TrackEditRequest, TrackEditResult, TrackFileEditHook, FileEditedNotice, FileEditedHook, HandsBandOptions } from "./hands-band.js";
18
18
  export type { AgentDefinition, SkillManifest, SkillSpec } from "./agent-definition.js";
package/dist/index.d.ts CHANGED
@@ -93,7 +93,7 @@ export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core
93
93
  export { mintReminderMark, isValidReminderMark, openSystemReminder, mintSystemReminder, reminderMarkDeclaration } from "./core/reminder-mint.js";
94
94
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
95
95
  export type { InvariantKind, FunctionContract, Invariant, InvariantViolation, CheckResult, } from "./core/property-harness.js";
96
- export { bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
96
+ export { bashReversibilityProbe, bashReadBoundaryProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
97
97
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, type BashReadonlyRootBoundary, type CompoundReadonlyVerdict, } from "./tools/fs/index.js";
98
98
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
99
99
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, type ReadDenyEntry, type ReadDenyMatcher, type NormalizedReadDenyEntry, type ReadDenyBuiltinTier, type ReadDenyBuiltinRow, type ReadDenyBuiltinConfig, } from "./tools/fs/index.js";
@@ -318,7 +318,7 @@ export { ROUTE_ADJUDICATION_CONFORMANCE_CORPUS, type RouteAdjudicationVector } f
318
318
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
319
319
  export { createAssistantMessageEventStream } from "./internal/llm.js";
320
320
  export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
321
- export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, TrackFileEditHook, TrackEditRequest, TrackEditResult, FileEditedHook, FileEditedNotice, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, ResumePreflightInfo, ResumePreflightVerdict, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, TerminalCause, PausedCause, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolInputVerdict, ToolInputValidationContext, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
321
+ export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, TrackFileEditHook, TrackEditRequest, TrackEditResult, FileEditedHook, FileEditedNotice, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, BrainRetryErrClass, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, ResumePreflightInfo, ResumePreflightVerdict, EngineNotice, RuntimeCaps, BackgroundChildEvent, DelegationLifecycleEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, TerminalCause, PausedCause, EffectiveMemoryScopes, RemoteEnvFailureNote, TaskSpec, ShellGateDoctrine, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolInputVerdict, ToolInputValidationContext, ReversibilityVerdict, ToolEffect, ToolContentOrigin, WorkflowGovernanceBaseline, DelegationTaskType, } from "./core/types.js";
322
322
  export { Type } from "typebox";
323
323
  export type { TSchema, Static } from "typebox";
324
324
  export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
package/dist/index.js CHANGED
@@ -72,7 +72,7 @@ export { runExecGate } from "./core/exec-gate.js";
72
72
  export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core/untrusted-text.js";
73
73
  export { mintReminderMark, isValidReminderMark, openSystemReminder, mintSystemReminder, reminderMarkDeclaration } from "./core/reminder-mint.js";
74
74
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
75
- export { bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
75
+ export { bashReversibilityProbe, bashReadBoundaryProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
76
76
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, } from "./tools/fs/index.js";
77
77
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
78
78
  export { READ_FACE_DEFAULT_DENY_ENTRIES, READ_FACE_BUILTIN_DENY_TABLE, READ_DENY_BUILTIN_TIERS, READ_DENY_DEFAULT_TIERS, resolveReadDenyBuiltins, compileReadDeny, } from "./tools/fs/index.js";
@@ -56,6 +56,47 @@ import { type BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
56
56
  * that adds nothing the reader cannot already see.
57
57
  */
58
58
  export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
59
+ /**
60
+ * The READ-BOUNDARY seat of the full shell: a `ToolSpec.reversibilityProbe` that answers exactly ONE
61
+ * question — does the deployment's read boundary object to where this command reads? — and nothing about
62
+ * the command's shape. It is the shell face of the rule every read face obeys: the structured readers
63
+ * (Read/Grep/Glob) refuse a deny-listed or out-of-root target; the shell reader gets a MANDATED ask for the
64
+ * same target. The engine installs it on Bash and Monitor whenever a real writable shell is mounted and the
65
+ * shellGate doctrine adds no residual-risk classifier of its own (`shellGate:"off"`, the default); under
66
+ * `"classify"` the doctrine installs {@link bashReversibilityProbe} instead, which composes THIS judgment
67
+ * with the read-only shape classifier, and under `"always"` the tier itself is the mandate and no probe runs.
68
+ * The boundary is therefore judged under every doctrine; the doctrine decides only what happens to the
69
+ * residual shell risk.
70
+ *
71
+ * The verdict, in the probe vocabulary the gate reads (`reversible` here means "the boundary raised no
72
+ * question — the seat vouches the call may run"):
73
+ * · the boundary DECLARED an objection — a deny-listed operand, a recursive walk under the deny judge, an
74
+ * operand outside the roots — ⇒ `{ reversible: false, mandated: true }` (+ the recursive-reach `cause`);
75
+ * · the boundary could NOT READ where the command reads — an unexpanded glob operand (the compound face
76
+ * reports it as undecided), a `~`-rooted operand with no home declared, a working-directory move the walk
77
+ * cannot model — ⇒ `{ reversible: false }` (fail-closed; a plain ask, which a stored allow rule may retire —
78
+ * the seat did not declare anything, it declined to guess);
79
+ * · otherwise ⇒ `{ reversible: true }`. This includes every command whose SHAPE the read faces refuse whole
80
+ * (an unlisted program, a redirection, a substitution, a segment that is not a reader): those shapes carry
81
+ * no read-face verdict, so this seat has no question to ask — the residual is the doctrine's, and under
82
+ * `"off"` the doctrine asks nothing. `run_in_background` is likewise not this seat's question (it changes
83
+ * WHEN the command runs, never WHERE it reads).
84
+ *
85
+ * Reach = the read faces' reach, and nothing wider: the compound face walks the operands of a command whose
86
+ * every segment is a listed reader, and the bounded poll-loop face walks a loop body of listed readers. A
87
+ * read hidden in a shape the faces refuse whole (`cat x | <unlisted>`, `$(cat x)`, `cat $DIR/x`, a launcher
88
+ * `env cat x`, a path-prefixed `/bin/cat x`) is not seen here — deny-listed or out-of-root alike — exactly
89
+ * the shapes the `"classify"` doctrine exists to ask about. The text-level containment gate the classify
90
+ * seat consults on its demotion arms is deliberately not consulted here ({@link boundaryDeclared}: it may
91
+ * add a mandate to an ask, never originate one). The poll-loop face reports its refusal as one sentence,
92
+ * so the boundary's share of it is read by removing the boundary's judging seats
93
+ * ({@link pollLoopBoundaryVerdict}).
94
+ *
95
+ * The reader set is the engine's classify set ({@link BASH_CLASSIFY_DEFAULT_ALLOW}) — here it is NOT an
96
+ * auto-allow list but the set of programs whose operands the faces read; a wider set means more reads are
97
+ * judged, never that more runs are vouched.
98
+ */
99
+ export declare function bashReadBoundaryProbe(boundary: BashReadonlyRootBoundary | (() => BashReadonlyRootBoundary | undefined)): (args: unknown) => ReversibilityVerdict;
59
100
  /**
60
101
  * design/199 D-6 — the FULL shell's contract id, single-sourced: both shell faces share the wire
61
102
  * name "Bash", so this id is the ONE structural discriminator between the write-capable shell and