@sema-agent/core 5.18.0 → 5.19.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,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.19.0 — 2026-08-10
4
+
5
+ > Three bundles, each pre-verified two-way by the evaluation line against the 5.18.1 artifact
6
+ > before this release (criteria [3134], green-lit [3146]).
7
+
8
+ ### BREAKING
9
+
10
+ - **A task's PreToolUse screening face now screens its DELEGATED CHILDREN** (issue #33). The
11
+ face used to run only in the gate of the task that installed it, so a call it denied executed
12
+ unrefused one level down — sync, background and workflow legs alike, at any depth. It now
13
+ folds into a ToolPolicy on the existing ancestor-constraint chain (full-installation identity,
14
+ four-axis dedup so a deps-level hook is consulted once per call at any depth). A screening
15
+ `ask` resolves at the installing task's frozen approver; a hook-wired parent with no approver
16
+ denies its children's screened calls fail-closed.
17
+ **Operational**: `parentConstraintCount` gains a screening entry for hook-wired trees — a
18
+ pending approval checkpoint minted before this change and resumed after (or the reverse)
19
+ fails pre-CAS with `resume.parent_constraint_mismatch`. **Drain pending checkpoints across
20
+ the upgrade.** A delegated child of a hook-wired parent also sets
21
+ `requiresParentConstraint`, so its durable resume must re-supply the chain via
22
+ `resumeStream(..., internals)`.
23
+ - **Write guards judge the call's live cwd** (P1, host-lane proven bypass). A resident shell's
24
+ `cd` moved what a relative Write/Edit/NotebookEdit target meant, while the guards kept
25
+ judging the wiring-time root — `cd .git/hooks` + `Write("pre-commit")` landed unrefused.
26
+ Every path-resolving guard (sensitive-path, fs-write-gate, session-rule allowDirs,
27
+ active-skill allowPaths, transcript-integrity, run-spec frozen deny) now resolves the target
28
+ against `ToolCallRequest.cwd`, stamped per call from the same tracked cwd the tools resolve
29
+ with. **The asymmetry is deliberate**: the target follows the live cwd; each guard's own
30
+ configured directories stay anchored to the static root, so a `cd` cannot relocate the fence
31
+ itself. Absent the stamp (direct invocation), every guard falls back to its configured root —
32
+ byte-identical prior behavior. Also newly asked: a non-read-only Bash whose cwd sits inside
33
+ the transcript directory, and a POSIX filename containing a backslash (fail-closed).
34
+ Registered, not fixed: bash builtin writes, hook `touchedPaths`, the shell-operand axis
35
+ (backlog #108 notes).
36
+
37
+ ### Fixed
38
+
39
+ - **Read-only shell classification (`shellGate:"classify"`) prompts less and is more accurate
40
+ in both directions.** The compound splitter carries quote state, so a quoted connector —
41
+ `grep -E "a|b"`, the ordinary spelling of a read-only monitoring pipeline — is argument text
42
+ rather than a phantom command boundary; `2>/dev/null` and output-descriptor fd duplication
43
+ are recognized as moving no data into any file. No widening: every other redirection form
44
+ still rejects, unbalanced quoting falls back to the stricter blind scan, and phantom segments
45
+ could only ever add a rejection. Also tightened in the demoting direction: operand counts now
46
+ match what bash passes, so stdin-reading forms that would hang (`grep "a | b"`,
47
+ `cut -d , -f1`, `grep -if-`, `grep -f/dev/zero`) no longer auto-allow.
48
+
49
+ ### Guards / tooling (no behavior surface)
50
+
51
+ - `scripts/ship-post-lint.mjs`: release-post commit references are ancestry-verified against
52
+ the release, and negative claims get the real two-version dist delta to be written against.
53
+ - A brain silent-recovery-arm ratchet freezes the 26 existing tell-nobody arms so a new one has
54
+ to be argued for; the delegation carriage surfaces (live chain, constraint entry, checkpoint
55
+ projection) are exhaustively registered so a new field stops compiling until its travel is
56
+ recorded.
57
+
58
+ ## 5.18.1 — 2026-08-10
59
+
60
+ > The 5.18.0 post-release verification round, folded back in. Every surface below was
61
+ > two-way-verified against the 5.18.0 artifact by the evaluation line before this release
62
+ > (pre-release criteria [3095], green-lit [3103]).
63
+
64
+ ### BREAKING (vs 5.18.0; both APIs are new in 5.18.0 and cli has not consumed them yet)
65
+
66
+ - **`confirmRuleApproval` returns `{ ok: true } | { ok: false, reason }`** instead of a bare
67
+ boolean — seven-member closed reason set (`record_not_found` deliberately covers the
68
+ foreign-principal arm so record existence does not leak, `selection_missing`,
69
+ `selection_invalid`, `selection_mismatch`, `batch_takes_no_selection`, `not_pending`,
70
+ `conflict`). `selectedCandidate` is an integer index.
71
+ - **`prepareCardApproval` refuses caller-supplied `candidates` loudly**
72
+ (`config.invalid_argument`) instead of silently ignoring them — card options are
73
+ engine-minted, and a dropped supply attempt is the same silent-narrowing shape this
74
+ release family removes elsewhere.
75
+
76
+ ### Added
77
+
78
+ - **`settledBy` on approval settlements** (`"human" | "timeout" | "aborted"`): an optional
79
+ field on the durable resume outcome's `policy_ask` arm, carried onto the settled call's
80
+ `tool_end` (top-level key). Out-of-vocabulary values are refused pre-CAS
81
+ (`checkpoint.invalid_outcome`, `detail.field: "settledBy"` — the row stays pending);
82
+ `decision: "allow"` accepts only `"human"` or omission (a contradictory record must not
83
+ become an execution). Core's own arms fill it (elapsed window = `timeout`; abort /
84
+ unclonable / out-of-contract = `aborted`; human answers = `"human"`); posture arms
85
+ (headless auto-deny, blanket modes) leave it absent. Absence = an older caller — consumers
86
+ must not infer semantics from a missing key. New exports: `ApprovalSettledBy`,
87
+ `APPROVAL_SETTLED_BY_VALUES`, `isApprovalSettledBy`.
88
+
89
+ ### Fixed
90
+
91
+ - **An elapsed approval window says nobody answered** — the live-deadline refusal no longer
92
+ borrows the sentence a person's refusal uses; human-refusal and no-provenance wordings are
93
+ byte-for-byte unchanged (269 existing approval strings verified untouched).
94
+ - **`createAllowDenyPolicy` screens malformed `mcp__` names** (empty segment: `mcp__`,
95
+ `mcp__a__`) alongside the content form; `mcp__server` / `mcp__server__tool` and the
96
+ conditionally-retired `Task` spelling stay accepted. The constructor-level retired-name
97
+ screen added briefly after 5.18.0 was withdrawn — the roster-aware prepare-time audit
98
+ (`config.legacy_tool_name`, all three kinds) already owns that refusal.
99
+
3
100
  ## 5.18.0 — 2026-08-10
