@sema-agent/core 5.49.0 → 5.50.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.
@@ -277,6 +277,19 @@ function raceAbort(p, signal, onAbort) {
277
277
  p.then(finish, () => finish(onAbort()));
278
278
  });
279
279
  }
280
+ function mcpRevocationWiring(deps) {
281
+ if (deps.mcpRevocations === undefined)
282
+ return undefined;
283
+ const ledger = deps.mcpRevocations;
284
+ return {
285
+ isRevoked: (name) => ledger.isRevoked(name),
286
+ onProbeFailure: (e) => deliverEngineNotice(deps.onNotice, {
287
+ code: "mcp.revocation_probe_failed",
288
+ message: `the mcpRevocations.isRevoked probe threw — MCP dispatch fails OPEN (no server treated as revoked) until the probe recovers: ${e instanceof Error ? e.message : String(e)}`,
289
+ detail: { message: e instanceof Error ? e.message : String(e) },
290
+ }),
291
+ };
292
+ }
280
293
  async function forgetQuietly(sessions, sessionId) {
281
294
  try {
282
295
  if (sessions.forget)
@@ -1030,6 +1043,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1030
1043
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1031
1044
  parentReadFace: () => carrierReadFace(),
1032
1045
  parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1046
+ ...(spec.handsReadOnly === true ? { parentHandsReadOnly: true } : {}), ...(spec.interactiveTools === false ? { parentInteractiveTools: false } : {}),
1033
1047
  onNotice: deps.onNotice,
1034
1048
  parentCheckpointStoreDisabled: spec.checkpointStore === null,
1035
1049
  parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
@@ -1101,7 +1115,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1101
1115
  tools.push(createOutputTool(outputRef, spec.outputSchema, compiled.strict ? compiled.modelSchema : undefined));
1102
1116
  }
1103
1117
  mcp = lockedPreflight.mcp?.length
1104
- ? await materializeMcpTools(lockedPreflight.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer, { reminderMark, counts: reminderDisclosureCounts })
1118
+ ? await materializeMcpTools(lockedPreflight.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer, { reminderMark, counts: reminderDisclosureCounts }, mcpRevocationWiring(deps))
1105
1119
  : { tools: [], toolAxes: [], warnings: [], serverInstructions: [], instructionsDelta: { pendingAdds: [], pendingRemovals: [] }, droppedTools: [], statuses: [], refresh: async () => [], dispose: async () => { } };
1106
1120
  for (const w of mcp.warnings)
1107
1121
  deps.onError?.(w, { phase: "mcp", sessionId });
@@ -233,6 +233,12 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
233
233
  continue;
234
234
  }
235
235
  rowsReaped++;
236
+ {
237
+ const staleInProc = core.handles.get(r.handle);
238
+ if (staleInProc !== undefined && staleInProc.status !== "running" && staleInProc.status !== "pending" && staleInProc.status !== "parked") {
239
+ core.handles.delete(r.handle);
240
+ }
241
+ }
236
242
  core.reapedHandles.add(r.handle);
