@monster-orange/den 0.2.0 → 0.2.2

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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * `<cli> mcp connect`: register this distribution's MCP server with the
3
+ * agents a developer uses, so nobody hand-edits an MCP config.
4
+ *
5
+ * Each client is a strategy: where its config lives (or which of its own
6
+ * CLIs registers a server) and in which shape. Planning is pure (tests drive
7
+ * it with a fake home and PATH); applying it writes one JSON key or runs the
8
+ * client's own `mcp add`. Nothing else in a client's config is touched.
9
+ *
10
+ * The registered command is the distribution's bin shim by absolute path,
11
+ * with PATH pinned so that GUI clients (which start without the login shell's
12
+ * PATH, so without nvm's node) can launch it.
13
+ */
14
+ import * as path from "path";
15
+
16
+ export type Scope = "user" | "project";
17
+
18
+ /** What gets registered: one stdio server. */
19
+ export interface ServerSpec {
20
+ name: string;
21
+ command: string;
22
+ args: string[];
23
+ env: Record<string, string>;
24
+ }
25
+
26
+ export interface ConnectEnv {
27
+ home: string;
28
+ platform: NodeJS.Platform;
29
+ /** %APPDATA% on Windows. */
30
+ appData?: string;
31
+ /** The project directory for project scope (where the agent runs). */
32
+ projectDir: string;
33
+ /** Whether an executable is on PATH. */
34
+ which: (bin: string) => boolean;
35
+ /** Whether a file or directory exists. */
36
+ exists: (p: string) => boolean;
37
+ }
38
+
39
+ /** One change to make. */
40
+ export type ConnectAction =
41
+ | {
42
+ kind: "json";
43
+ file: string;
44
+ /** Top-level key holding the servers: mcpServers, or servers (VS Code). */
45
+ key: "mcpServers" | "servers";
46
+ name: string;
47
+ entry: Record<string, unknown>;
48
+ }
49
+ | {
50
+ kind: "exec";
51
+ /** Run first, failure ignored: makes the add idempotent. */
52
+ remove?: string[];
53
+ add: string[];
54
+ /** Exit 0 when the server is registered (for status). */
55
+ check?: string[];
56
+ };
57
+
58
+ export interface McpClient {
59
+ id: string;
60
+ title: string;
61
+ scopes: Scope[];
62
+ /** Whether the client looks installed here. */
63
+ detect(env: ConnectEnv): boolean;
64
+ plan(spec: ServerSpec, scope: Scope, env: ConnectEnv): ConnectAction;
65
+ }
66
+
67
+ const stdioEntry = (s: ServerSpec): Record<string, unknown> => ({
68
+ command: s.command,
69
+ args: s.args,
70
+ ...(Object.keys(s.env).length ? { env: s.env } : {}),
71
+ });
72
+
73
+ const envFlags = (flag: string, env: Record<string, string>): string[] =>
74
+ Object.entries(env).flatMap(([k, v]) => [flag, `${k}=${v}`]);
75
+
76
+ function appSupport(env: ConnectEnv, app: string): string {
77
+ if (env.platform === "darwin")
78
+ return path.join(env.home, "Library", "Application Support", app);
79
+ if (env.platform === "win32")
80
+ return path.join(
81
+ env.appData ?? path.join(env.home, "AppData", "Roaming"),
82
+ app,
83
+ );
84
+ return path.join(env.home, ".config", app);
85
+ }
86
+
87
+ const json = (
88
+ file: string,
89
+ spec: ServerSpec,
90
+ key: "mcpServers" | "servers" = "mcpServers",
91
+ entry: Record<string, unknown> = stdioEntry(spec),
92
+ ): ConnectAction => ({ kind: "json", file, key, name: spec.name, entry });
93
+
94
+ export const MCP_CLIENTS: readonly McpClient[] = [
95
+ {
96
+ id: "claude-code",
97
+ title: "Claude Code",
98
+ scopes: ["user", "project"],
99
+ detect: (e) => e.which("claude"),
100
+ plan: (s, scope, e) =>
101
+ scope === "project"
102
+ ? json(path.join(e.projectDir, ".mcp.json"), s)
103
+ : {
104
+ kind: "exec",
105
+ remove: ["claude", "mcp", "remove", "--scope", "user", s.name],
106
+ add: [
107
+ "claude",
108
+ "mcp",
109
+ "add",
110
+ "--scope",
111
+ "user",
112
+ ...envFlags("-e", s.env),
113
+ s.name,
114
+ "--",
115
+ s.command,
116
+ ...s.args,
117
+ ],
118
+ check: ["claude", "mcp", "get", s.name],
119
+ },
120
+ },
121
+ {
122
+ id: "codex",
123
+ title: "Codex CLI",
124
+ scopes: ["user"],
125
+ detect: (e) => e.which("codex"),
126
+ plan: (s) => ({
127
+ kind: "exec",
128
+ remove: ["codex", "mcp", "remove", s.name],
129
+ add: [
130
+ "codex",
131
+ "mcp",
132
+ "add",
133
+ ...envFlags("--env", s.env),
134
+ s.name,
135
+ "--",
136
+ s.command,
137
+ ...s.args,
138
+ ],
139
+ check: ["codex", "mcp", "get", s.name],
140
+ }),
141
+ },
142
+ {
143
+ id: "cursor",
144
+ title: "Cursor",
145
+ scopes: ["user", "project"],
146
+ detect: (e) => e.which("cursor") || e.exists(path.join(e.home, ".cursor")),
147
+ plan: (s, scope, e) =>
148
+ json(
149
+ path.join(
150
+ scope === "project" ? e.projectDir : e.home,
151
+ ".cursor",
152
+ "mcp.json",
153
+ ),
154
+ s,
155
+ ),
156
+ },
157
+ {
158
+ id: "vscode",
159
+ title: "VS Code (Copilot agent)",
160
+ scopes: ["user", "project"],
161
+ detect: (e) => e.which("code"),
162
+ plan: (s, scope, e) =>
163
+ scope === "project"
164
+ ? json(path.join(e.projectDir, ".vscode", "mcp.json"), s, "servers", {
165
+ type: "stdio",
166
+ ...stdioEntry(s),
167
+ })
168
+ : {
169
+ kind: "exec",
170
+ add: [
171
+ "code",
172
+ "--add-mcp",
173
+ JSON.stringify({ name: s.name, type: "stdio", ...stdioEntry(s) }),
174
+ ],
175
+ },
176
+ },
177
+ {
178
+ id: "claude-desktop",
179
+ title: "Claude Desktop",
180
+ scopes: ["user"],
181
+ detect: (e) => e.exists(appSupport(e, "Claude")),
182
+ plan: (s, _scope, e) =>
183
+ json(path.join(appSupport(e, "Claude"), "claude_desktop_config.json"), s),
184
+ },
185
+ {
186
+ id: "windsurf",
187
+ title: "Windsurf",
188
+ scopes: ["user"],
189
+ detect: (e) =>
190
+ e.which("windsurf") ||
191
+ e.exists(path.join(e.home, ".codeium", "windsurf")),
192
+ plan: (s, _scope, e) =>
193
+ json(path.join(e.home, ".codeium", "windsurf", "mcp_config.json"), s),
194
+ },
195
+ {
196
+ id: "gemini",
197
+ title: "Gemini CLI",
198
+ scopes: ["user", "project"],
199
+ detect: (e) => e.which("gemini"),
200
+ plan: (s, scope, e) =>
201
+ json(
202
+ path.join(
203
+ scope === "project" ? e.projectDir : e.home,
204
+ ".gemini",
205
+ "settings.json",
206
+ ),
207
+ s,
208
+ ),
209
+ },
210
+ ];
211
+
212
+ export function findClient(id: string): McpClient | undefined {
213
+ return MCP_CLIENTS.find((c) => c.id === id);
214
+ }
215
+
216
+ /** The PATH a launched server needs: node's own directory first. */
217
+ export function serverPath(nodeBinDir: string, platform: NodeJS.Platform) {
218
+ const base =
219
+ platform === "win32"
220
+ ? []
221
+ : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
222
+ return [nodeBinDir, ...base.filter((d) => d !== nodeBinDir)].join(
223
+ platform === "win32" ? ";" : ":",
224
+ );
225
+ }
226
+
227
+ /** The server this distribution registers. */
228
+ export function serverSpec(o: {
229
+ cli: string;
230
+ distRoot: string;
231
+ nodeBinDir: string;
232
+ platform: NodeJS.Platform;
233
+ port?: number;
234
+ }): ServerSpec {
235
+ return {
236
+ name: o.cli,
237
+ command: path.join(o.distRoot, "bin", o.cli),
238
+ args: ["mcp", ...(o.port !== undefined ? ["--port", String(o.port)] : [])],
239
+ env: { PATH: serverPath(o.nodeBinDir, o.platform) },
240
+ };
241
+ }
242
+
243
+ // ── JSON configs ────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * `text` (a client's config file, or null when absent) with `name` set under
247
+ * `key`. Refuses to rewrite a file it cannot parse: a client's config is the
248
+ * developer's, and clobbering it loses their other servers.
249
+ */
250
+ export function mergeJsonConfig(
251
+ text: string | null,
252
+ key: string,
253
+ name: string,
254
+ entry: Record<string, unknown> | null,
255
+ ): string {
256
+ let doc: Record<string, unknown> = {};
257
+ if (text !== null && text.trim()) {
258
+ try {
259
+ doc = JSON.parse(text) as Record<string, unknown>;
260
+ } catch (e) {
261
+ throw new Error(
262
+ `not valid JSON (${(e as Error).message}); fix it or add the server by hand`,
263
+ );
264
+ }
265
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
266
+ throw new Error("not a JSON object; add the server by hand");
267
+ }
268
+ const servers =
269
+ doc[key] && typeof doc[key] === "object" && !Array.isArray(doc[key])
270
+ ? { ...(doc[key] as Record<string, unknown>) }
271
+ : {};
272
+ if (entry === null) delete servers[name];
273
+ else servers[name] = entry;
274
+ return `${JSON.stringify({ ...doc, [key]: servers }, null, 2)}\n`;
275
+ }
276
+
277
+ /** Whether a JSON config already registers `name` with this entry. */
278
+ export function jsonHasEntry(
279
+ text: string | null,
280
+ key: string,
281
+ name: string,
282
+ entry: Record<string, unknown>,
283
+ ): "connected" | "different" | "absent" {
284
+ if (!text) return "absent";
285
+ try {
286
+ const doc = JSON.parse(text) as Record<string, Record<string, unknown>>;
287
+ const have = doc?.[key]?.[name];
288
+ if (have === undefined) return "absent";
289
+ return JSON.stringify(have) === JSON.stringify(entry)
290
+ ? "connected"
291
+ : "different";
292
+ } catch {
293
+ return "absent";
294
+ }
295
+ }
296
+
297
+ /** A generic snippet for any other MCP client. */
298
+ export function genericSnippet(spec: ServerSpec): string {
299
+ return JSON.stringify(
300
+ { mcpServers: { [spec.name]: stdioEntry(spec) } },
301
+ null,
302
+ 2,
303
+ );
304
+ }
@@ -61,7 +61,10 @@ export function runsLocally(entry: ServiceEntryLike): boolean {
61
61
  return entry.commands.includes("up");
62
62
  }
63
63
 
64
- export function assertRunsLocally(entry: ServiceEntryLike): void {
64
+ export function assertRunsLocally(
65
+ entry: ServiceEntryLike,
66
+ command: string = "up",
67
+ ): void {
65
68
  const address = `${entry.repo ?? "?"}/${entry.service ?? "?"}`;
66
69
  if (entry.runs === "remote")
67
70
  throw new Refusal(
@@ -69,7 +72,8 @@ export function assertRunsLocally(entry: ServiceEntryLike): void {
69
72
  address,
70
73
  { runs: "remote", target: entry.target ?? null },
71
74
  (e) => isProductionEnv(e),
72
- ) ?? `${address} runs remotely; nothing to start here`,
75
+ command,
76
+ ) ?? `${address} runs remotely; nothing to ${command} here`,
73
77
  );
74
78
  if (!runsLocally(entry))
75
79
  throw new Refusal(
@@ -115,13 +115,78 @@ function parsePort(argv: string[]): number | undefined {
115
115
  export const MCP_USAGE = (cli: string): string =>
116
116
  [
117
117
  `Usage: ${cli} mcp [--port <web port>]`,
118
+ ` ${cli} mcp --list`,
119
+ ` ${cli} mcp --call <tool> ['<json args>']`,
118
120
  "",
119
121
  `An MCP server over stdio for agents, a client of the running web server`,
120
122
  `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
121
123
  "actions only as named in the org config's mcp.actions. See docs/distributions.md.",
124
+ "",
125
+ "--list prints the tool names; --call runs one tool through the same guards,",
126
+ "prints its text result and exits 1 if the tool reports an error.",
127
+ "",
128
+ `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
122
129
  ].join("\n");
123
130
 
124
- /** Run `<cli> mcp` until stdin closes. */
131
+ /** A one-shot invocation: `--list`, or `--call <tool> [json]`. */
132
+ export type OneShot =
133
+ | { kind: "list" }
134
+ | { kind: "call"; tool: string; args: Record<string, unknown> };
135
+
136
+ /** The one-shot `argv` asks for, or null for the stdio server. */
137
+ export function parseOneShot(argv: string[]): OneShot | null {
138
+ const i = argv.indexOf("--call");
139
+ if (i !== -1) {
140
+ const tool = argv[i + 1];
141
+ if (!tool || tool.startsWith("-"))
142
+ throw new Error("--call needs a tool name: --call <tool> ['<json>']");
143
+ const raw = argv[i + 2];
144
+ if (raw === undefined || raw.startsWith("--"))
145
+ return { kind: "call", tool, args: {} };
146
+ let args: unknown;
147
+ try {
148
+ args = JSON.parse(raw);
149
+ } catch (e) {
150
+ throw new Error(
151
+ `--call ${tool}: arguments are not JSON (${(e as Error).message})`,
152
+ );
153
+ }
154
+ if (args === null || typeof args !== "object" || Array.isArray(args))
155
+ throw new Error(`--call ${tool}: arguments must be a JSON object`);
156
+ return { kind: "call", tool, args: args as Record<string, unknown> };
157
+ }
158
+ return argv.includes("--list") ? { kind: "list" } : null;
159
+ }
160
+
161
+ export interface OneShotOptions {
162
+ cfg: ResolvedMcpConfig;
163
+ cli: string;
164
+ api: DenApi;
165
+ out?: (text: string) => void;
166
+ err?: (text: string) => void;
167
+ }
168
+
169
+ /**
170
+ * Run a one-shot through the tool layer the stdio server uses (callTool:
171
+ * mcp.actions, every guard, redaction). Returns the exit code.
172
+ */
173
+ export async function runOneShot(
174
+ shot: OneShot,
175
+ opts: OneShotOptions,
176
+ ): Promise<number> {
177
+ const out = opts.out ?? ((t: string) => process.stdout.write(t));
178
+ if (shot.kind === "list") {
179
+ const names = toolsFor(opts.cfg).map((t) => t.name);
180
+ out(names.length ? `${names.join("\n")}\n` : "");
181
+ return 0;
182
+ }
183
+ const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
184
+ const r = await callTool(shot.tool, shot.args, ctx);
185
+ out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
186
+ return r.isError ? 1 : 0;
187
+ }
188
+
189
+ /** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
125
190
  export async function runMcp(argv: string[]): Promise<void> {
126
191
  const cli = product().cli;
127
192
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -138,6 +203,24 @@ export async function runMcp(argv: string[]): Promise<void> {
138
203
  }
139
204
  const cfg = readMcpConfig();
140
205
  const baseUrl = `http://localhost:${port}`;
206
+ const shot = parseOneShot(argv);
207
+ if (shot) {
208
+ // Recorded like any MCP action: actor agent, this process, one session.
209
+ const session = randomUUID();
210
+ const api = createDenApi({
211
+ baseUrl,
212
+ cli,
213
+ headers: () =>
214
+ agentAuditHeaders({
215
+ client: `${cli} mcp --call`,
216
+ clientVersion: engineVersion() ?? "unknown",
217
+ mcpPid: process.pid,
218
+ session,
219
+ }),
220
+ });
221
+ process.exitCode = await runOneShot(shot, { cfg, cli, api });
222
+ return;
223
+ }
141
224
  const server = createDenMcpServer({ cfg, baseUrl, cli });
142
225
  await server.connect(new StdioServerTransport());
143
226
  process.stderr.write(
@@ -85,15 +85,61 @@ function tail(r: StreamResult, n: number): string[] {
85
85
  }
86
86
 
87
87
  /** One line per stream: how it ended and its exit code. */
88
+ /**
89
+ * Whether a streamed action failed: a non-zero exit, a detached service whose
90
+ * launcher exited non-zero, or a run that never finished (timeout).
91
+ */
92
+ export function streamFailed(r: StreamResult): boolean {
93
+ if (r.ended === "timeout") return true;
94
+ if (r.exitCode !== undefined && r.exitCode !== 0) return true;
95
+ return r.events.some(
96
+ (e) =>
97
+ e.type === "service-exited" &&
98
+ typeof e.exitCode === "number" &&
99
+ e.exitCode !== 0,
100
+ );
101
+ }
102
+
103
+ /** An action that ran and failed: its output goes back with isError set. */
104
+ export class ActionFailed extends Error {
105
+ constructor(readonly out: Record<string, unknown>) {
106
+ super("action failed");
107
+ this.name = "ActionFailed";
108
+ }
109
+ }
110
+
111
+ /**
112
+ * How the call ended, in words an agent reads about the service: a stream
113
+ * stopped at `ready` is "ready", at a launcher's exit "handed-off".
114
+ */
115
+ function endedLabel(r: StreamResult): string {
116
+ if (r.ended !== "stopped") return r.ended;
117
+ const last = r.events.at(-1)?.type;
118
+ if (last === "ready") return "ready";
119
+ if (last === "service-exited") return "handed-off";
120
+ return r.ended;
121
+ }
122
+
88
123
  function streamSummary(r: StreamResult, keep: number) {
89
124
  return {
90
- ended: r.ended,
125
+ ended: endedLabel(r),
91
126
  ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}),
92
127
  lines: r.lines.length,
93
128
  output: tail(r, keep),
94
129
  };
95
130
  }
96
131
 
132
+ /** The action's result; thrown as ActionFailed when the stream failed. */
133
+ function actionResult(
134
+ r: StreamResult,
135
+ keep: number,
136
+ head: Record<string, unknown> = {},
137
+ ): Record<string, unknown> {
138
+ const out = { ...head, ...streamSummary(r, keep) };
139
+ if (streamFailed(r)) throw new ActionFailed({ failed: true, ...out });
140
+ return out;
141
+ }
142
+
97
143
  // ── Read tools ──────────────────────────────────────────────────────────────
98
144
 
99
145
  const read = (
@@ -354,7 +400,7 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
354
400
  str(a.service),
355
401
  workspace,
356
402
  );
357
- assertRunsLocally(entry);
403
+ assertRunsLocally(entry, cmd);
358
404
  const r = await api.stream(
359
405
  "/api/service/exec",
360
406
  {
@@ -368,16 +414,20 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
368
414
  method: "GET",
369
415
  maxLines: 2000,
370
416
  timeoutMs: 180_000,
371
- // A detached `up` keeps streaming the service's log; its launch
372
- // script exiting is the hand-off.
373
- stopOn: (e) => e.type === "service-exited",
417
+ // A detached start keeps streaming the service's log. It is done
418
+ // when the server says it is ready (health green, or still running
419
+ // with no check), or when its launch script exits (the hand-off).
420
+ stopOn: (e) => e.type === "ready" || e.type === "service-exited",
374
421
  },
375
422
  );
376
- return {
423
+ const ready = r.events.find((e) => e.type === "ready");
424
+ return actionResult(r, 200, {
377
425
  service: `${entry.repo}/${entry.service}`,
378
426
  cmd,
379
- ...streamSummary(r, 200),
380
- };
427
+ ...(ready
428
+ ? { ready: true, healthChecked: ready.checked === true }
429
+ : {}),
430
+ });
381
431
  },
382
432
  };
