sentinelayer-cli 0.44.0 → 0.45.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 +8 -4
- package/package.json +1 -1
- package/src/agents/jules/tools/aidenid-email.js +7 -9
- package/src/agents/jules/tools/frontend-analyze.js +15 -3
- package/src/agents/jules/tools/runtime-audit.js +51 -21
- package/src/ai/aidenid.js +5 -4
- package/src/ai/proxy.js +15 -14
- package/src/audit/orchestrator.js +8 -5
- package/src/auth/admission-scope.js +5 -3
- package/src/auth/credential-destinations.js +221 -0
- package/src/auth/gate.js +4 -4
- package/src/auth/http.js +22 -5
- package/src/auth/service.js +52 -56
- package/src/cli.js +14 -6
- package/src/commands/ai/provision-governance.js +7 -6
- package/src/commands/init.js +25 -16
- package/src/commands/scan.js +6 -4
- package/src/commands/session.js +33 -45
- package/src/commands/spec.js +2 -2
- package/src/commands/watch.js +4 -3
- package/src/config/service.js +37 -0
- package/src/daemon/pulse.js +2 -1
- package/src/daemon/watchdog.js +117 -68
- package/src/legacy-cli.js +119 -39
- package/src/mcp/bridge-inputs.js +223 -0
- package/src/mcp/cli-command-tools.js +157 -7
- package/src/mcp/registry.js +2 -1
- package/src/mcp/session-stdio-server.js +114 -35
- package/src/mcp/smoke.js +27 -11
- package/src/mcp/token-service.js +7 -9
- package/src/memory/retrieval.js +14 -5
- package/src/pocket/ring-owner.js +13 -8
- package/src/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/session/admission-access.js +13 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +44 -9
- package/src/session/checkpoints.js +11 -10
- package/src/session/file-locks.js +19 -18
- package/src/session/invitations.js +26 -19
- package/src/session/live-source.js +13 -13
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +70 -56
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- package/src/telemetry/sync.js +8 -9
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import process from "node:process";
|
|
5
6
|
|
|
7
|
+
import { bridgeAllowedInputs } from "./bridge-inputs.js";
|
|
6
8
|
import { buildSentinelayerCliRegistryTemplate } from "./cli-registry.js";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -54,6 +56,11 @@ const SENSITIVE_COMMAND_PATHS = new Set([
|
|
|
54
56
|
"ai.identity.events",
|
|
55
57
|
"ai.identity.latest",
|
|
56
58
|
"ai.identity.wait-for-otp",
|
|
59
|
+
// Resumes a local Codex session: --message-file and --cwd name files it reads (a dry run returns
|
|
60
|
+
// the message), and --model and --last choose what runs. Blocked outright, not only behind the
|
|
61
|
+
// approval gate; unblocking it needs an argument-bound approval, workspace-scoped paths, and a
|
|
62
|
+
// model and session that the caller cannot choose freely.
|
|
63
|
+
"session.wake.codex",
|
|
57
64
|
]);
|
|
58
65
|
|
|
59
66
|
// Whole governance sub-trees under ai.identity (domain/target/site/legal-hold) are live
|
|
@@ -70,6 +77,10 @@ const SENSITIVE_COMMAND_PREFIXES = [
|
|
|
70
77
|
];
|
|
71
78
|
const REDACTION_MARKER = "[REDACTED]";
|
|
72
79
|
|
|
80
|
+
// Which inputs each command exposes is an explicit, default-deny list: src/mcp/bridge-inputs.js.
|
|
81
|
+
// Nothing there names where a request goes, so a tool caller never chooses a destination for this
|
|
82
|
+
// machine's credentials.
|
|
83
|
+
|
|
73
84
|
function normalizeString(value) {
|
|
74
85
|
return String(value == null ? "" : value).trim();
|
|
75
86
|
}
|
|
@@ -117,11 +128,21 @@ function optionFlagFromFlags(flags) {
|
|
|
117
128
|
return short ? short[0] : "";
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
// An input that names a file or directory: by its placeholder (<path>, <file>, <dir>) or its name.
|
|
132
|
+
const PATH_PLACEHOLDER_RE = /<(?:[a-z-]*-)?(?:path|file|dir|directory|folder)s?(?:\.\.\.)?>/i;
|
|
133
|
+
const PATH_NAME_RE = /^(?:cwd|path|paths|file|files|dir|out|output|plan|spec)$|^(?!(?:max|min|num)[A-Z]).*(?:Path|File|Dir|Directory|Folder)s?$/;
|
|
134
|
+
|
|
135
|
+
function isPathLikeInput(name, flags = "") {
|
|
136
|
+
return PATH_NAME_RE.test(String(name || "")) || PATH_PLACEHOLDER_RE.test(String(flags || ""));
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
function normalizePositionalSpec(argument = {}, index = 0) {
|
|
140
|
+
const name = normalizePropertyName(argument.name, `arg${index + 1}`);
|
|
121
141
|
return {
|
|
122
|
-
name
|
|
142
|
+
name,
|
|
123
143
|
required: Boolean(argument.required),
|
|
124
144
|
variadic: Boolean(argument.variadic),
|
|
145
|
+
pathLike: Boolean(argument.pathLike) || isPathLikeInput(name),
|
|
125
146
|
};
|
|
126
147
|
}
|
|
127
148
|
|
|
@@ -136,6 +157,7 @@ function normalizeOptionSpec(option = {}, index = 0) {
|
|
|
136
157
|
expectsValue,
|
|
137
158
|
variadic: Boolean(option.variadic),
|
|
138
159
|
json: name === "json" || flag === "--json",
|
|
160
|
+
pathLike: Boolean(option.pathLike) || (expectsValue && isPathLikeInput(name, option.flags)),
|
|
139
161
|
};
|
|
140
162
|
}
|
|
141
163
|
|
|
@@ -227,6 +249,52 @@ function isAllowedInputValue(value) {
|
|
|
227
249
|
return false;
|
|
228
250
|
}
|
|
229
251
|
|
|
252
|
+
function realpathOfNearestExisting(target) {
|
|
253
|
+
let current = target;
|
|
254
|
+
for (;;) {
|
|
255
|
+
try {
|
|
256
|
+
return fs.realpathSync.native(current);
|
|
257
|
+
} catch {
|
|
258
|
+
const parent = path.dirname(current);
|
|
259
|
+
if (parent === current) return current;
|
|
260
|
+
current = parent;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isInsideRoot(root, candidate) {
|
|
266
|
+
const relative = path.relative(root, candidate);
|
|
267
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// An input that names a file or directory stays inside the workspace: it is relative, does not
|
|
271
|
+
// leave the workspace with "..", and does not leave it through a symlink (the nearest existing
|
|
272
|
+
// ancestor's real path is checked). Returns the offending input's name, or "".
|
|
273
|
+
function workspacePathInputError(tool = {}, input = {}, targetPath = process.cwd()) {
|
|
274
|
+
const metadata = tool.metadata || {};
|
|
275
|
+
const specs = [
|
|
276
|
+
...(Array.isArray(metadata.positional) ? metadata.positional : []),
|
|
277
|
+
...(Array.isArray(metadata.options) ? metadata.options : []),
|
|
278
|
+
].filter((spec) => spec.pathLike);
|
|
279
|
+
if (specs.length === 0) return "";
|
|
280
|
+
const root = realpathOfNearestExisting(path.resolve(String(targetPath || ".")));
|
|
281
|
+
for (const spec of specs) {
|
|
282
|
+
const value = input[spec.name];
|
|
283
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
284
|
+
if (typeof item !== "string" || !item.trim()) continue;
|
|
285
|
+
const raw = item.trim();
|
|
286
|
+
if (path.isAbsolute(raw) || path.win32.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || raw.startsWith("~")) {
|
|
287
|
+
return spec.name;
|
|
288
|
+
}
|
|
289
|
+
const resolved = path.resolve(root, raw);
|
|
290
|
+
if (!isInsideRoot(root, resolved) || !isInsideRoot(root, realpathOfNearestExisting(resolved))) {
|
|
291
|
+
return spec.name;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
|
|
230
298
|
function validateBridgeToolInput(tool = {}, input = {}) {
|
|
231
299
|
if (!isPlainObject(input)) {
|
|
232
300
|
return "input_must_be_object";
|
|
@@ -241,9 +309,59 @@ function validateBridgeToolInput(tool = {}, input = {}) {
|
|
|
241
309
|
return `unsupported_input_type:${key}`;
|
|
242
310
|
}
|
|
243
311
|
}
|
|
312
|
+
const dashKey = dashLeadingValueKey(tool, input);
|
|
313
|
+
if (dashKey) {
|
|
314
|
+
return `unsupported_input_value:${dashKey}`;
|
|
315
|
+
}
|
|
316
|
+
return "";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Positional and option values are copied into argv verbatim, and a value that starts with
|
|
320
|
+
// "-" would be parsed as command-line syntax rather than as a value. The bridge does not
|
|
321
|
+
// accept such values; the direct CLI remains available for them.
|
|
322
|
+
function dashLeadingValueKey(tool = {}, input = {}) {
|
|
323
|
+
const metadata = tool.metadata || {};
|
|
324
|
+
const names = [
|
|
325
|
+
...(Array.isArray(metadata.positional) ? metadata.positional : []).map((spec) => spec.name),
|
|
326
|
+
...(Array.isArray(metadata.options) ? metadata.options : [])
|
|
327
|
+
.filter((spec) => spec.expectsValue && !spec.json)
|
|
328
|
+
.map((spec) => spec.name),
|
|
329
|
+
];
|
|
330
|
+
for (const name of names) {
|
|
331
|
+
const value = input[name];
|
|
332
|
+
const items = Array.isArray(value) ? value : [value];
|
|
333
|
+
if (items.some((item) => typeof item === "string" && item.trimStart().startsWith("-"))) return name;
|
|
334
|
+
if (items.some((item) => typeof item === "number" && item < 0)) return name;
|
|
335
|
+
}
|
|
244
336
|
return "";
|
|
245
337
|
}
|
|
246
338
|
|
|
339
|
+
// The inputs a command does not expose, and whether that leaves it unusable through the bridge:
|
|
340
|
+
// it has no entry, or an argument or a required option is not listed.
|
|
341
|
+
function unlistedInputs(commandPathKey, schema = {}, positionalSource = [], optionSource = []) {
|
|
342
|
+
const allowed = bridgeAllowedInputs(commandPathKey);
|
|
343
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
344
|
+
const withheld = new Set();
|
|
345
|
+
let unusable = !allowed;
|
|
346
|
+
for (const argument of positionalSource) {
|
|
347
|
+
if (!allowed?.has(argument.name)) unusable = true;
|
|
348
|
+
}
|
|
349
|
+
for (const option of optionSource) {
|
|
350
|
+
const { name } = normalizeOptionSpec(option);
|
|
351
|
+
if (allowed?.has(name)) continue;
|
|
352
|
+
withheld.add(name);
|
|
353
|
+
if (required.has(name)) unusable = true;
|
|
354
|
+
}
|
|
355
|
+
return { withheld, unusable };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function withoutInputs(schema = {}, names = new Set()) {
|
|
359
|
+
if (names.size === 0) return schema;
|
|
360
|
+
const properties = Object.fromEntries(Object.entries(schema.properties || {}).filter(([key]) => !names.has(key)));
|
|
361
|
+
const required = (Array.isArray(schema.required) ? schema.required : []).filter((key) => !names.has(key));
|
|
362
|
+
return { ...schema, properties, ...(Array.isArray(schema.required) ? { required } : {}) };
|
|
363
|
+
}
|
|
364
|
+
|
|
247
365
|
function normalizeRegistryTool(tool = {}) {
|
|
248
366
|
const metadata = tool.metadata || {};
|
|
249
367
|
const cliPath = Array.isArray(metadata.cliPath)
|
|
@@ -258,19 +376,28 @@ function normalizeRegistryTool(tool = {}) {
|
|
|
258
376
|
? metadata.arguments
|
|
259
377
|
: [];
|
|
260
378
|
const optionSource = Array.isArray(metadata.options) ? metadata.options : [];
|
|
261
|
-
const
|
|
379
|
+
const rawSchema = normalizeInputSchema(tool);
|
|
380
|
+
const { withheld, unusable } = unlistedInputs(commandPathKey, rawSchema, positionalSource, optionSource);
|
|
381
|
+
const options = optionSource
|
|
382
|
+
.map(normalizeOptionSpec)
|
|
383
|
+
.filter((option) => option.flag && !withheld.has(option.name));
|
|
262
384
|
const name = normalizeToolName(tool.name || commandPathKey);
|
|
263
|
-
const blockedReason =
|
|
385
|
+
const blockedReason =
|
|
386
|
+
blockedReasonForCommandPath(commandPathKey) || (unusable ? "blocked_unlisted_input" : "");
|
|
387
|
+
// The approval flag is enforced, not described: a tool that requires human approval (or whose
|
|
388
|
+
// flag is missing) is runtime-blocked until a server-validated, argument-bound approval exists.
|
|
389
|
+
const approvalRequired = tool.security?.requires_human_approval !== false;
|
|
390
|
+
const runtimeBlockReason = blockedReason || (approvalRequired ? "approval_required" : "");
|
|
264
391
|
const security = {
|
|
265
392
|
...(tool.security || {}),
|
|
266
|
-
...(
|
|
393
|
+
...(runtimeBlockReason ? { runtime_blocked: true, runtime_block_reason: runtimeBlockReason } : {}),
|
|
267
394
|
};
|
|
268
395
|
|
|
269
396
|
return {
|
|
270
397
|
name,
|
|
271
398
|
title: tool.title || `sl ${cliPath.join(" ")}`.trim(),
|
|
272
399
|
description: tool.description || `Run SentinelLayer CLI command sl ${cliPath.join(" ")}.`,
|
|
273
|
-
inputSchema:
|
|
400
|
+
inputSchema: withoutInputs(rawSchema, withheld),
|
|
274
401
|
security,
|
|
275
402
|
annotations: {
|
|
276
403
|
...(tool.annotations || {}),
|
|
@@ -283,9 +410,11 @@ function normalizeRegistryTool(tool = {}) {
|
|
|
283
410
|
cliPath,
|
|
284
411
|
positional: positionalSource.map(normalizePositionalSpec),
|
|
285
412
|
options,
|
|
413
|
+
withheldOptions: [...withheld].sort(),
|
|
286
414
|
supportsJson: options.some((option) => option.json),
|
|
287
415
|
blocked: Boolean(metadata.blocked) || Boolean(blockedReason),
|
|
288
416
|
blockedReason: metadata.blockedReason || blockedReason,
|
|
417
|
+
approvalRequired,
|
|
289
418
|
},
|
|
290
419
|
};
|
|
291
420
|
}
|
|
@@ -488,6 +617,10 @@ export function createCliCommandMcpToolHandlers(
|
|
|
488
617
|
targetPath = process.cwd(),
|
|
489
618
|
executeCliCommandFn = executeCliCommand,
|
|
490
619
|
env = process.env,
|
|
620
|
+
// Every CLI tool requires human approval, and no server-validated, argument-bound approval
|
|
621
|
+
// exists yet, so none is approved. Tests pass a validator to exercise the bridge itself; the
|
|
622
|
+
// MCP server passes none, and its dispatcher refuses these tools before any handler runs.
|
|
623
|
+
approve = () => false,
|
|
491
624
|
} = {},
|
|
492
625
|
) {
|
|
493
626
|
const handlers = {};
|
|
@@ -510,6 +643,21 @@ export function createCliCommandMcpToolHandlers(
|
|
|
510
643
|
tool: tool.name,
|
|
511
644
|
};
|
|
512
645
|
}
|
|
646
|
+
// a blocked tool, or one without approval, is refused before its inputs are looked at
|
|
647
|
+
if (tool.metadata.blocked) {
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
reason: tool.metadata.blockedReason || "blocked_cli_command",
|
|
651
|
+
tool: tool.name,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (tool.metadata.approvalRequired !== false && !approve(tool, input)) {
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
reason: "approval_required",
|
|
658
|
+
tool: tool.name,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
513
661
|
const inputError = validateBridgeToolInput(tool, input);
|
|
514
662
|
if (inputError) {
|
|
515
663
|
return {
|
|
@@ -519,10 +667,12 @@ export function createCliCommandMcpToolHandlers(
|
|
|
519
667
|
tool: tool.name,
|
|
520
668
|
};
|
|
521
669
|
}
|
|
522
|
-
|
|
670
|
+
const pathInput = workspacePathInputError(tool, input, targetPath);
|
|
671
|
+
if (pathInput) {
|
|
523
672
|
return {
|
|
524
673
|
ok: false,
|
|
525
|
-
reason:
|
|
674
|
+
reason: "path_outside_workspace",
|
|
675
|
+
detail: pathInput,
|
|
526
676
|
tool: tool.name,
|
|
527
677
|
};
|
|
528
678
|
}
|
package/src/mcp/registry.js
CHANGED
|
@@ -896,13 +896,14 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
896
896
|
name: "session_locks",
|
|
897
897
|
title: "List Senti File Locks",
|
|
898
898
|
description:
|
|
899
|
-
"List active authoritative file leases for a session.",
|
|
899
|
+
"List active authoritative file leases for a session. In an agent context it runs on the agent's admission (agentId, else SENTINELAYER_AGENT_ID).",
|
|
900
900
|
input_schema: {
|
|
901
901
|
type: "object",
|
|
902
902
|
additionalProperties: false,
|
|
903
903
|
required: ["sessionId"],
|
|
904
904
|
properties: {
|
|
905
905
|
sessionId: { type: "string" },
|
|
906
|
+
agentId: { type: "string" },
|
|
906
907
|
},
|
|
907
908
|
},
|
|
908
909
|
transport: {
|
|
@@ -13,7 +13,14 @@ import { eventMatchesAgent } from "../session/listener.js";
|
|
|
13
13
|
import { isSessionControlEvent } from "../session/control-events.js";
|
|
14
14
|
import { buildObservations } from "../session/recall/observations.js";
|
|
15
15
|
import { createMemoryService } from "../engram/index.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
AdmissionCredentialRefused,
|
|
18
|
+
assertCanonicalAgentId,
|
|
19
|
+
canonicalAgentId,
|
|
20
|
+
withAgentAdmission,
|
|
21
|
+
} from "../session/admission-auth.js";
|
|
22
|
+
import { hasStoredAdmissionCredentials, storedAdmissionAgentsForSession } from "../session/admission.js";
|
|
23
|
+
import { sessionRouteClass } from "../session/route-classes.js";
|
|
17
24
|
import {
|
|
18
25
|
SESSION_REACTION_TYPES,
|
|
19
26
|
isSessionReactionType,
|
|
@@ -886,43 +893,63 @@ async function persistSessionEvent({
|
|
|
886
893
|
};
|
|
887
894
|
}
|
|
888
895
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
"poll_inbox",
|
|
897
|
-
"read_history",
|
|
898
|
-
"send_message",
|
|
899
|
-
"attention_request",
|
|
900
|
-
"session_action",
|
|
901
|
-
"session_react",
|
|
902
|
-
"session_reply",
|
|
903
|
-
"session_lock",
|
|
904
|
-
"session_unlock",
|
|
905
|
-
]);
|
|
896
|
+
function refuseOutsideAdmission(reason) {
|
|
897
|
+
throw new AdmissionCredentialRefused(
|
|
898
|
+
`This tool runs for an agent on this machine and needs an admission it can bind to: ${reason}. ` +
|
|
899
|
+
`It will not fall back to other credentials.`,
|
|
900
|
+
{ reason: "session_admission_not_bound" },
|
|
901
|
+
);
|
|
902
|
+
}
|
|
906
903
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
904
|
+
/**
|
|
905
|
+
* An agent context is a process that acts for an agent: SENTINELAYER_AGENT_ID is set, or
|
|
906
|
+
* this machine stores any agent admission credential (usable or not).
|
|
907
|
+
*/
|
|
908
|
+
export async function isAgentContext({ env = process.env } = {}) {
|
|
909
|
+
return Boolean(normalizeString(env.SENTINELAYER_AGENT_ID)) || (await hasStoredAdmissionCredentials());
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* The one credential policy for local MCP session tools, by the tool's class in
|
|
914
|
+
* SESSION_ROUTE_CLASSES (src/session/route-classes.js):
|
|
915
|
+
* - exempt-local tools, and every tool outside an agent context: run as before
|
|
916
|
+
* - owner tools: refused in an agent context
|
|
917
|
+
* - the session stores an admission on this machine (in any state): the tool binds to the
|
|
918
|
+
* admission of the agent it names (input agentId, else SENTINELAYER_AGENT_ID) and runs
|
|
919
|
+
* inside its scope; naming no agent, an agent holding none of the session's admissions,
|
|
920
|
+
* or an expired, malformed or mis-bound admission is refused before any request
|
|
921
|
+
* - the session stores none: legacy-data-plane tools run as before; control, owner and
|
|
922
|
+
* unclassified (including newly added) tools are refused before any request
|
|
923
|
+
*/
|
|
924
|
+
export async function runInSessionToolContext(name, input = {}, run, { env = process.env } = {}) {
|
|
925
|
+
const routeClass = sessionRouteClass(`mcp:${name}`);
|
|
926
|
+
if (routeClass === "exempt-local") return run(input);
|
|
927
|
+
if (!(await isAgentContext({ env }))) return run(input);
|
|
928
|
+
if (routeClass === "owner") refuseOutsideAdmission("owner actions are not available to agents");
|
|
929
|
+
const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
|
|
930
|
+
if (!sessionId) refuseOutsideAdmission("no session id was given");
|
|
931
|
+
const stored = await storedAdmissionAgentsForSession(sessionId);
|
|
932
|
+
if (stored.length === 0) {
|
|
933
|
+
if (routeClass === "legacy-data-plane") return run(input);
|
|
934
|
+
refuseOutsideAdmission(`session ${sessionId} stores no admission on this machine and "${name}" needs one`);
|
|
935
|
+
}
|
|
936
|
+
const raw = normalizeString(input.agentId || input.agent_id || input.agent) || normalizeString(env.SENTINELAYER_AGENT_ID);
|
|
937
|
+
if (!raw) refuseOutsideAdmission("no agent id was given");
|
|
912
938
|
// One canonical identity for authorisation and execution (see admission-auth.js).
|
|
913
939
|
assertCanonicalAgentId(raw, "agentId");
|
|
914
|
-
|
|
940
|
+
const agentId = canonicalAgentId(raw);
|
|
941
|
+
if (!stored.includes(agentId)) {
|
|
942
|
+
refuseOutsideAdmission(`agent "${agentId}" holds none of the admissions stored for session ${sessionId}`);
|
|
943
|
+
}
|
|
944
|
+
// live: runs inside the admission scope; an expired, malformed or mis-bound one is refused there.
|
|
945
|
+
// The file was seen above, so one removed since then refuses too: never a fallback.
|
|
946
|
+
return withAgentAdmission(sessionId, agentId, () => run(input), { requireStored: true });
|
|
915
947
|
}
|
|
916
948
|
|
|
917
|
-
function
|
|
918
|
-
for (const name of
|
|
949
|
+
export function routeAgentToolsThroughAdmission(handlers, { env = process.env } = {}) {
|
|
950
|
+
for (const name of Object.keys(handlers)) {
|
|
919
951
|
const run = handlers[name];
|
|
920
|
-
handlers[name] =
|
|
921
|
-
const agentId = actorAgentId(name, input);
|
|
922
|
-
// No agent named: the tool's own validation reports it (read_history reads as the user).
|
|
923
|
-
if (!agentId) return run(input);
|
|
924
|
-
return withAgentAdmission(requireSessionId(input), agentId, () => run(input));
|
|
925
|
-
};
|
|
952
|
+
handlers[name] = (input = {}) => runInSessionToolContext(name, input, run, { env });
|
|
926
953
|
}
|
|
927
954
|
return handlers;
|
|
928
955
|
}
|
|
@@ -1336,7 +1363,7 @@ export function createSessionMcpToolHandlers({
|
|
|
1336
1363
|
});
|
|
1337
1364
|
},
|
|
1338
1365
|
};
|
|
1339
|
-
return
|
|
1366
|
+
return routeAgentToolsThroughAdmission(handlers);
|
|
1340
1367
|
}
|
|
1341
1368
|
|
|
1342
1369
|
export const SESSION_MCP_TOOLS = Object.freeze([
|
|
@@ -1625,13 +1652,14 @@ export const SESSION_MCP_TOOLS = Object.freeze([
|
|
|
1625
1652
|
name: "session_locks",
|
|
1626
1653
|
title: "List Senti File Locks",
|
|
1627
1654
|
description:
|
|
1628
|
-
"List active authoritative file leases for a session.",
|
|
1655
|
+
"List active authoritative file leases for a session. In an agent context it runs on the agent's admission (agentId, else SENTINELAYER_AGENT_ID).",
|
|
1629
1656
|
inputSchema: {
|
|
1630
1657
|
type: "object",
|
|
1631
1658
|
additionalProperties: false,
|
|
1632
1659
|
required: ["sessionId"],
|
|
1633
1660
|
properties: {
|
|
1634
1661
|
sessionId: { type: "string", minLength: 1 },
|
|
1662
|
+
agentId: { type: "string", minLength: 1 },
|
|
1635
1663
|
},
|
|
1636
1664
|
},
|
|
1637
1665
|
},
|
|
@@ -1712,6 +1740,45 @@ function jsonRpcError(id, code, message, data = undefined) {
|
|
|
1712
1740
|
});
|
|
1713
1741
|
}
|
|
1714
1742
|
|
|
1743
|
+
// The dispatcher enforces each tool's published policy before its handler runs. A tool that
|
|
1744
|
+
// requires human approval is refused: approval would have to be server-validated and bound to the
|
|
1745
|
+
// call's arguments, and no such approval exists yet. A runtime-blocked tool is refused too. Refused
|
|
1746
|
+
// tools are not advertised by tools/list, and a call to one gets a specific JSON-RPC error.
|
|
1747
|
+
export const MCP_HUMAN_APPROVAL_REQUIRED = Object.freeze({ code: -32001, reason: "human_approval_required" });
|
|
1748
|
+
export const MCP_TOOL_BLOCKED = Object.freeze({ code: -32002, reason: "tool_blocked" });
|
|
1749
|
+
|
|
1750
|
+
function toolRefusal(tool) {
|
|
1751
|
+
const security = isPlainObject(tool?.security) ? tool.security : {};
|
|
1752
|
+
if (security.requires_human_approval === true) return MCP_HUMAN_APPROVAL_REQUIRED.reason;
|
|
1753
|
+
if (tool?.metadata?.bridge === "cli-command" && security.requires_human_approval !== false) {
|
|
1754
|
+
return MCP_HUMAN_APPROVAL_REQUIRED.reason;
|
|
1755
|
+
}
|
|
1756
|
+
if (security.runtime_blocked === true || tool?.metadata?.blocked === true) return MCP_TOOL_BLOCKED.reason;
|
|
1757
|
+
return "";
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function terminalCommandFor(tool, toolName) {
|
|
1761
|
+
const cliPath = Array.isArray(tool?.metadata?.cliPath) ? tool.metadata.cliPath : toolName.replace(/^sl\./, "").split(".");
|
|
1762
|
+
return `sl ${cliPath.join(" ")}`;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function refusalError(id, toolName, tool, reason) {
|
|
1766
|
+
if (reason === MCP_HUMAN_APPROVAL_REQUIRED.reason) {
|
|
1767
|
+
return jsonRpcError(
|
|
1768
|
+
id,
|
|
1769
|
+
MCP_HUMAN_APPROVAL_REQUIRED.code,
|
|
1770
|
+
`Human approval is required for ${toolName}. It is not available over the MCP bridge in this version; run \`${terminalCommandFor(tool, toolName)}\` in a terminal instead.`,
|
|
1771
|
+
{ reason, tool: toolName },
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
return jsonRpcError(
|
|
1775
|
+
id,
|
|
1776
|
+
MCP_TOOL_BLOCKED.code,
|
|
1777
|
+
`${toolName} is not available over the MCP bridge; run \`${terminalCommandFor(tool, toolName)}\` in a terminal instead.`,
|
|
1778
|
+
{ reason, tool: toolName },
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1715
1782
|
export async function handleMcpJsonRpcMessage(
|
|
1716
1783
|
message,
|
|
1717
1784
|
{
|
|
@@ -1761,7 +1828,7 @@ export async function handleMcpJsonRpcMessage(
|
|
|
1761
1828
|
if (method === "tools/list") {
|
|
1762
1829
|
if (isNotification) return null;
|
|
1763
1830
|
return jsonRpcSuccess(id, {
|
|
1764
|
-
tools: tools.map((tool) => toMcpTool(tool)),
|
|
1831
|
+
tools: tools.filter((tool) => !toolRefusal(tool)).map((tool) => toMcpTool(tool)),
|
|
1765
1832
|
});
|
|
1766
1833
|
}
|
|
1767
1834
|
|
|
@@ -1770,6 +1837,18 @@ export async function handleMcpJsonRpcMessage(
|
|
|
1770
1837
|
const toolName = normalizeString(message.params?.name);
|
|
1771
1838
|
const args = isPlainObject(message.params?.arguments) ? message.params.arguments : {};
|
|
1772
1839
|
const handler = handlers[toolName];
|
|
1840
|
+
const tool = (Array.isArray(tools) ? tools : []).find((candidate) => normalizeString(candidate?.name) === toolName);
|
|
1841
|
+
const refusal = typeof handler === "function" && tool ? toolRefusal(tool) : "";
|
|
1842
|
+
if (refusal) {
|
|
1843
|
+
return refusalError(id, toolName, tool, refusal);
|
|
1844
|
+
}
|
|
1845
|
+
if (typeof handler === "function" && !tool) {
|
|
1846
|
+
// a handler without a published definition has no policy to check: refused
|
|
1847
|
+
return jsonRpcSuccess(
|
|
1848
|
+
id,
|
|
1849
|
+
buildToolResult({ ok: false, reason: "unknown_tool", tool: toolName }, { isError: true }),
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1773
1852
|
if (typeof handler !== "function") {
|
|
1774
1853
|
return jsonRpcSuccess(
|
|
1775
1854
|
id,
|
package/src/mcp/smoke.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
CredentialDestinationRefused,
|
|
5
|
+
credentialedRequest,
|
|
6
|
+
isCredential,
|
|
7
|
+
userCredential,
|
|
8
|
+
} from "../auth/credential-destinations.js";
|
|
3
9
|
import { DEFAULT_REQUEST_TIMEOUT_MS } from "../auth/http.js";
|
|
4
10
|
import { requestHostedMcpAccessToken } from "./token-service.js";
|
|
5
11
|
|
|
@@ -75,7 +81,7 @@ function summarizeJsonRpcError(error, secrets = []) {
|
|
|
75
81
|
async function postJsonRpc({
|
|
76
82
|
fetchImpl,
|
|
77
83
|
url,
|
|
78
|
-
|
|
84
|
+
credential,
|
|
79
85
|
payload,
|
|
80
86
|
timeoutMs,
|
|
81
87
|
redactionSecrets = [],
|
|
@@ -83,15 +89,18 @@ async function postJsonRpc({
|
|
|
83
89
|
const controller = new AbortController();
|
|
84
90
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
85
91
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
// fetchImpl is the transport credentialedRequest calls, after its origin check.
|
|
93
|
+
const response = await credentialedRequest(
|
|
94
|
+
credential,
|
|
95
|
+
url,
|
|
96
|
+
{
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify(payload),
|
|
100
|
+
signal: controller.signal,
|
|
91
101
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
102
|
+
{ fetchImpl },
|
|
103
|
+
);
|
|
95
104
|
const text = await response.text().catch(() => "");
|
|
96
105
|
let json = null;
|
|
97
106
|
if (text) {
|
|
@@ -108,6 +117,7 @@ async function postJsonRpc({
|
|
|
108
117
|
detail: json ? "" : redactMcpSmokeText(text, redactionSecrets),
|
|
109
118
|
};
|
|
110
119
|
} catch (error) {
|
|
120
|
+
if (error instanceof CredentialDestinationRefused) throw error;
|
|
111
121
|
const aborted = Boolean(error) && error.name === "AbortError";
|
|
112
122
|
return {
|
|
113
123
|
reached: false,
|
|
@@ -172,6 +182,12 @@ export async function runHostedMcpSmoke({
|
|
|
172
182
|
if (!accessToken) {
|
|
173
183
|
throw new Error("Hosted MCP token mint succeeded but returned no access token.");
|
|
174
184
|
}
|
|
185
|
+
// The minted bearer as a credential: the mint's own hosted MCP credential, or the bare token bound
|
|
186
|
+
// to the configured API. Any other credential (an agent's admission, say) is not accepted here.
|
|
187
|
+
if (minted.credential !== undefined && !(isCredential(minted.credential) && minted.credential.source === "hosted_mcp")) {
|
|
188
|
+
throw new TypeError("The hosted MCP mint must return a hosted MCP credential.");
|
|
189
|
+
}
|
|
190
|
+
const credential = minted.credential || (await userCredential(accessToken, { env, homeDir, source: "hosted_mcp" }));
|
|
175
191
|
const redactionSecrets = [accessToken];
|
|
176
192
|
|
|
177
193
|
const mcpUrl = joinUrl(minted.apiUrl, "/mcp");
|
|
@@ -181,7 +197,7 @@ export async function runHostedMcpSmoke({
|
|
|
181
197
|
const toolsResponse = await postJsonRpc({
|
|
182
198
|
fetchImpl,
|
|
183
199
|
url: mcpUrl,
|
|
184
|
-
|
|
200
|
+
credential,
|
|
185
201
|
timeoutMs: normalizedTimeoutMs,
|
|
186
202
|
redactionSecrets,
|
|
187
203
|
payload: {
|
|
@@ -247,7 +263,7 @@ export async function runHostedMcpSmoke({
|
|
|
247
263
|
const eventsResponse = await postJsonRpc({
|
|
248
264
|
fetchImpl,
|
|
249
265
|
url: mcpUrl,
|
|
250
|
-
|
|
266
|
+
credential,
|
|
251
267
|
timeoutMs: normalizedTimeoutMs,
|
|
252
268
|
redactionSecrets,
|
|
253
269
|
payload: {
|
package/src/mcp/token-service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
|
|
3
|
+
import { credentialFor, isAuthenticated, userCredential } from "../auth/credential-destinations.js";
|
|
3
4
|
import { DEFAULT_REQUEST_TIMEOUT_MS, requestJsonMutation } from "../auth/http.js";
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_API_TOKEN_TTL_DAYS,
|
|
@@ -39,12 +40,6 @@ function buildApiPath(apiUrl, pathSuffix) {
|
|
|
39
40
|
return `${base}/${suffix}`;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function toAuthHeader(token) {
|
|
43
|
-
return {
|
|
44
|
-
Authorization: `Bearer ${String(token || "").trim()}`,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
43
|
function applyScope(body, { scope = "", scopes } = {}) {
|
|
49
44
|
const normalizedScope = String(scope || "").trim();
|
|
50
45
|
const normalizedScopes = Array.isArray(scopes)
|
|
@@ -118,7 +113,7 @@ export async function requestHostedMcpAccessToken({
|
|
|
118
113
|
tokenTtlDays,
|
|
119
114
|
homeDir,
|
|
120
115
|
});
|
|
121
|
-
if (!session
|
|
116
|
+
if (!isAuthenticated(session)) {
|
|
122
117
|
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
123
118
|
}
|
|
124
119
|
|
|
@@ -135,17 +130,20 @@ export async function requestHostedMcpAccessToken({
|
|
|
135
130
|
{
|
|
136
131
|
method: "POST",
|
|
137
132
|
operationName: "mcp-token-mint",
|
|
138
|
-
|
|
133
|
+
credential: await credentialFor(session, { env, homeDir }),
|
|
139
134
|
body,
|
|
140
135
|
timeoutMs: normalizedTimeoutMs,
|
|
141
136
|
}
|
|
142
137
|
);
|
|
138
|
+
const accessToken = String(response.access_token || "");
|
|
143
139
|
|
|
144
140
|
return {
|
|
145
141
|
apiUrl: session.apiUrl,
|
|
146
142
|
authSource: session.source,
|
|
147
143
|
rotated: Boolean(session.rotated),
|
|
148
|
-
accessToken
|
|
144
|
+
accessToken,
|
|
145
|
+
// the minted bearer, bound to the configured API like the token that minted it
|
|
146
|
+
credential: await userCredential(accessToken, { env, homeDir, source: "hosted_mcp" }),
|
|
149
147
|
tokenType: String(response.token_type || "Bearer"),
|
|
150
148
|
expiresIn: Number(response.expires_in || 0),
|
|
151
149
|
expiresAt: String(response.expires_at || ""),
|
package/src/memory/retrieval.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
import { credentialedRequest } from "../auth/credential-destinations.js";
|
|
5
|
+
|
|
4
6
|
function normalizeString(value) {
|
|
5
7
|
return String(value || "").trim();
|
|
6
8
|
}
|
|
@@ -506,6 +508,7 @@ export async function queryHybridRetriever({
|
|
|
506
508
|
provider = "local",
|
|
507
509
|
apiEndpoint = "",
|
|
508
510
|
apiKey = "",
|
|
511
|
+
credential = null,
|
|
509
512
|
fetchImpl = globalThis.fetch,
|
|
510
513
|
} = {}) {
|
|
511
514
|
const normalizedProvider = normalizeString(provider).toLowerCase() || "local";
|
|
@@ -534,12 +537,18 @@ export async function queryHybridRetriever({
|
|
|
534
537
|
}
|
|
535
538
|
|
|
536
539
|
try {
|
|
537
|
-
|
|
540
|
+
// The user's credential goes only to its own origin (credentialedRequest); a dedicated memory
|
|
541
|
+
// API key is that service's own key, sent to the endpoint configured for it.
|
|
542
|
+
const send = (init) =>
|
|
543
|
+
credential
|
|
544
|
+
? credentialedRequest(credential, String(apiEndpoint), init, { fetchImpl })
|
|
545
|
+
: fetchImpl(String(apiEndpoint), {
|
|
546
|
+
...init,
|
|
547
|
+
headers: { ...init.headers, ...(normalizeString(apiKey) ? { authorization: `Bearer ${apiKey}` } : {}) },
|
|
548
|
+
});
|
|
549
|
+
const response = await send({
|
|
538
550
|
method: "POST",
|
|
539
|
-
headers: {
|
|
540
|
-
"content-type": "application/json",
|
|
541
|
-
...(normalizeString(apiKey) ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
542
|
-
},
|
|
551
|
+
headers: { "content-type": "application/json" },
|
|
543
552
|
body: JSON.stringify({
|
|
544
553
|
query: normalizeString(query),
|
|
545
554
|
limit: Math.max(1, Math.floor(normalizeNumber(limit, 12))),
|