sentinelayer-cli 0.32.5 → 0.33.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
@@ -46,6 +46,27 @@ sl watch run-events --run-id <run-id>
46
46
 
47
47
  Windows PowerShell note: `sl` is a built-in alias for `Set-Location`. Use `sentinelayer-cli` (or short alias `slc`) instead.
48
48
 
49
+ ## MCP Live Entry Points And Incident Response
50
+
51
+ Live MCP entry points:
52
+
53
+ - Local stdio server: `sl mcp server run --path .`
54
+ - Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`
55
+ - Hosted resource smoke: `POST https://api.sentinelayer.com/mcp` with JSON-RPC `tools/list`
56
+ - CLI bridge registry: `sl mcp registry init-cli`; credential-bearing commands are blocked from bridge execution
57
+
58
+ First-debug path:
59
+
60
+ 1. Run `sl auth status`.
61
+ 2. For local MCP, run `sl mcp server validate --file .sentinelayer/mcp/server.<id>.json`.
62
+ 3. For hosted MCP, mint a 300-second token and smoke `/mcp` with `tools/list`.
63
+ 4. If a bearer or bridge output is exposed, set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke the requesting CLI auth session with `sl auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
64
+
65
+ Architecture map: local stdio runtime and incident response are in
66
+ [docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat design is in
67
+ [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); session
68
+ coordination behavior is in [docs/sessions.md](docs/sessions.md).
69
+
49
70
  ## 60-second flow
50
71
 
51
72
  1. Trigger:
@@ -662,6 +683,7 @@ The CLI now includes deterministic MCP registry commands:
662
683
  - `sl mcp registry init-cli`
663
684
  - `sl mcp registry validate --file <path>`
664
685
  - `sl mcp registry validate-aidenid-adapter --file <path> [--registry-file <path>]`
686
+ - `sl mcp token mint --scope "sessions:read sessions:usage:read" --ttl-seconds 300 --json`
665
687
  - `sl mcp server init --id <server-id> --registry-file <path>`
666
688
  - `sl mcp server validate --file <path>`
667
689
  - `sl mcp server run --path .`
@@ -669,9 +691,60 @@ The CLI now includes deterministic MCP registry commands:
669
691
 
670
692
  Use `init-aidenid` to scaffold an Anthropic-compatible tool schema wrapper for AIdenID provisioning APIs, then customize transport/auth before runtime wiring.
671
693
  Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API contract (tool binding -> HTTP path/method -> response field mapping) and cross-check it against the registry with `validate-aidenid-adapter`.
694
+ Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
672
695
  Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, file locks, and attention requests to local MCP clients. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md).
673
696
  Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
674
697
 
698
+ MCP operator verification:
699
+
700
+ 1. Local stdio demo: run `sl mcp registry init-session`, then
701
+ `sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json`,
702
+ then configure the MCP host to execute `sl mcp server run --path .`.
703
+ 2. Hosted token smoke: run
704
+ `sl mcp token mint --scope "sessions:read sessions:usage:read" --ttl-seconds 300 --json`,
705
+ place `accessToken` in a local secret variable, and call hosted `/mcp`
706
+ `tools/list` as shown in [docs/mcp.md](docs/mcp.md).
707
+ 3. Kill switch: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1` and restart the
708
+ MCP host if a CLI bridge or bearer credential is exposed.
709
+
710
+ Architecture references: local stdio runtime and incident response live in
711
+ [docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat requirements live in
712
+ [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); Senti room
713
+ coordination behavior lives in [docs/sessions.md](docs/sessions.md).
714
+
715
+ ### MCP Live Entry Point Map
716
+
717
+ - Local stdio server: `sl mcp server run --path .`, configured from
718
+ `.sentinelayer/mcp/server.<id>.json`.
719
+ - Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`,
720
+ backed by `POST /api/v1/auth/mcp-token`.
721
+ - Hosted resource smoke: `POST https://api.sentinelayer.com/mcp` with
722
+ `Authorization: Bearer $SENTINELAYER_MCP_TOKEN` and JSON-RPC `tools/list`.
723
+ - Generated CLI bridge registry: `sl mcp registry init-cli`; secret-bearing
724
+ commands such as `sl mcp token mint` are blocked from bridge execution.
725
+
726
+ Architecture decisions:
727
+
728
+ - Local stdio and hosted HTTPS MCP are intentionally separate. Browser-hosted
729
+ clients cannot spawn the local process, so hosted execution must enforce
730
+ OAuth, session-seat binding, and server-side authorization.
731
+ - Hosted bearer minting is API-owned. The CLI requests a token with existing
732
+ auth and never signs, verifies, or derives token claims locally.
733
+ - The generated CLI bridge is fail-closed for credential-bearing commands; use
734
+ direct operator execution for token minting and the documented kill switch for
735
+ containment.
736
+
737
+ ### MCP Incident Response And Kill Switch
738
+
739
+ If a hosted MCP bearer or bridge command output is exposed:
740
+
741
+ 1. Set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`.
742
+ 2. Restart the MCP host process so the generated CLI bridge cannot execute.
743
+ 3. Run `sentinelayer-cli auth revoke` or revoke the requesting session from the
744
+ dashboard, then re-authenticate with `sentinelayer-cli auth login`.
745
+ 4. Remove the exposed value from local logs/transcripts where possible.
746
+ 5. Verify `/mcp` rejects the old bearer after expiry or revocation.
747
+
675
748
  ## Plugin governance foundation (Phase 5.2 slice)
676
749
 
677
750
  The CLI now includes deterministic plugin/template/policy pack governance commands:
@@ -1035,6 +1108,7 @@ The CLI now supports a command tree, while keeping slash-command compatibility:
1035
1108
  - `sentinelayer-cli daemon map scope|list|show` builds hybrid deterministic+semantic impact scopes with import-graph overlay for daemon work items
1036
1109
  - `sentinelayer-cli daemon reliability run|status` and `daemon maintenance status|on|off` operate the midnight synthetic lane and maintenance billboard lifecycle
1037
1110
  - `sentinelayer-cli mcp schema|registry|server|bridge ...` manages MCP registry schema, server configs, and VS Code bridge scaffolds
1111
+ - `sentinelayer-cli mcp token mint --scope "sessions:read" --ttl-seconds 300 --json` mints a short-lived hosted MCP bearer; text mode hides the secret, and generated CLI bridge execution blocks this command
1038
1112
  - `sentinelayer-cli plugin init|validate|list|order` manages plugin/template/policy packs and deterministic load-order governance
1039
1113
  - `sentinelayer-cli policy list|use <pack-id>` manages active policy pack selection (`community`, `strict`, `compliance-soc2`, `compliance-hipaa`, plugin packs)
1040
1114
  - `sentinelayer-cli ai provision-email` scaffolds and optionally executes AIdenID identity provisioning requests
@@ -1067,4 +1141,5 @@ Roadmap:
1067
1141
  - `GitHub CLI not installed`: install `gh` or run manual fallback.
1068
1142
  - `Invalid repo format`: use exact `owner/repo`.
1069
1143
  - `Missing token in workflow`: ensure `.github/workflows/omar-gate.yml` maps `sentinelayer_token: ${{ secrets.SENTINELAYER_TOKEN }}`.
1144
+ - `Hosted MCP bearer exposed`: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`, restart the MCP host, revoke/rotate the requesting CLI auth session with `sentinelayer-cli auth revoke`, and follow [docs/mcp.md](docs/mcp.md#bearer-exposure-response).
1070
1145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.32.5",
3
+ "version": "0.33.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -6,6 +6,7 @@ import { ZodError } from "zod";
6
6
 
7
7
  import fs from "node:fs/promises";
8
8
 
9
+ import { DEFAULT_REQUEST_TIMEOUT_MS, SentinelayerApiError } from "../auth/http.js";
9
10
  import {
10
11
  buildAidenIdProvisioningAdapterTemplate,
11
12
  buildHostedSentiSessionConnectorContract,
@@ -27,6 +28,7 @@ import {
27
28
  validateMcpToolRegistry,
28
29
  writeJsonFile,
29
30
  } from "../mcp/registry.js";
31
+ import { requestHostedMcpAccessToken } from "../mcp/token-service.js";
30
32
  import { buildSentinelayerCliRegistryTemplate } from "../mcp/cli-registry.js";
31
33
  import { runMcpStdioServer } from "../mcp/session-stdio-server.js";
32
34
  import { resolveOutputRoot } from "../config/service.js";
@@ -56,6 +58,14 @@ function zodIssueSummary(error) {
56
58
  .join("; ");
57
59
  }
58
60
 
61
+ function formatApiError(error) {
62
+ if (!(error instanceof SentinelayerApiError)) {
63
+ return error instanceof Error ? error.message : String(error || "Unknown error");
64
+ }
65
+ const requestId = error.requestId ? ` request_id=${error.requestId}` : "";
66
+ return `${error.message} [${error.code}] status=${error.status}${requestId}`;
67
+ }
68
+
59
69
  export function registerMcpCommand(program) {
60
70
  const mcp = program.command("mcp").description("Manage Sentinelayer MCP registry schemas and adapters");
61
71
 
@@ -113,6 +123,67 @@ export function registerMcpCommand(program) {
113
123
  }
114
124
  });
115
125
 
126
+ const token = mcp.command("token").description("Mint hosted MCP resource tokens");
127
+
128
+ token
129
+ .command("mint")
130
+ .description("Mint a short-lived bearer token for the hosted Sentinelayer MCP resource")
131
+ .option(
132
+ "--scope <scopes>",
133
+ "Space- or comma-separated MCP scopes (API default: sessions:read)"
134
+ )
135
+ .option("--ttl-seconds <seconds>", "Token lifetime in seconds (API default and maximum are server-configured)")
136
+ .option("--timeout-ms <ms>", "Sentinelayer API request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
137
+ .option("--api-url <url>", "Override Sentinelayer API base URL")
138
+ .option("--no-auto-rotate", "Disable stored CLI token rotation before minting")
139
+ .option("--json", "Emit machine-readable output, including the minted access token")
140
+ .action(async (options, command) => {
141
+ const emitJson = shouldEmitJson(options, command);
142
+ let result;
143
+ try {
144
+ result = await requestHostedMcpAccessToken({
145
+ cwd: process.cwd(),
146
+ env: process.env,
147
+ explicitApiUrl: options.apiUrl,
148
+ autoRotate: options.autoRotate !== false,
149
+ scope: options.scope,
150
+ ttlSeconds: options.ttlSeconds,
151
+ timeoutMs: options.timeoutMs,
152
+ });
153
+ } catch (error) {
154
+ throw new Error(formatApiError(error));
155
+ }
156
+
157
+ const payload = {
158
+ command: "mcp token mint",
159
+ apiUrl: result.apiUrl,
160
+ authSource: result.authSource,
161
+ rotated: result.rotated,
162
+ accessToken: result.accessToken,
163
+ tokenType: result.tokenType,
164
+ expiresIn: result.expiresIn,
165
+ expiresAt: result.expiresAt,
166
+ issuer: result.issuer,
167
+ audience: result.audience,
168
+ scope: result.scope,
169
+ };
170
+ if (emitJson) {
171
+ console.log(JSON.stringify(payload, null, 2));
172
+ return;
173
+ }
174
+
175
+ console.log(pc.green("Minted hosted MCP bearer credential"));
176
+ console.log(pc.gray(`API: ${payload.apiUrl}`));
177
+ console.log(`Expires in: ${payload.expiresIn}s`);
178
+ if (payload.expiresAt) {
179
+ console.log(`Expires at: ${payload.expiresAt}`);
180
+ }
181
+ console.log(`Issuer: ${payload.issuer}`);
182
+ console.log(`Audience: ${payload.audience}`);
183
+ console.log(`Scope: ${payload.scope}`);
184
+ console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
185
+ });
186
+
116
187
  const schema = mcp.command("schema").description("Inspect or materialize MCP tool-registry schema");
117
188
  schema
118
189
  .command("show")
@@ -17,6 +17,7 @@ const SENSITIVE_COMMAND_PATHS = new Set([
17
17
  "auth.login",
18
18
  "auth.logout",
19
19
  "auth.revoke",
20
+ "mcp.token.mint",
20
21
  "config.edit",
21
22
  "config.get",
22
23
  "config.list",
@@ -0,0 +1,156 @@
1
+ import process from "node:process";
2
+
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, requestJsonMutation } from "../auth/http.js";
4
+ import {
5
+ DEFAULT_API_TOKEN_TTL_DAYS,
6
+ DEFAULT_TOKEN_ROTATE_THRESHOLD_DAYS,
7
+ resolveActiveAuthSession,
8
+ } from "../auth/service.js";
9
+ import { authLoginHint } from "../ui/command-hints.js";
10
+
11
+ function normalizePositiveNumber(rawValue, field, fallbackValue) {
12
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
13
+ if (fallbackValue !== undefined) {
14
+ return fallbackValue;
15
+ }
16
+ throw new Error(`${field} must be a positive number.`);
17
+ }
18
+ const normalized = Number(rawValue);
19
+ if (!Number.isFinite(normalized) || normalized <= 0) {
20
+ throw new Error(`${field} must be a positive number.`);
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ function normalizeOptionalPositiveInteger(rawValue, field) {
26
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
27
+ return null;
28
+ }
29
+ const normalized = Number(rawValue);
30
+ if (!Number.isFinite(normalized) || normalized <= 0 || !Number.isInteger(normalized)) {
31
+ throw new Error(`${field} must be a positive integer.`);
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ function buildApiPath(apiUrl, pathSuffix) {
37
+ const base = String(apiUrl || "").replace(/\/+$/, "");
38
+ const suffix = String(pathSuffix || "").replace(/^\/+/, "");
39
+ return `${base}/${suffix}`;
40
+ }
41
+
42
+ function toAuthHeader(token) {
43
+ return {
44
+ Authorization: `Bearer ${String(token || "").trim()}`,
45
+ };
46
+ }
47
+
48
+ function applyScope(body, { scope = "", scopes } = {}) {
49
+ const normalizedScope = String(scope || "").trim();
50
+ const normalizedScopes = Array.isArray(scopes)
51
+ ? scopes.map((item) => String(item || "").trim()).filter(Boolean)
52
+ : String(scopes || "").trim();
53
+ if (normalizedScope && normalizedScopes && (!Array.isArray(normalizedScopes) || normalizedScopes.length > 0)) {
54
+ throw new Error("Use either scope or scopes, not both.");
55
+ }
56
+ if (normalizedScope) {
57
+ body.scope = normalizedScope;
58
+ } else if (Array.isArray(normalizedScopes) && normalizedScopes.length > 0) {
59
+ body.scopes = normalizedScopes;
60
+ } else if (typeof normalizedScopes === "string" && normalizedScopes) {
61
+ body.scope = normalizedScopes;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Request a short-lived hosted MCP bearer credential.
67
+ * The CLI authenticates with its existing Sentinelayer API token; the API
68
+ * performs all credential minting server-side and returns the secret in-memory.
69
+ *
70
+ * @param {{
71
+ * cwd?: string,
72
+ * env?: NodeJS.ProcessEnv,
73
+ * explicitApiUrl?: string,
74
+ * autoRotate?: boolean,
75
+ * rotateThresholdDays?: number,
76
+ * tokenLabel?: string,
77
+ * tokenTtlDays?: number,
78
+ * homeDir?: string,
79
+ * scope?: string,
80
+ * scopes?: string[] | string,
81
+ * ttlSeconds?: number | string | null,
82
+ * timeoutMs?: number | string | null
83
+ * }} [options]
84
+ * @returns {Promise<{
85
+ * apiUrl: string,
86
+ * authSource: string,
87
+ * rotated: boolean,
88
+ * accessToken: string,
89
+ * tokenType: string,
90
+ * expiresIn: number,
91
+ * expiresAt: string,
92
+ * issuer: string,
93
+ * audience: string,
94
+ * scope: string
95
+ * }>}
96
+ */
97
+ export async function requestHostedMcpAccessToken({
98
+ cwd = process.cwd(),
99
+ env = process.env,
100
+ explicitApiUrl = "",
101
+ autoRotate = true,
102
+ rotateThresholdDays = DEFAULT_TOKEN_ROTATE_THRESHOLD_DAYS,
103
+ tokenLabel = "",
104
+ tokenTtlDays = DEFAULT_API_TOKEN_TTL_DAYS,
105
+ homeDir,
106
+ scope = "",
107
+ scopes,
108
+ ttlSeconds = null,
109
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
110
+ } = {}) {
111
+ const session = await resolveActiveAuthSession({
112
+ cwd,
113
+ env,
114
+ explicitApiUrl,
115
+ autoRotate,
116
+ rotateThresholdDays,
117
+ tokenLabel,
118
+ tokenTtlDays,
119
+ homeDir,
120
+ });
121
+ if (!session || !session.token) {
122
+ throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
123
+ }
124
+
125
+ const body = {};
126
+ applyScope(body, { scope, scopes });
127
+ const normalizedTtlSeconds = normalizeOptionalPositiveInteger(ttlSeconds, "ttlSeconds");
128
+ if (normalizedTtlSeconds !== null) {
129
+ body.ttl_seconds = normalizedTtlSeconds;
130
+ }
131
+ const normalizedTimeoutMs = normalizePositiveNumber(timeoutMs, "timeoutMs", DEFAULT_REQUEST_TIMEOUT_MS);
132
+
133
+ const response = await requestJsonMutation(
134
+ buildApiPath(session.apiUrl, "/api/v1/auth/mcp-token"),
135
+ {
136
+ method: "POST",
137
+ operationName: "mcp-token-mint",
138
+ headers: toAuthHeader(session.token),
139
+ body,
140
+ timeoutMs: normalizedTimeoutMs,
141
+ }
142
+ );
143
+
144
+ return {
145
+ apiUrl: session.apiUrl,
146
+ authSource: session.source,
147
+ rotated: Boolean(session.rotated),
148
+ accessToken: String(response.access_token || ""),
149
+ tokenType: String(response.token_type || "Bearer"),
150
+ expiresIn: Number(response.expires_in || 0),
151
+ expiresAt: String(response.expires_at || ""),
152
+ issuer: String(response.issuer || ""),
153
+ audience: String(response.audience || ""),
154
+ scope: String(response.scope || ""),
155
+ };
156
+ }