sentinelayer-cli 0.30.14 → 0.31.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/package.json +1 -1
- package/src/commands/mcp.js +103 -0
- package/src/mcp/registry.js +332 -0
- package/src/review/persona-prompts.js +6 -0
- package/src/session/remote-hydrate.js +27 -8
- package/src/session/stream.js +58 -1
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
buildAidenIdProvisioningAdapterTemplate,
|
|
11
|
+
buildHostedSentiSessionConnectorContract,
|
|
11
12
|
buildAidenIdRegistryTemplate,
|
|
12
13
|
buildMcpToolRegistrySchema,
|
|
13
14
|
buildMcpServerConfigTemplate,
|
|
@@ -15,10 +16,12 @@ import {
|
|
|
15
16
|
buildVsCodeMcpBridgeTemplate,
|
|
16
17
|
readJsonFile,
|
|
17
18
|
resolveDefaultAidenIdAdapterContractPath,
|
|
19
|
+
resolveDefaultHostedSentiSessionConnectorContractPath,
|
|
18
20
|
resolveDefaultMcpOutputPath,
|
|
19
21
|
resolveDefaultMcpServerConfigPath,
|
|
20
22
|
resolveDefaultVsCodeBridgePath,
|
|
21
23
|
validateAidenIdAdapterContract,
|
|
24
|
+
validateHostedSentiSessionConnectorContract,
|
|
22
25
|
stringifyJson,
|
|
23
26
|
validateMcpServerConfig,
|
|
24
27
|
validateMcpToolRegistry,
|
|
@@ -255,6 +258,46 @@ export function registerMcpCommand(program) {
|
|
|
255
258
|
console.log(pc.gray("Next: run 'sl mcp server init --id sentinelayer-session --registry-file <path>'."));
|
|
256
259
|
});
|
|
257
260
|
|
|
261
|
+
registry
|
|
262
|
+
.command("init-hosted-session-connector")
|
|
263
|
+
.description("Write the hosted Senti session MCP connector contract")
|
|
264
|
+
.option(
|
|
265
|
+
"--registry-file <path>",
|
|
266
|
+
"Local session registry file referenced by hosted connector contract",
|
|
267
|
+
".sentinelayer/mcp/tool-registry.session-tools.json"
|
|
268
|
+
)
|
|
269
|
+
.option("--path <path>", "Destination file path override")
|
|
270
|
+
.option("--output-dir <path>", "Optional artifact output root override")
|
|
271
|
+
.option("--force", "Overwrite destination file if it already exists")
|
|
272
|
+
.option("--json", "Emit machine-readable output")
|
|
273
|
+
.action(async (options, command) => {
|
|
274
|
+
const defaultPath = await resolveDefaultHostedSentiSessionConnectorContractPath({
|
|
275
|
+
cwd: process.cwd(),
|
|
276
|
+
outputDir: options.outputDir,
|
|
277
|
+
env: process.env,
|
|
278
|
+
});
|
|
279
|
+
const outputPath = normalizeOutputPath(options.path, defaultPath);
|
|
280
|
+
const template = buildHostedSentiSessionConnectorContract({
|
|
281
|
+
localRegistryFile: options.registryFile,
|
|
282
|
+
});
|
|
283
|
+
validateHostedSentiSessionConnectorContract(template);
|
|
284
|
+
const writtenPath = await writeJsonFile(outputPath, template, { force: Boolean(options.force) });
|
|
285
|
+
const payload = {
|
|
286
|
+
command: "mcp registry init-hosted-session-connector",
|
|
287
|
+
outputPath: writtenPath,
|
|
288
|
+
localRegistryFile: template.local_registry_file,
|
|
289
|
+
toolCount: template.tools.length,
|
|
290
|
+
tools: template.tools.map((tool) => tool.tool_name),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
if (shouldEmitJson(options, command)) {
|
|
294
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
console.log(pc.green(`Wrote hosted Senti session connector contract: ${writtenPath}`));
|
|
298
|
+
console.log(pc.gray("This is a hosted connector contract, not a local stdio runtime claim."));
|
|
299
|
+
});
|
|
300
|
+
|
|
258
301
|
registry
|
|
259
302
|
.command("init-cli")
|
|
260
303
|
.description("Write a generated MCP registry for every SentinelLayer CLI leaf command")
|
|
@@ -335,6 +378,66 @@ export function registerMcpCommand(program) {
|
|
|
335
378
|
}
|
|
336
379
|
});
|
|
337
380
|
|
|
381
|
+
registry
|
|
382
|
+
.command("validate-hosted-session-connector")
|
|
383
|
+
.description("Validate a hosted Senti session MCP connector contract")
|
|
384
|
+
.requiredOption("--file <path>", "Hosted connector contract JSON file to validate")
|
|
385
|
+
.option("--registry-file <path>", "Optional local session registry JSON file for cross-check")
|
|
386
|
+
.option("--json", "Emit machine-readable output")
|
|
387
|
+
.action(async (options, command) => {
|
|
388
|
+
const inputPath = path.resolve(process.cwd(), String(options.file || "").trim());
|
|
389
|
+
const loaded = await readJsonFile(inputPath);
|
|
390
|
+
const registryFile = String(options.registryFile || "").trim();
|
|
391
|
+
const loadedRegistry = registryFile
|
|
392
|
+
? await readJsonFile(path.resolve(process.cwd(), registryFile))
|
|
393
|
+
: null;
|
|
394
|
+
|
|
395
|
+
let parsed;
|
|
396
|
+
try {
|
|
397
|
+
parsed = validateHostedSentiSessionConnectorContract(loaded.data, {
|
|
398
|
+
registryPayload: loadedRegistry ? loadedRegistry.data : undefined,
|
|
399
|
+
});
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const payload = {
|
|
402
|
+
command: "mcp registry validate-hosted-session-connector",
|
|
403
|
+
valid: false,
|
|
404
|
+
filePath: loaded.path,
|
|
405
|
+
registryFilePath: loadedRegistry ? loadedRegistry.path : null,
|
|
406
|
+
error: zodIssueSummary(error),
|
|
407
|
+
};
|
|
408
|
+
if (shouldEmitJson(options, command)) {
|
|
409
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
410
|
+
} else {
|
|
411
|
+
console.log(pc.red(`Hosted Senti session connector contract invalid: ${payload.error}`));
|
|
412
|
+
console.log(pc.gray(`File: ${loaded.path}`));
|
|
413
|
+
if (payload.registryFilePath) {
|
|
414
|
+
console.log(pc.gray(`Registry: ${payload.registryFilePath}`));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
process.exitCode = 2;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const payload = {
|
|
422
|
+
command: "mcp registry validate-hosted-session-connector",
|
|
423
|
+
valid: true,
|
|
424
|
+
filePath: loaded.path,
|
|
425
|
+
registryFilePath: loadedRegistry ? loadedRegistry.path : null,
|
|
426
|
+
connector: parsed.connector,
|
|
427
|
+
toolCount: parsed.tools.length,
|
|
428
|
+
releaseGateCount: parsed.release_gates.length,
|
|
429
|
+
};
|
|
430
|
+
if (shouldEmitJson(options, command)) {
|
|
431
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
console.log(pc.green(`Hosted Senti session connector contract valid (${parsed.tools.length} tools)`));
|
|
435
|
+
console.log(pc.gray(`File: ${loaded.path}`));
|
|
436
|
+
if (payload.registryFilePath) {
|
|
437
|
+
console.log(pc.gray(`Registry: ${payload.registryFilePath}`));
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
338
441
|
registry
|
|
339
442
|
.command("validate-aidenid-adapter")
|
|
340
443
|
.description(
|
package/src/mcp/registry.js
CHANGED
|
@@ -12,6 +12,8 @@ export const MCP_TOOL_REGISTRY_SCHEMA_VERSION = "1.0.0";
|
|
|
12
12
|
export const MCP_SERVER_CONFIG_SCHEMA_VERSION = "1.0.0";
|
|
13
13
|
/** AIdenID adapter-contract schema version used by Sentinelayer CLI generated artifacts. */
|
|
14
14
|
export const AIDENID_ADAPTER_CONTRACT_SCHEMA_VERSION = "1.0.0";
|
|
15
|
+
/** Hosted Senti session connector-contract schema version. */
|
|
16
|
+
export const HOSTED_SENTI_SESSION_CONNECTOR_CONTRACT_SCHEMA_VERSION = "1.0.0";
|
|
15
17
|
|
|
16
18
|
const serverIdRegex = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
17
19
|
const inputPlaceholderRegex = /^\{\{input\.[a-zA-Z0-9_.-]+\}\}$/;
|
|
@@ -142,6 +144,113 @@ const aidenIdAdapterContractSchema = z
|
|
|
142
144
|
})
|
|
143
145
|
.strict();
|
|
144
146
|
|
|
147
|
+
const hostedSessionConnectorToolSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
tool_name: z
|
|
150
|
+
.string()
|
|
151
|
+
.min(1)
|
|
152
|
+
.regex(/^[a-zA-Z0-9_.:-]+$/, "tool_name must contain only [a-zA-Z0-9_.:-]"),
|
|
153
|
+
local_tool_name: z
|
|
154
|
+
.string()
|
|
155
|
+
.min(1)
|
|
156
|
+
.regex(/^[a-zA-Z0-9_.:-]+$/, "local_tool_name must contain only [a-zA-Z0-9_.:-]"),
|
|
157
|
+
operation: z.enum([
|
|
158
|
+
"join_and_hydrate",
|
|
159
|
+
"read_history",
|
|
160
|
+
"send_message",
|
|
161
|
+
"session_action",
|
|
162
|
+
"session_lock",
|
|
163
|
+
"session_unlock",
|
|
164
|
+
"list_locks",
|
|
165
|
+
"attention_request",
|
|
166
|
+
"subscribe_wake",
|
|
167
|
+
"presence_renew",
|
|
168
|
+
"runner_teardown",
|
|
169
|
+
]),
|
|
170
|
+
input_policy: z
|
|
171
|
+
.object({
|
|
172
|
+
identity_source: z.literal("server_session_seat"),
|
|
173
|
+
rejects_identity_from_tool_args: z.literal(true),
|
|
174
|
+
allowed_user_args: z.array(z.string().min(1)).default([]),
|
|
175
|
+
forbidden_capability_args: z
|
|
176
|
+
.array(z.enum(["sessionId", "session_id", "agentId", "agent_id", "userId", "user_id", "orgId", "org_id"]))
|
|
177
|
+
.default(["sessionId", "session_id", "agentId", "agent_id", "userId", "user_id", "orgId", "org_id"]),
|
|
178
|
+
})
|
|
179
|
+
.strict(),
|
|
180
|
+
authorization: z
|
|
181
|
+
.object({
|
|
182
|
+
requires_validated_claims: z.literal(true),
|
|
183
|
+
requires_session_seat: z.literal(true),
|
|
184
|
+
required_scopes: z.array(z.string().min(1)).min(1),
|
|
185
|
+
})
|
|
186
|
+
.strict(),
|
|
187
|
+
wake_payload: z
|
|
188
|
+
.object({
|
|
189
|
+
policy: z.enum(["none", "cursor_reason_counts_only", "bounded_redacted_window"]),
|
|
190
|
+
may_include_message_content: z.boolean().default(false),
|
|
191
|
+
max_events: z.number().int().positive().max(200).optional(),
|
|
192
|
+
})
|
|
193
|
+
.strict()
|
|
194
|
+
.superRefine((payload, ctx) => {
|
|
195
|
+
if (payload.policy === "cursor_reason_counts_only" && payload.may_include_message_content) {
|
|
196
|
+
ctx.addIssue({
|
|
197
|
+
code: z.ZodIssueCode.custom,
|
|
198
|
+
path: ["may_include_message_content"],
|
|
199
|
+
message: "cursor/reason wake payloads must not include message content.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}),
|
|
203
|
+
audit: z
|
|
204
|
+
.object({
|
|
205
|
+
durable_receipt_required: z.literal(true),
|
|
206
|
+
idempotency_key_fields: z.array(z.string().min(1)).min(1),
|
|
207
|
+
})
|
|
208
|
+
.strict(),
|
|
209
|
+
runner_lifecycle: z
|
|
210
|
+
.object({
|
|
211
|
+
requires_scoped_runner_token: z.literal(true),
|
|
212
|
+
revoke_token_on_idle_teardown: z.literal(true),
|
|
213
|
+
})
|
|
214
|
+
.strict()
|
|
215
|
+
.optional(),
|
|
216
|
+
})
|
|
217
|
+
.strict();
|
|
218
|
+
|
|
219
|
+
const hostedSessionConnectorContractSchema = z
|
|
220
|
+
.object({
|
|
221
|
+
version: z
|
|
222
|
+
.literal(HOSTED_SENTI_SESSION_CONNECTOR_CONTRACT_SCHEMA_VERSION)
|
|
223
|
+
.default(HOSTED_SENTI_SESSION_CONNECTOR_CONTRACT_SCHEMA_VERSION),
|
|
224
|
+
provider: z.literal("sentinelayer"),
|
|
225
|
+
connector: z.literal("hosted-senti-session"),
|
|
226
|
+
generated_at: z.string().min(1),
|
|
227
|
+
local_registry_file: z.string().min(1),
|
|
228
|
+
runtime_status: z.literal("contract_only"),
|
|
229
|
+
boundary: z
|
|
230
|
+
.object({
|
|
231
|
+
transport: z.literal("https_mcp_streamable_http"),
|
|
232
|
+
identity_source: z.literal("validated_oauth_claims_and_server_session_seat"),
|
|
233
|
+
local_stdio_registry_is_not_hosted_capability: z.literal(true),
|
|
234
|
+
long_lived_cli_token_passthrough_allowed: z.literal(false),
|
|
235
|
+
})
|
|
236
|
+
.strict(),
|
|
237
|
+
tools: z.array(hostedSessionConnectorToolSchema).min(1),
|
|
238
|
+
release_gates: z.array(z.string().min(1)).min(1),
|
|
239
|
+
})
|
|
240
|
+
.strict();
|
|
241
|
+
|
|
242
|
+
const REQUIRED_HOSTED_SESSION_CONNECTOR_RELEASE_GATES = [
|
|
243
|
+
"oauth_resource_binding",
|
|
244
|
+
"session_seat_ownership",
|
|
245
|
+
"deny_identity_from_tool_args",
|
|
246
|
+
"approval_policy_enforcement",
|
|
247
|
+
"scoped_runner_token_revocation",
|
|
248
|
+
"wake_payload_minimization",
|
|
249
|
+
"durable_audit_receipts",
|
|
250
|
+
"output_redaction",
|
|
251
|
+
"omar_gate_mcp_security_pack",
|
|
252
|
+
];
|
|
253
|
+
|
|
145
254
|
const mcpServerTransportSchema = z.discriminatedUnion("mode", [
|
|
146
255
|
z
|
|
147
256
|
.object({
|
|
@@ -812,6 +921,152 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
812
921
|
};
|
|
813
922
|
}
|
|
814
923
|
|
|
924
|
+
function hostedSessionTool({
|
|
925
|
+
toolName,
|
|
926
|
+
operation,
|
|
927
|
+
allowedUserArgs,
|
|
928
|
+
requiredScopes,
|
|
929
|
+
wakePayload = { policy: "none", may_include_message_content: false },
|
|
930
|
+
runnerLifecycle = null,
|
|
931
|
+
}) {
|
|
932
|
+
const tool = {
|
|
933
|
+
tool_name: toolName,
|
|
934
|
+
local_tool_name: toolName,
|
|
935
|
+
operation,
|
|
936
|
+
input_policy: {
|
|
937
|
+
identity_source: "server_session_seat",
|
|
938
|
+
rejects_identity_from_tool_args: true,
|
|
939
|
+
allowed_user_args: allowedUserArgs,
|
|
940
|
+
forbidden_capability_args: ["sessionId", "session_id", "agentId", "agent_id", "userId", "user_id", "orgId", "org_id"],
|
|
941
|
+
},
|
|
942
|
+
authorization: {
|
|
943
|
+
requires_validated_claims: true,
|
|
944
|
+
requires_session_seat: true,
|
|
945
|
+
required_scopes: requiredScopes,
|
|
946
|
+
},
|
|
947
|
+
wake_payload: wakePayload,
|
|
948
|
+
audit: {
|
|
949
|
+
durable_receipt_required: true,
|
|
950
|
+
idempotency_key_fields: ["subject", "session_seat", "tool_name", "normalized_inputs"],
|
|
951
|
+
},
|
|
952
|
+
};
|
|
953
|
+
if (runnerLifecycle) {
|
|
954
|
+
tool.runner_lifecycle = runnerLifecycle;
|
|
955
|
+
}
|
|
956
|
+
return tool;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Create the hosted Senti session connector contract.
|
|
961
|
+
*
|
|
962
|
+
* This is intentionally separate from the local stdio session registry. Local
|
|
963
|
+
* tools need sessionId/agentId arguments; hosted tools must derive identity from
|
|
964
|
+
* validated OAuth claims plus a server-side session seat and must not treat those
|
|
965
|
+
* arguments as capabilities.
|
|
966
|
+
*
|
|
967
|
+
* @param {{ generatedAt?: string, localRegistryFile?: string }} [options]
|
|
968
|
+
* @returns {Record<string, any>}
|
|
969
|
+
*/
|
|
970
|
+
export function buildHostedSentiSessionConnectorContract({
|
|
971
|
+
generatedAt = new Date().toISOString(),
|
|
972
|
+
localRegistryFile = ".sentinelayer/mcp/tool-registry.session-tools.json",
|
|
973
|
+
} = {}) {
|
|
974
|
+
return {
|
|
975
|
+
version: HOSTED_SENTI_SESSION_CONNECTOR_CONTRACT_SCHEMA_VERSION,
|
|
976
|
+
provider: "sentinelayer",
|
|
977
|
+
connector: "hosted-senti-session",
|
|
978
|
+
generated_at: generatedAt,
|
|
979
|
+
local_registry_file: String(localRegistryFile || "").trim() || ".sentinelayer/mcp/tool-registry.session-tools.json",
|
|
980
|
+
runtime_status: "contract_only",
|
|
981
|
+
boundary: {
|
|
982
|
+
transport: "https_mcp_streamable_http",
|
|
983
|
+
identity_source: "validated_oauth_claims_and_server_session_seat",
|
|
984
|
+
local_stdio_registry_is_not_hosted_capability: true,
|
|
985
|
+
long_lived_cli_token_passthrough_allowed: false,
|
|
986
|
+
},
|
|
987
|
+
tools: [
|
|
988
|
+
hostedSessionTool({
|
|
989
|
+
toolName: "poll_inbox",
|
|
990
|
+
operation: "read_history",
|
|
991
|
+
allowedUserArgs: ["cursor", "limit", "actionLimit", "includeActions"],
|
|
992
|
+
requiredScopes: ["session:read"],
|
|
993
|
+
wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
|
|
994
|
+
}),
|
|
995
|
+
hostedSessionTool({
|
|
996
|
+
toolName: "send_message",
|
|
997
|
+
operation: "send_message",
|
|
998
|
+
allowedUserArgs: ["message", "to", "idempotencyKey"],
|
|
999
|
+
requiredScopes: ["session:write"],
|
|
1000
|
+
}),
|
|
1001
|
+
hostedSessionTool({
|
|
1002
|
+
toolName: "session_action",
|
|
1003
|
+
operation: "session_action",
|
|
1004
|
+
allowedUserArgs: ["actionType", "targetSequenceId", "targetCursor", "targetActionId", "note", "idempotencyKey"],
|
|
1005
|
+
requiredScopes: ["session:write", "session:action"],
|
|
1006
|
+
}),
|
|
1007
|
+
hostedSessionTool({
|
|
1008
|
+
toolName: "session_react",
|
|
1009
|
+
operation: "session_action",
|
|
1010
|
+
allowedUserArgs: ["reaction", "targetSequenceId", "targetCursor", "targetActionId", "idempotencyKey"],
|
|
1011
|
+
requiredScopes: ["session:write", "session:action"],
|
|
1012
|
+
}),
|
|
1013
|
+
hostedSessionTool({
|
|
1014
|
+
toolName: "session_reply",
|
|
1015
|
+
operation: "session_action",
|
|
1016
|
+
allowedUserArgs: ["targetSequenceId", "targetCursor", "targetActionId", "message", "idempotencyKey"],
|
|
1017
|
+
requiredScopes: ["session:write", "session:action"],
|
|
1018
|
+
}),
|
|
1019
|
+
hostedSessionTool({
|
|
1020
|
+
toolName: "session_lock",
|
|
1021
|
+
operation: "session_lock",
|
|
1022
|
+
allowedUserArgs: ["files", "intent", "ttlSeconds"],
|
|
1023
|
+
requiredScopes: ["session:lock"],
|
|
1024
|
+
}),
|
|
1025
|
+
hostedSessionTool({
|
|
1026
|
+
toolName: "session_unlock",
|
|
1027
|
+
operation: "session_unlock",
|
|
1028
|
+
allowedUserArgs: ["files", "reason"],
|
|
1029
|
+
requiredScopes: ["session:lock"],
|
|
1030
|
+
}),
|
|
1031
|
+
hostedSessionTool({
|
|
1032
|
+
toolName: "session_locks",
|
|
1033
|
+
operation: "list_locks",
|
|
1034
|
+
allowedUserArgs: [],
|
|
1035
|
+
requiredScopes: ["session:lock"],
|
|
1036
|
+
}),
|
|
1037
|
+
hostedSessionTool({
|
|
1038
|
+
toolName: "attention_request",
|
|
1039
|
+
operation: "attention_request",
|
|
1040
|
+
allowedUserArgs: ["message", "to", "priority", "severity", "idempotencyKey"],
|
|
1041
|
+
requiredScopes: ["session:write"],
|
|
1042
|
+
wakePayload: { policy: "cursor_reason_counts_only", may_include_message_content: false },
|
|
1043
|
+
}),
|
|
1044
|
+
hostedSessionTool({
|
|
1045
|
+
toolName: "subscribe_wake",
|
|
1046
|
+
operation: "subscribe_wake",
|
|
1047
|
+
allowedUserArgs: ["cadencePreset", "wakeTriggers", "sinceCursor"],
|
|
1048
|
+
requiredScopes: ["session:read", "session:wake"],
|
|
1049
|
+
wakePayload: { policy: "cursor_reason_counts_only", may_include_message_content: false },
|
|
1050
|
+
runnerLifecycle: {
|
|
1051
|
+
requires_scoped_runner_token: true,
|
|
1052
|
+
revoke_token_on_idle_teardown: true,
|
|
1053
|
+
},
|
|
1054
|
+
}),
|
|
1055
|
+
],
|
|
1056
|
+
release_gates: [
|
|
1057
|
+
"oauth_resource_binding",
|
|
1058
|
+
"session_seat_ownership",
|
|
1059
|
+
"deny_identity_from_tool_args",
|
|
1060
|
+
"approval_policy_enforcement",
|
|
1061
|
+
"scoped_runner_token_revocation",
|
|
1062
|
+
"wake_payload_minimization",
|
|
1063
|
+
"durable_audit_receipts",
|
|
1064
|
+
"output_redaction",
|
|
1065
|
+
"omar_gate_mcp_security_pack",
|
|
1066
|
+
],
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
815
1070
|
/**
|
|
816
1071
|
* Create an AIdenID adapter contract template that binds MCP tools to provisioning endpoints.
|
|
817
1072
|
*
|
|
@@ -975,6 +1230,68 @@ export function validateAidenIdAdapterContract(payload, { registryPayload } = {}
|
|
|
975
1230
|
return parsed;
|
|
976
1231
|
}
|
|
977
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* Validate and normalize hosted Senti session connector contract payload.
|
|
1235
|
+
* Optionally cross-validates hosted local_tool_name references against a local
|
|
1236
|
+
* session registry payload. Contract-only hosted tools, such as subscribe_wake,
|
|
1237
|
+
* can omit a local registry binding by using the same name as a non-local tool.
|
|
1238
|
+
*
|
|
1239
|
+
* @param {unknown} payload
|
|
1240
|
+
* @param {{ registryPayload?: unknown }} [options]
|
|
1241
|
+
* @returns {any}
|
|
1242
|
+
*/
|
|
1243
|
+
export function validateHostedSentiSessionConnectorContract(payload, { registryPayload } = {}) {
|
|
1244
|
+
const parsed = hostedSessionConnectorContractSchema.parse(payload);
|
|
1245
|
+
const releaseGateSet = new Set(parsed.release_gates);
|
|
1246
|
+
|
|
1247
|
+
for (const gate of REQUIRED_HOSTED_SESSION_CONNECTOR_RELEASE_GATES) {
|
|
1248
|
+
if (!releaseGateSet.has(gate)) {
|
|
1249
|
+
throw new Error(`Hosted connector contract is missing required release gate ${gate}.`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
for (const tool of parsed.tools) {
|
|
1254
|
+
const forbidden = new Set(tool.input_policy.forbidden_capability_args);
|
|
1255
|
+
for (const key of ["sessionId", "session_id", "agentId", "agent_id"]) {
|
|
1256
|
+
if (!forbidden.has(key)) {
|
|
1257
|
+
throw new Error(`Hosted connector tool ${tool.tool_name} must forbid capability argument ${key}.`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
for (const arg of tool.input_policy.allowed_user_args) {
|
|
1261
|
+
if (forbidden.has(arg)) {
|
|
1262
|
+
throw new Error(`Hosted connector tool ${tool.tool_name} allows forbidden capability argument ${arg}.`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (tool.operation === "subscribe_wake") {
|
|
1266
|
+
if (!tool.runner_lifecycle) {
|
|
1267
|
+
throw new Error(`Hosted connector tool ${tool.tool_name} must define scoped runner-token lifecycle controls.`);
|
|
1268
|
+
}
|
|
1269
|
+
if (!tool.runner_lifecycle.requires_scoped_runner_token) {
|
|
1270
|
+
throw new Error(`Hosted connector tool ${tool.tool_name} must require a scoped runner token.`);
|
|
1271
|
+
}
|
|
1272
|
+
if (!tool.runner_lifecycle.revoke_token_on_idle_teardown) {
|
|
1273
|
+
throw new Error(`Hosted connector tool ${tool.tool_name} must revoke runner tokens on idle teardown.`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (registryPayload !== undefined) {
|
|
1279
|
+
const registry = validateMcpToolRegistry(registryPayload);
|
|
1280
|
+
const localToolNames = new Set(registry.tools.map((tool) => tool.name));
|
|
1281
|
+
const missingLocalTools = parsed.tools
|
|
1282
|
+
.filter((tool) => tool.operation !== "subscribe_wake" && tool.operation !== "presence_renew" && tool.operation !== "runner_teardown")
|
|
1283
|
+
.map((tool) => tool.local_tool_name)
|
|
1284
|
+
.filter((toolName) => !localToolNames.has(toolName));
|
|
1285
|
+
if (missingLocalTools.length > 0) {
|
|
1286
|
+
throw new Error(
|
|
1287
|
+
`Hosted connector references local tools not present in registry: ${[...new Set(missingLocalTools)].join(", ")}`
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return parsed;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
978
1295
|
/**
|
|
979
1296
|
* Validate and normalize MCP server runtime config payload.
|
|
980
1297
|
*
|
|
@@ -1069,6 +1386,21 @@ export async function resolveDefaultAidenIdAdapterContractPath({ cwd, outputDir,
|
|
|
1069
1386
|
return path.join(outputRoot, "mcp", "aidenid-provisioning-adapter.json");
|
|
1070
1387
|
}
|
|
1071
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* Resolve default output path for hosted Senti session connector contracts.
|
|
1391
|
+
*
|
|
1392
|
+
* @param {{ cwd?: string, outputDir?: string, env?: NodeJS.ProcessEnv }} [options]
|
|
1393
|
+
* @returns {Promise<string>}
|
|
1394
|
+
*/
|
|
1395
|
+
export async function resolveDefaultHostedSentiSessionConnectorContractPath({ cwd, outputDir, env } = {}) {
|
|
1396
|
+
const outputRoot = await resolveOutputRoot({
|
|
1397
|
+
cwd,
|
|
1398
|
+
outputDirOverride: outputDir,
|
|
1399
|
+
env,
|
|
1400
|
+
});
|
|
1401
|
+
return path.join(outputRoot, "mcp", "hosted-senti-session-connector.json");
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1072
1404
|
/**
|
|
1073
1405
|
* Resolve default MCP server config output path for a specific server id.
|
|
1074
1406
|
*
|
|
@@ -72,6 +72,12 @@ MCP / agent-security (clearance-layer specific):
|
|
|
72
72
|
- Agent sandbox / microVM handed a long-lived or user credential instead of a short-lived, narrowly-scoped, per-session token; secrets injected via env or baked into a VM image/snapshot
|
|
73
73
|
- Sandbox egress not default-denied, or the cloud metadata endpoint (169.254.169.254 / IMDS) reachable from guest code
|
|
74
74
|
- VM memory snapshot reused across sessions or tenants (entropy/RNG/credential bleed)
|
|
75
|
+
- Hosted/bridge tool execution without SERVER-SIDE approval enforcement: requires_human_approval/scopes/kill_switch trusted from client-supplied metadata or skipped, or destructive tools (auth/config/session-kill/file-write/network-scan) runnable with no approval gate (must deny-by-default when policy metadata is missing or malformed)
|
|
76
|
+
- Token-like values UNREDACTED in tool output: stdout/stderr/parsed JSON/command echo/error messages returning bearer tokens, API keys, private keys, OTPs, session cookies, or local auth-store paths to the client or into stored artifacts
|
|
77
|
+
- Blind unpinned @latest CLI/dependency install inside a hosted runner, not resolved to an immutable version with checksum/signature/attestation verification (supply-chain RCE into every spawned VM)
|
|
78
|
+
- Notification/subscribe authorization derived from a sessionId (or other tool arg) instead of the caller's validated claims + server-side session membership (a sessionId is not a capability)
|
|
79
|
+
- Wake/notification payloads exposing message content or PII outside the subscriber's authorized scope (prefer counts/cursors/reasons; redact embedded content per scope)
|
|
80
|
+
- Idle teardown that kills the runner without revoking its scoped token, or session artifacts written by the guest/runner instead of by the control-plane under the owner's account
|
|
75
81
|
|
|
76
82
|
Evidence standard: Every finding MUST include file:line, exploit scenario, and remediation.
|
|
77
83
|
Do NOT report hypothetical issues without concrete code evidence.`,
|
|
@@ -67,11 +67,18 @@ async function readExistingRelayState(sessionId, { targetPath = process.cwd() }
|
|
|
67
67
|
const knownKeys = new Set();
|
|
68
68
|
const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
|
|
69
69
|
const indexByKey = new Map();
|
|
70
|
+
const duplicateKeys = new Set();
|
|
70
71
|
for (const [index, event] of events.entries()) {
|
|
72
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
73
|
+
const existingIndex = indexByKey.get(key);
|
|
74
|
+
if (Number.isInteger(existingIndex) && existingIndex >= 0 && existingIndex !== index) {
|
|
75
|
+
duplicateKeys.add(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
71
78
|
addSessionEventIdentityKeys(knownKeys, event);
|
|
72
79
|
indexRelayEventIdentityKeys(indexByKey, events, event, index);
|
|
73
80
|
}
|
|
74
|
-
return { knownKeys, events, indexByKey };
|
|
81
|
+
return { knownKeys, events, indexByKey, duplicateKeys };
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
|
|
@@ -380,13 +387,19 @@ export async function hydrateSessionFromRemote({
|
|
|
380
387
|
const successfulRelayKeys = relayState.knownKeys;
|
|
381
388
|
const existingRelayEvents = relayState.events;
|
|
382
389
|
const existingRelayIndexByKey = relayState.indexByKey;
|
|
390
|
+
const duplicateRelayKeys = relayState.duplicateKeys || new Set();
|
|
383
391
|
const newEvents = [];
|
|
392
|
+
const eventsNeedingMerge = new Set();
|
|
384
393
|
for (const event of merged) {
|
|
385
394
|
const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
|
|
386
395
|
if (existingIndex >= 0) {
|
|
387
396
|
const existing = existingRelayEvents[existingIndex];
|
|
388
|
-
if (
|
|
397
|
+
if (
|
|
398
|
+
sessionEventUpgradesExisting(existing, event) ||
|
|
399
|
+
sessionEventHasKnownIdentity(event, duplicateRelayKeys)
|
|
400
|
+
) {
|
|
389
401
|
newEvents.push(event);
|
|
402
|
+
eventsNeedingMerge.add(event);
|
|
390
403
|
}
|
|
391
404
|
continue;
|
|
392
405
|
}
|
|
@@ -416,14 +429,20 @@ export async function hydrateSessionFromRemote({
|
|
|
416
429
|
}
|
|
417
430
|
}
|
|
418
431
|
|
|
419
|
-
const appendEvents =
|
|
420
|
-
remoteStatus && !["active", "pending"].includes(remoteStatus)
|
|
421
|
-
?
|
|
422
|
-
:
|
|
432
|
+
const appendEvents = materialNewEvents.map((event) => ({
|
|
433
|
+
event: remoteStatus && !["active", "pending"].includes(remoteStatus)
|
|
434
|
+
? markPostKillEvent(event)
|
|
435
|
+
: event,
|
|
436
|
+
mergeExisting: eventsNeedingMerge.has(event),
|
|
437
|
+
}));
|
|
423
438
|
const failedRelayKeys = new Set();
|
|
424
|
-
for (const event of appendEvents) {
|
|
439
|
+
for (const { event, mergeExisting } of appendEvents) {
|
|
425
440
|
try {
|
|
426
|
-
const persisted = await _append(sessionId, event, {
|
|
441
|
+
const persisted = await _append(sessionId, event, {
|
|
442
|
+
targetPath,
|
|
443
|
+
syncRemote: false,
|
|
444
|
+
mergeExisting,
|
|
445
|
+
});
|
|
427
446
|
relayed += 1;
|
|
428
447
|
const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
|
|
429
448
|
existingRelayEvents.push(persistedEvent);
|
package/src/session/stream.js
CHANGED
|
@@ -3,6 +3,10 @@ import process from "node:process";
|
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
|
|
5
5
|
import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
|
|
6
|
+
import {
|
|
7
|
+
dedupeSessionEvents,
|
|
8
|
+
sessionEventIdentityKeys,
|
|
9
|
+
} from "./event-identity.js";
|
|
6
10
|
import { enrichEventWithMentions } from "./mentions.js";
|
|
7
11
|
import { resolveSessionPaths } from "./paths.js";
|
|
8
12
|
import { redactEventPayload } from "./redact.js";
|
|
@@ -348,6 +352,58 @@ async function rotateStreamIfNeeded(paths, maxEvents) {
|
|
|
348
352
|
await fsp.writeFile(paths.streamPath, retainedPayload, "utf-8");
|
|
349
353
|
}
|
|
350
354
|
|
|
355
|
+
function eventsShareIdentity(left = {}, right = {}) {
|
|
356
|
+
const leftKeys = new Set(sessionEventIdentityKeys(left));
|
|
357
|
+
if (leftKeys.size === 0) return false;
|
|
358
|
+
return sessionEventIdentityKeys(right).some((key) => leftKeys.has(key));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting = false } = {}) {
|
|
362
|
+
if (!mergeExisting) {
|
|
363
|
+
await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const currentEvents = await readEventsFromFile(paths.streamPath);
|
|
368
|
+
const matchingIndexes = [];
|
|
369
|
+
for (const [index, event] of currentEvents.entries()) {
|
|
370
|
+
if (eventsShareIdentity(event, canonicalEvent)) {
|
|
371
|
+
matchingIndexes.push(index);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (matchingIndexes.length === 0) {
|
|
376
|
+
await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const replacement = dedupeSessionEvents([
|
|
381
|
+
...matchingIndexes.map((index) => currentEvents[index]),
|
|
382
|
+
canonicalEvent,
|
|
383
|
+
])[0] || canonicalEvent;
|
|
384
|
+
const firstMatch = matchingIndexes[0];
|
|
385
|
+
const matchingIndexSet = new Set(matchingIndexes);
|
|
386
|
+
const mergedEvents = [];
|
|
387
|
+
for (const [index, event] of currentEvents.entries()) {
|
|
388
|
+
if (index === firstMatch) {
|
|
389
|
+
mergedEvents.push(replacement);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (matchingIndexSet.has(index)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
mergedEvents.push(event);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await fsp.writeFile(
|
|
399
|
+
paths.streamPath,
|
|
400
|
+
mergedEvents.length > 0
|
|
401
|
+
? `${mergedEvents.map((event) => JSON.stringify(event)).join("\n")}\n`
|
|
402
|
+
: "",
|
|
403
|
+
"utf-8",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
351
407
|
function filterBySince(events = [], since) {
|
|
352
408
|
const normalizedSince = normalizeString(since);
|
|
353
409
|
if (!normalizedSince) {
|
|
@@ -371,6 +427,7 @@ export async function appendToStream(
|
|
|
371
427
|
maxEvents = DEFAULT_MAX_STREAM_EVENTS,
|
|
372
428
|
syncRemote = true,
|
|
373
429
|
awaitRemoteSync = false,
|
|
430
|
+
mergeExisting = false,
|
|
374
431
|
} = {}
|
|
375
432
|
) {
|
|
376
433
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
@@ -403,7 +460,7 @@ export async function appendToStream(
|
|
|
403
460
|
await acquireLock(paths.lockPath);
|
|
404
461
|
try {
|
|
405
462
|
await fsp.mkdir(paths.sessionDir, { recursive: true });
|
|
406
|
-
await
|
|
463
|
+
await appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting });
|
|
407
464
|
await rotateStreamIfNeeded(paths, normalizedMaxEvents);
|
|
408
465
|
|
|
409
466
|
metadata.lastInteractionAt = nowIso;
|