@sema-agent/core 5.30.0 → 5.31.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,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.31.0 — 2026-08-14
4
+
5
+ No API-BREAKING changes. Behavior narrowings are all tighten-direction (auto-allow → ask, or
6
+ map → refuse) and named below; two additive surfaces (an optional contract-kit callback, a
7
+ workflow-governance notice family).
8
+
9
+ ### Narrowed (behavior)
10
+
11
+ - **Recursive/expanding read forms demote from auto-allow to ask when the sensitive-path deny set
12
+ is in force.** The bash read-only classifier's traversal set is not in the command text —
13
+ `grep -r . dir` names `dir`, not the `.ssh` it reaches — so under a wired deny judge, a
14
+ recursive verb (`grep -r/-R`, `ls -R`, `du`, `find`, `rg`/`ag`/`ack`/`tree` — recursive by
15
+ default, `tar` creation modes, `diff -r`) with a path operand is UNDECIDED and asks. Per-verb
16
+ getopt-aware form table (BSD/GNU divergences resolve demote-ward; divergent option-letter
17
+ ownership is scanned, not skipped); a bare `-` operand keeps stdin meaning only where the
18
+ command grants it (ls/du open a file literally named `-`); a recursive verb with NO operand
19
+ demotes too — the implicit cwd is the traversal root (`du`, `ls -R`, `tree` traverse `.` when
20
+ given nothing). A persisted allow rule clears the ask permanently (the rule lane absorbs the
21
+ one-time friction).
22
+ - **The classifier's operand walk honors POSIX end-of-options on EVERY face** (the same window's
23
+ `--` correction, disclosed as its own item because it flips behavior in BOTH directions on the
24
+ `bash_readonly` face too, which has no ask channel): past a bare `--`, a dash-leading token is
25
+ an OPERAND. Spellings like `cat -- -f/etc/passwd` used to be refused on a path extracted from
26
+ inside the token as if it were an option (a false refusal — the real program opens a relative
27
+ file literally named `-f/etc/passwd`); they now execute. Conversely a dash-named operand
28
+ (`cat -- -dir a.txt`) now enters symlink/boundary checking it previously skipped, so a
29
+ dash-named symlink pointing out of the roots is now refused. No new admission lands outside
30
+ the containment roots.
31
+ - **Win32 extended-length (`\\?\`) spellings map to DOS form only when the strip is an identity.**
32
+ Non-mappable bodies (`Volume{GUID}`, `GLOBALROOT`, device names, drive-relative `\\?\C:`,
33
+ forward-slash separators, empty/`.`/`..` components, trailing dots/spaces, reserved DOS device
34
+ components — `CONIN$`, superscript `COM¹` aliases included) now REFUSE by name instead of
35
+ silently resolving to a different file than the spelling denotes (approval-to-execution drift).
36
+ `WIN_RESERVED_RE` widens to the full documented reserved set (COM0/LPT0, superscript aliases,
37
+ `CONIN$`/`CONOUT$`) for every win-form key. UNC tail segments fold Win32 trailing dots/spaces so
38
+ a deny pattern matches the alias spelling.
39
+ - **The transcript-replay and startup-seed legs judge with the resolved read face** (open-face
40
+ twins of 5.30.0's attachment fix): a file legitimately read out-of-root under `readFace:"open"`
41
+ re-seeds across turns instead of being silently re-locked. A garbage `readFace` value now
42
+ refuses loudly on hands-less runs too (the value screen is unconditional).
43
+ - **Workflow governance: a governed script may declare the two read-face TIGHTENINGS**
44
+ (`readFace:"roots"` — admitted by VALUE, `"open"` never crosses — and `readDenyPatterns`,
45
+ grammar-checked by the child's own compiler). Every stripped unknown key is announced via the
46
+ new `workflow.governance_key_stripped` notice (one aggregated notice per governed build); the
47
+ WHITELIST_KEYS comment no longer claims a refusal rule that never existed.
48
+
49
+ ### Added
50
+
51
+ - **`toolResultStoreContract` gains an optional third parameter `onOptionalMember`** — the
52
+ observable channel for optional-member coverage (`deleteBySession`: `verified` | `absent`).
53
+ Previously absence was "reported" via an always-true assert message that only prints on failure,
54
+ so verified and absent produced byte-identical green runs. A present-but-uncallable member now
55
+ fails loudly as a defect. Additive: omitting the callback keeps prior behavior.
56
+ - **`PgToolResultStore.deleteBySession`** — the pg sibling of the file backend's session sweep
57
+ (same four-state semantics; one CTE statement so the deletion and the unattributable count come
58
+ from the same snapshot). The observable-absence channel above is what exposed it as missing.
59
+ - **Deny-refusal disclosure cites the view the pattern actually matched** (`ReadDenyHit.matchedView`):
60
+ under a symlink/case alias the canonical target and the requested spelling differ, and naming
61
+ the unmatched one sent the reader chasing a path the pattern does not match.
62
+ - Search results keep the honesty caveat and the deny-withholding note as SEPARATE disclosures
63
+ (a tail-anchored bracket merge used to splice read-loop facts into the deny note and drop the
64
+ "results may be incomplete" marker whenever pruning fired).
65
+ - Checkpoint face section: a compile-time closed-set guard forces a deliberate carry-or-drop
66
+ ruling when a new section key is added (the hands-less seed-carry whitelist can no longer
67
+ silently lag the persisted shape).
68
+
3
69
  ## 5.30.0 — 2026-08-13
4
70
 
5
71
  No API-BREAKING changes (exports grow only: `resolveReadFace`, `ReadFace`, `ReadFaceInputs`,
@@ -19,6 +85,18 @@ No API-BREAKING changes (exports grow only: `resolveReadFace`, `ReadFace`, `Read
19
85
  did not inherit it at all. Checkpoint schema bumps to v9 (`FACE_CHECKPOINT_VERSION`) to carry the
20
86
  resolved face across suspend/resume; an absent v9 section on an older checkpoint row is the default
21
87
  posture (roots) — no existing row's behavior changes.
88
+ - **Checkpoint schema v9** (erratum 2026-08-13 — this deserved its own entry, not a clause above; a
89
+ store schema bump is a load-bearing event for store implementors regardless of which feature rides
90
+ it). What v9 is: rows minted under a non-default read-face posture carry a `readFace` section —
91
+ `{ face: "open" | "roots", denyEntries?: Array<{pattern, caseSensitive}>, realApproval?: true }` —
92
+ and the resume pre-CAS ladder validates it (a malformed section refuses the row; a v9+ row claiming
93
+ `realApproval: true` with no well-formed gate bit refuses as `real_approval_damaged`). Absence
94
+ semantics: a missing section (all pre-v9 rows, and v9 rows minted under the default posture) means
95
+ ROOTS — zero migration, no existing row's behavior changes. Store implementors (SQL backends
96
+ included): the section is part of the opaque checkpoint state blob, so no schema/DDL change is
97
+ required — but a store that inspects or rewrites state must treat the section as tamper-guarded
98
+ (the pre-CAS ladder refuses a row whose section it cannot validate), and `MAX_SUPPORTED` acceptance
99
+ now includes v9.
22
100
  - **A built-in sensitive-path READ deny set**, exported as `READ_FACE_DEFAULT_DENY_ENTRIES` (SSH
23
101
  keys, cloud/VCS credential files, browser profile directories, crypto wallet files, shell history,
24
102
  and similar). Enforced under BOTH containment modes — an `"open"` face does not exempt it — and not
