@monster-orange/den 0.4.0 → 0.4.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -0,0 +1,177 @@
1
+ # Den reference
2
+
3
+ The technical reference that used to be the README. It will be split into separate pages (configuration, CLI, manifests, edge, secrets, tests, MCP) as part of DOC-2.
4
+
5
+ ## Make it your own
6
+
7
+ 1. **Create** a distribution named after your product:
8
+
9
+ ```sh
10
+ npx @monster-orange/den create acme # → ./acme
11
+ cd acme && npm install
12
+ ```
13
+
14
+ 2. **Name** it: `den.yml` holds `product: { name: acme }`, so the CLI is
15
+ `./bin/acme`, env vars are `ACME_*`, state lives in `~/.acme`. Pass
16
+ `--cli`, `--env-prefix` or `--title` to `create`, or edit `den.yml`.
17
+ 3. **Brand** it: `brand:` in `config/acme.yml` sets the name, tagline and
18
+ banner people see in the CLI and web UI.
19
+ 4. **Point it at your repos**: set `github_org` in `config/acme.yml`, then
20
+ `./bin/acme register ../shop-api` (or add a manifest per repo under
21
+ `manifests/`).
22
+ 5. **Run** it: `./bin/acme up`, `./bin/acme web`, `make test`.
23
+
24
+ Every field, the bin shim, the make fragment and upgrading are in
25
+ [docs/distributions.md](distributions.md);
26
+ [examples/distribution/](../examples/distribution) is what `create acme` writes.
27
+
28
+ ## Distributions
29
+
30
+ You run Den through a small repo of your own (a _distribution_):
31
+
32
+ ```
33
+ my-dev/
34
+ ├── den.yml # who this distribution is (below)
35
+ ├── package.json # pins an exact @monster-orange/den version
36
+ ├── config/<name>.yml # your org config
37
+ ├── config/environments/ # one <env>.json per environment
38
+ ├── manifests/ # manifests for repos that don't carry their own
39
+ ├── tests/pillars/<pillar>/ # your own suites (e.g. tests/pillars/perf/<module>/test.js)
40
+ ├── bin/<cli> # shim: DEN_ROOT=<this dir> exec node_modules/@monster-orange/den/bin/den
41
+ └── Makefile # include node_modules/@monster-orange/den/make/den.mk
42
+ ```
43
+
44
+ `den.yml` names the product. Every runtime name derives from it:
45
+
46
+ ```yaml
47
+ product:
48
+ name:
49
+ acme # CLI "acme", env vars ACME_*, config/acme.yml,
50
+ # manifests .acme.yml, state ~/.acme,
51
+ # managed clones .acme-services/, workspaces acme-workspaces/
52
+ # Override any of them: cli, env_prefix, config, manifest, state_dir,
53
+ # services_dir, workspaces_dir
54
+ ```
55
+
56
+ Den's own checkout is a distribution named `den`, so in this repo the CLI is
57
+ `./bin/den`, the config is `config/den.yml`, and env vars are `DEN_*`.
58
+
59
+ ## Repos opt in with a manifest
60
+
61
+ A repo joins by carrying a manifest at its root (`.<name>.yml`, e.g.
62
+ `.den.yml`) whose commands delegate to the repo's own targets. Den never
63
+ reaches inside a repo beyond that. When a repo has no manifest, the
64
+ distribution can hold one for it in `manifests/<repo>.<name>.yml`; the repo's
65
+ own manifest always wins.
66
+
67
+ ```yaml
68
+ # demo-api: a self-contained service. Nothing it calls, so it starts alone.
69
+ version: 1
70
+ name: demo-api
71
+ application: shop # the application this repo belongs to (den apps)
72
+ compose:
73
+ files: [docker-compose.yml]
74
+ routes:
75
+ api: api:8080 # https://api.demo-api.<edge domain>
76
+ services:
77
+ demo-api:
78
+ commands:
79
+ local:
80
+ setup: make setup
81
+ preflight: make test
82
+ # OWASP ZAP target for the security pillar (`make test-sec-api env=local`).
83
+ security:
84
+ api:
85
+ hosts:
86
+ local: ["https://api.demo-api.globex.localhost"]
87
+ ```
88
+
89
+ That is `examples/demo/demo-api/.den.yml`; the other demo repos are in
90
+ [`examples/demo/`](../examples/demo/).
91
+
92
+ ## What it does
93
+
94
+ - **Workspaces.** A workspace is a set of repos. Each is either
95
+ _dev-managed_ (your checkout, your branch) or _managed_ (cloned by Den and
96
+ kept on a tracking branch).
97
+ - **Derived dependency graph.** Start and stop order comes from real signals in
98
+ each repo — SDK imports, `*_URL` env vars, IaC references, event rules,
99
+ readiness probes — tuned in config with `graph.aliases`, `graph.edges` and
100
+ an optional shared local-infra repo (`graph.infra`).
101
+ - **Local HTTPS edge.** One local proxy routes `https://<route>.<domain>` to
102
+ each service, with an optional project CA.
103
+ - **Test pillars.** `smoke`, `browser` (synthetic Playwright), `api`,
104
+ `journeys` (agent-driven), `perf` (k6), `security` (OWASP ZAP against hosts
105
+ you own) and `suites` (a repo's own commands).
106
+ - **Releases.** A release view over the repos and deploy workflows your config
107
+ lists.
108
+ - **Agents and sessions.** `sessions start` runs a terminal that outlives the
109
+ web server; `agents start` runs a persona (Den ships a default pack) in a
110
+ workspace's checkout, with an MCP server pinned to that workspace and a
111
+ token of its own. The web UI's Agents tab lists them and opens their
112
+ terminals.
113
+ - **Browser sign-in beyond loopback.** On localhost no route needs a sign-in.
114
+ Through the edge or on a non-loopback bind, every route needs one:
115
+ `<cli> web login` prints a one-time link (`web open` opens it).
116
+ - **Web UI and CLI** over the same state.
117
+
118
+ ## Strategies
119
+
120
+ Concerns that vary by org are strategies selected in config, never hard-wired:
121
+
122
+ | Concern | Built-in choices | Chosen in |
123
+ | ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
124
+ | Secrets backend | `none`, `env`, `doppler`, `1password` | `secrets.provider` in the org config; per engineer in `config/local/user.yml`; per shell `<PREFIX>_SECRETS_PROVIDER` |
125
+ | Test-user tokens | `local-jwt`, `cognito`, `auth0`, `none` | `auth.provider` in `config/environments/<env>.json` |
126
+
127
+ A new secrets backend implements `SecretsProvider`
128
+ (`src/shared/secrets/types.ts`) and registers as a strategy
129
+ (`src/shared/secrets/strategies.ts`); a distribution needs no store at all
130
+ (`none`, see "Secrets" in `docs/distributions.md`). A new identity provider implements a token
131
+ provider in `src/shared/auth/token-providers/`.
132
+
133
+ ## CLI
134
+
135
+ ```bash
136
+ den up [repo] # start the repo's derived dependency closure (-x deps only, -n dry run)
137
+ den down [repo] # stop it cleanly (--with-deps: and what it sits on)
138
+ den restart [svc...] # restart services (-A <app> for a whole application)
139
+ den status # service health for the active workspace
140
+ den apps # Org > Application > Repo > Services
141
+ den graph [repo] # the derived dependency graph
142
+ den logs [repo] # tail a repo's local logs
143
+ den use-local [repo] # use your checkout · use-managed [repo] to switch back
144
+ den workspace list # workspaces (activate · deactivate · use · create · delete)
145
+ den run # run test suites (interactive, or with options)
146
+ den web # the web UI (open · login · sessions · logout · token)
147
+ den sessions list # terminals that outlive the web server (start · attach · kill)
148
+ den agents list -w ws # a workspace's agents (start · stop · resume · personas)
149
+ den doctor # the strategies in use and whether each can work (--probe)
150
+ den --help # everything else
151
+ ```
152
+
153
+ `up`, `down`, `restart` and `setup` take `-w <workspace>`. Without it, a
154
+ service you name is found in the focused workspace first, then across the
155
+ other active workspaces; when it is in several, the error lists the `-w`
156
+ command for each.
157
+
158
+ In a distribution the command is that distribution's `cli` name.
159
+
160
+ ## Developing Den
161
+
162
+ ```bash
163
+ npm ci
164
+ npm run typecheck
165
+ npm run lint
166
+ npm test
167
+ DEN_ORG_NAMES_FILE=<patterns file> node scripts/org-name-gate.mjs
168
+ ```
169
+
170
+ CI runs the same gates. The org-name gate fails the build when any file names
171
+ an organisation, product, host or account; its patterns are kept out of this
172
+ repo (a CI secret) so the list itself is never published. See
173
+ [CONTRIBUTING.md](../CONTRIBUTING.md).
174
+
175
+ ## License
176
+
177
+ [MIT](../LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,6 +62,8 @@
62
62
  "@modelcontextprotocol/sdk": "1.32.1",
63
63
  "@playwright/test": "^1.61.1",
64
64
  "@slack/webhook": "^7.0.2",
65
+ "@xterm/addon-fit": "0.11.0",
66
+ "@xterm/xterm": "6.0.0",
65
67
  "ajv": "^8.12.0",
66
68
  "ajv-formats": "^2.1.1",
67
69
  "auth0": "^4.3.1",
@@ -0,0 +1,275 @@
1
+ /**
2
+ * SEC-1c (SEC-1 spec §5): an agent's actor token.
3
+ *
4
+ * `agents start` (and resume) mints `den_at_<43 b64url>`: 32 random bytes,
5
+ * handed to the agent's session as DEN_ACTOR_TOKEN and to nothing else.
6
+ * The state dir keeps only its sha256 (`actor-tokens.json`, 0600, the dir
7
+ * 0700), with what it proves:
8
+ *
9
+ * { actor: "agent:<id>", workspace, grants, session: { name, id } }
10
+ *
11
+ * - One live token per agent: minting another (a resume, a restart)
12
+ * revokes the old one, so it stops working at once.
13
+ * - Revoked when the agent stops (`agents stop`, the page's Stop), and when
14
+ * its session ends: a token verifies only while the session it was minted
15
+ * for is listed as running in the session host's own record
16
+ * (`sessions/<id>.json`); an ended one's token is removed when presented.
17
+ * - It never derives from web.secret, so it proves nothing about the
18
+ * person. `web logout --all` (the "something leaked" button, SEC-1
19
+ * §6c.2) revokes every token as well as rotating the secret: each record
20
+ * is kept, marked `revokedAt`, so a running agent's next call is told
21
+ * `token_revoked` (restart or resume it) rather than a bare 401. Minting
22
+ * the agent's next token, or its session's end, drops the record.
23
+ *
24
+ * Nothing here prints or logs a token.
25
+ */
26
+ import { createHash, randomBytes } from "crypto";
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "./agent-actor.js";
30
+ import { sessionPaths } from "./sessions/paths.js";
31
+
32
+ export const ACTOR_TOKEN_PREFIX = "den_at_";
33
+ /** The agent's session carries its token here (AG spec §5a-env). */
34
+ export const ACTOR_TOKEN_ENV = "DEN_ACTOR_TOKEN";
35
+ /** A person's token in an env: never forwarded to any session. */
36
+ export const PERSON_TOKEN_ENV = "DEN_PERSON_TOKEN";
37
+ export const ACTOR_TOKENS_FILE = "actor-tokens.json";
38
+
39
+ const TOKEN_RE = /^den_at_[A-Za-z0-9_-]{43}$/;
40
+ /** web-secret.ts's PERSON_TOKEN_PREFIX and WEB_SECRET_FILE (it imports this module). */
41
+ const PERSON_PREFIX = "den_pt_";
42
+ const SECRET_FILE = "web.secret";
43
+
44
+ export interface ActorTokenRecord {
45
+ actor: `agent:${string}`;
46
+ workspace: string;
47
+ /** The agent's grants (agents.<id>.grants) when it was minted. */
48
+ grants: string[];
49
+ /** The session it was minted for: its name, and its id once started. */
50
+ session: { name: string; id?: string };
51
+ /** ISO 8601. */
52
+ createdAt: string;
53
+ /** ISO 8601: revoked by `web logout --all`. A revoked token never verifies. */
54
+ revokedAt?: string;
55
+ }
56
+
57
+ interface StoreFile {
58
+ v: 1;
59
+ /** sha256(token), hex → what it proves. */
60
+ tokens: Record<string, ActorTokenRecord>;
61
+ }
62
+
63
+ export function actorTokensPath(stateDir: string): string {
64
+ return path.join(stateDir, ACTOR_TOKENS_FILE);
65
+ }
66
+
67
+ /** The token's key in the store: sha256, hex. */
68
+ export function actorTokenKey(token: string): string {
69
+ return createHash("sha256").update(token).digest("hex");
70
+ }
71
+
72
+ /** Shaped like an actor token (says nothing about whether it is live). */
73
+ export function isActorTokenShape(v: string | undefined): v is string {
74
+ return typeof v === "string" && TOKEN_RE.test(v);
75
+ }
76
+
77
+ function load(stateDir: string): StoreFile {
78
+ try {
79
+ const raw = JSON.parse(
80
+ fs.readFileSync(actorTokensPath(stateDir), "utf-8"),
81
+ ) as Partial<StoreFile>;
82
+ const tokens =
83
+ raw.tokens && typeof raw.tokens === "object" && !Array.isArray(raw.tokens)
84
+ ? raw.tokens
85
+ : {};
86
+ return { v: 1, tokens };
87
+ } catch {
88
+ return { v: 1, tokens: {} };
89
+ }
90
+ }
91
+
92
+ function save(stateDir: string, data: StoreFile): void {
93
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
94
+ const file = actorTokensPath(stateDir);
95
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
96
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
97
+ fs.chmodSync(tmp, 0o600);
98
+ fs.renameSync(tmp, file);
99
+ }
100
+
101
+ /**
102
+ * A fresh token for agent `agentId`, its hash recorded, every older token
103
+ * of the same agent revoked. The token itself is returned once and never
104
+ * stored.
105
+ */
106
+ export function mintActorToken(
107
+ stateDir: string,
108
+ o: {
109
+ agentId: string;
110
+ workspace: string;
111
+ grants?: readonly string[];
112
+ sessionName: string;
113
+ },
114
+ ): { token: string; key: string } {
115
+ const actor = `agent:${o.agentId}` as const;
116
+ if (!AGENT_ACTOR_RE.test(actor))
117
+ throw new Error(`"${o.agentId}" is not an agent id`);
118
+ if (!WORKSPACE_NAME_RE.test(o.workspace))
119
+ throw new Error(`"${o.workspace}" is not a workspace name`);
120
+ const token = ACTOR_TOKEN_PREFIX + randomBytes(32).toString("base64url");
121
+ const key = actorTokenKey(token);
122
+ const data = load(stateDir);
123
+ for (const [k, r] of Object.entries(data.tokens))
124
+ if (r.actor === actor) delete data.tokens[k];
125
+ data.tokens[key] = {
126
+ actor,
127
+ workspace: o.workspace,
128
+ grants: (o.grants ?? []).filter((g) => WORKSPACE_NAME_RE.test(g)),
129
+ session: { name: o.sessionName },
130
+ createdAt: new Date().toISOString(),
131
+ };
132
+ save(stateDir, data);
133
+ return { token, key };
134
+ }
135
+
136
+ /** Bind a minted token to the session id the host gave its session. */
137
+ export function bindActorSession(
138
+ stateDir: string,
139
+ key: string,
140
+ sessionId: string,
141
+ ): void {
142
+ const data = load(stateDir);
143
+ const rec = data.tokens[key];
144
+ if (!rec) return;
145
+ rec.session = { ...rec.session, id: sessionId };
146
+ save(stateDir, data);
147
+ }
148
+
149
+ /** Revoke one token (by its key). */
150
+ export function revokeActorToken(stateDir: string, key: string): boolean {
151
+ const data = load(stateDir);
152
+ if (!data.tokens[key]) return false;
153
+ delete data.tokens[key];
154
+ save(stateDir, data);
155
+ return true;
156
+ }
157
+
158
+ /** Revoke every token of agent `agentId`; how many there were. */
159
+ export function revokeAgentTokens(stateDir: string, agentId: string): number {
160
+ const data = load(stateDir);
161
+ const actor = `agent:${agentId}`;
162
+ let n = 0;
163
+ for (const [k, r] of Object.entries(data.tokens))
164
+ if (r.actor === actor) {
165
+ delete data.tokens[k];
166
+ n++;
167
+ }
168
+ if (n) save(stateDir, data);
169
+ return n;
170
+ }
171
+
172
+ /**
173
+ * Revoke every agent's token (`web logout --all`, SEC-1 §6c.2): each record
174
+ * is marked revoked, so its agent learns why. How many were live.
175
+ */
176
+ export function revokeAllActorTokens(stateDir: string): number {
177
+ const data = load(stateDir);
178
+ const now = new Date().toISOString();
179
+ let n = 0;
180
+ for (const r of Object.values(data.tokens))
181
+ if (!r.revokedAt) {
182
+ r.revokedAt = now;
183
+ n++;
184
+ }
185
+ if (n) save(stateDir, data);
186
+ return n;
187
+ }
188
+
189
+ /**
190
+ * The agents whose token `web logout --all` revoked and whose record is
191
+ * still kept (their session still runs): the Agents tab marks them, so a
192
+ * person knows to restart or resume them (AG-4a-term). Ids only.
193
+ */
194
+ export function revokedAgentIds(stateDir: string): Set<string> {
195
+ const out = new Set<string>();
196
+ for (const r of Object.values(load(stateDir).tokens))
197
+ if (r.revokedAt && AGENT_ACTOR_RE.test(r.actor))
198
+ out.add(r.actor.slice("agent:".length));
199
+ return out;
200
+ }
201
+
202
+ /** What a presented token was minted as, live or not; null when unknown. */
203
+ export function lookupActorToken(
204
+ stateDir: string,
205
+ token: string,
206
+ ): ActorTokenRecord | null {
207
+ if (!isActorTokenShape(token)) return null;
208
+ return load(stateDir).tokens[actorTokenKey(token)] ?? null;
209
+ }
210
+
211
+ /** Whether the session host lists session `id` as running (its own record). */
212
+ export function sessionRunning(stateDir: string, id: string): boolean {
213
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) return false;
214
+ try {
215
+ const rec = JSON.parse(
216
+ fs.readFileSync(
217
+ path.join(sessionPaths(stateDir).dir, `${id}.json`),
218
+ "utf-8",
219
+ ),
220
+ ) as { exited?: unknown };
221
+ return rec.exited !== true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * What a presented token proves: its record; `"revoked"` when `web logout
229
+ * --all` revoked it and its session still runs (the agent is told to
230
+ * restart or resume); or null: unknown, not yet bound to a session, or its
231
+ * session has ended (then its record is removed here).
232
+ */
233
+ export function checkActorToken(
234
+ stateDir: string,
235
+ token: string,
236
+ ): ActorTokenRecord | "revoked" | null {
237
+ const rec = lookupActorToken(stateDir, token);
238
+ if (!rec?.session.id) return null;
239
+ if (!sessionRunning(stateDir, rec.session.id)) {
240
+ try {
241
+ revokeActorToken(stateDir, actorTokenKey(token));
242
+ } catch {
243
+ /* a read-only state dir: still refused */
244
+ }
245
+ return null;
246
+ }
247
+ return rec.revokedAt ? "revoked" : rec;
248
+ }
249
+
250
+ /** The record a presented token proves, or null (checkActorToken, revoked or not). */
251
+ export function verifyActorToken(
252
+ stateDir: string,
253
+ token: string,
254
+ ): ActorTokenRecord | null {
255
+ const r = checkActorToken(stateDir, token);
256
+ return r === "revoked" ? null : r;
257
+ }
258
+
259
+ /**
260
+ * A person's credential in an env (SEC-1 §5): DEN_PERSON_TOKEN, a value
261
+ * shaped like a person token, or a value that names a web.secret file.
262
+ * No session Den starts ever gets one, and `--keep-env` can't forward one.
263
+ */
264
+ export function isPersonCredentialEnv(
265
+ name: string,
266
+ value: string | undefined,
267
+ ): boolean {
268
+ if (name === PERSON_TOKEN_ENV) return true;
269
+ if (value === undefined) return false;
270
+ const v = value.trim();
271
+ if (v.includes(PERSON_PREFIX)) return true;
272
+ return v
273
+ .split(/[:;,\s]+/)
274
+ .some((part) => path.basename(part.replace(/[/\\]+$/, "")) === SECRET_FILE);
275
+ }
@@ -43,6 +43,11 @@ export interface AgentInfo {
43
43
 
44
44
  export interface AgentActor extends AgentInfo {
45
45
  kind: "agent";
46
+ /**
47
+ * SEC-1c: set when this came from headers the caller set, not from an
48
+ * agent's actor token. The audit keeps it as a claim, never as the actor.
49
+ */
50
+ unverified?: true;
46
51
  }
47
52
 
48
53
  /** null: a person in the web UI (no agent headers). */
@@ -75,7 +80,11 @@ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
75
80
  };
76
81
  }
77
82
 
78
- /** The actor an incoming request names, or null for a person in the UI. */
83
+ /**
84
+ * The actor an incoming request's headers claim, or null. SEC-1c: a claim,
85
+ * never the audit actor; the server's auditActorOf (request-guard.ts)
86
+ * takes the actor from the credential and keeps this as unverified.
87
+ */
79
88
  export function actorFromHeaders(
80
89
  headers: IncomingHttpHeaders | Record<string, string | undefined>,
81
90
  ): Actor {
@@ -107,6 +116,7 @@ export function actorLabel(actor: AgentActor): string {
107
116
  const ws = actor.workspace ? `workspace ${actor.workspace}, ` : "";
108
117
  return (
109
118
  `${who} ${actor.client} ${actor.clientVersion} ` +
110
- `(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
119
+ `(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})` +
120
+ (actor.unverified ? " (unverified)" : "")
111
121
  );
112
122
  }
@@ -9,6 +9,12 @@
9
9
  * workspace; --strict-mcp-config keeps any other MCP server out, so the
10
10
  * agent acts only through Den (§1).
11
11
  * - The role prompt is appended to Claude Code's own system prompt.
12
+ * - Den's state dir is denied to the agent (SEC-1 §6c.1): a settings file
13
+ * (--settings, 0600) whose `permissions.deny` lists Read and Bash rules
14
+ * (any command naming the path) for `<state>/web.secret` and
15
+ * `<state>/sessions/**`. It binds a
16
+ * cooperating agent only: the agent runs as the same OS user, so nothing
17
+ * stops one that ignores its permission config from reading them.
12
18
  * - Each launch picks a fresh conversation id (--session-id); resume
13
19
  * continues it with --resume <id>, in the same cwd, with the same MCP.
14
20
  * - Permission prompts stay in the terminal (approvals: "terminal"):
@@ -98,6 +104,81 @@ function detect(env: NodeJS.ProcessEnv = process.env): RuntimeDetect {
98
104
  return version ? { available: true, version } : { available: true };
99
105
  }
100
106
 
107
+ /**
108
+ * Claude Code `permissions.deny` rules that keep a cooperating agent out of
109
+ * Den's credentials (SEC-1 §6c.1): web.secret and the session host's
110
+ * records.
111
+ *
112
+ * - Read: absolute paths (`//…` in Claude Code's rule syntax).
113
+ * - Bash, in Claude Code's documented wildcard form: `*` matches any run of
114
+ * characters, at any position, and a rule matches a command (each part
115
+ * of a compound one) whole. `Bash(*<path>*)` denies any command that names
116
+ * the path, whatever the program (cat, head, cp, base64) and whether it is
117
+ * quoted ("…" or '…' still contain it). The legacy prefix form,
118
+ * `Bash(cat <path>:*)`, didn't match a bare `cat <path>` on Claude Code
119
+ * 2.1.x (it asked for approval instead of denying: a dogfood finding), so
120
+ * it is kept only for older versions, after the wildcard rules.
121
+ * - The readers in BASH_READERS (cat, head, tail, …) by name as well. This
122
+ * binds a cooperating agent only, and no list names every reader: the
123
+ * boundary is the server's token rules (SEC-1 §5), never these.
124
+ * - The same path as a shell writes it from `home`: `~/…` and `$HOME/…`,
125
+ * `${HOME}/…`, and, when the path has a space, with each space
126
+ * backslash-escaped.
127
+ */
128
+ export function stateDirDenyRules(stateDir: string, home?: string): string[] {
129
+ const abs = path.resolve(stateDir);
130
+ const secret = path.join(abs, "web.secret");
131
+ const sessions = path.join(abs, "sessions");
132
+ const spellings = (p: string): string[] => {
133
+ const out = [p];
134
+ if (home) {
135
+ const h = path.resolve(home);
136
+ if (p.startsWith(h + path.sep)) {
137
+ const rel = p.slice(h.length + 1);
138
+ out.push(`~/${rel}`, `$HOME/${rel}`, "${HOME}/" + rel);
139
+ }
140
+ }
141
+ for (const x of [...out])
142
+ if (x.includes(" ")) out.push(x.replace(/ /g, "\\ "));
143
+ return [...new Set(out)];
144
+ };
145
+ const targets = [
146
+ ...spellings(secret).map((p) => `${p}*`),
147
+ ...spellings(sessions).map((p) => `${p}/*`),
148
+ ];
149
+ return [
150
+ `Read(/${secret})`,
151
+ `Read(/${sessions}/**)`,
152
+ // Any command naming the path, whatever the program.
153
+ ...targets.map((t) => `Bash(*${t})`),
154
+ // The common readers by name too, should a version match a leading
155
+ // wildcard differently. A deny list can't name every reader: the
156
+ // boundary is the server's token rules (SEC-1 §5), not these.
157
+ ...BASH_READERS.flatMap((r) => targets.map((t) => `Bash(${r} *${t})`)),
158
+ `Bash(cat ${secret}:*)`,
159
+ `Bash(cat ${sessions}/:*)`,
160
+ ];
161
+ }
162
+
163
+ /** The readers named in the Bash deny rules (stateDirDenyRules). */
164
+ export const BASH_READERS = [
165
+ "cat",
166
+ "head",
167
+ "tail",
168
+ "less",
169
+ "more",
170
+ "xxd",
171
+ "od",
172
+ "strings",
173
+ "base64",
174
+ "cp",
175
+ ] as const;
176
+
177
+ /** The settings file den-claude passes with --settings. */
178
+ export function settingsPath(input: Pick<LaunchInput, "filesDir">): string {
179
+ return path.join(input.filesDir, "settings.json");
180
+ }
181
+
101
182
  /** The flags every run shares: Den's MCP only, the model, the role prompt. */
102
183
  function common(input: LaunchInput): {
103
184
  argv: string[];
@@ -106,11 +187,13 @@ function common(input: LaunchInput): {
106
187
  } {
107
188
  const file = mcpConfigPath(input);
108
189
  const mode = permissionMode(input);
190
+ const settings = input.stateDir ? settingsPath(input) : null;
109
191
  return {
110
192
  argv: [
111
193
  "--mcp-config",
112
194
  file,
113
195
  "--strict-mcp-config",
196
+ ...(settings ? ["--settings", settings] : []),
114
197
  "--permission-mode",
115
198
  mode,
116
199
  ...(input.model ? ["--model", input.model] : []),
@@ -122,7 +205,22 @@ function common(input: LaunchInput): {
122
205
  input.mcp.name,
123
206
  ),
124
207
  ],
125
- files: { [file]: mcpConfigJson(input.mcp) },
208
+ files: {
209
+ [file]: mcpConfigJson(input.mcp),
210
+ ...(settings && input.stateDir
211
+ ? {
212
+ [settings]: `${JSON.stringify(
213
+ {
214
+ permissions: {
215
+ deny: stateDirDenyRules(input.stateDir, input.env?.HOME),
216
+ },
217
+ },
218
+ null,
219
+ 2,
220
+ )}\n`,
221
+ }
222
+ : {}),
223
+ },
126
224
  warnings:
127
225
  mode === "bypassPermissions"
128
226
  ? [
@@ -51,6 +51,13 @@ export interface LaunchInput {
51
51
  mcp: AgentMcp;
52
52
  /** A directory of this agent's own (0700) for the files a runtime writes. */
53
53
  filesDir: string;
54
+ /**
55
+ * Den's own state dir (web.secret, the session records). A runtime adds
56
+ * deny rules for it to the agent's own permission config (SEC-1 §6c.1):
57
+ * that binds a cooperating agent only, since an agent runs as the same OS
58
+ * user and could read the files itself.
59
+ */
60
+ stateDir?: string;
54
61
  /**
55
62
  * The caller's environment, for finding the runtime's CLI on PATH. The
56
63
  * agent does not get it: it starts from an allowlist (sessions/env.ts),
@@ -5,6 +5,7 @@
5
5
  import { denClaude } from "./den-claude.js";
6
6
  import { generic } from "./generic.js";
7
7
  import { allScrubEnv, registerRuntime, type Runtime } from "./runtime.js";
8
+ import { ACTOR_TOKEN_ENV } from "../actor-token.js";
8
9
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
9
10
 
10
11
  registerRuntime(denClaude);
@@ -27,13 +28,16 @@ export {
27
28
  /**
28
29
  * An agent's session env, as `agents start` builds it and `doctor --probe`
29
30
  * checks it: the allowlist, the runtime's passthrough and launch env, Den's
30
- * marker (DEN_AGENT=agent:<id>), then every runtime's scrubEnv.
31
+ * markers (DEN_AGENT=agent:<id>, and SEC-1c's DEN_ACTOR_TOKEN when one was
32
+ * minted), then every runtime's scrubEnv and any person credential.
31
33
  */
32
34
  export function agentSessionEnv(
33
35
  caller: NodeJS.ProcessEnv,
34
36
  rt: Pick<Runtime, "envPassthrough">,
35
37
  launch: Record<string, string>,
36
38
  agentId: string,
39
+ /** SEC-1c: the agent's actor token (`agents start` mints it; the probe has none). */
40
+ actorToken?: string,
37
41
  ): Record<string, string> {
38
42
  return buildSessionEnv({
39
43
  caller,
@@ -41,7 +45,10 @@ export function agentSessionEnv(
41
45
  launch,
42
46
  // The seam for a persona's or runtime's declared env: none yet.
43
47
  extra: {},
44
- den: { [AGENT_ACTOR_ENV]: `agent:${agentId}` },
48
+ den: {
49
+ [AGENT_ACTOR_ENV]: `agent:${agentId}`,
50
+ ...(actorToken ? { [ACTOR_TOKEN_ENV]: actorToken } : {}),
51
+ },
45
52
  scrub: allScrubEnv(),
46
53
  });
47
54
  }