@monster-orange/den 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +1 -1
  3. package/bin/den +8 -0
  4. package/docs/distributions.md +283 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +19 -2
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +333 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +184 -55
  35. package/src/ui/cli/mcp-connect.ts +350 -0
  36. package/src/ui/cli/mcp-main.ts +13 -0
  37. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  38. package/src/ui/cli/named-target.ts +11 -0
  39. package/src/ui/cli/stack-command.ts +13 -40
  40. package/src/ui/cli/test-runner.ts +8 -1
  41. package/src/ui/mcp/api.ts +286 -0
  42. package/src/ui/mcp/connect.ts +304 -0
  43. package/src/ui/mcp/guards.ts +145 -0
  44. package/src/ui/mcp/server.ts +149 -0
  45. package/src/ui/mcp/tools.ts +644 -0
  46. package/src/ui/server/audit.ts +137 -0
  47. package/src/ui/server/changelog-parser.ts +11 -0
  48. package/src/ui/server/graph-prefs.ts +89 -0
  49. package/src/ui/server/health-row.ts +15 -0
  50. package/src/ui/server/index.ts +256 -61
  51. package/src/ui/server/lifecycle-exec.ts +188 -0
  52. package/src/ui/server/log-history.ts +4 -3
  53. package/src/ui/server/public/environments.html +4 -2
  54. package/src/ui/server/public/graph-model.js +416 -0
  55. package/src/ui/server/public/graph.css +980 -0
  56. package/src/ui/server/public/graph.js +986 -0
  57. package/src/ui/server/public/header.js +7 -5
  58. package/src/ui/server/public/local-dev.html +130 -2191
  59. package/src/ui/server/public/logs.html +11 -4
  60. package/src/ui/server/public/panel.js +790 -0
  61. package/src/ui/server/public/services.html +6 -1
  62. package/src/ui/server/readonly-routes.ts +209 -0
  63. package/src/ui/server/ui-assets.ts +73 -0
  64. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The MCP server's guards. Each one throws a Refusal (the tool then returns
