@sema-agent/core 7.33.7 → 7.33.8

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,3 +1,21 @@
1
+ ## 7.33.8 — 2026-09-28
2
+
3
+ ### Fix — every child process the engine starts gets its environment through the credential scrub; a `runSpec` oracle gate's command no longer inherits the engine's environment (#1181; security; behavior narrowing; +0 runtime exports; new optional type members `OracleGateSpec.inheritEnv`, `RunSpecOptions.onNotice`, `runOracle` options `onNotice`) (@server)
4
+
5
+ - **Before.** A `runSpec` / `runOracle` gate ran its command through `execFile` with no `env`, so the child inherited the engine's whole environment — every credential in it. The command is the deployment's, but the tests it runs load code the model wrote: that code could read the credentials, write them to a file and read them back on the next turn (measured on 7.33.7: a gate printing two planted credential-shaped variables printed both values). The engine's own helpers — the tree kill's `ps` / `taskkill` / Git Bash group kill, shell discovery (`which bash` / `where bash.exe`) and the store lock's holder probe (`ps -p`) — also inherited the whole environment; each is looked up on PATH, so a PATH entry the model can write to (a workspace `node_modules/.bin`, which `npm run` prepends) made a planted `ps` read the credentials on every timed-out tool shell's tree kill (reproduced).
6
+ - **Now.** One rule, machine-kept: a child's environment comes through the scrub. The oracle gate reads the tool shell's three states, per gate: `OracleGateSpec.inheritEnv` absent or `"scrub"` — the engine's environment without its credential-shaped variables (`scrubSecretEnv`); `"all"` — the whole environment; a list — exactly the named variables that are set (a strict allow list: name `PATH` too when the command is looked up on it). Names only, never values — the contract is serializable data. A malformed `inheritEnv` (another word, a non-string, an empty name, a name holding `=` or a NUL) makes that gate `error` and starts nothing; a command the OS refuses outright (a NUL in an argument) is an `error` gate instead of a rejected `runOracle`. The withheld names reach the shared once-per-process `config.secret_env_scrubbed` notice through the new `onNotice` on `runOracle`'s options and on `RunSpecOptions` (absent ⇒ the console line). The helpers pass the scrubbed environment and announce nothing. The tool shell reads the same three-state helper (`inheritedHostEnv`, src/core/secret-env.ts), behaviour unchanged; the language server keeps its own `env` / scrub; a stdio MCP server already received only the SDK's allow list (`HOME LOGNAME PATH SHELL TERM USER`; the Windows list) plus its declared `env`, now pinned by a real child. `gate:spawn-sites` (static tier) keeps the table: every call of a process-starting import under `src/` is a row — `scrub` (it passes an `env` and its file reaches the scrub) or `exempt` with the reason (only the MCP allow list and example code remain) — and each runtime dependency's process starts are reviewed at its pinned version; a new site, an unreadable shape (`require`, `import()`, a re-export, the binding used as a value) or an unreviewed dependency version is red. Residual (KL-1181): at `import("sharp")` on Linux, sharp's libc probe (`detect-libc`) may run `getconf …; ldd --version` through `execSync` with the engine's environment when the interpreter, `/usr/bin/ldd` and `process.report` all fail to answer — a dependency's own spawn, which core cannot hand an environment.
7
+ - **Who needs it.** Deployments that run `runSpec` / `runOracle` with credentials in the engine's environment; any deployment whose PATH holds a directory the model can write to.
8
+ - **Who is hurt / compensation.** A deployment whose oracle tests need a credential-shaped variable (`DATABASE_URL`, `*_TOKEN`, `*_API_KEY`, …): the test now runs without it and the gate may go red or `error`. Put it back per gate — `inheritEnv: ["PATH", "HOME", "DATABASE_URL", …]` (strict: list everything the command needs) or `inheritEnv: "all"` (the previous behaviour). A `runSpec` whose preflight is the first scrub in the process takes the once-per-process notice: wire `RunSpecOptions.onNotice` to the same sink as the exec env's `onNotice`, or the line goes to the console. A PATH-looked-up helper no longer sees credential-shaped variables (none of them needs one).
9
+ - Pins: `test/backlog1181-spawn-site-env.test.ts` — the oracle gate through real children (default / `"scrub"` / `"all"` / allow list, nine malformed shapes starting nothing, a NUL argument, the notice, the public `runSpec` preflight + stop gate + final run), the shared helper and its validator, a planted `ps` on PATH through a timed-out `NodeExecutionEnv` shell and through `processFingerprint`, the stdio MCP control row; `test/backlog1181-shell-discovery-env.test.ts` — `/bin/bash` read as absent, the public `getShellConfig()` asks a planted `which` that reads no credential; `gate:spawn-sites` green on the shipped tree and red on each mutation (the 7.33.7 oracle site, a lost scrub call, a new site in a registered file / a namespace import / a stdio transport, a moved count, `require` / `import()` / re-export / value / destructure / shorthand / local export / default export, the table's own stale / reasonless / unknown-word / duplicate rows, a new / bumped / removed dependency).
10
+
11
+ ### Fix — a credential entry of `GIT_CONFIG_PARAMETERS` is withheld wherever it sits in the list (#1183; security; 7.33.6 regression; behavior narrowing; +0 exports, +0 wire keys, `SecretEnvFindingKind` unchanged) (@server)
12
+
13
+ - **Before.** The variable's entries were judged with `entries.some(gitConfigEntryCarriesCredential)`: `some` passes each entry's index as the second argument, and that function's second parameter is its recursion bound (`depth`, command lines read only below 3, since 7.33.6). From the fourth entry on, a credential-carrying command line (`credential.helper=!sh -c '…password…'`, a `core.sshCommand` with a password) was not read and the variable reached the tool shell and the language server — git reads every entry.
14
+ - **Now.** Each entry is judged from depth 0, whatever its position (checked through the real git binary for positions 1–6, before and after the scrub). A clean list of the same length is handed on as before. `gate:point-free-callbacks` (static tier): a function handed by name to `some` / `every` / `map` / `filter` / `find` / `findIndex` / `findLast` / `findLastIndex` / `forEach` / `flatMap` / `Array.from` (dot or element-access spelling) may declare one parameter (`reduce` / `reduceRight`: two); more — or a rest parameter — is red unless an allow row says why the extra argument is wanted (the 155 point-free sites in `src/` today: one was this defect, none allowed).
15
+ - **Who needs it.** Hosts that hand git configuration through `GIT_CONFIG_PARAMETERS` (`git -c` in a parent process, CI wrappers).
16
+ - **Who is hurt / compensation.** None beyond the variable's existing reading: a list whose fourth or later entry carries a credential is now withheld whole, as the first three always were.
17
+ - Pins: `test/backlog1183-point-free-callbacks.test.ts` — the reported helper at positions 1–6 through real git; a clean six-entry list kept; the gate green on the shipped tree, red on the unwrapped call in the real `secret-env.ts`, on a defaulted / plain second parameter, a rest parameter, `parseInt`, a method reference, a three-parameter reducer, `Array.from`'s mapFn and the element-access spellings `x?.['some'](f)` / `Array['from'](xs, f)`; quiet on the wrapped form, `Boolean` / `String`, a two-parameter reducer, an inline `(e, i)` arrow, a non-callback `find(id)`; the allow list's silence, stale row and reasonless row.
18
+
1
19
  ## 7.33.7 — 2026-09-28
2
20
 
3
21
  ### Fix — the OpenTelemetry OTLP exporter's header lists are withheld from the tool shell and language-server children by name (#1180; security; behavior narrowing; +0 exports, +0 wire keys, `SecretEnvFindingKind` unchanged) (@server)
@@ -13,8 +13,9 @@
13
13
  * 2. The public export points at a STABLE first-party path that SURVIVES the planned de-vendoring refactor —
14
14
  * when NodeExecutionEnv is naturalized out of `src/vendor/`, this module does not move and nothing rebinds.
15
15
  *
16
- * The `inheritEnv` THREE-STATE assembly (`"all"` opt-out / `"scrub"` default / `string[]` allowlist) is a
17
- * per-adapter concern (each exec env owns how it composes its child env); only the secret PATTERN is shared.
16
+ * The `inheritEnv` THREE-STATE policy (`"all"` opt-out / `"scrub"` default / `string[]` allowlist) is read ONCE, by
17
+ * {@link inheritedHostEnv}, for every engine spawn site that takes it (the tool shell, a `runSpec` oracle gate). A
18
+ * downstream adapter composes its own child env; only the secret PATTERN is shared with it.
18
19
  */
19
20
  import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
20
21
  import { type EngineNotice } from "./engine-notice.js";
@@ -62,6 +63,19 @@ export declare function isSecretEnvKey(key: string, value?: string, env?: NodeJS
62
63
  * to this process's own platform, the answer for a child spawned on this host; a host whose child runs elsewhere (a
63
64
  * remote or sandboxed execution environment) passes that environment's platform. */
64
65
  export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[], platform?: NodeJS.Platform): NodeJS.ProcessEnv;
