sentinelayer-cli 0.43.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 +29 -16
- 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 +166 -51
- 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 +160 -7
- package/src/mcp/registry.js +6 -5
- package/src/mcp/session-stdio-server.js +157 -8
- 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 +41 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +91 -21
- package/src/session/checkpoints.js +11 -10
- package/src/session/control-events.js +2 -0
- 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/reactions.js +258 -0
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +110 -66
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- package/src/telemetry/sync.js +8 -9
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// bridge-inputs.js -- the inputs the MCP CLI bridge exposes, command by command.
|
|
2
|
+
//
|
|
3
|
+
// Default-deny. An input not listed for its command is not exposed; a command with no entry,
|
|
4
|
+
// or with an argument or a required option that is not listed, is not exposed at all. Commands
|
|
5
|
+
// the bridge blocks outright (src/mcp/cli-command-tools.js) need no entry. DENIED records each
|
|
6
|
+
// input deliberately left out, and why. A unit test walks the command tree and fails on any
|
|
7
|
+
// input of an exposed command that is in neither list, and on entries that name nothing.
|
|
8
|
+
//
|
|
9
|
+
// Nothing that names where a request goes (a URL, host, origin, endpoint or gateway) is allowed:
|
|
10
|
+
// a tool caller never chooses a destination for this machine's credentials. Those commands use
|
|
11
|
+
// the configured API and pocket gateway. Nor is anything that names a program to run (an
|
|
12
|
+
// executable, binary, shell, command or script) or turns off a safety check (a sandbox, approval
|
|
13
|
+
// or similar bypass): a tool caller never chooses what this machine executes, or how freely.
|
|
14
|
+
|
|
15
|
+
/** Command path (dotted, as in the tool name after "sl.") -> its exposed inputs, space-separated. */
|
|
16
|
+
export const BRIDGE_ALLOWED_INPUTS = Object.freeze({
|
|
17
|
+
"ai.identity.audit": "path outputDir stale json",
|
|
18
|
+
"ai.identity.lineage": "identityId path outputDir apiKey orgId projectId json",
|
|
19
|
+
"ai.identity.list": "path outputDir json",
|
|
20
|
+
"ai.identity.show": "identityId path outputDir json",
|
|
21
|
+
"apply": "plan path outputDir json",
|
|
22
|
+
"audit.architecture": "path registryFile outputDir refresh dryRun json",
|
|
23
|
+
"audit.compliance": "path registryFile outputDir refresh dryRun json",
|
|
24
|
+
"audit.diff": "baseRunId candidateRunId path outputDir json",
|
|
25
|
+
"audit.documentation": "path registryFile outputDir refresh dryRun json",
|
|
26
|
+
"audit.frontend": "path mode maxCost maxTurns provider model apiKey stream refresh skipBaseline skipRuntime outputDir json",
|
|
27
|
+
"audit.local": "path outputDir reuseOmargate json",
|
|
28
|
+
"audit.package": "path runId outputDir json",
|
|
29
|
+
"audit.performance": "path registryFile outputDir refresh dryRun json",
|
|
30
|
+
"audit.registry": "registryFile json",
|
|
31
|
+
"audit.replay": "runId path registryFile outputDir refresh json",
|
|
32
|
+
"audit.security": "path registryFile outputDir refresh dryRun json",
|
|
33
|
+
"audit.testing": "path registryFile outputDir refresh dryRun json",
|
|
34
|
+
"board.check": "file json",
|
|
35
|
+
"chat.ask": "prompt provider model apiKey path outputDir sessionId dryRun stream json",
|
|
36
|
+
"cost.record": "path outputDir sessionId provider model inputTokens outputTokens cacheReadTokens cacheWriteTokens durationMs toolCalls costUsd progressScore maxCost maxTokens maxRuntimeMs maxToolCalls maxNoProgress warnAtPercent json",
|
|
37
|
+
"cost.show": "path outputDir json",
|
|
38
|
+
"daemon.assign.claim": "workItemId path outputDir agent leaseTtlSeconds stage runId jiraIssueKey budgetSnapshotJson json",
|
|
39
|
+
"daemon.assign.heartbeat": "workItemId path outputDir agent leaseTtlSeconds stage runId jiraIssueKey budgetSnapshotJson json",
|
|
40
|
+
"daemon.assign.list": "path outputDir status agent includeExpired limit json",
|
|
41
|
+
"daemon.assign.reassign": "workItemId path outputDir fromAgent toAgent leaseTtlSeconds stage runId jiraIssueKey budgetSnapshotJson json",
|
|
42
|
+
"daemon.assign.release": "workItemId path outputDir agent status stage runId jiraIssueKey reason budgetSnapshotJson json",
|
|
43
|
+
"daemon.budget.check": "workItemId path outputDir usageJson budgetJson nowIso json",
|
|
44
|
+
"daemon.budget.status": "path outputDir workItemId lifecycleState limit json",
|
|
45
|
+
"daemon.control.snapshot": "path outputDir status agent limit nowIso json",
|
|
46
|
+
"daemon.error.queue": "path outputDir status limit json",
|
|
47
|
+
// endpoint: a route label written to the local error intake; nothing is sent to it
|
|
48
|
+
"daemon.error.record": "path outputDir source service endpoint errorCode severity message stack commitSha metadataJson json",
|
|
49
|
+
"daemon.error.worker": "path outputDir maxEvents json",
|
|
50
|
+
"daemon.jira.comment": "path outputDir workItemId issueKey actor type message json",
|
|
51
|
+
"daemon.jira.list": "path outputDir workItemId issueKey status limit json",
|
|
52
|
+
"daemon.jira.open": "workItemId path outputDir summary description labels assignee issueKey issueKeyPrefix actor json",
|
|
53
|
+
"daemon.jira.start": "workItemId plan path outputDir actor assignee summary description labels issueKey issueKeyPrefix json",
|
|
54
|
+
"daemon.jira.transition": "path outputDir workItemId issueKey to actor reason json",
|
|
55
|
+
"daemon.lineage.build": "path outputDir nowIso json",
|
|
56
|
+
"daemon.lineage.list": "path outputDir status workItemId limit json",
|
|
57
|
+
"daemon.lineage.show": "workItemId path outputDir json",
|
|
58
|
+
"daemon.maintenance.off": "path outputDir reason message actor json",
|
|
59
|
+
"daemon.maintenance.on": "path outputDir reason message actor json",
|
|
60
|
+
"daemon.maintenance.status": "path outputDir json",
|
|
61
|
+
"daemon.map.handoff": "workItemId path outputDir runId assignee maxFiles nowIso json",
|
|
62
|
+
"daemon.map.handoff-list": "path outputDir workItemId limit json",
|
|
63
|
+
"daemon.map.handoff-show": "workItemId path outputDir handoffRunId json",
|
|
64
|
+
"daemon.map.list": "path outputDir workItemId limit json",
|
|
65
|
+
"daemon.map.scope": "workItemId path outputDir maxFiles graphDepth nowIso json",
|
|
66
|
+
"daemon.map.show": "workItemId path outputDir runId json",
|
|
67
|
+
"daemon.reliability.run": "path outputDir region timezone simulateFailure checks maintenanceAutoOpen clearMaintenanceOnPass nowIso json",
|
|
68
|
+
"daemon.reliability.status": "path outputDir limit json",
|
|
69
|
+
"daemon.watchdog.run": "path outputDir toolCallSeconds repeatedFileReadsThreshold budgetWarningThreshold turnStallTurns limit execute nowIso json",
|
|
70
|
+
"daemon.watchdog.status": "path outputDir limit json",
|
|
71
|
+
"guide.export": "format path specFile outputFile enrich provider model apiKey maxPhases maxPrsPerPhase json",
|
|
72
|
+
"guide.generate": "path specFile outputFile json",
|
|
73
|
+
"guide.show": "path file plain json",
|
|
74
|
+
"ingest.map": "path outputFile outputDir json",
|
|
75
|
+
"init": "projectName nonInteractive interviewFile skipBrowserOpen",
|
|
76
|
+
"mcp.bridge.init-vscode": "serverId serverConfig path force json",
|
|
77
|
+
"mcp.doctor": "timeoutMs json",
|
|
78
|
+
"mcp.list": "path json",
|
|
79
|
+
"mcp.registry.init-aidenid": "path outputDir force json",
|
|
80
|
+
"mcp.registry.init-aidenid-adapter": "registryFile path outputDir force json",
|
|
81
|
+
"mcp.registry.init-cli": "path outputDir force json",
|
|
82
|
+
"mcp.registry.init-hosted-session-connector": "registryFile path outputDir force json",
|
|
83
|
+
"mcp.registry.init-senti-session": "path outputDir force json",
|
|
84
|
+
"mcp.registry.init-session": "path outputDir force json",
|
|
85
|
+
"mcp.registry.validate": "file json",
|
|
86
|
+
"mcp.registry.validate-aidenid-adapter": "file registryFile json",
|
|
87
|
+
"mcp.registry.validate-hosted-session-connector": "file registryFile json",
|
|
88
|
+
"mcp.schema.show": "json",
|
|
89
|
+
"mcp.schema.write": "path outputDir force json",
|
|
90
|
+
"mcp.server.init": "id registryFile path outputDir force json",
|
|
91
|
+
"mcp.server.validate": "file json",
|
|
92
|
+
"mcp.smoke": "session limit scope ttlSeconds timeoutMs autoRotate json",
|
|
93
|
+
"omargate.deep": "path outputDir ai aiDryRun scopeMode diff staged scanMode maxParallel model provider maxCost notifySession requireUsageLedger persona skipPersona stream json",
|
|
94
|
+
"persona.orchestrator": "mode path outputDir json",
|
|
95
|
+
"persona.run": "personaId mode path files json",
|
|
96
|
+
"plugin.init": "id packType stage path outputDir force json",
|
|
97
|
+
"plugin.list": "path outputDir json",
|
|
98
|
+
"plugin.order": "path stage outputDir json",
|
|
99
|
+
"plugin.validate": "file json",
|
|
100
|
+
"policy.list": "path outputDir json",
|
|
101
|
+
"prompt.generate": "path specFile agent outputFile outputDir refresh json",
|
|
102
|
+
"prompt.preview": "path specFile agent maxLines outputDir refresh plain json",
|
|
103
|
+
"prompt.show": "path file agent plain json",
|
|
104
|
+
"review.accept": "findingId runId path outputDir note actor json",
|
|
105
|
+
"review.defer": "findingId runId path outputDir note actor json",
|
|
106
|
+
"review.diff": "baseRunId candidateRunId path outputDir outputFile json",
|
|
107
|
+
"review.reject": "findingId runId path outputDir note actor json",
|
|
108
|
+
"review.replay": "runId path spec outputDir refresh ai aiDryRun json",
|
|
109
|
+
"review.scan": "mode diff staged path spec outputDir refresh json",
|
|
110
|
+
"ring-owner": "question session kind option whatWeNeed checkpoint idempotencyKey json",
|
|
111
|
+
"scan.init": "path specFile workflowFile secretName hasE2eTests playwrightMode nonInteractive json",
|
|
112
|
+
"scan.precheck": "path specFile outputFile outputDir hasE2eTests playwrightMode provider model apiKey sessionId maxCost maxTokens maxRuntimeMs maxToolCalls maxNoProgress warnAtPercent json",
|
|
113
|
+
"scan.validate": "path specFile workflowFile secretName hasE2eTests playwrightMode json",
|
|
114
|
+
"session.action": "sessionId actionType targetSequence targetCursor targetActionId note agent idempotencyKey path json",
|
|
115
|
+
"session.actions": "json",
|
|
116
|
+
"session.checkpoint.create": "sessionId startSequence endSequence title summary summaryFile kind checkpointId agent tokenStart tokenEnd path json",
|
|
117
|
+
"session.checkpoint.generate": "sessionId minEvents maxEvents catchUp maxCheckpoints operationId agent path json",
|
|
118
|
+
"session.checkpoint.list": "sessionId limit path json",
|
|
119
|
+
"session.checkpoint.show": "sessionId checkpointId contextEvents maxEvents path json",
|
|
120
|
+
"session.comment": "sessionId targetSequenceId message agent idempotencyKey path json",
|
|
121
|
+
"session.continue": "path title json",
|
|
122
|
+
"session.edit": "sessionId target message agent expectedRevision idempotencyKey path json",
|
|
123
|
+
"session.ensure": "path session title ttlSeconds forceNew resume reuseWindowSeconds json",
|
|
124
|
+
"session.guard": "sessionId files agent path json",
|
|
125
|
+
"session.guard-hook": "sessionId agent path",
|
|
126
|
+
"session.guard-install": "sessionId agent path json",
|
|
127
|
+
"session.guard-uninstall": "sessionId agent path json",
|
|
128
|
+
"session.history": "limit preview path json",
|
|
129
|
+
"session.inject-guide": "sessionId path json",
|
|
130
|
+
"session.join": "sessionId name agent inviteToken seatKey idempotencyKey role model path goal deliverable stopWhen scope ttl provider wait waitSeconds cancelAdmission json",
|
|
131
|
+
"session.leave": "sessionId agent reason path json",
|
|
132
|
+
"session.list": "remote includeArchived limit path json",
|
|
133
|
+
"session.listeners": "sessionId path limit maxPages json",
|
|
134
|
+
"session.lock": "sessionId files intent ttl agent path json",
|
|
135
|
+
"session.locks": "sessionId path json",
|
|
136
|
+
"session.observe": "sessionId summary agent model displayName role kind severity owner batch targetSequence targetCursor proposal messageFile stdin path json",
|
|
137
|
+
"session.pins": "sessionId path json",
|
|
138
|
+
"session.post-agent": "sessionId message agent model displayName role to path json",
|
|
139
|
+
"session.react": "sessionId reaction targetSequence targetCursor targetActionId agent idempotencyKey path json",
|
|
140
|
+
"session.read": "sessionId tail follow live remote beforeSequence actions agent view includeControlEvents path json",
|
|
141
|
+
"session.recall": "need session role k path remote includeControlEvents json",
|
|
142
|
+
"session.recap.now": "sessionId session remote agent maxEvents path json",
|
|
143
|
+
"session.renew": "sessionId files ttl agent path json",
|
|
144
|
+
"session.reply": "sessionId targetSequenceId message agent idempotencyKey path json",
|
|
145
|
+
"session.say": "sessionId message agent model displayName role to replyTo replyCursor messageFile stdin forceCliUser localOnly path json",
|
|
146
|
+
"session.search": "sessionId query beforeSequence limit path json",
|
|
147
|
+
"session.set-title": "sessionId title path json",
|
|
148
|
+
"session.setup-guides": "sessionId path json",
|
|
149
|
+
"session.start": "path title template ttlSeconds forceNew force resume reuseWindowSeconds daemon json",
|
|
150
|
+
"session.status": "sessionId path json",
|
|
151
|
+
"session.stop-listener": "sessionId agent path json",
|
|
152
|
+
"session.sync": "sessionId since path json",
|
|
153
|
+
"session.templates": "json",
|
|
154
|
+
"session.ticket.claim": "sessionId ticketId expectedVersion agent path json",
|
|
155
|
+
"session.ticket.events": "sessionId after agent path json",
|
|
156
|
+
"session.ticket.list": "sessionId available agent path json",
|
|
157
|
+
"session.ticket.release": "sessionId ticketId reason agent path json",
|
|
158
|
+
"session.ticket.renew": "sessionId ticketId agent path json",
|
|
159
|
+
"session.ticket.submit": "sessionId ticketId sha evidenceFile agent path json",
|
|
160
|
+
"session.unlock": "sessionId files intent agent path json",
|
|
161
|
+
"session.usage": "sessionId remote recent format out path json",
|
|
162
|
+
"session.wake.codex-notify": "sessionId notificationJson session agent notification path json",
|
|
163
|
+
// host and resumeSession: the local host adapter (claude|codex) and its session to resume
|
|
164
|
+
"session.wake.daemon": "sessionId session agent host resumeSession cwd idleMs maxAttempts once json",
|
|
165
|
+
"spec.generate": "path template description projectType outputFile outputDir refresh ai provider model apiKey sessionId maxCost maxTokens maxRuntimeMs maxToolCalls maxNoProgress warnAtPercent json",
|
|
166
|
+
"spec.list-templates": "json",
|
|
167
|
+
"spec.regenerate": "path file template description projectType refresh dryRun diff plain maxDiffLines preserveManual json",
|
|
168
|
+
"spec.show": "path file plain json",
|
|
169
|
+
"spec.show-template": "templateId json",
|
|
170
|
+
"swarm.create": "path outputDir scenario penTestScenario targetId execute json",
|
|
171
|
+
"swarm.dashboard": "path runId outputDir watch pollSeconds maxIdleSeconds json",
|
|
172
|
+
"swarm.plan": "targetPath path registryFile agents scenario objective maxParallel maxCostUsd maxOutputTokens maxRuntimeMs maxToolCalls warningThresholdPercent outputDir json",
|
|
173
|
+
"swarm.registry": "registryFile json",
|
|
174
|
+
"swarm.report": "path runId planFile outputDir json",
|
|
175
|
+
"swarm.run": "targetPath path planFile playbookFile scenarioFile registryFile agents agent scope identityId scenario objective maxParallel maxSteps engine execute maxCostUsd maxOutputTokens maxRuntimeMs maxToolCalls warningThresholdPercent outputDir json",
|
|
176
|
+
"swarm.scenario.init": "scenarioId path output json",
|
|
177
|
+
"swarm.scenario.validate": "file json",
|
|
178
|
+
"telemetry.record": "path outputDir sessionId runId eventType inputTokens outputTokens cacheReadTokens cacheWriteTokens costUsd durationMs toolCalls stopClass blocking reasonCodes metadataJson json",
|
|
179
|
+
"telemetry.show": "path outputDir json",
|
|
180
|
+
"watch.history": "runId path outputDir limit json",
|
|
181
|
+
"watch.run-events": "runId path outputDir pollSeconds maxIdleSeconds saveArtifacts json",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** Command path -> { input: why it is not exposed }. */
|
|
185
|
+
export const BRIDGE_DENIED_INPUTS = Object.freeze({
|
|
186
|
+
"ai.identity.lineage": {
|
|
187
|
+
apiUrl: "the AIdenID API that receives this command's API key",
|
|
188
|
+
},
|
|
189
|
+
"audit.frontend": {
|
|
190
|
+
url: "a deployed site that this command fetches and audits",
|
|
191
|
+
},
|
|
192
|
+
"init": {
|
|
193
|
+
injectSecret: "sets the project token as a GitHub Actions secret on a repository",
|
|
194
|
+
injectOpenaiKey: "sets OPENAI_API_KEY as a GitHub Actions secret on a repository",
|
|
195
|
+
},
|
|
196
|
+
"mcp.doctor": {
|
|
197
|
+
apiUrl: "the API that this command probes",
|
|
198
|
+
},
|
|
199
|
+
"mcp.smoke": {
|
|
200
|
+
apiUrl: "the API that receives the user's token",
|
|
201
|
+
},
|
|
202
|
+
"ring-owner": {
|
|
203
|
+
gatewayUrl: "the gateway that receives the user's token",
|
|
204
|
+
},
|
|
205
|
+
"swarm.create": {
|
|
206
|
+
target: "the site that this plan tests",
|
|
207
|
+
},
|
|
208
|
+
"swarm.run": {
|
|
209
|
+
startUrl: "the page that the browser opens",
|
|
210
|
+
},
|
|
211
|
+
"swarm.scenario.init": {
|
|
212
|
+
startUrl: "the page that a later swarm run opens",
|
|
213
|
+
},
|
|
214
|
+
"watch.run-events": {
|
|
215
|
+
apiUrl: "the API that receives the user's token",
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** The inputs a command exposes through the bridge, or null if it has no entry. */
|
|
220
|
+
export function bridgeAllowedInputs(commandPath) {
|
|
221
|
+
const listed = BRIDGE_ALLOWED_INPUTS[commandPath];
|
|
222
|
+
return typeof listed === "string" ? new Set(listed.split(/\s+/).filter(Boolean)) : null;
|
|
223
|
+
}
|
|
@@ -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
|
|
@@ -64,9 +71,16 @@ const SENSITIVE_COMMAND_PREFIXES = [
|
|
|
64
71
|
"ai.identity.target.",
|
|
65
72
|
"ai.identity.site.",
|
|
66
73
|
"ai.identity.legal-hold.",
|
|
74
|
+
// Room access administration (request/list/status/approve/deny/revoke/mode) belongs to
|
|
75
|
+
// the room owner's own session; operators use the direct CLI or the web dashboard.
|
|
76
|
+
"session.access.",
|
|
67
77
|
];
|
|
68
78
|
const REDACTION_MARKER = "[REDACTED]";
|
|
69
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
|
+
|
|
70
84
|
function normalizeString(value) {
|
|
71
85
|
return String(value == null ? "" : value).trim();
|
|
72
86
|
}
|
|
@@ -114,11 +128,21 @@ function optionFlagFromFlags(flags) {
|
|
|
114
128
|
return short ? short[0] : "";
|
|
115
129
|
}
|
|
116
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
|
+
|
|
117
139
|
function normalizePositionalSpec(argument = {}, index = 0) {
|
|
140
|
+
const name = normalizePropertyName(argument.name, `arg${index + 1}`);
|
|
118
141
|
return {
|
|
119
|
-
name
|
|
142
|
+
name,
|
|
120
143
|
required: Boolean(argument.required),
|
|
121
144
|
variadic: Boolean(argument.variadic),
|
|
145
|
+
pathLike: Boolean(argument.pathLike) || isPathLikeInput(name),
|
|
122
146
|
};
|
|
123
147
|
}
|
|
124
148
|
|
|
@@ -133,6 +157,7 @@ function normalizeOptionSpec(option = {}, index = 0) {
|
|
|
133
157
|
expectsValue,
|
|
134
158
|
variadic: Boolean(option.variadic),
|
|
135
159
|
json: name === "json" || flag === "--json",
|
|
160
|
+
pathLike: Boolean(option.pathLike) || (expectsValue && isPathLikeInput(name, option.flags)),
|
|
136
161
|
};
|
|
137
162
|
}
|
|
138
163
|
|
|
@@ -224,6 +249,52 @@ function isAllowedInputValue(value) {
|
|
|
224
249
|
return false;
|
|
225
250
|
}
|
|
226
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
|
+
|
|
227
298
|
function validateBridgeToolInput(tool = {}, input = {}) {
|
|
228
299
|
if (!isPlainObject(input)) {
|
|
229
300
|
return "input_must_be_object";
|
|
@@ -238,9 +309,59 @@ function validateBridgeToolInput(tool = {}, input = {}) {
|
|
|
238
309
|
return `unsupported_input_type:${key}`;
|
|
239
310
|
}
|
|
240
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
|
+
}
|
|
241
336
|
return "";
|
|
242
337
|
}
|
|
243
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
|
+
|
|
244
365
|
function normalizeRegistryTool(tool = {}) {
|
|
245
366
|
const metadata = tool.metadata || {};
|
|
246
367
|
const cliPath = Array.isArray(metadata.cliPath)
|
|
@@ -255,19 +376,28 @@ function normalizeRegistryTool(tool = {}) {
|
|
|
255
376
|
? metadata.arguments
|
|
256
377
|
: [];
|
|
257
378
|
const optionSource = Array.isArray(metadata.options) ? metadata.options : [];
|
|
258
|
-
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));
|
|
259
384
|
const name = normalizeToolName(tool.name || commandPathKey);
|
|
260
|
-
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" : "");
|
|
261
391
|
const security = {
|
|
262
392
|
...(tool.security || {}),
|
|
263
|
-
...(
|
|
393
|
+
...(runtimeBlockReason ? { runtime_blocked: true, runtime_block_reason: runtimeBlockReason } : {}),
|
|
264
394
|
};
|
|
265
395
|
|
|
266
396
|
return {
|
|
267
397
|
name,
|
|
268
398
|
title: tool.title || `sl ${cliPath.join(" ")}`.trim(),
|
|
269
399
|
description: tool.description || `Run SentinelLayer CLI command sl ${cliPath.join(" ")}.`,
|
|
270
|
-
inputSchema:
|
|
400
|
+
inputSchema: withoutInputs(rawSchema, withheld),
|
|
271
401
|
security,
|
|
272
402
|
annotations: {
|
|
273
403
|
...(tool.annotations || {}),
|
|
@@ -280,9 +410,11 @@ function normalizeRegistryTool(tool = {}) {
|
|
|
280
410
|
cliPath,
|
|
281
411
|
positional: positionalSource.map(normalizePositionalSpec),
|
|
282
412
|
options,
|
|
413
|
+
withheldOptions: [...withheld].sort(),
|
|
283
414
|
supportsJson: options.some((option) => option.json),
|
|
284
415
|
blocked: Boolean(metadata.blocked) || Boolean(blockedReason),
|
|
285
416
|
blockedReason: metadata.blockedReason || blockedReason,
|
|
417
|
+
approvalRequired,
|
|
286
418
|
},
|
|
287
419
|
};
|
|
288
420
|
}
|
|
@@ -485,6 +617,10 @@ export function createCliCommandMcpToolHandlers(
|
|
|
485
617
|
targetPath = process.cwd(),
|
|
486
618
|
executeCliCommandFn = executeCliCommand,
|
|
487
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,
|
|
488
624
|
} = {},
|
|
489
625
|
) {
|
|
490
626
|
const handlers = {};
|
|
@@ -507,6 +643,21 @@ export function createCliCommandMcpToolHandlers(
|
|
|
507
643
|
tool: tool.name,
|
|
508
644
|
};
|
|
509
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
|
+
}
|
|
510
661
|
const inputError = validateBridgeToolInput(tool, input);
|
|
511
662
|
if (inputError) {
|
|
512
663
|
return {
|
|
@@ -516,10 +667,12 @@ export function createCliCommandMcpToolHandlers(
|
|
|
516
667
|
tool: tool.name,
|
|
517
668
|
};
|
|
518
669
|
}
|
|
519
|
-
|
|
670
|
+
const pathInput = workspacePathInputError(tool, input, targetPath);
|
|
671
|
+
if (pathInput) {
|
|
520
672
|
return {
|
|
521
673
|
ok: false,
|
|
522
|
-
reason:
|
|
674
|
+
reason: "path_outside_workspace",
|
|
675
|
+
detail: pathInput,
|
|
523
676
|
tool: tool.name,
|
|
524
677
|
};
|
|
525
678
|
}
|
package/src/mcp/registry.js
CHANGED
|
@@ -655,7 +655,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
655
655
|
name: "session_action",
|
|
656
656
|
title: "Record Senti Session Action",
|
|
657
657
|
description:
|
|
658
|
-
"Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
|
|
658
|
+
"Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
|
|
659
659
|
input_schema: {
|
|
660
660
|
type: "object",
|
|
661
661
|
additionalProperties: false,
|
|
@@ -665,7 +665,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
665
665
|
agentId: { type: "string" },
|
|
666
666
|
actionType: {
|
|
667
667
|
type: "string",
|
|
668
|
-
enum: ["ack", "working_on", "reply", "like", "dislike", "disregard", "view"],
|
|
668
|
+
enum: ["ack", "working_on", "reply", "like", "dislike", "unlike", "undislike", "disregard", "view"],
|
|
669
669
|
},
|
|
670
670
|
targetSequenceId: { type: "integer", minimum: 1 },
|
|
671
671
|
targetCursor: { type: "string" },
|
|
@@ -702,7 +702,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
702
702
|
name: "session_react",
|
|
703
703
|
title: "React To Senti Message",
|
|
704
704
|
description:
|
|
705
|
-
"Acknowledge or react to a target session event with ack, like, or dislike.",
|
|
705
|
+
"Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
|
|
706
706
|
input_schema: {
|
|
707
707
|
type: "object",
|
|
708
708
|
additionalProperties: false,
|
|
@@ -710,7 +710,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
710
710
|
properties: {
|
|
711
711
|
sessionId: { type: "string" },
|
|
712
712
|
agentId: { type: "string" },
|
|
713
|
-
reaction: { type: "string", enum: ["ack", "like", "dislike"] },
|
|
713
|
+
reaction: { type: "string", enum: ["ack", "like", "dislike", "unlike", "undislike"] },
|
|
714
714
|
targetSequenceId: { type: "integer", minimum: 1 },
|
|
715
715
|
targetCursor: { type: "string" },
|
|
716
716
|
targetActionId: { type: "string" },
|
|
@@ -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: {
|