@sema-agent/core 5.30.0 → 5.32.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,104 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.32.0 — 2026-08-14
4
+
5
+ No BREAKING changes. One additive disclosure surface; zero resolution-result changes (the read-face
6
+ clamp VERDICT is unchanged since 5.30.0 — only its occurrence is now announced).
7
+
8
+ ### Added
9
+
10
+ - `config.read_face_deployment_clamped` EngineNotice family (#237): a deployment-wide
11
+ `readFace: "open"` beside a read-only (verifier) mount clamps to `"roots"` (stricter-wins);
12
+ the clamp's occurrence is now announced instead of silent. The trigger fires only when the
13
+ deployment seat was the deciding one — an explicit task-level `"roots"` reaches the same value
14
+ with or without the mount (no false-cause claim), and the task-seat `"open"` contradiction still
15
+ refuses loudly. Delivery: the prepare station announces once per `onNotice` SINK (the listener,
16
+ not the deps object — the Runner rebuilds its deps and `runTask` builds a Runner per call, so a
17
+ deps-keyed ledger would repeat per task; unwired console arm latches once per process); a
18
+ library-direct `createHandsToolkit({ readFace: "open", readOnly: true })` announces at toolkit
19
+ creation through its own `HandsToolkitOptions.onNotice` seat (absent ⇒ the same line via
20
+ `console.warn`). `detail: { seat, declared, inForce, cause }`. New export:
21
+ `deploymentReadFaceClampNotice` (the one shared notice builder).
22
+
23
+ ### Fixed
24
+
25
+ - `HandsToolkitOptions.readFace` JSDoc (ships in the `.d.ts`) stopped promising a loud refusal
26
+ beside `readOnly: true` — stale since the 96ef89d seat distinction (5.30.0): the deps-shaped
27
+ seat clamps and announces; the loud refusal belongs to `TaskSpec.readFace`. Wording only, two
28
+ sites; the runtime behavior was already as now documented.
29
+
30
+ ### Docs
31
+
32
+ - The recursive-verb table (`RECURSIVE_READ_FORMS`) declares its KNOWN OPEN SET with the standing
33
+ sweep obligation, and `StripSurvey` states consumer guidance for truncated samples (the two
34
+ 5.31.0 review deductions; wording only).
35
+
36
+ ## 5.31.0 — 2026-08-14
37
+
38
+ No API-BREAKING changes. Behavior narrowings are all tighten-direction (auto-allow → ask, or
39
+ map → refuse) and named below; two additive surfaces (an optional contract-kit callback, a
40
+ workflow-governance notice family).
41
+
42
+ ### Narrowed (behavior)
43
+
44
+ - **Recursive/expanding read forms demote from auto-allow to ask when the sensitive-path deny set
45
+ is in force.** The bash read-only classifier's traversal set is not in the command text —
46
+ `grep -r . dir` names `dir`, not the `.ssh` it reaches — so under a wired deny judge, a
47
+ recursive verb (`grep -r/-R`, `ls -R`, `du`, `find`, `rg`/`ag`/`ack`/`tree` — recursive by
48
+ default, `tar` creation modes, `diff -r`) with a path operand is UNDECIDED and asks. Per-verb
49
+ getopt-aware form table (BSD/GNU divergences resolve demote-ward; divergent option-letter
50
+ ownership is scanned, not skipped); a bare `-` operand keeps stdin meaning only where the
51
+ command grants it (ls/du open a file literally named `-`); a recursive verb with NO operand
52
+ demotes too — the implicit cwd is the traversal root (`du`, `ls -R`, `tree` traverse `.` when
53
+ given nothing). A persisted allow rule clears the ask permanently (the rule lane absorbs the
54
+ one-time friction).
55
+ - **The classifier's operand walk honors POSIX end-of-options on EVERY face** (the same window's
56
+ `--` correction, disclosed as its own item because it flips behavior in BOTH directions on the
57
+ `bash_readonly` face too, which has no ask channel): past a bare `--`, a dash-leading token is
58
+ an OPERAND. Spellings like `cat -- -f/etc/passwd` used to be refused on a path extracted from
59
+ inside the token as if it were an option (a false refusal — the real program opens a relative
60
+ file literally named `-f/etc/passwd`); they now execute. Conversely a dash-named operand
61
+ (`cat -- -dir a.txt`) now enters symlink/boundary checking it previously skipped, so a
62
+ dash-named symlink pointing out of the roots is now refused. No new admission lands outside
63
+ the containment roots.
64
+ - **Win32 extended-length (`\\?\`) spellings map to DOS form only when the strip is an identity.**
65
+ Non-mappable bodies (`Volume{GUID}`, `GLOBALROOT`, device names, drive-relative `\\?\C:`,
66
+ forward-slash separators, empty/`.`/`..` components, trailing dots/spaces, reserved DOS device
67
+ components — `CONIN$`, superscript `COM¹` aliases included) now REFUSE by name instead of
68
+ silently resolving to a different file than the spelling denotes (approval-to-execution drift).
69
+ `WIN_RESERVED_RE` widens to the full documented reserved set (COM0/LPT0, superscript aliases,
70
+ `CONIN$`/`CONOUT$`) for every win-form key. UNC tail segments fold Win32 trailing dots/spaces so
71
+ a deny pattern matches the alias spelling.
72
+ - **The transcript-replay and startup-seed legs judge with the resolved read face** (open-face
73
+ twins of 5.30.0's attachment fix): a file legitimately read out-of-root under `readFace:"open"`
74
+ re-seeds across turns instead of being silently re-locked. A garbage `readFace` value now
75
+ refuses loudly on hands-less runs too (the value screen is unconditional).
76
+ - **Workflow governance: a governed script may declare the two read-face TIGHTENINGS**
77
+ (`readFace:"roots"` — admitted by VALUE, `"open"` never crosses — and `readDenyPatterns`,
78
+ grammar-checked by the child's own compiler). Every stripped unknown key is announced via the
79
+ new `workflow.governance_key_stripped` notice (one aggregated notice per governed build); the
80
+ WHITELIST_KEYS comment no longer claims a refusal rule that never existed.
81
+
82
+ ### Added
83
+
84
+ - **`toolResultStoreContract` gains an optional third parameter `onOptionalMember`** — the
85
+ observable channel for optional-member coverage (`deleteBySession`: `verified` | `absent`).
86
+ Previously absence was "reported" via an always-true assert message that only prints on failure,
87
+ so verified and absent produced byte-identical green runs. A present-but-uncallable member now
88
+ fails loudly as a defect. Additive: omitting the callback keeps prior behavior.
89
+ - **`PgToolResultStore.deleteBySession`** — the pg sibling of the file backend's session sweep
90
+ (same four-state semantics; one CTE statement so the deletion and the unattributable count come
91
+ from the same snapshot). The observable-absence channel above is what exposed it as missing.
92
+ - **Deny-refusal disclosure cites the view the pattern actually matched** (`ReadDenyHit.matchedView`):
93
+ under a symlink/case alias the canonical target and the requested spelling differ, and naming
94
+ the unmatched one sent the reader chasing a path the pattern does not match.
95
+ - Search results keep the honesty caveat and the deny-withholding note as SEPARATE disclosures
96
+ (a tail-anchored bracket merge used to splice read-loop facts into the deny note and drop the
97
+ "results may be incomplete" marker whenever pruning fired).
98
+ - Checkpoint face section: a compile-time closed-set guard forces a deliberate carry-or-drop
99
+ ruling when a new section key is added (the hands-less seed-carry whitelist can no longer
100
+ silently lag the persisted shape).
101
+
3
102
  ## 5.30.0 — 2026-08-13
4
103
 
5
104
  No API-BREAKING changes (exports grow only: `resolveReadFace`, `ReadFace`, `ReadFaceInputs`,
@@ -19,6 +118,18 @@ No API-BREAKING changes (exports grow only: `resolveReadFace`, `ReadFace`, `Read
19
118
  did not inherit it at all. Checkpoint schema bumps to v9 (`FACE_CHECKPOINT_VERSION`) to carry the
20
119
  resolved face across suspend/resume; an absent v9 section on an older checkpoint row is the default
21
120
  posture (roots) — no existing row's behavior changes.
121
+ - **Checkpoint schema v9** (erratum 2026-08-13 — this deserved its own entry, not a clause above; a
122
+ store schema bump is a load-bearing event for store implementors regardless of which feature rides
123
+ it). What v9 is: rows minted under a non-default read-face posture carry a `readFace` section —
124
+ `{ face: "open" | "roots", denyEntries?: Array<{pattern, caseSensitive}>, realApproval?: true }` —
125
+ and the resume pre-CAS ladder validates it (a malformed section refuses the row; a v9+ row claiming
126
+ `realApproval: true` with no well-formed gate bit refuses as `real_approval_damaged`). Absence
127
+ semantics: a missing section (all pre-v9 rows, and v9 rows minted under the default posture) means
128
+ ROOTS — zero migration, no existing row's behavior changes. Store implementors (SQL backends
129
+ included): the section is part of the opaque checkpoint state blob, so no schema/DDL change is
130
+ required — but a store that inspects or rewrites state must treat the section as tamper-guarded
131
+ (the pre-CAS ladder refuses a row whose section it cannot validate), and `MAX_SUPPORTED` acceptance
132
+ now includes v9.
22
133
  - **A built-in sensitive-path READ deny set**, exported as `READ_FACE_DEFAULT_DENY_ENTRIES` (SSH
23
134
  keys, cloud/VCS credential files, browser profile directories, crypto wallet files, shell history,
24
135
  and similar). Enforced under BOTH containment modes — an `"open"` face does not exempt it — and not