@@ -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, 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";
@@ -342,6 +342,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
342
342
  throw e;
343
343
  }
344
344
  }
345
+ assertReadFaceValue(spec.readFace, "TaskSpec.readFace");
346
+ assertReadFaceValue(deps.readFace, "readFace (deployment seat)");
345
347
  if (spec.resumeAtMode !== undefined) {
346
348
  if (spec.resumeAt === undefined) {
347
349
  const e = new Error(`resumeAtMode "${spec.resumeAtMode}" requires resumeAt (there is no branch target to position against)`);
@@ -1388,6 +1390,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1388
1390
  parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
1389
1391
  parentReadFace: () => resolvedReadFace,
1390
1392
  parentReadDenyPatterns: () => (readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized : undefined),
1393
+ onNotice: deps.onNotice,
1391
1394
  parentCheckpointStoreDisabled: spec.checkpointStore === null,
1392
1395
  parentCenterArtifactDigest: () => centerAdoption?.artifact.artifactDigest,
1393
1396
  parentCenterSourceRevision: () => centerAdoption?.sourceRevision,
@@ -1652,17 +1655,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1652
1655
  }
1653
1656
  const readFileState = new Map((resume?.seed.readFileState ?? []).map(([k, v]) => [rebaseRestoredPath(k), v]));
1654
1657
  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
1658
  seedContextFiles = async (files) => {
1664
1659
  for (const f of files) {
1665
- const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical]);
1660
+ const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace);
1666
1661
  if (rk.ok)
1667
1662
  seedReadFileStateFromContext(readFileState, rk.key, f.content);
1668
1663
  }
@@ -1701,6 +1696,14 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1701
1696
  if (resume !== undefined && (seedReadFaceSection === undefined || seedReadFaceSection.face === "roots"))
1702
1697
  liveReadFace = "roots";
1703
1698
  resolvedReadFace = liveReadFace;
