@monster-orange/den 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -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 +16 -1
  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 +321 -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 +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
package/CHANGELOG.md CHANGED
@@ -4,6 +4,143 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.0] - 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **Manifest overrides.** An optional `manifests/<repo>.override.yml` in the
12
+ distribution is deep-merged onto the repo's base manifest (its own, or the
13
+ held one): maps merge by key, lists and scalars replace, and an explicit
14
+ `null` deletes a key. An override may only name services and keys the base
15
+ has, and an override with no base is an error. See "Overriding a manifest"
16
+ in `docs/distributions.md`.
17
+ - **Manifest layers are visible.** `/api/services` returns `manifestLayers`
18
+ per service (`["repo"]`, `["held", "override"]`, …) and the Services cards
19
+ show them; `GET /api/manifest/validate?path=<repo dir>` returns each layer's
20
+ result.
21
+ - **`validate` checks each layer.** For a repo folder it validates the base
22
+ and the override separately, each error naming its file, then the merged
23
+ result.
24
+ - **`<cli> mcp`: an MCP server over stdio** for agents, a thin client of the
25
+ running web server (it never starts it, and says how when it is down). Read
26
+ tools are always on, including the new `stacks` and `edge_status`; the
27
+ actions `service_up`, `service_down`, `service_restart`, `workspace_sync`
28
+ and `run_tests` are opt-in per org with `mcp.actions`. Guards: lifecycle
29
+ commands and env=local only, locally run services only, `run_tests` limited
30
+ to `mcp.run_tests.envs` (default `[local]`, never production), security runs
31
+ must pass the owned-host plan and `full` is local only, log tools read
32
+ local sources unless `mcp.logs.remote: true`, and all text returned is
33
+ secret-redacted. No deploy tool. Pinned `@modelcontextprotocol/sdk` 1.32.1.
34
+ See "MCP server" in `docs/distributions.md`.
35
+ - **Agent audit trail.** Every service exec, test run and sync is recorded
36
+ with who asked: an agent's actions carry actor `agent`, the MCP client's
37
+ self-reported name and version, and the MCP pid and session id, and show as
38
+ `[agent]` lines in the web server log on the Logs page.
39
+ - **Read-only status endpoints:** `/api/stacks` (what `stack ls/health` show),
40
+ `/api/edge` (what `edge status` shows) and `/api/security/plan` (the
41
+ security CLI's own dry run). `stack ls` and `edge status` now print from the
42
+ same code.
43
+ - **Graph: cards and a details panel.** The Local Dev graph is laid out by
44
+ ELK (layered, compound org or application › repo › service, orthogonal
45
+ edges; elkjs vendored and served by Den, so it works offline). Cards are
46
+ full size (detailed or compact) on a canvas you pan and zoom; Fit zooms out
47
+ to 35%. A card shows status, name, repo, placement and health, and its main
48
+ actions on hover. Clicking a card or a repo opens a side panel: one primary
49
+ action (Start when down, Open when up, else Logs), two common ones, and More
50
+ (manifest commands, tools, and careful actions that confirm on a second
51
+ click); tabs Overview, Logs (live tail), Config and Activity (the audit
52
+ trail, yours and agents'). ⤢ pops the panel out as a dialog. Layout
53
+ direction, grouping, card density and panel width are remembered per user
54
+ (`/api/settings` `graph`). Find highlights and centres on matches. The
55
+ panel is built from registries (actions, tabs, sections) a distribution or
56
+ later release can add to.
57
+ - `GET /api/activity`: the audit trail, filtered by service or repo.
58
+ - **One secret redactor** (`src/shared/redact.ts`), shared by the journeys and
59
+ security pillars and the MCP server.
60
+ - **Placement (R13): what runs here and what runs in the cloud.** A
61
+ manifest's service may say `runs: { local: remote, dev: remote }` (per env,
62
+ `local` or `remote`) and `target: { env, account?, url?, expect? }`.
63
+ Without `runs`, a service is local when it or any service of its repo has
64
+ local commands (or the repo is a compose stack), remote when it has only an
65
+ HTTP health URL and nothing in its repo runs here, and otherwise has no
66
+ placement. `/api/services`
67
+ and `/api/health` rows carry `runs` and `target`; `/api/applications`
68
+ carries `placements` per service address, and per application `placement`
69
+ (`local | remote | hybrid | none`) and a `summary`
70
+ (`shop-frontend local · shop-core remote (dev)`). `<cli> status`,
71
+ `services` and `apps` suffix remote services `☁ remote (dev)`, and
72
+ `<cli> graph` marks remote and hybrid nodes. See "Placement" in
73
+ `docs/distributions.md`.
74
+ - **Remote health.** A remote service is checked by the health declared for
75
+ its `target.env`; only when there is none does Den GET `target.url`, up when
76
+ the status is one of `target.expect` (a code or a list, 100–599; default
77
+ any 2xx). Its health row's label names the target (`dev · AWS`).
78
+ - **`production_envs`** in the org config: the envs that count as production
79
+ (default `[prod, production]`).
80
+
81
+ ### Changed
82
+
83
+ - **`/api/health` rows: `url` is only ever a URL.** A shell health check's
84
+ command (a compose stack's `<cli> stack health …`, or any declared shell
85
+ check) is now in a new `check` field and `url` is null; the UI shows the
86
+ same tooltips as before. Clients that read the command from `url` should
87
+ read `check`.
88
+ - `/api/server-info` returns Den's `version`.
89
+ - **Nothing runs a command on a remote service.** `/api/service/exec`,
90
+ `<cli> up|down|restart`, workspace up and down, and `<cli> up`'s plan refuse
91
+ it (`runs in dev (AWS); nothing to start here`; a `down` succeeds with
92
+ nothing to stop). The MCP guard `runsLocally()` reads `runs`. Security
93
+ `auto_targets` excludes remote services explicitly, a remote service is
94
+ never scanned as if it were local, and one whose `target.env` counts as
95
+ production is never scanned at all.
96
+ - **Overrides may add placement.** `runs` and `target` are the one thing a
97
+ `manifests/<repo>.override.yml` may add to a service its base has.
98
+ - `validate` checks env names: a `runs` key or `target.env` that is not one of
99
+ the org's `environments` is an error.
100
+ - `<cli> status` keeps a hybrid repo's remote service as its own row instead
101
+ of rolling it into the entrypoint.
102
+
103
+ ### Fixed
104
+
105
+ - **Logs of compose-stack services.** The live tail (`/api/service/taillog`,
106
+ so the UI's log view and `logs_tail`), log history and search
107
+ (`logs_search`) and `<cli> logs` now find a stack service's container,
108
+ `<stack>-<workspace>-<service>-1`, through the stacks naming helpers. They
109
+ looked only for opctl containers, so they fell back to the startup log
110
+ ("container log unavailable") or found nothing.
111
+ - **`<cli> mcp` against an older web server.** A tool whose route the web
112
+ server predates (`stacks`, `edge_status`, a security `run_tests`) now says
113
+ `web server is Den <x>; <tool> needs >= <y>: run <cli> web restart`
114
+ instead of a bare 404; other 404s name the tool and the route.
115
+ - Sync never touches a dev-managed checkout: a managed entry whose path is,
116
+ holds or sits inside one is refused with the reason, and naming a
117
+ dev-managed repo says so instead of doing nothing.
118
+ - The What's new dialog showed every changelog bullet cut off mid-sentence.
119
+ - With nothing run yet, the empty output pane no longer takes 200px from the
120
+ Local Dev page.
121
+ - A security run's mode chosen on the Tests page (`api`, `full`) now reaches
122
+ the scan; it always ran `baseline` before.
123
+
124
+ ### Security
125
+
126
+ - Dependency advisories: `npm audit fix` plus `@cursor/sdk` 1.0.23 → 1.0.40
127
+ (drops its `undici` 5 / `zod` 3 tree) clear every critical and high
128
+ advisory. Left: a `minimatch` ReDoS under `openapi-typescript` (only fix is
129
+ a downgrade to 6.x; it matches local config globs only) and dev-only
130
+ tooling (jest, eslint) moderates.
131
+
132
+ ### Migrations
133
+
134
+ - None required: with no override files, manifests resolve as in 0.1.0.
135
+ - To let agents act through `<cli> mcp`, add `mcp.actions` to the org config.
136
+ - Placement needs no change: without `runs`, services keep working as
137
+ before. A service with an HTTP health URL, no commands, and nothing else
138
+ in its repo that runs here now shows as remote; add `runs: { local: local }`
139
+ if it is really on this machine. To
140
+ mark more envs as production than `prod` and `production`, set
141
+ `production_envs`.
142
+
143
+
7
144
  ## [0.1.0] - 2026-11-04
8
145
 
9
146
  The first Den release: one engine for local development, tests and releases
package/README.md CHANGED
@@ -9,7 +9,7 @@ Den knows nothing about any particular organisation. Everything specific to
9
9
  yours — product name, repos, environments, hosts, secrets backend, test
10
10
  accounts — lives in **your distribution**, not in Den.
11
11
 
12
- > **Status: 0.1.0**, the first release: `npm install @monster-orange/den`. Den is pre-1.0; each release lists the changes a distribution makes under **Migrations** in [CHANGELOG.md](CHANGELOG.md).
12
+ > **Status: 0.2.0**: `npm install @monster-orange/den`. Den is pre-1.0; each release lists the changes a distribution makes under **Migrations** in [CHANGELOG.md](CHANGELOG.md).
13
13
 
14
14
  ## Make it your own
15
15
 
package/bin/den CHANGED
@@ -40,6 +40,13 @@ if [ "${1:-}" = "create" ]; then
40
40
  entry="src/ui/cli/create-main.ts"
41
41
  built="dist/src/ui/cli/create-main.js"
42
42
  fi
43
+ # `mcp` speaks the MCP protocol on stdout: its own entry point, so nothing
44
+ # the full CLI prints can reach the protocol stream.
45
+ if [ "${1:-}" = "mcp" ]; then
46
+ shift
47
+ entry="src/ui/cli/mcp-main.ts"
48
+ built="dist/src/ui/cli/mcp-main.js"
49
+ fi
43
50
 
44
51
  # Escape hatch: force the compiled build (e.g. to test the prod artifact).
45
52
  if [ "${DEN_USE_DIST:-}" = "1" ] && [ -f "$DEN_ENGINE_ROOT/$built" ]; then
@@ -49,18 +49,152 @@ make help
49
49
 
50
50
  ## Layout
51
51
 
52
- | Path | What |
53
- | ------------------------- | ------------------------------------------------------------ |
54
- | `den.yml` | The product profile ([below](#denyml-the-product-profile)) |
55
- | `package.json` | Pins an exact `@monster-orange/den` version |
56
- | `bin/<cli>` | The shim people run ([below](#the-bin-shim)) |
57
- | `Makefile` | Includes Den's targets ([below](#make-denmk)) |
58
- | `config/<name>.yml` | Org config: brand, `github_org`, `secrets`, `edge`, … |
59
- | `config/environments/` | One `<env>.json` per environment (base URLs, auth, timeouts) |
60
- | `config/local/` | Per-engineer overrides (`user.yml`); gitignored |
61
- | `manifests/` | Manifests for repos that do not carry their own |
62
- | `tests/pillars/<pillar>/` | Your own suites, e.g. `tests/pillars/perf/<module>/test.js` |
63
- | `reports/`, `results/` | Test output; gitignored |
52
+ | Path | What |
53
+ | ------------------------- | ------------------------------------------------------------------------------------------------ |
54
+ | `den.yml` | The product profile ([below](#denyml-the-product-profile)) |
55
+ | `package.json` | Pins an exact `@monster-orange/den` version |
56
+ | `bin/<cli>` | The shim people run ([below](#the-bin-shim)) |
57
+ | `Makefile` | Includes Den's targets ([below](#make-denmk)) |
58
+ | `config/<name>.yml` | Org config: brand, `github_org`, `secrets`, `edge`, … |
59
+ | `config/environments/` | One `<env>.json` per environment (base URLs, auth, timeouts) |
60
+ | `config/local/` | Per-engineer overrides (`user.yml`); gitignored |
61
+ | `manifests/` | Manifests for repos that do not carry their own, and overrides ([below](#overriding-a-manifest)) |
62
+ | `tests/pillars/<pillar>/` | Your own suites, e.g. `tests/pillars/perf/<module>/test.js` |
63
+ | `reports/`, `results/` | Test output; gitignored |
64
+
65
+ ## Overriding a manifest
66
+
67
+ A repo's manifest comes from one base: the repo's own file (`.acme.yml`, or
68
+ its `.json` twin) or, when the repo has none, the one the distribution holds
69
+ at `manifests/<repo>.acme.yml`. On top of that base, an optional
70
+ `manifests/<repo>.override.yml` changes what this distribution needs to
71
+ differ, without a commit to the repo. The override file is named with
72
+ `.override` plus the manifest's extension, and `<repo>` is matched exactly as
73
+ for a held manifest: the checkout's `origin` repo name, then the managed-clone
74
+ name, then the folder name.
75
+
76
+ ```yaml
77
+ # manifests/shop.override.yml, merged onto shop's own .acme.yml
78
+ services:
79
+ web:
80
+ commands:
81
+ local:
82
+ up: make up-fast # replaces up; the base's other commands stay
83
+ depends_on: [] # lists replace whole: web no longer waits on api
84
+ urls: null # null deletes the key
85
+ ```
86
+
87
+ Merge rules:
88
+
89
+ - Maps merge by key, recursively.
90
+ - Lists and scalars replace the base value whole.
91
+ - An explicit `null` deletes the key from the result.
92
+
93
+ An override only changes or deletes what the base has. A service or key the
94
+ base lacks is an error, even inside a map whose keys are free-form (a new
95
+ command, a new environment): put new entries in the base. An override with no
96
+ base is an error, never a manifest on its own.
97
+
98
+ `<cli> validate <repo-dir>` checks each layer against the schema on its own,
99
+ naming the file each error is in, then checks the merged result:
100
+
101
+ ```
102
+ ✓ /src/shop/.acme.yml (repo, v1, no issues)
103
+ ✗ /dist/manifests/shop.override.yml (override, v1, 1 error)
104
+ ERROR services.api.commands.local.down unknown key "down": not in the base manifest
105
+ ```
106
+
107
+ The web UI's Services cards show the layers each manifest was built from
108
+ (`manifest: repo + override`), and `/api/services` returns them per service as
109
+ `manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
110
+ held and override lookup at another directory (tests use it).
111
+
112
+ ## Placement
113
+
114
+ Each service says where it runs, per environment: `local` (on this machine,
115
+ started by Den) or `remote` (deployed elsewhere). A remote service still
116
+ shows in the graph, the health row and the applications, marked ☁, but Den
117
+ never starts, stops, scans or execs it. The common case is a hybrid
118
+ application: its frontend runs here and calls the team's dev deployment of
119
+ its backend.
120
+
121
+ ```yaml
122
+ # .acme.yml of shop-core
123
+ services:
124
+ core:
125
+ runs: { local: remote, dev: remote } # per env: local | remote
126
+ target:
127
+ env: dev # one of the org's environments
128
+ account: AWS # a label for where it is deployed
129
+ url: https://api.dev.acme.example/admin/manifest
130
+ expect: 401 # the API answers 401 when it is up (behind a JWT authorizer)
131
+ ```
132
+
133
+ When `runs` says nothing for the env being viewed:
134
+
135
+ - `local` if the service has commands there. Locally that also covers every
136
+ service of a repo that runs here: a compose stack, or a repo where any
137
+ service has `commands.local` (one repo-level `up` starts the others). A
138
+ remote service inside such a repo says `runs: { local: remote }`;
139
+ - `remote` if it has an HTTP health URL there and nothing in its repo runs
140
+ there;
141
+ - otherwise `none`: Den knows no runtime for it, and shows no placement.
142
+
143
+ `target` names where a remote service runs: `env`, an optional `account`
144
+ label, an optional `url`, and `expect`, a status code or a list of codes that
145
+ count as up when Den probes `url` (default: any 2xx). Codes are integers
146
+ 100–599. A service with `runs.local: remote` must have a `target`.
147
+
148
+ **Health.** A remote service is checked by the health declared for its
149
+ `target.env` (`health.dev` above), the same check Den runs when you view that
150
+ env. Only when there is none does Den GET `target.url` and compare the status
151
+ with `expect`. The health row's label names the target, `dev · AWS`, followed
152
+ by the status or response time.
153
+
154
+ **Validation.** `<cli> validate` rejects an env name in `runs`, or a
155
+ `target.env`, that is not one of the org's `environments`, an unknown key in
156
+ `target`, and `expect` codes outside 100–599. Placement is the one thing an
157
+ [override](#overriding-a-manifest) may add to a service its base has, so a
158
+ distribution can place a repo's service remote without a commit to the repo:
159
+
160
+ ```yaml
161
+ # manifests/shop-core.override.yml
162
+ services:
163
+ core:
164
+ runs: { local: remote }
165
+ target: { env: dev, account: AWS, url: https://api.dev.acme.example/health }
166
+ ```
167
+
168
+ **Where it shows.** `/api/services` and `/api/health` rows carry `runs`
169
+ (`local`, `remote` or `none`, for the env asked about) and `target`
170
+ (`{ env, account?, url? }`, or null unless remote). `/api/applications`
171
+ carries each service's placement in `placements` (keyed by address), and per
172
+ application `placement` (`local`, `remote`, `hybrid` or `none`) with a
173
+ `summary` such as `shop-frontend local · shop-core remote (dev)`.
174
+ `<cli> status` and `<cli> services` suffix remote services with
175
+ `☁ remote (dev)`; `<cli> apps` marks hybrid and remote applications and
176
+ repos; `<cli> graph` marks remote nodes (dashed in `--format dot`) and hybrid
177
+ ones (`⇄ hybrid`).
178
+
179
+ **Guards.**
180
+
181
+ - Service exec (`/api/service/exec`, `<cli> up|down|restart`, workspace-wide
182
+ up and down, `<cli> up`'s plan) refuses every command on a remote service:
183
+ `shop/core runs in dev (AWS); nothing to start here`. A `down` stops
184
+ nothing and succeeds, so a workspace down is not failed by it.
185
+ - MCP: `runsLocally()` reads the served `runs`, so agents cannot start, stop
186
+ or restart a remote service.
187
+ - Security: `auto_targets` never adds a remote service, even one with local
188
+ commands. A declared target of a remote service is not scanned in `local`,
189
+ only in other envs.
190
+ - **Production.** A remote service whose `target.env` counts as production is
191
+ never scanned, in any env, and never acted on. Production is an attribute of
192
+ an env in the org config, never a name check:
193
+
194
+ ```yaml
195
+ # config/acme.yml
196
+ production_envs: [prod] # default: [prod, production]
197
+ ```
64
198
 
65
199
  ## den.yml: the product profile
66
200
 
@@ -215,6 +349,115 @@ The service runs the engine's server with the `node` on your `PATH` at install
215
349
  time, from the distribution root; re-run `install` after changing node versions
216
350
  or upgrading Den. Keep the label stable: it is how launchd knows the service.
217
351
 
352
+ ## MCP server
353
+
354
+ `./bin/acme mcp` runs an [MCP](https://modelcontextprotocol.io) server over
355
+ stdio, so an agent (an MCP client such as an editor or a coding agent) can
356
+ read and, where the org allows it, act on the same local environment the web
357
+ UI shows. It is a thin client of the running web server
358
+ (`http://localhost:<web.port>`): agents and the UI share one state and one
359
+ set of guards. It never starts the web server; if it is not running, every
360
+ tool answers with the command that starts it (`./bin/acme web up`). stdout
361
+ carries only the protocol; diagnostics go to stderr.
362
+
363
+ The web server keeps running the Den it started with until it is restarted,
364
+ so after an upgrade it can be older than the MCP server. When a tool's route
365
+ answers 404, the MCP server reads the web server's version from
366
+ `/api/server-info` and, if it predates the route, says so:
367
+ `web server is Den 0.1.0; stacks needs >= 0.2.0: run acme web restart`. Any
368
+ other 404 is reported as it is.
369
+
370
+ Register it with a client as a stdio server, for example:
371
+
372
+ ```json
373
+ {
374
+ "mcpServers": {
375
+ "acme": { "command": "/path/to/acme/bin/acme", "args": ["mcp"] }
376
+ }
377
+ }
378
+ ```
379
+
380
+ `--port <n>` points it at a web server on another port.
381
+
382
+ ### Config
383
+
384
+ Without an `mcp:` block in the org config (`config/<name>.yml`) only the read
385
+ tools are registered. Actions are opt-in, by name:
386
+
387
+ ```yaml
388
+ mcp:
389
+ actions:
390
+ [service_up, service_down, service_restart, workspace_sync, run_tests]
391
+ run_tests:
392
+ envs: [local] # the default; production is refused even if listed
393
+ logs:
394
+ remote: false # the default; true lets the log tools read non-local sources
395
+ ```
396
+
397
+ An unknown action or environment name is an error, so a typo never turns a
398
+ guard off.
399
+
400
+ ### Tools
401
+
402
+ Read tools (always registered):
403
+
404
+ | Tool | Web API | What |
405
+ | ------------------- | ------------------------------------------ | ---------------------------------------------------------- |
406
+ | `status` | `/api/health` | Service health; `url` (HTTP check) or `check` (shell) |
407
+ | `services` | `/api/services` | Services, their mode (managed or dev-managed) and commands |
408
+ | `applications` | `/api/applications` | Org > Application > Repo > Services |
409
+ | `graph` | `/api/graph` | The derived dependency graph and startup order |
410
+ | `workspaces` | `/api/workspaces` | Workspaces, active and focused |
411
+ | `environments` | `/api/environments` | The enabled environment names (never their values) |
412
+ | `logs_search` | `/api/logs/search` | Search services' full log history |
413
+ | `logs_tail` | `/api/service/taillog` | The last lines of a service's log (bounded, no follow) |
414
+ | `tests_available` | `/api/modules`, `/api/test-sources` | A pillar's modules and the repos they come from |
415
+ | `security_latest` | `/api/security/latest` | The newest security scan per mode |
416
+ | `release_status` | `/api/release/runs`, `/api/release/status` | Recent CD runs, and an env's deployed-version table |
417
+ | `manifest_validate` | `/api/manifest/validate` | Validate a manifest by path or content |
418
+ | `stacks` | `/api/stacks` | What `stack ls/ps/health` show: project, routes, health |
419
+ | `edge_status` | `/api/edge` | The edge: up or down, its routes and cert names |
420
+
421
+ Action tools (only those named in `mcp.actions`): `service_up`,
422
+ `service_down`, `service_restart` (through `/api/service/exec`),
423
+ `workspace_sync` (`/api/workspace/sync`) and `run_tests` (`/api/run`). There
424
+ is no deploy tool.
425
+
426
+ ### Guards
427
+
428
+ - **Lifecycle only, local only.** The service tools run a manifest's `up`,
429
+ `down` or `restart` and nothing else, with `env=local`, and only for a
430
+ service run locally: its placement is `local` and it has a local `up`
431
+ command (see [Placement](#placement)).
432
+ - **Sync touches managed clones only.** It never pulls, resets or switches
433
+ branches in a dev-managed checkout (your own). The web server enforces
434
+ this for the UI's Sync too: a managed entry whose path is, holds or sits
435
+ inside a dev-managed checkout is refused, out loud.
436
+ - **Tests:** `run_tests` targets only `mcp.run_tests.envs` (default
437
+ `[local]`) and never production. A security run must first pass the
438
+ security CLI's own plan (`/api/security/plan`, a dry run): every target
439
+ host in `security.owned_hosts` and the mode within the env's ceiling.
440
+ `full` (active attacks) is local only.
441
+ - **Logs** read local sources only, unless `mcp.logs.remote: true`. All text
442
+ the tools return is scrubbed of secret-shaped values: bearer tokens, JWTs,
443
+ AWS keys, credentials in URLs and DSNs, `key=value` secrets, private-key
444
+ blocks.
445
+ - **Never exposed:** sudo passwords, AWS login, opening a terminal or editor,
446
+ workspace-wide exec, any other service command, settings writes, workspace
447
+ create, rename or mode changes, the server log, and anything that returns
448
+ environment values. The MCP server's HTTP client cannot reach any other
449
+ route.
450
+
451
+ ### Audit
452
+
453
+ Every action is recorded with actor `agent`, the MCP client's name and
454
+ version (as the client reports them at initialize: a label, never used to
455
+ authorise anything), and the MCP server's pid and session id. They travel as
456
+ `x-den-*` headers on each web API call. The web server writes each action
457
+ (and each UI one) as a lifecycle line in its log; on the Logs page, the
458
+ `acme/server` stream shows an agent's actions as `[agent]`, ending
459
+ `· by agent <client> <version> (mcp pid <pid>, session <id>)`.
460
+
218
461
  ## Pinning and upgrading
219
462
 
220
463
  `package.json` pins an exact Den version (`create` writes the version that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,8 @@
53
53
  "author": "Monster Orange",
54
54
  "license": "MIT",
55
55
  "dependencies": {
56
- "@cursor/sdk": "1.0.23",
56
+ "@cursor/sdk": "1.0.40",
57
+ "@modelcontextprotocol/sdk": "1.32.1",
57
58
  "@playwright/test": "^1.61.1",
58
59
  "@slack/webhook": "^7.0.2",
59
60
  "ajv": "^8.12.0",
@@ -62,6 +63,7 @@
62
63
  "chalk": "^5.3.0",
63
64
  "cli-table3": "^0.6.3",
64
65
  "dotenv": "^16.4.1",
66
+ "elkjs": "0.12.0",
65
67
  "inquirer": "^9.2.14",
66
68
  "jsonwebtoken": "^9.0.2",
67
69
  "openapi-typescript": "^7.13.0",
@@ -48,6 +48,7 @@ import type { JourneyPersonaRole } from "./spec/schema.js";
48
48
  import { product } from "../../shared/product.js";
49
49
  import { enginePath } from "../../shared/engine.js";
50
50
  import { getDistRoot } from "../../shared/paths.js";
51
+ import { containsValue } from "../../shared/redact.js";
51
52
  // ── Layer 1: captureSecrets ──────────────────────────────────────────────────
52
53
 
53
54
  /** 1Password field label holding the Cursor API key (= env var name). */
@@ -375,11 +376,7 @@ export class TranscriptScrubber {
375
376
 
376
377
  /** Throws SecretLeakError when `text` contains any live secret value. */
377
378
  assertClean(text: string): void {
378
- for (const secret of this.secrets) {
379
- if (text.includes(secret)) {
380
- throw new SecretLeakError();
381
- }
382
- }
379
+ if (containsValue(text, this.secrets)) throw new SecretLeakError();
383
380
  }
384
381
  }
385
382
 
@@ -11,6 +11,10 @@
11
11
  import { discoverRepos } from "../smoke/discover.js";
12
12
  import { effectiveLocalCommands } from "../../shared/stacks/manifest-commands.js";
13
13
  import type { WorkspaceService } from "../../shared/manifest/workspace-manifests.js";
14
+ import {
15
+ servicePlacement,
16
+ type Placement,
17
+ } from "../../shared/manifest/placement.js";
14
18
  import type {
15
19
  DiscoveredRepo,
16
20
  SecurityServiceConfig,
@@ -27,6 +31,12 @@ export interface DiscoveredSecurityTarget {
27
31
  config: SecurityServiceConfig;
28
32
  /** Derived by `security.auto_targets` from a health URL, not declared. */
29
33
  auto?: true;
34
+ /**
35
+ * Where the service runs from this machine (R13). A remote one is never
36
+ * scanned as if it were local, and never at all when its target env counts
37
+ * as production (see run.ts).
38
+ */
39
+ placement?: Placement;
30
40
  }
31
41
 
32
42
  /** Repo-qualified name used by --service and the module picker. */
@@ -61,6 +71,7 @@ export function discoverSecurityTargets(
61
71
  repoPath: repo.repoPath,
62
72
  serviceName,
63
73
  config,
74
+ placement: servicePlacement(repo.manifest, serviceName, "local"),
64
75
  });
65
76
  }
66
77
  }
@@ -73,7 +84,8 @@ export function discoverSecurityTargets(
73
84
  * local commands or compose) with an HTTP health URL, scanned at that URL's
74
85
  * origin in each env that has one. Never a service its manifest marks
75
86
  * `security.skip`, one already declared under `security:`, or one with no
76
- * local runtime (a remote or shell-checked service). Host policy is applied
87
+ * local runtime (a remote or shell-checked service). A service whose
88
+ * placement is remote is excluded explicitly, whatever commands it has. Host policy is applied
77
89
  * later as for any target: every URL must be on the owned-host allowlist.
78
90
  */
79
91
  export function autoSecurityTargets(
@@ -85,6 +97,10 @@ export function autoSecurityTargets(
85
97
  for (const s of services) {
86
98
  const id = securityTargetId({ repoName: s.repo, serviceName: s.service });
87
99
  if (taken.has(id) || s.config?.security?.skip) continue;
100
+ // Explicitly: a service placed remote (R13) is never an automatic
101
+ // target, even when it also has local commands.
102
+ if (servicePlacement(s.manifest, s.service, "local").runs !== "local")
103
+ continue;
88
104
  const runsLocally =
89
105
  Object.keys(effectiveLocalCommands(s.manifest, s.service, s.repo, "auto"))
90
106
  .length > 0;
@@ -35,7 +35,11 @@ import type {
35
35
  TestResult,
36
36
  } from "../../shared/types/index.js";
37
37
  import { getDistRoot } from "../../shared/paths.js";
38
- import { readSecurityAutoTargets } from "../../shared/den-config.js";
38
+ import {
39
+ isProductionEnv,
40
+ readSecurityAutoTargets,
41
+ } from "../../shared/den-config.js";
42
+ import { isProtectedRemote } from "../../shared/manifest/placement.js";
39
43
  import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
40
44
  import {
41
45
  autoSecurityTargets,
@@ -67,6 +71,7 @@ import {
67
71
  } from "./plan.js";
68
72
 
69
73
  import { envKey, product } from "../../shared/product.js";
74
+ import { containsValue, redactValues } from "../../shared/redact.js";
70
75
  // ── Exit codes ───────────────────────────────────────────────────────────────
71
76
 
72
77
  export const EXIT_CLEAN = 0;
@@ -189,6 +194,8 @@ export interface EntrypointDeps {
189
194
  now: () => Date;
190
195
  log: (message: string) => void;
191
196
  logError: (message: string) => void;
197
+ /** Whether the org counts an env as production (default: org config). */
198
+ isProduction?: (env: string) => boolean;
192
199
  }
193
200
 
194
201
  /** Production wiring: real docker, real token minting, real result writer. */
@@ -291,8 +298,8 @@ function redactFile(
291
298
  ): void {
292
299
  if (secret === undefined || secret.length === 0) return;
293
300
  const contents = deps.readFileIfExists(filePath);
294
- if (contents !== undefined && contents.includes(secret)) {
295
- deps.writeFile(filePath, contents.split(secret).join("[REDACTED]"));
301
+ if (contents !== undefined && containsValue(contents, [secret])) {
302
+ deps.writeFile(filePath, redactValues(contents, [secret]));
296
303
  }
297
304
  }
298
305
 
@@ -574,7 +581,28 @@ export async function runSecurityCli(
574
581
  const units: ScanUnit[] = [];
575
582
  try {
576
583
  const suffixes = deps.ownedHosts();
584
+ const isProduction =
585
+ deps.isProduction ?? ((e: string) => isProductionEnv(e));
577
586
  for (const target of targets) {
587
+ // R13: a remote service whose target env counts as production is never
588
+ // scanned; any other remote one is scanned only in its own env, never
589
+ // as if it ran locally.
590
+ const placed = target.placement;
591
+ if (placed?.runs === "remote" && placed.target) {
592
+ const where = placed.target.env;
593
+ if (isProtectedRemote(placed, isProduction)) {
594
+ deps.log(
595
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, which counts as production`,
596
+ );
597
+ continue;
598
+ }
599
+ if (args.env === "local") {
600
+ deps.log(
601
+ `↷ skipping ${securityTargetId(target)}: runs in ${where}, not on this machine`,
602
+ );
603
+ continue;
604
+ }
605
+ }
578
606
  // A target that declares no host for THIS env (e.g. only staging/prod)
579
607
  // is simply not in scope here — skip it, never abort the batch.
580
608
  const inEnv =