@monster-orange/den 0.3.0 → 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/docs/distributions.md +229 -8
  3. package/package.json +4 -1
  4. package/src/shared/agents/default-pack/be-dev.md +20 -0
  5. package/src/shared/agents/default-pack/devops.md +14 -0
  6. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  7. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  8. package/src/shared/agents/default-pack/planner.md +17 -0
  9. package/src/shared/agents/default-pack/reviewer.md +14 -0
  10. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  11. package/src/shared/agents/den-claude.ts +173 -0
  12. package/src/shared/agents/generic.ts +52 -0
  13. package/src/shared/agents/personas.ts +218 -0
  14. package/src/shared/agents/records.ts +118 -0
  15. package/src/shared/agents/runtime.ts +192 -0
  16. package/src/shared/agents/runtimes.ts +47 -0
  17. package/src/shared/den-config.ts +116 -0
  18. package/src/shared/engine.ts +23 -0
  19. package/src/shared/environment-manager/workspaces.ts +19 -0
  20. package/src/shared/secret-env.ts +70 -0
  21. package/src/shared/sessions/client.ts +277 -0
  22. package/src/shared/sessions/den-pty.ts +97 -0
  23. package/src/shared/sessions/env.ts +79 -0
  24. package/src/shared/sessions/host.ts +667 -0
  25. package/src/shared/sessions/names.ts +85 -0
  26. package/src/shared/sessions/paths.ts +27 -0
  27. package/src/shared/sessions/protocol.ts +72 -0
  28. package/src/shared/sessions/pty.ts +141 -0
  29. package/src/shared/sessions/ring-buffer.ts +160 -0
  30. package/src/shared/sessions/types.ts +81 -0
  31. package/src/ui/cli/agents-command.ts +812 -0
  32. package/src/ui/cli/doctor-command.ts +138 -9
  33. package/src/ui/cli/env-probe.ts +266 -0
  34. package/src/ui/cli/flag-parser.ts +11 -3
  35. package/src/ui/cli/index.ts +34 -2
  36. package/src/ui/cli/sessions-command.ts +718 -0
  37. package/src/ui/mcp/scope.ts +123 -19
  38. package/src/ui/mcp/server.ts +27 -2
  39. package/src/ui/mcp/tools.ts +44 -5
  40. package/src/ui/server/audit.ts +35 -0
  41. package/src/ui/server/index.ts +7 -0
  42. package/src/ui/server/readonly-routes.ts +25 -2
  43. package/src/ui/server/workspace-routes.ts +3 -15
@@ -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,6 +11,7 @@ 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";
@@ -22,7 +23,12 @@ import { createDenApi, type DenApi } from "./api.js";
22
23
  import { ownServer } from "../cli/server-identity.js";
23
24
  import { callTool, toolsFor, type ToolContext } from "./tools.js";
24
25
  import { plural } from "../../shared/plural.js";
25
- import { parseScopeFlags, ScopeUsageError, type McpScope } from "./scope.js";
26
+ import {
27
+ checkGrants,
28
+ parseScopeFlags,
29
+ ScopeUsageError,
30
+ type McpScope,
31
+ } from "./scope.js";
26
32
 
27
33
  export interface DenMcpOptions {
28
34
  cfg: ResolvedMcpConfig;
@@ -146,7 +152,7 @@ export const MCP_USAGE = (cli: string): string =>
146
152
  `Usage: ${cli} mcp [--port <web port>]`,
147
153
  ` ${cli} mcp --list`,
148
154
  ` ${cli} mcp --call <tool> ['<json args>']`,
149
- ` ${cli} mcp --workspace <ws> --actor agent:<id> [...]`,
155
+ ` ${cli} mcp --workspace <ws> --actor agent:<id> [--grant <ws>…] [...]`,
150
156
  "",
151
157
  `An MCP server over stdio for agents, a client of the running web server`,
152
158
  `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
@@ -160,6 +166,10 @@ export const MCP_USAGE = (cli: string): string =>
160
166
  "is recorded as agent:<id>. It binds cooperating agents only: the web",
161
167
  "server's HTTP API does not check the scope yet.",
162
168
  "",
169
+ "--grant <ws> (repeatable) also serves another workspace, only when the org",
170
+ "config's agents.<id>.grants lists it; each such call is recorded with both",
171
+ "workspaces. `agents start` passes an agent's grants for you.",
172
+ "",
163
173
  `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
164
174
  ].join("\n");
165
175
 
@@ -269,6 +279,20 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
269
279
  process.exitCode = 2;
270
280
  return;
271
281
  }
