@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +593 -0
  2. package/docs/distributions.md +429 -18
  3. package/package.json +6 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/default-pack/be-dev.md +20 -0
  7. package/src/shared/agents/default-pack/devops.md +14 -0
  8. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  9. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  10. package/src/shared/agents/default-pack/planner.md +17 -0
  11. package/src/shared/agents/default-pack/reviewer.md +14 -0
  12. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  13. package/src/shared/agents/den-claude.ts +271 -0
  14. package/src/shared/agents/generic.ts +52 -0
  15. package/src/shared/agents/personas.ts +218 -0
  16. package/src/shared/agents/records.ts +118 -0
  17. package/src/shared/agents/runtime.ts +199 -0
  18. package/src/shared/agents/runtimes.ts +54 -0
  19. package/src/shared/den-config.ts +116 -0
  20. package/src/shared/engine.ts +23 -0
  21. package/src/shared/environment-manager/scope.ts +22 -0
  22. package/src/shared/environment-manager/service-runner.ts +94 -2
  23. package/src/shared/environment-manager/workspaces.ts +19 -0
  24. package/src/shared/profile.ts +15 -4
  25. package/src/shared/secret-env.ts +70 -0
  26. package/src/shared/sessions/client.ts +284 -0
  27. package/src/shared/sessions/den-pty.ts +97 -0
  28. package/src/shared/sessions/env.ts +89 -0
  29. package/src/shared/sessions/host.ts +690 -0
  30. package/src/shared/sessions/names.ts +85 -0
  31. package/src/shared/sessions/paths.ts +27 -0
  32. package/src/shared/sessions/protocol.ts +72 -0
  33. package/src/shared/sessions/pty.ts +141 -0
  34. package/src/shared/sessions/ring-buffer.ts +160 -0
  35. package/src/shared/sessions/types.ts +81 -0
  36. package/src/shared/web-secret.ts +181 -0
  37. package/src/shared/web-session.ts +491 -0
  38. package/src/ui/cli/agent-ops.ts +843 -0
  39. package/src/ui/cli/agents-command.ts +294 -0
  40. package/src/ui/cli/doctor-command.ts +138 -9
  41. package/src/ui/cli/env-probe.ts +266 -0
  42. package/src/ui/cli/flag-parser.ts +32 -3
  43. package/src/ui/cli/index.ts +128 -12
  44. package/src/ui/cli/sessions-command.ts +750 -0
  45. package/src/ui/cli/target-workspace.ts +105 -0
  46. package/src/ui/cli/web-command.ts +536 -14
  47. package/src/ui/mcp/api.ts +7 -0
  48. package/src/ui/mcp/scope.ts +123 -19
  49. package/src/ui/mcp/server.ts +100 -7
  50. package/src/ui/mcp/tools.ts +44 -5
  51. package/src/ui/server/agent-routes.ts +323 -0
  52. package/src/ui/server/audit.ts +111 -22
  53. package/src/ui/server/http-errors.ts +21 -3
  54. package/src/ui/server/index.ts +820 -65
  55. package/src/ui/server/public/agents-page.js +648 -0
  56. package/src/ui/server/public/agents-section.js +299 -0
  57. package/src/ui/server/public/agents.html +439 -0
  58. package/src/ui/server/public/appearance.js +3 -0
  59. package/src/ui/server/public/error-page.js +103 -0
  60. package/src/ui/server/public/header.js +45 -11
  61. package/src/ui/server/public/i18n/es.js +90 -1
  62. package/src/ui/server/public/i18n/ja.js +86 -1
  63. package/src/ui/server/public/local-dev.html +53 -0
  64. package/src/ui/server/public/logs.html +3 -0
  65. package/src/ui/server/public/panel.js +1 -1
  66. package/src/ui/server/public/release.html +8 -0
  67. package/src/ui/server/public/settings-page.js +5 -0
  68. package/src/ui/server/public/settings-registry.js +33 -0
  69. package/src/ui/server/public/shell.js +248 -5
  70. package/src/ui/server/public/topbar.js +51 -2
  71. package/src/ui/server/readonly-routes.ts +25 -2
  72. package/src/ui/server/request-guard.ts +670 -0
  73. package/src/ui/server/route-classes.ts +317 -0
  74. package/src/ui/server/session-routes.ts +445 -0
  75. package/src/ui/server/shutdown-guard.ts +91 -0
  76. package/src/ui/server/sudo-store.ts +29 -3
  77. package/src/ui/server/ui-assets.ts +10 -1
  78. package/src/ui/server/websocket.ts +326 -0
  79. package/src/ui/server/workspace-routes.ts +16 -22
@@ -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
- return { scope: { workspace: got.workspace, actor: got.actor }, rest };
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
- /** The refusal for a call that names `other`. */
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 !== undefined && ws !== null && ws !== "" && ws !== scope.workspace)
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: scope.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 ws in r ? { [ws]: r[ws] } : {};
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) ? b.active.filter((n) => n === ws) : [],
184
- ...(b.focus === ws ? { focus: ws } : {}),
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: { workspace: ws, actor: scope.actor },
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<void> {
334
+ ): Promise<string> {
232
335
  const focused = focusedOf(await api.get("/api/workspaces"));
233
- if (focused !== scope.workspace)
234
- throw new ScopeRefusal(
235
- `${tool}: tests run against the focused workspace` +
236
- `${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
237
- `"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
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. */
@@ -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 { parseScopeFlags, ScopeUsageError, type McpScope } from "./scope.js";
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
- `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
316
- `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
317
- (scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
318
- "\n",
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
  }
@@ -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) await assertScopeFocused("tests_available", api, 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) await assertScopeFocused("run_tests", api, 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
- return "workspace" in tool.shape
714
- ? pinWorkspace(tool.name, args, scope)
715
- : args;
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(