@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.
- package/CHANGELOG.md +97 -1
- package/dist/agents/subagent.d.ts +5 -0
- package/dist/agents/subagent.js +1 -0
- package/dist/core/allow-entry.d.ts +4 -2
- package/dist/core/allow-entry.js +1 -1
- package/dist/core/engine-capabilities.d.ts +15 -0
- package/dist/core/engine-capabilities.js +1 -0
- package/dist/core/engine-notice.d.ts +8 -5
- package/dist/core/locked-config.d.ts +26 -5
- package/dist/core/locked-config.js +31 -6
- package/dist/core/mcp-server-spec.d.ts +37 -7
- package/dist/core/mcp-server-spec.js +84 -1
- package/dist/core/mcp.js +16 -4
- package/dist/core/runner/prepare-policy-chain.d.ts +1 -1
- package/dist/core/runner/prepare-policy-chain.js +4 -2
- package/dist/core/runner/session-rule-policy.d.ts +3 -1
- package/dist/core/runner/session-rule-policy.js +11 -9
- package/dist/core/runner/tool-output-projection.d.ts +5 -0
- package/dist/core/runner/tool-output-projection.js +3 -3
- package/dist/core/secret-env.d.ts +46 -15
- package/dist/core/secret-env.js +84 -18
- package/dist/core/session-policy-store.d.ts +54 -14
- package/dist/core/session-policy-store.js +83 -20
- package/dist/core/spec-contract.d.ts +5 -3
- package/dist/core/types.d.ts +2 -1
- package/dist/core/types.js +1 -0
- package/dist/engine/execution-env/kill-tree.js +4 -4
- package/dist/engine/execution-env/node-execution-env.d.ts +11 -4
- package/dist/engine/execution-env/node-execution-env.js +9 -4
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -1
- package/dist/engine/lsp/node-lsp-manager.js +2 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +7 -2
- package/dist/stores/file/fs-atomic.js +2 -2
- package/dist/stores/file/session-policy-store.d.ts +3 -3
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +33 -1
- package/wire-changes/core@7.51.1.json +259 -0
- 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 {
|
|
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 {
|
|
115
|
-
const denyList =
|
|
116
|
-
const allowList = toolAllow === undefined ? undefined :
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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 +
|
|
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}).
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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.
|
|
70
|
-
*
|
|
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;
|
package/dist/core/secret-env.js
CHANGED
|
@@ -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
|
-
|
|
137
|
-
if (Array.isArray(policy)) {
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
return picked;
|
|
208
|
+
arm[k] = process.env[k];
|
|
143
209
|
}
|
|
144
|
-
|
|
210
|
+
else
|
|
211
|
+
arm = scrubSecretEnv(process.env, findings);
|
|
212
|
+
return withholdOwnCredentials(arm, findings);
|
|
145
213
|
}
|
|
146
214
|
export function inheritEnvPolicyProblem(value) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* `["r","m"]`), and a key outside the buckets is
|
|
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:
|
|
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
|
|
192
|
-
* rule that names nothing guards nothing whoever writes it), then, for a non-operator write
|
|
193
|
-
* `loosen_forbidden` ({@link loosenReasons}) — or `corrupt` when that prior cannot be read as rules,
|
|
194
|
-
* reads as fewer. `
|
|
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
|
|
205
|
-
* is no
|
|
206
|
-
*
|
|
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
|
/**
|