sentinelayer-cli 0.33.0 → 0.34.1
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 +11 -1
- package/package.json +1 -1
- package/src/commands/mcp.js +109 -3
- package/src/commands/session.js +18 -0
- package/src/legacy-cli.js +7 -1
- package/src/mcp/doctor.js +247 -0
- package/src/scan/generator.js +1 -1
- package/src/session/recap.js +73 -16
- package/src/session/remote-hydrate.js +32 -1
- package/src/session/store.js +1 -0
package/README.md
CHANGED
|
@@ -712,6 +712,16 @@ Architecture references: local stdio runtime and incident response live in
|
|
|
712
712
|
[docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); Senti room
|
|
713
713
|
coordination behavior lives in [docs/sessions.md](docs/sessions.md).
|
|
714
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
|
+
|
|
715
725
|
### MCP Live Entry Point Map
|
|
716
726
|
|
|
717
727
|
- Local stdio server: `sl mcp server run --path .`, configured from
|
|
@@ -820,7 +830,7 @@ gh secret set SENTINELAYER_TOKEN --repo <owner/repo>
|
|
|
820
830
|
gh secret list --repo <owner/repo>
|
|
821
831
|
```
|
|
822
832
|
|
|
823
|
-
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`
|
|
824
834
|
|
|
825
835
|
4. BYOK mode (no Sentinelayer token):
|
|
826
836
|
- keep generated `docs/spec.md`, `docs/build-guide.md`, `prompts/execution-prompt.md`, and `tasks/todo.md`
|
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
import { requestHostedMcpAccessToken } from "../mcp/token-service.js";
|
|
32
32
|
import { buildSentinelayerCliRegistryTemplate } from "../mcp/cli-registry.js";
|
|
33
33
|
import { runMcpStdioServer } from "../mcp/session-stdio-server.js";
|
|
34
|
+
import { runMcpDoctorProbes } from "../mcp/doctor.js";
|
|
35
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
34
36
|
import { resolveOutputRoot } from "../config/service.js";
|
|
35
37
|
|
|
36
38
|
function shouldEmitJson(options, command) {
|
|
@@ -66,8 +68,52 @@ function formatApiError(error) {
|
|
|
66
68
|
return `${error.message} [${error.code}] status=${error.status}${requestId}`;
|
|
67
69
|
}
|
|
68
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
|
+
|
|
69
96
|
export function registerMcpCommand(program) {
|
|
70
|
-
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
|
+
);
|
|
71
117
|
|
|
72
118
|
mcp
|
|
73
119
|
.command("list")
|
|
@@ -184,6 +230,65 @@ export function registerMcpCommand(program) {
|
|
|
184
230
|
console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
|
|
185
231
|
});
|
|
186
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
|
+
|
|
187
292
|
const schema = mcp.command("schema").description("Inspect or materialize MCP tool-registry schema");
|
|
188
293
|
schema
|
|
189
294
|
.command("show")
|
|
@@ -371,7 +476,7 @@ export function registerMcpCommand(program) {
|
|
|
371
476
|
|
|
372
477
|
registry
|
|
373
478
|
.command("init-cli")
|
|
374
|
-
.description("Write
|
|
479
|
+
.description("Write guarded sl.* CLI bridge metadata for bridge-capable MCP hosts")
|
|
375
480
|
.option("--path <path>", "Destination file path override")
|
|
376
481
|
.option("--output-dir <path>", "Optional artifact output root override")
|
|
377
482
|
.option("--force", "Overwrite destination file if it already exists")
|
|
@@ -399,7 +504,8 @@ export function registerMcpCommand(program) {
|
|
|
399
504
|
}
|
|
400
505
|
console.log(pc.green(`Wrote SentinelLayer CLI MCP registry template: ${writtenPath}`));
|
|
401
506
|
console.log(pc.gray(`${template.tools.length} CLI leaf commands exposed as bridge tools.`));
|
|
402
|
-
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."));
|
|
403
509
|
});
|
|
404
510
|
|
|
405
511
|
registry
|
package/src/commands/session.js
CHANGED
|
@@ -755,6 +755,14 @@ function formatHumanAskLine(event = {}) {
|
|
|
755
755
|
return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
function formatRemoteHydrateAppendWarning(result = {}) {
|
|
759
|
+
const count = Number(result?.appendFailureCount || 0);
|
|
760
|
+
if (!Number.isFinite(count) || count <= 0) return "";
|
|
761
|
+
const reason = normalizeString(result.appendFailureReason) || "local_append_failed";
|
|
762
|
+
const noun = count === 1 ? "event" : "events";
|
|
763
|
+
return `Remote hydrate could not persist ${count} ${noun} locally (${reason}); affected cursors were not advanced.`;
|
|
764
|
+
}
|
|
765
|
+
|
|
758
766
|
function eventTimestampMs(event = {}) {
|
|
759
767
|
for (const key of ["ts", "timestamp", "createdAt", "at"]) {
|
|
760
768
|
const epoch = Date.parse(normalizeString(event?.[key]));
|
|
@@ -5481,6 +5489,10 @@ export function registerSessionCommand(program) {
|
|
|
5481
5489
|
),
|
|
5482
5490
|
);
|
|
5483
5491
|
}
|
|
5492
|
+
const appendWarning = formatRemoteHydrateAppendWarning(hydration);
|
|
5493
|
+
if (appendWarning) {
|
|
5494
|
+
console.log(pc.yellow(appendWarning));
|
|
5495
|
+
}
|
|
5484
5496
|
} else {
|
|
5485
5497
|
console.log(
|
|
5486
5498
|
pc.yellow(
|
|
@@ -5847,6 +5859,8 @@ export function registerSessionCommand(program) {
|
|
|
5847
5859
|
eventsCursor: result.eventsCursor,
|
|
5848
5860
|
materializedLocalSession: result.materializedLocalSession,
|
|
5849
5861
|
localAppendComplete: result.localAppendComplete,
|
|
5862
|
+
appendFailureCount: result.appendFailureCount,
|
|
5863
|
+
appendFailureReason: result.appendFailureReason,
|
|
5850
5864
|
access: access || undefined,
|
|
5851
5865
|
};
|
|
5852
5866
|
if (shouldEmitJson(options, command)) {
|
|
@@ -5857,6 +5871,10 @@ export function registerSessionCommand(program) {
|
|
|
5857
5871
|
console.log(
|
|
5858
5872
|
`Hydrated session ${normalizedSessionId}: relayed=${result.relayed} dropped=${result.dropped}.`,
|
|
5859
5873
|
);
|
|
5874
|
+
const appendWarning = formatRemoteHydrateAppendWarning(result);
|
|
5875
|
+
if (appendWarning) {
|
|
5876
|
+
console.log(pc.yellow(appendWarning));
|
|
5877
|
+
}
|
|
5860
5878
|
if (access && !access.accessible) {
|
|
5861
5879
|
if (access.reason === "session_not_found") {
|
|
5862
5880
|
console.log(
|
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
|
+
}
|
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,
|
|
@@ -38,6 +38,7 @@ import { isSessionControlEvent } from "./control-events.js";
|
|
|
38
38
|
const EVENTS_CURSOR_SUFFIX = "events";
|
|
39
39
|
const DEFAULT_EVENT_PAGE_LIMIT = 200;
|
|
40
40
|
const DEFAULT_MAX_EVENT_PAGES = 25;
|
|
41
|
+
const MAX_APPEND_FAILURE_REASON_LENGTH = 160;
|
|
41
42
|
|
|
42
43
|
function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
|
|
43
44
|
if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
|
|
@@ -163,6 +164,28 @@ function normalizePositiveInteger(value, fallbackValue) {
|
|
|
163
164
|
return Math.floor(normalized);
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
function localAppendFailureReason(error) {
|
|
168
|
+
const message = error?.message || String(error || "");
|
|
169
|
+
let reason = String(message).trim();
|
|
170
|
+
if (!reason) return "local_append_failed";
|
|
171
|
+
reason = reason.replace(
|
|
172
|
+
/(["'])(?:[A-Za-z]:\\|\/(?:Users|home|tmp|var|private|mnt|workspace)\/).*?\1/g,
|
|
173
|
+
"$1[path]$1",
|
|
174
|
+
);
|
|
175
|
+
reason = reason.replace(/[A-Za-z]:\\[^,;\r\n)]+/g, "[path]");
|
|
176
|
+
reason = reason.replace(/(^|[\s"'(])\/(?:Users|home|tmp|var|private|mnt|workspace)\/[^\s,;)"']+/g, "$1[path]");
|
|
177
|
+
reason = reason.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
178
|
+
reason = reason.replace(
|
|
179
|
+
/\b([A-Za-z0-9_-]*(?:api[_-]?key|authorization|password|secret|token)[A-Za-z0-9_-]*)\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,;]+)/gi,
|
|
180
|
+
"$1=[REDACTED]",
|
|
181
|
+
);
|
|
182
|
+
reason = reason.replace(/\b[A-Za-z0-9._~+/=-]{32,}\b/g, "[REDACTED]");
|
|
183
|
+
if (reason.length > MAX_APPEND_FAILURE_REASON_LENGTH) {
|
|
184
|
+
reason = `${reason.slice(0, MAX_APPEND_FAILURE_REASON_LENGTH - 3)}...`;
|
|
185
|
+
}
|
|
186
|
+
return reason || "local_append_failed";
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
async function pollSessionEventPages({
|
|
167
190
|
sessionId,
|
|
168
191
|
targetPath,
|
|
@@ -436,6 +459,8 @@ export async function hydrateSessionFromRemote({
|
|
|
436
459
|
mergeExisting: eventsNeedingMerge.has(event),
|
|
437
460
|
}));
|
|
438
461
|
const failedRelayKeys = new Set();
|
|
462
|
+
let appendFailureCount = 0;
|
|
463
|
+
let appendFailureReason = "";
|
|
439
464
|
for (const { event, mergeExisting } of appendEvents) {
|
|
440
465
|
try {
|
|
441
466
|
const persisted = await _append(sessionId, event, {
|
|
@@ -453,7 +478,11 @@ export async function hydrateSessionFromRemote({
|
|
|
453
478
|
persistedEvent,
|
|
454
479
|
existingRelayEvents.length - 1,
|
|
455
480
|
);
|
|
456
|
-
} catch {
|
|
481
|
+
} catch (error) {
|
|
482
|
+
appendFailureCount += 1;
|
|
483
|
+
if (!appendFailureReason) {
|
|
484
|
+
appendFailureReason = localAppendFailureReason(error);
|
|
485
|
+
}
|
|
457
486
|
addSessionEventIdentityKeys(failedRelayKeys, event);
|
|
458
487
|
// Append errors are observable via the stream but should not
|
|
459
488
|
// abort the rest of the batch — partial relay is still progress.
|
|
@@ -492,6 +521,8 @@ export async function hydrateSessionFromRemote({
|
|
|
492
521
|
eventsBackfillReason: eventsResult?.complete === false ? eventsResult?.reason || "" : "",
|
|
493
522
|
materializedLocalSession,
|
|
494
523
|
localAppendComplete: humanCursorSafe && eventsCursorSafe,
|
|
524
|
+
appendFailureCount,
|
|
525
|
+
appendFailureReason,
|
|
495
526
|
remoteStatus: remoteStatus || null,
|
|
496
527
|
};
|
|
497
528
|
}
|
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,
|