237
243
  if (deps.mailbox !== undefined) {
238
244
  try {
@@ -4730,6 +4730,10 @@ export interface EngineNotice {
4730
4730
  * `detail: { total, stripped: [{ key, reason }], omitted? }`, the rendered key list bounded in count
4731
4731
  * and length because the names come from the untrusted script. One aggregated notice per governed
4732
4732
  * child build, not de-duplicated across builds: each spec is a distinct fact.
4733
+ * - `"mcp.revocation_probe_failed"` (design/338) — the deployment's `mcpRevocations.isRevoked`
4734
+ * probe threw; MCP dispatch FAILS OPEN (revocation is a tightening face) and this announces
4735
+ * once per run. `detail: { message }`. The refusal itself (`mcp.server_revoked`) is a tool
4736
+ * RESULT code, not a notice.
4733
4737
  * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
4734
4738
  * (zero-restart model switching). `detail: { models, tiers }` — key COUNTS only, never the
4735
4739
  * catalog itself. In-flight tasks finish on the models they resolved at prepare (natural
@@ -4898,6 +4902,19 @@ export interface RunnerDeps {
4898
4902
  brain: Brain;
4899
4903
  /** Catalog used to resolve string ModelRefs to Model objects. */
4900
4904
  models?: Record<string, Model>;
4905
+ /**
4906
+ * design/338 (mid-turn MCP revocation) — the HOST's revocation ledger, probed synchronously at
4907
+ * every MCP dispatch (tool call + the three resource tools) BEFORE the transport. The engine
4908
+ * never caches the answer: the ledger's one authority lives on the host (a cached copy would be
4909
+ * a split-state second authority). A revoked server's calls settle as the coded refusal
4910
+ * `mcp.server_revoked` with known-not-executed wording; in-flight calls a revocation raced are
4911
+ * deliberately not chased (the threat shape is "new calls after removal"). Absent seat = the
4912
+ * pre-338 semantics. A THROWING probe fails open (revocation is a tightening face — a broken
4913
+ * probe must not brick every MCP call) with a once-per-run `mcp.revocation_probe_failed` notice.
4914
+ */
4915
+ mcpRevocations?: {
4916
+ isRevoked(serverName: string): boolean;
4917
+ };
4901
4918
  /**
4902
4919
  * design/147 S1c (clay ruling 2026-07-18) — the DURABLE name→agent roster behind explicit-name
4903
4920
  * addressing, a storage-tier seam like the checkpoint store: core bundles `MemoryRosterStore`
@@ -234,6 +234,18 @@ export interface RunWorkflowToolDeps {
234
234
  parentReadFace?: () => import("../core/types.js").TaskSpec["readFace"];
235
235
  /** Twin of the above for the deny-set additions (union, not replace). */
236
236
  parentReadDenyPatterns?: () => import("../core/types.js").TaskSpec["readDenyPatterns"];
237
+ /** #345 ③ — the HOST task's write-hands clamp, the read-face clamp's sibling on this lane (the
238
+ * subagent delegation lane has carried it as `ToolExecuteContext.handsReadOnly` since its own
239
+ * tighten-only ruling; the workflow lane was the remaining gap — a read-only host's workflow
240
+ * children spawned with full write hands). Filled ONLY when the clamp is ON (`true`), exactly
241
+ * like the ctx seat, so an unclamped deployment's mount gains no key. Known at prepare-time
242
+ * (frozen TaskSpec snapshot — no lazy getter needed, the `parentCheckpointStoreDisabled` shape). */
243
+ parentHandsReadOnly?: true;
244
+ /** #345 ③ — the HOST task's hard-headless clamp (`interactiveTools: false`), same carriage rules
245
+ * as `parentHandsReadOnly` above: only the DISABLING value travels, mirroring the subagent lane's
246
+ * `ToolExecuteContext.interactiveTools` seat — without it a headless-clamped host's workflow
247
+ * children could re-mount the interactive tool the host run disabled. */
248
+ parentInteractiveTools?: false;
237
249
  /** [1238](A) — call-time getter for the HOST task's RESOLVED Model object: a spawned agent whose
238
250
  * fold chain produced no model inherits the parent's full object (baseUrl/key routing included),
239
251
  * mirroring the subagent lane's ctx.model semantics. */
@@ -468,7 +468,7 @@ export async function createRunWorkflowTool(d) {
468
468
  }
469
469
  : governance;
470
470
  const scriptFn = (wfCtx) => {
471
- const primitives = buildWorkflowPrimitives(wfCtx, runGovernance, d.onAgentSpawn, d.parentThinking, principal, ctx.checkpointStoreDisabledForChildren === true || d.parentCheckpointStoreDisabled === true, d.parentReadFace, d.parentReadDenyPatterns);
471
+ const primitives = buildWorkflowPrimitives(wfCtx, runGovernance, d.onAgentSpawn, d.parentThinking, principal, ctx.checkpointStoreDisabledForChildren === true || d.parentCheckpointStoreDisabled === true, d.parentReadFace, d.parentReadDenyPatterns, ctx.handsReadOnly === true || d.parentHandsReadOnly === true, ctx.interactiveTools === false || d.parentInteractiveTools === false);
472
472
  return d.scriptRunner.run({ scriptSource: script, primitives, scriptArgs: effectiveArgs, signal: wfCtx.signal }).then((r) => r.result);
473
473
  };
474
474
  if (ctx.signal?.aborted) {
@@ -125,6 +125,33 @@ export interface ResourceClampNote {
125
125
  /** The effective value the child actually runs under (min of script/baseline/caps, or a forced ceiling). */
126
126
  applied: number;
127
127
  }
128
+ /**
129
+ * The worktree-overlay merge — `{ ...base, ...worktreeBase }` with ONE amendment: an overlay key
130
+ * whose value is OWN NULLISH (`undefined` OR `null` — both plain-JS deployment shapes) never
131
+ * overrides `base`; it reads as the absent key its value spells ("an absent overlay field inherits
132
+ * from base", the documented semantics). The GENERIC successor of the per-key protections that grew
133
+ * one field at a time (three face arrays, then `onAsk` — the mount-side deletes, which remain for
134
+ * their base-slot readings) while every OTHER same-shaped key stayed destructive: a surviving own
135
+ * nullish overlay key erased the base value — `toolPolicy` / `shellGate` / `limits` / `hooks` /
136
+ * `restoreGatedTools` / `readFace` / … — for exactly the children an untrusted script can route to
137
+ * the overlay (`isolation: "worktree"`), own-`undefined` keys included (a spread copies them; the
138
+ * old `=== null` face drop missed that half). One rule at the one merge point: a new baseline key
139
+ * is safe with no list to extend. The single exception is {@link NULL_VALUED_BASELINE_KEYS}
140
+ * (`checkpointStore: null` — the deployment disarming durable suspend for isolated children is a
141
+ * capability removal it must be able to spell). Explicit non-nullish overlay values keep winning
142
+ * wholesale — the documented deployment override.
143
+ *
144
+ * ACCEPTED COST, stated (adversarial-review round 2): the rule is direction-blind — a CAPABILITY
145
+ * key's out-of-type overlay `null` used to WITHHOLD the base value by the same spread accident
146
+ * (e.g. `worktreeBase: { getApiKeyAndHeaders: null }` overwrote the base resolver AND defeated the
147
+ * parent-credential `=== undefined` fill in workflow.ts — silently blocking the documented
148
+ * inheritance, the same seam-defeat disease the onAsk r3 ruling deleted null for), and such a key
149
+ * now inherits per the documented contract. Deployments withhold capabilities from isolated
150
+ * children with IN-TYPE spellings (`tools: []`, a deny `toolPolicy`, `handsReadOnly: true`);
151
+ * `getApiKeyAndHeaders` currently has no in-type disable value — that expressiveness gap is a
152
+ * baseline-contract question, deliberately not solved by resurrecting undocumented null semantics.
153
+ */
154
+ export declare function overlayWorktreeBaseline(base: WorkflowGovernanceBaseline["base"], worktreeBase: WorkflowGovernanceBaseline["base"]): WorkflowGovernanceBaseline["base"];
128
155
  /** Resolve an LLM-supplied model NAME to a deploy-configured `Model`, FAIL-CLOSED against the allowlist.
129
156
  * Throws (never falls open to the whole catalog) when no allowlist is configured or the name is not on it. */
130
157
  export declare function resolveModelName(name: string, allowlist: string[] | undefined, models: Record<string, Model> | undefined): Model;
@@ -57,6 +57,19 @@ function emitStrippedKeysNotice(survey, onNotice) {
57
57
  }
58
58
  }
59
59
  const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
60
+ const NULL_VALUED_BASELINE_KEYS = new Set(["checkpointStore"]);
61
+ export function overlayWorktreeBaseline(base, worktreeBase) {
62
+ let overlay = worktreeBase;
63
+ for (const key of Object.keys(worktreeBase)) {
64
+ const v = worktreeBase[key];
65
+ if (v !== undefined && (v !== null || NULL_VALUED_BASELINE_KEYS.has(key)))
66
+ continue;
67
+ if (overlay === worktreeBase)
68
+ overlay = { ...worktreeBase };
69
+ delete overlay[key];
70
+ }
71
+ return { ...base, ...overlay };
72
+ }
60
73
  export function resolveModelName(name, allowlist, models) {
61
74
  if (!allowlist || allowlist.length === 0) {
62
75
  throw new WorkflowModelNotAllowedError(name, "no workflowModelAllowlist is configured (fail-closed: an LLM-authored script cannot pick a model)");
@@ -44,4 +44,11 @@ parentCheckpointStoreDisabled?: boolean,
44
44
  parentReadFace?: () => TaskSpec["readFace"],
45
45
  /** Twin of the above for the deny-set additions (built-ins always apply; these are the extra
46
46
  * entries the host's own deployment/task layers stacked on). */
47
- parentReadDenyPatterns?: () => TaskSpec["readDenyPatterns"]): WorkflowPrimitives;
47
+ parentReadDenyPatterns?: () => TaskSpec["readDenyPatterns"],
48
+ /** #345 ③ — the HOST task's write-hands clamp is ON (`spec.handsReadOnly === true`). Injected
49
+ * tighten-only into every spawned child, like the read-face clamp above; see the injection
50
+ * below for why it outranks even a `worktreeBase: { handsReadOnly: false }` overlay. */
51
+ parentHandsReadOnly?: boolean,
52
+ /** #345 ③ — the HOST task's hard-headless clamp is ON (`spec.interactiveTools === false`).
53
+ * Same tighten-only injection; only the disabling value ever arrives here as `true`. */
54
+ parentInteractiveToolsOff?: boolean): WorkflowPrimitives;
@@ -1,5 +1,5 @@
1
1
  import { assertSupportedAgentIsolation } from "./workflow.js";
2
- import { buildGovernedChildSpec } from "./workflow-governance.js";
2
+ import { buildGovernedChildSpec, overlayWorktreeBaseline } from "./workflow-governance.js";
3
3
  function safeAgentOptions(opts) {
4
4
  if (typeof opts !== "object" || opts === null)
5
5
  return {};
@@ -22,12 +22,12 @@ function formatResourceClampNote(notes) {
22
22
  const parts = notes.map((n) => `${n.field}: requested ${n.requested === undefined ? "unset" : n.requested} → applied ${n.applied}`);
23
23
  return `workflow governance tightened this agent's resource limits (${parts.join("; ")})`;
24
24
  }
25
- export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThinking, parentPrincipal, parentCheckpointStoreDisabled, parentReadFace, parentReadDenyPatterns) {
25
+ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThinking, parentPrincipal, parentCheckpointStoreDisabled, parentReadFace, parentReadDenyPatterns, parentHandsReadOnly, parentInteractiveToolsOff) {
26
26
  const agent = (spec, opts) => {
27
27
  if (typeof spec === "string")
28
28
  spec = { objective: spec };
29
29
  const agentOpts = safeAgentOptions(opts);
30
- const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase !== undefined ? { ...b, base: { ...b.base, ...b.worktreeBase } } : b;
30
+ const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase != null ? { ...b, base: overlayWorktreeBaseline(b.base, b.worktreeBase) } : b;
31
31
  const childSpec = governance
32
32
  ? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)), governance.onNotice)
33
33
  : { ...spec };
@@ -53,6 +53,14 @@ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThi
53
53
  childSpec.readDenyPatterns = childSpec.readDenyPatterns !== undefined ? [...childSpec.readDenyPatterns, ...pd] : [...pd];
54
54
  }
55
55
  }
56
+ if (parentHandsReadOnly === true) {
57
+ childSpec.handsReadOnly = true;
58
+ }
59
+ if (parentInteractiveToolsOff === true) {
60
+ childSpec.interactiveTools = false;
61
+ if (childSpec.interactionPosture === "interactive")
62
+ delete childSpec.interactionPosture;
63
+ }
56
64
  if (onAgentSpawn) {
57
65
  return ctx.agentStream(childSpec, agentOpts).then((handle) => {
58
66
  onAgentSpawn(handle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.49.0",
3
+ "version": "5.50.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",