sentinelayer-cli 0.41.0 → 0.43.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 (45) hide show
  1. package/README.md +55 -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 +779 -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-access.js +256 -0
  24. package/src/session/admission-auth.js +319 -0
  25. package/src/session/admission-preimage.js +90 -0
  26. package/src/session/admission.js +641 -0
  27. package/src/session/checkpoints.js +25 -1
  28. package/src/session/codebase-context.js +54 -0
  29. package/src/session/coordination-guidance.js +1 -1
  30. package/src/session/daemon-spawn.js +20 -2
  31. package/src/session/daemon.js +7 -1
  32. package/src/session/event-identity.js +9 -2
  33. package/src/session/first-message.js +11 -3
  34. package/src/session/listener.js +3 -0
  35. package/src/session/live-source.js +47 -16
  36. package/src/session/message-edits.js +89 -0
  37. package/src/session/recap.js +26 -4
  38. package/src/session/remote-hydrate.js +7 -19
  39. package/src/session/store.js +13 -14
  40. package/src/session/stream.js +23 -3
  41. package/src/session/sync.js +128 -11
  42. package/src/session/tickets.js +316 -0
  43. package/src/session/title-sync.js +2 -1
  44. package/src/session/usage-report.js +41 -2
  45. 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,256 @@
1
+ // Owner/delegator controls for the Agent Admission Contract.
2
+ //
3
+ // Agent-side key generation, approval polling, proof-of-possession claim, and
4
+ // credential storage remain in admission.js. This module is deliberately the
5
+ // human control plane only: inspect requests, approve/deny them, or revoke a
6
+ // live grant. Every mutation uses the same session CSRF + idempotency contract
7
+ // as the existing request/claim path.
8
+
9
+ import process from "node:process";
10
+
11
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
12
+ import { resolveActiveAuthSession } from "../auth/service.js";
13
+ import {
14
+ SESSION_MUTATION_ORIGIN,
15
+ createSessionMutationHeaders,
16
+ createSessionMutationIdempotencyKey,
17
+ } from "./invitations.js";
18
+
19
+ export const ADMISSION_STATUSES = Object.freeze([
20
+ "pending",
21
+ "approved",
22
+ "active",
23
+ "denied",
24
+ "cancelled",
25
+ "expired",
26
+ "revoked",
27
+ "stopped",
28
+ ]);
29
+
30
+ const ROUTES = Object.freeze({
31
+ decision: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/decision",
32
+ revoke: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/revoke",
33
+ mode: "POST /api/v1/sessions/{session_id}/admission-mode",
34
+ });
35
+
36
+ function normalizeString(value) {
37
+ return String(value ?? "").trim();
38
+ }
39
+
40
+ function required(value, label) {
41
+ const normalized = normalizeString(value);
42
+ if (!normalized) throw new Error(`${label} is required.`);
43
+ return normalized;
44
+ }
45
+
46
+ async function authContext(targetPath, resolveAuthSession) {
47
+ const auth = await resolveAuthSession({
48
+ cwd: targetPath,
49
+ env: process.env,
50
+ autoRotate: false,
51
+ });
52
+ if (!auth?.token || !auth?.apiUrl) {
53
+ throw new Error("Not authenticated. Run `sl auth login` first.");
54
+ }
55
+ return {
56
+ token: auth.token,
57
+ apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
58
+ };
59
+ }
60
+
61
+ function admissionsUrl(apiUrl, sessionId, suffix = "") {
62
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
63
+ }
64
+
65
+ function boundedIdempotencyKey(value, operation) {
66
+ const explicit = normalizeString(value);
67
+ if (explicit.length > 128) throw new Error("--idempotency-key must be at most 128 characters.");
68
+ return explicit || createSessionMutationIdempotencyKey(operation);
69
+ }
70
+
71
+ async function mutateAdmission(
72
+ sessionId,
73
+ suffix,
74
+ routeId,
75
+ body,
76
+ {
77
+ targetPath,
78
+ idempotencyKey,
79
+ operationName,
80
+ origin,
81
+ resolveAuthSession,
82
+ requestMutation,
83
+ },
84
+ ) {
85
+ const key = boundedIdempotencyKey(idempotencyKey, operationName);
86
+ const auth = await authContext(targetPath, resolveAuthSession);
87
+ const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
88
+ method: "POST",
89
+ operationName,
90
+ idempotencyKey: key,
91
+ headers: createSessionMutationHeaders({
92
+ bearerToken: auth.token,
93
+ sessionId,
94
+ routeId,
95
+ idempotencyKey: key,
96
+ origin,
97
+ }),
98
+ body,
99
+ });
100
+ return { idempotencyKey: key, result };
101
+ }
102
+
103
+ export async function listSessionAdmissions(
104
+ sessionId,
105
+ {
106
+ status = "",
107
+ targetPath = process.cwd(),
108
+ resolveAuthSession = resolveActiveAuthSession,
109
+ requestRead = requestJson,
110
+ } = {},
111
+ ) {
112
+ const sid = required(sessionId, "session id");
113
+ const normalizedStatus = normalizeString(status).toLowerCase();
114
+ if (normalizedStatus && !ADMISSION_STATUSES.includes(normalizedStatus)) {
115
+ throw new Error(
116
+ `--status must be one of: ${ADMISSION_STATUSES.join(", ")}.`,
117
+ );
118
+ }
119
+ const auth = await authContext(targetPath, resolveAuthSession);
120
+ const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
121
+ return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
122
+ method: "GET",
123
+ headers: { Authorization: `Bearer ${auth.token}` },
124
+ });
125
+ }
126
+
127
+ export async function getSessionAdmission(
128
+ sessionId,
129
+ admissionId,
130
+ {
131
+ targetPath = process.cwd(),
132
+ resolveAuthSession = resolveActiveAuthSession,
133
+ requestRead = requestJson,
134
+ } = {},
135
+ ) {
136
+ const sid = required(sessionId, "session id");
137
+ const aid = required(admissionId, "admission id");
138
+ const auth = await authContext(targetPath, resolveAuthSession);
139
+ return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
140
+ method: "GET",
141
+ headers: { Authorization: `Bearer ${auth.token}` },
142
+ });
143
+ }
144
+
145
+ export async function decideSessionAdmission(
146
+ sessionId,
147
+ admissionId,
148
+ {
149
+ decision,
150
+ grantedActions,
151
+ ttlSeconds,
152
+ note,
153
+ targetPath = process.cwd(),
154
+ idempotencyKey = "",
155
+ origin = SESSION_MUTATION_ORIGIN,
156
+ resolveAuthSession = resolveActiveAuthSession,
157
+ requestMutation = requestJsonMutation,
158
+ } = {},
159
+ ) {
160
+ const sid = required(sessionId, "session id");
161
+ const aid = required(admissionId, "admission id");
162
+ const normalizedDecision = normalizeString(decision).toLowerCase();
163
+ if (!["approve", "deny"].includes(normalizedDecision)) {
164
+ throw new Error("decision must be approve or deny.");
165
+ }
166
+ if (normalizedDecision === "deny" && (grantedActions !== undefined || ttlSeconds !== undefined)) {
167
+ throw new Error("A denied admission cannot include --scope or --ttl.");
168
+ }
169
+ const body = { decision: normalizedDecision };
170
+ if (grantedActions !== undefined) body.grantedActions = grantedActions;
171
+ if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
172
+ const normalizedNote = normalizeString(note);
173
+ if (normalizedNote) body.note = normalizedNote;
174
+ return mutateAdmission(
175
+ sid,
176
+ `/${encodeURIComponent(aid)}/decision`,
177
+ ROUTES.decision,
178
+ body,
179
+ {
180
+ targetPath,
181
+ idempotencyKey,
182
+ operationName: `session.admission_${normalizedDecision}`,
183
+ origin,
184
+ resolveAuthSession,
185
+ requestMutation,
186
+ },
187
+ );
188
+ }
189
+
190
+ export async function revokeSessionAdmission(
191
+ sessionId,
192
+ admissionId,
193
+ {
194
+ reason,
195
+ targetPath = process.cwd(),
196
+ idempotencyKey = "",
197
+ origin = SESSION_MUTATION_ORIGIN,
198
+ resolveAuthSession = resolveActiveAuthSession,
199
+ requestMutation = requestJsonMutation,
200
+ } = {},
201
+ ) {
202
+ const sid = required(sessionId, "session id");
203
+ const aid = required(admissionId, "admission id");
204
+ const normalizedReason = normalizeString(reason);
205
+ return mutateAdmission(
206
+ sid,
207
+ `/${encodeURIComponent(aid)}/revoke`,
208
+ ROUTES.revoke,
209
+ normalizedReason ? { reason: normalizedReason } : {},
210
+ {
211
+ targetPath,
212
+ idempotencyKey,
213
+ operationName: "session.admission_revoke",
214
+ origin,
215
+ resolveAuthSession,
216
+ requestMutation,
217
+ },
218
+ );
219
+ }
220
+
221
+ export async function setSessionAdmissionMode(
222
+ sessionId,
223
+ mode,
224
+ {
225
+ targetPath = process.cwd(),
226
+ idempotencyKey = "",
227
+ origin = SESSION_MUTATION_ORIGIN,
228
+ resolveAuthSession = resolveActiveAuthSession,
229
+ requestMutation = requestJsonMutation,
230
+ } = {},
231
+ ) {
232
+ const sid = required(sessionId, "session id");
233
+ const normalizedMode = normalizeString(mode).toLowerCase();
234
+ if (!["legacy", "required"].includes(normalizedMode)) {
235
+ throw new Error("mode must be legacy or required.");
236
+ }
237
+ const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
238
+ const auth = await authContext(targetPath, resolveAuthSession);
239
+ const result = await requestMutation(
240
+ `${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
241
+ {
242
+ method: "POST",
243
+ operationName: "session.admission_mode",
244
+ idempotencyKey: key,
245
+ headers: createSessionMutationHeaders({
246
+ bearerToken: auth.token,
247
+ sessionId: sid,
248
+ routeId: ROUTES.mode,
249
+ idempotencyKey: key,
250
+ origin,
251
+ }),
252
+ body: { mode: normalizedMode },
253
+ },
254
+ );
255
+ return { idempotencyKey: key, result };
256
+ }