@sema-agent/core 7.51.0 → 7.51.2

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +97 -1
  2. package/dist/agents/subagent.d.ts +5 -0
  3. package/dist/agents/subagent.js +1 -0
  4. package/dist/core/allow-entry.d.ts +4 -2
  5. package/dist/core/allow-entry.js +1 -1
  6. package/dist/core/engine-capabilities.d.ts +15 -0
  7. package/dist/core/engine-capabilities.js +1 -0
  8. package/dist/core/engine-notice.d.ts +8 -5
  9. package/dist/core/locked-config.d.ts +26 -5
  10. package/dist/core/locked-config.js +31 -6
  11. package/dist/core/mcp-server-spec.d.ts +37 -7
  12. package/dist/core/mcp-server-spec.js +84 -1
  13. package/dist/core/mcp.js +16 -4
  14. package/dist/core/runner/prepare-policy-chain.d.ts +1 -1
  15. package/dist/core/runner/prepare-policy-chain.js +4 -2
  16. package/dist/core/runner/session-rule-policy.d.ts +3 -1
  17. package/dist/core/runner/session-rule-policy.js +11 -9
  18. package/dist/core/runner/tool-output-projection.d.ts +5 -0
  19. package/dist/core/runner/tool-output-projection.js +3 -3
  20. package/dist/core/secret-env.d.ts +46 -15
  21. package/dist/core/secret-env.js +84 -18
  22. package/dist/core/session-policy-store.d.ts +54 -14
  23. package/dist/core/session-policy-store.js +83 -20
  24. package/dist/core/spec-contract.d.ts +5 -3
  25. package/dist/core/types.d.ts +2 -1
  26. package/dist/core/types.js +1 -0
  27. package/dist/engine/execution-env/kill-tree.js +4 -4
  28. package/dist/engine/execution-env/node-execution-env.d.ts +11 -4
  29. package/dist/engine/execution-env/node-execution-env.js +9 -4
  30. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -1
  31. package/dist/engine/lsp/node-lsp-manager.js +2 -2
  32. package/dist/index.d.ts +10 -2
  33. package/dist/index.js +7 -2
  34. package/dist/stores/file/fs-atomic.js +2 -2
  35. package/dist/stores/file/session-policy-store.d.ts +3 -3
  36. package/package.json +1 -1
  37. package/test/export-surface.snapshot.json +33 -1
  38. package/wire-changes/core@7.51.1.json +259 -0
  39. package/wire-changes/core@7.51.2.json +293 -0
@@ -7,6 +7,7 @@ import { cloneObserverInput, resolveHookTimeoutMs } from "../hooks.js";
7
7
  import { assertHookSeatsCallable } from "../hook-seats.js";
8
8
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "../permission-rules.js";
9
9
  import { isIsolated } from "../remote-env.js";
10
+ import { earlierGrammarAdvisory } from "../session-policy-store.js";
10
11
  import { askApproverIdentity, attest, attestationOf, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, durableParkOptInDeclared, isLiveApproverSeat, mergeAttestation, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, withAttestation, wrapToolPolicy } from "../tool-policy.js";
11
12
  import { judgeRuleToolName, mountedToolNames } from "../tool-name-aliases.js";
12
13
  import { approvalCardMemberOf } from "../tool-roster.js";
@@ -110,6 +111,7 @@ export async function preparePolicyChain(input) {
110
111
  catch {
111
112
  }
112
113
  };
114
+ const announceEarlierGrammar = (rulesSessionId) => (entries) => deps.onError?.(Object.assign(new Error(earlierGrammarAdvisory(rulesSessionId, entries)), { code: "config.session_rules_earlier_grammar" }), { phase: "config", sessionId });
113
115
  let sessionRulePolicy;
114
116
  if (deps.sessionPolicyStore && spec.sessionId) {
115
117
  const store = deps.sessionPolicyStore;
@@ -125,7 +127,7 @@ export async function preparePolicyChain(input) {
125
127
  rules: row,
126
128
  };
127
129
  };
128
- sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg });
130
+ sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg, onEarlierGrammar: announceEarlierGrammar(sessionRuleKey.sessionId) });
129
131
  if (rules)
130
132
  publishOwnRules(rules);
131
133
  }
@@ -146,7 +148,7 @@ export async function preparePolicyChain(input) {
146
148
  ...(deps.sessionPolicyStore
147
149
  ? { read: createLiveSessionRules(deps.sessionPolicyStore, { sessionId: snap.sessionId, ...(snap.principal !== undefined ? { principal: snap.principal } : {}), seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishAncestorRules }) }
148
150
  : {}),
149
- }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg }));
151
+ }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg, onEarlierGrammar: announceEarlierGrammar(snap.sessionId) }));
150
152
  }
151
153
  const frozenConstraintLayers = (seedInheritedGate?.constraintChain ?? [])
152
154
  .filter((e) => !("opaque" in e))
