@sema-agent/core 7.24.3 → 7.25.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.
- package/CHANGELOG.md +101 -1
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/permission-rules.js +14 -10
- package/dist/core/rule-denial-sentence.d.ts +75 -0
- package/dist/core/rule-denial-sentence.js +16 -0
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +42 -22
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +28 -1
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +73 -2
- package/dist/core/runner/session-rule-policy.js +121 -21
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/orchestration/workflow.js +12 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-program-position.d.ts +4 -26
- package/dist/tools/fs/bash-program-position.js +0 -11
- package/dist/tools/fs/bash-readonly-classifier.d.ts +20 -309
- package/dist/tools/fs/bash-readonly-classifier.js +28 -766
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +82 -19
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.24.4.json +54 -0
- package/wire-changes/core@7.25.0.json +671 -0
|
@@ -115,7 +115,7 @@ function announceDurableGateUnavailable(args) {
|
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
export function prepareWiringManifest(input) {
|
|
118
|
-
const { spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, taskRootFinal, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs,
|
|
118
|
+
const { spec, deps, internals, resume, sessionId, runId, hostTaskId, taskScope, hooks, preToolUseObservational, policyLayers, effectivePolicy, handsEnabled, taskRootFinal, mcp, irreversibleTools, egressTools, toolEffects, ownedEnv, executionEnv, offloadStore, onceLedger, usageWindows, envLifetimeSuspendAt, runtimeCaps, resolvedInteractionPosture, durableQuestionFace, liveQuestionFace, checkpointStore, sessions, modelGateManifest, autoModeArmReason, lockedPreflight, peerLaneRefs, sessionBox, harness, pausedRef, frozenOnAsk, liveInheritedGate, delegation, toolRoster, readDenyBuiltinTiersActive, lspManagerWired, shellReadStationArmed, lspDiagnosticsLive } = input;
|
|
119
119
|
const onAsk = spec.onAsk ?? deps.onAsk;
|
|
120
120
|
const handWriteTools = toolRoster.entries.filter((e) => e.mountedBy === "hands" && (e.effect === "write" || e.effect === "idempotent")).map((e) => e.name);
|
|
121
121
|
const hasEffectAwareGate = Boolean(policyLayers.length > 0 || (hooks?.preToolUse !== undefined && !preToolUseObservational));
|
|
@@ -193,7 +193,7 @@ export function prepareWiringManifest(input) {
|
|
|
193
193
|
parkCapable: checkpointStore !== undefined,
|
|
194
194
|
parkDurableApprovalOptIn: spec.durableApproval !== undefined,
|
|
195
195
|
parkForceDurableGate: runtimeCaps?.forceDurableGate === true,
|
|
196
|
-
parkSafetyVocabularyArmed: irreversibleTools.size > 0 || egressTools.size > 0,
|
|
196
|
+
parkSafetyVocabularyArmed: irreversibleTools.size > 0 || egressTools.size > 0 || shellReadStationArmed,
|
|
197
197
|
...(checkpointStore !== undefined ? { checkpointDurability: resolveDeclaredDurability(checkpointStore, "checkpointStore") } : {}),
|
|
198
198
|
sessionDurability: resolveDeclaredDurability(sessions, "sessionStore"),
|
|
199
199
|
backgroundAgentStoreWired: deps.backgroundAgentStore !== undefined,
|
|
@@ -217,7 +217,8 @@ export function prepareWiringManifest(input) {
|
|
|
217
217
|
...(lspManagerWired ? { lsp: deriveLspSeam(toolRoster.entries.some((e) => e.contract.contractId === LSP_CONTRACT_ID), lspDiagnosticsLive) } : {}),
|
|
218
218
|
});
|
|
219
219
|
peerLaneRefs.askEffective = wiringManifest.ask.effective;
|
|
220
|
-
|
|
220
|
+
const sessionBoxBound = sessionBox !== undefined && internals?.onTaskNotification !== undefined ? sessionBox : undefined;
|
|
221
|
+
if (sessionBoxBound !== undefined && internals?.onTaskNotification !== undefined) {
|
|
221
222
|
bindPeerLaneDrain(harness, {
|
|
222
223
|
deps,
|
|
223
224
|
sessionId,
|
|
@@ -235,6 +236,7 @@ export function prepareWiringManifest(input) {
|
|
|
235
236
|
hooks?.preToolUse !== undefined ||
|
|
236
237
|
egressTools.size > 0 ||
|
|
237
238
|
irreversibleTools.size > 0 ||
|
|
239
|
+
shellReadStationArmed ||
|
|
238
240
|
resourceSuspendEligible ||
|
|
239
241
|
platformSuspendArmed ||
|
|
240
242
|
spec.enablePlanMode === true;
|
|
@@ -246,9 +248,11 @@ export function prepareWiringManifest(input) {
|
|
|
246
248
|
isDelegatedChild: delegation.isDelegatedChild,
|
|
247
249
|
...(internals?.insideFork === true ? { insideFork: true } : {}),
|
|
248
250
|
...(internals?.agentName !== undefined ? { agentName: internals.agentName } : {}),
|
|
251
|
+
...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
|
|
252
|
+
...(internals?.spawnAgentType !== undefined ? { agentType: internals.spawnAgentType } : {}),
|
|
249
253
|
...(internals?.parentToolCallId !== undefined ? { parentToolCallId: internals.parentToolCallId } : {}),
|
|
250
254
|
});
|
|
251
255
|
const hookContextConsumerWired = hooks?.preToolUse !== undefined || hooks?.postToolUse !== undefined || hooks?.postToolUseFailure !== undefined;
|
|
252
256
|
const hookEnvFace = hookContextConsumerWired && (ownedEnv ?? deps.executionEnv) != null ? createHookEnvCapabilities(executionEnv) : undefined;
|
|
253
|
-
return { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, platformSuspendArmed, durableApproval, wiringManifest, writeProtectionCheck: writeProtection?.check, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace };
|
|
257
|
+
return { onAsk, preToolContexts, blockedToolCalls, gateOutcomes, batchHaltRef, blockedTracked, incompleteSuspendAdapter, durableSuspendInfraReady, resourceSuspendEligible, usageGovernance, platformSuspendArmed, durableApproval, wiringManifest, writeProtectionCheck: writeProtection?.check, parkLaneArmed, gateMachineryActive, hookIdentity, hookEnvFace, sessionBoxBound };
|
|
254
258
|
}
|
|
@@ -26,6 +26,8 @@ export async function resumeApply(input) {
|
|
|
26
26
|
isDelegatedChild: effectiveDelegationFacts(internals, cp.state.isDelegatedChild).isDelegatedChild,
|
|
27
27
|
...(internals?.insideFork === true ? { insideFork: true } : {}),
|
|
28
28
|
...(internals?.agentName !== undefined ? { agentName: internals.agentName } : {}),
|
|
29
|
+
...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
|
|
30
|
+
...(internals?.spawnAgentType !== undefined ? { agentType: internals.spawnAgentType } : {}),
|
|
29
31
|
...(internals?.parentToolCallId !== undefined ? { parentToolCallId: internals.parentToolCallId } : {}),
|
|
30
32
|
});
|
|
31
33
|
const screenTimeoutMs = resolveHookTimeoutMs(resumeScreenHooks?.timeoutMs, (badErr) => {
|
|
@@ -309,6 +309,7 @@ export function createHarnessHandlers(input) {
|
|
|
309
309
|
...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
|
|
310
310
|
...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
|
|
311
311
|
...(subagentName ? { name: subagentName } : {}),
|
|
312
|
+
...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
|
|
312
313
|
model: prepared.model.id,
|
|
313
314
|
usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
|
|
314
315
|
status: "running",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
|
|
2
|
-
import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
2
|
+
import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
3
3
|
import type { PushQueue } from "../push-queue.js";
|
|
4
4
|
import type { TaskEvent, TaskSpec } from "../types.js";
|
|
5
5
|
import type { Stats } from "./assemble-result.js";
|
|
@@ -32,7 +32,7 @@ export interface RunIdentityWiringInput {
|
|
|
32
32
|
/** borrowed-readonly — the notification lane's ONE injection entry (the R0 lane's). */
|
|
33
33
|
injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
|
|
34
34
|
priority?: SystemInjectionPriority;
|
|
35
|
-
}) => Promise<
|
|
35
|
+
}) => Promise<NotificationInjectionOutcome>;
|
|
36
36
|
/** borrowed-mutable — the turn-open dedup set (the cross-layer double-park dedup): a redelivered frame's key is registered here. */
|
|
37
37
|
deliveredAtTurnOpen: Set<string>;
|
|
38
38
|
/** borrowed-mutable — the peer inbound chain, overwritten by each consumed peer-class payload. */
|
|
@@ -19,6 +19,7 @@ import { DEFAULT_PRECALL_OUTPUT_TOKENS, platformLimitTerminal } from "./clock-an
|
|
|
19
19
|
import { deepJsonEqual, resumeContinuation } from "./decide-continuation.js";
|
|
20
20
|
import { settleExecutionRecord } from "./execution-record.js";
|
|
21
21
|
import { mintGateOutcome } from "./gate-exit.js";
|
|
22
|
+
import { withGateTimeout } from "../gate-bounded-await.js";
|
|
22
23
|
import { resolveGitLegDelivery, wrapGitFrame } from "./git-leg-delivery.js";
|
|
23
24
|
import { GIT_STATUS_ECHO_PREVIEW } from "./git-status-frame.js";
|
|
24
25
|
import { toImageContent } from "./image.js";
|
|
@@ -30,6 +31,7 @@ import { announceListingTruncation } from "../skill-listing-budget.js";
|
|
|
30
31
|
import { attachmentEnvelopeTags, commitAgentListing, commitSkillsListing, reduceToolEnd, renderAgentListingFrame, renderOrphanedBackgroundTasks, renderSkillsListingFrame, stampWriteAnchor } from "./turn-attachments.js";
|
|
31
32
|
import { flushOwedAnnouncedSnapshot } from "./announce-once-ledger.js";
|
|
32
33
|
import { asThrownError, thrownMarksOf, thrownText } from "../thrown-value.js";
|
|
34
|
+
import { ruleSourcePhrase } from "../rule-denial-sentence.js";
|
|
33
35
|
export async function runLeg(input) {
|
|
34
36
|
const { spec, queue, prepared, resume, rs, isDelegatedChild, entryActor, effectiveTimeoutMs, timeout, pushContent, ident, emitCommitted, writeFamilyOf, startedToolCallIds, announceWorkspaceMove, withBrainSinks, flushGitMirror, loopLatch, notificationLane, unsubscribeTaskNotifications, unsubGitRetry, unsubBoundary, unsub, runner, next } = input;
|
|
35
37
|
let final;
|
|
@@ -645,7 +647,7 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
645
647
|
...advertisedWritableRootsSeat(prepared.advertisedWritableDirs),
|
|
646
648
|
}, prepared.abortController.signal));
|
|
647
649
|
if (narrowed.action === "deny") {
|
|
648
|
-
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by
|
|
650
|
+
const narrowedDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" is now denied by ${ruleSourcePhrase("narrowing")} and was not executed${narrowed.message ? `: ${narrowed.message}` : ""}.`, prepared.reminderMark);
|
|
649
651
|
await settleEnd(true, { content: narrowedDenial }, vetoed("policy"));
|
|
650
652
|
const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, narrowedDenial, true));
|
|
651
653
|
emitCommitted(eid, "toolResult", pendingAction.toolCallId);
|
|
@@ -677,6 +679,31 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
677
679
|
return;
|
|
678
680
|
}
|
|
679
681
|
}
|
|
682
|
+
if (prepared.shellReadCheck !== undefined) {
|
|
683
|
+
const edited = outcome.updatedInput !== undefined && !deepJsonEqual(outcome.updatedInput, pendingAction.args);
|
|
684
|
+
const judged = prepared.shellReadCheck.judge(pendingAction.toolName, resolvedArgs, prepared.cwdRef?.current, prepared.abortController.signal, {
|
|
685
|
+
toolCallId: pendingAction.toolCallId,
|
|
686
|
+
...(edited ? { seat: "rejudge" } : {}),
|
|
687
|
+
});
|
|
688
|
+
if (judged !== null) {
|
|
689
|
+
let verdict;
|
|
690
|
+
let answered = true;
|
|
691
|
+
try {
|
|
692
|
+
verdict = await withGateTimeout(judged, ORG_ADJUDICATION_TIMEOUT_MS, prepared.abortController.signal, "shellReadStation");
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
answered = false;
|
|
696
|
+
verdict = prepared.shellReadCheck.unanswered(pendingAction.toolName, "the read station could not answer on the resumed leg");
|
|
697
|
+
}
|
|
698
|
+
if (!answered || verdict.disposition === "refuse" || (edited && verdict.disposition !== "vouch")) {
|
|
699
|
+
const readDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${verdict.sentence ?? "the read boundary does not vouch for what it reads on this leg"} This approval is spent — the call has to be re-issued and approved again.`, prepared.reminderMark);
|
|
700
|
+
await settleEnd(true, { content: readDenial, ...(verdict.details !== undefined ? { details: verdict.details } : {}) }, vetoed("read_boundary"));
|
|
701
|
+
const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, readDenial, true));
|
|
702
|
+
emitCommitted(eid, "toolResult", pendingAction.toolCallId);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
680
707
|
const tool = prepared.tools.find((t) => t.name === pendingAction.toolName);
|
|
681
708
|
if (!tool) {
|
|
682
709
|
const e = new Error(`the approved tool "${pendingAction.toolName}" is no longer available on resume`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PeerInboundChainRef } from "../../agents/peer-admission.js";
|
|
2
2
|
import type { PushQueue } from "../push-queue.js";
|
|
3
3
|
import type { SessionStore } from "../session.js";
|
|
4
|
-
import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
4
|
+
import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
5
5
|
import type { RunnerDeps, TaskEvent, TaskSpec } from "../types.js";
|
|
6
6
|
import type { SessionReadFileStates } from "./prepare-hands-readface.js";
|
|
7
7
|
import type { NotificationLaneBindings, Prepared, PrepareTaskFn, ResumeRun, RunInternals, RunnerDepsSeat, RunnerSelfSeat, TaskIdRef } from "./contracts.js";
|
|
@@ -41,7 +41,7 @@ export interface RunNotificationLaneResult {
|
|
|
41
41
|
/** The ONE injection entry every notification producer rides (the identity lane binds the notify() bridge to it). */
|
|
42
42
|
injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
|
|
43
43
|
priority?: SystemInjectionPriority;
|
|
44
|
-
}) => Promise<
|
|
44
|
+
}) => Promise<NotificationInjectionOutcome>;
|
|
45
45
|
/** The turn-open dedup set (the cross-layer double-park dedup): the identity lane registers a redelivered frame's key here. */
|
|
46
46
|
deliveredAtTurnOpen: Set<string>;
|
|
47
47
|
/** The peer inbound chain ref this run carries (the spawn chain's, or a fresh one). */
|
|
@@ -118,9 +118,10 @@ export async function runNotificationLane(input) {
|
|
|
118
118
|
}
|
|
119
119
|
catch {
|
|
120
120
|
}
|
|
121
|
-
injectTaskNotification(notification, opts);
|
|
121
|
+
return injectTaskNotification(notification, opts);
|
|
122
122
|
},
|
|
123
123
|
}, self, taskIdRef);
|
|
124
|
+
pendingSessionNotifications.bindSessionBox(prepared.sessionId, prepared.sessionBox);
|
|
124
125
|
const notificationLane = {
|
|
125
126
|
get harness() { return notificationHarness; }, set harness(h) { notificationHarness = h; },
|
|
126
127
|
get sessionId() { return notificationSessionId; },
|
|
@@ -266,6 +266,7 @@ export async function runSettleAndTeardown(input) {
|
|
|
266
266
|
...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
|
|
267
267
|
...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
|
|
268
268
|
...(subagentName ? { name: subagentName } : {}),
|
|
269
|
+
...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
|
|
269
270
|
model: prepared.model.id,
|
|
270
271
|
usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
|
|
271
272
|
status: result.terminal.kind === "completed" ? "completed" : "failed",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExecutionEnv } from "../../internal/harness-types.js";
|
|
2
2
|
import type { ToolEffect } from "../types.js";
|
|
3
3
|
import { type NamedToolPolicy } from "../tool-policy.js";
|
|
4
|
-
import type { SessionPermissionRules } from "../session-policy-store.js";
|
|
4
|
+
import type { SessionPermissionRules, SessionPolicyStore, StoredSessionRules } from "../session-policy-store.js";
|
|
5
5
|
/** Tool names whose write target is the `file_path`/`path` arg ({@link fileArgPath}).
|
|
6
6
|
*
|
|
7
7
|
* REF-B3 — the ONE definition of that narrower set. Production consumer today:
|
|
@@ -48,6 +48,73 @@ export declare const PATH_CONFINABLE_WRITE_TOOLS: ReadonlySet<string>;
|
|
|
48
48
|
* separate functions on purpose — that one is private to the fence and keyed on canonical roots — but
|
|
49
49
|
* they must never disagree on a verdict; the empty-root arm below is that alignment. */
|
|
50
50
|
export declare function isWithin(root: string, p: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Where a session-rule layer takes the rules it enforces.
|
|
53
|
+
*
|
|
54
|
+
* @contract session_rules.every_gate_re_reads — a session-rule layer re-reads its rule source at EVERY
|
|
55
|
+
* tool gate, not once at prepare: an operator's mid-run write (a new deny, a narrowed allowlist) binds the
|
|
56
|
+
* very next call of the SAME run, on the same beat as the permission-rule store's per-call read. `seed` is
|
|
57
|
+
* the prepare-time snapshot and stays the FLOOR — a read that fails, a row that was deleted, or a lagging
|
|
58
|
+
* replica whose `rev` is below the snapshot's enforces the seed rather than loosening toward it — and it is
|
|
59
|
+
* also what the prepare-time policy-name audit sees, so a rule written after prepare is enforced but not
|
|
60
|
+
* audited. A source with no `read` is a constant one: the seed at every gate, which is what a caller that
|
|
61
|
+
* hands a plain rules object gets.
|
|
62
|
+
*/
|
|
63
|
+
export interface SessionRuleSource {
|
|
64
|
+
/** The prepare-time snapshot: the audit's view, and the floor no live read may loosen. */
|
|
65
|
+
seed: SessionPermissionRules;
|
|
66
|
+
/** The per-call read, keyed on the call being adjudicated, and bounded by the gate's own signal. Absent
|
|
67
|
+
* ⇒ the seed is the whole source. */
|
|
68
|
+
read?: (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The LIVE half of {@link SessionRuleSource}: re-read `(sessionId, principal)` from the store on each
|
|
72
|
+
* adjudicated call, floored by `seed`.
|
|
73
|
+
*
|
|
74
|
+
* ONE law, two consumers (this task's own session rules and each inherited ancestor's), where the policy
|
|
75
|
+
* chain used to carry a hand-written live-preferring read for ancestors only and a frozen snapshot for the
|
|
76
|
+
* task's own rules. Direction, unchanged from that read and stated once here: a whole-row replacement
|
|
77
|
+
* includes RELAXATION — an operator's authorized post-prepare rule change propagates in BOTH directions;
|
|
78
|
+
* what the floor forbids is an UNAUTHORIZED loosening (a lagging replica, a deleted row, an unreachable
|
|
79
|
+
* store), and nothing task-side can edit the rules row itself.
|
|
80
|
+
*
|
|
81
|
+
* The floor RATCHETS to the highest-rev row this layer has actually served, and a live row replaces it only
|
|
82
|
+
* on a STRICTLY GREATER `rev`. Two reasons, and the second is why "greater or equal" is not good enough:
|
|
83
|
+
* · pinning the floor to the prepare snapshot would make "a failed read never loosens" false in the one
|
|
84
|
+
* sequence that matters — a row tightened mid-run, enforced at one gate, then unreadable at the next
|
|
85
|
+
* would fall back PAST the tightening to the snapshot the run started with;
|
|
86
|
+
* · `rev` is per-ROW, and both bundled backends compute it as `(prior?.rev ?? 0) + 1`, so a row that is
|
|
87
|
+
* DELETED and written again starts over at 1. At `>=`, a recreated row whose rev merely TIES the
|
|
88
|
+
* enforced one would replace it — an unauthorized loosening dressed as an update. At `>`, a tie enforces
|
|
89
|
+
* the floor, which is a no-op for the honest case (equal rev is the same row) and fail-closed for the
|
|
90
|
+
* recycled one. Closed since 7.25.0 by the STORE's lineage identity (`StoredSessionRules.gen`): a
|
|
91
|
+
* recreated row is another generation and is never compared to the enforced one, whatever its rev, and
|
|
92
|
+
* a backend without lineages (gen 0 throughout) keeps the `>` protection above.
|
|
93
|
+
*
|
|
94
|
+
* The per-call handoff mirrors the permission-rule lanes': the first reader of a `toolCallId` takes the read
|
|
95
|
+
* and hands it on, a second reader of the SAME call claims that one snapshot, and an unclaimed entry is
|
|
96
|
+
* evicted first-in-first-out past a bound. It is a cost device, never a semantic one — a reader that cannot
|
|
97
|
+
* claim simply reads again. Its firing path here is the fold itself: `policyLayers` re-appends the narrowing
|
|
98
|
+
* layers AFTER a rewrite-capable caller layer, so one adjudication asks this layer twice under one id, and
|
|
99
|
+
* the leading (original args) and trailing (rewritten args) passes must judge ONE set of rules.
|
|
100
|
+
*
|
|
101
|
+
* Two bounds keep a handed entry from ever being a STALE one, because `toolCallId` is not always unique —
|
|
102
|
+
* the turn-boundary read-visibility query reuses one synthetic id for every path it screens and for every
|
|
103
|
+
* turn of the run. The entry is taken ONCE, and a claimed snapshot is served only while its `rev` still
|
|
104
|
+
* stands at the enforced floor; a claim from a later adjudication, whose floor has moved on, falls through
|
|
105
|
+
* to a fresh read. A real tool call's id is minted per call and never repeats.
|
|
106
|
+
*/
|
|
107
|
+
export declare function createLiveSessionRules(store: SessionPolicyStore, floor: {
|
|
108
|
+
sessionId: string;
|
|
109
|
+
principal?: string;
|
|
110
|
+
seed: StoredSessionRules;
|
|
111
|
+
}, hooks?: {
|
|
112
|
+
/** A rule source this layer could not read, once per distinct reason. */
|
|
113
|
+
onUnreadable?: (reason: string) => void;
|
|
114
|
+
/** The store row this layer now enforces, each time the floor ADVANCES. Never called for the synthetic
|
|
115
|
+
* empty seed: it reports rows the store actually served, which is what a child may inherit. */
|
|
116
|
+
onEnforced?: (row: StoredSessionRules) => void;
|
|
117
|
+
}): (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
|
|
51
118
|
/**
|
|
52
119
|
* Build the per-session deny-narrowing policy. For each tool call:
|
|
53
120
|
* - `toolDeny` → deny the listed tools.
|
|
@@ -63,8 +130,12 @@ export declare function isWithin(root: string, p: string): boolean;
|
|
|
63
130
|
* matching sees the actual filesystem (a remote/E2B task resolves in its container). A relative
|
|
64
131
|
* TARGET prefers the call's live tracked cwd ({@link ToolCallRequest.cwd}) when the engine stamps one;
|
|
65
132
|
* `allowDirs` entries always resolve against `rootPath`.
|
|
133
|
+
*
|
|
134
|
+
* The rules are taken from a {@link SessionRuleSource} at EVERY call
|
|
135
|
+
* ({@link SessionRuleSource} carries the law); a plain rules object is the constant source, which is what
|
|
136
|
+
* every caller outside the policy chain hands, and what a layer whose rules cannot change under it needs.
|
|
66
137
|
*/
|
|
67
|
-
export declare function createSessionRulePolicy(rules: SessionPermissionRules, opts: {
|
|
138
|
+
export declare function createSessionRulePolicy(rules: SessionPermissionRules | SessionRuleSource, opts: {
|
|
68
139
|
env: ExecutionEnv;
|
|
69
140
|
rootPath?: string;
|
|
70
141
|
toolEffects?: ReadonlyMap<string, ToolEffect>;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
|
|
2
2
|
import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
|
|
3
|
+
import { thrownText } from "../thrown-value.js";
|
|
4
|
+
import { ruleDenialSentence } from "../rule-denial-sentence.js";
|
|
5
|
+
import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
|
|
3
6
|
import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
|
|
4
7
|
import { pathTargetBaseOf } from "../effective-path-target.js";
|
|
5
8
|
export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
|
|
@@ -20,32 +23,129 @@ export function isWithin(root, p) {
|
|
|
20
23
|
const base = r.endsWith("/") ? r : r + "/";
|
|
21
24
|
return c.startsWith(base);
|
|
22
25
|
}
|
|
23
|
-
const deny = (
|
|
26
|
+
const deny = (tool, detail) => ({
|
|
27
|
+
action: "deny",
|
|
28
|
+
message: ruleDenialSentence({ source: "session", tool, ...(detail !== undefined ? { detail } : {}) }),
|
|
29
|
+
decisionReason: "rule",
|
|
30
|
+
});
|
|
31
|
+
const HANDOFF_BOUND = 256;
|
|
32
|
+
const ANNOUNCED_REASONS_BOUND = 8;
|
|
33
|
+
export function createLiveSessionRules(store, floor, hooks) {
|
|
34
|
+
const announced = new Set();
|
|
35
|
+
const announce = (reason) => {
|
|
36
|
+
if (announced.has(reason) || announced.size >= ANNOUNCED_REASONS_BOUND)
|
|
37
|
+
return;
|
|
38
|
+
announced.add(reason);
|
|
39
|
+
hooks?.onUnreadable?.(reason);
|
|
40
|
+
};
|
|
41
|
+
let enforced = floor.seed;
|
|
42
|
+
let servedRow = false;
|
|
43
|
+
let vanished = false;
|
|
44
|
+
const read = async (signal) => {
|
|
45
|
+
let live;
|
|
46
|
+
try {
|
|
47
|
+
live = await withGateTimeout(store.getRules(floor.sessionId, floor.principal), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "sessionRulesRead");
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
const wait = gateWaitFailureOf(err);
|
|
51
|
+
announce(wait !== undefined
|
|
52
|
+
? `the session-policy store read for session ${JSON.stringify(floor.sessionId)} ended without an answer (${wait})`
|
|
53
|
+
: `the session-policy store threw while re-reading session ${JSON.stringify(floor.sessionId)}: ${thrownText(err)}`);
|
|
54
|
+
return enforced;
|
|
55
|
+
}
|
|
56
|
+
if (live === null) {
|
|
57
|
+
if (servedRow && !vanished) {
|
|
58
|
+
vanished = true;
|
|
59
|
+
announce(`the rules row of session ${JSON.stringify(floor.sessionId)} was deleted while this leg enforced it`);
|
|
60
|
+
}
|
|
61
|
+
return enforced;
|
|
62
|
+
}
|
|
63
|
+
if (enforced.rev > 0) {
|
|
64
|
+
if (live.gen !== undefined && enforced.gen !== undefined) {
|
|
65
|
+
if (live.gen !== enforced.gen) {
|
|
66
|
+
announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
|
|
67
|
+
return enforced;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (vanished) {
|
|
71
|
+
announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
|
|
72
|
+
return enforced;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (live.rev <= enforced.rev) {
|
|
76
|
+
if (live.rev < enforced.rev) {
|
|
77
|
+
announce(`the session-policy store served session ${JSON.stringify(floor.sessionId)} at rev ${live.rev}, below the enforced rev ${enforced.rev}`);
|
|
78
|
+
}
|
|
79
|
+
return enforced;
|
|
80
|
+
}
|
|
81
|
+
enforced = live;
|
|
82
|
+
servedRow = true;
|
|
83
|
+
hooks?.onEnforced?.(live);
|
|
84
|
+
return live;
|
|
85
|
+
};
|
|
86
|
+
const handed = new Map();
|
|
87
|
+
return async (toolCallId, signal) => {
|
|
88
|
+
const handedRead = handed.get(toolCallId);
|
|
89
|
+
if (handedRead !== undefined) {
|
|
90
|
+
handed.delete(toolCallId);
|
|
91
|
+
const claimed = await handedRead;
|
|
92
|
+
if (claimed.rev >= enforced.rev)
|
|
93
|
+
return claimed;
|
|
94
|
+
}
|
|
95
|
+
const p = read(signal);
|
|
96
|
+
handed.set(toolCallId, p);
|
|
97
|
+
if (handed.size > HANDOFF_BOUND) {
|
|
98
|
+
const oldest = handed.keys().next().value;
|
|
99
|
+
if (oldest !== undefined)
|
|
100
|
+
handed.delete(oldest);
|
|
101
|
+
}
|
|
102
|
+
return p;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function compileSessionRules(rules) {
|
|
106
|
+
return {
|
|
107
|
+
toolDeny: new Set(rules.toolDeny ?? []),
|
|
108
|
+
toolAllow: rules.toolAllow ? new Set(rules.toolAllow) : undefined,
|
|
109
|
+
toolDenyCovering: namespacedCoveringEntries(rules.toolDeny),
|
|
110
|
+
toolAllowCovering: namespacedCoveringEntries(rules.toolAllow),
|
|
111
|
+
cmdPolicy: rules.commandAllow || rules.commandDeny
|
|
112
|
+
? createCoarseCommandNamePolicy({
|
|
113
|
+
...(rules.commandAllow ? { allow: rules.commandAllow } : {}),
|
|
114
|
+
...(rules.commandDeny ? { deny: rules.commandDeny } : {}),
|
|
115
|
+
defaultAction: "deny",
|
|
116
|
+
})
|
|
117
|
+
: undefined,
|
|
118
|
+
allowDirs: rules.allowDirs && rules.allowDirs.length > 0 ? rules.allowDirs : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
24
121
|
export function createSessionRulePolicy(rules, opts) {
|
|
25
122
|
const { env, rootPath, toolEffects } = opts;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
123
|
+
const source = "seed" in rules ? rules : { seed: rules };
|
|
124
|
+
const seed = source.seed;
|
|
125
|
+
const readRules = source.read ?? (() => Promise.resolve(seed));
|
|
126
|
+
let compiledFor;
|
|
127
|
+
let compiled;
|
|
128
|
+
const compile = (r) => {
|
|
129
|
+
if (compiled === undefined || compiledFor !== r) {
|
|
130
|
+
compiled = compileSessionRules(r);
|
|
131
|
+
compiledFor = r;
|
|
132
|
+
}
|
|
133
|
+
return compiled;
|
|
134
|
+
};
|
|
38
135
|
return {
|
|
39
|
-
nameSets: [{ ...(
|
|
136
|
+
nameSets: [{ ...(seed.toolDeny?.length ? { deny: [...seed.toolDeny] } : {}), ...(seed.toolAllow?.length ? { allow: [...seed.toolAllow] } : {}) }],
|
|
40
137
|
async check(req, signal) {
|
|
138
|
+
const { toolDeny, toolAllow, toolDenyCovering, toolAllowCovering, cmdPolicy, allowDirs } = compile(await readRules(req.toolCallId, signal));
|
|
41
139
|
const toolName = req.toolName;
|
|
42
140
|
if (toolDeny.has(toolName) || namespacedCoveringHit(toolDenyCovering, toolName))
|
|
43
|
-
return deny(
|
|
141
|
+
return deny(req.toolName);
|
|
44
142
|
if (toolAllow && !toolAllow.has(toolName) && !namespacedCoveringHit(toolAllowCovering, toolName)) {
|
|
45
|
-
return deny(
|
|
143
|
+
return deny(req.toolName, "it is not in the session-rule allowlist");
|
|
46
144
|
}
|
|
47
145
|
if (cmdPolicy) {
|
|
48
146
|
const d = await cmdPolicy.check(req, signal);
|
|
147
|
+
if (d.action === "deny")
|
|
148
|
+
return { ...d, message: ruleDenialSentence({ source: "session", tool: req.toolName, ...(d.message !== undefined ? { detail: d.message } : {}) }) };
|
|
49
149
|
if (d.action !== "allow")
|
|
50
150
|
return d;
|
|
51
151
|
}
|
|
@@ -53,21 +153,21 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
53
153
|
if (!isProtectedWrite(req)) {
|
|
54
154
|
const eff = toolEffects?.get(toolName) ?? "write";
|
|
55
155
|
if (eff !== "read") {
|
|
56
|
-
return deny(
|
|
156
|
+
return deny(req.toolName, "it is write-capable and cannot be path-confined, while the rule confines writes to allowDirs");
|
|
57
157
|
}
|
|
58
158
|
}
|
|
59
159
|
else {
|
|
60
160
|
const declared = declaredPathTargetOf(req);
|
|
61
161
|
if (declared === undefined) {
|
|
62
|
-
return deny(
|
|
162
|
+
return deny(req.toolName, "it declares no path target to confine, while the rule confines writes to allowDirs");
|
|
63
163
|
}
|
|
64
164
|
const path = writeTargetPath(req, declared);
|
|
65
165
|
if (typeof path !== "string" || path.length === 0) {
|
|
66
|
-
return deny(
|
|
166
|
+
return deny(req.toolName, "the call has no resolvable path to confine, while the rule confines writes to allowDirs");
|
|
67
167
|
}
|
|
68
168
|
const canon = await canonicalizeTarget(env, path, signal, pathFamilyOf({ root: rootPath, cwd: req.cwd ?? env.cwd }), pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
|
|
69
169
|
if (!canon.ok) {
|
|
70
|
-
return deny(`write to
|
|
170
|
+
return deny(req.toolName, `the real target of the write to ${JSON.stringify(path)} could not be resolved against the session-rule allowDirs`);
|
|
71
171
|
}
|
|
72
172
|
const roots = [];
|
|
73
173
|
for (const raw of allowDirs) {
|
|
@@ -76,7 +176,7 @@ export function createSessionRulePolicy(rules, opts) {
|
|
|
76
176
|
roots.push(r.key);
|
|
77
177
|
}
|
|
78
178
|
if (!roots.some((root) => isWithin(root, canon.key))) {
|
|
79
|
-
return deny(`write to
|
|
179
|
+
return deny(req.toolName, `the write to ${JSON.stringify(path)} is outside the session-rule allowDirs (${allowDirs.join(", ")})`);
|
|
80
180
|
}
|
|
81
181
|
}
|
|
82
182
|
}
|
|
@@ -36,6 +36,11 @@ export interface SessionPermissionRules {
|
|
|
36
36
|
/** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
|
|
37
37
|
export interface StoredSessionRules extends SessionPermissionRules {
|
|
38
38
|
rev: number;
|
|
39
|
+
/** The row's LINEAGE: which generation of this key the row belongs to. `rev` counts within one lineage; an
|
|
40
|
+
* erase (`deleteBySession`) ends it, and the next write on the key starts the next generation, so a reader
|
|
41
|
+
* can tell a recreated row from a continuation of the one it enforces whatever `rev` the new row wears.
|
|
42
|
+
* Absent = generation 0 (a backend that predates the mark, or a row written before it). */
|
|
43
|
+
gen?: number;
|
|
39
44
|
}
|
|
40
45
|
/** Options for {@link SessionPolicyStore.putRules}. */
|
|
41
46
|
export interface PutRulesOptions {
|
|
@@ -47,10 +52,11 @@ export interface PutRulesOptions {
|
|
|
47
52
|
operator?: boolean;
|
|
48
53
|
}
|
|
49
54
|
/** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
|
|
50
|
-
* = a non-operator write tried to relax the rules
|
|
55
|
+
* = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
|
|
56
|
+
* rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev. */
|
|
51
57
|
export declare class SessionPolicyError extends Error {
|
|
52
|
-
readonly code: "conflict" | "loosen_forbidden";
|
|
53
|
-
constructor(code: "conflict" | "loosen_forbidden", message: string);
|
|
58
|
+
readonly code: "conflict" | "loosen_forbidden" | "corrupt";
|
|
59
|
+
constructor(code: "conflict" | "loosen_forbidden" | "corrupt", message: string);
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
56
62
|
* The seam a deployment implements for DURABLE, cross-replica per-session rules. Unset on `RunnerDeps` ⇒ the
|
|
@@ -80,6 +86,17 @@ export interface SessionPolicyStore {
|
|
|
80
86
|
* rows — without this seam they survive the overwrite and keep enforcing the old branch's rules against
|
|
81
87
|
* the new content. Optional like {@link listBySession} (a sync-capable backend implements both); the two
|
|
82
88
|
* bundled impls do. Idempotent: deleting a session with no records is a no-op.
|
|
89
|
+
*
|
|
90
|
+
* @contract session_policy.rev_survives_delete — a key's `rev` (one key = `(sessionId, principal)`, the unit
|
|
91
|
+
* a row is stored under) is a HIGH-WATER MARK that survives this erase: the first `putRules` on that key
|
|
92
|
+
* after a `deleteBySession` stamps a `rev` strictly greater than every rev the key ever carried, so no later
|
|
93
|
+
* row of that key reads a smaller `rev` than an earlier one (another principal's row on the same session
|
|
94
|
+
* counts on its own), and a
|
|
95
|
+
* tighten-only (non-operator) write landing after an erase cannot slip under a floor a reader already
|
|
96
|
+
* enforced. A backend that reads "no row" as rev 0 must therefore keep the mark past the row (the bundled
|
|
97
|
+
* in-memory store and a deployment's SQL twin both do). The erase also ends the row's LINEAGE: the next
|
|
98
|
+
* write on the key carries the next `gen`, so a reader that enforces a row treats a later row of another
|
|
99
|
+
* generation as not comparable whatever its rev — a recreated row cannot pass for a continuation.
|
|
83
100
|
*/
|
|
84
101
|
deleteBySession?(sessionId: string): Promise<void>;
|
|
85
102
|
}
|
|
@@ -123,6 +140,13 @@ export declare function stripRev(rules: SessionPermissionRules): SessionPermissi
|
|
|
123
140
|
*/
|
|
124
141
|
export declare class InMemorySessionPolicyStore implements SessionPolicyStore {
|
|
125
142
|
private readonly map;
|
|
143
|
+
/**
|
|
144
|
+
* The per-key mark {@link SessionPolicyStore.deleteBySession} may not erase: the largest `rev` this key has
|
|
145
|
+
* ever been stamped with, kept outside the row so removing the row cannot roll the counter back. Written by
|
|
146
|
+
* every stamp, read by the next one, never read by anything else, and never collected — an erase frees the
|
|
147
|
+
* row and keeps this, so the cost is one integer per distinct key the process has ever written.
|
|
148
|
+
*/
|
|
149
|
+
private readonly highWater;
|
|
126
150
|
private key;
|
|
127
151
|
getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
|
|
128
152
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
@@ -76,6 +76,7 @@ export function stripRev(rules) {
|
|
|
76
76
|
}
|
|
77
77
|
export class InMemorySessionPolicyStore {
|
|
78
78
|
map = new Map();
|
|
79
|
+
highWater = new Map();
|
|
79
80
|
key(sessionId, principal) {
|
|
80
81
|
return JSON.stringify([sessionId, principal ?? null]);
|
|
81
82
|
}
|
|
@@ -96,8 +97,12 @@ export class InMemorySessionPolicyStore {
|
|
|
96
97
|
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
|
-
const
|
|
100
|
+
const mark = this.highWater.get(key) ?? { rev: 0, gen: 0 };
|
|
101
|
+
const floor = Math.max(prior?.rev ?? 0, mark.rev);
|
|
102
|
+
const gen = prior?.gen ?? mark.gen;
|
|
103
|
+
const next = { ...structuredClone(clean), rev: floor + 1, gen };
|
|
100
104
|
this.map.set(key, next);
|
|
105
|
+
this.highWater.set(key, { rev: next.rev, gen });
|
|
101
106
|
return structuredClone(next);
|
|
102
107
|
}
|
|
103
108
|
async listBySession(sessionId) {
|
|
@@ -110,10 +115,13 @@ export class InMemorySessionPolicyStore {
|
|
|
110
115
|
return out;
|
|
111
116
|
}
|
|
112
117
|
async deleteBySession(sessionId) {
|
|
113
|
-
for (const k of [...this.map.
|
|
118
|
+
for (const [k, row] of [...this.map.entries()]) {
|
|
114
119
|
const [sid] = JSON.parse(k);
|
|
115
|
-
if (sid
|
|
116
|
-
|
|
120
|
+
if (sid !== sessionId)
|
|
121
|
+
continue;
|
|
122
|
+
const mark = this.highWater.get(k) ?? { rev: 0, gen: 0 };
|
|
123
|
+
this.highWater.set(k, { rev: Math.max(mark.rev, row.rev), gen: mark.gen + 1 });
|
|
124
|
+
this.map.delete(k);
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
}
|
|
@@ -75,6 +75,16 @@ export async function backgroundAgentStoreContract(mk, runAssertion) {
|
|
|
75
75
|
const store = make();
|
|
76
76
|
await assert.rejects(store.put(record({ scope: "" })), "an empty scope must be refused loudly, not stored");
|
|
77
77
|
});
|
|
78
|
+
run("a row carrying a key this build does not declare round-trips: no refusal, every declared field intact", async (make) => {
|
|
79
|
+
const store = make();
|
|
80
|
+
await store.put({ ...record({ handle: "a-extra", name: "worker", agentType: "reader" }), teamName: "retired-column" });
|
|
81
|
+
const row = await held(store, "a-extra");
|
|
82
|
+
assert.equal(row.name, "worker");
|
|
83
|
+
assert.equal(row.agentType, "reader");
|
|
84
|
+
assert.equal(row.status, "running");
|
|
85
|
+
await store.update("a-extra", S, { ...row, status: "completed" }, { rev: row.rev });
|
|
86
|
+
assert.equal((await held(store, "a-extra")).status, "completed", "a CAS update over such a row still lands");
|
|
87
|
+
});
|
|
78
88
|
run("get returns DETACHED copies: mutating a read never reaches the stored row", async (make) => {
|
|
79
89
|
const store = make();
|
|
80
90
|
await store.put(record({ handle: "a1", usage: { totalTokens: 5 } }));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type PeerDirectory, type PeerSessionRecord } from "../../agents/peer-directory.js";
|
|
2
|
+
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
|
+
/** The scope every case registers rows under; the scope-wall case constructs a second one. */
|
|
4
|
+
export declare const PEER_DIRECTORY_CONTRACT_SCOPE = "peer-kit-A";
|
|
5
|
+
export interface PeerDirectoryContractHooks {
|
|
6
|
+
/** Write (or replace) one row through the host's own write protocol. The kit never writes a row
|
|
7
|
+
* the engine's reader would refuse. */
|
|
8
|
+
write: (dir: PeerDirectory, record: PeerSessionRecord) => void | Promise<void>;
|
|
9
|
+
runAssertion?: ContractAssertionRunner;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The directory contract. Four readings, each the reason the engine can do something:
|
|
13
|
+
* 1. **scope wall** — a listing is the ACCESSOR's scope only; the cross-principal wall's mechanical
|
|
14
|
+
* seat is this filter, so a row leaking across scopes is a tenant leak, not a display bug;
|
|
15
|
+
* 2. **the full set, copies** — live, dead AND deleted rows are all listed (a `name [ref]` is minted
|
|
16
|
+
* over the full set; a dead row is the name truth offline delivery resolves through; a deleted
|
|
17
|
+
* row resolves only to refuse), and a listed row is a COPY (mutating it changes no later listing);
|
|
18
|
+
* 3. **resolution feeds on the listing** — the engine's own resolver, over the rows as listed, finds
|
|
19
|
+
* a live row by name, still finds a dead one, and answers `deleted` for a tombstone;
|
|
20
|
+
* 4. **restore is CAS-on-absent** (only where the host implements it) — an absent row is restored and
|
|
21
|
+
* then listed; a present row is never overwritten (`false`, the existing row byte-unchanged).
|
|
22
|
+
*/
|
|
23
|
+
export declare function peerDirectoryContract(mk: () => PeerDirectory, hooks: PeerDirectoryContractHooks): Promise<void>;
|