66
+ /** How much of this process's environment a child spawned on this host inherits — the `inheritEnv` value. */
67
+ export type InheritEnvPolicy = "all" | "scrub" | string[];
68
+ /**
69
+ * The environment a child spawned ON THIS HOST inherits, by an {@link InheritEnvPolicy}: `"scrub"` (and absent) is
70
+ * this process's environment through {@link scrubSecretEnv} for this platform; `"all"` is this process's environment
71
+ * verbatim (the explicit opt-out); a `string[]` is exactly the named variables that are set, verbatim — a strict allow
72
+ * list, so a name it leaves out (`PATH` included) is not inherited. `findings` collects what the scrub withheld, and
73
+ * only the `"scrub"` arm adds to it. Any other value reads as `"scrub"`: a spawn site that must refuse a malformed
74
+ * policy asks {@link inheritEnvPolicyProblem} first.
75
+ */
76
+ export declare function inheritedHostEnv(policy: InheritEnvPolicy | undefined, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
77
+ /** Why `value` is not an {@link InheritEnvPolicy} (absent counts as one — the scrub), or `undefined` when it is. */
78
+ export declare function inheritEnvPolicyProblem(value: unknown): string | undefined;
65
79
  /** Test seam: never called by production code. */
66
80
  export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
67
81
  /**
@@ -469,7 +469,7 @@ export function readGitConfigParameters(text) {
469
469
  }
470
470
  function gitConfigVariableCarriesCredential(upperName, value) {
471
471
  if (upperName === "GIT_CONFIG_PARAMETERS")
472
- return readGitConfigParameters(value)?.some(gitConfigEntryCarriesCredential) ?? true;
472
+ return readGitConfigParameters(value)?.some((entry) => gitConfigEntryCarriesCredential(entry)) ?? true;
473
473
  const key = PROXY_ENV_NAMES.has(upperName) ? "http.proxy" : GIT_COMMAND_ENV_KEYS.get(upperName);
474
474
  return key !== undefined && gitConfigEntryCarriesCredential({ key, value });
475
475
  }
@@ -608,6 +608,30 @@ export function scrubSecretEnv(env, findings, platform = process.platform) {
608
608
  }
609
609
  return out;
610
610
  }
611
+ export function inheritedHostEnv(policy, findings) {
612
+ if (policy === "all")
613
+ return { ...process.env };
614
+ if (Array.isArray(policy)) {
615
+ const picked = {};
616
+ for (const k of policy)
617
+ if (process.env[k] !== undefined)
618
+ picked[k] = process.env[k];
619
+ return picked;
620
+ }
621
+ return scrubSecretEnv(process.env, findings, process.platform);
622
+ }
623
+ export function inheritEnvPolicyProblem(value) {
624
+ if (value === undefined || value === "all" || value === "scrub")
625
+ return undefined;
626
+ if (Array.isArray(value)) {
627
+ const bad = value.findIndex((k) => typeof k !== "string" || k.length === 0 || /[=\0]/.test(k));
628
+ if (bad === -1)
629
+ return undefined;
630
+ const k = value[bad];
631
+ return `inheritEnv[${bad}] is not a variable name (got ${typeof k !== "string" ? typeof k : k === "" ? "an empty string" : `a name holding ${k.includes("=") ? "\"=\"" : "a NUL"}`})`;
632
+ }
633
+ return `inheritEnv must be "scrub", "all" or a list of variable names (got ${typeof value === "string" ? JSON.stringify(value) : value === null ? "null" : typeof value})`;
634
+ }
611
635
  let secretEnvScrubNoticeAnnounced = false;
612
636
  export function __resetSecretEnvScrubNoticeAnnouncement() {
613
637
  secretEnvScrubNoticeAnnounced = false;
@@ -6,11 +6,14 @@
6
6
  * 🔴 **信任声明(fable-M3)**:SpecContract 是 **CODE-tier** 输入 —— `oracle[].cmd` 会被亲跑(execFile,
7
7
  * 任意命令)。它只能来自部署作者/可信代码路径;LLM 生成的 contract 直接喂 runSpec = 不受信输入获得
8
8
  * 命令执行权(越红线),禁止。contract 里的 `spec` **文本**才是 DATA-tier(runSpec 恒围栏置入 objective)。
9
+ * 命令可信 ≠ 它跑的代码可信:门跑的测试会加载模型改过的代码,故门子进程的环境缺省经 `scrubSecretEnv`
10
+ * (`OracleGateSpec.inheritEnv`,与 tool shell 同一三态),凭据名只在部署显式列名 / `"all"` 时交给它。
9
11
  *
10
12
  * 🔴 **oracle 三值(fable-M6)**:每门结果 ∈ {green, red, error} —— 判据不满足 = red;基础设施故障
11
13
  * (ENOENT/timeout/maxBuffer/parse 解析不出)= **error 非 red**。假红防线:error 绝不冒充"曾经红过"
12
14
  * (design/73 红线② 的机器化 —— `oracleHadRedRun` 只由真 red 观察置位)。
13
15
  */
16
+ import type { EngineNotice } from "./engine-notice.js";
14
17
  /** Declarative green criteria for one oracle gate. Absent ⇒ `exitCode === 0`. All declared criteria AND. */
15
18
  export interface OracleGreenSpec {
16
19
  /** Expected exit code. Default 0 when no `parse` is declared; with `parse`, exit code is only
@@ -48,6 +51,15 @@ export interface OracleGateSpec {
48
51
  /** Per-gate timeout. Default 60s. */
49
52
  timeoutMs?: number;
50
53
  green?: OracleGreenSpec;
54
+ /**
55
+ * How much of the engine's environment the gate's command inherits — the tool shell's `inheritEnv`, read the same
56
+ * way. Absent / `"scrub"`: the environment without its credential-shaped variables (`scrubSecretEnv`); the command
57
+ * runs code the model wrote, so the credentials are not handed to it by default. `"all"`: the whole environment.
58
+ * A list: exactly the named variables that are set (a strict allow list — `PATH` too, when the command is looked
59
+ * up on it). Names only, never values: the contract is serializable data. A value of any other shape makes the
60
+ * gate `error`.
61
+ */
62
+ inheritEnv?: "all" | "scrub" | string[];
51
63
  }
52
64
  /** design/125 D1 — the machine-readable task contract (CODE-tier input; see module doc). */
53
65
  export interface SpecContract {
@@ -113,6 +125,9 @@ export interface OracleRunReport {
113
125
  export declare function runOracle(gates: OracleGateSpec[], cwdRoot: string, opts?: {
114
126
  baseline?: OracleBaseline;
115
127
  signal?: AbortSignal;
128
+ /** Receives the once-per-process `config.secret_env_scrubbed` notice when a gate's scrub withholds names (the
129
+ * tool shell's seat and latch); absent ⇒ the console line. */
130
+ onNotice?: (notice: EngineNotice) => void;
116
131
  }): Promise<OracleRunReport>;
117
132
  /** Extract the per-gate baseline counts from a (preflight) report — only gates whose parse succeeded. */
118
133
  export declare function captureBaseline(report: OracleRunReport): OracleBaseline;
@@ -2,20 +2,15 @@ import { execFile } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, resolve, sep } from "node:path";
5
+ import { announceSecretEnvScrub, inheritEnvPolicyProblem, inheritedHostEnv } from "./secret-env.js";
5
6
  import { physicalPathOf } from "./physical-path.js";
6
7
  const DEFAULT_GATE_TIMEOUT_MS = 60_000;
7
8
  const GATE_MAX_BUFFER = 16 * 1024 * 1024;
8
9
  const OUTPUT_TAIL_CHARS = 1500;
9
10
  const REGEX_SCAN_CAP = 2 * 1024 * 1024;
10
- function execGate(gate, cwd, signal) {
11
+ function execGate(gate, cwd, env, signal) {
11
12
  return new Promise((resolvePromise) => {
12
- execFile(gate.cmd[0], gate.cmd.slice(1), {
13
- cwd,
14
- timeout: gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS,
15
- maxBuffer: GATE_MAX_BUFFER,
16
- encoding: "utf8",
17
- signal,
18
- }, (err, stdout, stderr) => {
13
+ const settle = (err, stdout, stderr) => {
19
14
  const output = (stdout ?? "") + (stderr ?? "");
20
15
  if (!err)
21
16
  return resolvePromise({ exitCode: 0, output });
@@ -26,7 +21,13 @@ function execGate(gate, cwd, signal) {
26
21
  return resolvePromise({ exitCode: null, output, infraError: `timeout/killed (${e.signal ?? "timeout"})` });
27
22
  }
28
23
  return resolvePromise({ exitCode: null, output, infraError: String(e.code ?? e.message ?? "spawn error") });
29
- });
24
+ };
25
+ try {
26
+ execFile(gate.cmd[0], gate.cmd.slice(1), { cwd, env, timeout: gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS, maxBuffer: GATE_MAX_BUFFER, encoding: "utf8", signal }, settle);
27
+ }
28
+ catch (e) {
29
+ settle(e instanceof Error ? e : new Error(String(e)));
30
+ }
30
31
  });
31
32
  }
32
33
  function safeTest(re, output) {
@@ -132,6 +133,11 @@ export async function runOracle(gates, cwdRoot, opts) {
132
133
  results.push(err(gate, "gate cmd is empty — no executable to run"));
133
134
  continue;
134
135
  }
136
+ const envProblem = inheritEnvPolicyProblem(gate.inheritEnv);
137
+ if (envProblem !== undefined) {
138
+ results.push(err(gate, `gate ${envProblem}`));
139
+ continue;
140
+ }
135
141
  let cwd = root;
136
142
  if (gate.cwd !== undefined) {
137
143
  const candidate = resolve(root, gate.cwd);
@@ -151,7 +157,10 @@ export async function runOracle(gates, cwdRoot, opts) {
151
157
  }
152
158
  cwd = candidate;
153
159
  }
154
- const exec = await execGate(gate, cwd, opts?.signal);
160
+ const withheld = [];
161
+ const env = inheritedHostEnv(gate.inheritEnv, withheld);
162
+ announceSecretEnvScrub(opts?.onNotice, withheld);
163
+ const exec = await execGate(gate, cwd, env, opts?.signal);
155
164
  results.push(evaluateGate(gate, exec, opts?.baseline));
156
165
  }
157
166
  const verdict = results.some((r) => r.verdict === "error")
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { scrubSecretEnv } from "../../core/secret-env.js";
2
3
  import { existsSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  const DEFAULT_GRACE_MS = 3000;
@@ -144,6 +145,7 @@ function enumerateDescendantsSync(rootPid) {
144
145
  let out;
145
146
  try {
146
147
  const result = spawnSync("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "etime="], {
148
+ env: scrubSecretEnv(process.env),
147
149
  timeout: PS_ENUMERATION_TIMEOUT_MS,
148
150
  encoding: "utf8",
149
151
  windowsHide: true,
@@ -209,6 +211,7 @@ function parseEtimeSeconds(etime) {
209
211
  function runTaskkill(args) {
210
212
  try {
211
213
  const child = spawn("taskkill", args, {
214
+ env: scrubSecretEnv(process.env),
212
215
  stdio: "ignore",
213
216
  detached: true,
214
217
  windowsHide: true,
@@ -268,7 +271,7 @@ function tryMsysGroupKill(bashPath, pid) {
268
271
  `[ -n "$g" ] && [ "$g" != "0" ] && kill -9 -"$g" 2>/dev/null; ` +
269
272
  `done; exit 0`;
270
273
  try {
271
- const child = spawn(bashPath, ["-c", script], { stdio: "ignore", detached: true, windowsHide: true });
274
+ const child = spawn(bashPath, ["-c", script], { env: scrubSecretEnv(process.env), stdio: "ignore", detached: true, windowsHide: true });
272
275
  child.on("error", () => { });
273
276
  return true;
274
277
  }
@@ -7,7 +7,7 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
- import { __resetSecretEnvScrubNoticeAnnouncement, announceSecretEnvScrub, scrubSecretEnv } from "../../core/secret-env.js";
10
+ import { __resetSecretEnvScrubNoticeAnnouncement, announceSecretEnvScrub, inheritedHostEnv, scrubSecretEnv } from "../../core/secret-env.js";
11
11
  import { physicalPathOfAsync } from "../../core/physical-path.js";
12
12
  import { isUncSpelling } from "../../tools/fs/safety.js";
13
13
  import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
@@ -119,6 +119,7 @@ async function runCommand(command, args, timeoutMs) {
119
119
  child = spawn(command, args, {
120
120
  stdio: ["ignore", "pipe", "ignore"],
121
121
  windowsHide: true,
122
+ env: scrubSecretEnv(process.env),
122
123
  });
123
124
  }
124
125
  catch {
@@ -203,44 +204,32 @@ export function __resetSecretScrubAnnouncement() {
203
204
  __resetSecretEnvScrubNoticeAnnouncement();
204
205
  }
205
206
  function getShellEnv(inheritEnv, baseEnv, extraEnv, onScrub, onNotice) {
206
- let inherited;
207
- if (inheritEnv === "all") {
208
- inherited = process.env;
209
- }
210
- else if (Array.isArray(inheritEnv)) {
211
- inherited = {};
212
- for (const k of inheritEnv)
213
- if (process.env[k] !== undefined)
214
- inherited[k] = process.env[k];
215
- }
216
- else {
217
- const scrubbed = [];
218
- inherited = scrubSecretEnv(process.env, scrubbed, process.platform);
219
- const findings = scrubbed.filter((f) => baseEnv?.[f.key] === undefined && extraEnv?.[f.key] === undefined);
220
- if (findings.length > 0) {
221
- if (typeof onScrub === "function") {
222
- try {
223
- const r = onScrub(findings);
224
- if (typeof r?.then === "function") {
225
- r.then(undefined, () => {
226
- });
227
- }
228
- }
229
- catch {
207
+ const scrubbed = [];
208
+ const inherited = inheritedHostEnv(inheritEnv, scrubbed);
209
+ const findings = scrubbed.filter((f) => baseEnv?.[f.key] === undefined && extraEnv?.[f.key] === undefined);
210
+ if (findings.length > 0) {
211
+ if (typeof onScrub === "function") {
212
+ try {
213
+ const r = onScrub(findings);
214
+ if (typeof r?.then === "function") {
215
+ r.then(undefined, () => {
216
+ });
230
217
  }
231
218
  }
232
- else if (onScrub !== undefined && !secretScrubSeatDefectAnnounced) {
233
- secretScrubSeatDefectAnnounced = true;
234
- try {
235
- console.warn(`onSecretEnvScrub seat holds ${onScrub === null ? "null" : typeof onScrub} — not a function; scrub findings fall back to the console summary (once per process)`);
236
- }
237
- catch {
238
- }
219
+ catch {
239
220
  }
240
- if (!(typeof onScrub === "function" && onNotice === undefined)) {
241
- announceSecretEnvScrub(onNotice, findings);
221
+ }
222
+ else if (onScrub !== undefined && !secretScrubSeatDefectAnnounced) {
223
+ secretScrubSeatDefectAnnounced = true;
224
+ try {
225
+ console.warn(`onSecretEnvScrub seat holds ${onScrub === null ? "null" : typeof onScrub} — not a function; scrub findings fall back to the console summary (once per process)`);
226
+ }
227
+ catch {
242
228
  }
243
229
  }
230
+ if (!(typeof onScrub === "function" && onNotice === undefined)) {
231
+ announceSecretEnvScrub(onNotice, findings);
232
+ }
244
233
  }
245
234
  return {
246
235
  ...inherited,
@@ -29,6 +29,7 @@ import type { RunnerSelfSeat } from "../core/runner/contracts.js";
29
29
  import type { TaskResult, TaskSpec } from "../core/types.js";
30
30
  import { type ToolPolicy } from "../core/tool-policy.js";
31
31
  import type { TaskOutcome } from "../core/task-outcome.js";
32
+ import type { EngineNotice } from "../core/engine-notice.js";
32
33
  import { type OracleBaseline, type OracleGateResult, type OracleVerdict, type SpecContract } from "../core/spec-contract.js";
33
34
  export interface RunSpecOptions {
34
35
  /** Workcopy root — the containment root for frozenPaths, oracle cwd, and `spec.path` resolution. */
@@ -36,6 +37,9 @@ export interface RunSpecOptions {
36
37
  /** Per-run base spec (model/tools/toolPolicy/hooks/limits/…). `objective` is owned by runSpec (the
37
38
  * spec framing); `toolPolicy` and `hooks` are COMPOSED with runSpec's, never replaced. */
38
39
  taskSpec: Omit<TaskSpec, "objective">;
40
+ /** Receives the oracle gates' once-per-process `config.secret_env_scrubbed` notice (`runOracle`'s `onNotice`);
41
+ * absent ⇒ the console line. */
42
+ onNotice?: (notice: EngineNotice) => void;
39
43
  }
40
44
  export interface RunSpecOracleReport {
41
45
  /** Terminal adjudication (the authoritative bit — the stop gate only advises mid-run). */
@@ -80,7 +80,8 @@ export async function runSpec(runner, contract, opts) {
80
80
  const startedAt = Date.now();
81
81
  const frozenResolved = resolveFrozenPaths(rootDir, contract.frozenPaths);
82
82
  const snapshot = snapshotFrozenPaths(frozenResolved);
83
- const preflight = await runOracle(contract.oracle, rootDir, { signal: opts.taskSpec.signal });
83
+ const noticeSeat = opts.onNotice !== undefined ? { onNotice: opts.onNotice } : {};
84
+ const preflight = await runOracle(contract.oracle, rootDir, { signal: opts.taskSpec.signal, ...noticeSeat });
84
85
  const baseline = captureBaseline(preflight);
85
86
  let oracleHadRedRun = preflight.verdict === "red";
86
87
  let oracleRuns = 1;
@@ -137,7 +138,7 @@ export async function runSpec(runner, contract, opts) {
137
138
  guardRestores += restoreFrozenPaths(snapshot, rootDir);
138
139
  if (!gateAbandoned) {
139
140
  const gateSignal = ctx.signal !== undefined && opts.taskSpec.signal !== undefined ? AbortSignal.any([ctx.signal, opts.taskSpec.signal]) : (ctx.signal ?? opts.taskSpec.signal);
140
- const report = await runOracle(contract.oracle, rootDir, { baseline, ...(gateSignal !== undefined ? { signal: gateSignal } : {}) });
141
+ const report = await runOracle(contract.oracle, rootDir, { baseline, ...(gateSignal !== undefined ? { signal: gateSignal } : {}), ...noticeSeat });
141
142
  if (ctx.signal?.aborted === true)
142
143
  return undefined;
143
144
  oracleRuns++;
@@ -175,7 +176,7 @@ export async function runSpec(runner, contract, opts) {
175
176
  hooks,
176
177
  });
177
178
  guardRestores += restoreFrozenPaths(snapshot, rootDir);
178
- const final = await runOracle(contract.oracle, rootDir, { baseline });
179
+ const final = await runOracle(contract.oracle, rootDir, { baseline, ...noticeSeat });
179
180
  oracleRuns++;
180
181
  if (final.verdict === "red") {
181
182
  oracleHadRedRun = true;
@@ -4,6 +4,7 @@ import { hrtime } from "node:process";
4
4
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
+ import { scrubSecretEnv } from "../../core/secret-env.js";
7
8
  import { physicalPathOf, physicalPathOfExisting } from "../../core/physical-path.js";
8
9
  import { thrownText } from "../../core/thrown-value.js";
9
10
  const SAFE_COMPONENT = /^[A-Za-z0-9_.-]+$/;
@@ -637,7 +638,7 @@ function probeStartTimesOnce(pid) {
637
638
  encoding: "utf8",
638
639
  timeout: START_TIME_PROBE_TIMEOUT_MS,
639
640
  stdio: ["ignore", "pipe", "ignore"],
640
- env: { ...process.env, LC_ALL: "C", LANG: "C" },
641
+ env: { ...scrubSecretEnv(process.env), LC_ALL: "C", LANG: "C" },
641
642
  });
642
643
  }
643
644
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.33.7",
3
+ "version": "7.33.8",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -86,6 +86,8 @@
86
86
  "gate:tool-docs": "node scripts/gen-tool-spec-fields.mjs --check",
87
87
  "gate:backlog-ledger": "node scripts/verify-backlog-ledger.mjs",
88
88
  "gate:child-clamp-seats": "node scripts/verify-child-clamp-seats.mjs",
89
+ "gate:spawn-sites": "node scripts/verify-spawn-sites.mjs",
90
+ "gate:point-free-callbacks": "node scripts/verify-point-free-callbacks.mjs",
89
91
  "gate:docs-tool-names": "node scripts/verify-docs-tool-names.mjs",
90
92
  "gate:comment-ratio": "vitest run test/comment-ratio-ratchet.test.ts",
91
93
  "gate:negative-controls": "vitest run test/gate-negative-controls.test.ts",
@@ -0,0 +1,165 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.33.8",
4
+ "from": "2dbd7b86789e1e5385ca2ebf13db6de617b33669",
5
+ "to": "v7.33.8",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [
10
+ {
11
+ "id": "F1",
12
+ "source": "behaviorFlips.summary",
13
+ "text": "#1181 (security, narrowing): a `runSpec` / `runOracle` gate's command is spawned with the engine's environment through the credential scrub; `OracleGateSpec.inheritEnv` (\"scrub\" default / \"all\" / allow list, names only) puts names back per gate; a malformed `inheritEnv` or a command the OS refuses outright is an `error` gate; the withheld names reach the shared once-per-process `config.secret_env_scrubbed` notice through `runOracle`'s and `RunSpecOptions`' new optional `onNotice`."
14
+ },
15
+ {
16
+ "id": "F2",
17
+ "source": "behaviorFlips.summary",
18
+ "text": "#1181 (security, narrowing): the engine's helpers looked up on PATH — the tree kill's `ps` / `taskkill` / Git Bash group kill, shell discovery (`which bash` / `where bash.exe`) and the lock-holder probe (`ps -p`) — spawn with the scrubbed environment and announce nothing."
19
+ },
20
+ {
21
+ "id": "F3",
22
+ "source": "behaviorFlips.summary",
23
+ "text": "#1183 (security, narrowing; 7.33.6 regression): every `GIT_CONFIG_PARAMETERS` entry is judged from depth 0 — a credential-carrying command line in the fourth or a later entry withholds the variable, as the first three did (`SecretEnvFinding.kind` = `family-rule`)."
24
+ }
25
+ ],
26
+ "closedSetMembers": [
27
+ {
28
+ "set": "InheritEnvPolicy",
29
+ "at": [
30
+ "src/core/secret-env.ts:941"
31
+ ],
32
+ "newSet": true,
33
+ "added": [
34
+ "all",
35
+ "scrub"
36
+ ],
37
+ "removed": [],
38
+ "breaking": false,
39
+ "criteriaCovered": false
40
+ }
41
+ ],
42
+ "closedSetsUnreadable": [],
43
+ "typeKeys": [
44
+ {
45
+ "type": "OracleGateSpec",
46
+ "at": [
47
+ "src/core/spec-contract.ts:54"
48
+ ],
49
+ "added": [
50
+ "inheritEnv"
51
+ ],
52
+ "addedDetail": [
53
+ {
54
+ "key": "inheritEnv",
55
+ "optional": true
56
+ }
57
+ ],
58
+ "addedRequired": [],
59
+ "removed": [],
60
+ "breaking": false,
61
+ "criteriaCovered": true
62
+ },
63
+ {
64
+ "type": "RunSpecOptions",
65
+ "at": [
66
+ "src/orchestration/run-spec.ts:56"
67
+ ],
68
+ "added": [
69
+ "onNotice"
70
+ ],
71
+ "addedDetail": [
72
+ {
73
+ "key": "onNotice",
74
+ "optional": true
75
+ }
76
+ ],
77
+ "addedRequired": [],
78
+ "removed": [],
79
+ "breaking": false,
80
+ "criteriaCovered": true
81
+ }
82
+ ],
83
+ "typeKeysUnreadableArms": [],
84
+ "typeKeysFlattenedCount": 217,
85
+ "declarationMoves": [],
86
+ "mintMoves": [],
87
+ "criteriaObserves": [
88
+ {
89
+ "kind": "engineNoticeCode",
90
+ "name": "config.secret_env_scrubbed",
91
+ "assertions": [
92
+ "G1.c"
93
+ ]
94
+ },
95
+ {
96
+ "kind": "typeMember",
97
+ "name": "ExecutionError.code",
98
+ "assertions": [
99
+ "G2.a"
100
+ ]
101
+ },
102
+ {
103
+ "kind": "typeMember",
104
+ "name": "McpServerSpec.transport",
105
+ "assertions": [
106
+ "G2.b",
107
+ "G2.c"
108
+ ]
109
+ },
110
+ {
111
+ "kind": "typeMember",
112
+ "name": "OracleGateResult.reason",
113
+ "assertions": [
114
+ "G1.b"
115
+ ]
116
+ },
117
+ {
118
+ "kind": "typeMember",
119
+ "name": "OracleGateResult.verdict",
120
+ "assertions": [
121
+ "G1.a",
122
+ "G1.b"
123
+ ]
124
+ },
125
+ {
126
+ "kind": "typeMember",
127
+ "name": "OracleGateSpec.inheritEnv",
128
+ "assertions": [
129
+ "G1.a",
130
+ "G2.c"
131
+ ]
132
+ },
133
+ {
134
+ "kind": "typeMember",
135
+ "name": "RunSpecOptions.onNotice",
136
+ "assertions": [
137
+ "G1.c"
138
+ ]
139
+ },
140
+ {
141
+ "kind": "typeMember",
142
+ "name": "SecretEnvFinding.key",
143
+ "assertions": [
144
+ "G3.a"
145
+ ]
146
+ },
147
+ {
148
+ "kind": "typeMember",
149
+ "name": "SecretEnvFinding.kind",
150
+ "assertions": [
151
+ "G3.a",
152
+ "G3.b"
153
+ ]
154
+ }
155
+ ],
156
+ "consumersHint": {
157
+ "cli": [],
158
+ "server": [],
159
+ "sdk": [],
160
+ "note": "scanned cli@4e593722 server@108404d4 sdk@8c379ef — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it. Ledger files (**/docs/core-consumption/**, **/docs/wire-consumption/**, **/*LEDGER*.json) are not evidence of use — server: 2 name(s) matched ONLY in bookkeeping files and were set aside (OracleGateSpec, RunSpecOptions)"
161
+ },
162
+ "breakingCount": 0,
163
+ "breakingItems": [],
164
+ "toResolvedAt": "128ed39a3b20bb0a28352a156cb229538261551e"
165
+ }