@sema-agent/core 5.11.0 → 5.13.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 +52 -0
- package/dist/agents/subagent.js +4 -2
- package/dist/core/auto-compaction.d.ts +12 -1
- package/dist/core/auto-compaction.js +3 -1
- package/dist/core/checkpoint-store.d.ts +8 -1
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/compliance.d.ts +11 -0
- package/dist/core/compliance.js +34 -0
- package/dist/core/exec-gate.js +12 -1
- package/dist/core/governance-codes.d.ts +12 -0
- package/dist/core/governance-codes.js +24 -0
- package/dist/core/locked-config.d.ts +27 -0
- package/dist/core/locked-config.js +42 -0
- package/dist/core/memory-admission.d.ts +47 -0
- package/dist/core/memory-admission.js +156 -0
- package/dist/core/memory.d.ts +2 -0
- package/dist/core/memory.js +3 -2
- package/dist/core/retention.d.ts +36 -0
- package/dist/core/retention.js +31 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/compaction-call-options.d.ts +13 -1
- package/dist/core/runner/compaction-call-options.js +85 -0
- package/dist/core/runner/prepare-memory.d.ts +9 -0
- package/dist/core/runner/prepare-memory.js +28 -2
- package/dist/core/runner/prepare-task.d.ts +4 -0
- package/dist/core/runner/prepare-task.js +183 -26
- package/dist/core/runner/runtask.js +101 -47
- package/dist/core/runner/turn-attachments.js +3 -1
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +1 -0
- package/dist/core/tool-result-store.d.ts +2 -0
- package/dist/core/tool-result-store.js +1 -0
- package/dist/core/types.d.ts +12 -2
- package/dist/engine/compaction/compaction.d.ts +11 -2
- package/dist/engine/compaction/compaction.js +87 -9
- package/dist/engine/harness/agent-harness.js +11 -1
- package/dist/engine/llm/validation.js +11 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -1
- package/dist/prompt-assembly/event-registry.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.13.0 — 2026-08-05
|
|
4
|
+
|
|
5
|
+
### BREAKING
|
|
6
|
+
|
|
7
|
+
- **Request-origin `org:` memory scopes now require fail-closed admission** (`RunnerDeps.memoryScopeAdmission`, new module `memory-admission.ts`). Under the v2 scope contract, an org scope in `TaskSpec.memory` (or a subagent's `AgentDefinition.memory`) that is not deployment-declared refuses the WHOLE prepare: resolver absent ⇒ `memory.admission_required` (transient); resolver throw ⇒ same; principal absent / explicit refusal / partial admit / unrequested grant ⇒ `memory.admission_denied` (terminal). Verdicts are two-state — no silent narrowing of the request side (mount visibility must not become an org-membership probe); the one silent-but-disclosed narrowing is the org WRITE face, which collapses to read-only (`null`) unless the verdict grants it explicitly. **Zero migration for operator-pinned deployments**: scopes listed in the new `RunnerDeps.deploymentMemoryScopes` are deployment-origin (self-certified — no principal, no resolver needed); a present resolver may narrow them (disclosed via `onError`, phase `"memory"`). Delegation freeze: the admitted org set rides `InheritedGate.admittedOrgScopes` (always emitted; absent = older chain, read fail-closed as empty) and persists on the checkpoint data half (seed ∩ live fold — a resume can never widen it); a child's request-origin org scopes must sit inside the parent verdict. On admission-governed deployments, org-SHAPED scopes outside the v2 contract are refused outright; without a governance surface, opaque behavior is byte-identical. **Consumer flips**: probes pinning "request-side org scope mounts with no resolver" red.
|
|
8
|
+
|
|
9
|
+
- **`classifySubagentError` governance retryability is table-driven** (new module `governance-codes.ts`, closed `GOVERNANCE_CODES` registry — the machine reads the table, never the `_required`/`_denied` name suffix). `errorKind`'s closed domain is unchanged (no new members), but governance is no longer uniformly non-retryable: TRANSIENT codes (`memory.admission_required`, `config.compliance_required` — control-plane absence/outage) now classify `retryable: true`; TERMINAL codes (`*_denied`, `config.locked_key`, the whole legacy `usage.*` family — whose deferred retryability stays on the paired `retryAfterMs` field) stay `retryable: false`. **Consumer flips**: probes pinning "governance ⇒ retryable:false unconditionally" red.
|
|
10
|
+
|
|
11
|
+
- **Org-admission state is version-fenced on checkpoints** (`ORG_ADMISSION_CHECKPOINT_VERSION = 6`; `MAX_SUPPORTED_CHECKPOINT_VERSION` raised to 6). A suspend whose state carries org-admission freeze fields — the delegation-chain verdict, the session's OWN verdict (`ownAdmittedOrgScopes`, persisted even when adjudicated-EMPTY), and the frozen org WRITE grant (`ownAdmittedOrgWriteScope`; `null` = read-only, a resume can never gain write authority the suspend leg did not hold) — stamps v6, so a pre-admission worker rejects it pre-CAS (`unsupported_version`, stays pending) instead of resuming raw org scopes with no admission door. The session freeze applies to the FINAL admitted projection regardless of origin: a deployment-declared tenant layer the session already lost is dropped-and-disclosed on resume, never silently remounted. Checkpoints without org-admission state keep their historic stamps (zero version movement for deployments not using org memory).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Administrator lock layer** (`locked-config.ts`): `RunnerDeps.lockedConfig` declares locked keys from the closed registry (`mcp` / `toolPolicy` / `compliancePosture` / `retentionPolicy`). A task spec occupying a locked slot refuses the whole prepare (`config.locked_key`, two-state); an unknown key in the declaration refuses too (`config.locked_registry` — a security control that doesn't parse must not half-arm). One preflight resolver runs at the prepare door before any side effect and mints the snapshot all downstream assembly reads (fresh/resume/child legs covered structurally). Locks are config-time only — no request-time unlock; "who can unlock" ≡ "who can change the deployment config". Unlocked deployments byte-identical.
|
|
16
|
+
|
|
17
|
+
- **Compliance capability veto** (`compliance.ts`): `RunnerDeps.compliancePostureResolver` resolves a per-principal posture (closed profiles `hipaa`/`zdr` × closed capabilities `mcp_servers`/`workflows`/`web_fetch`/`org_memory_mount`; effective denies = builtin floor ∪ `additionalDenies`, supply can only tighten; unknown values refuse loudly). Fail-closed: resolver fault denies every managed capability + `onError`. A denied capability the spec explicitly requests refuses the prepare (`config.compliance_denied`; `config.compliance_required` when the denial came from the resolver fault). `web_fetch` is enforced in CC double-point form (roster refusal at prepare + a call-time lock in the tool gate). No resolver ⇒ byte-identical.
|
|
18
|
+
|
|
19
|
+
- **Managed retention declaration + contract** (`retention.ts`): stores declare `retention: "managed" | "none"` (declaration, never duck-typing; absence reads fail-closed as `"none"`; an unparseable declaration is a loud refusal). All bundled stores honestly declare `"none"`. `RunnerDeps.retentionPolicy` + a LOCKED `retentionPolicy` key over any `"none"` store refuses the deployment (`config.retention_capability` — via the exported `assertRetentionCapability` at startup, re-run every prepare as backstop). `ManagedRetentionCapability` is the contract TYPE a deployment scheduler implements (tenant-domain enumeration, CAS-fenced checkpoint expiry that releases pins, lineage-aware session deletion, orphan tool-result cleanup; idempotent, tombstoned, audited) — core ships no scheduler and does no cleanup on the task path.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Every task notification now delivers at the NEXT turn boundary (mid-work included).** `later`-priority frames (background agent/workflow completions, Monitor watch events) rode `harness.followUp()`, whose queue drains only when the agent would otherwise stop — a model busy-polling a background task (the exact model the receipt told "you will be notified, do not poll") never received the completion, and the end-of-run drain then extended finished runs with stale frames. Re-anchored against CC 2.1.221: queued task-notification inputs are unconditionally deliverable at the boundary. Delivery order is ARRIVAL order; consecutive buffered notifications drain as ONE boundary batch (the retired `followUpMode:"all"` batching, now on the steer lane), and an engine-note backlog past 50 frames parks per session instead of crowding out user steering. `priority` keeps its park/uplink semantics; the harness followUp/stop-gate seam is untouched. Consumer note: a probe pinning "a busy run never sees a `later` completion frame" reds.
|
|
24
|
+
|
|
25
|
+
- **`finalVerification` mechanism hardening (four of the seven field failure modes).** ① The injection headroom guard read only the turn axis — it now also reads the tightest caller-armed budget axis (tokens/cost/walltime, same axes as the limit-approach frames); ≥90% full skips the injection, so the gate can no longer convert an externally-passing run into `limits.*_exceeded`. ② Both injections emit the standard `steering_injected` echo (**new closed-set member: `source: "final_verification"`** — switches over the source union add an arm), closing the stats-vs-stream observability gap. ③ The nudge licenses cleaning up residue the model's own testing created (the state-harmless clause no longer reads as "leave your test residue in place"). ④ The nudge forbids laundering pre-existing uncertainty into "confirmed". The remaining modes are design-bounded: the engine never judges check semantics, and self-grading is the deployment's verifier-role wiring.
|
|
26
|
+
|
|
27
|
+
## 5.12.0 — 2026-08-05
|
|
28
|
+
|
|
29
|
+
### BREAKING
|
|
30
|
+
|
|
31
|
+
- **The compaction summary request FORKS the main conversation (CC form).** When the summary model IS the main model (no `compactionModel`/`summarize` role) and the run's latest main request is a faithful (untrimmed) projection, the summary request is now that request byte-for-byte — same system prompt, same message prefix, same tools — plus ONE appended user-role instruction message, so the provider's prompt cache re-serves the already-paid prefix instead of re-prefilling an independent serialized-conversation request at full price every boundary. Degradations keep the independent form byte-identical: a direct `maybeCompact` caller without `forkContext`; an explicit `compactionModel`; the PTL-recovery lane; a lossy request projection (guard-trim drop, clearStale content blanking, aggregate tool-result/media caps, orphan sweep) clears the fork seam for that boundary; and a fork attempt that fails as prompt-too-long, the empty-summary class (e.g. the model answered the fork with a tool call), OR a non-conforming response (the fork instruction demands the summary inside a closed `<summary>` envelope; a response without one — e.g. an obedient refusal under a BYOM system prompt that forbids summaries — is discarded, never persisted as history) falls back ONCE inside `compact()` to the independent (clamp-capable) form — a generic summarizer failure stays terminal (one call per pass). The fork is also refused when the snapshot's served model differs from the summary model (mid-run budget degradation): the model is part of the provider cache key, so that fork would pay a full prefill for nothing. **Consumer flips**: probes pinning the OLD summary-request shape (independent `SUMMARIZATION_SYSTEM_PROMPT` + single serialized user message) red — detect a summary request as EITHER form (independent system prompt, OR main prefix + trailing user instruction mentioning the structured summary). `MaybeCompactOptions.forkContext` (accessor) and a trailing optional `compact(..., forkContext)` parameter are the additive seams.
|
|
32
|
+
|
|
33
|
+
- **Default `keepRecentTokens` drops 20000 → 0 (full compaction, CC form).** With the knob unset, a landed compaction now keeps NO verbatim token budget — the summary replaces the compacted region entirely (the cut still lands on the nearest valid floor, so tool pairs and an in-progress turn's suffix stay intact; the turn-prefix leg summarizes the rest). Explicit `keepRecentTokens` values keep their exact meaning and are the opt-back. **Consumer flips**: probes pinning "pre-compaction verbatim text survives after a default-settings compaction" red.
|
|
34
|
+
|
|
35
|
+
- **The post-compact background-task restatement is DEFAULT ON** (`attachments.backgroundTasks` becomes `boolean`; explicit `false` is the opt-out — same contract as the listing family). CC hard-codes this behavior; the opt-in default left every non-shell host with a window after a compaction where the model had no context evidence of an in-flight task — duplicate spawns and premature turn ends. Explicit `true`/`false` keep their exact meaning; only the absent case flips. The zero-config fast path (no attachment state at all) now applies only to runs that explicitly opt out — the residual cost on default runs is one cadence-clock pass per message (reducers stay type-gated no-ops). **Consumer flips**: a probe pinning "no `background_tasks` frame without opt-in" reds; the frame itself is byte-unchanged.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`compaction cannot help` — honest disclosure for an oversized fixed prefix.** When the fixed per-request prefix (system prompt + tool schemas) alone meets/exceeds the compaction trigger threshold, the run emits one `onError` frame (`phase:"config"`) naming the numbers, at trigger-geometry derivation time — compaction only shrinks conversation history, so such a run re-triggers or overflows no matter how well summaries land. One frame per run; geometry itself unchanged.
|
|
40
|
+
|
|
41
|
+
- **`compaction.staleToolResultOffload` — opt-in stale tool-result offload (request-projection clear).** In the outgoing request, same-tool results older than the most recent `keepRecentPerTool` (default 3) are replaced by a short pointer (persisted under the design/30 tool-result store, readable back via `read_tool_result`) once the swap saves ≥ `minSavingsChars` (default 2000). Refs are content-digested (`<toolCallId>_s<sha256-128bit>`), so recurring tool-call ids — which carry no cross-turn uniqueness contract under BYOM — cannot make one pointer resolve to another result's content (collision-resistant at 128 bits; tool results are untrusted text, so the digest width is chosen adversarially, not for accidental collisions). Replacement decisions are deterministic and one-way (an already-replaced result renders the same pointer bytes on every later request — no flip-flop churn); the verbatim→pointer transition itself is a bounded once-per-result cache break, amortized by the `minSavingsChars` floor. The session transcript is never rewritten. Absent (default) = requests byte-identical to before; knob set while the offload store is disabled = one `config` onError and the knob stays inert.
|
|
42
|
+
|
|
43
|
+
- **A PARKED child joins the post-compact restatement.** The snapshot filter (pending/running) gains `parked`, rendered as `parked awaiting an out-of-band approval — do NOT re-issue the gated call` — a child waiting at an approval gate is exactly the task a post-compact model must not re-issue.
|
|
44
|
+
|
|
45
|
+
- **The walltime axis feeds the limit-approach frames.** A caller-armed hard deadline (`limits.maxWalltimeMs`) now contributes its fill ratio to the design/164 converge/deliver advisories alongside tokens/cost/turns — previously the deadline was the only armed ceiling that never warned, so the model learned it was out of time by being cut. Same thresholds (default 80%/95%), same caller-armed rule, same per-slice clock the enforcement reads; frames name the axis (`walltime budget`). Consumer note: a probe pinning "no `limit_approach` steering on a walltime-only run" reds.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **The summarization instruction carries a fabricated-user-turn guard** (both prompt variants and the fork-form instruction): only real user-role messages count as user messages — a `"user: ..."`/`"Human: ..."` line quoted inside assistant output is the assistant's own text, never attribute it to the user.
|
|
50
|
+
|
|
51
|
+
- **`summarize` role doc corrected**: the main model is the correct default baseline (fork form amortizes the prefix via prompt cache); a separate cheap model is an explicit quality-for-price tradeoff, not the recommended posture.
|
|
52
|
+
|
|
53
|
+
- **The exec-gate's buffered leg keeps ran-then-cut partial output.** A timed-out/aborted setup step on a non-streaming env reported empty stdout/stderr even though the `ExecutionError` carried the captured partials — the streaming leg already kept its chunks. Partials now ride the step result, clamped at `maxOutputBytes`.
|
|
54
|
+
|
|
3
55
|
## 5.11.0 — 2026-08-04
|
|
4
56
|
|
|
5
57
|
### BREAKING
|
package/dist/agents/subagent.js
CHANGED
|
@@ -18,6 +18,7 @@ import { addWorktree } from "../core/git-worktree-env.js";
|
|
|
18
18
|
import { shellQuote } from "../tools/fs/search.js";
|
|
19
19
|
import { BG_AGENT_REAP_STOP_ERROR } from "../core/task-registry.js";
|
|
20
20
|
import { extractErrorCode } from "../brain/errors.js";
|
|
21
|
+
import { governanceRetryClass } from "../core/governance-codes.js";
|
|
21
22
|
import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX, RUNNING_AGENT_OBSERVE_EVERY_BEATS } from "../config/defaults.js";
|
|
22
23
|
function rollupDelegatedCost(stats, nested) {
|
|
23
24
|
if (stats.costMicroUsd === undefined)
|
|
@@ -221,6 +222,7 @@ export function classifySubagentError(child) {
|
|
|
221
222
|
return undefined;
|
|
222
223
|
const code = child.errorCode ?? extractErrorCode(child.errorMessage);
|
|
223
224
|
const msg = (child.errorMessage ?? "").toLowerCase();
|
|
225
|
+
const governanceClass = code !== undefined ? governanceRetryClass(code) : undefined;
|
|
224
226
|
const errorKind = code === "rate_limit"
|
|
225
227
|
? "rate_limit"
|
|
226
228
|
: code === "server" && (msg.includes("overloaded") || msg.includes("529"))
|
|
@@ -229,10 +231,10 @@ export function classifySubagentError(child) {
|
|
|
229
231
|
? "timeout"
|
|
230
232
|
: code === "network" || code === "server"
|
|
231
233
|
? "network"
|
|
232
|
-
:
|
|
234
|
+
: governanceClass !== undefined
|
|
233
235
|
? "governance"
|
|
234
236
|
: "logic";
|
|
235
|
-
return { errorKind, retryable: errorKind
|
|
237
|
+
return { errorKind, retryable: errorKind === "governance" ? governanceClass === "transient" : errorKind !== "logic" };
|
|
236
238
|
}
|
|
237
239
|
export function completedAgentCard(child, extras) {
|
|
238
240
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AgentMessage, type CompactionSettings, type Session, type ThinkingLevel } from "../internal/harness.js";
|
|
2
|
-
import type { Model } from "../internal/llm.js";
|
|
2
|
+
import type { Message, Model, Tool } from "../internal/llm.js";
|
|
3
3
|
import type { Hooks } from "./hooks.js";
|
|
4
4
|
import type { TraceEvent } from "./trace.js";
|
|
5
5
|
import type { Brain } from "./types.js";
|
|
@@ -20,6 +20,16 @@ export interface CompactionWindowSafetyInfo {
|
|
|
20
20
|
}
|
|
21
21
|
export declare const STALE_ANCHOR_STRUCTURAL_MARGIN = 2;
|
|
22
22
|
export declare function sanitizeCompactionSettings(settings: CompactionSettings, contextWindow: number | undefined): CompactionSettings;
|
|
23
|
+
export interface CompactionForkContext {
|
|
24
|
+
systemPrompt?: string;
|
|
25
|
+
systemBlocks?: Array<{
|
|
26
|
+
text: string;
|
|
27
|
+
cacheControlBoundary: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
messages: Message[];
|
|
30
|
+
tools?: Tool[];
|
|
31
|
+
modelId?: string;
|
|
32
|
+
}
|
|
23
33
|
export interface MaybeCompactOptions {
|
|
24
34
|
onNotifyError?: (failure: import("./safe-notify.js").SafeNotifyFailure) => void;
|
|
25
35
|
session: Session;
|
|
@@ -34,6 +44,7 @@ export interface MaybeCompactOptions {
|
|
|
34
44
|
};
|
|
35
45
|
model: Model;
|
|
36
46
|
compactionModel?: Model;
|
|
47
|
+
forkContext?: () => CompactionForkContext | undefined;
|
|
37
48
|
brain: Brain;
|
|
38
49
|
getApiKeyAndHeaders?: (model: Model) => Promise<{
|
|
39
50
|
apiKey: string;
|
|
@@ -234,12 +234,14 @@ export async function maybeCompact(opts) {
|
|
|
234
234
|
const auth = await opts.getApiKeyAndHeaders?.(summaryModel);
|
|
235
235
|
const summarySignal = opts.signal;
|
|
236
236
|
const runtime = brainToRuntime(opts.brain);
|
|
237
|
+
const forkCandidate = opts.compactionModel === undefined ? opts.forkContext?.() : undefined;
|
|
238
|
+
const forkContext = forkCandidate !== undefined && (forkCandidate.modelId === undefined || forkCandidate.modelId === summaryModel.id) ? forkCandidate : undefined;
|
|
237
239
|
let res;
|
|
238
240
|
summaryStartAt = Date.now();
|
|
239
241
|
try {
|
|
240
242
|
res = await compact(prep.value, summaryModel, auth?.apiKey, auth?.headers, effectiveInstructions, summarySignal, opts.thinking, undefined, runtime, undefined, opts.onInputTruncated, () => {
|
|
241
243
|
ptlRetries += 1;
|
|
242
|
-
});
|
|
244
|
+
}, forkContext);
|
|
243
245
|
}
|
|
244
246
|
finally {
|
|
245
247
|
summaryEndAt = Date.now();
|
|
@@ -142,6 +142,10 @@ export interface CheckpointState {
|
|
|
142
142
|
rules: SessionPermissionRules;
|
|
143
143
|
}>;
|
|
144
144
|
shellGate?: "off" | "always" | "classify";
|
|
145
|
+
admittedOrgScopes?: readonly string[];
|
|
146
|
+
ownAdmittedOrgScopes?: readonly string[];
|
|
147
|
+
ownAdmittedOrgWriteScope?: string | null;
|
|
148
|
+
orgAdmissionGoverned?: true;
|
|
145
149
|
requiresParentConstraint: boolean;
|
|
146
150
|
parentConstraintCount?: number;
|
|
147
151
|
};
|
|
@@ -158,7 +162,8 @@ export declare const CURRENT_CHECKPOINT_VERSION = 1;
|
|
|
158
162
|
export declare const RESOURCE_CHECKPOINT_VERSION = 2;
|
|
159
163
|
export declare const BINDING_CHECKPOINT_VERSION = 3;
|
|
160
164
|
export declare const TOKEN_CHECKPOINT_VERSION = 5;
|
|
161
|
-
export declare const
|
|
165
|
+
export declare const ORG_ADMISSION_CHECKPOINT_VERSION = 6;
|
|
166
|
+
export declare const MAX_SUPPORTED_CHECKPOINT_VERSION = 6;
|
|
162
167
|
export declare function checkpointVersionOf(cp: Pick<Checkpoint, "version">): number;
|
|
163
168
|
export interface ResourceLedger {
|
|
164
169
|
totalBudgetMicroUsd?: number;
|
|
@@ -255,6 +260,7 @@ export declare class CheckpointError extends Error {
|
|
|
255
260
|
} | undefined);
|
|
256
261
|
}
|
|
257
262
|
export interface CheckpointStore {
|
|
263
|
+
readonly retention?: import("./retention.js").RetentionDeclaration;
|
|
258
264
|
put(token: CheckpointToken, cp: Checkpoint): Promise<void>;
|
|
259
265
|
get(token: CheckpointToken): Promise<Checkpoint | null>;
|
|
260
266
|
resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise<boolean>;
|
|
@@ -280,6 +286,7 @@ export declare function checkpointRowMatches(cp: Checkpoint | undefined, scope:
|
|
|
280
286
|
export declare function checkpointOccMatches(cp: Checkpoint, expect?: ResolveExpectation): boolean;
|
|
281
287
|
export type CheckpointFaultMode = "resolve-after-commit" | "resolve-before-commit";
|
|
282
288
|
export declare class InMemoryCheckpointStore implements CheckpointStore {
|
|
289
|
+
readonly retention: "none";
|
|
283
290
|
private cps;
|
|
284
291
|
private fault;
|
|
285
292
|
put(token: CheckpointToken, cp: Checkpoint): Promise<void>;
|
|
@@ -128,7 +128,8 @@ export const CURRENT_CHECKPOINT_VERSION = 1;
|
|
|
128
128
|
export const RESOURCE_CHECKPOINT_VERSION = 2;
|
|
129
129
|
export const BINDING_CHECKPOINT_VERSION = 3;
|
|
130
130
|
export const TOKEN_CHECKPOINT_VERSION = 5;
|
|
131
|
-
export const
|
|
131
|
+
export const ORG_ADMISSION_CHECKPOINT_VERSION = 6;
|
|
132
|
+
export const MAX_SUPPORTED_CHECKPOINT_VERSION = 6;
|
|
132
133
|
export function checkpointVersionOf(cp) {
|
|
133
134
|
return cp.version ?? 0;
|
|
134
135
|
}
|
|
@@ -250,6 +251,7 @@ export function checkpointOccMatches(cp, expect) {
|
|
|
250
251
|
return expect === undefined || (cp.rev ?? 0) === expect.rev;
|
|
251
252
|
}
|
|
252
253
|
export class InMemoryCheckpointStore {
|
|
254
|
+
retention = "none";
|
|
253
255
|
cps = new Map();
|
|
254
256
|
fault = null;
|
|
255
257
|
async put(token, cp) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ComplianceProfile = "hipaa" | "zdr";
|
|
2
|
+
export type ComplianceCapability = "mcp_servers" | "workflows" | "web_fetch" | "org_memory_mount";
|
|
3
|
+
export declare const COMPLIANCE_CAPABILITIES: readonly ["mcp_servers", "workflows", "web_fetch", "org_memory_mount"];
|
|
4
|
+
export interface CompliancePosture {
|
|
5
|
+
profile: ComplianceProfile;
|
|
6
|
+
additionalDenies?: readonly ComplianceCapability[];
|
|
7
|
+
}
|
|
8
|
+
export declare const BUILTIN_COMPLIANCE_DENIES: Record<ComplianceProfile, readonly ComplianceCapability[]>;
|
|
9
|
+
export declare function resolveComplianceDenies(posture: CompliancePosture): ReadonlySet<ComplianceCapability>;
|
|
10
|
+
export declare const WEB_FETCH_TOOL_NAME = "WebFetch";
|
|
11
|
+
export declare function complianceCallDenial(denies: ReadonlySet<ComplianceCapability>, toolName: string): string | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const COMPLIANCE_CAPABILITIES = ["mcp_servers", "workflows", "web_fetch", "org_memory_mount"];
|
|
2
|
+
export const BUILTIN_COMPLIANCE_DENIES = {
|
|
3
|
+
hipaa: ["web_fetch", "org_memory_mount"],
|
|
4
|
+
zdr: ["org_memory_mount"],
|
|
5
|
+
};
|
|
6
|
+
const PROFILES = ["hipaa", "zdr"];
|
|
7
|
+
function codedError(code, message) {
|
|
8
|
+
const e = new Error(message);
|
|
9
|
+
e.code = code;
|
|
10
|
+
return e;
|
|
11
|
+
}
|
|
12
|
+
export function resolveComplianceDenies(posture) {
|
|
13
|
+
if (!PROFILES.includes(posture.profile)) {
|
|
14
|
+
throw codedError("config.compliance_posture", `compliance posture profile "${String(posture.profile)}" is not a member of the closed profile set ` +
|
|
15
|
+
`(${PROFILES.join(", ")}) — refusing to half-apply an unparseable posture.`);
|
|
16
|
+
}
|
|
17
|
+
const denies = new Set(BUILTIN_COMPLIANCE_DENIES[posture.profile]);
|
|
18
|
+
for (const cap of posture.additionalDenies ?? []) {
|
|
19
|
+
if (!COMPLIANCE_CAPABILITIES.includes(cap)) {
|
|
20
|
+
throw codedError("config.compliance_posture", `compliance posture additionalDenies contains "${String(cap)}", not a member of the closed capability set ` +
|
|
21
|
+
`(${COMPLIANCE_CAPABILITIES.join(", ")}) — a misspelled deny must never silently not-enforce.`);
|
|
22
|
+
}
|
|
23
|
+
denies.add(cap);
|
|
24
|
+
}
|
|
25
|
+
return denies;
|
|
26
|
+
}
|
|
27
|
+
export const WEB_FETCH_TOOL_NAME = "WebFetch";
|
|
28
|
+
export function complianceCallDenial(denies, toolName) {
|
|
29
|
+
if (denies.has("web_fetch") && toolName === WEB_FETCH_TOOL_NAME) {
|
|
30
|
+
return (`"${toolName}" is denied by this principal's compliance posture (capability "web_fetch") — ` +
|
|
31
|
+
`the call is refused by policy; do not retry it.`);
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
package/dist/core/exec-gate.js
CHANGED
|
@@ -65,7 +65,18 @@ async function runBufferedStep(env, step, options, maxOutputBytes) {
|
|
|
65
65
|
abortSignal: options.signal,
|
|
66
66
|
});
|
|
67
67
|
if (!res.ok) {
|
|
68
|
-
|
|
68
|
+
const po = clampBytes(res.error.partialStdout ?? "", maxOutputBytes);
|
|
69
|
+
const pe = clampBytes(res.error.partialStderr ?? "", maxOutputBytes);
|
|
70
|
+
return {
|
|
71
|
+
label,
|
|
72
|
+
command: step.command,
|
|
73
|
+
exitCode: null,
|
|
74
|
+
stdout: po.text,
|
|
75
|
+
stderr: pe.text,
|
|
76
|
+
ok: false,
|
|
77
|
+
errorCode: res.error.code,
|
|
78
|
+
truncated: po.truncated || pe.truncated,
|
|
79
|
+
};
|
|
69
80
|
}
|
|
70
81
|
const so = clampBytes(res.value.stdout, maxOutputBytes);
|
|
71
82
|
const se = clampBytes(res.value.stderr, maxOutputBytes);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type GovernanceRetryClass = "transient" | "terminal";
|
|
2
|
+
export declare const GOVERNANCE_CODES: {
|
|
3
|
+
readonly "usage.window_exhausted": "terminal";
|
|
4
|
+
readonly "memory.admission_required": "transient";
|
|
5
|
+
readonly "memory.admission_denied": "terminal";
|
|
6
|
+
readonly "config.compliance_required": "transient";
|
|
7
|
+
readonly "config.compliance_denied": "terminal";
|
|
8
|
+
readonly "config.locked_key": "terminal";
|
|
9
|
+
};
|
|
10
|
+
export type GovernanceCode = keyof typeof GOVERNANCE_CODES;
|
|
11
|
+
export declare const NON_GOVERNANCE_MEMORY_CODES: ReadonlySet<string>;
|
|
12
|
+
export declare function governanceRetryClass(code: string): GovernanceRetryClass | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const GOVERNANCE_CODES = {
|
|
2
|
+
"usage.window_exhausted": "terminal",
|
|
3
|
+
"memory.admission_required": "transient",
|
|
4
|
+
"memory.admission_denied": "terminal",
|
|
5
|
+
"config.compliance_required": "transient",
|
|
6
|
+
"config.compliance_denied": "terminal",
|
|
7
|
+
"config.locked_key": "terminal",
|
|
8
|
+
};
|
|
9
|
+
export const NON_GOVERNANCE_MEMORY_CODES = new Set([
|
|
10
|
+
"memory.secret_detected",
|
|
11
|
+
"memory.promotable_ungated",
|
|
12
|
+
"memory.announce_failed",
|
|
13
|
+
"memory.partition_adopt_failed",
|
|
14
|
+
"memory.partition_split",
|
|
15
|
+
"memory.tail",
|
|
16
|
+
]);
|
|
17
|
+
export function governanceRetryClass(code) {
|
|
18
|
+
if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
|
|
19
|
+
return GOVERNANCE_CODES[code];
|
|
20
|
+
}
|
|
21
|
+
if (code.startsWith("usage."))
|
|
22
|
+
return "terminal";
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RunnerDeps, TaskSpec } from "./types.js";
|
|
2
|
+
import type { ToolPolicy } from "./tool-policy.js";
|
|
3
|
+
export type LockedKey = "mcp" | "toolPolicy" | "compliancePosture" | "retentionPolicy";
|
|
4
|
+
export declare const LOCKED_KEY_REGISTRY: {
|
|
5
|
+
readonly mcp: {
|
|
6
|
+
readonly specFields: readonly ["mcp"];
|
|
7
|
+
};
|
|
8
|
+
readonly toolPolicy: {
|
|
9
|
+
readonly specFields: readonly ["toolPolicy"];
|
|
10
|
+
};
|
|
11
|
+
readonly compliancePosture: {
|
|
12
|
+
readonly specFields: readonly [];
|
|
13
|
+
};
|
|
14
|
+
readonly retentionPolicy: {
|
|
15
|
+
readonly specFields: readonly [];
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export interface LockedConfig {
|
|
19
|
+
keys: readonly LockedKey[];
|
|
20
|
+
}
|
|
21
|
+
export interface LockedPreflight {
|
|
22
|
+
lockedKeys: ReadonlySet<LockedKey>;
|
|
23
|
+
mcp: TaskSpec["mcp"];
|
|
24
|
+
toolPolicy: ToolPolicy | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare function resolveLockedKeys(config: LockedConfig | undefined): ReadonlySet<LockedKey>;
|
|
27
|
+
export declare function preflightLockedConfig(spec: TaskSpec, deps: RunnerDeps): LockedPreflight;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const LOCKED_KEY_REGISTRY = {
|
|
2
|
+
mcp: { specFields: ["mcp"] },
|
|
3
|
+
toolPolicy: { specFields: ["toolPolicy"] },
|
|
4
|
+
compliancePosture: { specFields: [] },
|
|
5
|
+
retentionPolicy: { specFields: [] },
|
|
6
|
+
};
|
|
7
|
+
function codedError(code, message) {
|
|
8
|
+
const e = new Error(message);
|
|
9
|
+
e.code = code;
|
|
10
|
+
return e;
|
|
11
|
+
}
|
|
12
|
+
export function resolveLockedKeys(config) {
|
|
13
|
+
if (config === undefined)
|
|
14
|
+
return new Set();
|
|
15
|
+
const keys = new Set();
|
|
16
|
+
for (const key of config.keys) {
|
|
17
|
+
if (!Object.prototype.hasOwnProperty.call(LOCKED_KEY_REGISTRY, key)) {
|
|
18
|
+
throw codedError("config.locked_registry", `lockedConfig.keys contains "${String(key)}", which is not a member of the locked-key registry ` +
|
|
19
|
+
`(${Object.keys(LOCKED_KEY_REGISTRY).join(", ")}). A lock that does not parse must not half-arm — ` +
|
|
20
|
+
`fix the deployment's lock declaration.`);
|
|
21
|
+
}
|
|
22
|
+
keys.add(key);
|
|
23
|
+
}
|
|
24
|
+
return keys;
|
|
25
|
+
}
|
|
26
|
+
export function preflightLockedConfig(spec, deps) {
|
|
27
|
+
const lockedKeys = resolveLockedKeys(deps.lockedConfig);
|
|
28
|
+
for (const key of lockedKeys) {
|
|
29
|
+
for (const field of LOCKED_KEY_REGISTRY[key].specFields) {
|
|
30
|
+
if (spec[field] !== undefined) {
|
|
31
|
+
throw codedError("config.locked_key", `TaskSpec.${field} is administratively locked by this deployment (locked key "${key}") — ` +
|
|
32
|
+
`the task-supplied value is refused wholesale (locks are config-time; there is no request-time ` +
|
|
33
|
+
`unlock). Remove the field from the task, or change the deployment's lock configuration.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
lockedKeys,
|
|
39
|
+
mcp: lockedKeys.has("mcp") ? undefined : spec.mcp,
|
|
40
|
+
toolPolicy: lockedKeys.has("toolPolicy") ? deps.toolPolicy : (spec.toolPolicy ?? deps.toolPolicy),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { NormalizedMemorySpec } from "./memory.js";
|
|
2
|
+
export type MemoryScopeOrigin = "deployment" | "request";
|
|
3
|
+
export interface MemoryScopeRequest {
|
|
4
|
+
scope: string;
|
|
5
|
+
origin: MemoryScopeOrigin;
|
|
6
|
+
}
|
|
7
|
+
export type MemoryAdmissionVerdict = {
|
|
8
|
+
ok: true;
|
|
9
|
+
scopes: readonly string[];
|
|
10
|
+
writeScope: string | null;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
export type MemoryScopeAdmission = (input: {
|
|
16
|
+
principal: string | undefined;
|
|
17
|
+
requested: ReadonlyArray<MemoryScopeRequest>;
|
|
18
|
+
requestedWriteScope: MemoryScopeRequest | null;
|
|
19
|
+
}) => MemoryAdmissionVerdict | Promise<MemoryAdmissionVerdict>;
|
|
20
|
+
export interface MemoryAdmissionInput {
|
|
21
|
+
memorySpec: NormalizedMemorySpec;
|
|
22
|
+
principal: string | undefined;
|
|
23
|
+
admission: MemoryScopeAdmission | undefined;
|
|
24
|
+
deploymentScopes: ReadonlySet<string>;
|
|
25
|
+
orgMemoryDenied: boolean;
|
|
26
|
+
complianceDegraded: boolean;
|
|
27
|
+
parentAdmittedOrgScopes: readonly string[] | undefined;
|
|
28
|
+
priorOwnVerdict: OwnOrgAdmissionVerdict | undefined;
|
|
29
|
+
governedProvenance: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface MemoryAdmissionOutcome {
|
|
32
|
+
scopes: string[];
|
|
33
|
+
writeScope: string | null;
|
|
34
|
+
admittedOrgScopes: string[];
|
|
35
|
+
droppedDeploymentScopes: string[];
|
|
36
|
+
writeScopeNarrowed: boolean;
|
|
37
|
+
ownVerdict: OwnOrgAdmissionVerdict | undefined;
|
|
38
|
+
}
|
|
39
|
+
export declare function foldAdmissionFreeze(input: {
|
|
40
|
+
delegated: boolean;
|
|
41
|
+
inherited: readonly string[] | undefined;
|
|
42
|
+
}): readonly string[] | undefined;
|
|
43
|
+
export interface OwnOrgAdmissionVerdict {
|
|
44
|
+
scopes: readonly string[];
|
|
45
|
+
writeScope: string | null;
|
|
46
|
+
}
|
|
47
|
+
export declare function admitMemoryScopes(input: MemoryAdmissionInput): Promise<MemoryAdmissionOutcome>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { parseScopeKey } from "./memory-engine/scope-contract.js";
|
|
2
|
+
function codedError(code, message) {
|
|
3
|
+
const e = new Error(message);
|
|
4
|
+
e.code = code;
|
|
5
|
+
return e;
|
|
6
|
+
}
|
|
7
|
+
export function foldAdmissionFreeze(input) {
|
|
8
|
+
return input.delegated ? (input.inherited ?? []) : input.inherited;
|
|
9
|
+
}
|
|
10
|
+
const ORG_SHAPE = /^org:/;
|
|
11
|
+
function orgScopesOf(spec) {
|
|
12
|
+
if (spec.scopeContract !== "v2")
|
|
13
|
+
return { readOrg: [], writeOrg: null };
|
|
14
|
+
const readOrg = spec.scopes.filter((s) => parseScopeKey(s).kind === "org");
|
|
15
|
+
const writeOrg = spec.writeScope !== null && parseScopeKey(spec.writeScope).kind === "org" ? spec.writeScope : null;
|
|
16
|
+
return { readOrg, writeOrg };
|
|
17
|
+
}
|
|
18
|
+
export async function admitMemoryScopes(input) {
|
|
19
|
+
const { memorySpec, principal, admission, deploymentScopes, parentAdmittedOrgScopes } = input;
|
|
20
|
+
const governed = admission !== undefined ||
|
|
21
|
+
deploymentScopes.size > 0 ||
|
|
22
|
+
input.priorOwnVerdict !== undefined ||
|
|
23
|
+
input.governedProvenance ||
|
|
24
|
+
input.orgMemoryDenied ||
|
|
25
|
+
(parentAdmittedOrgScopes !== undefined && parentAdmittedOrgScopes.length > 0);
|
|
26
|
+
if (governed && memorySpec.scopeContract !== "v2") {
|
|
27
|
+
const shaped = [...memorySpec.scopes, ...(memorySpec.writeScope !== null ? [memorySpec.writeScope] : [])].find((s) => ORG_SHAPE.test(s));
|
|
28
|
+
if (shaped !== undefined) {
|
|
29
|
+
throw codedError("memory.admission_denied", `memory scope "${shaped}" is org-shaped but the task did not opt into the v2 scope contract ` +
|
|
30
|
+
`(memory.scopeContract: "v2") — on a deployment with org-memory admission configured, org scopes ` +
|
|
31
|
+
`outside the contract are refused (fail-closed), not mounted as opaque keys.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const { readOrg, writeOrg } = orgScopesOf(memorySpec);
|
|
35
|
+
const allOrg = [...readOrg, ...(writeOrg !== null && !readOrg.includes(writeOrg) ? [writeOrg] : [])];
|
|
36
|
+
if (allOrg.length === 0) {
|
|
37
|
+
return {
|
|
38
|
+
scopes: [...memorySpec.scopes],
|
|
39
|
+
writeScope: memorySpec.writeScope,
|
|
40
|
+
admittedOrgScopes: [],
|
|
41
|
+
droppedDeploymentScopes: [],
|
|
42
|
+
writeScopeNarrowed: false,
|
|
43
|
+
ownVerdict: undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const priorOwn = input.priorOwnVerdict;
|
|
47
|
+
const applySessionFreeze = (proj) => {
|
|
48
|
+
let { admitted, scopes, writeScope, dropped, writeScopeNarrowed } = proj;
|
|
49
|
+
if (priorOwn !== undefined) {
|
|
50
|
+
const outsideRequest = admitted.filter((s) => !deploymentScopes.has(s) && !priorOwn.scopes.includes(s));
|
|
51
|
+
if (outsideRequest.length > 0) {
|
|
52
|
+
throw codedError("memory.admission_denied", `org memory scope(s) ${outsideRequest.map((s) => `"${s}"`).join(", ")} are outside this session's frozen ` +
|
|
53
|
+
`admission verdict (recorded at suspend) — a session's org grant can only narrow across suspend/resume.`);
|
|
54
|
+
}
|
|
55
|
+
const frozenOut = admitted.filter((s) => !priorOwn.scopes.includes(s));
|
|
56
|
+
if (frozenOut.length > 0) {
|
|
57
|
+
dropped = [...dropped, ...frozenOut];
|
|
58
|
+
admitted = admitted.filter((s) => priorOwn.scopes.includes(s));
|
|
59
|
+
scopes = scopes.filter((s) => !frozenOut.includes(s));
|
|
60
|
+
}
|
|
61
|
+
if (writeScope !== null && writeOrg !== null && writeScope === writeOrg && priorOwn.writeScope !== writeScope) {
|
|
62
|
+
writeScope = null;
|
|
63
|
+
writeScopeNarrowed = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
scopes,
|
|
68
|
+
writeScope,
|
|
69
|
+
admittedOrgScopes: admitted,
|
|
70
|
+
droppedDeploymentScopes: dropped,
|
|
71
|
+
writeScopeNarrowed,
|
|
72
|
+
ownVerdict: { scopes: admitted, writeScope: writeScope !== null && writeOrg !== null && writeScope === writeOrg ? writeScope : null },
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const stampedOrigins = memorySpec.scopeOrigins;
|
|
76
|
+
const originOf = (scope) => stampedOrigins !== undefined
|
|
77
|
+
? (stampedOrigins[scope] === "deployment" ? "deployment" : "request")
|
|
78
|
+
: deploymentScopes.has(scope)
|
|
79
|
+
? "deployment"
|
|
80
|
+
: "request";
|
|
81
|
+
const requests = allOrg.map((scope) => ({ scope, origin: originOf(scope) }));
|
|
82
|
+
const requestOrigin = requests.filter((r) => r.origin === "request");
|
|
83
|
+
if (input.orgMemoryDenied && requestOrigin.length > 0) {
|
|
84
|
+
throw codedError(input.complianceDegraded ? "config.compliance_required" : "config.compliance_denied", `org memory scope(s) ${requestOrigin.map((r) => `"${r.scope}"`).join(", ")} are denied for this principal by the ` +
|
|
85
|
+
`compliance posture (capability "org_memory_mount"${input.complianceDegraded ? "; posture resolver failing, fail-closed" : ""}).`);
|
|
86
|
+
}
|
|
87
|
+
if (parentAdmittedOrgScopes !== undefined) {
|
|
88
|
+
const exceeding = requestOrigin.filter((r) => !parentAdmittedOrgScopes.includes(r.scope));
|
|
89
|
+
if (exceeding.length > 0) {
|
|
90
|
+
throw codedError("memory.admission_denied", `delegated child requests org memory scope(s) ${exceeding.map((r) => `"${r.scope}"`).join(", ")} outside the ` +
|
|
91
|
+
`parent's frozen admission verdict — a child plane can only narrow the parent's org grant, never exceed it.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (admission === undefined) {
|
|
95
|
+
if (requestOrigin.length > 0) {
|
|
96
|
+
throw codedError("memory.admission_required", `org memory scope(s) ${requestOrigin.map((r) => `"${r.scope}"`).join(", ")} come from the request side but this ` +
|
|
97
|
+
`deployment has no memory-scope admission resolver (RunnerDeps.memoryScopeAdmission) — refused fail-closed. ` +
|
|
98
|
+
`Deployment-pinned org scopes belong in RunnerDeps.deploymentMemoryScopes.`);
|
|
99
|
+
}
|
|
100
|
+
return applySessionFreeze({
|
|
101
|
+
admitted: allOrg,
|
|
102
|
+
scopes: [...memorySpec.scopes],
|
|
103
|
+
writeScope: memorySpec.writeScope,
|
|
104
|
+
dropped: [],
|
|
105
|
+
writeScopeNarrowed: false,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (requestOrigin.length > 0 && principal === undefined) {
|
|
109
|
+
throw codedError("memory.admission_denied", `org memory scope(s) ${requestOrigin.map((r) => `"${r.scope}"`).join(", ")} require an authenticated ` +
|
|
110
|
+
`TaskSpec.principal for admission — a principal-less request cannot be admitted to a tenant plane.`);
|
|
111
|
+
}
|
|
112
|
+
let verdict;
|
|
113
|
+
try {
|
|
114
|
+
verdict = await admission({
|
|
115
|
+
principal,
|
|
116
|
+
requested: requests,
|
|
117
|
+
requestedWriteScope: writeOrg !== null ? { scope: writeOrg, origin: originOf(writeOrg) } : null,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const hinted = err.retryAfterMs;
|
|
122
|
+
const e = codedError("memory.admission_required", `memory-scope admission resolver failed (${err instanceof Error ? err.message : String(err)}) — refused fail-closed.`);
|
|
123
|
+
if (typeof hinted === "number" && Number.isFinite(hinted) && hinted > 0)
|
|
124
|
+
e.retryAfterMs = hinted;
|
|
125
|
+
throw e;
|
|
126
|
+
}
|
|
127
|
+
if (!verdict.ok) {
|
|
128
|
+
throw codedError("memory.admission_denied", `org memory admission denied: ${verdict.reason}`);
|
|
129
|
+
}
|
|
130
|
+
const unrequested = verdict.scopes.filter((s) => !allOrg.includes(s));
|
|
131
|
+
if (unrequested.length > 0 || (verdict.writeScope !== null && verdict.writeScope !== writeOrg)) {
|
|
132
|
+
throw codedError("memory.admission_denied", `memory-scope admission verdict granted unrequested scope(s) ` +
|
|
133
|
+
`${[...unrequested, ...(verdict.writeScope !== null && verdict.writeScope !== writeOrg ? [String(verdict.writeScope)] : [])].map((s) => `"${s}"`).join(", ")} — ` +
|
|
134
|
+
`admission can only narrow a request, never extend it; treating the verdict as a resolver fault.`);
|
|
135
|
+
}
|
|
136
|
+
const refusedRequest = requestOrigin.filter((r) => !verdict.scopes.includes(r.scope));
|
|
137
|
+
if (refusedRequest.length > 0) {
|
|
138
|
+
throw codedError("memory.admission_denied", `org memory scope(s) ${refusedRequest.map((r) => `"${r.scope}"`).join(", ")} were not admitted — the whole ` +
|
|
139
|
+
`prepare is refused (two-state verdict; partial admission is never applied silently).`);
|
|
140
|
+
}
|
|
141
|
+
const dropped = requests.filter((r) => r.origin === "deployment" && !verdict.scopes.includes(r.scope)).map((r) => r.scope);
|
|
142
|
+
const admitted = allOrg.filter((s) => !dropped.includes(s));
|
|
143
|
+
const scopes = memorySpec.scopes.filter((s) => !dropped.includes(s));
|
|
144
|
+
let writeScope = memorySpec.writeScope;
|
|
145
|
+
let writeScopeNarrowed = false;
|
|
146
|
+
if (writeOrg !== null) {
|
|
147
|
+
if (verdict.writeScope === writeOrg && !dropped.includes(writeOrg)) {
|
|
148
|
+
writeScope = writeOrg;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
writeScope = null;
|
|
152
|
+
writeScopeNarrowed = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return applySessionFreeze({ admitted, scopes, writeScope, dropped, writeScopeNarrowed });
|
|
156
|
+
}
|
package/dist/core/memory.d.ts
CHANGED
|
@@ -94,12 +94,14 @@ export interface NormalizedMemorySpec {
|
|
|
94
94
|
writeScope: string | null;
|
|
95
95
|
enabled: boolean;
|
|
96
96
|
scopeContract?: "v2";
|
|
97
|
+
scopeOrigins?: Readonly<Record<string, "deployment" | "request">>;
|
|
97
98
|
}
|
|
98
99
|
export type MemorySpecInput = {
|
|
99
100
|
scopes?: ReadonlyArray<string>;
|
|
100
101
|
writeScope?: string | null;
|
|
101
102
|
enabled?: boolean;
|
|
102
103
|
scopeContract?: "v2";
|
|
104
|
+
scopeOrigins?: Readonly<Record<string, "deployment" | "request">>;
|
|
103
105
|
};
|
|
104
106
|
export declare function normalizeMemorySpec(input: MemorySpecInput | undefined): NormalizedMemorySpec | undefined;
|
|
105
107
|
export declare const MAX_MEMORY_BYTES: number;
|
package/dist/core/memory.js
CHANGED
|
@@ -384,12 +384,13 @@ export function normalizeMemorySpec(input) {
|
|
|
384
384
|
else {
|
|
385
385
|
writeScope = scopes[scopes.length - 1];
|
|
386
386
|
}
|
|
387
|
+
const origins = input.scopeOrigins !== undefined ? { scopeOrigins: input.scopeOrigins } : {};
|
|
387
388
|
if (input.scopeContract === "v2") {
|
|
388
389
|
for (const key of writeScope !== null ? [...scopes, writeScope] : scopes)
|
|
389
390
|
parseScopeKey(key);
|
|
390
|
-
return { scopes, writeScope, enabled: input.enabled !== false, scopeContract: "v2" };
|
|
391
|
+
return { scopes, writeScope, enabled: input.enabled !== false, scopeContract: "v2", ...origins };
|
|
391
392
|
}
|
|
392
|
-
return { scopes, writeScope, enabled: input.enabled !== false };
|
|
393
|
+
return { scopes, writeScope, enabled: input.enabled !== false, ...origins };
|
|
393
394
|
}
|
|
394
395
|
export const MAX_MEMORY_BYTES = 100 * 1024;
|
|
395
396
|
export const MEMORY_WRAPPER_TAGS = ["user_memory"];
|