383
433
  }
@@ -408,7 +458,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
408
458
  maxLines: 5000,
409
459
  timeoutMs: 15 * 60_000,
410
460
  });
411
- return streamSummary(r, 300);
461
+ return actionResult(r, 300);
412
462
  },
413
463
  },
414
464
  run_tests: {
@@ -453,7 +503,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
453
503
  },
454
504
  { method: "GET", maxLines: 20_000, timeoutMs: 60 * 60_000 },
455
505
  );
456
- return { pillar, env, ...streamSummary(r, 300) };
506
+ return actionResult(r, 300, { pillar, env });
457
507
  },
458
508
  },
459
509
  };
@@ -532,6 +582,28 @@ async function notFoundMessage(
532
582
  * that refused, how to start the web server, or (when it runs an older Den
533
583
  * than the tool needs) how to restart it.
534
584
  */
585
+ /**
586
+ * Tell the web server a guard refused an action, so the activity trail shows
587
+ * the attempt (F2). Best effort: a report that fails changes nothing.
588
+ */
589
+ async function reportRefusal(
590
+ ctx: ToolContext,
591
+ tool: string,
592
+ args: Args,
593
+ reason: string,
594
+ ): Promise<void> {
595
+ try {
596
+ await ctx.api.get("/api/activity", {}, "POST", {
597
+ tool,
598
+ ...(typeof args.repo === "string" ? { repo: args.repo } : {}),
599
+ ...(typeof args.service === "string" ? { service: args.service } : {}),
600
+ reason,
601
+ });
602
+ } catch {
603
+ /* the refusal is still returned to the agent */
604
+ }
605
+ }
606
+
535
607
  export async function callTool(
536
608
  name: string,
537
609
  args: Args,
@@ -548,12 +620,19 @@ export async function callTool(
548
620
  const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
549
621
  return { text: redactSecrets(text), isError: false };
550
622
  } catch (e) {
623
+ if (e instanceof ActionFailed)
624
+ return {
625
+ text: redactSecrets(JSON.stringify(e.out, null, 2)),
626
+ isError: true,
627
+ };
551
628
  if (e instanceof DenHttpError && e.status === 404) {
552
629
  const text = await notFoundMessage(e, name, ctx).catch(
553
630
  (inner: Error) => `Error: ${inner.message}`,
554
631
  );
555
632
  return { text: redactSecrets(text), isError: true };
556
633
  }
634
+ if (e instanceof Refusal && tool.action)
635
+ await reportRefusal(ctx, name, args, e.message);
557
636
  const msg =
558
637
  e instanceof Refusal
559
638
  ? `Refused: ${e.message}`
@@ -20,9 +20,13 @@ export interface AuditEntry {
20
20
  clientVersion?: string;
21
21
  mcpPid?: number | null;
22
22
  session?: string;
23
- /** exec | run | sync */
23
+ /** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
24
24
  verb: string;
25
25
  detail: string;
26
+ /** Set when the action never ran: a guard or the server refused it. */
27
+ outcome?: "refused";
28
+ /** Why it was refused (first line). */
29
+ reason?: string;
26
30
  }
27
31
 
28
32
  type Sink = (line: string, actor: AgentActor | null) => void;
@@ -43,7 +47,12 @@ export function actorSuffix(actor: Actor): string {
43
47
 
44
48
  export function recordAction(
45
49
  actor: Actor,
46
- action: { verb: string; detail: string },
50
+ action: {
51
+ verb: string;
52
+ detail: string;
53
+ outcome?: "refused";
54
+ reason?: string;
55
+ },
47
56
  ): AuditEntry {
48
57
  const entry: AuditEntry = {
49
58
  ts: new Date().toISOString(),
@@ -58,13 +67,70 @@ export function recordAction(
58
67
  : {}),
59
68
  verb: action.verb,
60
69
  detail: action.detail,
70
+ ...(action.outcome ? { outcome: action.outcome } : {}),
71
+ ...(action.reason ? { reason: clean(action.reason, 300) } : {}),
61
72
  };
62
73
  ring.push(entry);
63
74
  if (ring.length > RING_MAX) ring.shift();
64
- sink?.(`▶ ${action.verb} ${action.detail}${actorSuffix(actor)}`, actor);
75
+ const mark = entry.outcome === "refused" ? "✗" : "▶";
76
+ const why = entry.reason ? ` — refused: ${entry.reason}` : "";
77
+ sink?.(
78
+ `${mark} ${action.verb} ${action.detail}${why}${actorSuffix(actor)}`,
79
+ actor,
80
+ );
65
81
  return entry;
66
82
  }
67
83
 
84
+ /** One line, printable characters only, at most `max` long. */
85
+ function clean(v: unknown, max: number): string {
86
+ const line = String(v ?? "").split(/[\r\n]/)[0];
87
+ return [...line]
88
+ .filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
89
+ .join("")
90
+ .slice(0, max);
91
+ }
92
+
93
+ const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
94
+ const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
95
+
96
+ /**
97
+ * An MCP guard refused an agent's call before it reached an action route.
98
+ * The agent's own report, so every field is sanitised: a lifecycle tool with
99
+ * 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.
101
+ */
102
+ export function recordAgentRefusal(
103
+ actor: Actor,
104
+ report: {
105
+ tool?: unknown;
106
+ repo?: unknown;
107
+ service?: unknown;
108
+ reason?: unknown;
109
+ },
110
+ ): AuditEntry {
111
+ const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
112
+ const repo = typeof report.repo === "string" ? report.repo : "";
113
+ const service = typeof report.service === "string" ? report.service : "";
114
+ const lifecycle = LIFECYCLE_TOOL.exec(tool);
115
+ const reason = clean(report.reason, 300) || "refused";
116
+ if (lifecycle && SAFE_PART.test(repo)) {
117
+ const address =
118
+ service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
119
+ return recordAction(actor, {
120
+ verb: "exec",
121
+ detail: `${address} ${lifecycle[1]} (local)`,
122
+ outcome: "refused",
123
+ reason,
124
+ });
125
+ }
126
+ return recordAction(actor, {
127
+ verb: tool,
128
+ detail: "",
129
+ outcome: "refused",
130
+ reason,
131
+ });
132
+ }
133
+
68
134
  /** The most recent actions, oldest first. */
69
135
  export function recentActions(): AuditEntry[] {
70
136
  return ring.slice();