@@ -30,6 +30,8 @@ import type { NestedUsageAccum, RunnerDeps, TaskEvent, TaskLimits, TaskResult, T
30
30
  import type { RepairBundle } from "../../agents/repair-loop.js";
31
31
  /** Test seam (mirrors `__resetBashTimeoutAnnouncements`): never called by production code. */
32
32
  export declare function __resetMaterializeEnvAnnouncements(): void;
33
+ /** Test seam (mirrors `__resetMalformedNoticeSeatAnnouncement`): never called by production code. */
34
+ export declare function __resetReadFaceClampAnnouncement(): void;
33
35
  /**
34
36
  * design/164 — validate `TaskSpec.limits` at the door and return it unchanged.
35
37
  *
@@ -74,7 +74,7 @@ import { hasBackgroundShell, sweepBackgroundShells } from "../background-shell.j
74
74
  import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../task-registry.js";
75
75
  import { createMonitorTool } from "../../tools/monitor.js";
76
76
  import { createWorktreeTools } from "../../tools/worktree.js";
77
- import { applyCompactionToReadFileState, bashReversibilityProbe, compileReadDeny, createHandsToolkit, isReadDedupStubResult, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, FULL_SHELL_CONTRACT_ID, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
77
+ import { applyCompactionToReadFileState, assertReadFaceValue, bashReversibilityProbe, compileReadDeny, createHandsToolkit, deploymentReadFaceClampNotice, isReadDedupStubResult, resolveReadFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, FULL_SHELL_CONTRACT_ID, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
78
78
  import { decodeTextBytes } from "../../tools/fs/encoding.js";
79
79
  import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTool, classifyQuestionOutcome, isLiveQuestionFace, validateAskQuestions, } from "../ask-question.js";
80
80
  import { createSchedulerTools } from "../../tools/scheduler-tools.js";
@@ -97,6 +97,11 @@ export function __resetMaterializeEnvAnnouncements() {
97
97
  function emitMaterializeEnvNotice(onNotice, message, detail) {
98
98
  deliverEngineNotice(onNotice, { code: "config.materialize_env_discarded", message, detail });
99
99
  }
100
+ const readFaceClampAnnouncedSinks = new WeakSet();
101
+ let readFaceClampConsoleAnnounced = false;
102
+ export function __resetReadFaceClampAnnouncement() {
103
+ readFaceClampConsoleAnnounced = false;
104
+ }
100
105
  const RECONCILE_MAX_RETRIES = 3;
101
106
  const DEFAULT_MAX_SUSPENDS = 5;
102
107
  const TASK_LIMIT_KEY_DICT = {
@@ -342,6 +347,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
342
347
  throw e;
343
348
  }
344
349
  }
350
+ assertReadFaceValue(spec.readFace, "TaskSpec.readFace");
351
+ assertReadFaceValue(deps.readFace, "readFace (deployment seat)");
345
352
  if (spec.resumeAtMode !== undefined) {
346
353
  if (spec.resumeAt === undefined) {
347
354
  const e = new Error(`resumeAtMode "${spec.resumeAtMode}" requires resumeAt (there is no branch target to position against)`);
@@ -1388,6 +1395,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1388
1395
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1389
1396
  parentReadFace: () => resolvedReadFace,
1390
1397
  parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1398
+ onNotice: deps.onNotice,
1391
1399
  parentCheckpointStoreDisabled: spec.checkpointStore === null,
1392
1400
  parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
1393
1401
  parentCenterSourceRevision: () => centerAdoption?.sourceRevision,
@@ -1652,17 +1660,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1652
1660
  }
1653
1661
  const readFileState = new Map((resume?.seed.readFileState ?? []).map(([k, v]) => [rebaseRestoredPath(k), v]));
1654
1662
  readFileStateForCheckpoint = readFileState;
1655
- if (resume === undefined && spec.sessionId !== undefined) {
1656
- const prior = await session.buildContext().catch(() => undefined);
1657
- for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1658
- const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical]);
1659
- if (rk.ok)
1660
- seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1661
- }
1662
- }
1663
1663
  seedContextFiles = async (files) => {
1664
1664
  for (const f of files) {
1665
- const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical]);
1665
+ const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace);
1666
1666
  if (rk.ok)
1667
1667
  seedReadFileStateFromContext(readFileState, rk.key, f.content);
1668
1668
  }
@@ -1697,10 +1697,32 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1697
1697
  readOnlyMount: handsReadOnly,
1698
1698
  orgGoverned: deps.permissionRuleOrg !== undefined,
1699
1699
  fullShellReachable,
1700
+ onDeploymentClamp: () => {
1701
+ const sink = deps.onNotice;
1702
+ if (typeof sink === "function") {
1703
+ if (readFaceClampAnnouncedSinks.has(sink))
1704
+ return;
1705
+ readFaceClampAnnouncedSinks.add(sink);
1706
+ }
1707
+ else {
1708
+ if (readFaceClampConsoleAnnounced)
1709
+ return;
1710
+ readFaceClampConsoleAnnounced = true;
1711
+ }
1712
+ deliverEngineNotice(sink, deploymentReadFaceClampNotice());
1713
+ },
1700
1714
  });
1701
1715
  if (resume !== undefined && (seedReadFaceSection === undefined || seedReadFaceSection.face === "roots"))
1702
1716
  liveReadFace = "roots";
1703
1717
  resolvedReadFace = liveReadFace;
1718
+ if (resume === undefined && spec.sessionId !== undefined) {
1719
+ const prior = await session.buildContext().catch(() => undefined);
1720
+ for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1721
+ const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, liveReadFace);
1722
+ if (rk.ok)
1723
+ seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1724
+ }
1725
+ }
1704
1726
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1705
1727
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1706
1728
  ...(additionalReadRootsCanonical.length > 0 ? { additionalReadRoots: additionalReadRootsCanonical } : {}),
@@ -19,4 +19,7 @@ import { type ContractAssertionRunner } from "./contract-harness.js";
19
19
  * host-side read face, and an optional parameter one store honors while another drops it silently is
20
20
  * exactly the divergence the two legs above exist to prevent.
21
21
  */
22
- export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner): Promise<void>;
22
+ export declare function toolResultStoreContract(make: () => ToolResultStore, runAssertion?: ContractAssertionRunner, onOptionalMember?: (report: {
23
+ member: "deleteBySession";
24
+ status: "verified" | "absent";
25
+ }) => void): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { strict as assert } from "node:assert";
2
2
  import { beginContract } from "./contract-harness.js";
