@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.
- package/CHANGELOG.md +144 -0
- package/dist/agents/roster-store.js +3 -0
- package/dist/brain/circuit-breaker.js +14 -3
- package/dist/brain/timeout.d.ts +1 -0
- package/dist/brain/timeout.js +11 -0
- package/dist/core/background-agent-store.d.ts +1 -0
- package/dist/core/background-agent-store.js +5 -0
- package/dist/core/fs-write-gate-policy.js +1 -1
- package/dist/core/hooks.d.ts +3 -1
- package/dist/core/hooks.js +32 -0
- package/dist/core/mailbox-store.js +2 -0
- package/dist/core/mcp.d.ts +4 -0
- package/dist/core/mcp.js +58 -11
- package/dist/core/retention-policy.d.ts +7 -0
- package/dist/core/retention-policy.js +21 -0
- package/dist/core/runner/active-skill-scope.js +1 -1
- package/dist/core/runner/prepare-task.d.ts +3 -0
- package/dist/core/runner/prepare-task.js +195 -121
- package/dist/core/runner/runtask.js +29 -4
- package/dist/core/runner/session-rule-policy.js +1 -1
- package/dist/core/sensitive-path-policy.js +1 -1
- package/dist/core/task-registry-agent.js +2 -0
- package/dist/core/tool-policy.d.ts +1 -0
- package/dist/core/tool-policy.js +21 -12
- package/dist/core/workflow-run-store.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/run-spec.js +5 -1
- package/dist/orchestration/workflow.js +13 -2
- package/dist/stores/file/background-agent-store.js +2 -1
- package/dist/stores/file/mailbox-store.js +2 -0
- package/dist/stores/file/workflow-run-store.js +2 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +5 -2
- package/dist/tools/fs/bash-readonly-classifier.js +129 -17
- package/dist/tools/web.js +32 -5
- 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
|
|
29
|
-
const cooldownMs = opts.cooldownMs
|
|
30
|
-
const halfOpenProbes = opts.halfOpenProbes
|
|
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();
|
package/dist/brain/timeout.d.ts
CHANGED
|
@@ -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;
|
package/dist/brain/timeout.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/hooks.js
CHANGED
|
@@ -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;
|
package/dist/core/mcp.d.ts
CHANGED
|
@@ -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
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
105
|
-
if (
|
|
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 =
|
|
108
|
-
|
|
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,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;
|