@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.
- package/CHANGELOG.md +184 -0
- package/docs/distributions.md +229 -8
- package/package.json +4 -1
- 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 +173 -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 +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -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/ui/cli/agents-command.ts +812 -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 +11 -3
- package/src/ui/cli/index.ts +34 -2
- package/src/ui/cli/sessions-command.ts +718 -0
- package/src/ui/mcp/scope.ts +123 -19
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/audit.ts +35 -0
- package/src/ui/server/index.ts +7 -0
- package/src/ui/server/readonly-routes.ts +25 -2
- package/src/ui/server/workspace-routes.ts +3 -15
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,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 {
|
|
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
|
}
|
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(
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -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
|
package/src/ui/server/index.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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> = {};
|