3
- export async function toolResultStoreContract(make, runAssertion) {
3
+ export async function toolResultStoreContract(make, runAssertion, onOptionalMember) {
4
4
  const { run, settle } = beginContract(runAssertion);
5
5
  run("put/get full + sliced; write-once no-op; unknown → undefined", async () => {
6
6
  const store = make();
@@ -57,10 +57,11 @@ export async function toolResultStoreContract(make, runAssertion) {
57
57
  });
58
58
  run("#196 deleteBySession (optional): four-state semantics when present; absence is REPORTED, not silently green", async () => {
59
59
  const probe = make();
60
- if (typeof probe.deleteBySession !== "function") {
61
- assert.equal(probe.deleteBySession, undefined, "deleteBySession is NOT IMPLEMENTED on this backend — sweep semantics unverified (optional member; this is a disclosure, not a failure)");
60
+ if (probe.deleteBySession === undefined) {
61
+ onOptionalMember?.({ member: "deleteBySession", status: "absent" });
62
62
  return;
63
63
  }
64
+ assert.equal(typeof probe.deleteBySession, "function", `deleteBySession is present but not callable (got ${typeof probe.deleteBySession}) — a garbage member is a DEFECT, not an optional-member absence`);
64
65
  const store = make();
65
66
  const del = (sessionId) => Promise.resolve(store.deleteBySession(sessionId));
66
67
  await store.put("tr_sweep~a", "A-bytes", { sessionId: "sess-X", taskId: "t1" });
@@ -77,6 +78,7 @@ export async function toolResultStoreContract(make, runAssertion) {
77
78
  const second = await del("sess-X");
78
79
  assert.equal(second.deleted, 0, "a repeated sweep deletes nothing");
79
80
  assert.equal((await store.get("tr_sweep~other")).content, "OTHER-bytes");
81
+ onOptionalMember?.({ member: "deleteBySession", status: "verified" });
80
82
  });
81
83
  await settle();
82
84
  }
@@ -103,10 +103,12 @@ export interface ToolResultStore {
103
103
  * - **concurrency-tolerant**: an entry that disappears between enumeration and removal is honest
104
104
  * absence, not an error.
105
105
  *
106
- * Typed OPTIONAL, and deliberately NOT part of the published contract kit
107
- * (`toolResultStoreContract`): unlike `ownerOf`, a backend that cannot enumerate by owner is still
108
- * a usable offload store, so its absence must stay a checkable fact rather than a contract breach.
109
- * Present the store can complete a session deletion; absent the deployment owns that gap.
106
+ * Typed OPTIONAL, and an OPTIONAL leg of the published contract kit (`toolResultStoreContract`,
107
+ * its #196 case): unlike `ownerOf`, a backend that cannot enumerate by owner is still a usable
108
+ * offload store, so absence is never a contract breach the kit verifies the four-state
109
+ * semantics when the member is present and reports absence through its `onOptionalMember`
110
+ * callback (a checkable fact, not a failure). Present ⇒ the store can complete a session
111
+ * deletion; absent ⇒ the deployment owns that gap.
110
112
  *
111
113
  * Implementing it does NOT make a store `retention: "managed"` — that declaration promises the whole
112
114
  * {@link import("./retention.js").ManagedRetentionCapability} (domain enumeration, tombstones,
@@ -4223,7 +4223,23 @@ export interface EngineNotice {
4223
4223
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
4224
4224
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
4225
4225
  * the notice claims a failed write, not an empty ref; `detail: { ref, sessionId, cause }`.
4226
- * Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact. */
4226
+ * Per-occurrence, not per-process-deduplicated: each failed write is a distinct fact.
4227
+ * - `"workflow.governance_key_stripped"` (#235) — the fields of an LLM-authored workflow `agent(spec)`
4228
+ * that did NOT cross the governed default-deny whitelist: an unrecognized/control-plane field, or a
4229
+ * `readFace` value that is not the containment-tightening one. The spawn PROCEEDS on the deployment
4230
+ * baseline (the strip is not a refusal in this window), which is why the drop is announced;
4231
+ * `detail: { total, stripped: [{ key, reason }], omitted? }`, the rendered key list bounded in count
4232
+ * and length because the names come from the untrusted script. One aggregated notice per governed
4233
+ * child build, not de-duplicated across builds: each spec is a distinct fact.
4234
+ * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
4235
+ * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
4236
+ * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
4237
+ * once per `onNotice` SINK at the prepare station (the verifier lane would otherwise repeat it
4238
+ * every spawn; the sink — not the deps object — is the unit because the Runner rebuilds its
4239
+ * deps and `runTask` builds a Runner per call), unwired console arm once per process;
4240
+ * a library-direct `createHandsToolkit` mount announces at toolkit creation (one per mount,
4241
+ * through the band-local `HandsToolkitOptions.onNotice` seat, absent ⇒ `console.warn`);
4242
+ * `detail: { seat, declared, inForce, cause }`. */
4227
4243
  code: string;
4228
4244
  /** The exact human-readable line the unwired build prints via `console.warn` — same words, one text. */
4229
4245
  message: string;
package/dist/index.d.ts CHANGED
@@ -88,7 +88,7 @@ export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW,
88
88
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, type BashReadonlyRootBoundary, type CompoundReadonlyVerdict, } from "./tools/fs/index.js";
89
89
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
90
90
  export { READ_FACE_DEFAULT_DENY_ENTRIES, compileReadDeny, type ReadDenyEntry, type ReadDenyMatcher, type NormalizedReadDenyEntry, } from "./tools/fs/index.js";
91
- export { resolveReadFace, type ReadFace, type ReadFaceInputs } from "./tools/fs/index.js";
91
+ export { deploymentReadFaceClampNotice, resolveReadFace, type ReadFace, type ReadFaceInputs } from "./tools/fs/index.js";
92
92
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, type ToolResultProvenance, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, type ToolResultStore, type ToolResultSlice, type ToolResultDeletionReport, } from "./core/tool-result-store.js";
93
93
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
94
94
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, type UsageWindow, type UsageWindowStore, type UsageWindowReading, type UsageWindowRecord, type UsageSlot, type UsageBucketRow, } from "./core/usage-window-store.js";
package/dist/index.js CHANGED
@@ -69,7 +69,7 @@ export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW,
69
69
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, } from "./tools/fs/index.js";
70
70
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
71
71
  export { READ_FACE_DEFAULT_DENY_ENTRIES, compileReadDeny, } from "./tools/fs/index.js";
72
- export { resolveReadFace } from "./tools/fs/index.js";
72
+ export { deploymentReadFaceClampNotice, resolveReadFace } from "./tools/fs/index.js";
73
73
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, toolResultContentSegment, MAX_MINTED_TOOL_RESULT_REF_CHARS, assertToolResultProvenanceMatch, normalizeToolResultProvenance, toolResultProvenanceOf, ToolResultRefConflictError, TOOL_RESULT_REF_CONFLICT_CODE, } from "./core/tool-result-store.js";
74
74
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
75
75
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, } from "./core/usage-window-store.js";
@@ -267,6 +267,11 @@ export interface RunWorkflowToolDeps {
267
267
  * ctx is minimal `{toolCallId, signal}`, so `ctx.checkpointStoreDisabledForChildren` was a DEAD
268
268
  * read there — the off-switch never actually reached this lane's children. */
269
269
  parentCheckpointStoreDisabled?: boolean;
270
+ /** #235 — the deployment's structured notice sink (`RunnerDeps.onNotice`), forwarded into the governed
271
+ * build so the fields a script's agent spec wrote that did NOT reach the child are announced instead of
272
+ * vanishing (`workflow.governance_key_stripped`). A dep for the same reason as the seats above: this
273
+ * auto-mount's execute ctx is minimal. Absent ⇒ the historic `console.warn` loudness. */
274
+ onNotice?: (n: import("../core/types.js").EngineNotice) => void;
270
275
  /** Twin of the above for design/148 S1's center-artifact inheritance (see the `ctx.centerArtifactDigest`/
271
276
  * `ctx.centerSourceRevision` reads at the `startWorkflow` options site) — `centerAdoption` resolves
272
277
  * LATE in prepare-task.ts (well after this tool's mount point), so this is a call-time getter, not
@@ -167,7 +167,7 @@ export async function createRunWorkflowTool(d) {
167
167
  : {}),
168
168
  }
169
169
  : d.governanceBaseline;
170
- const governance = { baseline: baselineWithParentFace, models: d.models, caps: childCaps };
170
+ const governance = { baseline: baselineWithParentFace, models: d.models, caps: childCaps, onNotice: d.onNotice };
171
171
  const builtinsEnabled = d.builtinWorkflows !== false;
172
172
  const namedWorkflowSection = renderNamedWorkflowListing(await collectNamedWorkflowListings(d.scriptStore, builtinsEnabled));
173
173
  const sizeGuidelineSection = workflowSizeGuidelineSection(d.sizeGuideline ?? lim.sizeGuideline);
@@ -13,7 +13,8 @@
13
13
  * cost/token caps — codex Q4).
14
14
  */
15
15
  import type { Model } from "../internal/llm.js";
16
- import type { ImageInput, TaskSpec, ThinkingLevel, WorkflowGovernanceBaseline } from "../core/types.js";
16
+ import type { EngineNotice, ImageInput, TaskSpec, ThinkingLevel, WorkflowGovernanceBaseline } from "../core/types.js";
17
+ import { type ReadDenyEntry } from "../tools/fs/read-deny.js";
17
18
  /** Thrown when an LLM-authored script picks a `modelName` not in the workflow model allowlist (or no
18
19
  * allowlist is configured). FAIL-CLOSED: a script can only ever name a model the deployment pre-approved. */
19
20
  export declare class WorkflowModelNotAllowedError extends Error {
@@ -41,12 +42,56 @@ export interface WorkflowAgentSpec {
41
42
  maxTokens?: number;
42
43
  maxCostUsd?: number;
43
44
  };
45
+ /**
46
+ * design/199 件A, TIGHTEN-ONLY: `"roots"` — the read-face containment judgment — is the ONLY value a
47
+ * script may set. `"open"` is the WIDENING direction (it removes the containment step), so it is
48
+ * stripped and announced rather than applied; the type states the asymmetry, and
49
+ * {@link pickWhitelist} enforces it on the untrusted value.
50
+ */
51
+ readFace?: "roots";
52
+ /**
53
+ * design/199 件B, TIGHTEN-ONLY: additional read-deny entries. Add-only at every layer (the built-in
54
+ * table and the baseline's entries are always in force and cannot be removed or replaced — see
55
+ * `compileReadDeny`'s zero-shrink contract and `tightenTaskSpec`'s union), so anything a script
56
+ * writes here can only ever narrow what the child may read.
57
+ */
58
+ readDenyPatterns?: readonly ReadDenyEntry[];
44
59
  }
45
60
  /**
46
61
  * The SINGLE source of truth for the whitelist (a test pins that it contains no control-plane key). `objective`
47
62
  * + `modelName` are handled explicitly in {@link buildGovernedChildSpec}; the rest map 1:1 onto `TaskSpec`.
63
+ *
64
+ * WHAT HAPPENS TO EVERY OTHER FIELD — {@link pickWhitelist} READS these keys and only these keys; it never
65
+ * enumerates the script's fields to judge them, which is precisely the complete-by-construction property this
66
+ * module is built on (nothing can leak by being forgotten in a denylist). The consequence is that an
67
+ * unrecognized field is STRIPPED, not refused: the spawn proceeds on the baseline as if the field had never
68
+ * been written. That silence is what {@link STRIPPED_KEYS_NOTICE_CODE} exists to close — one aggregated
69
+ * notice per governed build names the fields that did not cross the seam, so a mistyped budget axis (a
70
+ * top-level `maxCostUsd` that belongs inside `limits` since design/164, which would run the child unbounded
71
+ * on exactly the axis the author tried to bound) or a rejected containment request is visible to the operator
72
+ * instead of evaporating. Turning the strip into a LOUD REFUSAL that fails the spawn is a deliberate
73
+ * NON-GOAL of this window: it would break every existing script carrying a harmless extra field, and is held
74
+ * for a BREAKING window.
75
+ */
76
+ export declare const WHITELIST_KEYS: readonly ["objective", "modelName", "thinking", "systemPrompt", "images", "limits", "readFace", "readDenyPatterns"];
77
+ /**
78
+ * One field of an untrusted agent spec that did NOT reach the child, and why.
79
+ * - `not_whitelisted` — the field is outside {@link WHITELIST_KEYS}: a control-plane field, a work field
80
+ * spelled at the wrong nesting level, or plain noise. It was never read.
81
+ * - `not_a_tightening_value` — the KEY is whitelisted but only its containment-TIGHTENING value may cross
82
+ * the seam, and the script wrote a different one. Today that is `readFace`, where `"roots"` tightens and
83
+ * anything else (`"open"`, or garbage) would either widen the child's read face or ask for a value the
84
+ * resolver does not define.
48
85
  */
49
- export declare const WHITELIST_KEYS: readonly ["objective", "modelName", "thinking", "systemPrompt", "images", "limits"];
86
+ export interface StrippedSpecKeyNote {
87
+ key: string;
88
+ reason: "not_whitelisted" | "not_a_tightening_value";
89
+ }
90
+ /** The {@link EngineNotice} family for the strip announcement (see {@link WHITELIST_KEYS}). ONE notice per
91
+ * governed build, listing every field that did not cross the seam — a governed script can carry an
92
+ * arbitrary number of unrecognized fields, and a per-field notice would turn one authoring mistake into a
93
+ * flood. Not de-duplicated across builds: each spawn is a distinct fact about a distinct spec. */
94
+ export declare const STRIPPED_KEYS_NOTICE_CODE = "workflow.governance_key_stripped";
50
95
  /** Per-child workflow ceilings the engine forces onto every spawned agent (design/98 §D.6), independent of
51
96
  * what the script asks for. The child's effective limits = min(script, baseline, these). */
52
97
  export interface WorkflowChildCaps {
@@ -91,5 +136,10 @@ export declare function resolveModelName(name: string, allowlist: string[] | und
91
136
  * disclosure hook the caller can wire to a log/event channel (see `buildWorkflowPrimitives`, which logs it
92
137
  * onto the run's log stream so the LLM-authored script's caller can see requested→applied per field instead
93
138
  * of the child silently running under different limits than the script wrote).
139
+ *
140
+ * `onNotice` is the deployment's structured notice sink (`RunnerDeps.onNotice`): step 1's strip is announced
141
+ * through it as one aggregated {@link STRIPPED_KEYS_NOTICE_CODE} notice (see {@link WHITELIST_KEYS}). An
142
+ * absent sink keeps the historic loudness (`console.warn`); the delivery itself is swallow-guarded, so no
143
+ * sink can turn an announcement into a failed spawn.
94
144
  */
95
- export declare function buildGovernedChildSpec(scriptSpec: unknown, baseline: WorkflowGovernanceBaseline, models: Record<string, Model> | undefined, caps?: WorkflowChildCaps, onResourceClamp?: (notes: ResourceClampNote[]) => void): TaskSpec;
145
+ export declare function buildGovernedChildSpec(scriptSpec: unknown, baseline: WorkflowGovernanceBaseline, models: Record<string, Model> | undefined, caps?: WorkflowChildCaps, onResourceClamp?: (notes: ResourceClampNote[]) => void, onNotice?: (n: EngineNotice) => void): TaskSpec;