@monster-orange/den 0.2.6 → 0.2.8
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 +225 -0
- package/bin/den +9 -0
- package/docs/distributions.md +205 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +84 -5
- package/src/shared/environment-manager/catalog.ts +61 -3
- package/src/shared/environment-manager/default-workspace.ts +53 -14
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +51 -8
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +69 -26
- package/src/shared/environment-manager/workspace-org.ts +279 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +19 -2
- package/src/ui/cli/clone-plan.ts +374 -0
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +346 -48
- package/src/ui/cli/org-command.ts +172 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +94 -9
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +96 -5
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +40 -5
- package/src/ui/server/workspace-routes.ts +13 -6
package/src/ui/mcp/server.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* stdout carries the protocol only: diagnostics go to stderr.
|
|
7
7
|
*/
|
|
8
|
+
import { z } from "zod";
|
|
8
9
|
import { randomUUID } from "crypto";
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -21,6 +22,7 @@ import { createDenApi, type DenApi } from "./api.js";
|
|
|
21
22
|
import { ownServer } from "../cli/server-identity.js";
|
|
22
23
|
import { callTool, toolsFor, type ToolContext } from "./tools.js";
|
|
23
24
|
import { plural } from "../../shared/plural.js";
|
|
25
|
+
import { parseScopeFlags, ScopeUsageError, type McpScope } from "./scope.js";
|
|
24
26
|
|
|
25
27
|
export interface DenMcpOptions {
|
|
26
28
|
cfg: ResolvedMcpConfig;
|
|
@@ -32,6 +34,8 @@ export interface DenMcpOptions {
|
|
|
32
34
|
/** For tests: an API in place of the HTTP client. */
|
|
33
35
|
api?: DenApi;
|
|
34
36
|
log?: (line: string) => void;
|
|
37
|
+
/** `--workspace` + `--actor`: one workspace, one agent (AG-2a-core). */
|
|
38
|
+
scope?: McpScope;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
@@ -47,7 +51,11 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
47
51
|
`through the running ${p.title} web server (\`${opts.cli} web up\`). ` +
|
|
48
52
|
(opts.cfg.actions.length
|
|
49
53
|
? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
|
|
50
|
-
: "Read-only: the org has enabled no actions (mcp.actions).")
|
|
54
|
+
: "Read-only: the org has enabled no actions (mcp.actions).") +
|
|
55
|
+
(opts.scope
|
|
56
|
+
? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
|
|
57
|
+
"a call naming another workspace is refused, and lists show this one only."
|
|
58
|
+
: ""),
|
|
51
59
|
},
|
|
52
60
|
);
|
|
53
61
|
|
|
@@ -66,9 +74,15 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
66
74
|
clientVersion: client()?.version ?? "unknown",
|
|
67
75
|
mcpPid: opts.pid ?? process.pid,
|
|
68
76
|
session,
|
|
77
|
+
...scopeInfo(opts.scope),
|
|
69
78
|
}),
|
|
70
79
|
});
|
|
71
|
-
const ctx: ToolContext = {
|
|
80
|
+
const ctx: ToolContext = {
|
|
81
|
+
api,
|
|
82
|
+
cfg: opts.cfg,
|
|
83
|
+
cli: opts.cli,
|
|
84
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
85
|
+
};
|
|
72
86
|
|
|
73
87
|
for (const t of tools) {
|
|
74
88
|
server.registerTool(
|
|
@@ -89,6 +103,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
89
103
|
const who = client();
|
|
90
104
|
log(
|
|
91
105
|
`[mcp] ${t.name} ${r.isError ? "refused/failed" : "ok"} · ` +
|
|
106
|
+
(opts.scope
|
|
107
|
+
? `${opts.scope.actor} in ${opts.scope.workspace} · `
|
|
108
|
+
: "") +
|
|
92
109
|
`${who?.name ?? "unknown"} ${who?.version ?? ""} · session ${session}` +
|
|
93
110
|
(r.isError ? ` · ${r.text.split("\n")[0]}` : ""),
|
|
94
111
|
);
|
|
@@ -103,6 +120,14 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
|
|
|
103
120
|
return server;
|
|
104
121
|
}
|
|
105
122
|
|
|
123
|
+
/** The audit fields a scope adds to every call's headers (none unscoped). */
|
|
124
|
+
function scopeInfo(scope: McpScope | undefined): {
|
|
125
|
+
agent?: string;
|
|
126
|
+
workspace?: string;
|
|
127
|
+
} {
|
|
128
|
+
return scope ? { agent: scope.actor, workspace: scope.workspace } : {};
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
function parsePort(argv: string[]): number | undefined {
|
|
107
132
|
const i = argv.indexOf("--port");
|
|
108
133
|
const raw =
|
|
@@ -121,6 +146,7 @@ export const MCP_USAGE = (cli: string): string =>
|
|
|
121
146
|
`Usage: ${cli} mcp [--port <web port>]`,
|
|
122
147
|
` ${cli} mcp --list`,
|
|
123
148
|
` ${cli} mcp --call <tool> ['<json args>']`,
|
|
149
|
+
` ${cli} mcp --workspace <ws> --actor agent:<id> [...]`,
|
|
124
150
|
"",
|
|
125
151
|
`An MCP server over stdio for agents, a client of the running web server`,
|
|
126
152
|
`(\`${cli} web up\`; default port: config web.port). Read tools always;`,
|
|
@@ -129,6 +155,11 @@ export const MCP_USAGE = (cli: string): string =>
|
|
|
129
155
|
"--list prints the tool names; --call runs one tool through the same guards,",
|
|
130
156
|
"prints its text result and exits 1 if the tool reports an error.",
|
|
131
157
|
"",
|
|
158
|
+
"--workspace + --actor (both, or neither) scope the server to one workspace:",
|
|
159
|
+
"a call naming another is refused, lists show that one only, and every call",
|
|
160
|
+
"is recorded as agent:<id>. It binds cooperating agents only: the web",
|
|
161
|
+
"server's HTTP API does not check the scope yet.",
|
|
162
|
+
"",
|
|
132
163
|
`Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
|
|
133
164
|
].join("\n");
|
|
134
165
|
|
|
@@ -166,6 +197,7 @@ export interface OneShotOptions {
|
|
|
166
197
|
cfg: ResolvedMcpConfig;
|
|
167
198
|
cli: string;
|
|
168
199
|
api: DenApi;
|
|
200
|
+
scope?: McpScope;
|
|
169
201
|
out?: (text: string) => void;
|
|
170
202
|
err?: (text: string) => void;
|
|
171
203
|
}
|
|
@@ -184,19 +216,59 @@ export async function runOneShot(
|
|
|
184
216
|
out(names.length ? `${names.join("\n")}\n` : "");
|
|
185
217
|
return 0;
|
|
186
218
|
}
|
|
187
|
-
const ctx: ToolContext = {
|
|
188
|
-
|
|
219
|
+
const ctx: ToolContext = {
|
|
220
|
+
api: opts.api,
|
|
221
|
+
cfg: opts.cfg,
|
|
222
|
+
cli: opts.cli,
|
|
223
|
+
...(opts.scope ? { scope: opts.scope } : {}),
|
|
224
|
+
};
|
|
225
|
+
// The stdio server validates arguments against each tool's schema (the
|
|
226
|
+
// SDK does it); a one-shot must too, and strictly: it is typed by hand, so
|
|
227
|
+
// an unknown or misspelt key is an error, not silently dropped.
|
|
228
|
+
let args = shot.args;
|
|
229
|
+
const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
|
|
230
|
+
if (tool) {
|
|
231
|
+
const parsed = z.object(tool.shape).strict().safeParse(args);
|
|
232
|
+
if (!parsed.success) {
|
|
233
|
+
const err = opts.err ?? ((t: string) => process.stderr.write(t));
|
|
234
|
+
const lines = parsed.error.issues.map((i) => {
|
|
235
|
+
const at = i.path.length ? i.path.join(".") : "(arguments)";
|
|
236
|
+
return ` ${at}: ${i.message}`;
|
|
237
|
+
});
|
|
238
|
+
const keys = Object.keys(tool.shape);
|
|
239
|
+
err(
|
|
240
|
+
`--call ${shot.tool}: invalid arguments\n${lines.join("\n")}\n` +
|
|
241
|
+
` takes: ${keys.length ? keys.join(", ") : "no arguments"}\n`,
|
|
242
|
+
);
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
args = parsed.data as Record<string, unknown>;
|
|
246
|
+
}
|
|
247
|
+
const r = await callTool(shot.tool, args, ctx);
|
|
189
248
|
out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
|
|
190
249
|
return r.isError ? 1 : 0;
|
|
191
250
|
}
|
|
192
251
|
|
|
193
252
|
/** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
|
|
194
|
-
export async function runMcp(
|
|
253
|
+
export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
195
254
|
const cli = product().cli;
|
|
196
|
-
if (
|
|
255
|
+
if (rawArgv.includes("-h") || rawArgv.includes("--help")) {
|
|
197
256
|
process.stderr.write(`${MCP_USAGE(cli)}\n`);
|
|
198
257
|
return;
|
|
199
258
|
}
|
|
259
|
+
let scope: McpScope | null;
|
|
260
|
+
let argv: string[];
|
|
261
|
+
try {
|
|
262
|
+
({ scope, rest: argv } = parseScopeFlags(rawArgv));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (!(e instanceof ScopeUsageError)) throw e;
|
|
265
|
+
process.stderr.write(
|
|
266
|
+
`${cli} mcp: ${e.message}\n` +
|
|
267
|
+
`Usage: ${cli} mcp --workspace <ws> --actor agent:<id> [--port <n>] [--list | --call <tool> ['<json>']]\n`,
|
|
268
|
+
);
|
|
269
|
+
process.exitCode = 2;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
200
272
|
let port = parsePort(argv);
|
|
201
273
|
if (port === undefined) {
|
|
202
274
|
try {
|
|
@@ -221,15 +293,28 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
221
293
|
clientVersion: engineVersion() ?? "unknown",
|
|
222
294
|
mcpPid: process.pid,
|
|
223
295
|
session,
|
|
296
|
+
...scopeInfo(scope ?? undefined),
|
|
224
297
|
}),
|
|
225
298
|
});
|
|
226
|
-
process.exitCode = await runOneShot(shot, {
|
|
299
|
+
process.exitCode = await runOneShot(shot, {
|
|
300
|
+
cfg,
|
|
301
|
+
cli,
|
|
302
|
+
api,
|
|
303
|
+
...(scope ? { scope } : {}),
|
|
304
|
+
});
|
|
227
305
|
return;
|
|
228
306
|
}
|
|
229
|
-
const server = createDenMcpServer({
|
|
307
|
+
const server = createDenMcpServer({
|
|
308
|
+
cfg,
|
|
309
|
+
baseUrl,
|
|
310
|
+
cli,
|
|
311
|
+
...(scope ? { scope } : {}),
|
|
312
|
+
});
|
|
230
313
|
await server.connect(new StdioServerTransport());
|
|
231
314
|
process.stderr.write(
|
|
232
315
|
`${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
|
|
233
|
-
`(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}
|
|
316
|
+
`(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
|
|
317
|
+
(scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
|
|
318
|
+
"\n",
|
|
234
319
|
);
|
|
235
320
|
}
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -33,12 +33,27 @@ import {
|
|
|
33
33
|
type ServiceEntryLike,
|
|
34
34
|
type SecurityPlanLike,
|
|
35
35
|
} from "./guards.js";
|
|
36
|
+
import {
|
|
37
|
+
ScopeRefusal,
|
|
38
|
+
assertPathInScope,
|
|
39
|
+
assertScopeExists,
|
|
40
|
+
assertScopeFocused,
|
|
41
|
+
assertServicesInScope,
|
|
42
|
+
filterWorkspacesBody,
|
|
43
|
+
pinWorkspace,
|
|
44
|
+
type McpScope,
|
|
45
|
+
} from "./scope.js";
|
|
36
46
|
|
|
37
47
|
export interface ToolContext {
|
|
38
48
|
api: DenApi;
|
|
39
49
|
cfg: ResolvedMcpConfig;
|
|
40
50
|
/** The CLI name, for messages (`<cli> web up`). */
|
|
41
51
|
cli: string;
|
|
52
|
+
/**
|
|
53
|
+
* A scoped server's workspace and actor (AG-2a-core); absent, every tool
|
|
54
|
+
* behaves as it always has.
|
|
55
|
+
*/
|
|
56
|
+
scope?: McpScope;
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
type Args = Record<string, unknown>;
|
|
@@ -193,7 +208,10 @@ const READ_TOOLS: DenTool[] = [
|
|
|
193
208
|
"Workspaces",
|
|
194
209
|
"Workspaces, which are active and which is focused.",
|
|
195
210
|
{},
|
|
196
|
-
(_a, { api }) =>
|
|
211
|
+
async (_a, { api, scope }) => {
|
|
212
|
+
const body = await api.get("/api/workspaces");
|
|
213
|
+
return scope ? filterWorkspacesBody(body, scope) : body;
|
|
214
|
+
},
|
|
197
215
|
),
|
|
198
216
|
read(
|
|
199
217
|
"environments",
|
|
@@ -215,9 +233,19 @@ const READ_TOOLS: DenTool[] = [
|
|
|
215
233
|
.describe("repo/service names"),
|
|
216
234
|
env: envArg,
|
|
217
235
|
},
|
|
218
|
-
async (a, { api, cfg }) => {
|
|
236
|
+
async (a, { api, cfg, scope }) => {
|
|
219
237
|
const env = envOf(a);
|
|
220
238
|
assertLogsEnv(env, cfg);
|
|
239
|
+
if (scope)
|
|
240
|
+
await assertServicesInScope(
|
|
241
|
+
"logs_search",
|
|
242
|
+
api,
|
|
243
|
+
scope,
|
|
244
|
+
(a.targets as string[]).map((t) => {
|
|
245
|
+
const slash = t.lastIndexOf("/");
|
|
246
|
+
return { repo: t.slice(0, slash), service: t.slice(slash + 1) };
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
221
249
|
return api.get("/api/logs/search", {
|
|
222
250
|
q: String(a.q),
|
|
223
251
|
env,
|
|
@@ -241,9 +269,13 @@ const READ_TOOLS: DenTool[] = [
|
|
|
241
269
|
.describe("Lines to return (default 200)"),
|
|
242
270
|
env: envArg,
|
|
243
271
|
},
|
|
244
|
-
async (a, { api, cfg }) => {
|
|
272
|
+
async (a, { api, cfg, scope }) => {
|
|
245
273
|
const env = envOf(a);
|
|
246
274
|
assertLogsEnv(env, cfg);
|
|
275
|
+
if (scope)
|
|
276
|
+
await assertServicesInScope("logs_tail", api, scope, [
|
|
277
|
+
{ repo: String(a.repo), service: String(a.service) },
|
|
278
|
+
]);
|
|
247
279
|
const n = (a.lines as number | undefined) ?? 200;
|
|
248
280
|
const query: Query = { repo: String(a.repo), service: String(a.service) };
|
|
249
281
|
const r =
|
|
@@ -270,11 +302,17 @@ const READ_TOOLS: DenTool[] = [
|
|
|
270
302
|
"Available tests",
|
|
271
303
|
"The test modules of a pillar, and which repos (branch + SHA) they come from.",
|
|
272
304
|
{ pillar: z.enum(PILLARS) },
|
|
273
|
-
async (a, { api }) =>
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
async (a, { api, scope }) => {
|
|
306
|
+
if (scope) await assertScopeFocused("tests_available", api, scope);
|
|
307
|
+
return {
|
|
308
|
+
modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
|
|
309
|
+
sources: await api.get("/api/test-sources", {
|
|
310
|
+
pillar: String(a.pillar),
|
|
311
|
+
}),
|
|
312
|
+
};
|
|
313
|
+
},
|
|
277
314
|
),
|
|
315
|
+
|
|
278
316
|
read(
|
|
279
317
|
"security_latest",
|
|
280
318
|
"Latest security scans",
|
|
@@ -307,13 +345,20 @@ const READ_TOOLS: DenTool[] = [
|
|
|
307
345
|
path: z.string().optional().describe("Repo directory or manifest file"),
|
|
308
346
|
content: z.string().optional().describe("Manifest YAML to validate"),
|
|
309
347
|
},
|
|
310
|
-
async (a, { api }) => {
|
|
348
|
+
async (a, { api, scope }) => {
|
|
311
349
|
if (str(a.content) !== undefined)
|
|
312
350
|
return api.get("/api/manifest/validate", {}, "POST", {
|
|
313
351
|
content: a.content,
|
|
314
352
|
});
|
|
315
353
|
if (str(a.path) === undefined)
|
|
316
354
|
throw new Refusal("give a path or the manifest content.");
|
|
355
|
+
if (scope)
|
|
356
|
+
await assertPathInScope(
|
|
357
|
+
"manifest_validate",
|
|
358
|
+
api,
|
|
359
|
+
scope,
|
|
360
|
+
String(a.path),
|
|
361
|
+
);
|
|
317
362
|
return api.get("/api/manifest/validate", { path: str(a.path) });
|
|
318
363
|
},
|
|
319
364
|
),
|
|
@@ -447,19 +492,27 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
447
492
|
shape: {
|
|
448
493
|
repo: z.string().optional().describe("One managed repo (default: all)"),
|
|
449
494
|
},
|
|
450
|
-
async run(a, { api }) {
|
|
495
|
+
async run(a, { api, scope }) {
|
|
451
496
|
const repo = str(a.repo);
|
|
497
|
+
// Scoped: the sync names the scoped workspace (the web server's own
|
|
498
|
+
// default is the focused one).
|
|
499
|
+
const ws = scope ? { workspace: scope.workspace } : {};
|
|
452
500
|
if (repo) {
|
|
453
501
|
const services = (await api.get("/api/services", {
|
|
454
502
|
env: "local",
|
|
503
|
+
...ws,
|
|
455
504
|
})) as ServiceEntryLike[];
|
|
456
505
|
assertSyncableRepo(repo, services);
|
|
457
506
|
}
|
|
458
|
-
const r = await api.stream(
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
507
|
+
const r = await api.stream(
|
|
508
|
+
"/api/workspace/sync",
|
|
509
|
+
repo ? { repo, ...ws } : { ...ws },
|
|
510
|
+
{
|
|
511
|
+
method: "POST",
|
|
512
|
+
maxLines: 5000,
|
|
513
|
+
timeoutMs: 15 * 60_000,
|
|
514
|
+
},
|
|
515
|
+
);
|
|
463
516
|
return actionResult(r, 300);
|
|
464
517
|
},
|
|
465
518
|
},
|
|
@@ -478,13 +531,14 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
478
531
|
.optional()
|
|
479
532
|
.describe("Security scan mode (default baseline)"),
|
|
480
533
|
},
|
|
481
|
-
async run(a, { api, cfg }) {
|
|
534
|
+
async run(a, { api, cfg, scope }) {
|
|
482
535
|
const pillar = String(a.pillar);
|
|
483
536
|
const env = envOf(a);
|
|
484
537
|
const modules = (a.modules as string[] | undefined) ?? [];
|
|
485
538
|
const mode =
|
|
486
539
|
pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
|
|
487
540
|
assertRunTestsAllowed({ pillar, env, mode }, cfg);
|
|
541
|
+
if (scope) await assertScopeFocused("run_tests", api, scope);
|
|
488
542
|
if (pillar === "security") {
|
|
489
543
|
for (const service of modules.length ? modules : [undefined]) {
|
|
490
544
|
const plan = (await api.get("/api/security/plan", {
|
|
@@ -599,6 +653,10 @@ async function reportRefusal(
|
|
|
599
653
|
tool,
|
|
600
654
|
...(typeof args.repo === "string" ? { repo: args.repo } : {}),
|
|
601
655
|
...(typeof args.service === "string" ? { service: args.service } : {}),
|
|
656
|
+
...(Array.isArray(args.targets) ? { targets: args.targets } : {}),
|
|
657
|
+
// A path (manifest_validate): the server records it under the catalog
|
|
658
|
+
// repo it is inside, never as the path itself.
|
|
659
|
+
...(typeof args.path === "string" ? { path: args.path } : {}),
|
|
602
660
|
reason,
|
|
603
661
|
});
|
|
604
662
|
} catch {
|
|
@@ -606,6 +664,27 @@ async function reportRefusal(
|
|
|
606
664
|
}
|
|
607
665
|
}
|
|
608
666
|
|
|
667
|
+
/**
|
|
668
|
+
* A scoped server's arguments: the workspace exists (checked once per
|
|
669
|
+
* server), and a tool that takes a workspace gets the scoped one; naming
|
|
670
|
+
* another is refused.
|
|
671
|
+
*/
|
|
672
|
+
const verifiedScopes = new WeakSet<McpScope>();
|
|
673
|
+
async function scopeArgs(
|
|
674
|
+
tool: DenTool,
|
|
675
|
+
args: Args,
|
|
676
|
+
ctx: ToolContext,
|
|
677
|
+
scope: McpScope,
|
|
678
|
+
): Promise<Args> {
|
|
679
|
+
if (!verifiedScopes.has(scope)) {
|
|
680
|
+
await assertScopeExists(ctx.api, scope);
|
|
681
|
+
verifiedScopes.add(scope);
|
|
682
|
+
}
|
|
683
|
+
return "workspace" in tool.shape
|
|
684
|
+
? pinWorkspace(tool.name, args, scope)
|
|
685
|
+
: args;
|
|
686
|
+
}
|
|
687
|
+
|
|
609
688
|
export async function callTool(
|
|
610
689
|
name: string,
|
|
611
690
|
args: Args,
|
|
@@ -618,7 +697,10 @@ export async function callTool(
|
|
|
618
697
|
isError: true,
|
|
619
698
|
};
|
|
620
699
|
try {
|
|
621
|
-
const out = await tool.run(
|
|
700
|
+
const out = await tool.run(
|
|
701
|
+
ctx.scope ? await scopeArgs(tool, args, ctx, ctx.scope) : args,
|
|
702
|
+
ctx,
|
|
703
|
+
);
|
|
622
704
|
const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
|
|
623
705
|
return { text: redactSecrets(text), isError: false };
|
|
624
706
|
} catch (e) {
|
|
@@ -633,7 +715,9 @@ export async function callTool(
|
|
|
633
715
|
);
|
|
634
716
|
return { text: redactSecrets(text), isError: true };
|
|
635
717
|
}
|
|
636
|
-
|
|
718
|
+
// A call outside a scoped server's workspace is recorded whatever the
|
|
719
|
+
// tool: it is an attempt to cross the boundary (AG-2a-core).
|
|
720
|
+
if (e instanceof Refusal && (tool.action || e instanceof ScopeRefusal))
|
|
637
721
|
await reportRefusal(ctx, name, args, e.message);
|
|
638
722
|
const msg =
|
|
639
723
|
e instanceof Refusal
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* as the web-server log, so agent actions sit beside the UI's own; the
|
|
6
6
|
* structured entries are kept in a small in-memory ring.
|
|
7
7
|
*/
|
|
8
|
+
import * as path from "path";
|
|
8
9
|
import {
|
|
9
10
|
actorLabel,
|
|
10
11
|
type Actor,
|
|
@@ -20,6 +21,10 @@ export interface AuditEntry {
|
|
|
20
21
|
clientVersion?: string;
|
|
21
22
|
mcpPid?: number | null;
|
|
22
23
|
session?: string;
|
|
24
|
+
/** A scoped MCP server's actor, `agent:<id>` (AG-2a-core). */
|
|
25
|
+
agent?: string;
|
|
26
|
+
/** The workspace that scoped server is pinned to. */
|
|
27
|
+
workspace?: string;
|
|
23
28
|
/** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
|
|
24
29
|
verb: string;
|
|
25
30
|
detail: string;
|
|
@@ -27,6 +32,11 @@ export interface AuditEntry {
|
|
|
27
32
|
outcome?: "refused";
|
|
28
33
|
/** Why it was refused (first line). */
|
|
29
34
|
reason?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The services (`repo/service`) or repos a refused non-lifecycle call
|
|
37
|
+
* named, so each one's activity list shows it (`GET /api/activity`).
|
|
38
|
+
*/
|
|
39
|
+
addresses?: string[];
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
type Sink = (line: string, actor: AgentActor | null) => void;
|
|
@@ -52,6 +62,7 @@ export function recordAction(
|
|
|
52
62
|
detail: string;
|
|
53
63
|
outcome?: "refused";
|
|
54
64
|
reason?: string;
|
|
65
|
+
addresses?: string[];
|
|
55
66
|
},
|
|
56
67
|
): AuditEntry {
|
|
57
68
|
const entry: AuditEntry = {
|
|
@@ -63,19 +74,28 @@ export function recordAction(
|
|
|
63
74
|
clientVersion: actor.clientVersion,
|
|
64
75
|
mcpPid: actor.mcpPid,
|
|
65
76
|
session: actor.session,
|
|
77
|
+
...(actor.agent ? { agent: actor.agent } : {}),
|
|
78
|
+
...(actor.workspace ? { workspace: actor.workspace } : {}),
|
|
66
79
|
}
|
|
67
80
|
: {}),
|
|
68
81
|
verb: action.verb,
|
|
69
82
|
detail: action.detail,
|
|
70
83
|
...(action.outcome ? { outcome: action.outcome } : {}),
|
|
71
84
|
...(action.reason ? { reason: clean(action.reason, 300) } : {}),
|
|
85
|
+
...(action.addresses?.length ? { addresses: action.addresses } : {}),
|
|
72
86
|
};
|
|
73
87
|
ring.push(entry);
|
|
74
88
|
if (ring.length > RING_MAX) ring.shift();
|
|
75
89
|
const mark = entry.outcome === "refused" ? "✗" : "▶";
|
|
76
90
|
const why = entry.reason ? ` — refused: ${entry.reason}` : "";
|
|
91
|
+
// A refusal whose detail is its reason names what it touched instead.
|
|
92
|
+
const what = entry.addresses
|
|
93
|
+
? entry.addresses.join(", ")
|
|
94
|
+
: entry.reason && action.detail === entry.reason
|
|
95
|
+
? ""
|
|
96
|
+
: action.detail;
|
|
77
97
|
sink?.(
|
|
78
|
-
`${mark} ${action.verb} ${
|
|
98
|
+
`${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}`,
|
|
79
99
|
actor,
|
|
80
100
|
);
|
|
81
101
|
return entry;
|
|
@@ -92,12 +112,51 @@ function clean(v: unknown, max: number): string {
|
|
|
92
112
|
|
|
93
113
|
const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
|
|
94
114
|
const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
|
|
115
|
+
/** At most this many targets of one refused call are recorded. */
|
|
116
|
+
const MAX_ADDRESSES = 40;
|
|
117
|
+
|
|
118
|
+
/** `repo/service` (or `repo`) when every part is a safe name, else "". */
|
|
119
|
+
function safeAddress(repo: string, service = ""): string {
|
|
120
|
+
if (!SAFE_PART.test(repo)) return "";
|
|
121
|
+
return service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a refused call named: its `repo` (with `service`), else each of its
|
|
126
|
+
* `targets` (`repo/service`, as logs_search takes them), deduplicated.
|
|
127
|
+
*/
|
|
128
|
+
function refusedAddresses(report: {
|
|
129
|
+
repo?: unknown;
|
|
130
|
+
service?: unknown;
|
|
131
|
+
targets?: unknown;
|
|
132
|
+
}): string[] {
|
|
133
|
+
const out: string[] = [];
|
|
134
|
+
if (typeof report.repo === "string") {
|
|
135
|
+
const a = safeAddress(
|
|
136
|
+
report.repo,
|
|
137
|
+
typeof report.service === "string" ? report.service : "",
|
|
138
|
+
);
|
|
139
|
+
if (a) out.push(a);
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(report.targets))
|
|
142
|
+
for (const t of report.targets.slice(0, MAX_ADDRESSES)) {
|
|
143
|
+
if (typeof t !== "string") continue;
|
|
144
|
+
const slash = t.lastIndexOf("/");
|
|
145
|
+
const a =
|
|
146
|
+
slash > 0
|
|
147
|
+
? safeAddress(t.slice(0, slash), t.slice(slash + 1))
|
|
148
|
+
: safeAddress(t);
|
|
149
|
+
if (a && !out.includes(a)) out.push(a);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
95
153
|
|
|
96
154
|
/**
|
|
97
155
|
* An MCP guard refused an agent's call before it reached an action route.
|
|
98
156
|
* The agent's own report, so every field is sanitised: a lifecycle tool with
|
|
99
157
|
* a valid repo becomes an "exec" entry for that service (what the panel's
|
|
100
|
-
* activity list shows); anything else is recorded under the tool's name
|
|
158
|
+
* activity list shows); anything else is recorded under the tool's name,
|
|
159
|
+
* with the refusal as its detail and the services it named as `addresses`.
|
|
101
160
|
*/
|
|
102
161
|
export function recordAgentRefusal(
|
|
103
162
|
actor: Actor,
|
|
@@ -105,6 +164,7 @@ export function recordAgentRefusal(
|
|
|
105
164
|
tool?: unknown;
|
|
106
165
|
repo?: unknown;
|
|
107
166
|
service?: unknown;
|
|
167
|
+
targets?: unknown;
|
|
108
168
|
reason?: unknown;
|
|
109
169
|
},
|
|
110
170
|
): AuditEntry {
|
|
@@ -123,14 +183,41 @@ export function recordAgentRefusal(
|
|
|
123
183
|
reason,
|
|
124
184
|
});
|
|
125
185
|
}
|
|
186
|
+
// Any other tool: the refusal is the detail, under every service or repo
|
|
187
|
+
// the call named (all of a logs_search's targets, in one entry).
|
|
126
188
|
return recordAction(actor, {
|
|
127
189
|
verb: tool,
|
|
128
|
-
detail:
|
|
190
|
+
detail: reason,
|
|
129
191
|
outcome: "refused",
|
|
130
192
|
reason,
|
|
193
|
+
addresses: refusedAddresses(report),
|
|
131
194
|
});
|
|
132
195
|
}
|
|
133
196
|
|
|
197
|
+
/**
|
|
198
|
+
* The catalog repo a refused call's `path` is inside (manifest_validate), so
|
|
199
|
+
* the refusal shows in that repo's activity: the repo whose checkout is the
|
|
200
|
+
* path or holds it, the deepest when checkouts nest. Null for a relative
|
|
201
|
+
* path or one inside no repo. The path itself is never recorded.
|
|
202
|
+
*/
|
|
203
|
+
export function repoAtPath(
|
|
204
|
+
p: unknown,
|
|
205
|
+
repos: readonly { repoName: string; repoPath: string }[],
|
|
206
|
+
): string | null {
|
|
207
|
+
if (typeof p !== "string" || p.length > 4096 || !path.isAbsolute(p))
|
|
208
|
+
return null;
|
|
209
|
+
const target = path.resolve(p);
|
|
210
|
+
let best: { repoName: string; depth: number } | null = null;
|
|
211
|
+
for (const r of repos) {
|
|
212
|
+
if (!r.repoPath || !path.isAbsolute(r.repoPath)) continue;
|
|
213
|
+
const root = path.resolve(r.repoPath);
|
|
214
|
+
if (target !== root && !target.startsWith(root + path.sep)) continue;
|
|
215
|
+
if (!best || root.length > best.depth)
|
|
216
|
+
best = { repoName: r.repoName, depth: root.length };
|
|
217
|
+
}
|
|
218
|
+
return best && SAFE_PART.test(best.repoName) ? best.repoName : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
134
221
|
/** The most recent actions, oldest first. */
|
|
135
222
|
export function recentActions(): AuditEntry[] {
|
|
136
223
|
return ring.slice();
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* so an "error" health result is only acted on after a SECOND
|
|
12
12
|
* consecutive "error" check separated by a retry delay. "unknown"
|
|
13
13
|
* results and thrown checks always keep the entry (err on the side of
|
|
14
|
-
* keeping). A daemon whose address no longer resolves
|
|
15
|
-
* workspace/catalog service is process-death-equivalent evidence and
|
|
16
|
-
* removed directly.
|
|
14
|
+
* keeping). A daemon this root registered whose address no longer resolves
|
|
15
|
+
* to any workspace/catalog service is process-death-equivalent evidence and
|
|
16
|
+
* is removed directly. Records another root registered (all roots of a
|
|
17
|
+
* product share the file) are never touched, and an unstamped legacy record
|
|
18
|
+
* is not removed for being unresolvable.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
@@ -41,6 +43,12 @@ export interface DaemonReaperDeps {
|
|
|
41
43
|
delay: (ms: number) => Promise<void>;
|
|
42
44
|
/** Gap between the first "error" result and the confirming re-check. */
|
|
43
45
|
retryDelayMs: number;
|
|
46
|
+
/**
|
|
47
|
+
* Which root wrote the record (`daemonRootOf`). Every root of a product
|
|
48
|
+
* shares one daemons file: another root's records are never swept here,
|
|
49
|
+
* and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
|
|
50
|
+
*/
|
|
51
|
+
rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface DaemonReapResult {
|
|
@@ -92,9 +100,19 @@ export async function reapDeadDaemons(
|
|
|
92
100
|
await Promise.allSettled(
|
|
93
101
|
deps.listDaemons().map(async (daemon) => {
|
|
94
102
|
try {
|
|
103
|
+
const root = deps.rootOf(daemon);
|
|
104
|
+
if (root === "other") {
|
|
105
|
+
kept.push(daemon.address);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
95
108
|
const service = resolveForDaemon(deps, daemon);
|
|
96
109
|
if (!service) {
|
|
97
|
-
// No longer in any owning workspace/catalog — nothing could restart
|
|
110
|
+
// No longer in any owning workspace/catalog — nothing could restart
|
|
111
|
+
// it. An unstamped record may be another root's, so it stays.
|
|
112
|
+
if (root === "unstamped") {
|
|
113
|
+
kept.push(daemon.address);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
98
116
|
deps.unregisterDaemon(daemon.address);
|
|
99
117
|
removed.push(daemon.address);
|
|
100
118
|
return;
|
|
@@ -9,6 +9,7 @@ import { execFileSync, execSync } from "child_process";
|
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import { readDenConfig } from "../../shared/den-config.js";
|
|
11
11
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
12
|
+
import { githubOwnersOf } from "../../shared/org-sources.js";
|
|
12
13
|
import {
|
|
13
14
|
GH_API_TIMEOUT_MS,
|
|
14
15
|
GIT_REMOTE_TIMEOUT_MS,
|
|
@@ -183,7 +184,11 @@ export class GitHubService {
|
|
|
183
184
|
const config = readDenConfig();
|
|
184
185
|
const orgs = new Set<string>();
|
|
185
186
|
if (config.github_org) orgs.add(config.github_org);
|
|
186
|
-
|
|
187
|
+
// A legacy `orgs:` key is a GitHub owner; an ORG-1d org lists its GitHub
|
|
188
|
+
// sources' owners, and one with no GitHub source adds none (discovery is
|
|
189
|
+
// hidden for it).
|
|
190
|
+
for (const id of Object.keys(config.orgs ?? {}))
|
|
191
|
+
for (const owner of githubOwnersOf(id, config.orgs)) orgs.add(owner);
|
|
187
192
|
for (const entry of Object.values(readCatalog() ?? {})) {
|
|
188
193
|
if (entry?.gh && entry.gh.includes("/")) {
|
|
189
194
|
orgs.add(entry.gh.slice(0, entry.gh.indexOf("/")));
|