4
101
 
5
102
  > Two mainline features land together: design/178 personal-assistant memory v1 (the retrieval pair
@@ -95,6 +95,7 @@ export type ResumeOutcome = {
95
95
  updatedInput?: unknown;
96
96
  reason?: string;
97
97
  answer?: QuestionAnswer;
98
+ settledBy?: import("./tool-policy.js").ApprovalSettledBy;
98
99
  } | {
99
100
  gate: "resource_limit";
100
101
  decision: "continue";
@@ -277,10 +278,10 @@ export declare function summarizeCheckpoint(cp: Checkpoint): CheckpointSummary;
277
278
  export declare class CheckpointError extends Error {
278
279
  readonly code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" | "checkpoint.gate_mismatch" | "checkpoint.resume_aborted" | "checkpoint.invalid_outcome" | "checkpoint.unsupported_version" | "checkpoint.walltime_axis_retired" | "checkpoint.reopen_revote" | "checkpoint.reopened_concurrently" | "checkpoint.reopen_failed" | "steering.invalid_content" | "steering.queue_full" | "steering.duplicate_input_id" | "wake.gate_pending" | "wake.nothing_to_deliver" | "resume.parent_constraint_missing" | "resume.parent_constraint_mismatch";
279
280
  readonly detail?: {
280
- field?: "boundCallId" | "boundInputHash" | "answer";
281
+ field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
281
282
  } | undefined;
282
283
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found" | "checkpoint.gate_mismatch" | "checkpoint.resume_aborted" | "checkpoint.invalid_outcome" | "checkpoint.unsupported_version" | "checkpoint.walltime_axis_retired" | "checkpoint.reopen_revote" | "checkpoint.reopened_concurrently" | "checkpoint.reopen_failed" | "steering.invalid_content" | "steering.queue_full" | "steering.duplicate_input_id" | "wake.gate_pending" | "wake.nothing_to_deliver" | "resume.parent_constraint_missing" | "resume.parent_constraint_mismatch", message: string, detail?: {
283
- field?: "boundCallId" | "boundInputHash" | "answer";
284
+ field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
284
285
  } | undefined);
285
286
  }
286
287
  export type StoreDurability = "durable" | "process-local";
@@ -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,6 +1,6 @@
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
6
  postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
@@ -104,6 +104,7 @@ export declare function formatHookFeedback(text: string): string;
104
104
  export interface ToolGateResult {
105
105
  block?: boolean;
106
106
  reason?: string;
107
+ settledBy?: import("./tool-policy.js").ApprovalSettledBy;
107
108
  updatedInput?: unknown;
108
109
  suspend?: {
109
110
  token: import("./checkpoint-store.js").CheckpointToken;
@@ -164,4 +165,5 @@ export interface ToolGateInput {
164
165
  }) => void;
165
166
  };
166
167
  }
168
+ export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void): ToolPolicy;
167
169
  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
  }
@@ -411,9 +443,11 @@ export async function runToolGate(input) {
411
443
  if (input.permissionDenied) {
412
444
  await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
413
445
  }
446
+ const denySettledBy = decision.settledBy;
414
447
  return {
415
448
  block: true,
416
449
  reason: formatHookFeedback(denyReason),
450
+ ...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
417
451
  preToolContext,
418
452
  };
419
453
  }