1699
+ if (resume === undefined && spec.sessionId !== undefined) {
1700
+ const prior = await session.buildContext().catch(() => undefined);
1701
+ for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) {
1702
+ const rk = await resolveKey(executionEnv, rootCanonical, rec.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, liveReadFace);
1703
+ if (rk.ok)
1704
+ seedReadFileStateFromTranscript(readFileState, rk.key, rec.content, rec.at);
1705
+ }
1706
+ }
1704
1707
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1705
1708
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1706
1709
  ...(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,14 @@ 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. */
4227
4234
  code: string;
4228
4235
  /** The exact human-readable line the unwired build prints via `console.warn` — same words, one text. */
4229
4236
  message: string;
@@ -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;
@@ -1,5 +1,7 @@
1
+ import { deliverEngineNotice } from "../core/types.js";
1
2
  import { tightenTaskSpec } from "../core/tighten-task-spec.js";
2
3
  import { sanitizeUntrustedText } from "../core/untrusted-text.js";
4
+ import { compileReadDeny } from "../tools/fs/read-deny.js";
3
5
  import { WorkflowScriptError } from "./workflow-meta.js";
4
6
  export class WorkflowModelNotAllowedError extends Error {
5
7
  modelName;
@@ -17,7 +19,43 @@ export const WHITELIST_KEYS = [
17
19
  "systemPrompt",
18
20
  "images",
19
21
  "limits",
22
+ "readFace",
23
+ "readDenyPatterns",
20
24
  ];
25
+ const WHITELIST_KEY_SET = new Set(WHITELIST_KEYS);
26
+ export const STRIPPED_KEYS_NOTICE_CODE = "workflow.governance_key_stripped";
27
+ const MAX_STRIPPED_KEYS_ANNOUNCED = 20;
28
+ const MAX_STRIPPED_KEY_CHARS = 64;
29
+ const UNSAFE_KEY_CODE_POINT = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
30
+ function renderStrippedKey(key) {
31
+ let out = "";
32
+ let seen = 0;
33
+ for (const point of key) {
34
+ if (seen === MAX_STRIPPED_KEY_CHARS)
35
+ return `${out}…`;
36
+ out += UNSAFE_KEY_CODE_POINT.test(point) ? "?" : point;
37
+ seen++;
38
+ }
39
+ return out;
40
+ }
41
+ function emitStrippedKeysNotice(survey, onNotice) {
42
+ const shown = survey.sample.map((s) => ({ key: renderStrippedKey(s.key), reason: s.reason }));
43
+ const omitted = survey.total - shown.length;
44
+ const list = shown.map((s) => JSON.stringify(s.key)).join(", ") + (omitted > 0 ? `, +${omitted} more` : "");
45
+ const message = `workflow governance: ${survey.total} field(s) of an agent spec were NOT applied to the child (${list}). ` +
46
+ `A governed workflow script may set only: ${WHITELIST_KEYS.join(", ")} — and \`readFace\` only as "roots" ` +
47
+ `(the containment-tightening direction; "open" widens and is never taken from a script). Every other field ` +
48
+ `of the child comes from the deployment baseline.`;
49
+ try {
50
+ deliverEngineNotice(onNotice, {
51
+ code: STRIPPED_KEYS_NOTICE_CODE,
52
+ message,
53
+ detail: { total: survey.total, stripped: shown, ...(omitted > 0 ? { omitted } : {}) },
54
+ });
55
+ }
56
+ catch {
57
+ }
58
+ }
21
59
  const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
22
60
  export function resolveModelName(name, allowlist, models) {
23
61
  if (!allowlist || allowlist.length === 0) {
@@ -49,49 +87,121 @@ function pickImageInput(el, i) {
49
87
  }
50
88
  const o = el;
51
89
  if ("url" in o) {
52
- if (typeof o.url !== "string" || o.url.length === 0) {
90
+ const url = o.url;
91
+ if (typeof url !== "string" || url.length === 0) {
53
92
  throw new WorkflowScriptError(`${at}.url must be a non-empty string`);
54
93
  }
55
- return { url: o.url };
94
+ return { url };
56
95
  }
57
- if (typeof o.data !== "string") {
96
+ const data = o.data;
97
+ const mimeType = o.mimeType;
98
+ if (typeof data !== "string") {
58
99
  throw new WorkflowScriptError(`${at}.data must be a base64 string (or supply { url } instead)`);
59
100
  }
60
- if (typeof o.mimeType !== "string" || o.mimeType.length === 0) {
101
+ if (typeof mimeType !== "string" || mimeType.length === 0) {
61
102
  throw new WorkflowScriptError(`${at}.mimeType must be a non-empty string, e.g. "image/png"`);
62
103
  }
63
- return { data: o.data, mimeType: o.mimeType };
104
+ return { data, mimeType };
105
+ }
106
+ const MAX_SCRIPT_DENY_ENTRIES = 16;
107
+ const MAX_SCRIPT_DENY_PATTERN_CHARS = 256;
108
+ const MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT = 1;
109
+ const MAX_SCRIPT_IMAGES = 32;
110
+ function assertScriptDenyPatternBounded(pattern, at) {
111
+ if (pattern.length > MAX_SCRIPT_DENY_PATTERN_CHARS) {
112
+ throw new WorkflowScriptError(`${at} is ${pattern.length} characters — a workflow script's deny pattern is limited to ${MAX_SCRIPT_DENY_PATTERN_CHARS} (the matcher runs on every read of the child's run).`);
113
+ }
114
+ for (const segment of pattern.split("/")) {
115
+ const wildcards = segment.split("*").length - 1;
116
+ if (wildcards > MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT) {
117
+ throw new WorkflowScriptError(`${at} has a path segment with ${wildcards} \`*\` wildcards — a workflow script's deny pattern allows at most ` +
118
+ `${MAX_SCRIPT_DENY_WILDCARDS_PER_SEGMENT} per path segment (the same expressiveness the engine's built-in deny table ` +
119
+ `uses). Each \`*\` compiles to a greedy match, and more than one in a single segment makes the judgment cost grow ` +
120
+ `superlinearly with the path length, on every read the child performs. Write the intent as separate entries ` +
121
+ `(the set is a union, so more entries deny strictly more) or anchor it with literal segments.`);
122
+ }
123
+ }
124
+ }
125
+ function mapUntrustedArray(value, at, atMost, why, build) {
126
+ const declared = value.length;
127
+ if (typeof declared !== "number" || !Number.isSafeInteger(declared) || declared < 0) {
128
+ throw new WorkflowScriptError(`${at} does not report a valid array length.`);
129
+ }
130
+ if (declared > atMost) {
131
+ throw new WorkflowScriptError(`${at} carries ${declared} entries — a workflow script may set at most ${atMost}. ${why}`);
132
+ }
133
+ const out = [];
134
+ for (let i = 0; i < declared; i++)
135
+ out.push(build(value[i], i));
136
+ return out;
137
+ }
138
+ function pickReadDenyEntry(el, i) {
139
+ const at = `agent(spec): \`readDenyPatterns[${i}]\``;
140
+ if (typeof el === "string") {
141
+ if (el.length === 0)
142
+ throw new WorkflowScriptError(`${at} must be a non-empty pattern string`);
143
+ assertScriptDenyPatternBounded(el, at);
144
+ return el;
145
+ }
146
+ if (typeof el !== "object" || el === null || Array.isArray(el)) {
147
+ throw new WorkflowScriptError(`${at} must be a pattern string or an object { pattern, caseSensitive? }`);
148
+ }
149
+ const o = el;
150
+ const pattern = o.pattern;
151
+ const caseSensitive = o.caseSensitive;
152
+ if (typeof pattern !== "string" || pattern.length === 0) {
153
+ throw new WorkflowScriptError(`${at}.pattern must be a non-empty string, e.g. ".ssh" or "secrets/*.json"`);
154
+ }
155
+ if (caseSensitive !== undefined && typeof caseSensitive !== "boolean") {
156
+ throw new WorkflowScriptError(`${at}.caseSensitive must be a boolean`);
157
+ }
158
+ assertScriptDenyPatternBounded(pattern, at);
159
+ return caseSensitive === undefined ? { pattern } : { pattern, caseSensitive };
64
160
  }
65
161
  function pickWhitelist(scriptSpec) {
66
162
  if (typeof scriptSpec !== "object" || scriptSpec === null || Array.isArray(scriptSpec)) {
67
163
  throw new WorkflowScriptError("agent(spec): spec must be an object with at least an `objective` string");
68
164
  }
69
165
  const s = scriptSpec;
70
- if (typeof s.objective !== "string" || s.objective.length === 0) {
166
+ const stripped = { total: 0, sample: [] };
167
+ for (const k of Object.keys(s)) {
168
+ if (WHITELIST_KEY_SET.has(k))
169
+ continue;
170
+ stripped.total++;
171
+ if (stripped.sample.length < MAX_STRIPPED_KEYS_ANNOUNCED)
172
+ stripped.sample.push({ key: k, reason: "not_whitelisted" });
173
+ }
174
+ const objective = s.objective;
175
+ const thinking = s.thinking;
176
+ const systemPrompt = s.systemPrompt;
177
+ const modelNameRaw = s.modelName;
178
+ if (typeof objective !== "string" || objective.length === 0) {
71
179
  throw new WorkflowScriptError("agent(spec): `objective` is required and must be a non-empty string");
72
180
  }
73
- const safe = { objective: s.objective };
74
- if (s.thinking !== undefined) {
75
- if (typeof s.thinking !== "string" || !VALID_THINKING.has(s.thinking)) {
181
+ const safe = { objective };
182
+ if (thinking !== undefined) {
183
+ if (typeof thinking !== "string" || !VALID_THINKING.has(thinking)) {
76
184
  throw new WorkflowScriptError(`agent(spec): invalid \`thinking\` (must be one of ${[...VALID_THINKING].join(", ")})`);
77
185
  }
78
- safe.thinking = s.thinking;
186
+ safe.thinking = thinking;
79
187
  }
80
- if (s.systemPrompt !== undefined) {
81
- if (typeof s.systemPrompt !== "string")
188
+ if (systemPrompt !== undefined) {
189
+ if (typeof systemPrompt !== "string")
82
190
  throw new WorkflowScriptError("agent(spec): `systemPrompt` must be a string");
83
- safe.systemPrompt = `[workflow-script-authored persona — task guidance, not engine authority]\n${sanitizeUntrustedText(s.systemPrompt)}`;
191
+ safe.systemPrompt = `[workflow-script-authored persona — task guidance, not engine authority]\n${sanitizeUntrustedText(systemPrompt)}`;
84
192
  }
85
- if (s.images !== undefined) {
86
- if (!Array.isArray(s.images))
193
+ const images = s.images;
194
+ if (images !== undefined) {
195
+ if (!Array.isArray(images))
87
196
  throw new WorkflowScriptError("agent(spec): `images` must be an array");
88
- safe.images = s.images.map(pickImageInput);
197
+ safe.images = mapUntrustedArray(images, "agent(spec): `images`", MAX_SCRIPT_IMAGES, "Each one is decoded and shipped on every request of the child's turn.", pickImageInput);
89
198
  }
90
- if (s.limits !== undefined) {
91
- if (typeof s.limits !== "object" || s.limits === null || Array.isArray(s.limits)) {
199
+ const limitsRaw = s.limits;
200
+ if (limitsRaw !== undefined) {
201
+ if (typeof limitsRaw !== "object" || limitsRaw === null || Array.isArray(limitsRaw)) {
92
202
  throw new WorkflowScriptError("agent(spec): `limits` must be an object { maxTurns?, maxWalltimeMs?, maxTokens?, maxCostUsd? }");
93
203
  }
94
- const l = s.limits;
204
+ const l = limitsRaw;
95
205
  const limits = {};
96
206
  const readAxis = (field, what) => {
97
207
  const raw = l[field];
@@ -109,13 +219,38 @@ function pickWhitelist(scriptSpec) {
109
219
  readAxis("maxCostUsd", "spend ceiling");
110
220
  safe.limits = limits;
111
221
  }
222
+ if (s.readFace !== undefined) {
223
+ if (s.readFace === "roots") {
224
+ safe.readFace = "roots";
225
+ }
226
+ else {
227
+ stripped.total++;
228
+ stripped.sample.unshift({ key: "readFace", reason: "not_a_tightening_value" });
229
+ if (stripped.sample.length > MAX_STRIPPED_KEYS_ANNOUNCED)
230
+ stripped.sample.pop();
231
+ }
232
+ }
233
+ const readDenyPatternsRaw = s.readDenyPatterns;
234
+ if (readDenyPatternsRaw !== undefined) {
235
+ if (!Array.isArray(readDenyPatternsRaw)) {
236
+ throw new WorkflowScriptError('agent(spec): `readDenyPatterns` must be an array of deny entries (a "/"-separated segment run such as ".ssh", or { pattern, caseSensitive? })');
237
+ }
238
+ const entries = mapUntrustedArray(readDenyPatternsRaw, "agent(spec): `readDenyPatterns`", MAX_SCRIPT_DENY_ENTRIES, "Every entry is judged against every path the child reads, for the child's whole run; the built-in and deployment entries are always in force on top of these.", pickReadDenyEntry);
239
+ try {
240
+ compileReadDeny(entries, "agent(spec).readDenyPatterns");
241
+ }
242
+ catch (err) {
243
+ throw new WorkflowScriptError(err instanceof Error ? err.message : String(err));
244
+ }
245
+ safe.readDenyPatterns = entries;
246
+ }
112
247
  let modelName;
113
- if (s.modelName !== undefined) {
114
- if (typeof s.modelName !== "string")
248
+ if (modelNameRaw !== undefined) {
249
+ if (typeof modelNameRaw !== "string")
115
250
  throw new WorkflowScriptError("agent(spec): `modelName` must be a string (a model NAME, never a Model object)");
116
- modelName = s.modelName;
251
+ modelName = modelNameRaw;
117
252
  }
118
- return { safe, modelName };
253
+ return { safe, modelName, stripped };
119
254
  }
120
255
  function clampResourceLimits(safe, base, caps) {
121
256
  const trustedCandidates = [
@@ -172,8 +307,10 @@ function clampResourceLimits(safe, base, caps) {
172
307
  safe.limits = rebuilt;
173
308
  return notes;
174
309
  }
175
- export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp) {
176
- const { safe, modelName } = pickWhitelist(scriptSpec);
310
+ export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp, onNotice) {
311
+ const { safe, modelName, stripped } = pickWhitelist(scriptSpec);
312
+ if (stripped.total > 0)
313
+ emitStrippedKeysNotice(stripped, onNotice);
177
314
  if (modelName !== undefined) {
178
315
  safe.model = resolveModelName(modelName, baseline.workflowModelAllowlist, models);
179
316
  }
@@ -22,6 +22,11 @@ export interface WorkflowGovernance {
22
22
  baseline: WorkflowGovernanceBaseline;
23
23
  models?: Record<string, Model>;
24
24
  caps?: WorkflowChildCaps;
25
+ /** The deployment's structured notice sink (`RunnerDeps.onNotice`), threaded here because the governed
26
+ * build is the only place that can report which spec fields did NOT reach the child (see
27
+ * `buildGovernedChildSpec`'s `onNotice` param). Only meaningful in governed mode — the trusted-dev lane
28
+ * strips nothing. Absent ⇒ the notice falls back to `console.warn`. */
29
+ onNotice?: (n: import("../core/types.js").EngineNotice) => void;
25
30
  }
26
31
  /**
27
32
  * Build the flat {@link WorkflowPrimitives} a {@link WorkflowScriptRunner} runs the script against. The
@@ -29,7 +29,7 @@ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThi
29
29
  const agentOpts = safeAgentOptions(opts);
30
30
  const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase !== undefined ? { ...b, base: { ...b.base, ...b.worktreeBase } } : b;
31
31
  const childSpec = governance
32
- ? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)))
32
+ ? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)), governance.onNotice)
33
33
  : { ...spec };
34
34
  if (childSpec.thinking === undefined && parentThinking) {
35
35
  const inherited = parentThinking();
@@ -172,8 +172,32 @@ export interface CompoundReadonlyVerdict {
172
172
  *
173
173
  * `cd` is the one exception, and it fails closed: a glob there cannot be expanded into the single
174
174
  * directory the compound face must track as the new working directory, so it demotes (see {@link reason}).
175
+ *
176
+ * ALSO carries the {@link recursiveReadPaths} entries (a subset): both families are operands whose
177
+ * real read set is not in the command text, and the consumer contract above ("non-empty ⇒ ask, or
178
+ * resolve yourself") is stated over this one field so an auto-allow gate cannot honour one family
179
+ * and miss the other.
175
180
  */
176
181
  undecidedPaths?: readonly string[];
182
+ /**
183
+ * Operands of a RECURSIVE/EXPANDING read form (`grep -r`, `ls -R`, `du`, … — see
184
+ * {@link RECURSIVE_READ_FORMS}) judged with a {@link BashReadonlyRootBoundary.denyMatch} seat wired.
185
+ * The deny judge sees only the operand's own resolved spelling, but a recursive verb reads the
186
+ * operand's whole SUBTREE — `grep -r x /home/user` touches `/home/user/.ssh/*` while the judged
187
+ * spelling `/home/user` matches no deny pattern. The traversal's reach set is therefore not covered
188
+ * by the lexical check at all, and with zero I/O "provably not a directory" does not exist — so
189
+ * every such operand is UNDECIDED (no directory guessing, no bounded pre-check: both would be a
190
+ * false "resolved, inside" of exactly the kind {@link undecidedPaths} exists to prevent).
191
+ *
192
+ * Subset of {@link undecidedPaths} (same consumer contract: ask, never auto-allow); carried
193
+ * separately so a consumer minting prose can name the recursive-reach cause rather than the glob
194
+ * one. Minted ONLY when `denyMatch` is wired: without a deny judge there is nothing the traversal
195
+ * bypasses — the containment half already judges the operand itself, and its lexical residuals are
196
+ * recorded on {@link checkedPaths}. The `bash_readonly` face never wires `denyMatch` (v1 ruling,
197
+ * see that seat's note), so this field never appears there and its expand-and-verify execution
198
+ * path is unchanged.
199
+ */
200
+ recursiveReadPaths?: readonly string[];
177
201
  }
178
202
  /**
179
203
  * RB-412 — the single minting point for the out-of-root-read approval option text, so a gate rendering
@@ -224,10 +248,14 @@ export declare function classifyCompoundReadonlyDetailed(command: string, allow:
224
248
  export declare function classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
225
249
  /**
226
250
  * design/154 — compound read-only classification, reason-only face. Returns the demotion reason, or
227
- * undefined when the command classifies read-only. RB-412 added the optional `boundary`: with it, an
228
- * allowlisted reader whose path arguments leave the allowed directories is demoted too (use
229
- * {@link classifyCompoundReadonlyDetailed} when the caller wants to know that WHY, e.g. to offer the
230
- * narrow "allow reading from <dir>" approval); without it the verdict is exactly what it always was.
251
+ * undefined when the command classifies read-only. ⚠️ `undefined` is NOT "safe to auto-execute":
252
+ * the detailed verdict may still carry `undecidedPaths` (operands whose unexpanded spelling a
253
+ * glob is what got checked), and this face discards that field. An auto-allow decision must read
254
+ * {@link classifyCompoundReadonlyDetailed} and treat a non-empty `undecidedPaths` as ask the
255
+ * engine's own probe does exactly that (fs-bash.ts). RB-412 added the optional `boundary`: with it,
256
+ * an allowlisted reader whose path arguments leave the allowed directories is demoted too (use the
257
+ * detailed face when the caller wants to know WHY, e.g. to offer the narrow "allow reading from
258
+ * <dir>" approval); without it the verdict is exactly what it always was.
231
259
  */
232
260
  export declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
233
261
  /**
@@ -284,6 +284,90 @@ function takesSeparatedValue(name, tok) {
284
284
  }
285
285
  return false;
286
286
  }
287
+ const RECURSIVE_READ_FORMS = {
288
+ grep: {
289
+ shortLetters: "rR",
290
+ valueOwners: "efmABCD",
291
+ longNames: ["recursive", "dereference-recursive"],
292
+ enumOptions: [{ shortLetter: "d", longName: "directories", recursiveValue: "recurse" }],
293
+ dashIsStdin: true,
294
+ },
295
+ ls: { shortLetters: "R", longNames: ["recursive"] },
296
+ du: { always: true },
297
+ find: { always: true },
298
+ rg: { always: true, dashIsStdin: true },
299
+ tree: { always: true },
300
+ ag: { always: true, dashIsStdin: true },
301
+ ack: { always: true, dashIsStdin: true },
302
+ tar: { shortLetters: "cru", valueOwners: "fCTXbg", longNames: ["create", "append", "update"], bundledModeLetters: "cru", dashIsStdin: true },
303
+ diff: { shortLetters: "r", valueOwners: "UCWISFXx", longNames: ["recursive"], dashIsStdin: true },
304
+ };
305
+ function segmentSelectsRecursiveRead(name, args) {
306
+ const model = RECURSIVE_READ_FORMS[name];
307
+ if (model === undefined)
308
+ return false;
309
+ if (model.always === true)
310
+ return true;
311
+ if (model.bundledModeLetters !== undefined) {
312
+ const first = args.find((t) => t.length > 0);
313
+ if (first !== undefined && !first.startsWith("-") && /^[A-Za-z]+$/.test(first) && [...first].some((ch) => model.bundledModeLetters.includes(ch))) {
314
+ return true;
315
+ }
316
+ }
317
+ let endOfOptions = false;
318
+ for (let k = 0; k < args.length; k++) {
319
+ const t = args[k];
320
+ if (endOfOptions)
321
+ continue;
322
+ if (t === "--") {
323
+ const prev = k > 0 ? args[k - 1] : undefined;
324
+ const prevMayOwnValue = prev !== undefined && prev.startsWith("--") && prev.length > 2 && !prev.includes("=");
325
+ if (!prevMayOwnValue)
326
+ endOfOptions = true;
327
+ continue;
328
+ }
329
+ if (t.startsWith("--")) {
330
+ const long = longOptionNameOf(t);
331
+ if (long === undefined)
332
+ continue;
333
+ if (model.longNames?.some((full) => isLongOptionAbbrevOf(long, full)) === true)
334
+ return true;
335
+ for (const en of model.enumOptions ?? []) {
336
+ if (en.longName === undefined || !isLongOptionAbbrevOf(long, en.longName))
337
+ continue;
338
+ const eq = t.indexOf("=");
339
+ const v = eq >= 0 ? t.slice(eq + 1) : args[k + 1];
340
+ if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
341
+ return true;
342
+ }
343
+ continue;
344
+ }
345
+ if (!t.startsWith("-") || t === "-")
346
+ continue;
347
+ for (let i = 1; i < t.length; i++) {
348
+ const ch = t[i];
349
+ if (model.shortLetters?.includes(ch) === true)
350
+ return true;
351
+ const en = (model.enumOptions ?? []).find((e) => e.shortLetter === ch);
352
+ if (en !== undefined) {
353
+ const v = i === t.length - 1 ? args[k + 1] : t.slice(i + 1);
354
+ if (v !== undefined && v.length > 0 && en.recursiveValue.startsWith(v.toLowerCase()))
355
+ return true;
356
+ if (i === t.length - 1)
357
+ k++;
358
+ break;
359
+ }
360
+ if (model.valueOwners?.includes(ch) === true) {
361
+ if (i === t.length - 1)
362
+ k++;
363
+ break;
364
+ }
365
+ if (!/[A-Za-z0-9]/.test(ch))
366
+ break;
367
+ }
368
+ }
369
+ return false;
370
+ }
287
371
  function isGrepFileStdinLongOption(tok) {
288
372
  const eq = tok.indexOf("=");
289
373
  if (eq < 0 || tok.slice(eq + 1) !== "-")
@@ -327,6 +411,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
327
411
  const findings = [];
328
412
  const candidates = [];
329
413
  const bareWordOperands = [];
414
+ const positionalOperands = [];
330
415
  const argGlobs = (k) => {
331
416
  const raw = tokens.raw[k + 1];
332
417
  return raw !== undefined && hasUnquotedGlobMetachar(raw);
@@ -356,31 +441,47 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
356
441
  candidates.push({ text: target, globbed: false });
357
442
  }
358
443
  else {
359
- const patternSuppliedByFlag = name === "grep" && args.some(isGrepPatternFlagToken);
444
+ const eoo = args.indexOf("--");
445
+ const patternSuppliedByFlag = name === "grep" && (eoo === -1 ? args : args.slice(0, eoo)).some(isGrepPatternFlagToken);
360
446
  let sawOperand = false;
447
+ let endOfOptions = false;
361
448
  for (let k = 0; k < args.length; k++) {
362
449
  const t = args[k];
363
- if (name === "cut" && (t === "-d" || t === "--delimiter" || t === "--output-delimiter")) {
364
- k++;
365
- continue;
366
- }
367
- if (name === "cut" && (/^-d./.test(t) || isCutDelimiterPayloadLongOption(t)))
368
- continue;
369
- if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
370
- continue;
371
- if (t.startsWith("-") && t !== "-") {
372
- for (const payload of attachedOptionPayloads(t)) {
373
- if (isAbsolutePathForm(payload) || isPathShapedToken(payload))
374
- candidates.push({ text: payload, globbed: argGlobs(k) });
450
+ if (!endOfOptions) {
451
+ if (t === "--") {
452
+ endOfOptions = true;
453
+ continue;
454
+ }
455
+ if (name === "cut" && (t === "-d" || t === "--delimiter" || t === "--output-delimiter")) {
456
+ k++;
457
+ continue;
458
+ }
459
+ if (name === "cut" && (/^-d./.test(t) || isCutDelimiterPayloadLongOption(t)))
460
+ continue;
461
+ if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
462
+ continue;
463
+ if (t.startsWith("-") && t !== "-") {
464
+ for (const payload of attachedOptionPayloads(t)) {
465
+ if (isAbsolutePathForm(payload) || isPathShapedToken(payload))
466
+ candidates.push({ text: payload, globbed: argGlobs(k) });
467
+ }
468
+ continue;
375
469
  }
376
- continue;
377
470
  }
378
471
  const isGrepPatternSlot = name === "grep" && !patternSuppliedByFlag && !sawOperand;
379
472
  sawOperand = true;
380
- if (t === "-")
473
+ if (t === "-") {
474
+ if (isGrepPatternSlot)
475
+ continue;
476
+ const m = RECURSIVE_READ_FORMS[name];
477
+ if (m === undefined || m.dashIsStdin === true)
478
+ continue;
479
+ positionalOperands.push(t);
381
480
  continue;
481
+ }
382
482
  if (isGrepPatternSlot)
383
483
  continue;
484
+ positionalOperands.push(t);
384
485
  if (isPathShapedToken(t))
385
486
  candidates.push({ text: t, globbed: argGlobs(k) });
386
487
  else
@@ -416,6 +517,13 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
416
517
  if (!globbed && withinAnyRoot(boundary.roots, resolved))
417
518
  findings.push({ kind: "inside", path: resolved });
418
519
  }
520
+ if (boundary.denyMatch !== undefined && segmentSelectsRecursiveRead(name, args)) {
521
+ const roots = positionalOperands.length > 0 ? positionalOperands : ["."];
522
+ for (const operand of roots) {
523
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
524
+ findings.push({ kind: "recursive", path: resolved ?? operand });
525
+ }
526
+ }
419
527
  return findings;
420
528
  }
421
529
  export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
@@ -584,6 +692,7 @@ function evaluateReadBoundary(foldedSegments, boundary) {
584
692
  const outside = [];
585
693
  const inside = [];
586
694
  const undecided = [];
695
+ const recursive = [];
587
696
  for (const toks of foldedSegments) {
588
697
  for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
589
698
  if (finding.kind === "unresolvable")
@@ -604,6 +713,11 @@ function evaluateReadBoundary(foldedSegments, boundary) {
604
713
  undecided.push(finding.path);
605
714
  continue;
606
715
  }
716
+ if (finding.kind === "recursive") {
717
+ if (!recursive.includes(finding.path))
718
+ recursive.push(finding.path);
719
+ continue;
720
+ }
607
721
  if (boundary.face === "open") {
608
722
  if (!inside.includes(finding.path))
609
723
  inside.push(finding.path);
@@ -613,7 +727,11 @@ function evaluateReadBoundary(foldedSegments, boundary) {
613
727
  outside.push(finding);
614
728
  }
615
729
  }
616
- const undecidedField = undecided.length > 0 ? { undecidedPaths: undecided } : {};
730
+ const undecidedAll = [...undecided, ...recursive.filter((p) => !undecided.includes(p))];
731
+ const undecidedField = {
732
+ ...(undecidedAll.length > 0 ? { undecidedPaths: undecidedAll } : {}),
733
+ ...(recursive.length > 0 ? { recursiveReadPaths: recursive } : {}),
734
+ };
617
735
  if (outside.length === 0)
618
736
  return inside.length > 0 ? { checkedPaths: inside, ...undecidedField } : { ...undecidedField };
619
737
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
@@ -724,6 +842,9 @@ export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
724
842
  const verdict = classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
725
843
  if (verdict.reason !== undefined)
726
844
  return verdict.reason;
845
+ if (verdict.recursiveReadPaths !== undefined) {
846
+ return `the loop body reads recursively from ${verdict.recursiveReadPaths.join(", ")} — the traversal's reach is not covered by this lexical check, so it is not auto-allowed`;
847
+ }
727
848
  if (verdict.undecidedPaths !== undefined) {
728
849
  return `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed`;
729
850
  }
@@ -20,6 +20,11 @@ export interface NormalizedReadDenyEntry {
20
20
  /** A deny verdict: which entry's pattern matched. */
21
21
  export interface ReadDenyHit {
22
22
  pattern: string;
23
+ /** The view string the pattern actually matched (set by {@link CompiledReadDeny.matchTarget}: the
24
+ * canonical key, or the lexical view when only the SPELLING matched). Disclosure must cite the
25
+ * matched view — under a symlink/case alias the two strings differ, and naming the other one
26
+ * sends the reader chasing a path the pattern does not match. */
27
+ matchedView?: string;
23
28
  }
24
29
  /** One compiled ripgrep glob flag for the traversal legs (`--iglob` = case-insensitive entry). */
25
30
  export interface ReadDenyRgGlob {
@@ -143,7 +143,15 @@ export function compileReadDeny(additions = [], layer = "additions") {
143
143
  entries: normalized,
144
144
  matchPath,
145
145
  matchTarget(canonicalKey, lexicalView) {
146
- return matchPath(canonicalKey) ?? (lexicalView !== undefined ? matchPath(lexicalView) : null);
146
+ const canonical = matchPath(canonicalKey);
147
+ if (canonical !== null)
148
+ return { ...canonical, matchedView: canonicalKey };
149
+ if (lexicalView !== undefined) {
150
+ const lexical = matchPath(lexicalView);
151
+ if (lexical !== null)
152
+ return { ...lexical, matchedView: lexicalView };
153
+ }
154
+ return null;
147
155
  },
148
156
  rgExclusionGlobs,
149
157
  rgProbeGlobs,
@@ -2,6 +2,12 @@
2
2
  * `"open"` = the containment step is skipped (canonicalization, deny set, UNC out-of-set refusal
3
3
  * and the type gates all still run). */
4
4
  export type ReadFace = "open" | "roots";
5
+ /** Loud value gate (#123): a `readFace` seat carries exactly "open" | "roots" — anything else
6
+ * (case variants, truthy garbage) refuses at wiring time, never folds to a default. Exported
7
+ * module-internally (not on the package surface) so prepare's unconditional config-guard leg can
8
+ * screen the seats on runs that never mount hands — the resolver only runs beside a mount, and a
9
+ * garbage value must not become silently legal on the hands-less path (5.30 re-review). */
10
+ export declare function assertReadFaceValue(v: unknown, seat: string): ReadFace | undefined;
5
11
  /** Inputs to {@link resolveReadFace} — all structural/declaration facts, never permission modes
6
12
  * (a deliberate axis separation: this shape carries facts, not verdicts). */
7
13
  export interface ReadFaceInputs {
@@ -1,4 +1,4 @@
1
- function assertReadFaceValue(v, seat) {
1
+ export function assertReadFaceValue(v, seat) {
2
2
  if (v === undefined)
3
3
  return undefined;
4
4
  if (v === "open" || v === "roots")
@@ -270,6 +270,7 @@ export declare function lexicalViewOf(spelled: string, base: string): string;
270
270
  export declare function resolveKey(env: ExecutionEnv, rootCanonical: string, path: string, signal?: AbortSignal, baseCwd?: string, additionalRootsCanonical?: readonly string[], exactFileReadExemption?: (canonicalKey: string) => boolean, readDeny?: {
271
271
  matchTarget(canonicalKey: string, lexicalView?: string): {
272
272
  pattern: string;
273
+ matchedView?: string;
273
274
  } | null;
274
275
  }, readFace?: "open" | "roots"): Promise<{
275
276
  ok: true;
@@ -49,7 +49,7 @@ const PROC_SENSITIVE_SUFFIXES = ["/environ", "/cmdline", "/auxv", "/maps", "/mem
49
49
  function isProcSensitiveFile(key) {
50
50
  return key.startsWith("/proc/") && PROC_SENSITIVE_SUFFIXES.some((suf) => key.endsWith(suf));
51
51
  }
52
- const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
52
+ const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[0-9¹²³]|LPT[0-9¹²³]|CONIN\$|CONOUT\$)(\.[^\\/]*)?$/i;
53
53
  export function isWinFormPath(p) {
54
54
  return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
55
55
  }
@@ -273,10 +273,21 @@ function stripWin32ExtendedPrefix(p) {
273
273
  if (!p.startsWith("\\\\?\\"))
274
274
  return p;
275
275
  const rest = p.slice(4);
276
- const unc = /^UNC[\\/]/i.exec(rest);
277
- if (unc)
278
- return "\\\\" + rest.slice(unc[0].length);
279
- return rest;
276
+ const unc = /^UNC\\/i.exec(rest);
277
+ const body = unc !== null ? rest.slice(unc[0].length) : /^[A-Za-z]:\\/.test(rest) ? rest.slice(3) : undefined;
278
+ if (body === undefined)
279
+ return p;
280
+ if (body !== "") {
281
+ for (const seg of body.split("\\")) {
282
+ if (seg === "" || seg === "." || seg === "..")
283
+ return p;
284
+ if (seg.endsWith(".") || seg.endsWith(" ") || seg.includes("/"))
285
+ return p;
286
+ if (WIN_RESERVED_RE.test(seg))
287
+ return p;
288
+ }
289
+ }
290
+ return unc ? "\\\\" + rest.slice(unc[0].length) : rest;
280
291
  }
281
292
  function foldUncLexically(p) {
282
293
  const body = p.slice(2);
@@ -295,7 +306,8 @@ function foldUncLexically(p) {
295
306
  out.pop();
296
307
  continue;
297
308
  }
298
- out.push(seg);
309
+ const stripped = seg.replace(/[. ]+$/, "");
310
+ out.push(stripped.length > 0 ? stripped : seg);
299
311
  }
300
312
  const anchor = `\\\\${host}${share !== undefined ? `\\${share}` : ""}`;
301
313
  return { key: out.length > 0 ? `${anchor}\\${out.join("\\")}` : anchor, climbedAboveShare: climbed };
@@ -329,11 +341,15 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
329
341
  if (readDeny !== undefined) {
330
342
  const hit = readDeny.matchTarget(key, lexicalViewOf(path, baseCwd ?? rootCanonical));
331
343
  if (hit !== null) {
344
+ const lexicalHit = hit.matchedView !== undefined && hit.matchedView !== key;
345
+ const judged = lexicalHit
346
+ ? `the requested path's spelling ("${hit.matchedView}") matches`
347
+ : `the target matches`;
332
348
  return {
333
349
  ok: false,
334
350
  violation: {
335
351
  code: "read_path_denied",
336
- message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: the target matches the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
352
+ message: `reading "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}is refused: ${judged} the sensitive-path read deny list (pattern "${hit.pattern}"). This list guards credential-class paths and applies regardless of the containment roots.`,
337
353
  target: key,
338
354
  pattern: hit.pattern,
339
355
  },
@@ -373,6 +389,9 @@ export async function canonicalizeTarget(env, path, signal, baseCwd) {
373
389
  return { ok: false, message: `path "${path}" is a Win32 device-namespace path (\\\\.\\ names a raw device object, not a file); refused.` };
374
390
  }
375
391
  const unprefixed = stripWin32ExtendedPrefix(path);
392
+ if (unprefixed.startsWith("\\\\?\\")) {
393
+ return { ok: false, message: `path "${path}" is a Win32 extended-length namespace path with no DOS-path equivalent (only \\\\?\\<drive>: and \\\\?\\UNC\\ forms name files); refused.` };
394
+ }
376
395
  if (isUncPath(unprefixed) && isWinFormPath(unprefixed)) {
377
396
  const folded = foldUncLexically(unprefixed);
378
397
  if (folded.climbedAboveShare) {
@@ -807,7 +807,8 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
807
807
  if (denyOut !== undefined && walked.denyPruned > 0) {
808
808
  denyOut.withheld = { kind: "pruned_count", count: walked.denyPruned, patterns: walked.denyPatterns };
809
809
  }
810
- let caveat = walkCaveat(walked, true) + denyWithheldNote(walked);
810
+ let honestyCaveat = walkCaveat(walked, true);
811
+ const denyNote = denyWithheldNote(walked);
811
812
  let files = walked.files;
812
813
  files.sort();
813
814
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
@@ -962,11 +963,12 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
962
963
  if (multilineSkippedFiles > 0)
963
964
  extra.push(`${multilineSkippedFiles} file(s) skipped by multiline matching (content over ${longLineLimit} chars; the pattern's worst-case matching cost grows superlinearly with input length on the fallback engine)`);
964
965
  if (extra.length > 0) {
965
- caveat = caveat.length > 0
966
- ? caveat.replace(/\]$/, `; ${extra.join("; ")}]`)
966
+ honestyCaveat = honestyCaveat.length > 0
967
+ ? honestyCaveat.replace(/\]$/, `; ${extra.join("; ")}]`)
967
968
  : `\n…[results may be incomplete: ${extra.join("; ")}]`;
968
969
  }
969
970
  }
971
+ const caveat = honestyCaveat + denyNote;
970
972
  const paged = (arr) => (off > 0 ? arr.slice(off, off + cap) : arr);
971
973
  const offNote = off > 0 ? `\n[offset ${off}]` : "";
972
974
  if (mode === "files_with_matches") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.30.0",
3
+ "version": "5.31.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",