@sema-agent/core 5.5.0 → 5.7.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.7.0 (2026-08-04)
4
+
5
+ _Three collected cars: a parked question is answered by the decision itself (behavior face — see the consumer note), a descendant's terminal notification anchors on the delegation tree, and the env scrub stops deleting infrastructure key names (behavior face)._
6
+
7
+ ### Fixed
8
+
9
+ - A descendant background agent's terminal notification now anchors on the delegation tree instead of dying with its spawner. A background agent notifies through the injector of the run that spawned it, and that injector parked on that run's OWN session once its lane was gone — so a grandchild launched from a delegated run (root → child A → background grandchild B; A completes, B settles later) parked its terminal on a throwaway child session that never runs again, and the root conversation was never told (the data was never lost — `TaskOutput` still answered; the gap was purely the push face). A delegated agent's TERMINAL frame now also escalates one hop up the delegation chain: a live ancestor takes it in its current run, a dead one repeats the decision one level higher, and the walk ends at the tree root; without a chain link the recorded root anchor is used directly. Strictly additive: the own-session park is kept (a retained child session later resumed still receives what it always received), the live lane is untouched, and every other notification class (shell completions, SendMessage deliveries, external `notify()` events) stays addressed to its own session. A delegated run that can reach neither an uplink nor a root anchor still parks (fail-open) and discloses once (`degraded` / `descendant-terminal-unrouted`).
10
+
11
+ - The env scrub no longer deletes infrastructure key names. `SECRET_ENV_RE`'s bare `KEY` word matched `PARTITION_KEY` / `SORT_KEY` / `RANGE_KEY` / `IDEMPOTENCY_KEY` / `CACHE_KEY` / `ROUTING_KEY`, and `scrubSecretEnv` DELETES what it matches — a spawned child silently lost variables that carry no credential (writes to a wrong partition, a routing key nothing binds). The word itself cannot be withdrawn (`API_KEY` / `SECRET_KEY` / `PRIVATE_KEY` / `ACCESS_KEY` are the bulk of real credential names), so the fix is the mirror of the existing positive exact-name list: `NON_SECRET_ENV_EXACT_NAMES`, exact names (never a shape — a pattern exemption would be a bypass) confirmed non-credential, consulted before the shape rule with the credential list read first (a name on both lists resolves to the credential reading). An exempted entry is kept and mints no finding. Decorated variants (`USERS_SORT_KEY`, `SORT_KEY_2`) are still dropped. **Consumers pinning the old shape**: a probe asserting `isSecretEnvKey("SORT_KEY") === true`, or that a scrub drops one of the six exempt names, now reads the opposite answer.
12
+
13
+ ### Added
14
+
15
+ - A durable question can be answered by the decision itself. `ResumeOutcome`'s `policy_ask` arm takes an optional `answer` (a `QuestionAnswer`) when the checkpoint's pending call is the reserved `AskUserQuestion` tool; the resume binds it as the resumed leg's answering face, so the pending call executes against the operator's actual selections. This is the same wiring the documented live arm builds by hand (`resume(token, outcome, { ...config, onQuestion: async () => answer })`) — but minted from the payload, which is the only route available to a caller redeeming an **offline background child**: the revive drive rebuilds that leg's config from the parked row and has no seat to put a closure in, so approving a parked question used to feed the model a fabricated "no human is available" default. The answer is untrusted wire data throughout: it goes through the existing answer fence unchanged (`selected ⊆ options` for the trusted line, off-list values and free-text `note` in a data fence), so multi-select and "Other" free text behave identically on both arms. The binding is one-shot — it answers the decided call only; a new question on the resumed leg is not fed the old answer. The answer is also recorded on the persisted winner, so an `env_failed` reopen must replay the identical answer (a replay may not keep the verdict and swap the text).
16
+
17
+ ### Changed
18
+
19
+ - **Behavior face** — approving a durable question without an answer is now refused instead of consumed. A `policy_ask` `allow` whose pending call is the reserved question tool is rejected **pre-CAS** (`checkpoint.invalid_outcome`, `detail.field: "answer"`, the checkpoint stays `pending` and redeemable) when it carries neither an `answer` nor a live answering face — where "live" excludes the reserved `QUESTION_AWAITS_RESUME` placeholder, whose contract is that it must never run. Previously that shape consumed the human's approval and let the run finish on a fabricated default (or, with the placeholder, on its config error) with nothing left to redeem. A resume that supplies its own real `onQuestion` is unaffected; so is every non-question approval. Symmetrically, an `answer` attached to a decision that cannot carry one (a `deny`, or a pending call that is not the question tool) is rejected the same way rather than silently dropped. **Consumers pinning the old shape**: a probe that approved a parked question with a bare allow now gets a typed rejection — supply the answer on the outcome, or deny.
20
+
21
+ ## 5.6.0 (2026-08-03)
22
+
23
+ _A hardening tail: numeric knobs refuse what they cannot honor, the binary gate reads bytes instead of trusting statistics, and the MCP SDK takes its last zero-cost step before the v2 jump._
24
+
25
+ ### Fixed
26
+
27
+ - Stall-timeout knobs (`connectTimeoutMs` / `firstTokenTimeoutMs` / `idleTimeoutMs`) refuse invalid numbers loudly (`config.stall_timeout_invalid`) instead of silently disarming — a NaN (units fumble: `"120s"` → `Number` → NaN) used to read as "unset", switching every watchdog OFF exactly when a deployment meant to tighten them. `0` stays legal as an explicit off; absent stays absent.
28
+
29
+ - Read's binary-extension list gains magic-byte criteria of its own (~40 formats, one data-driven signature table): a file whose bytes carry a real binary header (ZIP/gzip/7z/ELF/tar/RIFF…) is now refused by CONTENT even when printable padding used to slip it past the statistical sniffer as mojibake "text". Names still never decide: a `.zip` holding plain text still reads. The Edit/Write/NotebookEdit read-refusal texts all name the same escape route (one constant; NotebookEdit used to render the bare sentence with no hint at all).
30
+
31
+ ### Changed
32
+
33
+ - `@modelcontextprotocol/sdk` 1.29.0 → 1.30.0 (exact pin). Public surface is purely additive upstream (stdio `maxBufferSize`, SSE `keepAliveMs`); `types` is byte-identical; the only behavior delta is a richer zod parse-error message the engine never pins on. Groundwork for the v2 three-package migration (spiked, GO, waiting out the first patch release).
34
+
3
35
  ## 5.5.0 (2026-08-03)