@@ -422,8 +456,10 @@ export async function runToolGate(input) {
422
456
  if (rw !== undefined)
423
457
  currentInput = rw;
424
458
  }
459
+ const allowSettledBy = decision.action === "allow" ? decision.settledBy : undefined;
425
460
  return {
426
461
  updatedInput: currentInput === event.input ? undefined : currentInput,
462
+ ...(allowSettledBy !== undefined ? { settledBy: allowSettledBy } : {}),
427
463
  preToolContext,
428
464
  };
429
465
  }
@@ -56,7 +56,14 @@ export declare function confirmRuleApproval(opts: {
56
56
  principal: string;
57
57
  selectedCandidate?: number;
58
58
  deps: RuleConsentDeps;
59
- }): Promise<boolean>;
59
+ }): Promise<ConfirmResult>;
60
+ export type ConfirmRefusalReason = "record_not_found" | "selection_missing" | "selection_invalid" | "selection_mismatch" | "batch_takes_no_selection" | "not_pending" | "conflict";
61
+ export type ConfirmResult = {
62
+ ok: true;
63
+ } | {
64
+ ok: false;
65
+ reason: ConfirmRefusalReason;
66
+ };
60
67
  export type RedeemResult = {
61
68
  status: "redeemed";
62
69
  rule: string;
@@ -47,6 +47,11 @@ function parseRuleTicket(ticket) {
47
47
  }
48
48
  export async function prepareCardApproval(opts) {
49
49
  const principal = requirePrincipal(opts.principal, "prepareCardApproval");
50
+ if ("candidates" in opts) {
51
+ const e = new Error("prepareCardApproval does not accept caller candidates — card options are minted by the engine from the adjudicated command");
52
+ e.code = "config.invalid_argument";
53
+ throw e;
54
+ }
50
55
  if (opts.toolName !== CARD_RULE_TOOL)
51
56
  return undefined;
52
57
  const scope = opts.scope ?? { kind: "global" };
@@ -70,24 +75,30 @@ export async function prepareCardApproval(opts) {
70
75
  const CARD_RULE_TOOL = "Bash";
71
76
  export async function confirmRuleApproval(opts) {
72
77
  const principal = requirePrincipal(opts.principal, "confirmRuleApproval");
78
+ const no = (reason) => ({ ok: false, reason });
73
79
  const rec = await opts.deps.approvals.get(opts.approvalId);
74
80
  if (rec === undefined || rec.principal !== principal)
75
- return false;
81
+ return no("record_not_found");
76
82
  if (rec.kind === "card") {
77
83
  const chosen = opts.selectedCandidate;
78
- if (chosen === undefined || !Number.isInteger(chosen) || rec.candidates[chosen] === undefined)
79
- return false;
80
- if (rec.state === "approved" || rec.state === "redeemed")
81
- return rec.selectedCandidate === chosen;
82
- return await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedCandidate: chosen });
84
+ if (chosen === undefined)
85
+ return no("selection_missing");
86
+ if (!Number.isInteger(chosen) || rec.candidates[chosen] === undefined)
87
+ return no("selection_invalid");
88
+ if (rec.state === "approved" || rec.state === "redeemed") {
89
+ return rec.selectedCandidate === chosen ? { ok: true } : no("selection_mismatch");
90
+ }
91
+ const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved", selectedCandidate: chosen });
92
+ return won ? { ok: true } : no("conflict");
83
93
  }
84
94
  if (opts.selectedCandidate !== undefined)
85
- return false;
95
+ return no("batch_takes_no_selection");
86
96
  if (rec.state === "approved")
87
- return true;
97
+ return { ok: true };
88
98
  if (rec.state !== "pending")
89
- return false;
90
- return await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
99
+ return no("not_pending");
100
+ const won = await opts.deps.approvals.cas(rec.id, rec.rev, { ...rec, rev: rec.rev + 1, state: "approved" });
101
+ return won ? { ok: true } : no("conflict");
91
102
  }
92
103
  export async function redeemRuleTicket(opts) {
93
104
  const principal = requirePrincipal(opts.principal, "redeemRuleTicket");
@@ -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";
@@ -64,6 +65,7 @@ export interface Prepared {
64
65
  hit: boolean;
65
66
  };
66
67
  blockedToolCalls: Set<string>;
68
+ approvalSettledBy: Map<string, import("../tool-policy.js").ApprovalSettledBy>;
67
69
  nestedStats: NestedUsageAccum;
68
70
  rewindNotes?: NonNullable<TaskResult["rewindNotes"]>;
69
71
  cwdRef?: CwdRef;
@@ -329,6 +331,8 @@ export interface InheritedGate {
329
331
  policy: ToolPolicy;
330
332
  onAsk?: OnAsk;
331
333
  durableMandate?: boolean;
334
+ preToolUse?: Hooks["preToolUse"];
335
+ hookEnv?: unknown;
332
336
  }>;
333
337
  }
334
338
  export declare function isFableFamilyModelId(id: string): boolean;