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.
Files changed (42) hide show
  1. package/assets/devcontainer/README.md +17 -5
  2. package/assets/devcontainer/devcontainer.json +3 -8
  3. package/assets/devcontainer/hooks/initialize.sh +49 -27
  4. package/assets/devcontainer/hooks/post-create.sh +11 -0
  5. package/assets/devcontainer/hooks/post-start.sh +0 -10
  6. package/assets/devcontainer/utils/git-signing-bootstrap.sh +58 -5
  7. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  8. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  9. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  10. package/dist/bin/cli.cjs +1 -1
  11. package/dist/bin/cli.mjs +1 -1
  12. package/dist/{main-Df4E8ARj.cjs → main-BDgyf2t5.cjs} +459 -199
  13. package/dist/{main-XMBsKjIK.mjs → main-J4_2Up3o.mjs} +461 -201
  14. package/dist/main-J4_2Up3o.mjs.map +1 -0
  15. package/dist/main.cjs +1 -1
  16. package/dist/main.d.cts +6 -0
  17. package/dist/main.d.cts.map +1 -1
  18. package/dist/main.d.mts +6 -0
  19. package/dist/main.d.mts.map +1 -1
  20. package/dist/main.mjs +1 -1
  21. package/docs/features/commit-signing.md +71 -0
  22. package/docs/features/generated-config-and-state.md +19 -4
  23. package/docs/features/github-auth-refresh.md +3 -2
  24. package/docs/features/lifecycle.md +6 -0
  25. package/docs/features/start-and-shell.md +5 -0
  26. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  27. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  28. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +20 -0
  29. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  30. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  31. package/package.json +1 -1
  32. package/src/config.ts +14 -2
  33. package/src/constants.ts +7 -0
  34. package/src/devcontainer.ts +37 -26
  35. package/src/doctor.ts +120 -23
  36. package/src/git-signing.ts +205 -25
  37. package/src/logging.ts +11 -1
  38. package/src/main.ts +2 -1
  39. package/src/paths.ts +21 -1
  40. package/src/purge.ts +8 -0
  41. package/src/shell.ts +6 -0
  42. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxdown",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Run a reusable devcontainer sandbox for any local project",
5
5
  "types": "dist/main.d.mts",
6
6
  "type": "module",
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 === true ? { SSH_AUTH_SOCK: '/run/boxdown/ssh-agent.sock' } : {})
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'
@@ -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 isRecord (value: unknown): value is Record<string, unknown> {
146
- return value !== null && typeof value === 'object' && !Array.isArray(value)
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 (trimmed.length === 0) {
144
+ if (rawId === undefined || rawId.length === 0) {
153
145
  return undefined
154
146
  }
155
147
 
156
- let parsed: DockerInspectContainer
148
+ let id: unknown
149
+ let name: unknown
157
150
 
158
151
  try {
159
- parsed = JSON.parse(trimmed) as DockerInspectContainer
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 parsed.Image !== 'string' || parsed.Image.length === 0) {
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: parsed.Image,
174
- ...(configImage === undefined ? {} : { name: configImage })
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
- writeGeneratedDevcontainerConfig(context, await resolveGitSigningPlan(context))
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
- writeGeneratedDevcontainerConfig(context, await resolveGitSigningPlan(context))
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
  }