4
36
 
5
37
  _Three follow-through items: the deferred-placeholder wrong-guess round trip halves, durable cron jobs survive their creating session, and the redaction findings channel closes its last three audit gaps._
@@ -2,7 +2,7 @@ import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
3
3
  import { retryBackoffMs } from "./retry.js";
4
4
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
5
- import { createConnectController } from "./timeout.js";
5
+ import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  const DEFAULT_MAX_RETRIES = 10;
8
8
  const MAX_RETRIES_ENV_CEILING = 15;
@@ -113,9 +113,9 @@ export function runStreamingBrain(args) {
113
113
  const req = buildRequest();
114
114
  const maxRetries = resolveMaxRetries(config.maxRetries);
115
115
  const baseDelay = config.retryDelayMs ?? 500;
116
- const firstTokenTimeoutMs = config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs;
117
- const idleTimeoutMs = config.idleTimeoutMs ?? stallTimeouts?.idleMs;
118
- const connectTimeoutMs = config.connectTimeoutMs ?? stallTimeouts?.connectMs;
116
+ const firstTokenTimeoutMs = resolveStallTimeoutMs(config.firstTokenTimeoutMs ?? stallTimeouts?.firstTokenMs, "firstTokenTimeoutMs");
117
+ const idleTimeoutMs = resolveStallTimeoutMs(config.idleTimeoutMs ?? stallTimeouts?.idleMs, "idleTimeoutMs");
118
+ const connectTimeoutMs = resolveStallTimeoutMs(config.connectTimeoutMs ?? stallTimeouts?.connectMs, "connectTimeoutMs");
119
119
  let attempt = 0;
120
120
  let thinkingRetries = 0;
121
121
  let startEmitted = false;
@@ -1,4 +1,5 @@
1
1
  import type { StreamFn } from "../internal/llm.js";
2
+ export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined;
2
3
  export interface BrainTimeoutConfig {
3
4
  connectTimeoutMs?: number;
4
5
  firstTokenTimeoutMs?: number;
@@ -1,3 +1,13 @@
1
+ export function resolveStallTimeoutMs(value, knob) {
2
+ if (value === undefined)
3
+ return undefined;
4
+ if (!Number.isFinite(value) || value < 0) {
5
+ const e = new Error(`${knob} must be a non-negative finite number of milliseconds (got ${String(value)})`);
6
+ e.code = "config.stall_timeout_invalid";
7
+ throw e;
8
+ }
9
+ return value;
10
+ }
1
11
  export function createConnectController(connectTimeoutMs, outerSignal, deadlineAtMs) {
2
12
  const ac = new AbortController();
3
13
  let timedOut = false;
@@ -1,3 +1,4 @@
1
+ import { type QuestionAnswer } from "./ask-question.js";
1
2
  import type { ReadEntry } from "../tools/fs/safety.js";
2
3
  import type { RepairBundle } from "../agents/repair-loop.js";
3
4
  import type { WorkspaceHandle } from "./remote-env.js";
@@ -68,6 +69,7 @@ export type ResumeOutcome = {
68
69
  decision: "allow" | "deny";
69
70
  updatedInput?: unknown;
70
71
  reason?: string;
72
+ answer?: QuestionAnswer;
71
73
  } | {
72
74
  gate: "resource_limit";
73
75
  decision: "continue";
@@ -180,6 +182,7 @@ export interface ResolvedOutcome {
180
182
  boundCallId: string;
181
183
  decision: "allow" | "deny" | "approve" | "reject" | "edit";
182
184
  updatedInput?: unknown;
185
+ answer?: QuestionAnswer;
183
186
  }
184
187
  export type ReopenReason = "env_failed" | "tool_unavailable";
185
188
  export interface ResolveExpectation {
@@ -241,10 +244,10 @@ export declare function summarizeCheckpoint(cp: Checkpoint): CheckpointSummary;
241
244
  export declare class CheckpointError extends Error {
242
245
  readonly code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" | "checkpoint.gate_mismatch" | "checkpoint.resume_aborted" | "checkpoint.invalid_outcome" | "checkpoint.unsupported_version" | "checkpoint.reopen_revote" | "checkpoint.reopened_concurrently" | "checkpoint.reopen_failed" | "steering.invalid_content" | "wake.gate_pending" | "wake.nothing_to_deliver" | "resume.parent_constraint_missing" | "resume.parent_constraint_mismatch";
243
246
  readonly detail?: {
244
- field?: "boundCallId" | "boundInputHash";
247
+ field?: "boundCallId" | "boundInputHash" | "answer";
245
248
  } | undefined;
246
249
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" | "checkpoint.gate_mismatch" | "checkpoint.resume_aborted" | "checkpoint.invalid_outcome" | "checkpoint.unsupported_version" | "checkpoint.reopen_revote" | "checkpoint.reopened_concurrently" | "checkpoint.reopen_failed" | "steering.invalid_content" | "wake.gate_pending" | "wake.nothing_to_deliver" | "resume.parent_constraint_missing" | "resume.parent_constraint_mismatch", message: string, detail?: {
247
- field?: "boundCallId" | "boundInputHash";
250
+ field?: "boundCallId" | "boundInputHash" | "answer";
248
251
  } | undefined);
249
252
  }
250
253
  export interface CheckpointStore {
@@ -223,9 +223,12 @@ export function winnerFromOutcome(outcome) {
223
223
  }
224
224
  if (outcome.gate !== "policy_ask")
225
225
  return undefined;
226
- return outcome.updatedInput === undefined
227
- ? { boundCallId: outcome.boundCallId, decision: outcome.decision }
228
- : { boundCallId: outcome.boundCallId, decision: outcome.decision, updatedInput: outcome.updatedInput };
226
+ return {
227
+ boundCallId: outcome.boundCallId,
228
+ decision: outcome.decision,
229
+ ...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
230
+ ...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
231
+ };
229
232
  }
230
233
  export function validatePendingSteer(steer) {
231
234
  if (sanitizeUntrustedText(steer.text) !== steer.text) {
@@ -5,7 +5,7 @@ import { engineVersion } from "../version.js";
5
5
  import { CONFIG_CATALOG_VERSION, declarationReasons, resolveEffectiveConfig } from "../../config/catalog.js";
6
6
  import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
7
7
  import { DEFAULT_COMPACTION_INSTRUCTIONS, createRapidRefillState, isCompactionManualCancel, isCompactionWalltimeAbort, maybeCompact, nextTrimForceBackoff, recordCompactionAndCheckRapidRefill } from "../auto-compaction.js";
8
- import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
8
+ import { ASK_USER_QUESTION_TOOL_NAME, QUESTION_AWAITS_RESUME } from "../ask-question.js";
9
9
  import { computeCostMicroUsd, modelCostToPricing } from "../pricing.js";
10
10
  import { emitTrace } from "../trace.js";
11
11
  import { emitTaskOutcome } from "../task-outcome.js";
@@ -38,7 +38,7 @@ import { RunnerSharedToolResultStore } from "../tool-result-store.js";
38
38
  import { formatDiagnosticsBlock } from "../lsp-diagnostics.js";
39
39
  import { refuseOutOfContractDecision, toolPolicyNameSets } from "../tool-policy.js";
40
40
  import { defaultTaskRegistry } from "../task-registry.js";
41
- import { discloseDroppedPending, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
41
+ import { discloseDroppedPending, isDelegatedAgentTerminal, PendingSessionNotifications, renderTaskNotificationXml, SystemInjectionQueue, taskNotificationDedupKey } from "../task-notification.js";
42
42
  import { ToolDetachHub } from "../tool-detach.js";
43
43
  import { workflowSizeGuidelineChangeNotice } from "../../orchestration/workflow-size-guideline.js";
44
44
  import { DEFAULT_MAX_TURNS } from "../../config/defaults.js";
@@ -135,7 +135,24 @@ function deepJsonEqual(a, b) {
135
135
  return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepJsonEqual(ao[k], bo[k]));
136
136
  }
137
137
  function sameWinner(a, b) {
138
- return a.boundCallId === b.boundCallId && a.decision === b.decision && deepJsonEqual(a.updatedInput, b.updatedInput);
138
+ return (a.boundCallId === b.boundCallId &&
139
+ a.decision === b.decision &&
140
+ deepJsonEqual(a.updatedInput, b.updatedInput) &&
141
+ deepJsonEqual(a.answer, b.answer));
142
+ }
143
+ function pendingContentAskCallId(cp) {
144
+ return cp.pendingAction.kind === "tool_approval" && cp.pendingAction.toolName === ASK_USER_QUESTION_TOOL_NAME
145
+ ? cp.pendingAction.toolCallId
146
+ : undefined;
147
+ }
148
+ function answerFaceForRedeemedCall(answer, redeemedCallId, base) {
149
+ return async (req, signal) => {
150
+ if (req.toolCallId === redeemedCallId)
151
+ return answer;
152
+ if (base !== undefined)
153
+ return base(req, signal);
154
+ throw new Error("this resumed leg's answer was bound to the decided question only — a new question has no answer on this leg");
155
+ };
139
156
  }
140
157
  function writeFamilyOfCanonical(name) {
141
158
  if (name === "TaskCreate" || name === "TaskUpdate")
@@ -1559,6 +1576,32 @@ export class Runner {
1559
1576
  let notificationIdent = () => ({});
1560
1577
  let notificationLaneLive = true;
1561
1578
  let notificationSessionId;
1579
+ let descendantAnchorDisclosed = false;
1580
+ const parkTaskNotification = (payload, priority) => {
1581
+ if (notificationSessionId !== undefined)
1582
+ this.pendingSessionNotifications.pend(notificationSessionId, payload);
1583
+ if (!isDelegatedAgentTerminal(payload))
1584
+ return;
1585
+ const uplink = internals?.parentNotify;
1586
+ if (uplink !== undefined) {
1587
+ try {
1588
+ uplink(payload, { priority });
1589
+ }
1590
+ catch (e) {
1591
+ this.deps.onError?.(e, { phase: "degraded", sessionId: notificationSessionId ?? "", classification: "descendant-terminal-uplink" });
1592
+ }
1593
+ return;
1594
+ }
1595
+ const rootAnchor = internals?.rootSessionId;
1596
+ if (rootAnchor !== undefined && rootAnchor !== notificationSessionId) {
1597
+ this.pendingSessionNotifications.pend(rootAnchor, payload);
1598
+ return;
1599
+ }
1600
+ if (internals?.parentSessionId !== undefined && !descendantAnchorDisclosed) {
1601
+ descendantAnchorDisclosed = true;
1602
+ this.deps.onError?.(new Error(`background-agent terminal ${payload.task_id} parked on this run's own session — the delegation tree's root session is not reachable from this run (no uplink, no root anchor)`), { phase: "degraded", sessionId: notificationSessionId ?? "", classification: "descendant-terminal-unrouted" });
1603
+ }
1604
+ };
1562
1605
  const unsubscribeTaskNotifications = taskNotificationQueue.subscribe((item) => {
1563
1606
  queue.push({ type: "task_notification", notification: item.payload, ...notificationIdent() });
1564
1607
  if (notificationHarness) {
@@ -1567,14 +1610,12 @@ export class Runner {
1567
1610
  ? notificationHarness.followUp(xml, { provenance: "engine-note", enginePayload: item.payload })
1568
1611
  : notificationHarness.steer(xml, { provenance: "engine-note", enginePayload: item.payload });
1569
1612
  void deliver.then(() => item.onDisposition?.("queued"), () => {
1570
- if (notificationSessionId !== undefined)
1571
- this.pendingSessionNotifications.pend(notificationSessionId, item.payload);
1613
+ parkTaskNotification(item.payload, item.priority);
1572
1614
  item.onDisposition?.("parked");
1573
1615
  });
1574
1616
  }
1575
1617
  else {
1576
- if (notificationSessionId !== undefined)
1577
- this.pendingSessionNotifications.pend(notificationSessionId, item.payload);
1618
+ parkTaskNotification(item.payload, item.priority);
1578
1619
  item.onDisposition?.("parked");
1579
1620
  }
1580
1621
  });
@@ -1584,8 +1625,7 @@ export class Runner {
1584
1625
  if (deliveredAtTurnOpen.has(taskNotificationDedupKey(notification)))
1585
1626
  return Promise.resolve("dropped_duplicate");
1586
1627
  if (!notificationLaneLive) {
1587
- if (notificationSessionId !== undefined)
1588
- this.pendingSessionNotifications.pend(notificationSessionId, notification);
1628
+ parkTaskNotification(notification, opts?.priority ?? "later");
1589
1629
  return Promise.resolve("parked");
1590
1630
  }
1591
1631
  return new Promise((resolve) => {
@@ -3240,6 +3280,20 @@ export class Runner {
3240
3280
  throw new CheckpointError("checkpoint.reopen_revote", `this checkpoint was reopened after an env-restore failure (env_failed) — a re-resume is a system retry of the already-approved action, not a re-vote; the supplied decision must replay the persisted winner (boundCallId "${cp.resolvedOutcome.boundCallId}", decision "${cp.resolvedOutcome.decision}"), refusing a different decision`);
3241
3281
  }
3242
3282
  }
3283
+ const contentAskCallId = pendingContentAskCallId(cp);
3284
+ if (outcome.answer !== undefined && (contentAskCallId === undefined || outcome.decision !== "allow")) {
3285
+ throw new CheckpointError("checkpoint.invalid_outcome", contentAskCallId === undefined
3286
+ ? "resume carried a content-ask `answer` but the checkpoint's pending call is not the reserved question tool — an answer has no consumer on a side-effecting tool's approval; refusing rather than dropping it silently"
3287
+ : "resume carried a content-ask `answer` on a `deny` — a denial injects a refusal, never an answer; refusing rather than dropping it silently", { field: "answer" });
3288
+ }
3289
+ if (contentAskCallId !== undefined && outcome.decision === "allow" && outcome.answer === undefined) {
3290
+ const liveFace = taskConfig.onQuestion ?? this.deps.onQuestion;
3291
+ if (liveFace === undefined || liveFace === QUESTION_AWAITS_RESUME) {
3292
+ throw new CheckpointError("checkpoint.invalid_outcome", "resume approved a content-ask (the reserved question tool) without an `answer`, and this resume has no live answering face — " +
3293
+ 'executing the question against nothing would hand the model a fabricated "no human is available" default while consuming the approval; ' +
3294
+ "re-resume with the operator's answer on the outcome (or deny it), the checkpoint stays pending", { field: "answer" });
3295
+ }
3296
+ }
3243
3297
  }
3244
3298
  if (checkpointVersionOf(cp) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
3245
3299
  throw new CheckpointError("checkpoint.unsupported_version", `checkpoint version ${checkpointVersionOf(cp)} is newer than this worker supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION})`);
@@ -3279,12 +3333,16 @@ export class Runner {
3279
3333
  await internals.afterCheckpointResolve();
3280
3334
  consumeFlipDone = true;
3281
3335
  }
3336
+ const answerFace = outcome.gate === "policy_ask" && outcome.answer !== undefined
3337
+ ? answerFaceForRedeemedCall(outcome.answer, outcome.boundCallId, taskConfig.onQuestion ?? this.deps.onQuestion)
3338
+ : undefined;
3282
3339
  const spec = {
3283
3340
  ...taskConfig,
3284
3341
  objective: "",
3285
3342
  sessionId: cp.sessionId,
3286
3343
  requireExistingSession: true,
3287
3344
  preemptSignal: taskConfig.preemptSignal?.aborted ? undefined : taskConfig.preemptSignal,
3345
+ ...(answerFace !== undefined ? { onQuestion: answerFace } : {}),
3288
3346
  };
3289
3347
  const reopenFn = store.reopen?.bind(store);
3290
3348
  const onEnvRestoreFailed = reopenFn
@@ -6,5 +6,7 @@ export interface SecretEnvFinding {
6
6
  confidence: RedactionConfidence;
7
7
  source: RedactionSource;
8
8
  }
9
+ export type ExactEnvNameVerdict = "credential" | "exempt" | "unknown";
10
+ export declare function classifyExactEnvName(key: string, credentialNames: ReadonlySet<string>, exemptNames: ReadonlySet<string>): ExactEnvNameVerdict;
9
11
  export declare function isSecretEnvKey(key: string): boolean;
10
12
  export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
@@ -4,16 +4,28 @@ const SECRET_ENV_EXACT_NAMES = new Set([
4
4
  "PGPASSWORD", "MYSQL_PWD", "SSHPASS", "GPG_PASSPHRASE", "GH_PAT", "GITHUB_PAT",
5
5
  "NPM_CONFIG__AUTH", "NPM_CONFIG__AUTHTOKEN",
6
6
  ]);
7
+ const NON_SECRET_ENV_EXACT_NAMES = new Set([
8
+ "PARTITION_KEY", "SORT_KEY", "RANGE_KEY", "IDEMPOTENCY_KEY", "CACHE_KEY", "ROUTING_KEY",
9
+ ]);
7
10
  const SECRET_ENV_KIND_CONFIDENCE = {
8
11
  "exact-name": "high",
9
12
  "suffix-rule": "medium",
10
13
  };
14
+ export function classifyExactEnvName(key, credentialNames, exemptNames) {
15
+ const upper = key.toUpperCase();
16
+ if (credentialNames.has(upper))
17
+ return "credential";
18
+ if (exemptNames.has(upper))
19
+ return "exempt";
20
+ return "unknown";
21
+ }
11
22
  function classifySecretEnvKey(key) {
12
- if (SECRET_ENV_RE.test(key))
13
- return "suffix-rule";
14
- if (SECRET_ENV_EXACT_NAMES.has(key.toUpperCase()))
23
+ const byExactName = classifyExactEnvName(key, SECRET_ENV_EXACT_NAMES, NON_SECRET_ENV_EXACT_NAMES);
24
+ if (byExactName === "credential")
15
25
  return "exact-name";
16
- return undefined;
26
+ if (byExactName === "exempt")
27
+ return undefined;
28
+ return SECRET_ENV_RE.test(key) ? "suffix-rule" : undefined;
17
29
  }
18
30
  export function isSecretEnvKey(key) {
19
31
  return classifySecretEnvKey(key) !== undefined;
@@ -45,6 +45,7 @@ export declare function renderTaskNotificationXml(n: TaskNotificationPayload): s
45
45
  export declare const MAX_PENDING_EVENTS_PER_TASK = 50;
46
46
  export declare const MAX_PENDING_EVENTS_PER_SESSION = 200;
47
47
  export declare const MAX_PENDING_SESSIONS = 100;
48
+ export declare function isDelegatedAgentTerminal(n: Pick<TaskNotificationPayload, "task_type" | "status">): boolean;
48
49
  export interface DrainedPendingNotifications {
49
50
  items: TaskNotificationPayload[];
50
51
  dropped: Map<string, {
@@ -83,6 +83,9 @@ export const MAX_PENDING_EVENTS_PER_SESSION = 200;
83
83
  export const MAX_PENDING_SESSIONS = 100;
84
84
  const MAX_TOMBSTONES = 5_000;
85
85
  const TERMINAL_STATUSES = new Set(["completed", "failed", "killed", "cancelled"]);
86
+ export function isDelegatedAgentTerminal(n) {
87
+ return n.task_type === "background_agent" && TERMINAL_STATUSES.has(n.status);
88
+ }
86
89
  export class PendingSessionNotifications {
87
90
  sessions = new Map();
88
91
  droppedSessions = 0;
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { sha256, resolveKey, violationText, violationDetails, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
3
+ import { sha256, resolveKey, violationText, violationDetails, binaryMagicFormat, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, } from "./safety.js";
4
4
  import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
@@ -166,6 +166,11 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
166
166
  if (pdfMagicMatches(readBin.value)) {
167
167
  return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities));
168
168
  }
169
+ const magicFormat = binaryMagicFormat(readBin.value);
170
+ if (magicFormat !== undefined) {
171
+ return errorResult(`Error (Read): "${path}" appears to be a binary file (${magicFormat}, identified by its file signature — whatever its extension says); ` +
172
+ `this tool reads UTF-8 and BOM-marked UTF-16LE text only. Inspect or convert it with bash instead.`);
173
+ }
169
174
  const decoded = decodeTextBytes(readBin.value);
170
175
  if (decoded.malformed) {
171
176
  return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "../../core/tools.js";
4
- import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
4
+ import { sha256, resolveKey, violationText, violationDetails, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, READ_REFUSED_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
7
  async function gateToolWrite(hook, tool, path, key, content) {
@@ -136,7 +136,7 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
136
136
  return errorResult(ipynb);
137
137
  const notRead = requireRead(state, r.key);
138
138
  if (notRead)
139
- return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
139
+ return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
140
140
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
141
141
  if (!readBin.ok)
142
142
  return errorResult(`Error (Edit): cannot read "${path}": ${readBin.error.message}`);
@@ -249,7 +249,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
249
249
  if (exists.value) {
250
250
  const notRead = requireRead(state, r.key);
251
251
  if (notRead) {
252
- return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT));
252
+ return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
253
253
  }
254
254
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
255
255
  if (!readBin.ok)
@@ -334,7 +334,7 @@ export function createNotebookEditTool(env, state, rootCanonical, cwdRef, additi
334
334
  return errorResult(violationText("NotebookEdit", r.violation), violationDetails(r.violation));
335
335
  const notRead = requireRead(state, r.key);
336
336
  if (notRead)
337
- return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
337
+ return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal, READ_REFUSED_ESCAPE_HINT));
338
338
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
339
339
  if (!readBin.ok)
340
340
  return errorResult(`Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`);
@@ -30,6 +30,16 @@ export declare function foldRedundantPathSegments(p: string): string;
30
30
  export declare function hasBinaryExtension(path: string): boolean;
31
31
  export declare function imageMimeForRead(path: string): string | undefined;
32
32
  export declare function imageMagicMatches(bytes: Uint8Array, mimeType: string): boolean;
33
+ export interface BinaryMagicSegment {
34
+ readonly offset: number;
35
+ readonly bytes: readonly number[];
36
+ }
37
+ export interface BinaryMagicSignature {
38
+ readonly format: string;
39
+ readonly segments: readonly BinaryMagicSegment[];
40
+ }
41
+ export declare const BINARY_MAGIC_SIGNATURES: readonly BinaryMagicSignature[];
42
+ export declare function binaryMagicFormat(bytes: Uint8Array): string | undefined;
33
43
  export declare function isBinaryContent(sample: string): boolean;
34
44
  export declare function isUncPath(path: string): boolean;
35
45
  export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRootsCanonical?: readonly string[], exactFileReadExemption?: (canonicalKey: string) => boolean): Promise<{
@@ -59,7 +69,7 @@ export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to t
59
69
  export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
60
70
  export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
61
71
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
62
- export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
72
+ export declare const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
63
73
  export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
64
74
  export declare function checkStale(entry: ReadEntry | undefined, currentHash: string): FsViolation | undefined;
65
75
  export declare function countOccurrences(haystack: string, needle: string): number;
@@ -150,6 +150,89 @@ export function imageMagicMatches(bytes, mimeType) {
150
150
  return false;
151
151
  }
152
152
  }
153
+ const asciiBytes = (s) => Array.from(s, (c) => c.charCodeAt(0));
154
+ export const BINARY_MAGIC_SIGNATURES = [
155
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x03, 0x04] }] },
156
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x05, 0x06] }] },
157
+ { format: "ZIP archive", segments: [{ offset: 0, bytes: [0x50, 0x4b, 0x07, 0x08] }] },
158
+ { format: "gzip archive", segments: [{ offset: 0, bytes: [0x1f, 0x8b, 0x08] }] },
159
+ ...[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39].map((level) => ({
160
+ format: "bzip2 archive",
161
+ segments: [{ offset: 0, bytes: [0x42, 0x5a, 0x68, level] }],
162
+ })),
163
+ { format: "xz archive", segments: [{ offset: 0, bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00] }] },
164
+ { format: "zstd archive", segments: [{ offset: 0, bytes: [0x28, 0xb5, 0x2f, 0xfd] }] },
165
+ { format: "lz4 archive", segments: [{ offset: 0, bytes: [0x04, 0x22, 0x4d, 0x18] }] },
166
+ { format: "compress archive", segments: [{ offset: 0, bytes: [0x1f, 0x9d] }] },
167
+ { format: "7-Zip archive", segments: [{ offset: 0, bytes: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c] }] },
168
+ { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00] }] },
169
+ { format: "RAR archive", segments: [{ offset: 0, bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00] }] },
170
+ { format: "tar archive", segments: [{ offset: 257, bytes: asciiBytes("ustar") }] },
171
+ { format: "ar archive (static library or Debian package)", segments: [{ offset: 0, bytes: asciiBytes("!<arch>\n") }] },
172
+ { format: "RPM package", segments: [{ offset: 0, bytes: [0xed, 0xab, 0xee, 0xdb] }] },
173
+ { format: "ISO 9660 disc image", segments: [{ offset: 32769, bytes: asciiBytes("CD001") }] },
174
+ { format: "PNG image", segments: [{ offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }] },
175
+ { format: "JPEG image", segments: [{ offset: 0, bytes: [0xff, 0xd8, 0xff] }] },
176
+ { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF87a") }] },
177
+ { format: "GIF image", segments: [{ offset: 0, bytes: asciiBytes("GIF89a") }] },
178
+ { format: "TIFF image", segments: [{ offset: 0, bytes: [0x49, 0x49, 0x2a, 0x00] }] },
179
+ { format: "TIFF image", segments: [{ offset: 0, bytes: [0x4d, 0x4d, 0x00, 0x2a] }] },
180
+ { format: "BMP image", segments: [{ offset: 0, bytes: asciiBytes("BM") }, { offset: 6, bytes: [0x00, 0x00, 0x00, 0x00] }] },
181
+ { format: "ICO image", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0x00] }] },
182
+ { format: "Photoshop document", segments: [{ offset: 0, bytes: asciiBytes("8BPS") }] },
183
+ { format: "WebP image", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WEBP") }] },
184
+ { format: "RIFF audio (WAV)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("WAVE") }] },
185
+ { format: "RIFF video (AVI)", segments: [{ offset: 0, bytes: asciiBytes("RIFF") }, { offset: 8, bytes: asciiBytes("AVI ") }] },
186
+ { format: "AIFF audio", segments: [{ offset: 0, bytes: asciiBytes("FORM") }, { offset: 8, bytes: asciiBytes("AIFF") }] },
187
+ { format: "Ogg container", segments: [{ offset: 0, bytes: asciiBytes("OggS") }, { offset: 4, bytes: [0x00] }] },
188
+ { format: "FLAC audio", segments: [{ offset: 0, bytes: asciiBytes("fLaC") }] },
189
+ ...[0x02, 0x03, 0x04].map((major) => ({
190
+ format: "MP3 audio (ID3 tag)",
191
+ segments: [{ offset: 0, bytes: [0x49, 0x44, 0x33, major] }],
192
+ })),
193
+ { format: "ISO base media container (MP4/MOV/HEIC)", segments: [{ offset: 4, bytes: asciiBytes("ftyp") }] },
194
+ { format: "Matroska/WebM container", segments: [{ offset: 0, bytes: [0x1a, 0x45, 0xdf, 0xa3] }] },
195
+ { format: "ASF container (WMV/WMA)", segments: [{ offset: 0, bytes: [0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c] }] },
196
+ { format: "FLV video", segments: [{ offset: 0, bytes: [0x46, 0x4c, 0x56, 0x01] }] },
197
+ { format: "MPEG program stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xba] }] },
198
+ { format: "MPEG video stream", segments: [{ offset: 0, bytes: [0x00, 0x00, 0x01, 0xb3] }] },
199
+ { format: "ELF binary", segments: [{ offset: 0, bytes: [0x7f, 0x45, 0x4c, 0x46] }] },
200
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xce] }] },
201
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xfe, 0xed, 0xfa, 0xcf] }] },
202
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xce, 0xfa, 0xed, 0xfe] }] },
203
+ { format: "Mach-O binary", segments: [{ offset: 0, bytes: [0xcf, 0xfa, 0xed, 0xfe] }] },
204
+ { format: "Java class file or Mach-O universal binary", segments: [{ offset: 0, bytes: [0xca, 0xfe, 0xba, 0xbe] }] },
205
+ { format: "WebAssembly module", segments: [{ offset: 0, bytes: [0x00, 0x61, 0x73, 0x6d] }] },
206
+ { format: "OLE compound document (Office 97-2003 or MSI)", segments: [{ offset: 0, bytes: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] }] },
207
+ { format: "SQLite database", segments: [{ offset: 0, bytes: [...asciiBytes("SQLite format 3"), 0x00] }] },
208
+ { format: "WOFF font", segments: [{ offset: 0, bytes: asciiBytes("wOFF") }] },
209
+ { format: "WOFF2 font", segments: [{ offset: 0, bytes: asciiBytes("wOF2") }] },
210
+ { format: "OpenType font", segments: [{ offset: 0, bytes: asciiBytes("OTTO") }] },
211
+ { format: "TrueType font", segments: [{ offset: 0, bytes: [0x00, 0x01, 0x00, 0x00] }] },
212
+ { format: "TrueType font collection", segments: [{ offset: 0, bytes: asciiBytes("ttcf") }] },
213
+ ];
214
+ export function binaryMagicFormat(bytes) {
215
+ for (const signature of BINARY_MAGIC_SIGNATURES) {
216
+ let matched = true;
217
+ for (const segment of signature.segments) {
218
+ if (bytes.length < segment.offset + segment.bytes.length) {
219
+ matched = false;
220
+ break;
221
+ }
222
+ for (let i = 0; i < segment.bytes.length; i++) {
223
+ if (bytes[segment.offset + i] !== segment.bytes[i]) {
224
+ matched = false;
225
+ break;
226
+ }
227
+ }
228
+ if (!matched)
229
+ break;
230
+ }
231
+ if (matched)
232
+ return signature.format;
233
+ }
234
+ return undefined;
235
+ }
153
236
  export function isBinaryContent(sample) {
154
237
  if (sample.length === 0)
155
238
  return false;
@@ -297,7 +380,7 @@ export function requireRead(state, key) {
297
380
  }
298
381
  export const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused — read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
299
382
  export const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view — the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
300
- export const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
383
+ export const READ_REFUSED_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
301
384
  export function checkNoChange(oldString, newString) {
302
385
  if (oldString === newString) {
303
386
  return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -67,7 +67,7 @@
67
67
  "gate:single-mint": "node scripts/verify-single-mint.mjs"
68
68
  },
69
69
  "dependencies": {
70
- "@modelcontextprotocol/sdk": "1.29.0",
70
+ "@modelcontextprotocol/sdk": "1.30.0",
71
71
  "typebox": "1.1.39"
72
72
  },
73
73
  "optionalDependencies": {