@monster-orange/den 0.4.0 → 0.4.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 (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -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
  }
@@ -82,6 +82,28 @@ export function scopeView(scope?: ViewScope): ScopeView {
82
82
  };
83
83
  }
84
84
 
85
+ /**
86
+ * WS-6.4: what health covers, whatever is in view: every active workspace,
87
+ * focused first. Scope only filters what is displayed (inViewMarker); it
88
+ * never narrows what is polled, so a workspace outside it still shows its
89
+ * real health on the Workspaces page.
90
+ */
91
+ export function pollView(): ScopeView {
92
+ return scopeView(ALL_ACTIVE);
93
+ }
94
+
95
+ /**
96
+ * Whether a health row is in view: one of the workspaces that define it
97
+ * (`workspaces`, its owners) is in `view`. A row without owners counts as
98
+ * in view.
99
+ */
100
+ export function inViewMarker(
101
+ view: ScopeView,
102
+ ): (owners: readonly string[] | undefined) => boolean {
103
+ const names = new Set(view.workspaces.map((w) => w.name));
104
+ return (owners) => !owners?.length || owners.some((o) => names.has(o));
105
+ }
106
+
85
107
  /** A cache key for what is in view (the scope's workspaces, in order). */
86
108
  export function scopeKey(view: ScopeView): string {
87
109
  return view.workspaces.map((w) => w.name).join(",");
@@ -400,6 +400,70 @@ export function findDaemonByAddress(address: string): DaemonRecord | undefined {
400
400
  return _daemonRegistry.get(address);
401
401
  }
402
402
 
403
+ /**
404
+ * The other spelling of a single-service repo's daemon: "<repo>" and
405
+ * "<repo>/<svc>" name the same process (0.4.1-dev.5, a consumer's dev.4
406
+ * finding: `up <repo>` recorded "<repo>", `restart <repo>/<svc>` found no
407
+ * record, adopted the running process instead of restarting it, and left
408
+ * two records on one process group). Null for a repo with several
409
+ * services: each of its services is its own daemon.
410
+ */
411
+ function singleServiceAlias(
412
+ resolved: Pick<ResolvedService, "address" | "repoName" | "subsystem"> & {
413
+ repoPath: string;
414
+ },
415
+ ): string | null {
416
+ const keys = Object.keys(readManifest(resolved.repoPath)?.services ?? {});
417
+ if (keys.length !== 1 || keys[0] !== resolved.subsystem) return null;
418
+ const full = `${resolved.repoName}/${resolved.subsystem}`;
419
+ if (resolved.address === resolved.repoName) return full;
420
+ if (resolved.address === full) return resolved.repoName;
421
+ return null;
422
+ }
423
+
424
+ /**
425
+ * Bring a single-service repo's record to the address this command uses,
426
+ * so every lookup by `resolved.address` finds it whichever spelling wrote
427
+ * it. A record only under the other spelling moves; a duplicate (two
428
+ * records on one process group, the dev.4 bug's leftover, or one of them
429
+ * dead or without a group) is merged into one, owners and containers
430
+ * unioned. Two records on two different live groups are left alone.
431
+ */
432
+ export function reconcileDaemonAddress(resolved: ResolvedService): void {
433
+ const alias = singleServiceAlias(resolved);
434
+ if (!alias) return;
435
+ loadDaemonsFromFile();
436
+ const other = _daemonRegistry.get(alias);
437
+ if (!other || other.repoPath !== resolved.repoPath) return;
438
+ const own = _daemonRegistry.get(resolved.address);
439
+ if (own) {
440
+ const sameGroup =
441
+ own.pgid === undefined ||
442
+ other.pgid === undefined ||
443
+ own.pgid === other.pgid ||
444
+ isDaemonVerifiablyDead(other);
445
+ if (!sameGroup) return;
446
+ }
447
+ // The record carrying a process group is the one `down` can stop.
448
+ const keep =
449
+ own && (own.pgid !== undefined || other.pgid === undefined) ? own : other;
450
+ const drop = keep === own ? other : own;
451
+ const merged: DaemonRecord = {
452
+ ...keep,
453
+ address: resolved.address,
454
+ workspaces: [
455
+ ...new Set([...(keep.workspaces ?? []), ...(drop?.workspaces ?? [])]),
456
+ ],
457
+ };
458
+ const containers = [
459
+ ...new Set([...(keep.containers ?? []), ...(drop?.containers ?? [])]),
460
+ ];
461
+ if (containers.length > 0) merged.containers = containers;
462
+ _daemonRegistry.delete(alias);
463
+ _daemonRegistry.set(resolved.address, merged);
464
+ saveDaemonsFile(getDaemonsFile());
465
+ }
466
+
403
467
  /** Add a workspace owner to a running daemon (share / attach). */
404
468
  export function attachDaemonOwner(address: string, workspace: string): void {
405
469
  loadDaemonsFromFile();
@@ -1344,6 +1408,12 @@ export interface RunCommandOptions {
1344
1408
  explicit?: boolean;
1345
1409
  /** For `down`: what it found to stop (see DownOutcome). */
1346
1410
  onDownOutcome?: (outcome: DownOutcome) => void;
1411
+ /**
1412
+ * Internal: this `up` is a restart's up step ("up"), or the up step of
1413
+ * the restart a restart ran after adopting a process ("after-adopt").
1414
+ * A restart never ends on an adoption: what it adopts it restarts.
1415
+ */
1416
+ restartStep?: "up" | "after-adopt";
1347
1417
  }
1348
1418
 
1349
1419
  /**
@@ -1506,6 +1576,7 @@ function settleDaemonRecord(
1506
1576
  record: DaemonRecord | undefined;
1507
1577
  outcome: "adopted" | "dropped" | "busy" | null;
1508
1578
  } {
1579
+ reconcileDaemonAddress(resolved);
1509
1580
  const record = findDaemonByAddress(resolved.address);
1510
1581
  if (!record || !isLegacyRecord(record)) return { record, outcome: null };
1511
1582
  const outcome = settleLegacyRecord(record, resolved, workspace, onLine);
@@ -1674,7 +1745,12 @@ function restartAsDownThenUp(
1674
1745
  options.onClose(code);
1675
1746
  return;
1676
1747
  }
1677
- const up = runServiceCommand({ ...options, command: "up" });
1748
+ const up = runServiceCommand({
1749
+ ...options,
1750
+ command: "up",
1751
+ restartStep:
1752
+ options.restartStep === "after-adopt" ? "after-adopt" : "up",
1753
+ });
1678
1754
  tail = up.proc;
1679
1755
  if (stopTail) {
1680
1756
  try {
@@ -2052,6 +2128,9 @@ export function runServiceCommand(
2052
2128
  }
2053
2129
 
2054
2130
  const { repoPath, subsystem } = resolved;
2131
+ // A single-service repo's record answers to "<repo>" and "<repo>/<svc>":
2132
+ // every decision below reads it under this command's spelling.
2133
+ reconcileDaemonAddress(resolved);
2055
2134
 
2056
2135
  const explicit = options.explicit ?? true;
2057
2136
  const reportDown = (outcome: DownOutcome): void =>
@@ -2217,6 +2296,16 @@ export function runServiceCommand(
2217
2296
  `${resolved.address} not started`,
2218
2297
  );
2219
2298
  if (holder === "adopted") {
2299
+ // A restart's up step (its down found nothing recorded): the
2300
+ // adopted process is the old instance, so it is restarted now,
2301
+ // never left running as if restarted (0.4.1-dev.5).
2302
+ if (options.restartStep === "up") {
2303
+ onLine(`restarting the adopted process for ${resolved.address}`);
2304
+ return restartAsDownThenUp(
2305
+ { ...options, restartStep: "after-adopt" },
2306
+ getServiceLogPath(resolved.repoName, resolved.subsystem),
2307
+ );
2308
+ }
2220
2309
  closeAsync(0);
2221
2310
  return dummy();
2222
2311
  }
@@ -2355,7 +2444,10 @@ export function runServiceCommand(
2355
2444
  resolved.repoName,
2356
2445
  workspace,
2357
2446
  );
2358
- let cmdDef = svcCommands[command];
2447
+ // Own properties only (SEC-1 §6b.2): `constructor` is no command.
2448
+ let cmdDef = Object.prototype.hasOwnProperty.call(svcCommands, command)
2449
+ ? svcCommands[command]
2450
+ : undefined;
2359
2451
  const requestedCmdDef = cmdDef;
2360
2452
 
2361
2453
  // `restart` fallback: synthesise `make down && make up` when no explicit
@@ -70,8 +70,16 @@ const expandHome = (p: string): string =>
70
70
  ? path.join(os.homedir(), p.slice(2))
71
71
  : p;
72
72
 
73
- /** A profile from a `product:` block; unset fields derive from the name. */
74
- export function profileFrom(raw: RawProduct = {}): ProductProfile {
73
+ /**
74
+ * A profile from a `product:` block; unset fields derive from the name. A
75
+ * relative `state_dir` resolves against `baseDir`: the directory holding
76
+ * den.yml (the distribution root), never the cwd, so an agent's CLI or MCP
77
+ * server started elsewhere reads the same state.
78
+ */
79
+ export function profileFrom(
80
+ raw: RawProduct = {},
81
+ baseDir: string = process.cwd(),
82
+ ): ProductProfile {
75
83
  const name = str(raw.name) ?? "den";
76
84
  if (!SLUG.test(name))
77
85
  throw new Error(
@@ -91,7 +99,10 @@ export function profileFrom(raw: RawProduct = {}): ProductProfile {
91
99
  envPrefix,
92
100
  configFile: str(raw.config) ?? `config/${name}.yml`,
93
101
  manifestFiles: json === manifest ? [manifest] : [manifest, json],
94
- stateDir: path.resolve(expandHome(str(raw.state_dir) ?? `~/.${name}`)),
102
+ stateDir: path.resolve(
103
+ baseDir,
104
+ expandHome(str(raw.state_dir) ?? `~/.${name}`),
105
+ ),
95
106
  servicesDir: str(raw.services_dir) ?? `.${name}-services`,
96
107
  workspacesDir: str(raw.workspaces_dir) ?? `${name}-workspaces`,
97
108
  };
@@ -106,7 +117,7 @@ export function readProfileFile(file: string): ProductProfile {
106
117
  if ((e as NodeJS.ErrnoException).code === "ENOENT") return profileFrom();
107
118
  throw new Error(`${file}: ${(e as Error).message}`);
108
119
  }
109
- return profileFrom(raw?.product ?? {});
120
+ return profileFrom(raw?.product ?? {}, path.dirname(path.resolve(file)));
110
121
  }
111
122
 
112
123
  let cached: { key: string; profile: ProductProfile } | null = null;
@@ -129,14 +129,21 @@ export class HostClient extends EventEmitter {
129
129
  keptEnv?: { name: string; credentialShaped: boolean }[];
130
130
  /** Who asked, for that line. */
131
131
  actor?: string;
132
+ /**
133
+ * `--keep-env` names Den refuses to forward (SEC-1 §5: a person's
134
+ * credential): the host records the refusal and starts nothing.
135
+ */
136
+ refusedEnv?: string[];
132
137
  } = {},
133
138
  ): Promise<SessionInfo> {
134
139
  const r = await this.request("start", {
135
140
  spec,
136
141
  ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
137
- ...(opts.keptEnv?.length
138
- ? { keptEnv: opts.keptEnv, actor: opts.actor ?? "unknown" }
142
+ ...(opts.keptEnv?.length || opts.refusedEnv?.length
143
+ ? { actor: opts.actor ?? "unknown" }
139
144
  : {}),
145
+ ...(opts.keptEnv?.length ? { keptEnv: opts.keptEnv } : {}),
146
+ ...(opts.refusedEnv?.length ? { refusedEnv: opts.refusedEnv } : {}),
140
147
  });
141
148
  return r.info as SessionInfo;
142
149
  }
@@ -10,14 +10,21 @@
10
10
  * 4. `launch`: the runtime's launch env (LaunchOutput.env)
11
11
  * 5. `extra`: the persona's or runtime's declared env (none can declare
12
12
  * one yet)
13
- * 6. `den`: Den's own markers (DEN_AGENT for an agent)
13
+ * 6. `den`: Den's own markers (DEN_AGENT, and SEC-1c's DEN_ACTOR_TOKEN
14
+ * for an agent: the only way an actor token reaches a session; one
15
+ * from anywhere above is dropped first)
14
16
  * 7. then `scrub` removes every name it matches, whatever put it back:
15
17
  * each runtime's scrubEnv (den-claude: CLAUDECODE, CLAUDE_CODE_*).
18
+ * 8. and always (SEC-1 §5): a person's credential, DEN_PERSON_TOKEN or
19
+ * any value that is a person token or names web.secret
20
+ * (actor-token.ts isPersonCredentialEnv), whatever put it there.
16
21
  *
17
22
  * The session backend knows none of this: it is handed the result. Nothing
18
23
  * here logs or returns a value other than the env itself.
19
24
  */
20
25
 
26
+ import { ACTOR_TOKEN_ENV, isPersonCredentialEnv } from "../actor-token.js";
27
+
21
28
  export const DEFAULT_TERM = "xterm-256color";
22
29
 
23
30
  /** Set in an agent's env: `agent:<id>`, so Den's CLI run inside knows. */
@@ -72,8 +79,11 @@ export function buildSessionEnv(i: SessionEnvInput): Record<string, string> {
72
79
  for (const [k, v] of Object.entries(i.caller))
73
80
  if (v !== undefined && (inSessionBase(k) || named.has(k))) env[k] = v;
74
81
  if (!env.TERM) env.TERM = DEFAULT_TERM;
75
- Object.assign(env, i.launch ?? {}, i.extra ?? {}, i.den ?? {});
82
+ Object.assign(env, i.launch ?? {}, i.extra ?? {});
83
+ delete env[ACTOR_TOKEN_ENV];
84
+ Object.assign(env, i.den ?? {});
76
85
  const scrub = i.scrub ?? [];
77
- for (const k of Object.keys(env)) if (matchesScrub(k, scrub)) delete env[k];
86
+ for (const [k, v] of Object.entries(env))
87
+ if (matchesScrub(k, scrub) || isPersonCredentialEnv(k, v)) delete env[k];
78
88
  return env;
79
89
  }
@@ -126,6 +126,17 @@ function keptEnvAudit(v: unknown): string {
126
126
  .join(", ");
127
127
  }
128
128
 
129
+ /** A start's refused `--keep-env` names, printable: "A, B", or "". */
130
+ function refusedEnvAudit(v: unknown): string {
131
+ if (!Array.isArray(v)) return "";
132
+ return v
133
+ .filter(
134
+ (k): k is string =>
135
+ typeof k === "string" && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k),
136
+ )
137
+ .join(", ");
138
+ }
139
+
129
140
  function printableActor(v: unknown): string {
130
141
  return (
131
142
  String(v ?? "unknown")
@@ -477,6 +488,18 @@ export class SessionHost {
477
488
  const { name, cwd, argv, env } = spec;
478
489
  if (typeof name !== "string" || !name)
479
490
  throw new Refusal("bad-request", "start needs a name");
491
+ // SEC-1 §5: a --keep-env the caller refused (a person's credential) is
492
+ // audited here, names only, and nothing starts.
493
+ const refusedEnv = refusedEnvAudit(req.refusedEnv);
494
+ if (refusedEnv || Array.isArray(req.refusedEnv)) {
495
+ this.log(
496
+ `session ${printableActor(name)} refused: --keep-env ${refusedEnv || "?"} (a person's credential is never forwarded) · by ${printableActor(req.actor)}`,
497
+ );
498
+ throw new Refusal(
499
+ "bad-request",
500
+ `--keep-env ${refusedEnv || "?"}: a person's credential is never forwarded to a session`,
501
+ );
502
+ }
480
503
  if (
481
504
  !Array.isArray(argv) ||
482
505
  !argv.length ||
@@ -0,0 +1,181 @@
1
+ /**
2
+ * SEC-1a: the web server's secret and the person token derived from it.
3
+ *
4
+ * `web.secret` is 32 random bytes in the distribution's state dir, mode
5
+ * 0600 (the dir 0700 when Den creates it). Reading it is the proof that a
6
+ * caller is the OS user, the same proof as the session host's socket. It is
7
+ * never printed, never logged and never served.
8
+ *
9
+ * The person token is HMAC-SHA256(secret, "den-person-v1:" + os user),
10
+ * sent as `Authorization: Bearer den_pt_<b64url>`. The server recomputes it
11
+ * and compares in constant time; its actor is `person:<os user>`.
12
+ *
13
+ * SEC-1b's `web logout --all` rotates the secret (rotateWebSecret).
14
+ * SEC-1c's agent tokens (`den_at_…`) are actor-token.ts's: random, not
15
+ * derived from the secret.
16
+ */
17
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
18
+ import * as fs from "fs";
19
+ import * as os from "os";
20
+ import * as path from "path";
21
+ import { ACTOR_TOKEN_ENV, isActorTokenShape } from "./actor-token.js";
22
+ import { getStateDir } from "./paths.js";
23
+ import { AGENT_ACTOR_ENV } from "./sessions/env.js";
24
+
25
+ export const WEB_SECRET_FILE = "web.secret";
26
+ const SECRET_BYTES = 32;
27
+ export const PERSON_TOKEN_PREFIX = "den_pt_";
28
+ const PERSON_CONTEXT = "den-person-v1:";
29
+
30
+ /** `<state dir>/web.secret`. */
31
+ export function webSecretPath(stateDir: string = getStateDir()): string {
32
+ return path.join(stateDir, WEB_SECRET_FILE);
33
+ }
34
+
35
+ /** The OS user the process runs as (the person-token actor's name). */
36
+ export function osUser(): string {
37
+ try {
38
+ return os.userInfo().username;
39
+ } catch {
40
+ return process.env.USER ?? process.env.LOGNAME ?? "unknown";
41
+ }
42
+ }
43
+
44
+ /** The secret, or null when it is missing, unreadable or malformed. */
45
+ export function readWebSecret(stateDir: string = getStateDir()): Buffer | null {
46
+ try {
47
+ const b = fs.readFileSync(webSecretPath(stateDir));
48
+ return b.length === SECRET_BYTES ? b : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The secret, created when missing: the state dir 0700 when it is made
56
+ * here, the file 0600, written exclusively so two starts never race to two
57
+ * secrets. An existing file looser than 0600 is tightened.
58
+ */
59
+ export function ensureWebSecret(stateDir: string = getStateDir()): Buffer {
60
+ const file = webSecretPath(stateDir);
61
+ const existing = readWebSecret(stateDir);
62
+ if (existing) {
63
+ tighten(file);
64
+ return existing;
65
+ }
66
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
67
+ const fresh = randomBytes(SECRET_BYTES);
68
+ try {
69
+ // A malformed file (wrong length) is replaced; a missing one created.
70
+ if (fs.existsSync(file)) fs.unlinkSync(file);
71
+ fs.writeFileSync(file, fresh, { mode: 0o600, flag: "wx" });
72
+ } catch (e) {
73
+ // Another process created it first: use theirs.
74
+ const won = readWebSecret(stateDir);
75
+ if (won) return won;
76
+ throw e;
77
+ }
78
+ tighten(file);
79
+ return fresh;
80
+ }
81
+
82
+ /**
83
+ * SEC-1b (`web logout --all`): a fresh secret in place of the old one,
84
+ * written to a temp file (0600) and renamed over it, so a reader sees one
85
+ * or the other, never half. Every person token and every browser session
86
+ * derived from the old one stops verifying; the CLI re-reads the file on
87
+ * each call, so it keeps working.
88
+ */
89
+ export function rotateWebSecret(stateDir: string = getStateDir()): void {
90
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
91
+ const file = webSecretPath(stateDir);
92
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
93
+ fs.writeFileSync(tmp, randomBytes(SECRET_BYTES), { mode: 0o600, flag: "wx" });
94
+ fs.renameSync(tmp, file);
95
+ tighten(file);
96
+ }
97
+
98
+ function tighten(file: string): void {
99
+ try {
100
+ if ((fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600);
101
+ } catch {
102
+ /* gone or not ours: the read already decided */
103
+ }
104
+ }
105
+
106
+ /** `den_pt_<b64url(HMAC-SHA256(secret, "den-person-v1:" + user))>`. */
107
+ export function personToken(secret: Buffer, user: string = osUser()): string {
108
+ const mac = createHmac("sha256", secret)
109
+ .update(PERSON_CONTEXT + user)
110
+ .digest("base64url");
111
+ return PERSON_TOKEN_PREFIX + mac;
112
+ }
113
+
114
+ /**
115
+ * The actor a presented person token proves (`person:<user>`), or null.
116
+ * Constant time: both sides are hashed to one length before comparing.
117
+ */
118
+ export function verifyPersonToken(
119
+ presented: string,
120
+ secret: Buffer,
121
+ user: string = osUser(),
122
+ ): `person:${string}` | null {
123
+ const expected = personToken(secret, user);
124
+ const a = createHmac("sha256", "den-cmp").update(presented).digest();
125
+ const b = createHmac("sha256", "den-cmp").update(expected).digest();
126
+ return timingSafeEqual(a, b) ? `person:${user}` : null;
127
+ }
128
+
129
+ /**
130
+ * The headers every CLI and stdio-MCP call to the web server carries:
131
+ * - inside an agent's session (SEC-1c): its DEN_ACTOR_TOKEN, never the
132
+ * person's; an agent (DEN_AGENT set) without one sends no credential
133
+ * rather than fall back to the person token;
134
+ * - otherwise the person token, or none when there is no secret yet (a
135
+ * server that predates SEC-1, or none started).
136
+ */
137
+ export function cliAuthHeaders(
138
+ stateDir: string = getStateDir(),
139
+ env: NodeJS.ProcessEnv = process.env,
140
+ ): Record<string, string> {
141
+ const actorToken = env[ACTOR_TOKEN_ENV];
142
+ if (isActorTokenShape(actorToken))
143
+ return { Authorization: `Bearer ${actorToken}` };
144
+ if (env[AGENT_ACTOR_ENV] !== undefined || actorToken !== undefined) return {};
145
+ const secret = readWebSecret(stateDir);
146
+ return secret ? { Authorization: `Bearer ${personToken(secret)}` } : {};
147
+ }
148
+
149
+ /**
150
+ * The server's view of the secret: read once, re-read when the file changes
151
+ * (SEC-1b's rotation), created at start.
152
+ */
153
+ export class WebSecretStore {
154
+ private cached: { key: string; secret: Buffer } | null = null;
155
+ constructor(private readonly stateDir: () => string = getStateDir) {}
156
+
157
+ /** Create the secret if needed (at server start). */
158
+ ensure(): void {
159
+ const secret = ensureWebSecret(this.stateDir());
160
+ this.cached = { key: this.key() ?? "", secret };
161
+ }
162
+
163
+ /** The current secret, or null when it is missing. */
164
+ current(): Buffer | null {
165
+ const key = this.key();
166
+ if (key === null) return null;
167
+ if (this.cached?.key === key) return this.cached.secret;
168
+ const secret = readWebSecret(this.stateDir());
169
+ this.cached = secret ? { key, secret } : null;
170
+ return secret;
171
+ }
172
+
173
+ private key(): string | null {
174
+ try {
175
+ const s = fs.statSync(webSecretPath(this.stateDir()));
176
+ return `${s.ino}:${s.size}:${s.mtimeMs}`;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ }