@sema-agent/core 5.18.1 → 5.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/agents/roster-store.js +3 -0
  3. package/dist/brain/circuit-breaker.js +14 -3
  4. package/dist/brain/timeout.d.ts +1 -0
  5. package/dist/brain/timeout.js +11 -0
  6. package/dist/core/background-agent-store.d.ts +1 -0
  7. package/dist/core/background-agent-store.js +5 -0
  8. package/dist/core/fs-write-gate-policy.js +1 -1
  9. package/dist/core/hooks.d.ts +3 -1
  10. package/dist/core/hooks.js +32 -0
  11. package/dist/core/mailbox-store.js +2 -0
  12. package/dist/core/mcp.d.ts +4 -0
  13. package/dist/core/mcp.js +58 -11
  14. package/dist/core/retention-policy.d.ts +7 -0
  15. package/dist/core/retention-policy.js +21 -0
  16. package/dist/core/runner/active-skill-scope.js +1 -1
  17. package/dist/core/runner/prepare-task.d.ts +3 -0
  18. package/dist/core/runner/prepare-task.js +195 -121
  19. package/dist/core/runner/runtask.js +29 -4
  20. package/dist/core/runner/session-rule-policy.js +1 -1
  21. package/dist/core/sensitive-path-policy.js +1 -1
  22. package/dist/core/task-registry-agent.js +2 -0
  23. package/dist/core/tool-policy.d.ts +1 -0
  24. package/dist/core/tool-policy.js +21 -12
  25. package/dist/core/workflow-run-store.js +2 -0
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/orchestration/run-spec.js +5 -1
  29. package/dist/orchestration/workflow.js +13 -2
  30. package/dist/stores/file/background-agent-store.js +2 -1
  31. package/dist/stores/file/mailbox-store.js +2 -0
  32. package/dist/stores/file/workflow-run-store.js +2 -0
  33. package/dist/tools/fs/bash-readonly-classifier.d.ts +5 -2
  34. package/dist/tools/fs/bash-readonly-classifier.js +129 -17
  35. package/dist/tools/web.js +32 -5
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.20.0 — 2026-08-08
4
+
5
+ ### BREAKING
6
+
7
+ Configuration hygiene sweep. A malformed numeric knob used to be accepted and then quietly do
8
+ something — usually the OPPOSITE of what it was set to. Every seam below now either refuses the
9
+ value at its resolution point or clamps it and says so. Well-formed values in the documented range
10
+ are untouched everywhere; the break is that values which used to be accepted-then-misbehave are
11
+ now rejected, so a deployment carrying one gets a loud failure at wiring time instead of a
12
+ mysterious runtime.
13
+
14
+ - **MCP millisecond env knobs** (`MCP_TOOL_TIMEOUT`, `MCP_TOOL_TIMEOUT_TOTAL`,
15
+ `MCP_IDLE_TIMEOUT_STDIO`, `MCP_IDLE_TIMEOUT_HTTP`, `MCP_TIMEOUT`) share one parser, which was
16
+ `parseInt`-based: `1e9` meant 1ms, `30s` meant 30ms, and `100_000_000` — the way this repo writes
17
+ the default constant it invites you to copy — meant 100ms. Past 2^31-1 the host timer truncated
18
+ the delay and fired at once, so widening a watchdog to "basically off" made it abort every call,
19
+ while the failure text quoted the requested number back. The parser now reads plain digits,
20
+ scientific notation and digit grouping, refuses anything else, and clamps into
21
+ **[1000, 2147483647] ms**, warning once on stderr with the knob name, the raw text and the value
22
+ actually in force. Timeout frames quote the resolved value. `MAX_MCP_OUTPUT_TOKENS` moves onto the
23
+ same grammar (its `1e5` used to mean a 4-char budget), keeping upstream's unbounded range.
24
+ Upgrade note (the widening side of this fix): on 5.19.0 and earlier, `1e9` / `1_800_000` — both
25
+ legal upstream spellings — silently took effect as **1ms**. After upgrading they take effect at
26
+ face value, so a deployment that wrote one of these will see the knob jump from 1ms to the
27
+ number it always said.
28
+ - **Millisecond knobs that arm a host timer** are refused above 2147483647ms (~24.8 days), where
29
+ the timer truncates and fires immediately: the stall watchdogs (`connectTimeoutMs`,
30
+ `firstTokenTimeoutMs`, `idleTimeoutMs`), `brainCallGuardrailMs`, `runWorkflow`'s `totalTimeoutMs`
31
+ / `stallMs` / `throttleBackoffMs`, and `createApprovalPolicy`'s `approvalTimeoutMs` — the last of
32
+ which would otherwise have denied every request instantly, fail-closed and silent. Each already
33
+ documents `0` / `false` / omission as "off". `limits.maxWalltimeMs` is the exception and keeps
34
+ its promise of no ceiling: the hard-abort timer now waits in representable chunks.
35
+ - **Retention policies** (`BackgroundAgentStore.reap`, `MailboxStore.reap`, `WorkflowRunStore.reap`,
36
+ `reapDurableAgents`, both roster stores' constructors) refuse a non-finite or negative bound with
37
+ `config.retention_policy_invalid`. `keep` is consumed as `slice(Math.max(0, keep))`, where NaN
38
+ collapses to `slice(0)` and deleted EVERY terminal row in the scope; the roster's `maxAgeMs` made
39
+ every durable address read as expired. Absent fields keep their "this arm is not applied" meaning.
40
+ Operational note: this is a refusal, not a sanitize — a deployment with a malformed retention knob
41
+ will see every reap throw (rows accumulate) until the knob is corrected. Fix the knob before
42
+ upgrading; nothing is auto-repaired on your behalf.
43
+ - **Web and circuit-breaker knobs**: `timeoutMs` on WebFetch / WebSearch / the Searxng backend fed a
44
+ bare `setTimeout`, where NaN and Infinity both become 1ms — a budget written as "no limit" aborted
45
+ every request on arrival and reported "timed out after NaNms". `maxResults` fed `slice(0, max)`,
46
+ so a NaN or 0 cap emptied every search from a backend that answered. The circuit breaker's
47
+ `failureThreshold` / `cooldownMs` / `halfOpenProbes` had no validation at all, and `failures >= NaN`
48
+ is false forever — an unevaluable threshold left the breaker permanently open-loop. All are now
49
+ resolved at assembly time (`config.web_timeout_invalid`,
50
+ `config.web_search_max_results_invalid`, `config.circuit_breaker_invalid`). `probeSearchBackend`
51
+ keeps its documented never-throws contract: a malformed budget comes back as `{ ok: false, error }`.
52
+
53
+ ### Added
54
+
55
+ - **`Hooks.preToolUseObservational`** — a deployment declares that its PreToolUse face is a pure
56
+ OBSERVER (a tracer, an audit sink) and never judges. 5.19.0's delegation fold keys on PRESENCE,
57
+ which cannot tell a tracer from a screener: a deployment that flipped on a default-OFF diagnostic
58
+ hook thereby added an opaque constraint to every delegated child, and a child that durably parked
59
+ recorded that count on its row — where an approval redeemed by a leg that cannot re-supply the
60
+ live screening closure has no recovery path. With the declaration the face contributes **no
61
+ chain entry**, so `parentConstraintCount` counts only constraints that actually judge; without it,
62
+ 5.19.0 behavior is unchanged. The callback still runs in the installing task's own gate, on every
63
+ call, with the same crash posture (a throw is still the fail-closed deny) — only the inherited
64
+ constraint goes away. A declared-observational face that returns something anyway has that return
65
+ **refused, not obeyed**: the call proceeds as if the face had no opinion (this covers
66
+ `additionalContext` too) and `onError` is told (`phase:"hook"`, classification
67
+ `observational-hook-verdict-ignored`), so the declaration cannot become a quiet way to keep a
68
+ screening face's verdicts while shedding the delegation constraint they belong to. A declared face
69
+ is also handed a **detached copy of the arguments** (best-effort, same helper and same limits as the
70
+ other observe-only payloads): the gate reads a rewrite off reference identity, so an in-place edit
71
+ would be a second channel outliving the dropped return. Two further consequences of "it does not
72
+ judge": a declared face no longer collapses an ancestor's screening entry that names the same
73
+ callback (the descendant no longer honors it, so the ancestor's constraint is folded instead), and it
74
+ no longer counts as an effect-aware gate for the loud ungated-write-surface warning.
75
+ - **`createPreToolUseConstraintPolicy` is now a public export**, and a durable park under an inherited
76
+ screening constraint says so at PARK time. A checkpoint records only how many opaque constraints its
77
+ leg ran under; the resume must hand the same chain back. Every entry kind except one is a policy the
78
+ deployment authored — the screening entry 5.19.0 added is engine-minted, so a hook-wired tree's
79
+ parked children were redeemable only in the process that spawned them (same-Runner resumes are
80
+ auto-re-supplied from the in-memory registry and were never affected). The mint is now exported, so a
81
+ fresh Runner can rebuild the entry — same callback, same installed env — and hand the full chain to
82
+ `resumeStream(..., internals)`; the recorded-count check is unchanged, so a short or mismatched chain
83
+ is still refused pre-CAS. And the park itself now announces the requirement once per task on
84
+ `onError` (`phase:"degraded"`, classification `screening-constraint-in-durable-chain`), naming the
85
+ recorded count and the ways out, instead of leaving it to be discovered at a redemption that fails.
86
+
87
+ ## 5.19.0 — 2026-08-10
88
+
89
+ > Three bundles, each pre-verified two-way by the evaluation line against the 5.18.1 artifact
90
+ > before this release (criteria [3134], green-lit [3146]).
91
+
92
+ ### BREAKING
93
+
94
+ - **A task's PreToolUse screening face now screens its DELEGATED CHILDREN** (issue #33). The
95
+ face used to run only in the gate of the task that installed it, so a call it denied executed
96
+ unrefused one level down — sync, background and workflow legs alike, at any depth. It now
97
+ folds into a ToolPolicy on the existing ancestor-constraint chain (full-installation identity,
98
+ four-axis dedup so a deps-level hook is consulted once per call at any depth). A screening
99
+ `ask` resolves at the installing task's frozen approver; a hook-wired parent with no approver
100
+ denies its children's screened calls fail-closed.
101
+ **Operational (corrected per downstream measurement)**: `parentConstraintCount` gains a
102
+ screening entry for hook-wired trees. Pre-5.19.0 rows (including hook-wired parents) recorded
103
+ the old count and rebuild legs that supply it keep matching — upgrade and rollback are clean
104
+ for EXISTING pending checkpoints. The unrecoverable case is a checkpoint **minted on 5.19.0**
105
+ by a hook-wired parent and redeemed by a rebuild leg that cannot re-supply the live screening
106
+ entry (cross-replica redemption): it fails pre-CAS with `resume.parent_constraint_mismatch`
107
+ and stays pending, with no recovery path (the count is written at park time; disabling the
108
+ hook afterwards does not change it). Same-replica resumes via `resumeStream(..., internals)`
109
+ are unaffected. A delegated child of a hook-wired parent also sets
110
+ `requiresParentConstraint`, so its durable resume must re-supply the chain via
111
+ `resumeStream(..., internals)`.
112
+ - **Write guards judge the call's live cwd** (P1, host-lane proven bypass). A resident shell's
113
+ `cd` moved what a relative Write/Edit/NotebookEdit target meant, while the guards kept
114
+ judging the wiring-time root — `cd .git/hooks` + `Write("pre-commit")` landed unrefused.
115
+ Every path-resolving guard (sensitive-path, fs-write-gate, session-rule allowDirs,
116
+ active-skill allowPaths, transcript-integrity, run-spec frozen deny) now resolves the target
117
+ against `ToolCallRequest.cwd`, stamped per call from the same tracked cwd the tools resolve
118
+ with. **The asymmetry is deliberate**: the target follows the live cwd; each guard's own
119
+ configured directories stay anchored to the static root, so a `cd` cannot relocate the fence
120
+ itself. Absent the stamp (direct invocation), every guard falls back to its configured root —
121
+ byte-identical prior behavior. Also newly asked: a non-read-only Bash whose cwd sits inside
122
+ the transcript directory, and a POSIX filename containing a backslash (fail-closed).
123
+ Registered, not fixed: bash builtin writes, hook `touchedPaths`, the shell-operand axis
124
+ (backlog #108 notes).
125
+
126
+ ### Fixed
127
+
128
+ - **Read-only shell classification (`shellGate:"classify"`) prompts less and is more accurate
129
+ in both directions.** The compound splitter carries quote state, so a quoted connector —
130
+ `grep -E "a|b"`, the ordinary spelling of a read-only monitoring pipeline — is argument text
131
+ rather than a phantom command boundary; `2>/dev/null` and output-descriptor fd duplication
132
+ are recognized as moving no data into any file. No widening: every other redirection form
133
+ still rejects, unbalanced quoting falls back to the stricter blind scan, and phantom segments
134
+ could only ever add a rejection. Also tightened in the demoting direction: operand counts now
135
+ match what bash passes, so stdin-reading forms that would hang (`grep "a | b"`,
136
+ `cut -d , -f1`, `grep -if-`, `grep -f/dev/zero`) no longer auto-allow.
137
+
138
+ ### Guards / tooling (no behavior surface)
139
+
140
+ - `scripts/ship-post-lint.mjs`: release-post commit references are ancestry-verified against
141
+ the release, and negative claims get the real two-version dist delta to be written against.
142
+ - A brain silent-recovery-arm ratchet freezes the 26 existing tell-nobody arms so a new one has
143
+ to be argued for; the delegation carriage surfaces (live chain, constraint entry, checkpoint
144
+ projection) are exhaustively registered so a new field stops compiling until its travel is
145
+ recorded.
146
+
3
147
  ## 5.18.1 — 2026-08-10
4
148
 
5
149
  > The 5.18.0 post-release verification round, folded back in. Every surface below was
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, readFileSync } from "node:fs";
2
+ import { assertRetentionPolicy } from "../core/retention-policy.js";
2
3
  import { atomicWriteFile } from "../stores/file/fs-atomic.js";
3
4
  import { dirname } from "node:path";
4
5
  import { normalizeAgentName } from "../core/task-registry.js";
@@ -50,6 +51,7 @@ export class MemoryRosterStore {
50
51
  maxAgeMs;
51
52
  gc;
52
53
  constructor(opts) {
54
+ assertRetentionPolicy("RosterStore", opts);
53
55
  this.maxAgeMs = opts?.maxAgeMs;
54
56
  this.gc = opts;
55
57
  }
@@ -78,6 +80,7 @@ export class FileRosterStore {
78
80
  gc;
79
81
  onCorruptRead;
80
82
  constructor(path, opts) {
83
+ assertRetentionPolicy("RosterStore", opts);
81
84
  this.path = path;
82
85
  this.maxAgeMs = opts?.maxAgeMs;
83
86
  this.gc = opts;
@@ -24,10 +24,21 @@ function errorAssistantMessage(model, code, detail, stopReason = "error") {
24
24
  timestamp: Date.now(),
25
25
  };
26
26
  }
27
+ function finiteBreakerKnob(value, knob, fallback, wholeAtLeastOne) {
28
+ if (value === undefined)
29
+ return fallback;
30
+ const ok = wholeAtLeastOne ? Number.isInteger(value) && value >= 1 : Number.isFinite(value) && value >= 0;
31
+ if (!ok) {
32
+ const e = new Error(`createCircuitBreakerBrain: ${knob} must be ${wholeAtLeastOne ? "a whole number of 1 or more" : "a finite, non-negative number of milliseconds"} (got ${String(value)}) — an unevaluable knob leaves the breaker permanently open-loop, which is a protection that is silently not there`);
33
+ e.code = "config.circuit_breaker_invalid";
34
+ throw e;
35
+ }
36
+ return value;
37
+ }
27
38
  export function createCircuitBreakerBrain(inner, opts = {}) {
28
- const failureThreshold = opts.failureThreshold ?? 5;
29
- const cooldownMs = opts.cooldownMs ?? 30_000;
30
- const halfOpenProbes = opts.halfOpenProbes ?? 1;
39
+ const failureThreshold = finiteBreakerKnob(opts.failureThreshold, "failureThreshold", 5, true);
40
+ const cooldownMs = finiteBreakerKnob(opts.cooldownMs, "cooldownMs", 30_000, false);
41
+ const halfOpenProbes = finiteBreakerKnob(opts.halfOpenProbes, "halfOpenProbes", 1, true);
31
42
  const countCodes = new Set(opts.countCodes ?? ["network", "server", "rate_limit"]);
32
43
  const keyOf = opts.key ?? ((m) => `${m.provider}:${m.id}`);
33
44
  const state = opts.state ?? new InMemoryBreakerState();
@@ -1,4 +1,5 @@
1
1
  import type { StreamFn } from "../internal/llm.js";
2
+ export declare const MAX_TIMER_DELAY_MS = 2147483647;
2
3
  export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined;
3
4
  export declare const STALL_CONNECT_MS = 30000;
4
5
  export declare const STALL_FIRST_TOKEN_MS = 120000;
@@ -1,3 +1,4 @@
1
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
1
2
  export function resolveStallTimeoutMs(value, knob) {
2
3
  if (value === undefined)
3
4
  return undefined;
@@ -6,6 +7,11 @@ export function resolveStallTimeoutMs(value, knob) {
6
7
  e.code = "config.stall_timeout_invalid";
7
8
  throw e;
8
9
  }
10
+ if (value > MAX_TIMER_DELAY_MS) {
11
+ const e = new Error(`${knob} must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) — a host timer truncates a larger delay and fires immediately, so this watchdog would abort every call instead of tolerating a long one (got ${String(value)}). Pass 0 to disable it.`);
12
+ e.code = "config.stall_timeout_invalid";
13
+ throw e;
14
+ }
9
15
  return value;
10
16
  }
11
17
  export const STALL_CONNECT_MS = 30_000;
@@ -66,6 +72,11 @@ export function resolveBrainCallGuardrailMs(knob) {
66
72
  e.code = "config.brain_call_guardrail_invalid";
67
73
  throw e;
68
74
  }
75
+ if (knob > MAX_TIMER_DELAY_MS) {
76
+ const e = new Error(`brainCallGuardrailMs must not exceed ${MAX_TIMER_DELAY_MS}ms (~24.8 days) — a host timer truncates a larger delay and fires immediately, so the backstop would abort every model call (got ${String(knob)}). Pass false or 0 to turn the backstop off.`);
77
+ e.code = "config.brain_call_guardrail_invalid";
78
+ throw e;
79
+ }
69
80
  return knob;
70
81
  }
71
82
  function armCallGuardrail(limitMs, outerSignal, ref) {
@@ -87,6 +87,7 @@ export interface BackgroundAgentReapOptions {
87
87
  keep?: number;
88
88
  staleRunningMaxAgeMs?: number;
89
89
  }
90
+ export declare function assertBackgroundAgentReapOptions(opts: BackgroundAgentReapOptions | undefined): void;
90
91
  export interface BackgroundAgentStore {
91
92
  put(record: BackgroundAgentRecord): Promise<void>;
92
93
  get(handle: string, scope: string): Promise<BackgroundAgentRecord | null>;
@@ -1,4 +1,5 @@
1
1
  import { uuidv7 } from "../internal/harness.js";
2
+ import { assertRetentionPolicy } from "./retention-policy.js";
2
3
  export const REVIVED_ROW_CLEARED_FIELDS = [
3
4
  "settledAt",
4
5
  "stoppedBy",
@@ -43,6 +44,9 @@ export function canAccessAgentRecord(record, access) {
43
44
  return false;
44
45
  }
45
46
  export const STALE_RUNNING_REAP_ATTRIBUTION = "the host process was interrupted while this agent was running — its in-process state was lost. Check its worktree / output file for partial work before assuming the task landed (stale running row reaped).";
47
+ export function assertBackgroundAgentReapOptions(opts) {
48
+ assertRetentionPolicy("BackgroundAgentStore.reap", opts);
49
+ }
46
50
  export async function reconcileParkedAgents(stores, scope, now, opts) {
47
51
  const out = { failed: 0, rolledBack: 0 };
48
52
  const excluded = (row) => (opts?.excludeHandles?.has(row.handle) ?? false) ||
@@ -256,6 +260,7 @@ export class InMemoryBackgroundAgentStore {
256
260
  return [...new Set([...this.rows.values()].map((r) => r.scope))].sort();
257
261
  }
258
262
  async reap(scope, now, opts) {
263
+ assertBackgroundAgentReapOptions(opts);
259
264
  if (!opts || (opts.maxAgeMs === undefined && opts.keep === undefined && opts.staleRunningMaxAgeMs === undefined))
260
265
  return 0;
261
266
  const flippedKeys = new Set();
@@ -15,7 +15,7 @@ export function createFsWriteGatePolicy(opts) {
15
15
  if (path === undefined) {
16
16
  return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine`);
17
17
  }
18
- const canon = await canonicalizeTarget(env, path, signal, rootPath);
18
+ const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
19
19
  if (!canon.ok) {
20
20
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})`);
21
21
  }
@@ -1,8 +1,9 @@
1
1
  import type { DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
- import type { PermissionResult, ResolvedAsk, ToolCallRequest } from "./tool-policy.js";
3
+ import type { PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  export interface Hooks {
5
5
  preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
6
+ preToolUseObservational?: true;
6
7
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
7
8
  userPromptSubmit?(prompt: string): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
8
9
  stop?(ctx: StopHookContext): StopHookResult | undefined | Promise<StopHookResult | undefined>;
@@ -165,4 +166,5 @@ export interface ToolGateInput {
165
166
  }) => void;
166
167
  };
167
168
  }
169
+ export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
168
170
  export declare function runToolGate(input: ToolGateInput): Promise<ToolGateResult>;
@@ -98,6 +98,38 @@ function screenPreToolUseResult(r) {
98
98
  return undefined;
99
99
  return refuseOutOfContractDecision(r);
100
100
  }
101
+ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
102
+ return {
103
+ check: async (req) => {
104
+ let r;
105
+ try {
106
+ r = screenPreToolUseResult(await preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, ...(env !== undefined ? { env } : {}) }));
107
+ }
108
+ catch (err) {
109
+ try {
110
+ onCrash?.(err);
111
+ }
112
+ catch {
113
+ }
114
+ return { action: "deny", message: preToolUseCrashReason(`this call to "${req.toolName}"`, err), decisionReason: "hook" };
115
+ }
116
+ if (r === undefined)
117
+ return { action: "allow" };
118
+ if (r.action === "deny") {
119
+ return { action: "deny", message: decisionText(r) ?? `tool "${req.toolName}" blocked by a PreToolUse hook`, decisionReason: "hook" };
120
+ }
121
+ if (r.action === "ask") {
122
+ return {
123
+ action: "ask",
124
+ message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
125
+ decisionReason: "hook",
126
+ ...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
127
+ };
128
+ }
129
+ return r.updatedInput !== undefined ? { action: "allow", updatedInput: r.updatedInput } : { action: "allow" };
130
+ },
131
+ };
132
+ }
101
133
  function traceHookCrash(input, err, notifier) {
102
134
  notifier.notify(() => input.onHookError?.(err), "toolGate.onHookError");
103
135
  }
@@ -1,3 +1,4 @@
1
+ import { assertRetentionPolicy } from "./retention-policy.js";
1
2
  export function newestSentAt(messages) {
2
3
  let newest;
3
4
  for (const m of messages) {
@@ -65,6 +66,7 @@ export class InMemoryMailboxStore {
65
66
  this.boxes.delete(this.key(scope, handle));
66
67
  }
67
68
  async reap(scope, now, opts) {
69
+ assertRetentionPolicy("MailboxStore.reap", opts);
68
70
  if (opts?.maxAgeMs === undefined)
69
71
  return 0;
70
72
  let dropped = 0;
@@ -63,11 +63,15 @@ export declare function gateMcpOutput(content: Array<TextContent | ImageContent>
63
63
  export declare function structuredContentErrorLine(structuredContent: unknown, collectedText: string): string | undefined;
64
64
  export declare function truncateMcpErrorText(s: string): string;
65
65
  export declare const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100000000;
66
+ export declare const MCP_ENV_MS_MIN = 1000;
67
+ export declare const MCP_ENV_MS_MAX = 2147483647;
68
+ export declare function __resetMcpEnvAnnouncements(): void;
66
69
  export declare function mcpToolTimeoutMs(): number;
67
70
  export declare function mcpToolTotalTimeoutMs(perCallMs: number): number;
68
71
  export declare const MCP_IDLE_TIMEOUT_STDIO_DEFAULT_MS: number;
69
72
  export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
70
73
  export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
74
+ export declare function mcpStartupTimeoutMs(): number | undefined;
71
75
  export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
72
76
  export declare function collapseMcpErrorPrefix(message: string): string;
73
77
  export declare function networkErrorCode(err: unknown, depth?: number): string | undefined;
package/dist/core/mcp.js CHANGED
@@ -24,13 +24,18 @@ export function resolveMcpDeclaredResultSize(meta) {
24
24
  return Math.min(declared, MCP_META_RESULT_SIZE_CAP);
25
25
  }
26
26
  function mcpMaxOutputTokens() {
27
- const raw = process.env.MAX_MCP_OUTPUT_TOKENS;
28
- if (raw) {
29
- const n = parseInt(raw, 10);
30
- if (Number.isFinite(n) && n > 0)
31
- return n;
27
+ const rawEnv = process.env.MAX_MCP_OUTPUT_TOKENS;
28
+ if (rawEnv === undefined)
29
+ return MCP_OUTPUT_TOKENS_DEFAULT;
30
+ const raw = rawEnv.trim();
31
+ if (raw === "")
32
+ return MCP_OUTPUT_TOKENS_DEFAULT;
33
+ const n = parseWholeNumber(raw);
34
+ if (n === undefined || n <= 0) {
35
+ announceMcpEnvKnob(`MAX_MCP_OUTPUT_TOKENS=${rawEnv} was ignored — it is not a positive whole number of tokens. Using ${MCP_OUTPUT_TOKENS_DEFAULT} instead.`);
36
+ return MCP_OUTPUT_TOKENS_DEFAULT;
32
37
  }
33
- return MCP_OUTPUT_TOKENS_DEFAULT;
38
+ return n;
34
39
  }
35
40
  function mcpTruncationNote(limitTokens) {
36
41
  return (`\n\n[OUTPUT TRUNCATED - exceeded ${limitTokens} token limit]\n\n` +
@@ -100,12 +105,54 @@ export function truncateMcpErrorText(s) {
100
105
  return out.length < s.length ? out : s;
101
106
  }
102
107
  export const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100_000_000;
108
+ export const MCP_ENV_MS_MIN = 1_000;
109
+ export const MCP_ENV_MS_MAX = 2_147_483_647;
110
+ const ENV_SCIENTIFIC_RE = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/;
111
+ const ENV_GROUPED_RE = /^[+-]?\d{1,3}([_,\u00A0\u202F ])\d{3}(?:\1\d{3})*$/;
112
+ const ENV_GROUP_SEPARATORS_RE = /[_,\u00A0\u202F ]/g;
113
+ const ENV_NUMERIC_MAX_LEN = 32;
114
+ function parseWholeNumber(raw) {
115
+ if (/^[+-]?\d+$/.test(raw)) {
116
+ const n = Number(raw);
117
+ return Number.isSafeInteger(n) ? n : undefined;
118
+ }
119
+ if (raw.length > ENV_NUMERIC_MAX_LEN)
120
+ return undefined;
121
+ if (ENV_SCIENTIFIC_RE.test(raw)) {
122
+ const n = Number(raw);
123
+ return Number.isSafeInteger(n) ? n : undefined;
124
+ }
125
+ if (ENV_GROUPED_RE.test(raw))
126
+ return parseInt(raw.replace(ENV_GROUP_SEPARATORS_RE, ""), 10);
127
+ return undefined;
128
+ }
129
+ const announcedMcpEnvKnobs = new Set();
130
+ function announceMcpEnvKnob(line) {
131
+ if (announcedMcpEnvKnobs.has(line))
132
+ return;
133
+ announcedMcpEnvKnobs.add(line);
134
+ console.warn(line);
135
+ }
136
+ export function __resetMcpEnvAnnouncements() {
137
+ announcedMcpEnvKnobs.clear();
138
+ }
103
139
  function parseEnvMs(name) {
104
- const raw = process.env[name];
105
- if (!raw)
140
+ const rawEnv = process.env[name];
141
+ if (rawEnv === undefined)
142
+ return undefined;
143
+ const raw = rawEnv.trim();
144
+ if (raw === "")
106
145
  return undefined;
107
- const n = parseInt(raw, 10);
108
- return Number.isFinite(n) && n > 0 ? n : undefined;
146
+ const n = parseWholeNumber(raw);
147
+ if (n === undefined || n <= 0) {
148
+ announceMcpEnvKnob(`${name}=${rawEnv} was ignored — it is not a positive whole number of milliseconds. Using the built-in default instead.`);
149
+ return undefined;
150
+ }
151
+ const clamped = Math.min(Math.max(n, MCP_ENV_MS_MIN), MCP_ENV_MS_MAX);
152
+ if (clamped !== n) {
153
+ announceMcpEnvKnob(`${name}=${rawEnv} is outside the range this runtime can honor (${MCP_ENV_MS_MIN}..${MCP_ENV_MS_MAX} ms). Using ${clamped}ms instead.`);
154
+ }
155
+ return clamped;
109
156
  }
110
157
  export function mcpToolTimeoutMs() {
111
158
  return parseEnvMs("MCP_TOOL_TIMEOUT") ?? MCP_TOOL_TIMEOUT_DEFAULT_MS;
@@ -150,7 +197,7 @@ function armMcpIdleWatchdog(health, idleMs, outerSignal) {
150
197
  },
151
198
  };
152
199
  }
153
- function mcpStartupTimeoutMs() {
200
+ export function mcpStartupTimeoutMs() {
154
201
  return parseEnvMs("MCP_TIMEOUT");
155
202
  }
156
203
  const MCP_SPEC_ERROR_CODE_NAMES = new Map([
@@ -0,0 +1,7 @@
1
+ export interface RetentionPolicyKnobs {
2
+ maxAgeMs?: number;
3
+ keep?: number;
4
+ staleRunningMaxAgeMs?: number;
5
+ maxEntries?: number;
6
+ }
7
+ export declare function assertRetentionPolicy(label: string, opts: RetentionPolicyKnobs | undefined): void;
@@ -0,0 +1,21 @@
1
+ function invalidPolicy(label, knob, value, requirement) {
2
+ const e = new Error(`${label}: ${knob} must be ${requirement} (got ${String(value)}) — a retention bound that cannot be evaluated silently decides what to delete instead of bounding it`);
3
+ e.code = "config.retention_policy_invalid";
4
+ throw e;
5
+ }
6
+ export function assertRetentionPolicy(label, opts) {
7
+ if (opts === undefined)
8
+ return;
9
+ for (const knob of ["maxAgeMs", "staleRunningMaxAgeMs"]) {
10
+ const v = opts[knob];
11
+ if (v !== undefined && (!Number.isFinite(v) || v < 0)) {
12
+ invalidPolicy(label, knob, v, "a finite, non-negative number of milliseconds");
13
+ }
14
+ }
15
+ for (const knob of ["keep", "maxEntries"]) {
16
+ const v = opts[knob];
17
+ if (v !== undefined && (!Number.isInteger(v) || v < 0)) {
18
+ invalidPolicy(label, knob, v, "a whole number of rows, 0 or more");
19
+ }
20
+ }
21
+ }
@@ -96,7 +96,7 @@ export function createActiveSkillScopePolicy(opts) {
96
96
  decisionReason: "safety",
97
97
  };
98
98
  }
99
- const canon = await canonicalizeTarget(env, path, signal, rootPath);
99
+ const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
100
100
  if (!canon.ok) {
101
101
  return {
102
102
  action: "deny",
@@ -10,6 +10,7 @@ import { SubagentRetainLedger } from "../../agents/retain-ledger.js";
10
10
  import type { OnAsk, ToolPolicy } from "../tool-policy.js";
11
11
  import { type ActiveSkillFrame } from "./active-skill-scope.js";
12
12
  import type { SessionPermissionRules } from "../session-policy-store.js";
13
+ import { type Hooks } from "../hooks.js";
13
14
  import { type RecoveredOrphan } from "../session-reconcile.js";
14
15
  import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
15
16
  import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
@@ -330,6 +331,8 @@ export interface InheritedGate {
330
331
  policy: ToolPolicy;
331
332
  onAsk?: OnAsk;
332
333
  durableMandate?: boolean;
334
+ preToolUse?: Hooks["preToolUse"];
335
+ hookEnv?: unknown;
333
336
  }>;
334
337
  }
335
338
  export declare function isFableFamilyModelId(id: string): boolean;