282
+ if (scope?.grants?.length) {
283
+ // A grant is config: a flag alone never widens a scope.
284
+ let refused: string | null;
285
+ try {
286
+ refused = checkGrants(scope, readAgentsConfig());
287
+ } catch (e) {
288
+ refused = (e as Error).message;
289
+ }
290
+ if (refused) {
291
+ process.stderr.write(`${cli} mcp: ${refused}\n`);
292
+ process.exitCode = 2;
293
+ return;
294
+ }
295
+ }
272
296
  let port = parsePort(argv);
273
297
  if (port === undefined) {
274
298
  try {
@@ -315,6 +339,7 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
315
339
  `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
316
340
  `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
317
341
  (scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
342
+ (scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
318
343
  "\n",
319
344
  );
320
345
  }
@@ -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(
@@ -8,6 +8,7 @@
8
8
  import * as path from "path";
9
9
  import {
10
10
  actorLabel,
11
+ WORKSPACE_NAME_RE,
11
12
  type Actor,
12
13
  type AgentActor,
13
14
  } from "../../shared/agent-actor.js";
@@ -25,6 +26,11 @@ export interface AuditEntry {
25
26
  agent?: string;
26
27
  /** The workspace that scoped server is pinned to. */
27
28
  workspace?: string;
29
+ /**
30
+ * The other workspace a call reached under a grant (`agents.<id>.grants`,
31
+ * AG-2a); `workspace` is the agent's own.
32
+ */
33
+ targetWorkspace?: string;
28
34
  /** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
29
35
  verb: string;
30
36
  detail: string;
@@ -63,6 +69,7 @@ export function recordAction(
63
69
  outcome?: "refused";
64
70
  reason?: string;
65
71
  addresses?: string[];
72
+ targetWorkspace?: string;
66
73
  },
67
74
  ): AuditEntry {
68
75
  const entry: AuditEntry = {
@@ -78,6 +85,9 @@ export function recordAction(
78
85
  ...(actor.workspace ? { workspace: actor.workspace } : {}),
79
86
  }
80
87
  : {}),
88
+ ...(action.targetWorkspace
89
+ ? { targetWorkspace: action.targetWorkspace }
90
+ : {}),
81
91
  verb: action.verb,
82
92
  detail: action.detail,
83
93
  ...(action.outcome ? { outcome: action.outcome } : {}),
@@ -194,6 +204,31 @@ export function recordAgentRefusal(
194
204
  });
195
205
  }
196
206
 
207
+ /**
208
+ * A scoped agent's call into a workspace it holds a grant for (AG-2a, AG
209
+ * spec §5b), reported by its MCP server before the call runs. Recorded
210
+ * with both workspaces: the agent's own (from its headers) and the target.
211
+ * Null, and nothing recorded, unless the actor is a scoped agent and the
212
+ * target is a well-formed workspace other than its own. `GET /api/activity
213
+ * ?address=workspace:<ws>` lists it under either workspace.
214
+ */
215
+ export function recordCrossWorkspace(
216
+ actor: Actor,
217
+ report: { tool?: unknown; crossWorkspace?: unknown },
218
+ ): AuditEntry | null {
219
+ if (!actor?.agent || !actor.workspace) return null;
220
+ const target =
221
+ typeof report.crossWorkspace === "string" ? report.crossWorkspace : "";
222
+ if (!WORKSPACE_NAME_RE.test(target) || target === actor.workspace)
223
+ return null;
224
+ const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
225
+ return recordAction(actor, {
226
+ verb: tool,
227
+ detail: `in workspace ${target}, under a grant to ${actor.agent} of workspace ${actor.workspace}`,
228
+ targetWorkspace: target,
229
+ });
230
+ }
231
+
197
232
  /**
198
233
  * The catalog repo a refused call's `path` is inside (manifest_validate), so
199
234
  * the refusal shows in that repo's activity: the repo whose checkout is the
@@ -168,6 +168,7 @@ import {
168
168
  actorSuffix,
169
169
  recordAction,
170
170
  recordAgentRefusal,
171
+ recordCrossWorkspace,
171
172
  repoAtPath,
172
173
  setAuditSink,
173
174
  } from "./audit.js";
@@ -1088,6 +1089,12 @@ async function handleRequest(
1088
1089
  }
1089
1090
  if (repo) report = { ...report, repo };
1090
1091
  }
1092
+ // A call into a granted workspace (AG-2a): both workspaces recorded.
1093
+ if (report.crossWorkspace !== undefined) {
1094
+ const crossed = recordCrossWorkspace(actor, report);
1095
+ if (!crossed) return send400(res, "not a cross-workspace report");
1096
+ return sendJson(res, { recorded: true, ts: crossed.ts });
1097
+ }
1091
1098
  const entry = recordAgentRefusal(actor, report);
1092
1099
  return sendJson(res, { recorded: true, ts: entry.ts });
1093
1100
  }
@@ -11,7 +11,9 @@
11
11
  * target discovery and the owned-host check, nothing run
12
12
  * /api/activity the audit trail for one service (?address=repo/svc) or
13
13
  * repo (?repo=): execs and syncs, yours and agents',
14
- * newest first (the graph panel's Activity tab)
14
+ * newest first (the graph panel's Activity tab);
15
+ * ?address=workspace:<ws>, an agent's cross-workspace
16
+ * calls (a grant) into or out of workspace <ws>
15
17
  *
16
18
  * The MCP server's `stacks`, `edge_status` and `run_tests` (its security
17
19
  * pre-flight) read these. Collaborators are injectable for tests.
@@ -28,6 +30,7 @@ import {
28
30
  } from "../../shared/stacks/status.js";
29
31
  import { send400, sendJson } from "./http-helpers.js";
30
32
  import { recentActions, type AuditEntry } from "./audit.js";
33
+ import { WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
31
34
 
32
35
  export interface SecurityPlanArgs {
33
36
  env: string;
@@ -97,6 +100,17 @@ export function activityFor(
97
100
  entries: AuditEntry[],
98
101
  target: { address?: string; repo?: string },
99
102
  ): AuditEntry[] {
103
+ // workspace:<ws>: a cross-workspace call has no repo or service; it is
104
+ // listed under the workspace it reached and the agent's own.
105
+ const ws = workspaceAddress(target.address);
106
+ if (ws !== null)
107
+ return entries
108
+ .filter(
109
+ (e) =>
110
+ e.targetWorkspace !== undefined &&
111
+ (e.targetWorkspace === ws || e.workspace === ws),
112
+ )
113
+ .reverse();
100
114
  // A repo-level entry ("core down (local)") is an attempt on the repo
101
115
  // without naming a service, as agents usually do: it shows for the repo
102
116
  // and for each of its services.
@@ -127,6 +141,12 @@ export function activityFor(
127
141
  }
128
142
  const MODES = ["baseline", "api", "full", "all"];
129
143
 
144
+ /** The workspace a `workspace:<ws>` address names, else null. */
145
+ function workspaceAddress(address: string | undefined): string | null {
146
+ const m = /^workspace:(.*)$/.exec(address ?? "");
147
+ return m && WORKSPACE_NAME_RE.test(m[1]) ? m[1] : null;
148
+ }
149
+
130
150
  /** Handles the routes above; false when `pathname` is not one of them. */
131
151
  export async function handleReadonlyRoutes(
132
152
  req: http.IncomingMessage,
@@ -187,7 +207,10 @@ export async function handleReadonlyRoutes(
187
207
  const address = url.searchParams.get("address") ?? undefined;
188
208
  const repo = url.searchParams.get("repo") ?? undefined;
189
209
  const name = address ?? repo;
190
- if (!name || !SAFE_NAME.test(name)) {
210
+ if (
211
+ !name ||
212
+ (!SAFE_NAME.test(name) && workspaceAddress(address) === null)
213
+ ) {
191
214
  send400(res, "address or repo is required");
192
215
  return true;
193
216
  }
@@ -41,6 +41,7 @@ import {
41
41
  syncJetBrainsWorkspaceSymlinks,
42
42
  DEN_SERVICES_DIR,
43
43
  DEN_WORKSPACES_DIR,
44
+ resolveWorkspaceDir,
44
45
  } from "../../shared/environment-manager/workspaces.js";
45
46
  import {
46
47
  checkServiceHealth,
@@ -517,21 +518,8 @@ export async function handleWorkspaceRoutes(
517
518
  const wsFile = readWorkspacesFile();
518
519
  const reposRoot = getReposRoot();
519
520
  const resolvedDirs: Record<string, string> = {};
520
- for (const [name, ws] of Object.entries(wsFile.workspaces)) {
521
- if (ws.directory) {
522
- resolvedDirs[name] = path.resolve(ROOT, ws.directory);
523
- } else {
524
- const gwDir = path.join(DEN_WORKSPACES_DIR, name);
525
- const legacyDir = path.join(reposRoot, name);
526
- if (fs.existsSync(gwDir)) {
527
- resolvedDirs[name] = gwDir;
528
- } else if (fs.existsSync(legacyDir)) {
529
- resolvedDirs[name] = legacyDir;
530
- } else {
531
- resolvedDirs[name] = reposRoot;
532
- }
533
- }
534
- }
521
+ for (const [name, ws] of Object.entries(wsFile.workspaces))
522
+ resolvedDirs[name] = resolveWorkspaceDir(name, ws, reposRoot);
535
523
  const editor = readUserConfig().editor ?? "";
536
524
  const catalog = readCatalog();
537
525
  const codeWorkspacePaths: Record<string, string> = {};