From 7e0ff9ab06a35f435732ed19e52d8472a078c27a Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 14 Aug 2026 20:38:31 +0200 Subject: [PATCH] Keep every credential in one store, not a per-repo copy The repo carried a gitignored .secrets/ directory with four files. Two of them (context7-token, gitea-token) were byte-identical copies of variables the login shell already exported. One (gitea-host) is not a secret. The fourth (worker-gitea-token) was the only copy anywhere, and nothing exported it, so bridged read gitTokenEnv from an environment that never had it and every worker push got an empty token. All four values now live in the operator's single sourced secrets file, verified by sha256 before the copies were removed. opencode.json reads them as {env:...}, which .mcp.json already did. A second copy of a secret is the problem: the copy you forget is the one that leaks or goes stale. This makes worktree isolation load-bearing rather than a workaround. opencode.json is tracked, so it lands in every worktree. It used to fail there, because {file:.secrets/} pointed at files a worktree never receives and OpenCode refuses to start on a dangling reference. With {env:...} the reference resolves, and a member would silently inherit the primary's admin-scoped GITEA_ACCESS_TOKEN. GitWorktrees already neutralizes the file; only its stated reason changes, and it is now a confidentiality boundary. The port-to-opencode skill taught {file:.secrets/} as the preferred pattern, so it is rewritten to teach the central store and to say why we moved. .gitignore keeps the .secrets/ line as a backstop against habit. Includes the wiki pointer, which also carries the CB-559 config-reload correction. --- .claude/skills/port-to-opencode/SKILL.md | 30 ++++++++++++------- .gitignore | 5 ++-- .../ltms/bridged/session/GitWorktrees.java | 19 ++++++++---- opencode.json | 6 ++-- wiki | 2 +- 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/.claude/skills/port-to-opencode/SKILL.md b/.claude/skills/port-to-opencode/SKILL.md index 8bd2783..74f5504 100644 --- a/.claude/skills/port-to-opencode/SKILL.md +++ b/.claude/skills/port-to-opencode/SKILL.md @@ -92,24 +92,32 @@ route by who launches opencode: | Launcher | Route | |---|---| -| a human, from a terminal | `{file:…}` — see below | +| a human, from a terminal | one central store, sourced by the login shell | | a spawner (bridge, CI, IDE) | `{env:…}`, with the spawner injecting the variable | -For the human case prefer **`{file:…}` with a workspace-relative path**, kept in a gitignored -`.secrets/` directory beside `opencode.json`: +**For the human case, keep every credential in one file the login shell sources.** Here that file +is `${SHARED_ENV}/tools/secrets.sh`, sourced from `${SHARED_ENV}/.ltms`, kept at mode 600 and never +committed. `opencode.json` then names variables and holds no values: ```json -"headers": { "Authorization": "Bearer {file:.secrets/api-token}" } +"headers": { "Authorization": "Bearer {env:CONTEXT7_TOKEN}" } ``` -Verified: opencode resolves relative `{file:}` paths against the project root, so this needs no -shell setup at all — no rc export leaking the secret to every process, no direnv dependency. +Both routes end at the same syntax, and that is the point. The file does not change when a human +launches opencode instead of the bridge. -**The catch, and state it out loud:** `.secrets/` is gitignored, so a peer running in a git worktree -does **not** get it — worktrees receive tracked files only, the same rule that makes `opencode.json` -itself worth committing. Spawned peers must therefore be fed through `{env:…}` by whatever launches -them. Check the variable *names* match: a spawner often injects under a different name than your -shell uses, and the config has no fallback. +**`{file:…}` also works, and this project moved away from it.** Opencode resolves a relative +`{file:}` path against the project root, so a gitignored `.secrets/` beside `opencode.json` needs no +shell setup at all. It did not fail; the problem is that it makes a second copy of the token. The +same secret then lives in two places, and the copy you forget is the one that leaks or goes stale. +One store with many references is easier to rotate and to audit. + +**One catch survives either choice, so state it out loud:** what a human's shell exports does not +reach a spawned peer, and neither does a gitignored `.secrets/` — a git worktree receives tracked +files only. Spawned peers must be fed through `{env:…}` by whatever launches them. Check the +variable *names* match: a spawner often injects under a different name than your shell uses, and +the config has no fallback. In this repo the bridge goes further: it neutralizes a worktree's +`opencode.json`, so a member cannot inherit the primary's credentials by accident. ## 4. Do not port machine-local MCP servers diff --git a/.gitignore b/.gitignore index 95bc6ee..5a0e8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# Workspace-scoped secrets for tools that read no settings cascade of their own -# (opencode resolves these via {file:.secrets/...} in opencode.json). +# No secret belongs in this repo any more: every credential lives in one shell-level store +# (${SHARED_ENV}/tools/secrets.sh), and opencode.json reads it as {env:...}. This line stays as a +# backstop, so a workspace-scoped copy that someone re-creates by habit still cannot be committed. .secrets/ # Settings backups inherit the env block — and secrets with it. diff --git a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java index 7387ca5..c574c5b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java @@ -35,10 +35,17 @@ public final class GitWorktrees implements Worktrees { /** What {@link #isolateToolSurface} writes for {@code .mcp.json}: a valid, explicitly empty server map. */ private static final String NEUTRAL_MCP_CONFIG = "{\n \"mcpServers\": {}\n}\n"; - /** OpenCode's repo-level config. Tracked here, so it lands in every worktree; it carries - * {@code {file:.secrets/...}} references to gitignored secrets that never reach a worktree, and - * opencode refuses to start on a dangling reference — so it is neutralized and the worker gets - * only the config its launcher writes via {@code OPENCODE_CONFIG}. */ + /** OpenCode's repo-level config. Tracked here, so it lands in every worktree, and it mounts the + * primary's gitea and context7 servers with the primary's credentials. Neutralized so the worker + * gets only the config its launcher writes via {@code OPENCODE_CONFIG}. + * + *

The reason has changed shape and is now stronger. It used to be a crash: the file carried + * {@code {file:.secrets/...}} references to gitignored files that never reached a worktree, and + * opencode refuses to start on a dangling reference (CB-543). Those credentials now live in one + * shell-level store and the file reads them as {@code {env:...}}, so in a worktree the reference + * resolves instead of failing. That is worse, not better: a member would silently inherit the + * primary's admin-scoped {@code GITEA_ACCESS_TOKEN}. A loud crash became a quiet privilege leak, + * so this entry protects a boundary now rather than papering over a startup error. */ private static final String OPENCODE_CONFIG = "opencode.json"; /** What {@link #isolateToolSurface} writes for {@code opencode.json}: a valid, empty JSON object. */ @@ -112,8 +119,8 @@ public final class GitWorktrees implements Worktrees { * paths outside its own worktree. That is not hypothetical: a CB-523 worker made all 59 of its * edits in the primary checkout while compiling its worktree, so every build it ran was of code * that did not contain its changes. {@code opencode.json} is the same trap one tool over — tracked, - * so it lands in every worktree, referencing gitignored {@code .secrets/} files that never do, and - * opencode refuses to start on the dangling reference. {@code .autoenv} extends the principle to a + * so it lands in every worktree, and it mounts gitea and context7 with the primary's own + * credentials, which a member must never hold. {@code .autoenv} extends the principle to a * config that is not tracked today: autoenv authorizes by path, so a fresh worktree path is always * unauthorized and its interactive prompt would block every spawn, so re-landing one must be safe. * diff --git a/opencode.json b/opencode.json index de4c029..eb11b2c 100644 --- a/opencode.json +++ b/opencode.json @@ -14,7 +14,7 @@ "url": "https://ct7.ltms.dev/mcp", "enabled": true, "headers": { - "Authorization": "Bearer {file:.secrets/context7-token}" + "Authorization": "Bearer {env:CONTEXT7_TOKEN}" } }, "gitea": { @@ -26,8 +26,8 @@ ], "enabled": true, "environment": { - "GITEA_ACCESS_TOKEN": "{file:.secrets/gitea-token}", - "GITEA_HOST": "{file:.secrets/gitea-host}" + "GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}", + "GITEA_HOST": "{env:GITEA_HOST}" } } } diff --git a/wiki b/wiki index 8b1eb68..7c50cce 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 8b1eb688039c8be502932cbc1139f14214a069e3 +Subproject commit 7c50cce52ea5d0229a00a3d9615bb1b18b8d4e71