@@ -111,7 +111,8 @@ export declare function createLiveSessionRules(store: SessionPolicyStore, floor:
111
111
  * (a line it cannot read against the rule is refused too), a path rule over every view of the call's target.
112
112
  * - `toolAllow` (if set) → deny any call no entry admits (the matcher's allow reading in the `narrowing` posture: every
113
113
  * segment of a command line named by an entry; never the allow layer's auto-mode strip), naming the uncovered segment.
114
- * - an entry that reads as nothing → in no row (it guards and grants nothing; the prepare-time audit names it).
114
+ * - an entry that reads as nothing → in no row (it guards and grants nothing; the prepare-time audit names it, or
115
+ * `onEarlierGrammar`, once per distinct set, when its snapshot's own earlier grammar reads it so).
115
116
  * - a snapshot the shape reading refuses → deny every call, saying what it holds (never its text).
116
117
  * - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
117
118
  * so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
@@ -131,4 +132,5 @@ export declare function createSessionRulePolicy(rules: SessionPermissionRules |
131
132
  rootPath?: string;
132
133
  toolEffects?: ReadonlyMap<string, ToolEffect>;
133
134
  anchors?: PathRuleBases;
135
+ onEarlierGrammar?: (entries: readonly string[]) => void;
134
136
  }): NamedToolPolicy;
@@ -1,6 +1,6 @@
1
1
  import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
2
- import { continuesLineage, readSessionRulesShape } from "../session-policy-store.js";
3
- import { compileAllowList, compiledEntryName, judgeAllowEntries, uncoveredCommandSegment } from "../allow-entry.js";
2
+ import { compileSessionRuleBucket, continuesLineage, earlierGrammarAnnouncer, readSessionRulesShape } from "../session-policy-store.js";
3
+ import { compiledEntryName, judgeAllowEntries, uncoveredCommandSegment } from "../allow-entry.js";
4
4
  import { inlineUntrusted } from "../untrusted-text.js";
5
5
  import { callTargetReadingOf, pathTargetBaseOf, readCallTarget } from "../effective-path-target.js";
6
6
  import { egressThrownText } from "../untrusted-egress.js";
@@ -104,26 +104,27 @@ export function createLiveSessionRules(store, floor, hooks) {
104
104
  return p;
105
105
  };
106
106
  }
107
- function compileSessionRules(rules) {
107
+ function compileSessionRules(rules, announceEarlier) {
108
108
  const shaped = readSessionRulesShape(rules);
109
109
  if ("fault" in shaped) {
110
110
  const { field, got } = shaped.fault;
111
111
  const what = got === "not a rule bucket" ? "a key that is no rule bucket" : field === "" ? `a value that is ${got}, not an object of rule buckets` : `${field} holding ${got}`;
112
112
  return { deny: [], allow: undefined, allowDirs: undefined, pathRows: false, names: { deny: [], allow: [] }, unreadable: `the session rules cannot be read as rules (they hold ${what}) — every call is refused until they are rewritten` };
113
113
  }
114
- const { toolDeny, toolAllow, allowDirs } = shaped.rules;
115
- const denyList = compileAllowList(toolDeny ?? [], "deny");
116
- const allowList = toolAllow === undefined ? undefined : compileAllowList(toolAllow, "allow");
114
+ const { toolAllow, allowDirs } = shaped.rules;
115
+ const denyList = compileSessionRuleBucket(shaped.rules, "toolDeny");
116
+ const allowList = toolAllow === undefined ? undefined : compileSessionRuleBucket(shaped.rules, "toolAllow");
117
117
  const rowsOf = (behavior, list) => list.rows.map((r) => ({ behavior, entry: r.entry, compiled: r.compiled }));
118
118
  const deny = rowsOf("deny", denyList);
119
119
  const allow = allowList === undefined ? undefined : rowsOf("allow", allowList);
120
- const namesOf = (rows, list) => [...rows.map((r) => compiledEntryName(r.compiled)), ...(list?.inert ?? []).map((i) => i.entry)];
120
+ const named = (list, earlier) => (list?.inert ?? []).filter((i) => (i.reason === "earlier_grammar") === earlier).map((i) => i.entry);
121
+ announceEarlier(named(allowList, true));
121
122
  return {
122
123
  deny,
123
124
  allow,
124
125
  allowDirs: allowDirs !== undefined && allowDirs.length > 0 ? allowDirs : undefined,
125
126
  pathRows: [...deny, ...(allow ?? [])].some((r) => r.compiled.kind === "path"),
126
- names: { deny: namesOf(deny, denyList), allow: namesOf(allow ?? [], allowList) },
127
+ names: { deny: [...deny.map((r) => compiledEntryName(r.compiled)), ...named(denyList, false)], allow: [...(allow ?? []).map((r) => compiledEntryName(r.compiled)), ...named(allowList, false)] },
127
128
  };
128
129
  }
129
130
  export function createSessionRulePolicy(rules, opts) {
@@ -132,11 +133,12 @@ export function createSessionRulePolicy(rules, opts) {
132
133
  const source = "seed" in rules ? rules : { seed: rules };
133
134
  const seed = source.seed;
134
135
  const readRules = source.read ?? (() => Promise.resolve(seed));
136
+ const announceEarlier = earlierGrammarAnnouncer(opts.onEarlierGrammar);
135
137
  let compiledFor;
136
138
  let compiled;
137
139
  const compile = (r) => {
138
140
  if (compiled === undefined || compiledFor !== r) {
139
- compiled = compileSessionRules(r);
141
+ compiled = compileSessionRules(r, announceEarlier);
140
142
  compiledFor = r;
141
143
  }
142
144
  return compiled;
@@ -4,6 +4,11 @@
4
4
  * state, zero runtask coupling — the module-level inputs are the text cap below and the shared media budget default
5
5
  * (`mediaBudgetBytes`); the leg hands in its own `mediaByteCapBytes`.
6
6
  */
7
+ /** A block's own size in characters — the ONE counter `tool_end.totalChars` adds up over a tool result's blocks (a
8
+ * consumer that re-renders a result sums the same function): a text block its text plus whatever else it carries at
9
+ * its serialized length (the contract's text block carries nothing else), a media block its payload, any other block
10
+ * its serialization. */
11
+ export declare const toolOutputBlockChars: (b: unknown) => number;
7
12
  type ToolOutput = {
8
13
  output: unknown;
9
14
  truncated: boolean;
@@ -4,9 +4,9 @@ import { engineCardTypes } from "../tool-registry.js";
4
4
  import { readMember } from "../thrown-value.js";
5
5
  const MAX_TOOL_END_OUTPUT_CHARS = 16384;
6
6
  const marker = (n) => `…[+${n} chars truncated]`;
7
- const ownChars = (b) => isTextBlock(b) ? b.text.length + (JSON.stringify({ ...b, type: undefined, text: undefined })?.length ?? 2) - 2 : isMediaBlock(b) ? b.data.length : (JSON.stringify(b)?.length ?? 0);
7
+ export const toolOutputBlockChars = (b) => isTextBlock(b) ? b.text.length + (JSON.stringify({ ...b, type: undefined, text: undefined })?.length ?? 2) - 2 : isMediaBlock(b) ? b.data.length : (JSON.stringify(b)?.length ?? 0);
8
8
  const boundTextLane = (blocks, lane) => {
9
- const sized = lane.map((i) => ({ i, size: ownChars(blocks[i]) }));
9
+ const sized = lane.map((i) => ({ i, size: toolOutputBlockChars(blocks[i]) }));
10
10
  const stream = sized.reduce((n, { size }, k) => n + size + (k > 0 ? 1 : 0), 0);
11
11
  const forms = new Map();
12
12
  let used = 0;
@@ -61,7 +61,7 @@ const project = (content, mediaByteCapBytes) => {
61
61
  return { output: content, truncated: false };
62
62
  let next = 0;
63
63
  const output = content.flatMap((_, i) => (media[i] ? (fit.blocks[next++] ?? []) : text.forms.has(i) ? [text.forms.get(i)] : []));
64
- return { output, truncated: true, totalChars: content.reduce((n, b) => n + ownChars(b), 0) };
64
+ return { output, truncated: true, totalChars: content.reduce((n, b) => n + toolOutputBlockChars(b), 0) };
65
65
  };
66
66
  export const toolOutputFrom = (result, mediaByteCapBytes) => {
67
67
  try {
@@ -4,7 +4,8 @@
4
4
  * A child the engine spawns may run what the MODEL chose (a tool shell, a gate over model-written code, a language
5
5
  * server). Under the `"scrub"` policy (the default) it inherits every variable but those the judgment below names —
6
6
  * deleted whole, never rewritten; `"all"` and a `string[]` allow list are the caller's explicit alternatives
7
- * ({@link inheritedHostEnv}). One judgment per variable ({@link classifySecretEnvEntry}), the first rule that answers:
7
+ * ({@link inheritedHostEnv}). Whatever the policy, a name the deployment declares as its own credential
8
+ * ({@link declareWithheldEnv}) is never inherited. One judgment per variable ({@link classifySecretEnvEntry}), the first rule that answers:
8
9
  * 1. exempt — git's environment config and the proxy variables, handed on as written ({@link isExemptEnvName});
9
10
  * 2. `exact-name` — a known credential name (the vocabulary's exact env names);
10
11
  * 3. `prefix-rule` — the OpenTelemetry settings ({@link OTEL_ENV_RE});
@@ -25,10 +26,11 @@ import { type EngineNotice } from "./engine-notice.js";
25
26
  * Reads the first {@link VALUE_SCAN_LIMIT} code units. The value half of the judgment, exported apart from the env
26
27
  * entry's exemptions ({@link isExemptEnvName}): a caller holding a value that is no environment variable asks this. */
27
28
  export declare function looksLikeCredentialValue(value: string): boolean;
28
- /** Which rule withheld an entry. */
29
- export type SecretEnvFindingKind = "exact-name" | "name-rule" | "namespace-rule" | "prefix-rule" | "value-rule";
30
- /** One env entry the `"scrub"` policy withheld (deleted): its name — never its value, so the record is loggable —, the
31
- * rule that withheld it, how strong that claim is, and the minting module. */
29
+ /** Which rule withheld an entry: one of the scrub's rules, or `own-credential` — the deployment declared the name as its
30
+ * own credential ({@link declareWithheldEnv}), which every policy withholds. */
31
+ export type SecretEnvFindingKind = "exact-name" | "name-rule" | "namespace-rule" | "prefix-rule" | "value-rule" | "own-credential";
32
+ /** One env entry withheld (deleted) from a child's environment: its name — never its value, so the record is
33
+ * loggable —, the rule that withheld it, how strong that claim is, and the minting module. */
32
34
  export interface SecretEnvFinding {
33
35
  key: string;
34
36
  kind: SecretEnvFindingKind;
@@ -49,16 +51,44 @@ export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: Secret
49
51
  /** How much of this process's environment a child spawned on this host inherits — the `inheritEnv` value. */
50
52
  export type InheritEnvPolicy = "all" | "scrub" | string[];
51
53
  /**
52
- * The environment a child spawned ON THIS HOST inherits, by an {@link InheritEnvPolicy}: `"scrub"` (and absent) is
53
- * this process's environment through {@link scrubSecretEnv}; `"all"` is this process's environment
54
- * verbatim (the explicit opt-out); a `string[]` is exactly the named variables that are set, verbatim — a strict allow
55
- * list, so a name it leaves out (`PATH` included) is not inherited. `findings` collects what the scrub withheld, and
56
- * only the `"scrub"` arm adds to it. Any other value reads as `"scrub"`; both spawn sites that take a policy — the
57
- * tool shell's `NodeExecutionEnv` (at construction) and the oracle gate (per gate) — refuse a malformed one through
58
- * {@link inheritEnvPolicyProblem} before it gets here.
54
+ * Declare the names of this deployment's OWN credentials in this process's environment — the engine's or its host's:
55
+ * an approval bearer, a service token. From then on no child spawned on this host inherits one, whatever the policy:
56
+ * every spawn site reads its environment through {@link inheritedHostEnv}, which subtracts them from `"all"` and from
57
+ * the scrub's result (each one present is a finding of kind `own-credential`), and {@link inheritEnvPolicyProblem}
58
+ * refuses an allow list naming one. Compared as the platform compares variable names: exactly, but case-blind on
59
+ * Windows. A variable a host hands a child itself (a shell's `shellEnv`, a per-exec `env`, a language server's `env`, an MCP server entry's `env`)
60
+ * is its explicit grant and is not read against the list.
61
+ *
62
+ * Declared once, at start-up, before anything spawns; a later call REPLACES the list (a configuration reload), read
63
+ * from the next spawn on — a spawn whose environment is already read (one announcing its findings) keeps it. The
64
+ * list is copied. A malformed list — not an array, an entry that is no variable name, or a name the host hands every
65
+ * child anyway ({@link runtimeSuppliedEnv}) — throws and leaves the declaration as it was.
66
+ *
67
+ * Two Windows readings, measured on a real machine: a withheld name that the child's own shell or runtime re-creates
68
+ * with a DEFAULT of its own (`ComSpec`, `PATHEXT`, `HOME`, `CommonProgramFiles`, …) shows up in the child's table with
69
+ * that default, never with this process's value — withheld, not leaked; and the disclosure (`detail.ownCredentials`)
70
+ * spells each withheld name as this process's environment spells it, not as the declaration spelled it.
71
+ */
72
+ export declare function declareWithheldEnv(names: readonly string[]): void;
73
+ /** Test seam: never called by production code (a host re-declares `[]`). */
74
+ export declare function __resetWithheldEnv(): void;
75
+ /**
76
+ * THE environment a child spawned ON THIS HOST inherits: the policy's arm, less the declared own credentials
77
+ * ({@link declareWithheldEnv}). The arms: `"scrub"` (and absent) is this process's environment through
78
+ * {@link scrubSecretEnv}; `"all"` is this process's environment as written; a `string[]` is exactly the named
79
+ * variables that are set, as written — a strict allow list, so a name it leaves out (`PATH` included) is not
80
+ * inherited. `findings` collects every entry withheld: the scrub's, and each declared own credential the arm held.
81
+ *
82
+ * A policy {@link inheritEnvPolicyProblem} refuses throws that reason HERE, at every spawn, read against the
83
+ * declaration as it stands: a value that is no policy at all (never read as the scrub), and an allow list naming a
84
+ * declared own credential — whenever the declaration was made, so the refusal at spawn time is the invariant. The
85
+ * spawn sites that take a policy (the tool shell's `NodeExecutionEnv` at construction, the oracle gate per gate) also
86
+ * ask first, so a host that declared before building them hears it at start-up.
59
87
  */
60
88
  export declare function inheritedHostEnv(policy: InheritEnvPolicy | undefined, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
61
- /** Why `value` is not an {@link InheritEnvPolicy} (absent counts as one — the scrub), or `undefined` when it is. */
89
+ /** Why `value` is not an {@link InheritEnvPolicy} this deployment can read (absent counts as one — the scrub), or
90
+ * `undefined` when it is: a malformed policy, or an allow list naming one of the declared own credentials
91
+ * ({@link declareWithheldEnv}, compared as it says). */
62
92
  export declare function inheritEnvPolicyProblem(value: unknown): string | undefined;
63
93
  /** Test seam: never called by production code. */
64
94
  export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
@@ -66,7 +96,8 @@ export declare function __resetSecretEnvScrubNoticeAnnouncement(): void;
66
96
  * The ONE disclosure point for a scrub's withheld keys. Routed through {@link deliverEngineNotice} — the
67
97
  * engine's one notice throat — so a wired `onNotice` seat REPLACES the console line exactly as every
68
98
  * other `EngineNotice` does, and an absent seat prints the same words `console.warn` always printed here.
69
- * `detail.names` carries every withheld key. Once per PROCESS, never per call or per site; an EMPTY
70
- * findings array is a no-op.
99
+ * `detail.names` carries every withheld key; `detail.ownCredentials`, present only when one was withheld, the
100
+ * subset withheld as the deployment's declared own credentials (kind `own-credential`) rather than by the scrub.
101
+ * Once per PROCESS, never per call or per site; an EMPTY findings array is a no-op.
71
102
  */
72
103
  export declare function announceSecretEnvScrub(onNotice: ((notice: EngineNotice) => void) | undefined, findings: readonly SecretEnvFinding[]): void;
@@ -1,4 +1,5 @@
1
1
  import { CREDENTIAL_VOCABULARY, envNameView, splitCamelCase } from "./credential-vocabulary.js";
2
+ import { DEFAULT_INHERITED_ENV_VARS } from "@modelcontextprotocol/sdk/client/stdio.js";
2
3
  import { deliverEngineNotice } from "./engine-notice.js";
3
4
  const GIT_CONFIG_ENV_RE = /^GIT_CONFIG_(?:COUNT|PARAMETERS|(?:KEY|VALUE)_[0-9]+)$/;
4
5
  const PROXY_ENV_RE = /^(?:(?:https?|ftp|all|no)_proxy|npm_config_(?:https?_)?proxy|npm_config_noproxy|yarn_proxy|(?:yarn|global_agent|docker|claude_code)_(?:https?|no)_proxy|cloudsdk_proxy_[a-z]+|electron_get_use_proxy)$/i;
@@ -93,6 +94,7 @@ const SECRET_ENV_KIND_CONFIDENCE = {
93
94
  "exact-name": "high",
94
95
  "prefix-rule": "high",
95
96
  "value-rule": "high",
97
+ "own-credential": "high",
96
98
  "name-rule": "medium",
97
99
  "namespace-rule": "medium",
98
100
  };
@@ -131,29 +133,91 @@ export function scrubSecretEnv(env, findings) {
131
133
  }
132
134
  return Object.fromEntries(kept);
133
135
  }
136
+ function envNameKey(name) {
137
+ return process.platform === "win32" ? name.toUpperCase() : name;
138
+ }
139
+ const UNPAIRED_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
140
+ function variableNameListProblem(label, value) {
141
+ const bad = value.findIndex((k) => typeof k !== "string" || k.length === 0 || /[=\0\s]/.test(k) || UNPAIRED_SURROGATE_RE.test(k));
142
+ if (bad === -1)
143
+ return undefined;
144
+ const k = value[bad];
145
+ const why = typeof k !== "string" ? typeof k : k === "" ? "an empty string" : `a name holding ${k.includes("=") ? "\"=\"" : k.includes("\0") ? "a NUL" : /\s/.test(k) ? "whitespace" : "an unpaired surrogate"}`;
146
+ return `${label}[${bad}] is not a variable name (got ${why})`;
147
+ }
148
+ const WINDOWS_REQUIRED_ENV = Object.freeze(["HOMEDRIVE", "HOMEPATH", "LOGONSERVER", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERDOMAIN", "USERNAME", "USERPROFILE", "WINDIR"]);
149
+ function runtimeSuppliedEnv() {
150
+ return ["NODE_V8_COVERAGE", ...DEFAULT_INHERITED_ENV_VARS, ...(process.platform === "win32" ? WINDOWS_REQUIRED_ENV : [])];
151
+ }
152
+ const WITHHELD_ENV = Symbol.for("sema.core.withheldEnv");
153
+ const NOTHING_WITHHELD = Object.freeze([]);
154
+ const withheldEnvOf = () => {
155
+ const held = Reflect.get(globalThis, WITHHELD_ENV);
156
+ return Array.isArray(held) ? held : NOTHING_WITHHELD;
157
+ };
158
+ const setWithheldEnv = (names) => {
159
+ Reflect.set(globalThis, WITHHELD_ENV, Object.freeze([...names]));
160
+ };
161
+ export function declareWithheldEnv(names) {
162
+ const value = names;
163
+ if (!Array.isArray(value))
164
+ throw new Error(`declareWithheldEnv: the list must be an array of variable names (got ${typeof value === "string" ? JSON.stringify(value) : value === null ? "null" : typeof value})`);
165
+ const problem = variableNameListProblem("names", value);
166
+ if (problem !== undefined)
167
+ throw new Error(`declareWithheldEnv: ${problem}`);
168
+ const supplied = new Set(runtimeSuppliedEnv().map(envNameKey));
169
+ const unwithholdable = value.findIndex((k) => supplied.has(envNameKey(k)));
170
+ if (unwithholdable !== -1)
171
+ throw new Error(`declareWithheldEnv: names[${unwithholdable}] is ${value[unwithholdable]}, which a child is handed from this process's environment whatever the spawn passes (by Node, the MCP stdio transport or Windows) — it cannot be withheld`);
172
+ setWithheldEnv(value);
173
+ }
174
+ export function __resetWithheldEnv() {
175
+ setWithheldEnv([]);
176
+ }
177
+ function withholdOwnCredentials(inherited, findings) {
178
+ if (withheldEnvOf().length === 0)
179
+ return inherited;
180
+ const own = new Set(withheldEnvOf().map(envNameKey));
181
+ const kept = [];
182
+ for (const key of Object.keys(inherited)) {
183
+ if (!own.has(envNameKey(key)))
184
+ kept.push([key, inherited[key]]);
185
+ else
186
+ findings?.push({ key, kind: "own-credential", confidence: SECRET_ENV_KIND_CONFIDENCE["own-credential"], source: "secret-env" });
187
+ }
188
+ return Object.fromEntries(kept);
189
+ }
190
+ function policyShapeProblem(value) {
191
+ if (value === undefined || value === "all" || value === "scrub")
192
+ return undefined;
193
+ if (Array.isArray(value))
194
+ return variableNameListProblem("inheritEnv", value);
195
+ return `inheritEnv must be "scrub", "all" or a list of variable names (got ${typeof value === "string" ? JSON.stringify(value) : value === null ? "null" : typeof value})`;
196
+ }
134
197
  export function inheritedHostEnv(policy, findings) {
198
+ const problem = inheritEnvPolicyProblem(policy);
199
+ if (problem !== undefined)
200
+ throw new Error(problem);
201
+ let arm;
135
202
  if (policy === "all")
136
- return { ...process.env };
137
- if (Array.isArray(policy)) {
138
- const picked = {};
203
+ arm = { ...process.env };
204
+ else if (Array.isArray(policy)) {
205
+ arm = {};
139
206
  for (const k of policy)
140
207
  if (process.env[k] !== undefined)
141
- picked[k] = process.env[k];
142
- return picked;
208
+ arm[k] = process.env[k];
143
209
  }
144
- return scrubSecretEnv(process.env, findings);
210
+ else
211
+ arm = scrubSecretEnv(process.env, findings);
212
+ return withholdOwnCredentials(arm, findings);
145
213
  }
146
214
  export function inheritEnvPolicyProblem(value) {
147
- if (value === undefined || value === "all" || value === "scrub")
148
- return undefined;
149
- if (Array.isArray(value)) {
150
- const bad = value.findIndex((k) => typeof k !== "string" || k.length === 0 || /[=\0]/.test(k));
151
- if (bad === -1)
152
- return undefined;
153
- const k = value[bad];
154
- 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"}`})`;
155
- }
156
- return `inheritEnv must be "scrub", "all" or a list of variable names (got ${typeof value === "string" ? JSON.stringify(value) : value === null ? "null" : typeof value})`;
215
+ const shape = policyShapeProblem(value);
216
+ if (shape !== undefined || !Array.isArray(value) || withheldEnvOf().length === 0)
217
+ return shape;
218
+ const own = new Set(withheldEnvOf().map(envNameKey));
219
+ const named = value.findIndex((k) => own.has(envNameKey(k)));
220
+ return named === -1 ? undefined : `inheritEnv[${named}] names ${value[named]}, which is this deployment's own credential and cannot be inherited by a child (a configuration error: no retry succeeds until the policy or the declaration changes)`;
157
221
  }
158
222
  let secretEnvScrubNoticeAnnounced = false;
159
223
  export function __resetSecretEnvScrubNoticeAnnouncement() {
@@ -164,9 +228,11 @@ export function announceSecretEnvScrub(onNotice, findings) {
164
228
  return;
165
229
  secretEnvScrubNoticeAnnounced = true;
166
230
  const names = findings.map((f) => f.key);
231
+ const ownCredentials = findings.filter((f) => f.kind === "own-credential").map((f) => f.key);
232
+ const own = ownCredentials.length > 0 ? `; ${ownCredentials.length} of them this deployment's own credential(s) (${ownCredentials.join(", ")})` : "";
167
233
  deliverEngineNotice(onNotice, {
168
234
  code: "config.secret_env_scrubbed",
169
- message: `secret_env_scrubbed: ${findings.length} env key(s) withheld from a spawned child's environment (${names.join(", ")}) — once per process; wire onNotice for the structured form`,
170
- detail: { names },
235
+ message: `secret_env_scrubbed: ${findings.length} env key(s) withheld from a spawned child's environment (${names.join(", ")})${own} — once per process; wire onNotice for the structured form`,
236
+ detail: { names, ...(ownCredentials.length > 0 ? { ownCredentials } : {}) },
171
237
  });
172
238
  }
@@ -18,10 +18,13 @@
18
18
  * (`readRuleEntry`, allow-entry.ts): a tool name, a protocol namespace, a shell command rule (`Bash(git push:*)`), a
19
19
  * path rule, a name or host rule. Entries are stored as written; every station that validates, compares, matches or
20
20
  * names one reads that reader's answer ({@link judgeSessionRulesWrite}, {@link loosenReasons}, the run-time layer).
21
+ * - **a row keeps its grammar**: the write judge stamps the grammar it read the entries in ({@link SESSION_RULES_GRAMMAR}), and
22
+ * every station reads a row's entries in the grammar the row carries ({@link readSessionRulesShape}) — a row stored before
23
+ * the stamp keeps the reading it was written under, so an upgrade never turns a stored entry into a grant.
21
24
  * - **NOT A SANDBOX**: a shell command rule reads the command line the call carries (every program run in its connector
22
25
  * chain); the real isolation boundary is the executionEnv, never these rules.
23
26
  */
24
- import { type AllowEntryInertDetail, type AllowEntryInertReason } from "./allow-entry.js";
27
+ import { compileAllowList, type AllowEntryInertDetail, type AllowEntryInertReason } from "./allow-entry.js";
25
28
  /** The per-session rules. All optional; an absent bucket imposes no constraint of that kind. */
26
29
  export interface SessionPermissionRules {
27
30
  /** If set, ONLY the calls these entries admit are allowed — a narrowing allowlist: a call no entry admits is refused.
@@ -45,6 +48,14 @@ declare const SESSION_RULE_BUCKETS: {
45
48
  export type SessionRuleEntryField = {
46
49
  [K in keyof typeof SESSION_RULE_BUCKETS]: (typeof SESSION_RULE_BUCKETS)[K] extends "dirs" ? never : K;
47
50
  }[keyof typeof SESSION_RULE_BUCKETS];
51
+ /**
52
+ * The rule grammar this engine reads a session's rule entries in: the `grammar` the write judge stamps on every rules value it
53
+ * judges ({@link judgeSessionRulesWrite}), so every store that writes through it keeps the stamp on the row. It counts
54
+ * revisions of what an entry MEANS and moves only when that changes. Grammar 0 is the reading before the one entry reader:
55
+ * a bucket's entries were tool names compared as written, so an allow entry the reader now reads as speaking for another name
56
+ * (`Bash(rm:*)`, `Bash(*)`) named no tool and admitted nothing.
57
+ */
58
+ export declare const SESSION_RULES_GRAMMAR = 1;
48
59
  /** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
49
60
  export interface StoredSessionRules extends SessionPermissionRules {
50
61
  rev: number;
@@ -53,7 +64,15 @@ export interface StoredSessionRules extends SessionPermissionRules {
53
64
  * can tell a recreated row from a continuation of the one it enforces whatever `rev` the new row wears.
54
65
  * Absent = generation 0 (a backend that predates the mark, or a row written before it). */
55
66
  gen?: number;
67
+ /** The rule grammar the row's entries are read in ({@link SESSION_RULES_GRAMMAR}), stamped by the write judge. Absent =
68
+ * a row stored before the stamp: grammar 0. */
69
+ grammar?: number;
56
70
  }
71
+ /** A rules value as the shape reading hands it on ({@link readSessionRulesShape}): its buckets, and the grammar its entries
72
+ * are read in — resolved, so a store that serves it on beside its own counters serves the row's grammar with it. */
73
+ export type SessionRulesInGrammar = SessionPermissionRules & {
74
+ grammar: number;
75
+ };
57
76
  /** Options for {@link SessionPolicyStore.putRules}. */
58
77
  export interface PutRulesOptions {
59
78
  /** CAS: reject the write if the stored `rev` is not this (optimistic concurrency). Omit to skip the check. */
@@ -169,13 +188,14 @@ declare function lexWithin(root: string, p: string): boolean;
169
188
  * THE ONE SHAPE READING of a rules value — what every station that takes one reads it through: the write (incoming rules),
170
189
  * a store's read-back of its own rows, the tighten comparison's prior, the run-time layer's served row. A rules value is an
171
190
  * object whose keys are buckets ({@link SessionPermissionRules}); each bucket present is a list of strings (`allowDirs`: of
172
- * non-blank strings — a directory is no blank). A bucket's TYPE is all this reads: what an entry means is the entry reader's.
173
- * The store's own stamps (`rev`, `gen`) are passed over, and a key holding `undefined` holds nothing. Anything else is a fault,
174
- * named by key and by TYPE, never by value — a bare string is never one rule and never its characters (`"rm"` spread into
175
- * `["r","m"]`), and a key outside the buckets is never dropped (a dropped deny is a deny nobody enforces).
191
+ * non-blank strings — a directory is no blank). A bucket's TYPE is all this reads: what an entry means is the entry reader's,
192
+ * in the grammar the value is written in ({@link grammarOf}), handed on as `grammar`. The store's counters (`rev`, `gen`) are
193
+ * passed over, and a key holding `undefined` holds nothing. Anything else is a fault, named by key and by TYPE, never by value —
194
+ * a bare string is never one rule and never its characters (`"rm"` spread into `["r","m"]`), and a key outside the buckets is
195
+ * never dropped (a dropped deny is a deny nobody enforces).
176
196
  */
177
197
  export declare function readSessionRulesShape(value: unknown): {
178
- rules: SessionPermissionRules;
198
+ rules: SessionRulesInGrammar;
179
199
  } | {
180
200
  fault: SessionRuleShapeRefusal;
181
201
  sentence: string;
@@ -185,15 +205,33 @@ export declare function readSessionRulesShape(value: unknown): {
185
205
  * stored form is `..`-free and the tighten diff compares the form the runtime resolves. Both bundled stores and a durable twin
186
206
  * stamp exactly this, so a structuredClone and a JSON round-trip of it are byte-identical. */
187
207
  export declare function normalizeRules(rules: unknown): SessionPermissionRules;
208
+ /**
209
+ * THE LIST READING of one rule bucket of a rules value the shape reading read — what the run-time layer enforces, the tighten
210
+ * comparison compares and the write judge refuses on: `compileAllowList` over the one entry reader, in the bucket's direction,
211
+ * against `roster` when given, in the grammar the value is written in. An entry its grammar reads as nothing that the reader
212
+ * reads as a row ({@link readsAsNothingIn}) is moved to the entries that read as nothing, reason `earlier_grammar`.
213
+ */
214
+ export declare function compileSessionRuleBucket(rules: SessionRulesInGrammar, field: SessionRuleEntryField, roster?: ReadonlySet<string>): ReturnType<typeof compileAllowList>;
215
+ /** The advisory for allow entries a stored row's earlier grammar reads as nothing ({@link compileSessionRuleBucket}): the session,
216
+ * the entries as written, what they do and the way to make them the rules they now spell. */
217
+ export declare function earlierGrammarAdvisory(sessionId: string, entries: readonly string[]): string;
218
+ /** A layer's delivery of the entries its snapshots' earlier grammar reads as nothing ({@link compileSessionRuleBucket},
219
+ * `earlier_grammar`) to `sink`: once per distinct set per layer (the ledger is this closure's), an empty set is no news, and a
220
+ * throwing sink never changes what the snapshot enforces. */
221
+ export declare function earlierGrammarAnnouncer(sink: ((entries: readonly string[]) => void) | undefined): (entries: readonly string[]) => void;
188
222
  /**
189
223
  * THE ONE WRITE JUDGE every store applies before it stamps a row (both bundled stores, and a durable backend at the same point):
190
224
  * the rules to stamp, or a {@link SessionPolicyError}, in this order — `conflict` (a stale `expectedRev` against `prior`'s rev),
191
- * `invalid_shape` ({@link normalizeRules}), `inert_entry` (an entry the reader reads as nothing; operator writes included — a
192
- * rule that names nothing guards nothing whoever writes it), then, for a non-operator write over an existing row,
193
- * `loosen_forbidden` ({@link loosenReasons}) — or `corrupt` when that prior cannot be read as rules, which the comparison never
194
- * reads as fewer. `mountedToolNames` = the store's declaration ({@link SessionPolicyStoreOptions.mountedToolNames}).
225
+ * `invalid_shape` ({@link normalizeRules}), `inert_entry` (an entry that reads as nothing in the grammar the rules are written
226
+ * in; operator writes included — a rule that names nothing guards nothing whoever writes it), then, for a non-operator write
227
+ * over an existing row, `loosen_forbidden` ({@link loosenReasons}) — or `corrupt` when that prior cannot be read as rules,
228
+ * which the comparison never reads as fewer. The rules to stamp carry `grammar` = {@link SESSION_RULES_GRAMMAR}: what passed
229
+ * the judge means the same in this engine's grammar, and THIS is the one place the stamp is minted, so every store that
230
+ * stores what the judge returns keeps it. `mountedToolNames` = the store's declaration ({@link SessionPolicyStoreOptions.mountedToolNames}).
195
231
  */
196
- export declare function judgeSessionRulesWrite(prior: StoredSessionRules | null, rules: unknown, opts: PutRulesOptions | undefined, mountedToolNames?: ReadonlySet<string>): SessionPermissionRules;
232
+ export declare function judgeSessionRulesWrite(prior: StoredSessionRules | null, rules: unknown, opts: PutRulesOptions | undefined, mountedToolNames?: ReadonlySet<string>): SessionPermissionRules & {
233
+ grammar: number;
234
+ };
197
235
  /**
198
236
  * Pure tighten-only diff: the reasons `next` LOOSENS `prior` (empty ⇒ `next` is a pure tightening or no-op), over the EFFECTIVE
199
237
  * entries of each rule bucket — the list as the run-time layer enforces it ({@link effectiveEntries}). An entry that reads as
@@ -201,9 +239,11 @@ export declare function judgeSessionRulesWrite(prior: StoredSessionRules | null,
201
239
  * takes out an entry that names nothing); an allowlist a `*` voids admits nothing, so dropping the `*` widens. toolDeny: removing an effective entry loosens. toolAllow: removing the list
202
240
  * (→ everything allowed) or adding an effective entry outside the prior set loosens; prior-undefined → any defined next only
203
241
  * narrows. allowDirs: removing confinement, or adding a dir not within some prior dir, loosens (coarse raw containment).
204
- * Entries compare as written. Both sides are read through {@link readSessionRulesShape} first: a side it cannot read (a key that
205
- * is no bucket — a retired one included) throws `invalid_shape` rather than compare without it. `mountedToolNames` = the store's
206
- * declaration ({@link SessionPolicyStoreOptions.mountedToolNames}; empty when it has none) — the write judge's too.
242
+ * Entries compare as written, each side's in the grammar that side is written in (a stored row's entry that its grammar reads as
243
+ * nothing is in no effect, so writing it in this engine's grammar widens). Both sides are read through
244
+ * {@link readSessionRulesShape} first: a side it cannot read (a key that is no bucket — a retired one included) throws
245
+ * `invalid_shape` rather than compare without it. `mountedToolNames` = the store's declaration
246
+ * ({@link SessionPolicyStoreOptions.mountedToolNames}; empty when it has none) — the write judge's too.
207
247
  */
208
248
  export declare function loosenReasons(priorValue: SessionPermissionRules, nextValue: SessionPermissionRules, mountedToolNames?: ReadonlySet<string>): string[];
209
249
  /**