@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.
- package/CHANGELOG.md +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- 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.
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
-
*
|
|
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: {
|
|
48
|
+
den: {
|
|
49
|
+
[AGENT_ACTOR_ENV]: `agent:${agentId}`,
|
|
50
|
+
...(actorToken ? { [ACTOR_TOKEN_ENV]: actorToken } : {}),
|
|
51
|
+
},
|
|
45
52
|
scrub: allScrubEnv(),
|
|
46
53
|
});
|
|
47
54
|
}
|