sentinelayer-cli 0.42.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.
package/README.md CHANGED
@@ -163,6 +163,43 @@ sl session join <session-id> --agent <agent-name>
163
163
  sl session action <session-id> working_on --target-sequence <n>
164
164
  ```
165
165
 
166
+ For a room that requires agent admission, the agent declares its goal, requested
167
+ actions, and TTL. The CLI creates an agent-held Ed25519 identity, waits for a
168
+ human decision, then waits for the minted AIdenID email and verified signed
169
+ purpose receipt. Only after that evidence is ready does it claim the approved
170
+ grant with proof of possession and join. The private key and scoped credential
171
+ are stored locally and are never printed:
172
+
173
+ ```bash
174
+ # Agent: request and wait for the owner's decision (maximum grant TTL is 24h).
175
+ sl session join <session-id> --agent review-agent \
176
+ --goal "Review the release and report findings" \
177
+ --scope session.read,session.post --ttl 24h
178
+
179
+ # Or request without waiting, for an asynchronous handoff.
180
+ sl session access request <session-id> --agent review-agent \
181
+ --goal "Review the release and report findings" \
182
+ --scope session.read,session.post --ttl 24h --json
183
+
184
+ # Owner/admin: inspect the untrusted goal claim, then approve, narrow, or deny.
185
+ sl session access mode <session-id> required
186
+ sl session access list <session-id> --status pending
187
+ sl session access approve <session-id> <admission-id> \
188
+ --scope session.read,session.post --ttl 24h
189
+ # sl session access deny <session-id> <admission-id> --note "reason"
190
+ # sl session access revoke <session-id> <admission-id> --reason "work complete"
191
+ ```
192
+
193
+ Approval queues the AIdenID email and purpose-receipt work. `session access
194
+ list --json` reports the real `email`, `purpose`, and Jev status; it does not
195
+ invent successful issuance while those jobs are pending or unavailable, and
196
+ the API withholds the claim challenge until both identity artifacts are ready.
197
+ The agent must re-run `session join` with the same goal, scope, and TTL after an
198
+ asynchronous request so it can claim and verify its live session-bound receipt.
199
+ New rooms default to fail-closed `required` mode. Existing rooms retain their
200
+ stored `legacy` mode until an owner explicitly sets `required`; the mode command
201
+ is the activation step for those pre-existing rooms.
202
+
166
203
  Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
167
204
 
168
205
  - session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -98,6 +98,13 @@ import {
98
98
  parseTtlSeconds as parseAdmissionTtlSeconds,
99
99
  runAdmissionJoin,
100
100
  } from "../session/admission.js";
101
+ import {
102
+ decideSessionAdmission,
103
+ getSessionAdmission,
104
+ listSessionAdmissions,
105
+ revokeSessionAdmission,
106
+ setSessionAdmissionMode,
107
+ } from "../session/admission-access.js";
101
108
  import {
102
109
  assertCanonicalAgentId,
103
110
  canonicalAgentId,
@@ -3488,17 +3495,24 @@ export function registerSessionCommand(program) {
3488
3495
  wait: options.wait !== false,
3489
3496
  waitTimeoutMs: (Number.isFinite(waitSeconds) && waitSeconds >= 0 ? waitSeconds : 900) * 1000,
3490
3497
  targetPath,
3491
- onPending: ({ approveUrl }) => {
3498
+ onPending: ({ approveUrl, phase }) => {
3492
3499
  // stderr, so --json stdout stays a single parseable document.
3493
- const line = `Waiting for a room owner to approve this agent: ${approveUrl}`;
3500
+ const line = phase === "identity"
3501
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
3502
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
3494
3503
  if (emitJson) process.stderr.write(`${line}\n`);
3495
- else console.log(pc.yellow(line));
3504
+ // Keep the line containing a remote URL free of all terminal control
3505
+ // bytes. `terminalText()` strips controls from the value itself; adding
3506
+ // local ANSI colour around it would make that guarantee untestable and
3507
+ // lets OSC-looking input hide inside a coloured terminal span.
3508
+ else if (phase === "identity") console.log(pc.yellow(line));
3509
+ else console.log(line);
3496
3510
  },
3497
3511
  });
3498
3512
  if (admission.status !== "active") {
3499
3513
  // Only an ACTIVE admission joins. Pending, denied, cancelled or expired never
3500
3514
  // registers the agent or materializes the room for it.
3501
- process.exitCode = admission.status === "pending" ? 3 : 4;
3515
+ process.exitCode = ["pending", "approved"].includes(admission.status) ? 3 : 4;
3502
3516
  const payload = {
3503
3517
  command: "session join",
3504
3518
  joined: false,
@@ -3510,9 +3524,12 @@ export function registerSessionCommand(program) {
3510
3524
  if (emitJson) {
3511
3525
  console.log(JSON.stringify(payload, null, 2));
3512
3526
  } else if (admission.status === "pending") {
3513
- console.log(pc.yellow(`Admission pending. A room owner approves it here: ${admission.approveUrl || "(see the session page)"}`));
3527
+ const approval = terminalText(admission.approveUrl, 1024) || "(see the session page)";
3528
+ console.log(`Admission pending. A room owner approves it here: ${approval}`);
3529
+ } else if (admission.status === "approved") {
3530
+ console.log(pc.yellow("Admission approved; AIdenID identity evidence is still provisioning. The agent was not joined."));
3514
3531
  } else {
3515
- console.log(pc.red(`Admission ${admission.status}. The agent was not joined.`));
3532
+ console.log(pc.red(`Admission ${terminalText(admission.status, 16) || "unknown"}. The agent was not joined.`));
3516
3533
  }
3517
3534
  return;
3518
3535
  }
@@ -6787,6 +6804,285 @@ export function registerSessionCommand(program) {
6787
6804
  }
6788
6805
  });
6789
6806
 
6807
+ // ------------------------------------------------------- agent access
6808
+ // Human control plane for the Agent Admission Contract. The request command
6809
+ // delegates to the established agent-side flow; list/decision/revoke use the
6810
+ // matching owner-only REST routes. Goal text is an agent claim and is always
6811
+ // rendered as bounded, untrusted data.
6812
+ const access = session
6813
+ .command("access")
6814
+ .description("Request and govern AIdenID-backed, scoped agent access to a session");
6815
+
6816
+ function accessOutput(options, command, payload, render) {
6817
+ if (shouldEmitJson(options, command)) {
6818
+ console.log(JSON.stringify(payload, null, 2));
6819
+ return;
6820
+ }
6821
+ render();
6822
+ }
6823
+
6824
+ function terminalText(value, maxLength = 240) {
6825
+ let result = "";
6826
+ for (const ch of normalizeString(value).normalize("NFC")) {
6827
+ // Refuse terminal control/invisible formatting from remote values,
6828
+ // including ANSI/OSC initiators and bidi overrides/isolates.
6829
+ result += /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u.test(ch) ? " " : ch;
6830
+ if (result.length >= maxLength) break;
6831
+ }
6832
+ return result.replace(/\s+/gu, " ").trim();
6833
+ }
6834
+
6835
+ function renderAdmissionLine(admission) {
6836
+ const requested = Array.isArray(admission?.requestedScope?.actions)
6837
+ ? terminalText(admission.requestedScope.actions.join(","), 180)
6838
+ : "scope unavailable";
6839
+ const ttl = Number(admission?.requestedScope?.ttlSeconds);
6840
+ const duration = Number.isFinite(ttl) ? ` for ${ttl}s` : "";
6841
+ const identity = terminalText(admission?.displayName, 120) || terminalText(admission?.agentId, 64) || "unknown-agent";
6842
+ console.log(`${terminalText(admission?.admissionId, 64) || "unknown"} ${terminalText(admission?.status, 16) || "unknown"} ${identity}`);
6843
+ console.log(pc.gray(` requested: ${requested}${duration}`));
6844
+ const summary = terminalText(admission?.goal?.summary);
6845
+ if (summary) console.log(pc.gray(` claimed goal (untrusted): ${JSON.stringify(summary)}`));
6846
+ if (admission?.email) {
6847
+ // Human terminal output is routinely copied into logs and support
6848
+ // transcripts. Keep the address in the explicit --json response only.
6849
+ const inboxStatus = terminalText(admission.email.status, 32) || "unknown";
6850
+ console.log(pc.gray(` AIdenID inbox: ${inboxStatus}`));
6851
+ }
6852
+ }
6853
+
6854
+ access
6855
+ .command("mode <sessionId> <mode>")
6856
+ .description("Set whether this room requires admission for agent-attributed access (required or legacy)")
6857
+ .option("--idempotency-key <key>", "Retry the same logical mode change safely")
6858
+ .option("--path <path>", "Workspace path for auth context", ".")
6859
+ .option("--json", "Emit machine-readable output")
6860
+ .action(async (sessionId, mode, options, command) => {
6861
+ const sid = normalizeString(sessionId);
6862
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6863
+ const changed = await setSessionAdmissionMode(sid, mode, {
6864
+ idempotencyKey: options.idempotencyKey,
6865
+ targetPath,
6866
+ });
6867
+ accessOutput(
6868
+ options,
6869
+ command,
6870
+ {
6871
+ command: "session access mode",
6872
+ idempotencyKey: changed.idempotencyKey,
6873
+ ...changed.result,
6874
+ },
6875
+ () => console.log(
6876
+ `Agent admission mode for ${terminalText(sid, 64)}: ${terminalText(changed.result.agentAdmissionMode, 16) || "unknown"}.`,
6877
+ ),
6878
+ );
6879
+ });
6880
+
6881
+ access
6882
+ .command("request <sessionId>")
6883
+ .description("Request agent access; a human must approve before the agent can claim or join")
6884
+ .requiredOption("--agent <id>", "Canonical agent id requesting access")
6885
+ .requiredOption("--goal <text>", "What the agent is coming to do")
6886
+ .option("--display-name <name>", "Human-readable agent name")
6887
+ .option("--deliverable <text>", "An output this agent will produce (repeatable)", collectOption, [])
6888
+ .option("--stop-when <text>", "A condition that ends this agent's work (repeatable)", collectOption, [])
6889
+ .option(
6890
+ "--scope <actions>",
6891
+ "Comma-separated actions: session.read, session.post, session.react, tickets.read, tickets.work",
6892
+ )
6893
+ .option("--ttl <duration>", "Requested grant lifetime, e.g. 90m or 24h (5m..24h)", "2h")
6894
+ .option("--model <model>", "Agent model hint", "cli")
6895
+ .option("--provider <name>", "Model provider hint")
6896
+ .option("--wait", "Wait for approval and claim the grant on this machine")
6897
+ .option("--wait-seconds <n>", "Maximum approval wait when --wait is set", "900")
6898
+ .option("--path <path>", "Workspace path for local admission state", ".")
6899
+ .option("--json", "Emit machine-readable output")
6900
+ .action(async (sessionId, options, command) => {
6901
+ const sid = normalizeString(sessionId);
6902
+ const agentId = canonicalAgentId(options.agent);
6903
+ assertCanonicalAgentId(options.agent, "--agent");
6904
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6905
+ const waitSeconds = Number.parseInt(String(options.waitSeconds ?? "900"), 10);
6906
+ if (!Number.isFinite(waitSeconds) || waitSeconds < 0) {
6907
+ throw new Error("--wait-seconds must be a non-negative integer.");
6908
+ }
6909
+ const admission = await runAdmissionJoin(sid, {
6910
+ agentId,
6911
+ displayName: options.displayName,
6912
+ model: options.model,
6913
+ provider: options.provider,
6914
+ goal: options.goal,
6915
+ deliverables: options.deliverable || [],
6916
+ stopConditions: options.stopWhen || [],
6917
+ actions: parseAdmissionScope(options.scope),
6918
+ ttlSeconds: parseAdmissionTtlSeconds(options.ttl),
6919
+ wait: Boolean(options.wait),
6920
+ waitTimeoutMs: waitSeconds * 1000,
6921
+ targetPath,
6922
+ onPending: ({ approveUrl, phase }) => {
6923
+ const line = phase === "identity"
6924
+ ? "Approval received; waiting for the AIdenID email and signed purpose receipt."
6925
+ : `Waiting for a room owner to approve this agent: ${terminalText(approveUrl, 1024)}`;
6926
+ if (shouldEmitJson(options, command)) process.stderr.write(`${line}\n`);
6927
+ // A line carrying a remote URL must remain free of every terminal
6928
+ // control byte, including trusted colour wrappers. This mirrors the
6929
+ // guarded `session join` path and keeps raw support logs auditable.
6930
+ else if (phase === "identity") console.log(pc.yellow(line));
6931
+ else console.log(line);
6932
+ },
6933
+ });
6934
+ const payload = {
6935
+ command: "session access request",
6936
+ sessionId: sid,
6937
+ agentId,
6938
+ admission,
6939
+ readyToJoin: admission.status === "active",
6940
+ };
6941
+ accessOutput(options, command, payload, () => {
6942
+ if (admission.status === "pending") {
6943
+ console.log(`Access requested for ${agentId}; waiting for a room owner.`);
6944
+ if (admission.approveUrl) console.log(`Approval: ${terminalText(admission.approveUrl, 1024)}`);
6945
+ } else if (admission.status === "active") {
6946
+ console.log(`Access active for ${agentId}. The scoped credential is stored locally and was not printed.`);
6947
+ console.log(pc.gray("Run `sl session join` again with the same goal, scope, and TTL to attach this agent."));
6948
+ } else if (admission.status === "approved") {
6949
+ console.log("Access approved; waiting for the AIdenID inbox and signed purpose receipt before claim.");
6950
+ } else {
6951
+ console.log(`Access ${terminalText(admission.status, 16) || "unknown"} for ${agentId}.`);
6952
+ }
6953
+ });
6954
+ });
6955
+
6956
+ access
6957
+ .command("list <sessionId>")
6958
+ .description("List admission requests visible to a room owner or admin")
6959
+ .option("--status <status>", "Filter by admission status")
6960
+ .option("--path <path>", "Workspace path for auth context", ".")
6961
+ .option("--json", "Emit machine-readable output")
6962
+ .action(async (sessionId, options, command) => {
6963
+ const sid = normalizeString(sessionId);
6964
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6965
+ const result = await listSessionAdmissions(sid, {
6966
+ status: options.status,
6967
+ targetPath,
6968
+ });
6969
+ const payload = { command: "session access list", ...result };
6970
+ accessOutput(options, command, payload, () => {
6971
+ console.log(pc.gray(`Admission mode: ${terminalText(result.agentAdmissionMode, 16) || "unknown"}`));
6972
+ if (!Array.isArray(result.admissions) || result.admissions.length === 0) {
6973
+ console.log("No admission requests.");
6974
+ return;
6975
+ }
6976
+ for (const admission of result.admissions) renderAdmissionLine(admission);
6977
+ });
6978
+ });
6979
+
6980
+ access
6981
+ .command("status <sessionId> <admissionId>")
6982
+ .description("Show the requesting delegator's current admission status")
6983
+ .option("--path <path>", "Workspace path for auth context", ".")
6984
+ .option("--json", "Emit machine-readable output")
6985
+ .action(async (sessionId, admissionId, options, command) => {
6986
+ const sid = normalizeString(sessionId);
6987
+ const aid = normalizeString(admissionId);
6988
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
6989
+ const result = await getSessionAdmission(sid, aid, { targetPath });
6990
+ accessOutput(
6991
+ options,
6992
+ command,
6993
+ { command: "session access status", sessionId: sid, ...result },
6994
+ () => {
6995
+ console.log(`${terminalText(result.admissionId, 64) || aid} ${terminalText(result.status, 16) || "unknown"} ${terminalText(result.agentId, 64) || "unknown-agent"}`);
6996
+ if (result.approveUrl) console.log(`Approval: ${terminalText(result.approveUrl, 1024)}`);
6997
+ },
6998
+ );
6999
+ });
7000
+
7001
+ access
7002
+ .command("approve <sessionId> <admissionId>")
7003
+ .description("Approve a pending request, optionally narrowing its scope or TTL")
7004
+ .option("--scope <actions>", "Comma-separated granted actions; must be a subset of the request")
7005
+ .option("--ttl <duration>", "Granted lifetime; may only shorten the request (5m..24h)")
7006
+ .option("--note <text>", "Approval note (max 500 characters)")
7007
+ .option("--idempotency-key <key>", "Retry the same logical approval safely")
7008
+ .option("--path <path>", "Workspace path for auth context", ".")
7009
+ .option("--json", "Emit machine-readable output")
7010
+ .action(async (sessionId, admissionId, options, command) => {
7011
+ const sid = normalizeString(sessionId);
7012
+ const aid = normalizeString(admissionId);
7013
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7014
+ const decision = await decideSessionAdmission(sid, aid, {
7015
+ decision: "approve",
7016
+ grantedActions: options.scope === undefined ? undefined : parseAdmissionScope(options.scope),
7017
+ ttlSeconds: options.ttl === undefined ? undefined : parseAdmissionTtlSeconds(options.ttl),
7018
+ note: options.note,
7019
+ idempotencyKey: options.idempotencyKey,
7020
+ targetPath,
7021
+ });
7022
+ const result = decision.result;
7023
+ accessOutput(
7024
+ options,
7025
+ command,
7026
+ { command: "session access approve", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: result },
7027
+ () => {
7028
+ console.log(`Approved ${terminalText(result.admissionId, 64) || aid} for ${terminalText(result.agentId, 64) || "the agent"}.`);
7029
+ if (result.email) {
7030
+ const inboxStatus = terminalText(result.email.status, 32) || "queued";
7031
+ console.log(pc.gray(`AIdenID inbox: ${inboxStatus}. Use --json to inspect its address.`));
7032
+ }
7033
+ },
7034
+ );
7035
+ });
7036
+
7037
+ access
7038
+ .command("deny <sessionId> <admissionId>")
7039
+ .description("Deny a pending agent admission request")
7040
+ .option("--note <text>", "Denial note (max 500 characters)")
7041
+ .option("--idempotency-key <key>", "Retry the same logical denial safely")
7042
+ .option("--path <path>", "Workspace path for auth context", ".")
7043
+ .option("--json", "Emit machine-readable output")
7044
+ .action(async (sessionId, admissionId, options, command) => {
7045
+ const sid = normalizeString(sessionId);
7046
+ const aid = normalizeString(admissionId);
7047
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7048
+ const decision = await decideSessionAdmission(sid, aid, {
7049
+ decision: "deny",
7050
+ note: options.note,
7051
+ idempotencyKey: options.idempotencyKey,
7052
+ targetPath,
7053
+ });
7054
+ accessOutput(
7055
+ options,
7056
+ command,
7057
+ { command: "session access deny", sessionId: sid, idempotencyKey: decision.idempotencyKey, admission: decision.result },
7058
+ () => console.log(`Denied ${terminalText(decision.result.admissionId, 64) || aid}.`),
7059
+ );
7060
+ });
7061
+
7062
+ access
7063
+ .command("revoke <sessionId> <admissionId>")
7064
+ .description("Revoke an approved or active agent admission")
7065
+ .option("--reason <text>", "Revocation reason (max 200 characters)")
7066
+ .option("--idempotency-key <key>", "Retry the same logical revocation safely")
7067
+ .option("--path <path>", "Workspace path for auth context", ".")
7068
+ .option("--json", "Emit machine-readable output")
7069
+ .action(async (sessionId, admissionId, options, command) => {
7070
+ const sid = normalizeString(sessionId);
7071
+ const aid = normalizeString(admissionId);
7072
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
7073
+ const revoked = await revokeSessionAdmission(sid, aid, {
7074
+ reason: options.reason,
7075
+ idempotencyKey: options.idempotencyKey,
7076
+ targetPath,
7077
+ });
7078
+ accessOutput(
7079
+ options,
7080
+ command,
7081
+ { command: "session access revoke", sessionId: sid, idempotencyKey: revoked.idempotencyKey, admission: revoked.result },
7082
+ () => console.log(`Revoked ${terminalText(revoked.result.admissionId, 64) || aid}.`),
7083
+ );
7084
+ });
7085
+
6790
7086
  // ---------------------------------------------------------------- tickets
6791
7087
  // The room's shared backlog (API "Ticket Lease Contract (T2)"). Every subcommand is
6792
7088
  // an ACTOR command: the runCli choke point runs it on the agent's admission.
@@ -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
+ }
@@ -153,13 +153,20 @@ export const SESSION_AGENT_PRINCIPAL = Object.freeze({
153
153
  "ticket release": "actor",
154
154
  "ticket submit": "actor",
155
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",
156
163
  kill: "target",
157
164
  "stop-listener": "target",
158
165
  leave: "target",
159
166
  "guard-uninstall": "target",
160
167
  join: "control",
161
168
  });
162
- const SESSION_COMMAND_GROUPS = new Set(["checkpoint", "ticket"]);
169
+ const SESSION_COMMAND_GROUPS = new Set(["access", "checkpoint", "ticket"]);
163
170
 
164
171
  async function implicitAgent(sessionId, targetPath, env) {
165
172
  // The same identity the command itself resolves when --agent is omitted
@@ -471,12 +471,33 @@ export async function runAdmissionJoin(
471
471
  headers: { Authorization: `Bearer ${auth.token}` },
472
472
  });
473
473
  state.status = polled.status;
474
- if (polled.status === "approved" || TERMINAL.has(polled.status) || polled.status === "active") break;
475
- onPending({ admissionId: state.admissionId, approveUrl: polled.approveUrl || state.approveUrl });
474
+ const claimReady =
475
+ polled.status === "approved" &&
476
+ polled.identityReady === true &&
477
+ polled?.claim?.domain === CLAIM_DOMAIN &&
478
+ polled?.claim?.preimage;
479
+ if (polled.status === "approved" && polled.identityReady === true && !claimReady) {
480
+ throw new Error("Identity-ready admission did not include a valid claim challenge.");
481
+ }
482
+ if (polled.status === "approved" && polled.identityReady !== true && polled?.claim) {
483
+ throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
484
+ }
485
+ if (claimReady || TERMINAL.has(polled.status) || polled.status === "active") break;
486
+ const phase = polled.status === "approved" ? "identity" : "approval";
487
+ onPending({
488
+ admissionId: state.admissionId,
489
+ approveUrl: polled.approveUrl || state.approveUrl,
490
+ phase,
491
+ identityReady: polled.identityReady === true,
492
+ identity: polled.identity || null,
493
+ });
476
494
  if (now() >= deadline) {
477
495
  await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
478
496
  return {
479
- status: "pending",
497
+ status: polled.status === "approved" ? "approved" : "pending",
498
+ phase,
499
+ identityReady: polled.identityReady === true,
500
+ identity: polled.identity || null,
480
501
  admissionId: state.admissionId,
481
502
  approveUrl: polled.approveUrl || state.approveUrl,
482
503
  waited: true,