sentinelayer-cli 0.32.5 → 0.34.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 +86 -1
- package/package.json +1 -1
- package/src/commands/mcp.js +180 -3
- package/src/legacy-cli.js +7 -1
- package/src/mcp/cli-command-tools.js +1 -0
- package/src/mcp/doctor.js +247 -0
- package/src/mcp/token-service.js +156 -0
- package/src/scan/generator.js +1 -1
- package/src/session/recap.js +73 -16
- package/src/session/store.js +1 -0
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,70 @@ 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
|
+
Worked MCP examples:
|
|
716
|
+
|
|
717
|
+
```bash
|
|
718
|
+
sl mcp list --json
|
|
719
|
+
sl mcp registry init-session --force
|
|
720
|
+
sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
|
|
721
|
+
sl mcp server run --path .
|
|
722
|
+
sl mcp registry init-cli --json
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
### MCP Live Entry Point Map
|
|
726
|
+
|
|
727
|
+
- Local stdio server: `sl mcp server run --path .`, configured from
|
|
728
|
+
`.sentinelayer/mcp/server.<id>.json`.
|
|
729
|
+
- Hosted token mint: `sl mcp token mint --scope "sessions:read" --ttl-seconds 300 --json`,
|
|
730
|
+
backed by `POST /api/v1/auth/mcp-token`.
|
|
731
|
+
- Hosted resource smoke: `POST https://api.sentinelayer.com/mcp` with
|
|
732
|
+
`Authorization: Bearer $SENTINELAYER_MCP_TOKEN` and JSON-RPC `tools/list`.
|
|
733
|
+
- Generated CLI bridge registry: `sl mcp registry init-cli`; secret-bearing
|
|
734
|
+
commands such as `sl mcp token mint` are blocked from bridge execution.
|
|
735
|
+
|
|
736
|
+
Architecture decisions:
|
|
737
|
+
|
|
738
|
+
- Local stdio and hosted HTTPS MCP are intentionally separate. Browser-hosted
|
|
739
|
+
clients cannot spawn the local process, so hosted execution must enforce
|
|
740
|
+
OAuth, session-seat binding, and server-side authorization.
|
|
741
|
+
- Hosted bearer minting is API-owned. The CLI requests a token with existing
|
|
742
|
+
auth and never signs, verifies, or derives token claims locally.
|
|
743
|
+
- The generated CLI bridge is fail-closed for credential-bearing commands; use
|
|
744
|
+
direct operator execution for token minting and the documented kill switch for
|
|
745
|
+
containment.
|
|
746
|
+
|
|
747
|
+
### MCP Incident Response And Kill Switch
|
|
748
|
+
|
|
749
|
+
If a hosted MCP bearer or bridge command output is exposed:
|
|
750
|
+
|
|
751
|
+
1. Set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`.
|
|
752
|
+
2. Restart the MCP host process so the generated CLI bridge cannot execute.
|
|
753
|
+
3. Run `sentinelayer-cli auth revoke` or revoke the requesting session from the
|
|
754
|
+
dashboard, then re-authenticate with `sentinelayer-cli auth login`.
|
|
755
|
+
4. Remove the exposed value from local logs/transcripts where possible.
|
|
756
|
+
5. Verify `/mcp` rejects the old bearer after expiry or revocation.
|
|
757
|
+
|
|
675
758
|
## Plugin governance foundation (Phase 5.2 slice)
|
|
676
759
|
|
|
677
760
|
The CLI now includes deterministic plugin/template/policy pack governance commands:
|
|
@@ -747,7 +830,7 @@ gh secret set SENTINELAYER_TOKEN --repo <owner/repo>
|
|
|
747
830
|
gh secret list --repo <owner/repo>
|
|
748
831
|
```
|
|
749
832
|
|
|
750
|
-
3. For manual setup details: `https://
|
|
833
|
+
3. For manual setup details: `https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped`
|
|
751
834
|
|
|
752
835
|
4. BYOK mode (no Sentinelayer token):
|
|
753
836
|
- keep generated `docs/spec.md`, `docs/build-guide.md`, `prompts/execution-prompt.md`, and `tasks/todo.md`
|
|
@@ -1035,6 +1118,7 @@ The CLI now supports a command tree, while keeping slash-command compatibility:
|
|
|
1035
1118
|
- `sentinelayer-cli daemon map scope|list|show` builds hybrid deterministic+semantic impact scopes with import-graph overlay for daemon work items
|
|
1036
1119
|
- `sentinelayer-cli daemon reliability run|status` and `daemon maintenance status|on|off` operate the midnight synthetic lane and maintenance billboard lifecycle
|
|
1037
1120
|
- `sentinelayer-cli mcp schema|registry|server|bridge ...` manages MCP registry schema, server configs, and VS Code bridge scaffolds
|
|
1121
|
+
- `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
1122
|
- `sentinelayer-cli plugin init|validate|list|order` manages plugin/template/policy packs and deterministic load-order governance
|
|
1039
1123
|
- `sentinelayer-cli policy list|use <pack-id>` manages active policy pack selection (`community`, `strict`, `compliance-soc2`, `compliance-hipaa`, plugin packs)
|
|
1040
1124
|
- `sentinelayer-cli ai provision-email` scaffolds and optionally executes AIdenID identity provisioning requests
|
|
@@ -1067,4 +1151,5 @@ Roadmap:
|
|
|
1067
1151
|
- `GitHub CLI not installed`: install `gh` or run manual fallback.
|
|
1068
1152
|
- `Invalid repo format`: use exact `owner/repo`.
|
|
1069
1153
|
- `Missing token in workflow`: ensure `.github/workflows/omar-gate.yml` maps `sentinelayer_token: ${{ secrets.SENTINELAYER_TOKEN }}`.
|
|
1154
|
+
- `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
1155
|
|
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -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,8 +28,11 @@ 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";
|
|
34
|
+
import { runMcpDoctorProbes } from "../mcp/doctor.js";
|
|
35
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
32
36
|
import { resolveOutputRoot } from "../config/service.js";
|
|
33
37
|
|
|
34
38
|
function shouldEmitJson(options, command) {
|
|
@@ -56,8 +60,60 @@ function zodIssueSummary(error) {
|
|
|
56
60
|
.join("; ");
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
function formatApiError(error) {
|
|
64
|
+
if (!(error instanceof SentinelayerApiError)) {
|
|
65
|
+
return error instanceof Error ? error.message : String(error || "Unknown error");
|
|
66
|
+
}
|
|
67
|
+
const requestId = error.requestId ? ` request_id=${error.requestId}` : "";
|
|
68
|
+
return `${error.message} [${error.code}] status=${error.status}${requestId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function resolveMcpDoctorApiBaseUrl({
|
|
72
|
+
explicitApiUrl = "",
|
|
73
|
+
cwd = process.cwd(),
|
|
74
|
+
env = process.env,
|
|
75
|
+
resolveActiveAuthSessionImpl = resolveActiveAuthSession,
|
|
76
|
+
} = {}) {
|
|
77
|
+
const directApiUrl = String(explicitApiUrl || "").trim();
|
|
78
|
+
if (directApiUrl) {
|
|
79
|
+
return directApiUrl;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Probes are unauthenticated, but the API base URL can live in the CLI auth
|
|
83
|
+
// config. Read it without token rotation so doctor remains side-effect-free.
|
|
84
|
+
try {
|
|
85
|
+
const session = await resolveActiveAuthSessionImpl({
|
|
86
|
+
cwd,
|
|
87
|
+
env,
|
|
88
|
+
autoRotate: false,
|
|
89
|
+
});
|
|
90
|
+
return String((session && session.apiUrl) || "").trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
export function registerMcpCommand(program) {
|
|
60
|
-
const mcp = program
|
|
97
|
+
const mcp = program
|
|
98
|
+
.command("mcp")
|
|
99
|
+
.description("Manage local MCP registries, stdio servers, and guarded CLI bridge metadata")
|
|
100
|
+
.addHelpText(
|
|
101
|
+
"after",
|
|
102
|
+
`
|
|
103
|
+
Examples:
|
|
104
|
+
sl mcp list --json
|
|
105
|
+
sl mcp registry init-session --force
|
|
106
|
+
sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
|
|
107
|
+
sl mcp server run --path .
|
|
108
|
+
sl mcp registry init-cli --json
|
|
109
|
+
|
|
110
|
+
Notes:
|
|
111
|
+
Local stdio MCP works for clients that can spawn the CLI process.
|
|
112
|
+
Hosted Claude/ChatGPT connectors require the separate HTTPS/OAuth hosted connector contract.
|
|
113
|
+
Generated CLI bridge tools are metadata only until a bridge host enforces auth, approval, and runtime policy.
|
|
114
|
+
|
|
115
|
+
Docs: https://github.com/mrrCarter/create-sentinelayer/blob/main/docs/mcp.md`,
|
|
116
|
+
);
|
|
61
117
|
|
|
62
118
|
mcp
|
|
63
119
|
.command("list")
|
|
@@ -113,6 +169,126 @@ export function registerMcpCommand(program) {
|
|
|
113
169
|
}
|
|
114
170
|
});
|
|
115
171
|
|
|
172
|
+
const token = mcp.command("token").description("Mint hosted MCP resource tokens");
|
|
173
|
+
|
|
174
|
+
token
|
|
175
|
+
.command("mint")
|
|
176
|
+
.description("Mint a short-lived bearer token for the hosted Sentinelayer MCP resource")
|
|
177
|
+
.option(
|
|
178
|
+
"--scope <scopes>",
|
|
179
|
+
"Space- or comma-separated MCP scopes (API default: sessions:read)"
|
|
180
|
+
)
|
|
181
|
+
.option("--ttl-seconds <seconds>", "Token lifetime in seconds (API default and maximum are server-configured)")
|
|
182
|
+
.option("--timeout-ms <ms>", "Sentinelayer API request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
|
|
183
|
+
.option("--api-url <url>", "Override Sentinelayer API base URL")
|
|
184
|
+
.option("--no-auto-rotate", "Disable stored CLI token rotation before minting")
|
|
185
|
+
.option("--json", "Emit machine-readable output, including the minted access token")
|
|
186
|
+
.action(async (options, command) => {
|
|
187
|
+
const emitJson = shouldEmitJson(options, command);
|
|
188
|
+
let result;
|
|
189
|
+
try {
|
|
190
|
+
result = await requestHostedMcpAccessToken({
|
|
191
|
+
cwd: process.cwd(),
|
|
192
|
+
env: process.env,
|
|
193
|
+
explicitApiUrl: options.apiUrl,
|
|
194
|
+
autoRotate: options.autoRotate !== false,
|
|
195
|
+
scope: options.scope,
|
|
196
|
+
ttlSeconds: options.ttlSeconds,
|
|
197
|
+
timeoutMs: options.timeoutMs,
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error(formatApiError(error));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const payload = {
|
|
204
|
+
command: "mcp token mint",
|
|
205
|
+
apiUrl: result.apiUrl,
|
|
206
|
+
authSource: result.authSource,
|
|
207
|
+
rotated: result.rotated,
|
|
208
|
+
accessToken: result.accessToken,
|
|
209
|
+
tokenType: result.tokenType,
|
|
210
|
+
expiresIn: result.expiresIn,
|
|
211
|
+
expiresAt: result.expiresAt,
|
|
212
|
+
issuer: result.issuer,
|
|
213
|
+
audience: result.audience,
|
|
214
|
+
scope: result.scope,
|
|
215
|
+
};
|
|
216
|
+
if (emitJson) {
|
|
217
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(pc.green("Minted hosted MCP bearer credential"));
|
|
222
|
+
console.log(pc.gray(`API: ${payload.apiUrl}`));
|
|
223
|
+
console.log(`Expires in: ${payload.expiresIn}s`);
|
|
224
|
+
if (payload.expiresAt) {
|
|
225
|
+
console.log(`Expires at: ${payload.expiresAt}`);
|
|
226
|
+
}
|
|
227
|
+
console.log(`Issuer: ${payload.issuer}`);
|
|
228
|
+
console.log(`Audience: ${payload.audience}`);
|
|
229
|
+
console.log(`Scope: ${payload.scope}`);
|
|
230
|
+
console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
mcp
|
|
234
|
+
.command("doctor")
|
|
235
|
+
.description(
|
|
236
|
+
"Diagnose hosted MCP auth: probe the OAuth discovery chain (PRM, AS metadata, JWKS) and verify unauthenticated /mcp is rejected"
|
|
237
|
+
)
|
|
238
|
+
.option(
|
|
239
|
+
"--api-url <url>",
|
|
240
|
+
"Sentinelayer API base URL to probe (default: resolved from your CLI auth session)"
|
|
241
|
+
)
|
|
242
|
+
.option("--timeout-ms <ms>", "Per-probe request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
|
|
243
|
+
.option("--json", "Emit machine-readable output")
|
|
244
|
+
.action(async (options, command) => {
|
|
245
|
+
const emitJson = shouldEmitJson(options, command);
|
|
246
|
+
const parsedTimeout = Number(options.timeoutMs);
|
|
247
|
+
const timeoutMs =
|
|
248
|
+
Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
249
|
+
|
|
250
|
+
const apiBaseUrl = await resolveMcpDoctorApiBaseUrl({
|
|
251
|
+
explicitApiUrl: options.apiUrl,
|
|
252
|
+
cwd: process.cwd(),
|
|
253
|
+
env: process.env,
|
|
254
|
+
});
|
|
255
|
+
if (!apiBaseUrl) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
"Could not resolve the Sentinelayer API URL. Pass --api-url <url>, or run `sl auth login` first."
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const result = await runMcpDoctorProbes({ apiBaseUrl, timeoutMs });
|
|
262
|
+
|
|
263
|
+
if (emitJson) {
|
|
264
|
+
console.log(JSON.stringify({ command: "mcp doctor", ...result }, null, 2));
|
|
265
|
+
} else {
|
|
266
|
+
console.log(pc.bold(`MCP auth doctor — ${result.apiBaseUrl}`));
|
|
267
|
+
for (const probe of result.probes) {
|
|
268
|
+
const mark =
|
|
269
|
+
probe.verdict === "PASS"
|
|
270
|
+
? pc.green("PASS")
|
|
271
|
+
: probe.verdict === "WARN"
|
|
272
|
+
? pc.yellow("WARN")
|
|
273
|
+
: pc.red("FAIL");
|
|
274
|
+
console.log(` [${mark}] ${probe.label} (HTTP ${probe.status})`);
|
|
275
|
+
console.log(pc.gray(` ${probe.url}`));
|
|
276
|
+
if (probe.detail) {
|
|
277
|
+
console.log(pc.gray(` ${probe.detail}`));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
console.log(
|
|
281
|
+
result.ok
|
|
282
|
+
? pc.green("All critical MCP auth probes passed.")
|
|
283
|
+
: pc.red("One or more MCP auth probes FAILED — remote agents may not authenticate correctly.")
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
116
292
|
const schema = mcp.command("schema").description("Inspect or materialize MCP tool-registry schema");
|
|
117
293
|
schema
|
|
118
294
|
.command("show")
|
|
@@ -300,7 +476,7 @@ export function registerMcpCommand(program) {
|
|
|
300
476
|
|
|
301
477
|
registry
|
|
302
478
|
.command("init-cli")
|
|
303
|
-
.description("Write
|
|
479
|
+
.description("Write guarded sl.* CLI bridge metadata for bridge-capable MCP hosts")
|
|
304
480
|
.option("--path <path>", "Destination file path override")
|
|
305
481
|
.option("--output-dir <path>", "Optional artifact output root override")
|
|
306
482
|
.option("--force", "Overwrite destination file if it already exists")
|
|
@@ -328,7 +504,8 @@ export function registerMcpCommand(program) {
|
|
|
328
504
|
}
|
|
329
505
|
console.log(pc.green(`Wrote SentinelLayer CLI MCP registry template: ${writtenPath}`));
|
|
330
506
|
console.log(pc.gray(`${template.tools.length} CLI leaf commands exposed as bridge tools.`));
|
|
331
|
-
console.log(pc.gray("Execution requires a bridge-capable MCP host
|
|
507
|
+
console.log(pc.gray("Execution requires a bridge-capable MCP host with auth, approval, and runtime policy."));
|
|
508
|
+
console.log(pc.gray("Sensitive token, export, and identity-mutation commands are runtime-blocked."));
|
|
332
509
|
});
|
|
333
510
|
|
|
334
511
|
registry
|
package/src/legacy-cli.js
CHANGED
|
@@ -292,7 +292,13 @@ function printUsage() {
|
|
|
292
292
|
console.log(" sl auth login && npx create-sentinelayer my-app && cd my-app");
|
|
293
293
|
console.log(" # Then hand docs/spec.md to your coding agent");
|
|
294
294
|
console.log("");
|
|
295
|
-
console.log("
|
|
295
|
+
console.log("Examples:");
|
|
296
|
+
console.log(" sl session read <id> --remote --agent <agent> --tail 20");
|
|
297
|
+
console.log(" sl session react <id> ack --target-sequence <seq> --agent <agent>");
|
|
298
|
+
console.log(" sl mcp registry init-session --force && sl mcp server run --path .");
|
|
299
|
+
console.log(" sl mcp registry init-cli --json");
|
|
300
|
+
console.log("");
|
|
301
|
+
console.log("Docs: https://github.com/mrrCarter/create-sentinelayer#readme");
|
|
296
302
|
}
|
|
297
303
|
|
|
298
304
|
function normalizeInterviewInput(
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sl mcp doctor` probe logic.
|
|
3
|
+
*
|
|
4
|
+
* Diagnoses whether the hosted Sentinelayer MCP server is correctly set up for
|
|
5
|
+
* remote-agent (ChatGPT / Claude) OAuth authentication. All probes are
|
|
6
|
+
* UNAUTHENTICATED on purpose: they read public OAuth discovery documents and
|
|
7
|
+
* confirm the resource server REJECTS an unauthenticated call. No bearer token
|
|
8
|
+
* is sent and no token is minted, so the command is side-effect-free and safe
|
|
9
|
+
* to run before `sl auth login`.
|
|
10
|
+
*
|
|
11
|
+
* Checks (RFCs map to the WARDEN hosted-MCP-auth build-spec §2/§4):
|
|
12
|
+
* 1. Protected Resource Metadata GET /.well-known/oauth-protected-resource (RFC 9728)
|
|
13
|
+
* 2. Authorization Server Metadata GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
14
|
+
* 3. JSON Web Key Set GET /.well-known/jwks.json
|
|
15
|
+
* 4. Enforcement POST /mcp with no Authorization -> must 401 + WWW-Authenticate
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_DOCTOR_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
const PASS = "PASS";
|
|
21
|
+
const WARN = "WARN";
|
|
22
|
+
const FAIL = "FAIL";
|
|
23
|
+
|
|
24
|
+
function joinUrl(base, suffix) {
|
|
25
|
+
const normalizedBase = String(base || "").replace(/\/+$/, "");
|
|
26
|
+
const normalizedSuffix = String(suffix || "").replace(/^\/+/, "");
|
|
27
|
+
return `${normalizedBase}/${normalizedSuffix}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function probeRequest(fetchImpl, url, { method = "GET", headers, body, timeoutMs } = {}) {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetchImpl(url, { method, headers, body, signal: controller.signal });
|
|
35
|
+
const text = await response.text().catch(() => "");
|
|
36
|
+
let json = null;
|
|
37
|
+
if (text) {
|
|
38
|
+
try {
|
|
39
|
+
json = JSON.parse(text);
|
|
40
|
+
} catch {
|
|
41
|
+
json = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { reached: true, status: response.status, headers: response.headers, json };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const aborted = Boolean(error) && error.name === "AbortError";
|
|
47
|
+
return {
|
|
48
|
+
reached: false,
|
|
49
|
+
status: 0,
|
|
50
|
+
detail: aborted ? `timed out after ${timeoutMs}ms` : String((error && error.message) || error),
|
|
51
|
+
};
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function headerValue(headers, name) {
|
|
58
|
+
if (headers && typeof headers.get === "function") {
|
|
59
|
+
return String(headers.get(name) || "");
|
|
60
|
+
}
|
|
61
|
+
if (headers && typeof headers === "object") {
|
|
62
|
+
return String(headers[name] || headers[name.toLowerCase()] || "");
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the hosted MCP auth discovery + enforcement probes.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ apiBaseUrl: string, timeoutMs?: number, fetchImpl?: typeof fetch }} params
|
|
71
|
+
* @returns {Promise<{ apiBaseUrl: string, ok: boolean, probes: Array<{id:string,label:string,url:string,status:number,verdict:string,detail:string}> }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function runMcpDoctorProbes({
|
|
74
|
+
apiBaseUrl,
|
|
75
|
+
timeoutMs = DEFAULT_DOCTOR_TIMEOUT_MS,
|
|
76
|
+
fetchImpl = fetch,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const base = String(apiBaseUrl || "").trim().replace(/\/+$/, "");
|
|
79
|
+
if (!base) {
|
|
80
|
+
throw new Error("apiBaseUrl is required to run MCP doctor probes.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const probes = [];
|
|
84
|
+
// Whether PRM advertised an authorization server changes how severe an
|
|
85
|
+
// AS-metadata 503 is: advertised pointer + 503 is a broken chain (FAIL),
|
|
86
|
+
// while omitted pointer + 503 is a consistent fail-closed state (WARN).
|
|
87
|
+
let prmAdvertisesAuthServer = false;
|
|
88
|
+
|
|
89
|
+
// 1. Protected Resource Metadata (RFC 9728)
|
|
90
|
+
{
|
|
91
|
+
const url = joinUrl(base, "/.well-known/oauth-protected-resource");
|
|
92
|
+
const result = await probeRequest(fetchImpl, url, { timeoutMs });
|
|
93
|
+
let verdict = FAIL;
|
|
94
|
+
let detail;
|
|
95
|
+
if (!result.reached) {
|
|
96
|
+
detail = result.detail;
|
|
97
|
+
} else if (result.status === 200 && result.json && typeof result.json.resource === "string" && result.json.resource) {
|
|
98
|
+
verdict = PASS;
|
|
99
|
+
const count = Array.isArray(result.json.authorization_servers)
|
|
100
|
+
? result.json.authorization_servers.length
|
|
101
|
+
: 0;
|
|
102
|
+
prmAdvertisesAuthServer = count > 0;
|
|
103
|
+
detail = `resource=${result.json.resource}; authorization_servers=${count}`;
|
|
104
|
+
} else if (result.status === 200) {
|
|
105
|
+
verdict = WARN;
|
|
106
|
+
detail = "200 but missing the required 'resource' field";
|
|
107
|
+
} else {
|
|
108
|
+
detail = `expected 200, got HTTP ${result.status}`;
|
|
109
|
+
}
|
|
110
|
+
probes.push({
|
|
111
|
+
id: "protected_resource_metadata",
|
|
112
|
+
label: "Protected Resource Metadata (RFC 9728)",
|
|
113
|
+
url,
|
|
114
|
+
status: result.status,
|
|
115
|
+
verdict,
|
|
116
|
+
detail,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. Authorization Server Metadata (RFC 8414)
|
|
121
|
+
{
|
|
122
|
+
const url = joinUrl(base, "/.well-known/oauth-authorization-server");
|
|
123
|
+
const result = await probeRequest(fetchImpl, url, { timeoutMs });
|
|
124
|
+
let verdict = FAIL;
|
|
125
|
+
let detail;
|
|
126
|
+
if (!result.reached) {
|
|
127
|
+
detail = result.detail;
|
|
128
|
+
} else if (result.status === 200) {
|
|
129
|
+
verdict = PASS;
|
|
130
|
+
detail =
|
|
131
|
+
result.json && result.json.token_endpoint
|
|
132
|
+
? `token_endpoint=${result.json.token_endpoint}`
|
|
133
|
+
: "advertised";
|
|
134
|
+
} else if (result.status === 503) {
|
|
135
|
+
if (prmAdvertisesAuthServer) {
|
|
136
|
+
// Broken discovery chain: PRM points clients at an authorization server
|
|
137
|
+
// that then returns 503. Clients WILL follow the pointer and fail.
|
|
138
|
+
verdict = FAIL;
|
|
139
|
+
detail =
|
|
140
|
+
"PRM advertises authorization_servers but AS metadata is 503 — remote clients will follow the advertised pointer to an unconfigured authorization server (broken discovery chain)";
|
|
141
|
+
} else {
|
|
142
|
+
// Consistent fail-closed: no AS advertised, AS metadata not configured.
|
|
143
|
+
verdict = WARN;
|
|
144
|
+
detail =
|
|
145
|
+
"503 unconfigured and PRM does not advertise an authorization server (fail-closed, consistent) — set MCP_OAUTH_AUTHORIZATION_ENDPOINT + MCP_OAUTH_TOKEN_ENDPOINT to enable remote auto-discovery";
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
detail = `expected 200, got HTTP ${result.status}`;
|
|
149
|
+
}
|
|
150
|
+
probes.push({
|
|
151
|
+
id: "authorization_server_metadata",
|
|
152
|
+
label: "Authorization Server Metadata (RFC 8414)",
|
|
153
|
+
url,
|
|
154
|
+
status: result.status,
|
|
155
|
+
verdict,
|
|
156
|
+
detail,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. JSON Web Key Set
|
|
161
|
+
{
|
|
162
|
+
const url = joinUrl(base, "/.well-known/jwks.json");
|
|
163
|
+
const result = await probeRequest(fetchImpl, url, { timeoutMs });
|
|
164
|
+
let verdict = FAIL;
|
|
165
|
+
let detail;
|
|
166
|
+
if (!result.reached) {
|
|
167
|
+
detail = result.detail;
|
|
168
|
+
} else if (result.status === 200 && result.json && Array.isArray(result.json.keys) && result.json.keys.length > 0) {
|
|
169
|
+
const keys = result.json.keys;
|
|
170
|
+
// A PUBLIC JWKS must only ever publish ASYMMETRIC public keys. A symmetric
|
|
171
|
+
// key (kty "oct" / HS* alg) here IS the shared HMAC signing secret — anyone
|
|
172
|
+
// who can read it can forge MCP access tokens. Treat that as a hard failure.
|
|
173
|
+
const symmetricKeys = keys.filter((key) => {
|
|
174
|
+
const kty = String((key && key.kty) || "").toLowerCase();
|
|
175
|
+
const alg = String((key && key.alg) || "").toUpperCase();
|
|
176
|
+
return kty === "oct" || alg.startsWith("HS");
|
|
177
|
+
});
|
|
178
|
+
if (symmetricKeys.length > 0) {
|
|
179
|
+
verdict = FAIL;
|
|
180
|
+
detail = `CRITICAL: ${symmetricKeys.length} symmetric key(s) (kty=oct / HS*) published in the public JWKS — that is the HMAC signing secret and lets anyone forge MCP access tokens`;
|
|
181
|
+
} else {
|
|
182
|
+
verdict = PASS;
|
|
183
|
+
const algs = [
|
|
184
|
+
...new Set(keys.map((key) => String((key && key.alg) || (key && key.kty) || "?"))),
|
|
185
|
+
].join(", ");
|
|
186
|
+
detail = `${keys.length} asymmetric signing key(s) published (${algs})`;
|
|
187
|
+
}
|
|
188
|
+
} else if (result.status === 200) {
|
|
189
|
+
verdict = WARN;
|
|
190
|
+
detail = "200 but no signing keys published (expected for HS256/non-prod; production must publish an RS256 JWKS)";
|
|
191
|
+
} else {
|
|
192
|
+
detail = `expected 200, got HTTP ${result.status}`;
|
|
193
|
+
}
|
|
194
|
+
probes.push({
|
|
195
|
+
id: "jwks",
|
|
196
|
+
label: "JSON Web Key Set",
|
|
197
|
+
url,
|
|
198
|
+
status: result.status,
|
|
199
|
+
verdict,
|
|
200
|
+
detail,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4. Enforcement: an unauthenticated /mcp call must be rejected with a
|
|
205
|
+
// 401 + WWW-Authenticate challenge that points back at the PRM document.
|
|
206
|
+
{
|
|
207
|
+
const url = joinUrl(base, "/mcp");
|
|
208
|
+
const result = await probeRequest(fetchImpl, url, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "Content-Type": "application/json" },
|
|
211
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "mcp-doctor", method: "ping" }),
|
|
212
|
+
timeoutMs,
|
|
213
|
+
});
|
|
214
|
+
let verdict = FAIL;
|
|
215
|
+
let detail;
|
|
216
|
+
if (!result.reached) {
|
|
217
|
+
detail = result.detail;
|
|
218
|
+
} else if (result.status === 401) {
|
|
219
|
+
const challenge = headerValue(result.headers, "www-authenticate");
|
|
220
|
+
if (challenge && /resource_metadata=/i.test(challenge)) {
|
|
221
|
+
verdict = PASS;
|
|
222
|
+
detail = "401 + WWW-Authenticate challenge advertises the protected-resource metadata";
|
|
223
|
+
} else if (challenge) {
|
|
224
|
+
verdict = WARN;
|
|
225
|
+
detail = "401 but the WWW-Authenticate challenge omits resource_metadata (clients cannot auto-discover the AS)";
|
|
226
|
+
} else {
|
|
227
|
+
verdict = WARN;
|
|
228
|
+
detail = "401 but no WWW-Authenticate header (clients cannot auto-discover the AS)";
|
|
229
|
+
}
|
|
230
|
+
} else if (result.status === 200) {
|
|
231
|
+
detail = "CRITICAL: unauthenticated /mcp returned 200 — the resource server is NOT enforcing authentication";
|
|
232
|
+
} else {
|
|
233
|
+
detail = `expected 401, got HTTP ${result.status}`;
|
|
234
|
+
}
|
|
235
|
+
probes.push({
|
|
236
|
+
id: "mcp_enforcement",
|
|
237
|
+
label: "Unauthenticated /mcp is rejected",
|
|
238
|
+
url,
|
|
239
|
+
status: result.status,
|
|
240
|
+
verdict,
|
|
241
|
+
detail,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ok = probes.every((probe) => probe.verdict !== FAIL);
|
|
246
|
+
return { apiBaseUrl: base, ok, probes };
|
|
247
|
+
}
|
|
@@ -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
|
+
}
|
package/src/scan/generator.js
CHANGED
|
@@ -498,7 +498,7 @@ export function buildSecretSetupInstructions(
|
|
|
498
498
|
`- gh secret set ${normalizedSecret} --repo ${resolvedRepoSlug}`,
|
|
499
499
|
`- Verify secret visibility: gh secret list --repo ${resolvedRepoSlug}`,
|
|
500
500
|
"- Commit and push .github/workflows/omar-gate.yml",
|
|
501
|
-
"- For manual setup: https://
|
|
501
|
+
"- For manual setup: https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped",
|
|
502
502
|
];
|
|
503
503
|
}
|
|
504
504
|
|
package/src/session/recap.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fsp from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import process from "node:process";
|
|
@@ -72,6 +73,25 @@ function normalizePositiveInteger(value, fallbackValue) {
|
|
|
72
73
|
return Math.max(1, Math.floor(normalized));
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
function buildWorkPlanSourceContext(targetPath = process.cwd(), { sourceReason = "caller_target_path" } = {}) {
|
|
77
|
+
const workspacePath = path.resolve(String(targetPath || "."));
|
|
78
|
+
const workspaceLabel = normalizeString(path.basename(workspacePath)) || ".";
|
|
79
|
+
const workspaceFingerprint = createHash("sha256")
|
|
80
|
+
.update(workspacePath)
|
|
81
|
+
.digest("hex")
|
|
82
|
+
.slice(0, 8);
|
|
83
|
+
const sourceLabel = `${WORK_PLAN_RELATIVE_PATH} from ${workspaceLabel}#${workspaceFingerprint}`;
|
|
84
|
+
return {
|
|
85
|
+
workspacePath,
|
|
86
|
+
filePath: path.join(workspacePath, WORK_PLAN_RELATIVE_PATH),
|
|
87
|
+
path: WORK_PLAN_RELATIVE_PATH,
|
|
88
|
+
workspaceLabel,
|
|
89
|
+
workspaceFingerprint,
|
|
90
|
+
sourceLabel,
|
|
91
|
+
sourceReason: normalizeString(sourceReason),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
function toEpoch(value, fallbackIso = new Date().toISOString()) {
|
|
76
96
|
return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
|
|
77
97
|
}
|
|
@@ -407,9 +427,13 @@ function emptyTaskLedgerSummary() {
|
|
|
407
427
|
};
|
|
408
428
|
}
|
|
409
429
|
|
|
410
|
-
function emptyWorkPlanSummary() {
|
|
430
|
+
function emptyWorkPlanSummary(sourceContext = {}) {
|
|
411
431
|
return {
|
|
412
|
-
path: WORK_PLAN_RELATIVE_PATH,
|
|
432
|
+
path: normalizeString(sourceContext.path) || WORK_PLAN_RELATIVE_PATH,
|
|
433
|
+
workspaceLabel: normalizeString(sourceContext.workspaceLabel),
|
|
434
|
+
workspaceFingerprint: normalizeString(sourceContext.workspaceFingerprint),
|
|
435
|
+
sourceLabel: normalizeString(sourceContext.sourceLabel),
|
|
436
|
+
sourceReason: normalizeString(sourceContext.sourceReason),
|
|
413
437
|
exists: false,
|
|
414
438
|
truncated: false,
|
|
415
439
|
detailSuppressed: false,
|
|
@@ -434,8 +458,11 @@ function shortWorkPlanText(value) {
|
|
|
434
458
|
return `${text.slice(0, 97)}...`;
|
|
435
459
|
}
|
|
436
460
|
|
|
437
|
-
function summarizeWorkPlanMarkdown(
|
|
438
|
-
|
|
461
|
+
function summarizeWorkPlanMarkdown(
|
|
462
|
+
raw = "",
|
|
463
|
+
{ limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false, sourceContext = {} } = {}
|
|
464
|
+
) {
|
|
465
|
+
const summary = emptyWorkPlanSummary(sourceContext);
|
|
439
466
|
summary.exists = true;
|
|
440
467
|
summary.truncated = Boolean(truncated);
|
|
441
468
|
if (summary.truncated) {
|
|
@@ -487,15 +514,19 @@ function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY
|
|
|
487
514
|
return summary;
|
|
488
515
|
}
|
|
489
516
|
|
|
490
|
-
async function readWorkPlanSummary({
|
|
491
|
-
|
|
517
|
+
async function readWorkPlanSummary({
|
|
518
|
+
targetPath = process.cwd(),
|
|
519
|
+
limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT,
|
|
520
|
+
sourceReason = "caller_target_path",
|
|
521
|
+
} = {}) {
|
|
522
|
+
const sourceContext = buildWorkPlanSourceContext(targetPath, { sourceReason });
|
|
492
523
|
try {
|
|
493
|
-
const stats = await fsp.stat(filePath);
|
|
524
|
+
const stats = await fsp.stat(sourceContext.filePath);
|
|
494
525
|
let source = "";
|
|
495
526
|
let truncated = false;
|
|
496
527
|
if (stats.size > MAX_WORK_PLAN_BYTES) {
|
|
497
528
|
truncated = true;
|
|
498
|
-
const handle = await fsp.open(filePath, "r");
|
|
529
|
+
const handle = await fsp.open(sourceContext.filePath, "r");
|
|
499
530
|
try {
|
|
500
531
|
const buffer = Buffer.alloc(MAX_WORK_PLAN_BYTES);
|
|
501
532
|
const position = Math.max(0, stats.size - MAX_WORK_PLAN_BYTES);
|
|
@@ -505,14 +536,18 @@ async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT
|
|
|
505
536
|
await handle.close();
|
|
506
537
|
}
|
|
507
538
|
} else {
|
|
508
|
-
source = await fsp.readFile(filePath, "utf-8");
|
|
539
|
+
source = await fsp.readFile(sourceContext.filePath, "utf-8");
|
|
509
540
|
}
|
|
510
|
-
return summarizeWorkPlanMarkdown(source, {
|
|
541
|
+
return summarizeWorkPlanMarkdown(source, {
|
|
542
|
+
limit,
|
|
543
|
+
truncated,
|
|
544
|
+
sourceContext,
|
|
545
|
+
});
|
|
511
546
|
} catch (error) {
|
|
512
547
|
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
513
|
-
return emptyWorkPlanSummary();
|
|
548
|
+
return emptyWorkPlanSummary(sourceContext);
|
|
514
549
|
}
|
|
515
|
-
return emptyWorkPlanSummary();
|
|
550
|
+
return emptyWorkPlanSummary(sourceContext);
|
|
516
551
|
}
|
|
517
552
|
}
|
|
518
553
|
|
|
@@ -733,9 +768,24 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
|
|
|
733
768
|
}
|
|
734
769
|
const open = Number(workPlan.open || 0);
|
|
735
770
|
const completed = Number(workPlan.completed || 0);
|
|
736
|
-
const
|
|
771
|
+
const sourceText =
|
|
772
|
+
normalizeString(workPlan.sourceLabel) ||
|
|
773
|
+
(() => {
|
|
774
|
+
const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
|
|
775
|
+
const workspaceLabel = normalizeString(workPlan.workspaceLabel);
|
|
776
|
+
const workspaceFingerprint = normalizeString(workPlan.workspaceFingerprint);
|
|
777
|
+
if (!workspaceLabel) {
|
|
778
|
+
return pathText;
|
|
779
|
+
}
|
|
780
|
+
const workspaceText = workspaceFingerprint
|
|
781
|
+
? `${workspaceLabel}#${workspaceFingerprint}`
|
|
782
|
+
: workspaceLabel;
|
|
783
|
+
return `${pathText} from ${workspaceText}`;
|
|
784
|
+
})();
|
|
785
|
+
const sourceReason = normalizeString(workPlan.sourceReason);
|
|
786
|
+
const sourceReasonText = sourceReason ? ` (${sourceReason})` : "";
|
|
737
787
|
if (workPlan.detailSuppressed || workPlan.truncated) {
|
|
738
|
-
return `Plan: ${open} open / ${completed} done in recent ${
|
|
788
|
+
return `Plan: ${open} open / ${completed} done in recent ${sourceText} window${sourceReasonText}. Current/next items suppressed because the plan file is large.`;
|
|
739
789
|
}
|
|
740
790
|
const currentSection = normalizeString(workPlan.currentSection);
|
|
741
791
|
const currentText = currentSection ? ` Current: ${currentSection}.` : "";
|
|
@@ -752,7 +802,7 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
|
|
|
752
802
|
.join("; ")}.`
|
|
753
803
|
: "";
|
|
754
804
|
const truncatedText = workPlan.truncated ? " Recent window only." : "";
|
|
755
|
-
return `Plan: ${open} open / ${completed} done in ${
|
|
805
|
+
return `Plan: ${open} open / ${completed} done in ${sourceText}${sourceReasonText}.${currentText}${nextText}${truncatedText}`;
|
|
756
806
|
}
|
|
757
807
|
|
|
758
808
|
function roundCurrency(value) {
|
|
@@ -911,6 +961,12 @@ export async function buildSessionRecap(
|
|
|
911
961
|
} catch {
|
|
912
962
|
sessionMetadata = null;
|
|
913
963
|
}
|
|
964
|
+
const sessionWorkspaceTargetPath = path.resolve(
|
|
965
|
+
normalizeString(sessionMetadata?.targetPath) || normalizedTargetPath,
|
|
966
|
+
);
|
|
967
|
+
const workPlanSourceReason = normalizeString(sessionMetadata?.targetPath)
|
|
968
|
+
? "session_metadata_target_path"
|
|
969
|
+
: "caller_target_path";
|
|
914
970
|
const sortedEvents = sortEventsByConversationTime(dedupeSessionEvents(allEvents), normalizedNow);
|
|
915
971
|
const usageSummary = normalizeUsageSummary(sortedEvents);
|
|
916
972
|
const events = sortedEvents.slice(-normalizedMaxEvents);
|
|
@@ -955,7 +1011,8 @@ export async function buildSessionRecap(
|
|
|
955
1011
|
targetPath: normalizedTargetPath,
|
|
956
1012
|
});
|
|
957
1013
|
const workPlan = await readWorkPlanSummary({
|
|
958
|
-
targetPath:
|
|
1014
|
+
targetPath: sessionWorkspaceTargetPath,
|
|
1015
|
+
sourceReason: workPlanSourceReason,
|
|
959
1016
|
});
|
|
960
1017
|
const snippets = summarizeRecentActivity(actorEvents, {
|
|
961
1018
|
forAgentId: normalizedForAgentId,
|
package/src/session/store.js
CHANGED
|
@@ -407,6 +407,7 @@ export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString(
|
|
|
407
407
|
function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
|
|
408
408
|
return {
|
|
409
409
|
sessionId: metadata.sessionId,
|
|
410
|
+
targetPath: metadata.targetPath,
|
|
410
411
|
sessionDir: paths.sessionDir,
|
|
411
412
|
metadataPath: paths.metadataPath,
|
|
412
413
|
streamPath: paths.streamPath,
|