sentinelayer-cli 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -2
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +483 -47
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
@@ -0,0 +1,158 @@
1
+ // Fail-closed network egress guard for test processes.
2
+ //
3
+ // Why: on 2026-09-29 raw unit-test runs on a logged-in developer machine synced
4
+ // tens of thousands of junk sessions to the production API, and a later audit
5
+ // found read paths (fetchSessionFromApi, listSessionsFromApi, pollSessionEvents,
6
+ // ...) that ignore SENTINELAYER_SKIP_REMOTE_SYNC entirely. An env flag that each
7
+ // call site must remember to check is not a safety boundary.
8
+ //
9
+ // SCOPE (stated exactly): this wraps `globalThis.fetch` only. It does not cover
10
+ // node:http/https/net, a separately imported undici, or child tools (git, curl).
11
+ // At the time of writing, src/ makes its network calls through globalThis.fetch
12
+ // (and injected fetchImpl defaults to it).
13
+ //
14
+ // One normalization, validated AND dispatched: every call is converted ONCE with
15
+ // native Request semantics (`new Request(input, init)`, the same conversion fetch
16
+ // itself performs), then that exact Request's URL is checked and that exact
17
+ // Request is what reaches the network. The guard never validates one URL
18
+ // representation and dispatches another (e.g. an object whose .url and
19
+ // toString() disagree, or a getter that answers differently on a second read).
20
+ //
21
+ // Policy, inside a test process:
22
+ // - natively, only numeric loopback: 127.0.0.1 or [::1]. Host names (localhost,
23
+ // *.test, *.invalid, *.localhost) are refused: a name is not proof of loopback
24
+ // routing, and tests that use fixture names replace globalThis.fetch with a mock.
25
+ // - redirects in the default "follow" mode are walked manually and EVERY hop is
26
+ // checked against the same policy. Only 301/302/303/307/308 are followed. A
27
+ // 303, or a 301/302 after POST, becomes GET (body headers dropped); a 307/308
28
+ // that would re-send a body is refused (use redirect: "manual" in such a test).
29
+ // On an origin change Authorization/Proxy-Authorization/Cookie are dropped, as
30
+ // native fetch does. Explicit "manual"/"error" modes pass through unchanged.
31
+ // - data: and blob: never leave the process and are allowed.
32
+ //
33
+ // Activation: NODE_TEST_CONTEXT (set by `node --test` for test files) or
34
+ // SENTINELAYER_TEST_EGRESS_GUARD=1 (set by tests/setup-env.mjs and inherited by
35
+ // spawned children). Production processes set neither, so there it is a no-op.
36
+ // A test that installs its own `globalThis.fetch` mock replaces the wrapper, and
37
+ // restoring the saved original puts the guard back.
38
+
39
+ const GUARD_MARKER = Symbol.for("sentinelayer.testEgressGuard");
40
+ const NUMERIC_LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]"]);
41
+ const MAX_REDIRECTS = 20;
42
+ // Only these are redirects (as in native fetch); a 300/304/305/306 with a Location header is returned as-is.
43
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
44
+ // Dropped when a redirect changes origin (as native undici does), so credentials never cross origins.
45
+ const CROSS_ORIGIN_STRIPPED_HEADERS = ["authorization", "proxy-authorization", "cookie"];
46
+ // Dropped when a redirect turns the request into a body-less GET (fetch spec request-body-header names).
47
+ const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "content-language", "content-location"];
48
+
49
+ export class TestEgressBlockedError extends Error {
50
+ constructor(target, detail = "") {
51
+ super(
52
+ `test egress blocked: ${target}${detail ? ` (${detail})` : ""} ` +
53
+ "(tests may only reach 127.0.0.1 or [::1] natively; mock fetch for anything else)",
54
+ );
55
+ this.name = "TestEgressBlockedError";
56
+ this.code = "TEST_EGRESS_BLOCKED";
57
+ }
58
+ }
59
+
60
+ export function isTestEgressGuardRequested(env = process.env) {
61
+ return Boolean(String(env.NODE_TEST_CONTEXT || "").trim()) ||
62
+ String(env.SENTINELAYER_TEST_EGRESS_GUARD || "").trim() === "1";
63
+ }
64
+
65
+ // Policy on an already-canonical URL string (a Request's .url, or a redirect target resolved with new URL()).
66
+ export function isAllowedTestDestination(url) {
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(String(url));
70
+ } catch {
71
+ return false; // unparseable: refuse rather than guess
72
+ }
73
+ if (parsed.protocol === "data:" || parsed.protocol === "blob:") return true; // never leaves the process
74
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
75
+ // WHATWG URL canonicalizes IPv4 forms (127.1, 0x7f.0.0.1) to dotted decimal.
76
+ return NUMERIC_LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase());
77
+ }
78
+
79
+ const blockedForTests = [];
80
+
81
+ export function getBlockedTestEgress() {
82
+ return blockedForTests.slice();
83
+ }
84
+
85
+ function originOf(url) {
86
+ try {
87
+ return new URL(String(url)).origin;
88
+ } catch {
89
+ return "<unparseable>";
90
+ }
91
+ }
92
+
93
+ function block(url, detail) {
94
+ const origin = originOf(url);
95
+ blockedForTests.push(origin);
96
+ return new TestEgressBlockedError(origin, detail);
97
+ }
98
+
99
+ export function installTestEgressGuard({ env = process.env, force = false } = {}) {
100
+ if (!force && !isTestEgressGuardRequested(env)) return false;
101
+ const nativeFetch = globalThis.fetch;
102
+ const NativeRequest = globalThis.Request;
103
+ if (typeof nativeFetch !== "function" || typeof NativeRequest !== "function") return false;
104
+ if (nativeFetch[GUARD_MARKER]) return true; // idempotent
105
+
106
+ async function followChecked(thisArg, request, hops) {
107
+ const response = await nativeFetch.call(thisArg, new NativeRequest(request, { redirect: "manual" }));
108
+ const location = response.headers.get("location");
109
+ if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
110
+ let next;
111
+ try {
112
+ next = new URL(location, request.url).href;
113
+ } catch {
114
+ throw block(location, "unparseable redirect location");
115
+ }
116
+ if (!isAllowedTestDestination(next)) throw block(next, `redirect hop ${hops + 1}`);
117
+ if (hops + 1 > MAX_REDIRECTS) throw new TypeError("test fetch: too many redirects");
118
+ const method = request.method.toUpperCase();
119
+ const toGet = response.status === 303 ? method !== "HEAD"
120
+ : (response.status === 301 || response.status === 302) && method === "POST";
121
+ if (!toGet && request.body !== null && method !== "GET" && method !== "HEAD") {
122
+ throw block(next, `redirect ${response.status} would re-send a request body; use redirect: "manual"`);
123
+ }
124
+ const headers = new Headers(request.headers);
125
+ if (new URL(next).origin !== new URL(request.url).origin) {
126
+ for (const name of CROSS_ORIGIN_STRIPPED_HEADERS) headers.delete(name);
127
+ }
128
+ if (toGet) {
129
+ for (const name of BODY_HEADERS) headers.delete(name);
130
+ }
131
+ const nextRequest = new NativeRequest(next, {
132
+ method: toGet ? "GET" : method,
133
+ headers,
134
+ signal: request.signal,
135
+ redirect: "manual",
136
+ });
137
+ return followChecked(thisArg, nextRequest, hops + 1);
138
+ }
139
+
140
+ const guarded = function guardedTestFetch(input, init) {
141
+ let request;
142
+ try {
143
+ request = new NativeRequest(input, init); // the ONE conversion; exactly what fetch would do
144
+ } catch (error) {
145
+ return Promise.reject(error); // the same TypeError native fetch would raise
146
+ }
147
+ if (!isAllowedTestDestination(request.url)) return Promise.reject(block(request.url));
148
+ if (request.redirect !== "follow") return nativeFetch.call(this, request);
149
+ return followChecked(this, request, 0);
150
+ };
151
+ Object.defineProperty(guarded, GUARD_MARKER, { value: true });
152
+ globalThis.fetch = guarded;
153
+ return true;
154
+ }
155
+
156
+ export function isTestEgressGuardInstalled() {
157
+ return Boolean(typeof globalThis.fetch === "function" && globalThis.fetch[GUARD_MARKER]);
158
+ }
@@ -352,10 +352,18 @@ export async function runInvestorDd({
352
352
 
353
353
  const streamPath = path.join(artifactBase, "stream.ndjson");
354
354
  const streamHandle = await fsp.open(streamPath, "w");
355
+ let streamClosePromise = null;
356
+
357
+ const closeStream = () => {
358
+ streamClosePromise ??= streamHandle.close();
359
+ return streamClosePromise;
360
+ };
355
361
 
356
362
  const emit = (event) => {
357
363
  const enriched = { ...event, at: new Date().toISOString(), runId };
358
- streamHandle.write(`${JSON.stringify(enriched)}\n`).catch(() => {});
364
+ if (!streamClosePromise) {
365
+ streamHandle.write(`${JSON.stringify(enriched)}\n`).catch(() => {});
366
+ }
359
367
  try {
360
368
  onEvent(enriched);
361
369
  } catch {
@@ -363,6 +371,7 @@ export async function runInvestorDd({
363
371
  }
364
372
  };
365
373
 
374
+ try {
366
375
  const startTime = Date.now();
367
376
  emit({ type: "investor_dd_start", rootPath, personas });
368
377
 
@@ -593,7 +602,7 @@ export async function runInvestorDd({
593
602
  await writeJson(path.join(artifactBase, "progress.json"), ddProgress);
594
603
  await writeJson(path.join(artifactBase, "summary.json"), summary);
595
604
 
596
- await streamHandle.close();
605
+ await closeStream();
597
606
 
598
607
  const artifactFiles = await fsp.readdir(artifactBase);
599
608
  const manifest = {};
@@ -624,4 +633,7 @@ export async function runInvestorDd({
624
633
  }
625
634
 
626
635
  return runResult;
636
+ } finally {
637
+ await closeStream();
638
+ }
627
639
  }
@@ -0,0 +1,312 @@
1
+ // admission-auth.js — which credential a session command runs on.
2
+ //
3
+ // An admitted agent must act with ITS admission credential: room-scoped, expiring,
4
+ // revocable, and limited to what the owner approved. The human's own token is
5
+ // broader (every room they belong to, owner rights where they have them), so
6
+ // quietly falling back to it would let an agent step around its grant.
7
+ //
8
+ // A command that knows its (session, agent) wraps its work in withAgentAdmission:
9
+ //
10
+ // - nothing stored for that pair -> the legacy path, unchanged (human token)
11
+ // - a live credential -> every session API call made inside the
12
+ // callback resolves to that credential, sent
13
+ // only to the API that issued it
14
+ // - a tombstone (expired, malformed, bound elsewhere) -> refused before any
15
+ // request is made; never treated as absence
16
+ //
17
+ // The scope is an AsyncLocalStorage context (src/auth/admission-scope.js) that
18
+ // resolveActiveAuthSession consults first. It therefore covers every helper a
19
+ // command calls, however deep, and every credential lookup, not only the session
20
+ // transport's.
21
+ // A revoked credential looks live here; the API refuses it (401) and the command
22
+ // fails. There is no retry with another credential anywhere on this path.
23
+
24
+ import path from "node:path";
25
+ import process from "node:process";
26
+
27
+ import { admittedAgentScope } from "../auth/admission-scope.js";
28
+ import { readAdmissionCredentialState } from "./admission.js";
29
+
30
+ export class AdmissionCredentialRefused extends Error {
31
+ constructor(message, { reason, sessionId, agentId } = {}) {
32
+ super(message);
33
+ this.name = "AdmissionCredentialRefused";
34
+ this.code = "ADMISSION_CREDENTIAL_REFUSED";
35
+ this.reason = reason;
36
+ this.sessionId = sessionId;
37
+ this.agentId = agentId;
38
+ }
39
+ }
40
+
41
+ const REFUSAL_HINT = {
42
+ expired: "its admission has expired",
43
+ malformed: "its stored admission credential is unreadable",
44
+ bound_to_another_session_or_agent: "its stored admission credential names a different session or agent",
45
+ no_issuing_authority: "its stored admission credential does not record the API that issued it",
46
+ };
47
+
48
+ /**
49
+ * THE canonical agent id: exactly what every session command acts as. Commands
50
+ * canonicalise through this (normalizeAgentId delegates here), so the identity that
51
+ * authorises and the identity that executes are computed by one function.
52
+ */
53
+ export function canonicalAgentId(value) {
54
+ return String(value ?? "")
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/[^a-z0-9._-]+/g, "-")
58
+ .replace(/^-+|-+$/g, "");
59
+ }
60
+
61
+ /**
62
+ * Refuse, before any I/O, an agent id whose canonical form is a DIFFERENT identity than
63
+ * its spelling (case folding aside): "a:b" would act as "a-b", a separate identity with
64
+ * its own key and credential. Two spellings are never silently aliased.
65
+ */
66
+ export function assertCanonicalAgentId(value, source) {
67
+ const raw = String(value ?? "").trim();
68
+ if (!raw) return;
69
+ const canonical = canonicalAgentId(raw);
70
+ if (canonical !== raw.toLowerCase()) {
71
+ throw new AdmissionCredentialRefused(
72
+ `${source} "${raw}" is not a canonical agent id: commands would act as "${canonical || "(none)"}", ` +
73
+ `a different identity. Use the canonical spelling.`,
74
+ { reason: "non_canonical_agent_id" }
75
+ );
76
+ }
77
+ }
78
+
79
+ // Options whose value decides WHICH identity, and so which credential, a command
80
+ // runs as. Given more than once they are ambiguous (Commander keeps the LAST value),
81
+ // so they are refused outright rather than guessed at.
82
+ const IDENTITY_OPTIONS = ["--agent", "--session", "--id", "--path"];
83
+
84
+ // Options end at a bare `--`; everything after it is an operand, as Commander reads it.
85
+ function optionTokens(args) {
86
+ const tokens = args.map((arg) => String(arg ?? ""));
87
+ const end = tokens.indexOf("--");
88
+ return end < 0 ? tokens : tokens.slice(0, end);
89
+ }
90
+
91
+ function optionValue(args, name) {
92
+ const tokens = optionTokens(args);
93
+ for (let i = 0; i < tokens.length; i += 1) {
94
+ const arg = tokens[i];
95
+ if (arg === name) return String(tokens[i + 1] || "").trim();
96
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1).trim();
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /** Refuse a `session` command that gives an identity-deciding option more than once. */
102
+ export function assertUnambiguousIdentityOptions(args = []) {
103
+ if (String(args[0] || "").trim().toLowerCase() !== "session") return;
104
+ const tokens = optionTokens(args);
105
+ for (const name of IDENTITY_OPTIONS) {
106
+ const count = tokens.filter((t) => t === name || t.startsWith(`${name}=`)).length;
107
+ if (count > 1) {
108
+ throw new AdmissionCredentialRefused(
109
+ `${name} is given ${count} times. It decides which identity and credential this ` +
110
+ `command runs as, so it may be given at most once.`,
111
+ { reason: "ambiguous_identity_option" }
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ // Who `--agent` names, per `session` subcommand ("group sub" for nested groups).
118
+ // actor -- the named agent performs the operation: it runs on THAT agent's
119
+ // admission (or refuses, if what is stored is a tombstone)
120
+ // target -- a human acts ON that agent (stop its listener, kill or unregister
121
+ // it): it stays on the human's own credential, so an owner can always
122
+ // recover from a misbehaving or expired agent
123
+ // control -- join: the human's control plane; it scopes its own post-claim steps
124
+ // A `session` subcommand missing from this table fails CLOSED when an admission
125
+ // is stored for its (session, agent): new commands must be classified.
126
+ export const SESSION_AGENT_PRINCIPAL = Object.freeze({
127
+ say: "actor",
128
+ "post-agent": "actor",
129
+ observe: "actor",
130
+ action: "actor",
131
+ react: "actor",
132
+ reply: "actor",
133
+ comment: "actor",
134
+ edit: "actor",
135
+ read: "actor",
136
+ listen: "actor",
137
+ pins: "actor",
138
+ recap: "actor",
139
+ now: "actor",
140
+ lock: "actor",
141
+ unlock: "actor",
142
+ renew: "actor",
143
+ guard: "actor",
144
+ "guard-hook": "actor",
145
+ "guard-install": "actor",
146
+ "codex-notify": "actor",
147
+ daemon: "actor",
148
+ "checkpoint create": "actor",
149
+ "checkpoint generate": "actor",
150
+ "ticket list": "actor",
151
+ "ticket claim": "actor",
152
+ "ticket renew": "actor",
153
+ "ticket release": "actor",
154
+ "ticket submit": "actor",
155
+ "ticket events": "actor",
156
+ kill: "target",
157
+ "stop-listener": "target",
158
+ leave: "target",
159
+ "guard-uninstall": "target",
160
+ join: "control",
161
+ });
162
+ const SESSION_COMMAND_GROUPS = new Set(["checkpoint", "ticket"]);
163
+
164
+ async function implicitAgent(sessionId, targetPath, env) {
165
+ // The same identity the command itself resolves when --agent is omitted
166
+ // (SENTINELAYER_AGENT_ID, then the sole joined local agent). Imported lazily:
167
+ // the commands module imports this one.
168
+ const { resolveSessionSayIdentity } = await import("../commands/session.js");
169
+ const identity = await resolveSessionSayIdentity({ sessionId, agentId: "", targetPath, env }).catch(() => null);
170
+ const agentId = String(identity?.agentId || "").trim();
171
+ return agentId && agentId !== "cli-user" ? agentId : "";
172
+ }
173
+
174
+ /**
175
+ * The (session, agent) a `session` command must run as, when this machine holds an
176
+ * admission for it -- live OR tombstoned -- else null. Used by BOTH the auth gate
177
+ * and the dispatch choke point in runCli, so the two can never disagree.
178
+ *
179
+ * Only ACTOR commands are scoped (see SESSION_AGENT_PRINCIPAL). The agent is
180
+ * --agent, else SENTINELAYER_AGENT_ID, else the implicit joined identity. A
181
+ * subcommand outside the table is scoped only if it NAMES an agent with --agent,
182
+ * and then it refuses (unclassified); otherwise it is a human command. The
183
+ * session is --session or any positional argument for which an admission is stored:
184
+ * a credential file is keyed by (session, agent), so a positional that is not a
185
+ * session can never match one. An unclassified subcommand with a stored admission
186
+ * throws: it is never silently run on the human token.
187
+ */
188
+ export async function resolveAgentAdmissionTarget(args = [], { env = process.env, homeDir } = {}) {
189
+ if (String(args[0] || "").trim().toLowerCase() !== "session") return null;
190
+ assertUnambiguousIdentityOptions(args);
191
+ const tokens = args.map((a) => String(a ?? "").trim());
192
+ const terminator = tokens.indexOf("--");
193
+ const positional = [
194
+ ...(terminator < 0 ? tokens : tokens.slice(0, terminator)).slice(1).filter((a) => a && !a.startsWith("-")),
195
+ ...(terminator < 0 ? [] : tokens.slice(terminator + 1).filter(Boolean)),
196
+ ];
197
+ if (!positional.length) return null;
198
+ // Every `session` subcommand, join included: an identity-changing spelling is refused
199
+ // before any lookup, dispatch or request.
200
+ assertCanonicalAgentId(optionValue(args, "--agent"), "--agent");
201
+ assertCanonicalAgentId(env.SENTINELAYER_AGENT_ID, "SENTINELAYER_AGENT_ID");
202
+ let command = positional[0].toLowerCase();
203
+ let rest = positional.slice(1);
204
+ if (SESSION_COMMAND_GROUPS.has(command) && rest.length) {
205
+ command = `${command} ${rest[0].toLowerCase()}`;
206
+ rest = rest.slice(1);
207
+ }
208
+ const principal = SESSION_AGENT_PRINCIPAL[command];
209
+ if (principal === "target" || principal === "control") return null;
210
+ const flagAgent = optionValue(args, "--agent");
211
+ // A subcommand outside the table that names NO agent is a human command (archive,
212
+ // members, ...): it is not agent-attributed, whatever SENTINELAYER_AGENT_ID says.
213
+ if (!principal && !flagAgent) return null;
214
+ const explicitAgent = flagAgent || String(env.SENTINELAYER_AGENT_ID || "").trim();
215
+ const candidates = [optionValue(args, "--session"), optionValue(args, "--id"), ...rest].filter(Boolean);
216
+ for (const sessionId of candidates) {
217
+ const targetPath = path.resolve(process.cwd(), optionValue(args, "--path") || ".");
218
+ const agentId = explicitAgent || (await implicitAgent(sessionId, targetPath, env));
219
+ if (!agentId) continue;
220
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
221
+ if (held.state === "none") continue;
222
+ if (!principal) {
223
+ throw new AdmissionCredentialRefused(
224
+ `\`sl session ${command}\` is not classified as acting as "${agentId}" or on it, and "${agentId}" ` +
225
+ `holds an admission in session ${sessionId}. Refusing rather than guessing which credential it should use.`,
226
+ { reason: "unclassified_command", sessionId, agentId }
227
+ );
228
+ }
229
+ return { sessionId, agentId };
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /**
235
+ * preAction guard: the identity Commander ACTUALLY resolved for this action must be
236
+ * the one whose admission scope (if any) the dispatch was wrapped in. Computed from
237
+ * Commander's own resolved options -- counting only values given on the command line,
238
+ * not defaults such as read's `--agent cli-user` -- so no argv reading of ours can
239
+ * disagree with execution. A mismatch refuses before the action runs.
240
+ */
241
+ export async function assertDispatchMatchesScope(actionCommand, { env = process.env, homeDir } = {}) {
242
+ const names = [];
243
+ for (let cmd = actionCommand; cmd && cmd.parent; cmd = cmd.parent) names.unshift(cmd.name());
244
+ if (String(names[0] || "").toLowerCase() !== "session") return;
245
+ const command = names.slice(1).join(" ").toLowerCase();
246
+ const principal = SESSION_AGENT_PRINCIPAL[command];
247
+ const opts = actionCommand.opts();
248
+ const given = (key) =>
249
+ actionCommand.getOptionValueSource?.(key) === "cli" ? String(opts[key] ?? "").trim() : "";
250
+ let actual = null;
251
+ if (principal === "actor" || (!principal && given("agent"))) {
252
+ const sessionId = given("session") || given("id") || String(actionCommand.processedArgs?.[0] ?? "").trim();
253
+ const targetPath = path.resolve(process.cwd(), String(opts.path || "."));
254
+ const agentId =
255
+ given("agent") ||
256
+ String(env.SENTINELAYER_AGENT_ID || "").trim() ||
257
+ (sessionId ? await implicitAgent(sessionId, targetPath, env) : "");
258
+ if (sessionId && agentId) {
259
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
260
+ if (held.state !== "none") actual = { sessionId, agentId };
261
+ }
262
+ }
263
+ const inForce = currentAdmittedAgent();
264
+ const key = (pair) => (pair ? `${String(pair.sessionId).toLowerCase()}/${String(pair.agentId).toLowerCase()}` : "none");
265
+ if (key(actual) !== key(inForce)) {
266
+ throw new AdmissionCredentialRefused(
267
+ `Refusing: \`sl session ${command}\` resolved to ${key(actual)} but was dispatched under ` +
268
+ `${key(inForce)}. Authorization and execution must name the same identity.`,
269
+ { reason: "scope_mismatch" }
270
+ );
271
+ }
272
+ }
273
+
274
+ /** Run `fn` with the right credential for (sessionId, agentId). See the module header. */
275
+ export async function withAgentAdmission(sessionId, agentId, fn, { homeDir, now } = {}) {
276
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir, now });
277
+ if (held.state === "none") return fn({ admitted: false });
278
+ if (held.state !== "live") {
279
+ const why = REFUSAL_HINT[held.reason] || `its stored admission is unusable (${held.reason})`;
280
+ throw new AdmissionCredentialRefused(
281
+ `Agent "${agentId}" cannot act in session ${sessionId}: ${why}. ` +
282
+ `It will not fall back to your own credentials. Request a new admission with ` +
283
+ `\`sl session join ${sessionId} --agent ${agentId} --goal "<what it is here to do>"\`.`,
284
+ { reason: held.reason, sessionId, agentId }
285
+ );
286
+ }
287
+ const credential = Object.freeze({ ...held.credential });
288
+ try {
289
+ return await admittedAgentScope.run(credential, () =>
290
+ fn({ admitted: true, admissionId: credential.admissionId, agentId: credential.agentId })
291
+ );
292
+ } catch (error) {
293
+ if (error instanceof Error && /\bapi_401\b|not_authenticated/.test(error.message)) {
294
+ error.message +=
295
+ ` The API no longer accepts agent "${agentId}"'s admission for this room (revoked, expired,` +
296
+ ` or outside its grant). It did not fall back to your own credentials.`;
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /** The admission this code is running under, without the token, or null. */
303
+ export function currentAdmittedAgent() {
304
+ const credential = admittedAgentScope.getStore();
305
+ if (!credential) return null;
306
+ return {
307
+ admissionId: credential.admissionId,
308
+ sessionId: credential.sessionId,
309
+ agentId: credential.agentId,
310
+ expiresAt: credential.expiresAt,
311
+ };
312
+ }
@@ -0,0 +1,90 @@
1
+ // admission-preimage.js — the bytes an agent signs to claim an admission.
2
+ //
3
+ // This MUST reproduce sentinelayer-api src/services/canonical_preimage.py byte for
4
+ // byte, or every claim signature fails to verify. The API SPEC ("Agent Passport
5
+ // Contract", preimage construction) is normative:
6
+ //
7
+ // ASCII(domain) || 0x00 || JCS( select(payload, fields) with every string NFC )
8
+ //
9
+ // It deliberately COMPOSES rather than re-implements. JSON encoding is the one JCS
10
+ // encoder this CLI already has (src/engram/canonical.js); the NFC step is applied
11
+ // here, BEFORE encoding, because that is where the API format puts it. The engram
12
+ // encoder must stay non-normalising (content addressing needs NFC != NFD), so the
13
+ // normalisation belongs to this format, not to the shared encoder.
14
+ //
15
+ // Rules copied from the Python, each a refusal rather than a coercion:
16
+ // - the domain is required and ASCII
17
+ // - fields is a non-empty SET: the caller's order is ignored
18
+ // - a named field absent from the payload is an error, never an omission
19
+ // - object keys are ASCII, so UTF-16 and code-point key order agree
20
+ // - no floats: integers must be safe integers (enforced by the encoder)
21
+ // - every key is an OWN data property, "__proto__" included. Objects built here
22
+ // have a null prototype, so assigning that key defines it rather than invoking
23
+ // the legacy prototype setter (which would silently drop it from the bytes).
24
+ //
25
+ // Conformance: tests/fixtures/admission/canonical-preimage-vectors-v1.json is
26
+ // generated FROM the Python implementation; tests/unit.admission-preimage.test.mjs
27
+ // asserts these bytes match every vector exactly.
28
+
29
+ import { canonicalize } from "../engram/canonical.js";
30
+
31
+ const ASCII = /^[\x00-\x7f]*$/;
32
+
33
+ export class CanonicalPreimageError extends TypeError {}
34
+
35
+ function nfcDeep(value, path) {
36
+ if (typeof value === "string") return value.normalize("NFC");
37
+ if (value === null || typeof value !== "object") return value;
38
+ if (Array.isArray(value)) return value.map((item, i) => nfcDeep(item, `${path}[${i}]`));
39
+ const out = Object.create(null);
40
+ for (const [key, item] of Object.entries(value)) {
41
+ if (!ASCII.test(key)) {
42
+ throw new CanonicalPreimageError(`object keys must be ASCII at ${path}: ${JSON.stringify(key)}`);
43
+ }
44
+ out[key] = nfcDeep(item, `${path}.${key}`);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** The exact bytes to sign or verify, as a Buffer. */
50
+ export function canonicalPreimage(payload, { domain, fields }) {
51
+ const normalizedDomain = String(domain ?? "").normalize("NFC").trim();
52
+ if (!normalizedDomain) throw new CanonicalPreimageError("domain is required");
53
+ if (!ASCII.test(normalizedDomain)) throw new CanonicalPreimageError("domain must be ASCII");
54
+ if (!Array.isArray(fields) || fields.length === 0) {
55
+ throw new CanonicalPreimageError(
56
+ "fields is required: an empty selection would sign nothing while appearing to sign a document"
57
+ );
58
+ }
59
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
60
+ throw new CanonicalPreimageError("payload must be an object");
61
+ }
62
+ const selected = Object.create(null);
63
+ const missing = [];
64
+ for (const name of fields) {
65
+ if (typeof name !== "string" || !ASCII.test(name)) {
66
+ throw new CanonicalPreimageError(`field names must be ASCII strings: ${JSON.stringify(name)}`);
67
+ }
68
+ if (!Object.prototype.hasOwnProperty.call(payload, name)) {
69
+ missing.push(name);
70
+ continue;
71
+ }
72
+ selected[name] = payload[name];
73
+ }
74
+ if (missing.length) {
75
+ throw new CanonicalPreimageError(
76
+ `fields absent from payload cannot be covered: ${missing.sort().join(", ")}`
77
+ );
78
+ }
79
+ let body;
80
+ try {
81
+ body = canonicalize(nfcDeep(selected, "$"));
82
+ } catch (error) {
83
+ throw new CanonicalPreimageError(error.message);
84
+ }
85
+ return Buffer.concat([
86
+ Buffer.from(normalizedDomain, "ascii"),
87
+ Buffer.from([0x00]),
88
+ Buffer.from(body, "utf8"),
89
+ ]);
90
+ }