boxdown 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/devcontainer/README.md +17 -5
- package/assets/devcontainer/devcontainer.json +3 -8
- package/assets/devcontainer/hooks/initialize.sh +49 -27
- package/assets/devcontainer/hooks/post-create.sh +11 -0
- package/assets/devcontainer/hooks/post-start.sh +0 -10
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +58 -5
- package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
- package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
- package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/{main-Df4E8ARj.cjs → main-BDgyf2t5.cjs} +459 -199
- package/dist/{main-XMBsKjIK.mjs → main-J4_2Up3o.mjs} +461 -201
- package/dist/main-J4_2Up3o.mjs.map +1 -0
- package/dist/main.cjs +1 -1
- package/dist/main.d.cts +6 -0
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +6 -0
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +1 -1
- package/docs/features/commit-signing.md +71 -0
- package/docs/features/generated-config-and-state.md +19 -4
- package/docs/features/github-auth-refresh.md +3 -2
- package/docs/features/lifecycle.md +6 -0
- package/docs/features/start-and-shell.md +5 -0
- package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
- package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +20 -0
- package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
- package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
- package/package.json +1 -1
- package/src/config.ts +14 -2
- package/src/constants.ts +7 -0
- package/src/devcontainer.ts +37 -26
- package/src/doctor.ts +120 -23
- package/src/git-signing.ts +205 -25
- package/src/logging.ts +11 -1
- package/src/main.ts +2 -1
- package/src/paths.ts +21 -1
- package/src/purge.ts +8 -0
- package/src/shell.ts +6 -0
- package/dist/main-XMBsKjIK.mjs.map +0 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Runtime Secret Environment Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Keep Boxdown-provided secrets available in Bash sessions without writing their values to the repository, persistent Boxdown state, Docker environment configuration, or workspace logs.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A private per-workspace runtime directory holds three secret files and is mounted read-only at `/run/boxdown/secrets`. A Bash bootstrap exports values for interactive, non-interactive, SSH, and Boxdown coding-agent Bash sessions. Docker configuration carries only paths; metadata inspection requests only image fields; logging structurally redacts known assignments.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js 24, TypeScript, Bash, `node:test`, Docker/Dev Containers configuration.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- Do not print, log, serialize, or place a secret value in generated configuration, command arguments, metadata, or diagnostics.
|
|
14
|
+
- Fixed Boxdown secret names: `ANTHROPIC_API_KEY`, `SNYK_TOKEN`, and `OP_SERVICE_ACCOUNT_TOKEN`.
|
|
15
|
+
- Missing values and unavailable 1Password access are non-blocking; the affected variable is absent.
|
|
16
|
+
- `.env.development` is project-owned: Boxdown does not create, edit, delete, or migrate it.
|
|
17
|
+
- Runtime secret directories/files use `0700`/`0600` and are outside `workspaceDataDir`.
|
|
18
|
+
- Existing containers require `boxdown start --recreate`; do not add legacy cleanup or `doctor --fix-secrets`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## File Structure
|
|
23
|
+
|
|
24
|
+
| File | Responsibility |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `src/constants.ts`, `src/paths.ts` | Runtime secret paths and fixed secret-name constants. |
|
|
27
|
+
| `src/config.ts`, `assets/devcontainer/devcontainer.json` | Read-only mount and non-secret Bash bootstrap config. |
|
|
28
|
+
| `assets/devcontainer/hooks/initialize.sh` | Atomically create/remove runtime secret files. |
|
|
29
|
+
| `assets/devcontainer/utils/secret-env-bootstrap.sh` | Read fixed files and export present values without output. |
|
|
30
|
+
| `assets/devcontainer/hooks/post-create.sh`, `src/shell.ts` | Source bootstrap for interactive and Boxdown-launched Bash sessions. |
|
|
31
|
+
| `src/main.ts`, `src/purge.ts` | Remove workspace runtime state after down/purge. |
|
|
32
|
+
| `src/devcontainer.ts`, `src/logging.ts` | Narrow Docker inspection and structural redaction. |
|
|
33
|
+
| `src/doctor.ts`, docs, `__tests__/app.test.ts` | Safety checks, documentation, and regression tests. |
|
|
34
|
+
|
|
35
|
+
### Task 1: Define runtime secret state and remove workspace injection
|
|
36
|
+
|
|
37
|
+
**Files:**
|
|
38
|
+
|
|
39
|
+
- Modify: `src/constants.ts`, `src/paths.ts`, `src/config.ts`, `assets/devcontainer/devcontainer.json`, `assets/devcontainer/hooks/initialize.sh`, `assets/devcontainer/hooks/post-start.sh`, `__tests__/app.test.ts`
|
|
40
|
+
|
|
41
|
+
**Interfaces:**
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export const BOXDOWN_SECRET_ENV_NAMES = ['ANTHROPIC_API_KEY', 'SNYK_TOKEN', 'OP_SERVICE_ACCOUNT_TOKEN'] as const
|
|
45
|
+
export const BOXDOWN_CONTAINER_SECRET_ENV_DIR = '/run/boxdown/secrets'
|
|
46
|
+
export const BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP = '/opt/boxdown/devcontainer/utils/secret-env-bootstrap.sh'
|
|
47
|
+
export function defaultRuntimeRoot (env?: NodeJS.ProcessEnv): string
|
|
48
|
+
// WorkspaceContext adds runtimeRoot, workspaceRuntimeDir, workspaceSecretEnvDir.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- [ ] **Step 1: Write failing tests**
|
|
52
|
+
|
|
53
|
+
Create a context with `BOXDOWN_RUNTIME_HOME`. Assert that its secret directory starts below runtime root but not persistent workspace data. Assert generated config mounts that directory read-only at `/run/boxdown/secrets`, sets the non-secret bootstrap `BASH_ENV`, retains `NODE_ENV`, and serializes none of `--env-file`, `.env.development`, or the three secret names.
|
|
54
|
+
|
|
55
|
+
- [ ] **Step 2: Verify red**
|
|
56
|
+
|
|
57
|
+
Run `pnpm test -- --test-name-pattern "runtime secret|generated config"`. Expected: FAIL because the current config injects workspace-file and Docker-environment secrets.
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 3: Implement paths and config**
|
|
60
|
+
|
|
61
|
+
Add the fixed constants. Resolve runtime root in this order: `BOXDOWN_RUNTIME_HOME`; then `join(XDG_RUNTIME_DIR, PACKAGE_NAME)`; then `join(tmpdir(), package-name plus UID)`. Add workspace-ID scoped runtime/secret paths. Append the read-only secret mount and `BASH_ENV`; remove the base `--env-file` pair and secret `containerEnv` values while preserving non-secret entries.
|
|
62
|
+
|
|
63
|
+
- [ ] **Step 4: Implement host runtime-file refresh**
|
|
64
|
+
|
|
65
|
+
Pass only `BOXDOWN_SECRET_ENV_DIR` to `initializeCommand`. Set `umask 077`; create the directory at `0700`; write each nonempty file through `mktemp` plus `chmod 0600` plus atomic rename; remove a file when its host value is absent. Read the existing 1Password reference into a local variable and never echo it. Remove all `.env.development` preparation/upsert logic and post-start deletion.
|
|
66
|
+
|
|
67
|
+
- [ ] **Step 5: Verify green and commit**
|
|
68
|
+
|
|
69
|
+
Run `pnpm test -- --test-name-pattern "runtime secret|generated config|devcontainer git config hooks"` and `bash -n assets/devcontainer/hooks/initialize.sh assets/devcontainer/hooks/post-start.sh`; expect both to pass. Commit the listed sources and tests as `feat: mount runtime secret environment`.
|
|
70
|
+
|
|
71
|
+
### Task 2: Export secret files in supported Bash sessions and clean runtime state
|
|
72
|
+
|
|
73
|
+
**Files:**
|
|
74
|
+
|
|
75
|
+
- Create: `assets/devcontainer/utils/secret-env-bootstrap.sh`
|
|
76
|
+
- Modify: `assets/devcontainer/hooks/post-create.sh`, `src/shell.ts`, `src/main.ts`, `src/purge.ts`, `__tests__/app.test.ts`
|
|
77
|
+
|
|
78
|
+
**Interfaces:**
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Bootstrap reads /run/boxdown/secrets/<fixed-name> and exports only present values.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- [ ] **Step 1: Write failing tests**
|
|
85
|
+
|
|
86
|
+
Run the bootstrap against a temporary secret directory with sentinel values. Assert each present value is exported, an absent file remains unset, and stdout/stderr do not contain a sentinel. Assert interactive shell/agent scripts source the bootstrap before `exec`. Assert down/purge remove only workspace runtime state.
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 2: Verify red**
|
|
89
|
+
|
|
90
|
+
Run `pnpm test -- --test-name-pattern "secret bootstrap|runtime secret cleanup|interactive shell setup"`. Expected: FAIL because no bootstrap or runtime cleanup exists.
|
|
91
|
+
|
|
92
|
+
- [ ] **Step 3: Implement bootstrap/session loading**
|
|
93
|
+
|
|
94
|
+
Create a no-output script that reads the three fixed files with `IFS= read -r`, exports only nonempty values, and never sources secret file contents as code. Add an idempotent source line to the node user's `.bashrc` in post-create. Add the same bootstrap source before final `exec` in both shell script builders; never add a value to `interactiveShellEnvArgs`.
|
|
95
|
+
|
|
96
|
+
- [ ] **Step 4: Implement safe runtime cleanup**
|
|
97
|
+
|
|
98
|
+
Add a path-contained helper that removes only `context.workspaceRuntimeDir`, accepts absence, and never removes `runtimeRoot`. Invoke it after successful down and during purge before persistent state removal.
|
|
99
|
+
|
|
100
|
+
- [ ] **Step 5: Verify green and commit**
|
|
101
|
+
|
|
102
|
+
Run `pnpm test -- --test-name-pattern "secret bootstrap|runtime secret cleanup|interactive shell setup"` and `bash -n assets/devcontainer/utils/secret-env-bootstrap.sh assets/devcontainer/hooks/post-create.sh`; expect pass. Commit as `feat: export runtime secrets in Bash sessions`.
|
|
103
|
+
|
|
104
|
+
### Task 3: Replace full Docker inspection and harden logging
|
|
105
|
+
|
|
106
|
+
**Files:**
|
|
107
|
+
|
|
108
|
+
- Modify: `src/devcontainer.ts`, `src/logging.ts`, `__tests__/app.test.ts`
|
|
109
|
+
|
|
110
|
+
**Interfaces:**
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export function parseDockerInspectImage (output: string, containerId: string): DockerImageInfo | undefined
|
|
114
|
+
export function redactKnownSecretEnvironmentAssignments (value: string): string
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- [ ] **Step 1: Write failing tests**
|
|
118
|
+
|
|
119
|
+
Test parsing two JSON-string lines representing image ID/name. Add a fake Docker complete-inspect fixture containing an OP sentinel, assert the lifecycle log omits it and inspect uses a narrow format. Test plain and Docker-JSON assignment forms for each known secret variable.
|
|
120
|
+
|
|
121
|
+
- [ ] **Step 2: Verify red**
|
|
122
|
+
|
|
123
|
+
Run `pnpm test -- --test-name-pattern "docker inspect|known secret|workspace logger"`. Expected: FAIL because inspect uses `{{json .}}` and logging is value-only.
|
|
124
|
+
|
|
125
|
+
- [ ] **Step 3: Implement projection/redaction**
|
|
126
|
+
|
|
127
|
+
Use Docker format `{{json .Image}}` followed by `{{json .Config.Image}}`; parse exactly two scalar JSON lines and provide a container-ID-specific malformed-output error. Redact values after each fixed name before dynamic redactions and apply it through logger `#redact` for arguments, sections, errors, and child output while preserving the names.
|
|
128
|
+
|
|
129
|
+
- [ ] **Step 4: Verify green and commit**
|
|
130
|
+
|
|
131
|
+
Run `pnpm test -- --test-name-pattern "docker inspect|known secret|workspace logger"`; expect pass. Commit as `fix: keep container secrets out of workspace logs`.
|
|
132
|
+
|
|
133
|
+
### Task 4: Doctor coverage and documentation
|
|
134
|
+
|
|
135
|
+
**Files:**
|
|
136
|
+
|
|
137
|
+
- Modify: `src/doctor.ts`, `__tests__/app.test.ts`, `assets/devcontainer/README.md`, `docs/features/generated-config-and-state.md`, `docs/features/start-and-shell.md`
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 1: Write failing tests**
|
|
140
|
+
|
|
141
|
+
Extend doctor fake-command tests to require a disposable runtime-secret mount probe and a warning when generated config contains env-file, `.env.development`, or a fixed secret name in `containerEnv`. Add existing-style static documentation assertions for session exposure and `--recreate`.
|
|
142
|
+
|
|
143
|
+
- [ ] **Step 2: Verify red**
|
|
144
|
+
|
|
145
|
+
Run `pnpm test -- --test-name-pattern "doctor|generated config|runtime secret"`. Expected: FAIL because doctor does not inspect those conditions.
|
|
146
|
+
|
|
147
|
+
- [ ] **Step 3: Implement non-blocking checks/docs**
|
|
148
|
+
|
|
149
|
+
Add `workspaceSecretEnvDir` to disposable mount probes. Add a `secret-environment-config` doctor result: missing generated config is warning, unsafe config is warning, safe config is ok; never read secret file contents. Update docs: values are ordinary Bash-session variables but not Docker config; `.env.development` is ignored; missing values are non-blocking; existing containers need recreate; legacy leaked values/logs require manual removal and rotation.
|
|
150
|
+
|
|
151
|
+
- [ ] **Step 4: Verify green and commit**
|
|
152
|
+
|
|
153
|
+
Run `pnpm test -- --test-name-pattern "doctor|generated config|runtime secret"`; expect pass. Commit as `docs: describe runtime secret environment handling`.
|
|
154
|
+
|
|
155
|
+
### Task 5: Full verification
|
|
156
|
+
|
|
157
|
+
- [ ] **Step 1: Run the complete suite**
|
|
158
|
+
|
|
159
|
+
Run `pnpm test`, `pnpm lint`, `pnpm build`, `bash -n` for all changed hooks/utilities, `git diff --check`, and `git diff main...HEAD --check`. Expected: every command exits 0.
|
|
160
|
+
|
|
161
|
+
- [ ] **Step 2: Review secret-safety diff**
|
|
162
|
+
|
|
163
|
+
Run `git diff main...HEAD -- assets/devcontainer/devcontainer.json src/config.ts src/devcontainer.ts src/logging.ts`. Confirm no implementation path writes `.env.development`, uses `--env-file`, or contains literal token-like values.
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 3: Commit a verification-only correction when required**
|
|
166
|
+
|
|
167
|
+
If verification requires a source correction, commit only that correction as `test: cover runtime secret environment regression`.
|
|
168
|
+
|
|
169
|
+
## Plan Self-Review
|
|
170
|
+
|
|
171
|
+
- Tasks 1–2 implement runtime-only state and compatible Bash-session exports.
|
|
172
|
+
- Task 3 removes the full-inspect log leak and adds a logging backstop.
|
|
173
|
+
- Task 4 adds doctor/documentation coverage without legacy cleanup.
|
|
174
|
+
- Task 5 runs full test, lint, build, Bash syntax, and diff checks.
|
|
@@ -115,6 +115,12 @@ Boxdown resolves a signing identity in this order:
|
|
|
115
115
|
4. With zero or multiple unresolved identities, produce a disabled signing
|
|
116
116
|
plan and a warning reason. Do not guess.
|
|
117
117
|
|
|
118
|
+
`user.signingKey` accepts an inline SSH public key, Git's `key::` public-key
|
|
119
|
+
form, an absolute path, a `~/` path, or a path relative to the workspace.
|
|
120
|
+
Boxdown reads public-key files only. An explicitly configured key is
|
|
121
|
+
authoritative: unreadable, malformed, or unloaded configured keys disable
|
|
122
|
+
signing and never fall through to GitHub or single-identity selection.
|
|
123
|
+
|
|
118
124
|
Failure to execute `ssh-add`, query GitHub, parse a public key, or resolve a
|
|
119
125
|
single identity is non-fatal.
|
|
120
126
|
|
|
@@ -321,7 +327,10 @@ testable. At minimum, reasons distinguish:
|
|
|
321
327
|
- host agent unavailable;
|
|
322
328
|
- no loaded identities;
|
|
323
329
|
- ambiguous loaded identities;
|
|
330
|
+
- configured host SSH signing key unreadable or malformed;
|
|
324
331
|
- configured host SSH signing key not loaded;
|
|
332
|
+
- host agent socket unavailable;
|
|
333
|
+
- no local Docker image available for the agent mount probe;
|
|
325
334
|
- Docker agent bridge unavailable;
|
|
326
335
|
- public-key snapshot failure;
|
|
327
336
|
- selected identity missing in the container;
|
|
@@ -333,6 +342,17 @@ Human-readable warnings state that commits will remain unsigned but are not
|
|
|
333
342
|
blocked. A missing GitHub registration warning instead states that commits are
|
|
334
343
|
signed locally but will not appear Verified on GitHub.
|
|
335
344
|
|
|
345
|
+
User-facing lifecycle commands print the concise warning. The workspace
|
|
346
|
+
command log additionally records the stable reason code and sanitized detail,
|
|
347
|
+
without public-key material or secrets. Internal SSH proxy startup remains
|
|
348
|
+
log-only. Generated configuration passes the stable host reason into container
|
|
349
|
+
bootstrap so later lifecycle warnings preserve the original diagnosis.
|
|
350
|
+
|
|
351
|
+
Docker Desktop may expose the mounted host-agent socket only to root. Boxdown
|
|
352
|
+
therefore relays it through a root-owned Unix-socket proxy to a node-owned
|
|
353
|
+
socket. Explicit user Git-signing preferences remain authoritative; Boxdown
|
|
354
|
+
uses SSH signing only as the default when no preference exists.
|
|
355
|
+
|
|
336
356
|
## Testing Strategy
|
|
337
357
|
|
|
338
358
|
### Unit tests
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Node SSH-Agent Proxy for Commit Signing
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Make Boxdown's default SSH commit signing usable by the container's `node`
|
|
6
|
+
user when Docker Desktop mounts the host agent socket as `root:root` with mode
|
|
7
|
+
`0660`. Signing must remain best-effort and never expose a private key.
|
|
8
|
+
|
|
9
|
+
## Root Cause
|
|
10
|
+
|
|
11
|
+
The host preflight and the raw socket mount both succeed, but lifecycle hooks
|
|
12
|
+
run as `node`. In the reproduced environment, `ssh-add -L` succeeds as root
|
|
13
|
+
and fails as `node` with `Permission denied`. The signing bootstrap therefore
|
|
14
|
+
sets `commit.gpgsign=false` and reports `container-agent-unavailable`.
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
Boxdown continues to bind-mount the host-facing agent socket at
|
|
19
|
+
`/run/boxdown/ssh-agent.sock`. A small Node Unix-socket proxy runs as root and
|
|
20
|
+
forwards each connection from that raw socket to
|
|
21
|
+
`/run/boxdown/ssh-agent-node.sock`. The proxy creates the latter socket with
|
|
22
|
+
ownership and permissions suitable for `node`. Generated configuration sets
|
|
23
|
+
`SSH_AUTH_SOCK` to the node-facing socket and exposes the raw path only through
|
|
24
|
+
`BOXDOWN_GIT_SIGNING_SOURCE_SOCKET` for the proxy.
|
|
25
|
+
|
|
26
|
+
The post-create hook starts the proxy with passwordless `sudo`, waits briefly
|
|
27
|
+
for its socket, verifies `ssh-add -L` as `node`, then invokes the existing
|
|
28
|
+
signing bootstrap. A proxy failure is non-blocking and has its own stable
|
|
29
|
+
reason code.
|
|
30
|
+
|
|
31
|
+
## Git Configuration Precedence
|
|
32
|
+
|
|
33
|
+
**Decision:** Boxdown does not make its SSH configuration authoritative over
|
|
34
|
+
explicit user Git signing preferences. This is intentional because the
|
|
35
|
+
container begins from a writable copy of the user's Git configuration.
|
|
36
|
+
|
|
37
|
+
- With no explicit global or repository-local signing preference, Boxdown
|
|
38
|
+
configures SSH signing as the default.
|
|
39
|
+
- With an explicit SSH preference, Boxdown preserves the selected identity;
|
|
40
|
+
it only translates an inaccessible host public-key path to the mounted
|
|
41
|
+
public-key snapshot in the container.
|
|
42
|
+
- With `commit.gpgsign=false`, a non-SSH `gpg.format`, or an explicit
|
|
43
|
+
`gpg.program`, Boxdown preserves that preference and does not replace it.
|
|
44
|
+
|
|
45
|
+
Repository-local settings retain Git's normal precedence. In particular, a
|
|
46
|
+
local `commit.gpgsign=false` keeps commits unsigned even when agent forwarding
|
|
47
|
+
is healthy. A future product decision may deliberately reverse this policy and
|
|
48
|
+
make Boxdown authoritative; that would be a behavior change, not a bug fix.
|
|
49
|
+
|
|
50
|
+
## Failure Handling
|
|
51
|
+
|
|
52
|
+
The proxy never reads, copies, or logs private-key material. It reports
|
|
53
|
+
`container-agent-proxy-unavailable` when `sudo`, Node, proxy readiness, or the
|
|
54
|
+
node-user agent probe fails. The signing bootstrap remains non-blocking and
|
|
55
|
+
preserves explicit user configuration rather than setting a contradictory
|
|
56
|
+
global value.
|
|
57
|
+
|
|
58
|
+
## Validation
|
|
59
|
+
|
|
60
|
+
Tests cover forwarding a real Unix-socket request through the proxy, a proxy
|
|
61
|
+
startup failure, generated socket environment values, successful default SSH
|
|
62
|
+
signing, and preservation of global and local user preferences. Documentation
|
|
63
|
+
will require `--recreate` after upgrade.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Runtime Secret Environment Design
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Keep Boxdown-provided secrets available as ordinary environment variables in
|
|
6
|
+
container user sessions without placing their values in Docker container
|
|
7
|
+
configuration, the workspace, or Boxdown's persistent state and logs.
|
|
8
|
+
|
|
9
|
+
This design covers `ANTHROPIC_API_KEY`, `SNYK_TOKEN`, and
|
|
10
|
+
`OP_SERVICE_ACCOUNT_TOKEN`. It also prevents the Docker inspection command
|
|
11
|
+
used for image metadata from logging the complete container configuration.
|
|
12
|
+
|
|
13
|
+
## Non-goals
|
|
14
|
+
|
|
15
|
+
- Migrating, deleting, or rewriting legacy `.env.development` files.
|
|
16
|
+
- Adding a `boxdown doctor --fix-secrets` command.
|
|
17
|
+
- Preventing a user who can enter the container from reading secrets that are
|
|
18
|
+
deliberately available to that user's session.
|
|
19
|
+
- Changing GitHub CLI authentication refresh, which already passes its host
|
|
20
|
+
token over stdin and redacts it from the workspace log.
|
|
21
|
+
|
|
22
|
+
## Security Model
|
|
23
|
+
|
|
24
|
+
The user has requested compatibility with the current developer experience:
|
|
25
|
+
the three values above remain ordinary environment variables in Bash-based
|
|
26
|
+
container sessions. This intentionally permits session processes to read them.
|
|
27
|
+
|
|
28
|
+
The protection boundary is against accidental exposure outside that session:
|
|
29
|
+
|
|
30
|
+
- Docker inspection must not contain secret values.
|
|
31
|
+
- The repository and its `.env.development` file must not be created,
|
|
32
|
+
modified, or removed by Boxdown secret handling.
|
|
33
|
+
- Boxdown's persistent workspace data and `boxdown.log` must not contain
|
|
34
|
+
secret values.
|
|
35
|
+
- Secret files are short-lived, host-private runtime state and are mounted
|
|
36
|
+
read-only into the container.
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
### Host runtime secret state
|
|
41
|
+
|
|
42
|
+
`WorkspaceContext` gains a per-workspace runtime-secret directory under a
|
|
43
|
+
user-private runtime root. The root uses `XDG_RUNTIME_DIR` when available and
|
|
44
|
+
a user-private directory under the system temporary directory otherwise. It
|
|
45
|
+
is distinct from `workspaceDataDir`, which is persistent and contains logs and
|
|
46
|
+
metadata.
|
|
47
|
+
|
|
48
|
+
The directory and each secret file are created with owner-only permissions
|
|
49
|
+
(`0700` directory, `0600` files). The three files use fixed names matching
|
|
50
|
+
their environment variables. No secret value appears in generated
|
|
51
|
+
configuration, command arguments, progress output, metadata, or diagnostics.
|
|
52
|
+
|
|
53
|
+
The host-side `initializeCommand` refreshes this directory before each
|
|
54
|
+
Dev Container startup:
|
|
55
|
+
|
|
56
|
+
1. It creates the runtime directory safely.
|
|
57
|
+
2. It writes `ANTHROPIC_API_KEY` and `SNYK_TOKEN` only when each corresponding
|
|
58
|
+
host environment value is non-empty.
|
|
59
|
+
3. It retrieves `OP_SERVICE_ACCOUNT_TOKEN` from the existing 1Password item
|
|
60
|
+
only when `op` is available and can read it.
|
|
61
|
+
4. It atomically replaces files for available values and removes files for
|
|
62
|
+
unavailable values, preventing a stale token from a previous start.
|
|
63
|
+
|
|
64
|
+
Failures to retrieve 1Password or an absent host variable are informational
|
|
65
|
+
and non-blocking. The affected variable is absent from the next container
|
|
66
|
+
session.
|
|
67
|
+
|
|
68
|
+
### Generated Dev Container configuration
|
|
69
|
+
|
|
70
|
+
The base `runArgs` no longer includes:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
--env-file ${localWorkspaceFolder}/.env.development
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The base `containerEnv` no longer includes secret values via `localEnv`.
|
|
77
|
+
`NODE_ENV` remains ordinary non-secret configuration.
|
|
78
|
+
|
|
79
|
+
Boxdown's generated configuration adds a read-only bind mount from the host
|
|
80
|
+
runtime-secret directory to `/run/boxdown/secrets`. It may contain non-secret
|
|
81
|
+
bootstrap configuration such as `BASH_ENV` and the mounted directory path,
|
|
82
|
+
but never a secret value.
|
|
83
|
+
|
|
84
|
+
Because mounts are create-time settings, `boxdown start --recreate` is needed
|
|
85
|
+
for an already-created container to receive the new mount. New containers get
|
|
86
|
+
it automatically through every Boxdown lifecycle path (`setup`, `start`,
|
|
87
|
+
`codex`, and the other coding-agent commands).
|
|
88
|
+
|
|
89
|
+
### Container session bootstrap
|
|
90
|
+
|
|
91
|
+
A mounted Boxdown bootstrap script reads only the fixed secret files, then
|
|
92
|
+
exports values that are present. It never echoes values or shell source lines.
|
|
93
|
+
It handles missing or unreadable files by leaving the corresponding variable
|
|
94
|
+
unset.
|
|
95
|
+
|
|
96
|
+
The generated configuration sets the non-secret `BASH_ENV` path so
|
|
97
|
+
non-interactive Bash processes source the bootstrap. The container setup also
|
|
98
|
+
installs the bootstrap in the `node` user's Bash startup path for interactive
|
|
99
|
+
shells and SSH sessions. Boxdown's direct shell and coding-agent launch
|
|
100
|
+
scripts source it explicitly before `exec` as an additional deterministic
|
|
101
|
+
path.
|
|
102
|
+
|
|
103
|
+
Consequently, an ordinary Bash session can use, for example,
|
|
104
|
+
`$ANTHROPIC_API_KEY`, `$SNYK_TOKEN`, and `$OP_SERVICE_ACCOUNT_TOKEN` as it can
|
|
105
|
+
today. Docker's configured environment contains only the bootstrap path and
|
|
106
|
+
other non-secret values, so `docker inspect` cannot reveal secret contents.
|
|
107
|
+
|
|
108
|
+
### Workspace file handling
|
|
109
|
+
|
|
110
|
+
The initialization hook stops creating or editing `.env.development`. The
|
|
111
|
+
post-start hook stops deleting it. Boxdown therefore treats that file as
|
|
112
|
+
project-owned in all cases.
|
|
113
|
+
|
|
114
|
+
This release deliberately does not modify legacy files. A pre-existing
|
|
115
|
+
`OP_SERVICE_ACCOUNT_TOKEN` value remains the user's responsibility to remove
|
|
116
|
+
or rotate. Documentation will state that Boxdown no longer reads the file and
|
|
117
|
+
that users should remove a legacy accidental entry manually.
|
|
118
|
+
|
|
119
|
+
### Logging and Docker inspection
|
|
120
|
+
|
|
121
|
+
Boxdown currently runs `docker inspect --format '{{json .}}'` only to record
|
|
122
|
+
the image ID and image name. The full payload includes `.Config.Env`, and the
|
|
123
|
+
command logger writes all child output even when it is not mirrored to the
|
|
124
|
+
terminal.
|
|
125
|
+
|
|
126
|
+
The implementation replaces that command with a narrow template that returns
|
|
127
|
+
only the image ID and configured image name. Its parser accepts that narrow,
|
|
128
|
+
non-sensitive output; it never parses a complete inspect object.
|
|
129
|
+
|
|
130
|
+
The workspace logger also gains structural redaction for assignments of the
|
|
131
|
+
three Boxdown secret variable names. This is a defense-in-depth backstop for
|
|
132
|
+
legacy logs or future accidental command output, not the primary protection.
|
|
133
|
+
Existing value-based redaction remains for dynamically acquired GitHub tokens.
|
|
134
|
+
|
|
135
|
+
## Lifecycle and Cleanup
|
|
136
|
+
|
|
137
|
+
- `initializeCommand` is the only writer of runtime secret files.
|
|
138
|
+
- `down` and `purge` remove the runtime-secret directory after stopping or
|
|
139
|
+
removing the associated container.
|
|
140
|
+
- A new start refreshes values atomically, so no previous value remains when a
|
|
141
|
+
host value is absent or 1Password lookup fails.
|
|
142
|
+
- Runtime secret state is never written below `workspaceDataDir` and is never
|
|
143
|
+
included in status output.
|
|
144
|
+
- Existing workspaces require `boxdown start --recreate` to replace the old
|
|
145
|
+
Docker environment configuration. Boxdown will not automatically migrate
|
|
146
|
+
existing containers or `.env.development` files.
|
|
147
|
+
|
|
148
|
+
## Diagnostics and Documentation
|
|
149
|
+
|
|
150
|
+
Startup output may state whether the optional 1Password token was available,
|
|
151
|
+
but must not print values, file contents, or paths that encode secret data.
|
|
152
|
+
`boxdown doctor` verifies that the runtime-secret mount is constructible and
|
|
153
|
+
that the generated configuration contains neither the legacy `--env-file`
|
|
154
|
+
argument nor secret `containerEnv` entries. Missing optional values are
|
|
155
|
+
warnings, not failures.
|
|
156
|
+
|
|
157
|
+
Documentation will explain:
|
|
158
|
+
|
|
159
|
+
- which host values Boxdown forwards;
|
|
160
|
+
- that they are exposed to Bash sessions but not Docker configuration;
|
|
161
|
+
- that `.env.development` is project-owned and ignored by Boxdown;
|
|
162
|
+
- that `--recreate` is required for existing containers; and
|
|
163
|
+
- how to manually remove and rotate a legacy 1Password token if it was
|
|
164
|
+
previously written to a workspace or log.
|
|
165
|
+
|
|
166
|
+
## Test Strategy
|
|
167
|
+
|
|
168
|
+
Tests will cover:
|
|
169
|
+
|
|
170
|
+
- host secret-state creation, owner-only modes, atomic refresh, and removal of
|
|
171
|
+
stale files without printing values;
|
|
172
|
+
- success, missing host values, missing `op`, and failed 1Password lookup as
|
|
173
|
+
non-blocking cases;
|
|
174
|
+
- generated configuration contains the read-only runtime mount and contains no
|
|
175
|
+
`--env-file`, secret `containerEnv` keys, or secret values;
|
|
176
|
+
- `.env.development` is neither created nor altered by initialization or
|
|
177
|
+
post-start hooks;
|
|
178
|
+
- interactive, non-interactive, SSH, and Boxdown coding-agent Bash launch
|
|
179
|
+
paths export available values and leave unavailable values unset;
|
|
180
|
+
- image inspection uses only a narrow projection and workspace logs never
|
|
181
|
+
receive a synthetic inspect environment value;
|
|
182
|
+
- logger structural redaction for all three known keys;
|
|
183
|
+
- doctor warnings and mount probes, with optional-secret absence non-fatal;
|
|
184
|
+
- focused tests first, then the full test suite, lint, build, Bash syntax
|
|
185
|
+
checks, and `git diff --check`.
|
|
186
|
+
|
|
187
|
+
## Decision Record
|
|
188
|
+
|
|
189
|
+
The chosen design favors compatibility over strict command-level least
|
|
190
|
+
privilege. The three values are intentionally ordinary environment variables
|
|
191
|
+
inside Bash sessions. If this causes unacceptable in-container exposure or
|
|
192
|
+
developer-experience problems, a future design may replace session bootstrap
|
|
193
|
+
with per-command wrappers. That change would be a deliberate compatibility
|
|
194
|
+
break and is not part of this work.
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
BOXDOWN_CONTAINER_DEVCONTAINER_DIR,
|
|
9
9
|
BOXDOWN_CONTAINER_GITCONFIG_PATH,
|
|
10
10
|
BOXDOWN_CONTAINER_HOST_GITCONFIG_DIR,
|
|
11
|
+
BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP,
|
|
12
|
+
BOXDOWN_CONTAINER_SECRET_ENV_DIR,
|
|
11
13
|
BOXDOWN_CONTAINER_SSH_DIR,
|
|
12
14
|
BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH
|
|
13
15
|
} from './constants.ts'
|
|
@@ -67,7 +69,8 @@ export function buildGeneratedDevcontainerConfig (context: WorkspaceContext, sig
|
|
|
67
69
|
const boxdownMounts = [
|
|
68
70
|
`type=bind,source=${context.assetsDevcontainerDir},target=${BOXDOWN_CONTAINER_DEVCONTAINER_DIR},readonly`,
|
|
69
71
|
`type=bind,source=${context.sshPublicKeyRuntimeDir},target=${BOXDOWN_CONTAINER_SSH_DIR},readonly`,
|
|
70
|
-
`type=bind,source=${context.hostGitconfigSnapshotDir},target=${BOXDOWN_CONTAINER_HOST_GITCONFIG_DIR},readonly
|
|
72
|
+
`type=bind,source=${context.hostGitconfigSnapshotDir},target=${BOXDOWN_CONTAINER_HOST_GITCONFIG_DIR},readonly`,
|
|
73
|
+
`type=bind,source=${context.workspaceSecretEnvDir},target=${BOXDOWN_CONTAINER_SECRET_ENV_DIR},readonly`
|
|
71
74
|
]
|
|
72
75
|
|
|
73
76
|
if (
|
|
@@ -98,6 +101,7 @@ export function buildGeneratedDevcontainerConfig (context: WorkspaceContext, sig
|
|
|
98
101
|
`BOXDOWN_WORKSPACE_FOLDER=${shellQuote(context.workspaceFolder)}`,
|
|
99
102
|
`BOXDOWN_HOST_GITCONFIG_PATH=${shellQuote(context.hostGitconfigPath)}`,
|
|
100
103
|
`BOXDOWN_HOST_GITCONFIG_SNAPSHOT_PATH=${shellQuote(context.hostGitconfigSnapshotPath)}`,
|
|
104
|
+
`BOXDOWN_SECRET_ENV_DIR=${shellQuote(context.workspaceSecretEnvDir)}`,
|
|
101
105
|
`BOXDOWN_PROGRESS=${shellQuote('${localEnv:BOXDOWN_PROGRESS}')}`,
|
|
102
106
|
`BOXDOWN_VERBOSE=${shellQuote('${localEnv:BOXDOWN_VERBOSE}')}`,
|
|
103
107
|
'bash',
|
|
@@ -119,10 +123,18 @@ export function buildGeneratedDevcontainerConfig (context: WorkspaceContext, sig
|
|
|
119
123
|
...(baseConfig.containerEnv ?? {}),
|
|
120
124
|
BOXDOWN_CONTAINER_WORKSPACE_FOLDER: '/workspaces/${localWorkspaceFolderBasename}',
|
|
121
125
|
BOXDOWN_WORKSPACE_BASENAME: '${localWorkspaceFolderBasename}',
|
|
126
|
+
BOXDOWN_SECRET_ENV_DIR: BOXDOWN_CONTAINER_SECRET_ENV_DIR,
|
|
127
|
+
BASH_ENV: BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP,
|
|
122
128
|
DEVCONTAINER_SSH_PUBLIC_KEY_FILE: BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH,
|
|
123
129
|
BOXDOWN_GIT_SIGNING_ENABLED: signing?.enabled === true ? '1' : '0',
|
|
124
130
|
BOXDOWN_GIT_SIGNING_KEY_PATH: '/opt/boxdown/state/git-signing/signing-key.pub',
|
|
125
|
-
...(signing?.enabled ===
|
|
131
|
+
...(signing?.enabled === false && signing.reason !== undefined ? { BOXDOWN_GIT_SIGNING_REASON: signing.reason } : {}),
|
|
132
|
+
...(signing?.enabled === true
|
|
133
|
+
? {
|
|
134
|
+
BOXDOWN_GIT_SIGNING_SOURCE_SOCKET: '/run/boxdown/ssh-agent.sock',
|
|
135
|
+
SSH_AUTH_SOCK: '/run/boxdown/ssh-agent-node.sock'
|
|
136
|
+
}
|
|
137
|
+
: {})
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
}
|
package/src/constants.ts
CHANGED
|
@@ -10,5 +10,12 @@ export const BOXDOWN_CONTAINER_AGENTS_DIR = '/home/node/.agents'
|
|
|
10
10
|
export const BOXDOWN_CONTAINER_GITCONFIG_PATH = '/home/node/.gitconfig'
|
|
11
11
|
export const BOXDOWN_CONTAINER_CODEX_DIR = '/home/node/.codex'
|
|
12
12
|
export const BOXDOWN_CONTAINER_CODEX_AUTH_PATH = `${BOXDOWN_CONTAINER_CODEX_DIR}/auth.json`
|
|
13
|
+
export const BOXDOWN_CONTAINER_SECRET_ENV_DIR = '/run/boxdown/secrets'
|
|
14
|
+
export const BOXDOWN_CONTAINER_SECRET_ENV_BOOTSTRAP = `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/secret-env-bootstrap.sh`
|
|
15
|
+
export const BOXDOWN_SECRET_ENV_NAMES = [
|
|
16
|
+
'ANTHROPIC_API_KEY',
|
|
17
|
+
'SNYK_TOKEN',
|
|
18
|
+
'OP_SERVICE_ACCOUNT_TOKEN'
|
|
19
|
+
] as const
|
|
13
20
|
|
|
14
21
|
export const PACKAGE_NAME = 'boxdown'
|
package/src/devcontainer.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { buildGeneratedDevcontainerConfig, publishContainerPortFromConfig, write
|
|
|
5
5
|
import { codingAgentBinary, type CodingAgentCli } from './coding-agents.ts'
|
|
6
6
|
import { resolveDevcontainerCli } from './devcontainer-cli.ts'
|
|
7
7
|
import { configureWorkspaceGithubGitAuth } from './github-git-auth.ts'
|
|
8
|
-
import { resolveGitSigningPlan } from './git-signing.ts'
|
|
8
|
+
import { reportGitSigningPlan, resolveGitSigningPlan } from './git-signing.ts'
|
|
9
9
|
import type { WorkspaceCommandLogger } from './logging.ts'
|
|
10
10
|
import { recordWorkspaceDockerImage } from './metadata.ts'
|
|
11
11
|
import type { WorkspaceContext } from './paths.ts'
|
|
@@ -43,10 +43,6 @@ export interface DockerImageInfo {
|
|
|
43
43
|
name?: string
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
interface DockerInspectContainer {
|
|
47
|
-
Image?: unknown
|
|
48
|
-
Config?: unknown
|
|
49
|
-
}
|
|
50
46
|
|
|
51
47
|
function devcontainerWorkspaceArgs (context: WorkspaceContext): string[] {
|
|
52
48
|
return [
|
|
@@ -142,36 +138,28 @@ export async function findRunningContainerId (context: WorkspaceContext, options
|
|
|
142
138
|
return result.stdout.split(/\r?\n/).find((line) => line.length > 0)
|
|
143
139
|
}
|
|
144
140
|
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function parseDockerInspectImage (output: string, containerId: string): DockerImageInfo | undefined {
|
|
150
|
-
const trimmed = output.trim()
|
|
141
|
+
export function parseDockerInspectImage (output: string, containerId: string): DockerImageInfo | undefined {
|
|
142
|
+
const [rawId, rawName] = output.trim().split('|')
|
|
151
143
|
|
|
152
|
-
if (
|
|
144
|
+
if (rawId === undefined || rawId.length === 0) {
|
|
153
145
|
return undefined
|
|
154
146
|
}
|
|
155
147
|
|
|
156
|
-
let
|
|
148
|
+
let id: unknown
|
|
149
|
+
let name: unknown
|
|
157
150
|
|
|
158
151
|
try {
|
|
159
|
-
|
|
152
|
+
id = JSON.parse(rawId)
|
|
153
|
+
name = rawName === undefined || rawName.length === 0 ? undefined : JSON.parse(rawName)
|
|
160
154
|
} catch (error) {
|
|
161
155
|
throw new Error(`Could not parse docker inspect output for ${containerId}`, { cause: error })
|
|
162
156
|
}
|
|
163
157
|
|
|
164
|
-
if (typeof
|
|
165
|
-
return undefined
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const configImage = isRecord(parsed.Config) && typeof parsed.Config.Image === 'string' && parsed.Config.Image.length > 0
|
|
169
|
-
? parsed.Config.Image
|
|
170
|
-
: undefined
|
|
158
|
+
if (typeof id !== 'string' || id.length === 0) return undefined
|
|
171
159
|
|
|
172
160
|
return {
|
|
173
|
-
id
|
|
174
|
-
...(
|
|
161
|
+
id,
|
|
162
|
+
...(typeof name === 'string' && name.length > 0 ? { name } : {})
|
|
175
163
|
}
|
|
176
164
|
}
|
|
177
165
|
|
|
@@ -179,7 +167,7 @@ export async function inspectContainerImage (containerId: string, options: { log
|
|
|
179
167
|
const result = await runBuffered('docker', [
|
|
180
168
|
'inspect',
|
|
181
169
|
'--format',
|
|
182
|
-
'{{json .}}',
|
|
170
|
+
'{{json .Image}}|{{json .Config.Image}}',
|
|
183
171
|
containerId
|
|
184
172
|
], {
|
|
185
173
|
logger: options.logger,
|
|
@@ -320,7 +308,19 @@ export async function startDevcontainer (context: WorkspaceContext, options: Sta
|
|
|
320
308
|
progress.detail(context.generatedConfigPath)
|
|
321
309
|
}
|
|
322
310
|
try {
|
|
323
|
-
|
|
311
|
+
const signingPlan = await resolveGitSigningPlan(context)
|
|
312
|
+
reportGitSigningPlan(signingPlan, {
|
|
313
|
+
logger: options.logger,
|
|
314
|
+
quiet: proxyMode,
|
|
315
|
+
...(progress === undefined
|
|
316
|
+
? {}
|
|
317
|
+
: {
|
|
318
|
+
writeWarning: (message: string) => {
|
|
319
|
+
progress.warn(message.trimEnd())
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
writeGeneratedDevcontainerConfig(context, signingPlan)
|
|
324
324
|
if (hasConfigStep) {
|
|
325
325
|
progress?.completeStep('devcontainer-config')
|
|
326
326
|
}
|
|
@@ -669,7 +669,18 @@ export async function refreshContainerGhAuth (context: WorkspaceContext, options
|
|
|
669
669
|
quiet: true,
|
|
670
670
|
progress
|
|
671
671
|
})
|
|
672
|
-
|
|
672
|
+
const signingPlan = await resolveGitSigningPlan(context)
|
|
673
|
+
reportGitSigningPlan(signingPlan, {
|
|
674
|
+
logger: options.logger,
|
|
675
|
+
...(progress === undefined
|
|
676
|
+
? {}
|
|
677
|
+
: {
|
|
678
|
+
writeWarning: (message: string) => {
|
|
679
|
+
progress.warn(message.trimEnd())
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
writeGeneratedDevcontainerConfig(context, signingPlan)
|
|
673
684
|
if (hasConfigStep) {
|
|
674
685
|
progress?.completeStep('gh-auth-config')
|
|
675
686
|
}
|