@monster-orange/den 0.3.0 → 0.4.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/CHANGELOG.md +593 -0
- package/docs/distributions.md +429 -18
- package/package.json +6 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +271 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +199 -0
- package/src/shared/agents/runtimes.ts +54 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/profile.ts +15 -4
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +284 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +89 -0
- package/src/shared/sessions/host.ts +690 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +294 -0
- package/src/ui/cli/doctor-command.ts +138 -9
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +32 -3
- package/src/ui/cli/index.ts +128 -12
- package/src/ui/cli/sessions-command.ts +750 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/scope.ts +123 -19
- package/src/ui/mcp/server.ts +100 -7
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +111 -22
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +820 -65
- package/src/ui/server/public/agents-page.js +648 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/readonly-routes.ts +25 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +16 -22
package/src/ui/mcp/scope.ts
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
* filters lists to it, and stamps the actor on every web API call (the
|
|
5
5
|
* x-den-mcp-agent / x-den-mcp-workspace headers, see shared/agent-actor.ts).
|
|
6
6
|
*
|
|
7
|
+
* `--grant <ws>` (AG-2a, AG spec §5b; repeatable) widens the scope to another
|
|
8
|
+
* workspace, and only as far as the org config's `agents.<id>.grants` says
|
|
9
|
+
* (checkGrants): an agent never grants itself one. A call that names a
|
|
10
|
+
* granted workspace is served, and reported to the audit trail with both
|
|
11
|
+
* workspaces named. Lists show the scoped workspace and its grants.
|
|
12
|
+
*
|
|
7
13
|
* It binds cooperating agents only: the scope lives in this process, and the
|
|
8
14
|
* web server's HTTP API does not check it yet (WS-3a).
|
|
9
15
|
*/
|
|
10
16
|
import * as path from "path";
|
|
11
17
|
import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
18
|
+
import type { ResolvedAgentsConfig } from "../../shared/den-config.js";
|
|
12
19
|
import type { DenApi } from "./api.js";
|
|
13
20
|
import { Refusal } from "./guards.js";
|
|
14
21
|
|
|
@@ -17,6 +24,16 @@ export interface McpScope {
|
|
|
17
24
|
workspace: string;
|
|
18
25
|
/** Who acts: `agent:<id>`. */
|
|
19
26
|
actor: string;
|
|
27
|
+
/**
|
|
28
|
+
* Other workspaces it may name (`--grant`, from agents.<id>.grants);
|
|
29
|
+
* absent when there are none.
|
|
30
|
+
*/
|
|
31
|
+
grants?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The workspaces a scope serves: its own first, then its grants. */
|
|
35
|
+
export function scopeWorkspaces(scope: McpScope): string[] {
|
|
36
|
+
return [scope.workspace, ...(scope.grants ?? [])];
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
/** A call reached outside the scoped workspace. Always reported (F2). */
|
|
@@ -35,10 +52,11 @@ export class ScopeUsageError extends Error {
|
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
const FLAGS: Record<string, "workspace" | "actor"> = {
|
|
55
|
+
const FLAGS: Record<string, "workspace" | "actor" | "grant"> = {
|
|
39
56
|
"--workspace": "workspace",
|
|
40
57
|
"-w": "workspace",
|
|
41
58
|
"--actor": "actor",
|
|
59
|
+
"--grant": "grant",
|
|
42
60
|
};
|
|
43
61
|
|
|
44
62
|
/**
|
|
@@ -53,6 +71,7 @@ export function parseScopeFlags(argv: readonly string[]): {
|
|
|
53
71
|
rest: string[];
|
|
54
72
|
} {
|
|
55
73
|
const got: Partial<Record<"workspace" | "actor", string>> = {};
|
|
74
|
+
const grants: string[] = [];
|
|
56
75
|
const rest: string[] = [];
|
|
57
76
|
for (let i = 0; i < argv.length; i++) {
|
|
58
77
|
const a = argv[i];
|
|
@@ -71,12 +90,25 @@ export function parseScopeFlags(argv: readonly string[]): {
|
|
|
71
90
|
throw new ScopeUsageError(`${name} needs a value`);
|
|
72
91
|
i++;
|
|
73
92
|
}
|
|
93
|
+
if (key === "grant") {
|
|
94
|
+
if (!WORKSPACE_NAME_RE.test(value))
|
|
95
|
+
throw new ScopeUsageError(
|
|
96
|
+
`--grant "${printable(value)}" is not a workspace name`,
|
|
97
|
+
);
|
|
98
|
+
if (!grants.includes(value)) grants.push(value);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
74
101
|
if (got[key] !== undefined && got[key] !== value)
|
|
75
102
|
throw new ScopeUsageError(`--${key} given twice`);
|
|
76
103
|
got[key] = value;
|
|
77
104
|
}
|
|
78
|
-
if (got.workspace === undefined && got.actor === undefined)
|
|
105
|
+
if (got.workspace === undefined && got.actor === undefined) {
|
|
106
|
+
if (grants.length)
|
|
107
|
+
throw new ScopeUsageError(
|
|
108
|
+
"--grant needs --workspace and --actor: a grant widens a scoped server",
|
|
109
|
+
);
|
|
79
110
|
return { scope: null, rest };
|
|
111
|
+
}
|
|
80
112
|
if (got.workspace === undefined)
|
|
81
113
|
throw new ScopeUsageError(
|
|
82
114
|
"--actor needs --workspace: a scoped server serves one workspace",
|
|
@@ -95,7 +127,35 @@ export function parseScopeFlags(argv: readonly string[]): {
|
|
|
95
127
|
`--actor "${printable(got.actor)}" is not agent:<id> ` +
|
|
96
128
|
"(the id: letters, digits, dots, dashes, underscores; at most 64)",
|
|
97
129
|
);
|
|
98
|
-
|
|
130
|
+
const widened = grants.filter((g) => g !== got.workspace);
|
|
131
|
+
return {
|
|
132
|
+
scope: {
|
|
133
|
+
workspace: got.workspace,
|
|
134
|
+
actor: got.actor,
|
|
135
|
+
...(widened.length ? { grants: widened } : {}),
|
|
136
|
+
},
|
|
137
|
+
rest,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Null when every `--grant` is one the org config gives this agent
|
|
143
|
+
* (`agents.<id>.grants`), else the refusal. A grant is config in git,
|
|
144
|
+
* reviewed like code: a flag alone never widens a scope.
|
|
145
|
+
*/
|
|
146
|
+
export function checkGrants(
|
|
147
|
+
scope: McpScope,
|
|
148
|
+
agents: Pick<ResolvedAgentsConfig, "agents">,
|
|
149
|
+
): string | null {
|
|
150
|
+
const id = scope.actor.slice("agent:".length);
|
|
151
|
+
const allowed = agents.agents[id]?.grants ?? [];
|
|
152
|
+
for (const g of scope.grants ?? [])
|
|
153
|
+
if (!allowed.includes(g))
|
|
154
|
+
return (
|
|
155
|
+
`--grant ${g}: ${scope.actor} has no such grant in agents.${id}.grants ` +
|
|
156
|
+
"of the org config; a grant is config, never a flag alone"
|
|
157
|
+
);
|
|
158
|
+
return null;
|
|
99
159
|
}
|
|
100
160
|
|
|
101
161
|
/** A value echoed back in an error: one printable line, bounded. */
|
|
@@ -124,7 +184,14 @@ export function printablePath(v: string, max = 160): string {
|
|
|
124
184
|
return `${head}…${more}${tail}`;
|
|
125
185
|
}
|
|
126
186
|
|
|
127
|
-
/**
|
|
187
|
+
/** "grants: a, b" or "no grants": what else the scope reaches. */
|
|
188
|
+
function grantsNote(scope: McpScope): string {
|
|
189
|
+
return scope.grants?.length
|
|
190
|
+
? `grants: ${scope.grants.join(", ")}`
|
|
191
|
+
: "no grants";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The refusal for a call that names `other`; it names the agent's grants. */
|
|
128
195
|
export function otherWorkspace(
|
|
129
196
|
tool: string,
|
|
130
197
|
scope: McpScope,
|
|
@@ -132,7 +199,7 @@ export function otherWorkspace(
|
|
|
132
199
|
): ScopeRefusal {
|
|
133
200
|
return new ScopeRefusal(
|
|
134
201
|
`${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
|
|
135
|
-
`(${scope.actor}); "${printable(other)}" is another workspace, so nothing was read or run.`,
|
|
202
|
+
`(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run.`,
|
|
136
203
|
);
|
|
137
204
|
}
|
|
138
205
|
|
|
@@ -146,9 +213,20 @@ export function pinWorkspace(
|
|
|
146
213
|
scope: McpScope,
|
|
147
214
|
): Record<string, unknown> {
|
|
148
215
|
const ws = args.workspace;
|
|
149
|
-
if (ws
|
|
216
|
+
if (ws === undefined || ws === null || ws === "")
|
|
217
|
+
return { ...args, workspace: scope.workspace };
|
|
218
|
+
if (!scopeWorkspaces(scope).includes(String(ws)))
|
|
150
219
|
throw otherWorkspace(tool, scope, String(ws));
|
|
151
|
-
return { ...args, workspace:
|
|
220
|
+
return { ...args, workspace: String(ws) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The granted workspace `args` names, or null for the scope's own. */
|
|
224
|
+
export function crossedWorkspace(
|
|
225
|
+
args: Record<string, unknown>,
|
|
226
|
+
scope: McpScope,
|
|
227
|
+
): string | null {
|
|
228
|
+
const ws = args.workspace;
|
|
229
|
+
return typeof ws === "string" && ws && ws !== scope.workspace ? ws : null;
|
|
152
230
|
}
|
|
153
231
|
|
|
154
232
|
/** What /api/workspaces answers (the fields the scope reads). */
|
|
@@ -175,13 +253,20 @@ export function filterWorkspacesBody(
|
|
|
175
253
|
): Record<string, unknown> {
|
|
176
254
|
const b = asRecord(body) as WorkspacesBody;
|
|
177
255
|
const ws = scope.workspace;
|
|
256
|
+
const served = scopeWorkspaces(scope);
|
|
178
257
|
const only = (v: unknown): Record<string, unknown> => {
|
|
179
258
|
const r = asRecord(v);
|
|
180
|
-
return
|
|
259
|
+
return Object.fromEntries(
|
|
260
|
+
served.filter((n) => n in r).map((n) => [n, r[n]]),
|
|
261
|
+
);
|
|
181
262
|
};
|
|
182
263
|
return {
|
|
183
|
-
active: Array.isArray(b.active)
|
|
184
|
-
|
|
264
|
+
active: Array.isArray(b.active)
|
|
265
|
+
? b.active.filter((n) => served.includes(n as string))
|
|
266
|
+
: [],
|
|
267
|
+
...(typeof b.focus === "string" && served.includes(b.focus)
|
|
268
|
+
? { focus: b.focus }
|
|
269
|
+
: {}),
|
|
185
270
|
workspaces: only(b.workspaces),
|
|
186
271
|
...(b.resolvedDirs !== undefined
|
|
187
272
|
? { resolvedDirs: only(b.resolvedDirs) }
|
|
@@ -189,7 +274,11 @@ export function filterWorkspacesBody(
|
|
|
189
274
|
...(b.codeWorkspacePaths !== undefined
|
|
190
275
|
? { codeWorkspacePaths: only(b.codeWorkspacePaths) }
|
|
191
276
|
: {}),
|
|
192
|
-
scope: {
|
|
277
|
+
scope: {
|
|
278
|
+
workspace: ws,
|
|
279
|
+
actor: scope.actor,
|
|
280
|
+
...(scope.grants?.length ? { grants: scope.grants } : {}),
|
|
281
|
+
},
|
|
193
282
|
};
|
|
194
283
|
}
|
|
195
284
|
|
|
@@ -219,23 +308,38 @@ export async function assertScopeExists(
|
|
|
219
308
|
return body;
|
|
220
309
|
}
|
|
221
310
|
|
|
311
|
+
/** Throws unless the granted workspace `ws` exists in this distribution. */
|
|
312
|
+
export async function assertGrantExists(
|
|
313
|
+
tool: string,
|
|
314
|
+
api: DenApi,
|
|
315
|
+
scope: McpScope,
|
|
316
|
+
ws: string,
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const body = await api.get("/api/workspaces");
|
|
319
|
+
if (!(ws in asRecord(asRecord(body).workspaces)))
|
|
320
|
+
throw new ScopeRefusal(
|
|
321
|
+
`${tool}: ${scope.actor} has a grant for workspace "${printable(ws)}", which does not exist in this distribution; nothing was read or run.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
222
325
|
/**
|
|
223
326
|
* Tests run against the focused workspace (the web server has no workspace
|
|
224
327
|
* argument for them): a scoped server runs or lists them only when that is
|
|
225
|
-
* its own.
|
|
328
|
+
* its own or a granted one. Returns the focused workspace.
|
|
226
329
|
*/
|
|
227
330
|
export async function assertScopeFocused(
|
|
228
331
|
tool: string,
|
|
229
332
|
api: DenApi,
|
|
230
333
|
scope: McpScope,
|
|
231
|
-
): Promise<
|
|
334
|
+
): Promise<string> {
|
|
232
335
|
const focused = focusedOf(await api.get("/api/workspaces"));
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
336
|
+
// A granted workspace that is focused is served too (and audited).
|
|
337
|
+
if (focused && scopeWorkspaces(scope).includes(focused)) return focused;
|
|
338
|
+
throw new ScopeRefusal(
|
|
339
|
+
`${tool}: tests run against the focused workspace` +
|
|
340
|
+
`${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
|
|
341
|
+
`"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
|
|
342
|
+
);
|
|
239
343
|
}
|
|
240
344
|
|
|
241
345
|
/** The fields of an /api/services entry the scope checks read. */
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -11,18 +11,25 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
11
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
12
|
import { agentAuditHeaders } from "../../shared/agent-actor.js";
|
|
13
13
|
import {
|
|
14
|
+
readAgentsConfig,
|
|
14
15
|
readMcpConfig,
|
|
15
16
|
type ResolvedMcpConfig,
|
|
16
17
|
} from "../../shared/den-config.js";
|
|
17
18
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
18
|
-
import { engineVersion } from "../../shared/paths.js";
|
|
19
|
+
import { engineVersion, getStateDir } from "../../shared/paths.js";
|
|
20
|
+
import { ACTOR_TOKEN_ENV, lookupActorToken } from "../../shared/actor-token.js";
|
|
19
21
|
import { product } from "../../shared/product.js";
|
|
20
22
|
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
21
23
|
import { createDenApi, type DenApi } from "./api.js";
|
|
22
24
|
import { ownServer } from "../cli/server-identity.js";
|
|
23
25
|
import { callTool, toolsFor, type ToolContext } from "./tools.js";
|
|
24
26
|
import { plural } from "../../shared/plural.js";
|
|
25
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
checkGrants,
|
|
29
|
+
parseScopeFlags,
|
|
30
|
+
ScopeUsageError,
|
|
31
|
+
type McpScope,
|
|
32
|
+
} from "./scope.js";
|
|
26
33
|
|
|
27
34
|
export interface DenMcpOptions {
|
|
28
35
|
cfg: ResolvedMcpConfig;
|
|
@@ -146,7 +153,7 @@ export const MCP_USAGE = (cli: string): string =>
|
|
|
146
153
|
`Usage: ${cli} mcp [--port <web port>]`,
|
|
147
154
|
` ${cli} mcp --list`,
|
|
148
155
|
` ${cli} mcp --call <tool> ['<json args>']`,
|
|
149
|
-
` ${cli} mcp --workspace <ws> --actor agent:<id> [...]`,
|
|
156
|
+
` ${cli} mcp --workspace <ws> --actor agent:<id> [--grant <ws>…] [...]`,
|
|
150
157
|
"",
|
|
151
158
|
`An MCP server over stdio for agents, a client of the running web server`,
|
|
152
159
|
`(\`${cli} web up\`; default port: config web.port). Read tools always;`,
|
|
@@ -160,6 +167,10 @@ export const MCP_USAGE = (cli: string): string =>
|
|
|
160
167
|
"is recorded as agent:<id>. It binds cooperating agents only: the web",
|
|
161
168
|
"server's HTTP API does not check the scope yet.",
|
|
162
169
|
"",
|
|
170
|
+
"--grant <ws> (repeatable) also serves another workspace, only when the org",
|
|
171
|
+
"config's agents.<id>.grants lists it; each such call is recorded with both",
|
|
172
|
+
"workspaces. `agents start` passes an agent's grants for you.",
|
|
173
|
+
"",
|
|
163
174
|
`Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
|
|
164
175
|
].join("\n");
|
|
165
176
|
|
|
@@ -249,6 +260,63 @@ export async function runOneShot(
|
|
|
249
260
|
return r.isError ? 1 : 0;
|
|
250
261
|
}
|
|
251
262
|
|
|
263
|
+
/**
|
|
264
|
+
* SEC-1c: the MCP server Den starts for an agent carries the agent's actor
|
|
265
|
+
* token (DEN_ACTOR_TOKEN, inherited from the agent's session) as the
|
|
266
|
+
* credential of every call (cliAuthHeaders), and the web server records
|
|
267
|
+
* that token's agent as the actor. Why it must not start, or null: a token
|
|
268
|
+
* minted for another agent than `--actor`, or one this distribution never
|
|
269
|
+
* minted. With no token it runs as before (a person's own scoped server:
|
|
270
|
+
* the person token, its --actor an unverified claim).
|
|
271
|
+
*/
|
|
272
|
+
export function actorTokenRefusal(
|
|
273
|
+
scope: McpScope | null,
|
|
274
|
+
env: NodeJS.ProcessEnv,
|
|
275
|
+
stateDir: string,
|
|
276
|
+
): string | null {
|
|
277
|
+
const token = env[ACTOR_TOKEN_ENV];
|
|
278
|
+
if (token === undefined) return null;
|
|
279
|
+
const rec = lookupActorToken(stateDir, token);
|
|
280
|
+
if (!rec)
|
|
281
|
+
return `${ACTOR_TOKEN_ENV} is not a live actor token of this distribution (stopped, resumed, or never minted here)`;
|
|
282
|
+
if (rec.revokedAt)
|
|
283
|
+
return `${ACTOR_TOKEN_ENV} was revoked (\`web logout --all\`): restart or resume the agent to get a new one`;
|
|
284
|
+
if (scope && scope.actor !== rec.actor)
|
|
285
|
+
return `--actor ${scope.actor} does not match ${ACTOR_TOKEN_ENV}, which is ${rec.actor}'s`;
|
|
286
|
+
if (scope && scope.workspace !== rec.workspace)
|
|
287
|
+
return `--workspace ${scope.workspace} does not match ${ACTOR_TOKEN_ENV}, which is workspace ${rec.workspace}'s`;
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The line the stdio server prints at start (stderr). Its actor reads
|
|
293
|
+
* `as agent:<id>` only when DEN_ACTOR_TOKEN verified it (actorTokenRefusal
|
|
294
|
+
* passed with a token); without one the actor is a claim, and says so.
|
|
295
|
+
*/
|
|
296
|
+
export function mcpStartupLine(o: {
|
|
297
|
+
cli: string;
|
|
298
|
+
tools: number;
|
|
299
|
+
actions: readonly string[];
|
|
300
|
+
baseUrl: string;
|
|
301
|
+
scope: McpScope | null;
|
|
302
|
+
/** A DEN_ACTOR_TOKEN that matched the scope's actor and workspace. */
|
|
303
|
+
verified: boolean;
|
|
304
|
+
}): string {
|
|
305
|
+
const { scope } = o;
|
|
306
|
+
const actor = scope
|
|
307
|
+
? o.verified
|
|
308
|
+
? scope.actor
|
|
309
|
+
: `${scope.actor} (unverified: no ${ACTOR_TOKEN_ENV})`
|
|
310
|
+
: "";
|
|
311
|
+
return (
|
|
312
|
+
`${o.cli} mcp: ${plural(o.tools, "tool")} ` +
|
|
313
|
+
`(${o.actions.length ? `actions: ${o.actions.join(", ")}` : "read only"}) → ${o.baseUrl}` +
|
|
314
|
+
(scope ? ` · scoped to ${scope.workspace} as ${actor}` : "") +
|
|
315
|
+
(scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
|
|
316
|
+
"\n"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
252
320
|
/** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
|
|
253
321
|
export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
254
322
|
const cli = product().cli;
|
|
@@ -269,6 +337,26 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
|
269
337
|
process.exitCode = 2;
|
|
270
338
|
return;
|
|
271
339
|
}
|
|
340
|
+
const refusedToken = actorTokenRefusal(scope, process.env, getStateDir());
|
|
341
|
+
if (refusedToken) {
|
|
342
|
+
process.stderr.write(`${cli} mcp: ${refusedToken}\n`);
|
|
343
|
+
process.exitCode = 2;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (scope?.grants?.length) {
|
|
347
|
+
// A grant is config: a flag alone never widens a scope.
|
|
348
|
+
let refused: string | null;
|
|
349
|
+
try {
|
|
350
|
+
refused = checkGrants(scope, readAgentsConfig());
|
|
351
|
+
} catch (e) {
|
|
352
|
+
refused = (e as Error).message;
|
|
353
|
+
}
|
|
354
|
+
if (refused) {
|
|
355
|
+
process.stderr.write(`${cli} mcp: ${refused}\n`);
|
|
356
|
+
process.exitCode = 2;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
272
360
|
let port = parsePort(argv);
|
|
273
361
|
if (port === undefined) {
|
|
274
362
|
try {
|
|
@@ -312,9 +400,14 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
|
312
400
|
});
|
|
313
401
|
await server.connect(new StdioServerTransport());
|
|
314
402
|
process.stderr.write(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
403
|
+
mcpStartupLine({
|
|
404
|
+
cli,
|
|
405
|
+
tools: toolsFor(cfg).length,
|
|
406
|
+
actions: cfg.actions,
|
|
407
|
+
baseUrl,
|
|
408
|
+
scope,
|
|
409
|
+
// actorTokenRefusal passed: a token, if any, is this scope's.
|
|
410
|
+
verified: process.env[ACTOR_TOKEN_ENV] !== undefined,
|
|
411
|
+
}),
|
|
319
412
|
);
|
|
320
413
|
}
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -35,10 +35,12 @@ import {
|
|
|
35
35
|
} from "./guards.js";
|
|
36
36
|
import {
|
|
37
37
|
ScopeRefusal,
|
|
38
|
+
assertGrantExists,
|
|
38
39
|
assertPathInScope,
|
|
39
40
|
assertScopeExists,
|
|
40
41
|
assertScopeFocused,
|
|
41
42
|
assertServicesInScope,
|
|
43
|
+
crossedWorkspace,
|
|
42
44
|
filterWorkspacesBody,
|
|
43
45
|
pinWorkspace,
|
|
44
46
|
type McpScope,
|
|
@@ -303,7 +305,12 @@ const READ_TOOLS: DenTool[] = [
|
|
|
303
305
|
"The test modules of a pillar, and which repos (branch + SHA) they come from.",
|
|
304
306
|
{ pillar: z.enum(PILLARS) },
|
|
305
307
|
async (a, { api, scope }) => {
|
|
306
|
-
if (scope)
|
|
308
|
+
if (scope)
|
|
309
|
+
await crossIfGranted(
|
|
310
|
+
{ api, scope },
|
|
311
|
+
"tests_available",
|
|
312
|
+
await assertScopeFocused("tests_available", api, scope),
|
|
313
|
+
);
|
|
307
314
|
return {
|
|
308
315
|
modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
|
|
309
316
|
sources: await api.get("/api/test-sources", {
|
|
@@ -538,7 +545,12 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
538
545
|
const mode =
|
|
539
546
|
pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
|
|
540
547
|
assertRunTestsAllowed({ pillar, env, mode }, cfg);
|
|
541
|
-
if (scope)
|
|
548
|
+
if (scope)
|
|
549
|
+
await crossIfGranted(
|
|
550
|
+
{ api, scope },
|
|
551
|
+
"run_tests",
|
|
552
|
+
await assertScopeFocused("run_tests", api, scope),
|
|
553
|
+
);
|
|
542
554
|
if (pillar === "security") {
|
|
543
555
|
for (const service of modules.length ? modules : [undefined]) {
|
|
544
556
|
const plan = (await api.get("/api/security/plan", {
|
|
@@ -710,9 +722,36 @@ async function scopeArgs(
|
|
|
710
722
|
await assertScopeExists(ctx.api, scope);
|
|
711
723
|
verifiedScopes.add(scope);
|
|
712
724
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
725
|
+
if (!("workspace" in tool.shape)) return args;
|
|
726
|
+
const pinned = pinWorkspace(tool.name, args, scope);
|
|
727
|
+
const crossed = crossedWorkspace(pinned, scope);
|
|
728
|
+
if (crossed) {
|
|
729
|
+
await assertGrantExists(tool.name, ctx.api, scope, crossed);
|
|
730
|
+
await reportCrossWorkspace(ctx.api, tool.name, crossed);
|
|
731
|
+
}
|
|
732
|
+
return pinned;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* A call into a granted workspace (AG-2a, §5b) is recorded before it runs,
|
|
737
|
+
* with both workspaces named: the server's entry takes the scoped one from
|
|
738
|
+
* the actor's headers and the target from this report.
|
|
739
|
+
*/
|
|
740
|
+
async function reportCrossWorkspace(
|
|
741
|
+
api: DenApi,
|
|
742
|
+
tool: string,
|
|
743
|
+
target: string,
|
|
744
|
+
): Promise<void> {
|
|
745
|
+
await api.get("/api/activity", {}, "POST", { tool, crossWorkspace: target });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Report `ws` when it is a granted workspace, not the scope's own. */
|
|
749
|
+
async function crossIfGranted(
|
|
750
|
+
ctx: { api: DenApi; scope: McpScope },
|
|
751
|
+
tool: string,
|
|
752
|
+
ws: string,
|
|
753
|
+
): Promise<void> {
|
|
754
|
+
if (ws !== ctx.scope.workspace) await reportCrossWorkspace(ctx.api, tool, ws);
|
|
716
755
|
}
|
|
717
756
|
|
|
718
757
|
export async function callTool(
|