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,319 @@
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
+ "access mode": "control",
157
+ "access request": "control",
158
+ "access list": "control",
159
+ "access status": "control",
160
+ "access approve": "control",
161
+ "access deny": "control",
162
+ "access revoke": "control",
163
+ kill: "target",
164
+ "stop-listener": "target",
165
+ leave: "target",
166
+ "guard-uninstall": "target",
167
+ join: "control",
168
+ });
169
+ const SESSION_COMMAND_GROUPS = new Set(["access", "checkpoint", "ticket"]);
170
+
171
+ async function implicitAgent(sessionId, targetPath, env) {
172
+ // The same identity the command itself resolves when --agent is omitted
173
+ // (SENTINELAYER_AGENT_ID, then the sole joined local agent). Imported lazily:
174
+ // the commands module imports this one.
175
+ const { resolveSessionSayIdentity } = await import("../commands/session.js");
176
+ const identity = await resolveSessionSayIdentity({ sessionId, agentId: "", targetPath, env }).catch(() => null);
177
+ const agentId = String(identity?.agentId || "").trim();
178
+ return agentId && agentId !== "cli-user" ? agentId : "";
179
+ }
180
+
181
+ /**
182
+ * The (session, agent) a `session` command must run as, when this machine holds an
183
+ * admission for it -- live OR tombstoned -- else null. Used by BOTH the auth gate
184
+ * and the dispatch choke point in runCli, so the two can never disagree.
185
+ *
186
+ * Only ACTOR commands are scoped (see SESSION_AGENT_PRINCIPAL). The agent is
187
+ * --agent, else SENTINELAYER_AGENT_ID, else the implicit joined identity. A
188
+ * subcommand outside the table is scoped only if it NAMES an agent with --agent,
189
+ * and then it refuses (unclassified); otherwise it is a human command. The
190
+ * session is --session or any positional argument for which an admission is stored:
191
+ * a credential file is keyed by (session, agent), so a positional that is not a
192
+ * session can never match one. An unclassified subcommand with a stored admission
193
+ * throws: it is never silently run on the human token.
194
+ */
195
+ export async function resolveAgentAdmissionTarget(args = [], { env = process.env, homeDir } = {}) {
196
+ if (String(args[0] || "").trim().toLowerCase() !== "session") return null;
197
+ assertUnambiguousIdentityOptions(args);
198
+ const tokens = args.map((a) => String(a ?? "").trim());
199
+ const terminator = tokens.indexOf("--");
200
+ const positional = [
201
+ ...(terminator < 0 ? tokens : tokens.slice(0, terminator)).slice(1).filter((a) => a && !a.startsWith("-")),
202
+ ...(terminator < 0 ? [] : tokens.slice(terminator + 1).filter(Boolean)),
203
+ ];
204
+ if (!positional.length) return null;
205
+ // Every `session` subcommand, join included: an identity-changing spelling is refused
206
+ // before any lookup, dispatch or request.
207
+ assertCanonicalAgentId(optionValue(args, "--agent"), "--agent");
208
+ assertCanonicalAgentId(env.SENTINELAYER_AGENT_ID, "SENTINELAYER_AGENT_ID");
209
+ let command = positional[0].toLowerCase();
210
+ let rest = positional.slice(1);
211
+ if (SESSION_COMMAND_GROUPS.has(command) && rest.length) {
212
+ command = `${command} ${rest[0].toLowerCase()}`;
213
+ rest = rest.slice(1);
214
+ }
215
+ const principal = SESSION_AGENT_PRINCIPAL[command];
216
+ if (principal === "target" || principal === "control") return null;
217
+ const flagAgent = optionValue(args, "--agent");
218
+ // A subcommand outside the table that names NO agent is a human command (archive,
219
+ // members, ...): it is not agent-attributed, whatever SENTINELAYER_AGENT_ID says.
220
+ if (!principal && !flagAgent) return null;
221
+ const explicitAgent = flagAgent || String(env.SENTINELAYER_AGENT_ID || "").trim();
222
+ const candidates = [optionValue(args, "--session"), optionValue(args, "--id"), ...rest].filter(Boolean);
223
+ for (const sessionId of candidates) {
224
+ const targetPath = path.resolve(process.cwd(), optionValue(args, "--path") || ".");
225
+ const agentId = explicitAgent || (await implicitAgent(sessionId, targetPath, env));
226
+ if (!agentId) continue;
227
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
228
+ if (held.state === "none") continue;
229
+ if (!principal) {
230
+ throw new AdmissionCredentialRefused(
231
+ `\`sl session ${command}\` is not classified as acting as "${agentId}" or on it, and "${agentId}" ` +
232
+ `holds an admission in session ${sessionId}. Refusing rather than guessing which credential it should use.`,
233
+ { reason: "unclassified_command", sessionId, agentId }
234
+ );
235
+ }
236
+ return { sessionId, agentId };
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * preAction guard: the identity Commander ACTUALLY resolved for this action must be
243
+ * the one whose admission scope (if any) the dispatch was wrapped in. Computed from
244
+ * Commander's own resolved options -- counting only values given on the command line,
245
+ * not defaults such as read's `--agent cli-user` -- so no argv reading of ours can
246
+ * disagree with execution. A mismatch refuses before the action runs.
247
+ */
248
+ export async function assertDispatchMatchesScope(actionCommand, { env = process.env, homeDir } = {}) {
249
+ const names = [];
250
+ for (let cmd = actionCommand; cmd && cmd.parent; cmd = cmd.parent) names.unshift(cmd.name());
251
+ if (String(names[0] || "").toLowerCase() !== "session") return;
252
+ const command = names.slice(1).join(" ").toLowerCase();
253
+ const principal = SESSION_AGENT_PRINCIPAL[command];
254
+ const opts = actionCommand.opts();
255
+ const given = (key) =>
256
+ actionCommand.getOptionValueSource?.(key) === "cli" ? String(opts[key] ?? "").trim() : "";
257
+ let actual = null;
258
+ if (principal === "actor" || (!principal && given("agent"))) {
259
+ const sessionId = given("session") || given("id") || String(actionCommand.processedArgs?.[0] ?? "").trim();
260
+ const targetPath = path.resolve(process.cwd(), String(opts.path || "."));
261
+ const agentId =
262
+ given("agent") ||
263
+ String(env.SENTINELAYER_AGENT_ID || "").trim() ||
264
+ (sessionId ? await implicitAgent(sessionId, targetPath, env) : "");
265
+ if (sessionId && agentId) {
266
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir }).catch(() => ({ state: "none" }));
267
+ if (held.state !== "none") actual = { sessionId, agentId };
268
+ }
269
+ }
270
+ const inForce = currentAdmittedAgent();
271
+ const key = (pair) => (pair ? `${String(pair.sessionId).toLowerCase()}/${String(pair.agentId).toLowerCase()}` : "none");
272
+ if (key(actual) !== key(inForce)) {
273
+ throw new AdmissionCredentialRefused(
274
+ `Refusing: \`sl session ${command}\` resolved to ${key(actual)} but was dispatched under ` +
275
+ `${key(inForce)}. Authorization and execution must name the same identity.`,
276
+ { reason: "scope_mismatch" }
277
+ );
278
+ }
279
+ }
280
+
281
+ /** Run `fn` with the right credential for (sessionId, agentId). See the module header. */
282
+ export async function withAgentAdmission(sessionId, agentId, fn, { homeDir, now } = {}) {
283
+ const held = await readAdmissionCredentialState(sessionId, agentId, { homeDir, now });
284
+ if (held.state === "none") return fn({ admitted: false });
285
+ if (held.state !== "live") {
286
+ const why = REFUSAL_HINT[held.reason] || `its stored admission is unusable (${held.reason})`;
287
+ throw new AdmissionCredentialRefused(
288
+ `Agent "${agentId}" cannot act in session ${sessionId}: ${why}. ` +
289
+ `It will not fall back to your own credentials. Request a new admission with ` +
290
+ `\`sl session join ${sessionId} --agent ${agentId} --goal "<what it is here to do>"\`.`,
291
+ { reason: held.reason, sessionId, agentId }
292
+ );
293
+ }
294
+ const credential = Object.freeze({ ...held.credential });
295
+ try {
296
+ return await admittedAgentScope.run(credential, () =>
297
+ fn({ admitted: true, admissionId: credential.admissionId, agentId: credential.agentId })
298
+ );
299
+ } catch (error) {
300
+ if (error instanceof Error && /\bapi_401\b|not_authenticated/.test(error.message)) {
301
+ error.message +=
302
+ ` The API no longer accepts agent "${agentId}"'s admission for this room (revoked, expired,` +
303
+ ` or outside its grant). It did not fall back to your own credentials.`;
304
+ }
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ /** The admission this code is running under, without the token, or null. */
310
+ export function currentAdmittedAgent() {
311
+ const credential = admittedAgentScope.getStore();
312
+ if (!credential) return null;
313
+ return {
314
+ admissionId: credential.admissionId,
315
+ sessionId: credential.sessionId,
316
+ agentId: credential.agentId,
317
+ expiresAt: credential.expiresAt,
318
+ };
319
+ }
@@ -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
+ }