3
+ * an error naming the rule) before anything reaches the web API. They sit on
4
+ * top of the web server's own checks, which still apply.
5
+ */
6
+ import type { Environment } from "../../shared/types/index.js";
7
+ import {
8
+ isProductionEnv,
9
+ type ResolvedMcpConfig,
10
+ } from "../../shared/den-config.js";
11
+ import {
12
+ placementRefusal,
13
+ type Placement,
14
+ } from "../../shared/manifest/placement.js";
15
+
16
+ /** A guard said no. The message says which rule and how to change it. */
17
+ export class Refusal extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = "Refusal";
21
+ }
22
+ }
23
+
24
+ /** The only manifest commands an agent may run. */
25
+ export const LIFECYCLE_CMDS = ["up", "down", "restart"] as const;
26
+ export type LifecycleCmd = (typeof LIFECYCLE_CMDS)[number];
27
+
28
+ export function assertLifecycleCmd(cmd: string): asserts cmd is LifecycleCmd {
29
+ if (!(LIFECYCLE_CMDS as readonly string[]).includes(cmd))
30
+ throw new Refusal(
31
+ `"${cmd}" is not a lifecycle command: agents may run only ${LIFECYCLE_CMDS.join(", ")}.`,
32
+ );
33
+ }
34
+
35
+ /** Service lifecycle actions run on this machine only. */
36
+ export function assertLocalEnv(env: string): void {
37
+ if (env !== "local")
38
+ throw new Refusal(
39
+ `service actions are local only (env "${env}" refused); nothing remote is ever started or stopped.`,
40
+ );
41
+ }
42
+
43
+ /** The fields of an /api/services entry the guards read. */
44
+ export interface ServiceEntryLike {
45
+ repo?: string;
46
+ service?: string;
47
+ commands: string[];
48
+ mode?: string;
49
+ /** R13: where the service runs (absent from an older web server). */
50
+ runs?: Placement["runs"];
51
+ target?: Placement["target"];
52
+ }
53
+
54
+ /**
55
+ * Whether Den runs this service on this machine: the served `runs` is
56
+ * "local" and it has a local `up` command. A web server that does not serve
57
+ * `runs` yet is judged by the `up` command alone.
58
+ */
59
+ export function runsLocally(entry: ServiceEntryLike): boolean {
60
+ if (entry.runs !== undefined && entry.runs !== "local") return false;
61
+ return entry.commands.includes("up");
62
+ }
63
+
64
+ export function assertRunsLocally(
65
+ entry: ServiceEntryLike,
66
+ command: string = "up",
67
+ ): void {
68
+ const address = `${entry.repo ?? "?"}/${entry.service ?? "?"}`;
69
+ if (entry.runs === "remote")
70
+ throw new Refusal(
71
+ placementRefusal(
72
+ address,
73
+ { runs: "remote", target: entry.target ?? null },
74
+ (e) => isProductionEnv(e),
75
+ command,
76
+ ) ?? `${address} runs remotely; nothing to ${command} here`,
77
+ );
78
+ if (!runsLocally(entry))
79
+ throw new Refusal(
80
+ `${address} is not run locally (its manifest has no local "up" command), so agents may not start, stop or restart it.`,
81
+ );
82
+ }
83
+
84
+ const PRODUCTION: readonly string[] = ["production", "prod"];
85
+
86
+ /**
87
+ * run_tests: production never; otherwise only the envs the org listed in
88
+ * `mcp.run_tests.envs` (default [local]). Security `full` (active attacks) is
89
+ * local only.
90
+ */
91
+ export function assertRunTestsAllowed(
92
+ args: { pillar: string; env: string; mode?: string },
93
+ cfg: ResolvedMcpConfig,
94
+ ): void {
95
+ if (PRODUCTION.includes(args.env))
96
+ throw new Refusal("run_tests never targets production.");
97
+ if (!cfg.runTestsEnvs.includes(args.env as Environment))
98
+ throw new Refusal(
99
+ `env "${args.env}" is not in mcp.run_tests.envs (allowed: ${cfg.runTestsEnvs.join(", ")}).`,
100
+ );
101
+ if (
102
+ args.pillar === "security" &&
103
+ args.mode === "full" &&
104
+ args.env !== "local"
105
+ )
106
+ throw new Refusal(
107
+ `security mode "full" (active attacks) is local only (env "${args.env}" refused).`,
108
+ );
109
+ }
110
+
111
+ /** The security CLI's dry-run verdict, from /api/security/plan. */
112
+ export interface SecurityPlanLike {
113
+ ok: boolean;
114
+ errors?: string[];
115
+ }
116
+
117
+ /** A security run must pass the CLI's own plan: owned hosts, mode ceiling. */
118
+ export function assertSecurityPlanOk(plan: SecurityPlanLike): void {
119
+ if (!plan.ok)
120
+ throw new Refusal(
121
+ `the security plan refused this run: ${(plan.errors ?? []).join(" ") || "no reason given"}`,
122
+ );
123
+ }
124
+
125
+ /** workspace_sync <repo>: the repo must be a managed clone in the workspace. */
126
+ export function assertSyncableRepo(
127
+ repo: string,
128
+ services: readonly ServiceEntryLike[],
129
+ ): void {
130
+ const entries = services.filter((s) => s.repo === repo);
131
+ if (entries.length === 0)
132
+ throw new Refusal(`no repo named "${repo}" in the workspace.`);
133
+ if (!entries.some((s) => s.mode === "managed"))
134
+ throw new Refusal(
135
+ `${repo} is dev-managed (your own checkout): sync only updates managed clones, never a dev-managed one.`,
136
+ );
137
+ }
138
+
139
+ /** Log tools read local sources unless the org set `mcp.logs.remote: true`. */
140
+ export function assertLogsEnv(env: string, cfg: ResolvedMcpConfig): void {
141
+ if (env !== "local" && !cfg.logsRemote)
142
+ throw new Refusal(
143
+ `remote log sources (env "${env}") are off: set mcp.logs.remote: true to allow them.`,
144
+ );
145
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `<cli> mcp`: an MCP server over stdio, a thin client of the running web
3
+ * server (http://localhost:<web.port>). Agents and the web UI share one
4
+ * state and one set of guards; nothing is started implicitly.
5
+ *
6
+ * stdout carries the protocol only: diagnostics go to stderr.
7
+ */
8
+ import { randomUUID } from "crypto";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { agentAuditHeaders } from "../../shared/agent-actor.js";
12
+ import {
13
+ readMcpConfig,
14
+ type ResolvedMcpConfig,
15
+ } from "../../shared/den-config.js";
16
+ import { readWebPort } from "../../shared/edge/config.js";
17
+ import { engineVersion } from "../../shared/paths.js";
18
+ import { product } from "../../shared/product.js";
19
+ import { DEFAULT_PORT } from "../../shared/constants.js";
20
+ import { createDenApi, type DenApi } from "./api.js";
21
+ import { callTool, toolsFor, type ToolContext } from "./tools.js";
22
+
23
+ export interface DenMcpOptions {
24
+ cfg: ResolvedMcpConfig;
25
+ baseUrl: string;
26
+ cli: string;
27
+ /** One id per MCP server process, recorded with every action. */
28
+ session?: string;
29
+ pid?: number;
30
+ /** For tests: an API in place of the HTTP client. */
31
+ api?: DenApi;
32
+ log?: (line: string) => void;
33
+ }
34
+
35
+ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
36
+ const p = product();
37
+ const session = opts.session ?? randomUUID();
38
+ const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
39
+ const tools = toolsFor(opts.cfg);
40
+ const server = new McpServer(
41
+ { name: p.name, version: engineVersion() ?? "0.0.0" },
42
+ {
43
+ instructions:
44
+ `${p.title}: local services, workspaces, logs and tests for this org, ` +
45
+ `through the running ${p.title} web server (\`${opts.cli} web up\`). ` +
46
+ (opts.cfg.actions.length
47
+ ? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
48
+ : "Read-only: the org has enabled no actions (mcp.actions)."),
49
+ },
50
+ );
51
+
52
+ // The client's self-reported name/version, known once it has initialized.
53
+ const client = () => server.server.getClientVersion();
54
+ const api =
55
+ opts.api ??
56
+ createDenApi({
57
+ baseUrl: opts.baseUrl,
58
+ cli: opts.cli,
59
+ headers: () =>
60
+ agentAuditHeaders({
61
+ client: client()?.name ?? "unknown",
62
+ clientVersion: client()?.version ?? "unknown",
63
+ mcpPid: opts.pid ?? process.pid,
64
+ session,
65
+ }),
66
+ });
67
+ const ctx: ToolContext = { api, cfg: opts.cfg, cli: opts.cli };
68
+
69
+ for (const t of tools) {
70
+ server.registerTool(
71
+ t.name,
72
+ {
73
+ title: t.title,
74
+ description: t.description,
75
+ inputSchema: t.shape,
76
+ annotations: {
77
+ readOnlyHint: !t.action,
78
+ destructiveHint: t.action,
79
+ openWorldHint: false,
80
+ },
81
+ },
82
+ async (args: Record<string, unknown>) => {
83
+ const r = await callTool(t.name, args ?? {}, ctx);
84
+ if (t.action || r.isError) {
85
+ const who = client();
86
+ log(
87
+ `[mcp] ${t.name} ${r.isError ? "refused/failed" : "ok"} · ` +
88
+ `${who?.name ?? "unknown"} ${who?.version ?? ""} · session ${session}` +
89
+ (r.isError ? ` · ${r.text.split("\n")[0]}` : ""),
90
+ );
91
+ }
92
+ return {
93
+ content: [{ type: "text" as const, text: r.text }],
94
+ isError: r.isError,
95
+ };
96
+ },
97
+ );
98
+ }
99
+ return server;
100
+ }
101
+
102
+ function parsePort(argv: string[]): number | undefined {
103
+ const i = argv.indexOf("--port");
104
+ const raw =
105
+ i !== -1
106
+ ? argv[i + 1]
107
+ : argv.find((a) => a.startsWith("--port="))?.slice(7);
108
+ if (raw === undefined) return undefined;
109
+ const n = Number(raw);
110
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
111
+ throw new Error(`--port must be a port number, got "${raw}"`);
112
+ return n;
113
+ }
114
+
115
+ export const MCP_USAGE = (cli: string): string =>
116
+ [
117
+ `Usage: ${cli} mcp [--port <web port>]`,
118
+ "",
119
+ `An MCP server over stdio for agents, a client of the running web server`,
120
+ `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
121
+ "actions only as named in the org config's mcp.actions. See docs/distributions.md.",
122
+ "",
123
+ `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
124
+ ].join("\n");
125
+
126
+ /** Run `<cli> mcp` until stdin closes. */
127
+ export async function runMcp(argv: string[]): Promise<void> {
128
+ const cli = product().cli;
129
+ if (argv.includes("-h") || argv.includes("--help")) {
130
+ process.stderr.write(`${MCP_USAGE(cli)}\n`);
131
+ return;
132
+ }
133
+ let port = parsePort(argv);
134
+ if (port === undefined) {
135
+ try {
136
+ port = readWebPort();
137
+ } catch {
138
+ port = DEFAULT_PORT;
139
+ }
140
+ }
141
+ const cfg = readMcpConfig();
142
+ const baseUrl = `http://localhost:${port}`;
143
+ const server = createDenMcpServer({ cfg, baseUrl, cli });
144
+ await server.connect(new StdioServerTransport());
145
+ process.stderr.write(
146
+ `${cli} mcp: ${toolsFor(cfg).length} tools ` +
147
+ `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
148
+ );
149
+ }