diff --git a/CHANGELOG.md b/CHANGELOG.md index 815ebf3..bc78f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project are documented here. The format follows [Kee - Clarified the host-side and isolated-container Codex runtime paths. - Aligned offline deployment guidance with default validated-registry inclusion and the `--no-registry` opt-out. - Limited the compatibility matrix to reproducible reports with explicit evidence. +- **Breaking:** `lc code` and the container entrypoint now refuse to start Codex when a `.env` is present in the workspace it will run in, instead of warning and proceeding. Codex runs with `approval_policy = "never"` on the host and `danger-full-access` in the container, so a `.env` in the workspace is readable by one model-issued `cat`; both reported instances of that exposure were on documented happy paths, where a single warning line is easy to scroll past or lose in model output. Pass `--allow-workspace-secrets` on the host or `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` in the container to allow it explicitly — reviewing airgap-coder itself remains a supported workflow, and the override still prints the full warning. Anyone who ran `lc code` from the airgap-coder directory now needs either the flag or a `cd` into their own project. ### Fixed diff --git a/README.md b/README.md index cdf7ce2..3486e6d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ cd ~/your-project ``` > [!IMPORTANT] -> `lc code` runs Codex in the current directory under `approval_policy = "never"`, so the model can read that directory without asking. Starting it inside the airgap-coder directory puts `.env` — every upstream endpoint and credential — in reach of a single `cat`. `lc code` warns when it detects this, but does not block it, because reviewing airgap-coder itself is a supported workflow. See [Credentials in the Codex workspace](docs/threat-model.md#credentials-in-the-codex-workspace). +> `lc code` runs Codex in the current directory under `approval_policy = "never"`, so the model can read that directory without asking. Starting it inside the airgap-coder directory puts `.env` — every upstream endpoint and credential — in reach of a single `cat`. `lc code` **refuses to start** when it detects this. If you really do want Codex to work on that directory — the one case being changing airgap-coder itself — pass `--allow-workspace-secrets`, which starts the session and still prints the warning. See [Credentials in the Codex workspace](docs/threat-model.md#credentials-in-the-codex-workspace). `lc init` asks for the upstream URL, credential, model ID, context window, and backend family. Resolved endpoints and credentials are written only to `.env`; the shareable structure is written to `registry.json`. @@ -80,7 +80,7 @@ docker run --rm -it -v "/path/to/your-project:/workspace" \ ``` > [!IMPORTANT] -> In the container Codex runs with `sandbox_mode = "danger-full-access"`, because its Landlock/seccomp sandbox is unreliable inside many container runtimes; the container itself is the isolation boundary. That boundary only holds if the mount holds. Mounting the airgap-coder directory — where `lc init` just wrote `.env` — puts every upstream endpoint and credential inside the workspace. The entrypoint warns when it finds a `.env` in the workspace, but does not block. See [Credentials in the Codex workspace](docs/threat-model.md#credentials-in-the-codex-workspace). +> In the container Codex runs with `sandbox_mode = "danger-full-access"`, because its Landlock/seccomp sandbox is unreliable inside many container runtimes; the container itself is the isolation boundary. That boundary only holds if the mount holds. Mounting the airgap-coder directory — where `lc init` just wrote `.env` — puts every upstream endpoint and credential inside the workspace. The entrypoint **refuses to start** when it finds a `.env` in the workspace; mount your own project instead, or pass `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` to allow it explicitly (which still prints the warning). See [Credentials in the Codex workspace](docs/threat-model.md#credentials-in-the-codex-workspace). See the complete [offline deployment guide](docs/offline-deployment.md) before crossing a network boundary. @@ -104,7 +104,7 @@ Read [architecture](docs/architecture.md) for components and trust boundaries. | `lc up`, `lc down`, `lc status`, `lc logs` | Manage the gateway | | `lc test [name]` | Run protocol and tool-calling checks | | `lc e2e [name]` | Ask Codex to edit a fixture and verify the result | -| `lc code [...]` | Start host-side Codex with the selected upstream | +| `lc code [--allow-workspace-secrets] [...]` | Start host-side Codex with the selected upstream; refuses when `.env` is in the workspace unless allowed explicitly | | `lc doctor` | Diagnose versions, proxy settings, connectivity, and tool calling | | `lc sync` | Regenerate LiteLLM and Codex configuration | | `lc migrate` | Move legacy plaintext header values into `.env` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f0d92c..384a1fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,7 +61,7 @@ cd ~/your-project ``` > [!IMPORTANT] -> `lc code` 在当前目录里启动 Codex,且配置是 `approval_policy = "never"`——模型不用批准就能读这个目录。在 airgap-coder 目录里启动,等于把 `.env`(全部上游的地址与凭证)放进它一句 `cat` 就能拿到的地方。`lc code` 检测到这种情况会警告,但**不会阻断**,因为用 Codex 审查 airgap-coder 自己是被支持的用法。详见[工作区中的凭证](docs/threat-model.md#credentials-in-the-codex-workspace)。 +> `lc code` 在当前目录里启动 Codex,且配置是 `approval_policy = "never"`——模型不用批准就能读这个目录。在 airgap-coder 目录里启动,等于把 `.env`(全部上游的地址与凭证)放进它一句 `cat` 就能拿到的地方。`lc code` 检测到这种情况会**拒绝启动**。确实要让 Codex 在这个目录里干活——唯一的正当场景是改 airgap-coder 自己——加上 `--allow-workspace-secrets` 显式放行,放行之后仍然会打印那条警告。详见[工作区中的凭证](docs/threat-model.md#credentials-in-the-codex-workspace)。 `lc init` 会询问上游地址、凭证、模型 ID、上下文窗口和后端类型。解析后的地址与凭证只写入 `.env`;可共享的结构写入 `registry.json`。 @@ -80,7 +80,7 @@ docker run --rm -it -v "/path/to/your-project:/workspace" \ ``` > [!IMPORTANT] -> 容器里 Codex 跑的是 `sandbox_mode = "danger-full-access"`——它的 Landlock/seccomp 沙箱在多数容器运行时下不可靠,所以隔离边界是容器本身。这条边界成立的前提是挂载正确:把 airgap-coder 目录(`lc init` 刚往那里写了 `.env`)挂进去,等于把全部上游的地址与凭证放进工作区。entrypoint 检测到工作区里有 `.env` 会警告,但**不会阻断**。详见[工作区中的凭证](docs/threat-model.md#credentials-in-the-codex-workspace)。 +> 容器里 Codex 跑的是 `sandbox_mode = "danger-full-access"`——它的 Landlock/seccomp 沙箱在多数容器运行时下不可靠,所以隔离边界是容器本身。这条边界成立的前提是挂载正确:把 airgap-coder 目录(`lc init` 刚往那里写了 `.env`)挂进去,等于把全部上游的地址与凭证放进工作区。entrypoint 检测到工作区里有 `.env` 会**拒绝启动**:改成挂你自己的项目目录,或者加 `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` 显式放行(放行之后仍然会打印那条警告)。详见[工作区中的凭证](docs/threat-model.md#credentials-in-the-codex-workspace)。 跨越网络边界前,请先阅读完整的[离线部署指南](docs/offline-deployment.md)。 @@ -104,7 +104,7 @@ docker run --rm -it -v "/path/to/your-project:/workspace" \ | `lc up`、`lc down`、`lc status`、`lc logs` | 管理网关生命周期 | | `lc test [name]` | 执行协议与工具调用检查 | | `lc e2e [name]` | 让 Codex 修改测试文件并验证结果 | -| `lc code [...]` | 使用所选上游启动主机上的 Codex | +| `lc code [--allow-workspace-secrets] [...]` | 使用所选上游启动主机上的 Codex;`.env` 在工作区里时拒绝启动,除非显式放行 | | `lc doctor` | 诊断版本、代理、连接与工具调用 | | `lc sync` | 重新生成 LiteLLM 与 Codex 配置 | | `lc migrate` | 将旧版明文 HTTP 头值迁移到 `.env` | diff --git a/bin/lc b/bin/lc index e75b292..9908b52 100755 --- a/bin/lc +++ b/bin/lc @@ -11,7 +11,8 @@ lc up / down / status / logs lc test [name] 协议层 5 项测试 lc e2e [name] 端到端测试(真改代码) - lc code [args...] 用当前上游启动 Codex + lc code [args...] 用当前上游启动 Codex(工作区里有 .env 会拒绝启动, + 确实要在那个目录里干活加 --allow-workspace-secrets) lc doctor 环境体检 lc sync 由注册表重新生成 litellm/codex 配置 lc migrate 把 registry 里遗留的明文 header 值搬进 .env @@ -819,8 +820,11 @@ def env_in_workspace(cwd=None): return True -def warn_env_in_workspace(reg): - """.env 在工作区里时提醒,但**不阻断**(issue #46)。 +ALLOW_WS_SECRETS = "--allow-workspace-secrets" + + +def guard_env_in_workspace(reg, allow=False): + """.env 在 Codex 工作区里时**拒绝启动**,除非显式放行(issue #52)。 #42 把注入给 Codex 的环境变量收到了最小集,那只关掉了「进程环境」这条读法。 `lc code` 是在当前工作目录里启动 Codex 的,而生成的配置是 @@ -828,10 +832,27 @@ def warn_env_in_workspace(reg): .env 的话,一句 `cat .env` 就把 #42 收回去的东西全拿回来了。0600 挡的是别的 用户,挡不住以你的身份运行的 Codex。 - 不阻断是有意的:在 airgap-coder 仓库里用 Codex 改 airgap-coder 自己是正当 - 用法(docs/codex-workflow.md 就是这么教的),拒绝会挡住它;而加一个放行 flag - 的人以后会永远带着那个 flag,等于回到只警告但多一步。真正的解法是目录分离, - 所以警告里给的是这条路。 + #46 当时只警告,理由是「在这个仓库里用 Codex 改 airgap-coder 自己是正当用法, + 拒绝会挡住它;而放行 flag 加上去之后就永远带着」。这个理由只对**开发 + airgap-coder 的人**成立,而 #46 / #50 两次踩的都不是这类人:内网的最终用户是 + 照着文档走进来的,撞上时的心智状态是「我在照文档走」而不是「我知道自己在绕过 + 什么」——对他们,一行会被 scroll 过去的黄字和一个必须处理的硬停,差别是决定性 + 的。对开发者「阻断 ≈ 警告」不构成反对,因为他们本来就知道自己在做什么。 + + 「flag 会退化成静默通道」这个顾虑由两条约束兜住,改这段代码前请先读它们 + (issue #52 的维护者决策): + + 1. **放行时仍然打印完整警告**。放行不等于安全,只是「你知道你在做什么」。 + 所以就算开发者把 flag 写进 alias 永远带着,他们看到的东西和只警告的 + 年代完全一样,不会更差。 + 2. **不提供环境变量形式的全局开关**。CLI 侧只有 --allow-workspace-secrets + 这个每次都要显式敲的参数;给了环境变量,`export` 一次就全局静默了, + 那才是真正的退化。容器侧不得不用环境变量(entrypoint 那边没有命令行可 + 加),但那是被迫,不构成这里的先例。 + + 真正的根治是目录分离(把 .env 搬出仓库目录),#52 评估后没有采纳:它会破坏 + 「解包 → 目录里什么都有 → 删目录即干净卸载」这个对隔离网交付的承诺。完整的 + 取舍分析在 issue #52 里,将来出现第三条暴露路径时从那份分析重新评估。 """ if not env_in_workspace(): return @@ -840,8 +861,14 @@ def warn_env_in_workspace(reg): say(" 会话是 approval_policy = \"never\",模型发的 shell 命令不用批准就执行," "一句 `cat .env` 就能读走 %d 个上游的地址与凭证。" % n) say(" 0600 挡的是别的用户,挡不住以你的身份运行的 Codex。") + if allow: + say(" %s已用 %s 显式放行,本次照常启动——放行不等于安全。%s" + % (C["d"], ALLOW_WS_SECRETS, C["x"])) + return say(" %s要避开:cd 到你自己的项目目录再跑 lc code——工具目录和工作目录是" "两回事。%s" % (C["d"], C["x"])) + die("已拒绝启动 Codex。确实要在这个目录里干活(比如用 Codex 改 airgap-coder " + "自己),加上 %s 显式放行。" % ALLOW_WS_SECRETS) def cmd_code(argv): @@ -849,7 +876,12 @@ def cmd_code(argv): if not gw_alive(reg): die("网关没起来,先跑 `lc up`") profile = _default_target(reg) - warn_env_in_workspace(reg) + # 这个 flag 是 lc 自己的,必须从透传给 Codex 的参数里摘掉:Codex 不认识它, + # 会以一句 "unexpected argument" 直接退出,用户看到的就成了「按提示放行之后 + # 反而更坏了」。按精确串摘,不做前缀匹配,免得吃掉 Codex 将来同名前缀的参数。 + allow = ALLOW_WS_SECRETS in argv + argv = [a for a in argv if a != ALLOW_WS_SECRETS] + guard_env_in_workspace(reg, allow) sys.exit(subprocess.call(["codex", "--profile", profile] + list(argv), env=codex_env())) @@ -1544,7 +1576,8 @@ COMMANDS = [ ("logs", cmd_logs, "跟踪网关日志"), ("test", cmd_test, "协议层 5 项测试: lc test [name...]"), ("e2e", cmd_e2e, "端到端测试(真改代码): lc e2e [name...]"), - ("code", cmd_code, "用当前上游启动 Codex: lc code [codex 参数]"), + ("code", cmd_code, + "用当前上游启动 Codex: lc code [--allow-workspace-secrets] [codex 参数]"), ("doctor", cmd_doctor, "环境体检(代理劫持 + 上游 tool calling 探测)"), ("sync", cmd_sync, "由 registry.json 重新生成配置"), ("migrate", cmd_migrate, "把 registry 里遗留的明文 header 值搬进 .env"), diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index fcb4a4c..366b316 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,7 +41,7 @@ env_key = "LITELLM_MASTER_KEY" wire_api = "responses" EOF -# 工作区里躺着 .env 时提醒,但**不阻断**(issue #50)。 +# 工作区里躺着 .env 时**拒绝启动**,除非显式放行(issue #50 发现,issue #52 定调)。 # # 上面 `sandbox_mode = "danger-full-access"` 的前提是「挂进来的只有该看的工作区」, # 而文档一度教的是 `-v "$PWD:/workspace"`,紧跟在 `lc init` 把凭证写进那个目录的 @@ -49,20 +49,40 @@ EOF # (issue #42 收紧的那条),但挂载把整份 .env 又送了回来:里面是**全部**上游的 # 地址、API Key 和自定义头的值,包括这次会话用不到的。 # -# 这条检查只能长在这里:`lc code` 那句同样的警告在 bin/lc 里,容器路径根本不跑 +# 这条检查只能长在这里:`lc code` 那句同样的检查在 bin/lc 里,容器路径根本不跑 # lc,结构上不可能触发。判据用 $PWD 而不是写死 /workspace,这样 `-w` 换过工作 # 目录也照样成立;只报路径,不读文件、不打印任何值。 # -# 不阻断的理由同 bin/lc 的 warn_env_in_workspace():在这个目录里用 Codex 审查 -# airgap-coder 自己是正当用法,拒绝会挡住它,而放行 flag 加上去之后就永远带着。 +# #50 当时只警告,理由是「用 Codex 审查 airgap-coder 自己是正当用法,拒绝会挡住 +# 它,而放行 flag 加上去之后就永远带着」。#52 否掉了这个理由:它只对开发 +# airgap-coder 的人成立,而两次踩坑的都是照文档走的内网最终用户——对他们,非交互 +# `codex exec` 里一行会被模型输出淹没的黄字,和一个必须处理的硬停,差别是决定性 +# 的。开发者那边「阻断 ≈ 警告」不构成反对,他们本来就知道自己在做什么。 +# +# 两条约束防止逃生阀退化成静默通道,改这段前先读(细节见 bin/lc 的 +# guard_env_in_workspace()):放行时**仍然打印完整警告**;而这里用环境变量是被迫 +# 的(容器侧没有命令行可加),CLI 侧不许跟着加环境变量开关。 +# +# 报错教的是**挂载写法**而不是 `cd`:容器里没有「换个目录跑」这个选项,用户能改的 +# 只有 -v。 if [ -f "${PWD}/.env" ]; then { echo "⚠️ 工作区里有 .env:${PWD}/.env" echo " 这个会话是 approval_policy = \"never\" + sandbox_mode = \"danger-full-access\":" echo " 模型发的 shell 命令不用批准直接执行,一句 \`cat .env\` 就能读走里面全部上游的地址与凭证。" - echo " 要避开:挂你要它改的那个项目目录,别挂 airgap-coder 或离线包目录——" - echo " docker run ... -v \"/path/to/your-project:/workspace\" ..." } >&2 + if [ "${AIRGAP_ALLOW_WORKSPACE_SECRETS:-}" = "1" ]; then + echo " 已用 AIRGAP_ALLOW_WORKSPACE_SECRETS=1 显式放行,本次照常启动——放行不等于安全。" >&2 + else + { + echo "❌ 已拒绝启动 Codex。" + echo " 要避开:挂你要它改的那个项目目录,别挂 airgap-coder 或离线包目录——" + echo " docker run ... -v \"/path/to/your-project:/workspace\" ..." + echo " 确实要让 Codex 看这个目录(比如改 airgap-coder 自己),显式放行:" + echo " docker run ... -e AIRGAP_ALLOW_WORKSPACE_SECRETS=1 ..." + } >&2 + exit 1 + fi fi # `--skip-git-repo-check` 是 `codex exec` 的子命令参数,不能放在 Codex 顶层。 diff --git a/docs/offline-deployment.md b/docs/offline-deployment.md index 20968ea..48b03df 100644 --- a/docs/offline-deployment.md +++ b/docs/offline-deployment.md @@ -87,7 +87,7 @@ cd /path/to/your-project /path/to/airgap-coder-0.1.0-YYYYMMDD-HHMMSS/bin/lc code ``` -`lc init` writes the site's endpoints and credentials into `.env` inside the bundle directory. `lc code` starts Codex in the current directory under `approval_policy = "never"`, so starting it inside the bundle directory puts that `.env` in the workspace the model can read. `lc code` warns when it detects this; see [Credentials in the Codex workspace](threat-model.md#credentials-in-the-codex-workspace). +`lc init` writes the site's endpoints and credentials into `.env` inside the bundle directory. `lc code` starts Codex in the current directory under `approval_policy = "never"`, so starting it inside the bundle directory puts that `.env` in the workspace the model can read. `lc code` refuses to start when it detects this, and `--allow-workspace-secrets` overrides the refusal for the rare case where the bundle directory really is what you want Codex to work on; see [Credentials in the Codex workspace](threat-model.md#credentials-in-the-codex-workspace). `install.sh` verifies every checksum in `SHA256SUMS` before running `docker load`, and stops if any file fails. This detects transfer corruption and interrupted extraction — an unpack that dies partway leaves a directory that looks complete but is not. It is not tamper protection: whoever can modify the bundle can modify `SHA256SUMS` with it. Release-artifact provenance, described at the end of this page, is the control for that. When the bundle includes `registry.json`, `lc init` reuses its reviewed model structure and asks only for the isolated site's endpoint and credential. Without a bundled registry, `lc init` creates the first upstream definition. `lc doctor` then checks the local environment and the configured upstream; `lc test` verifies the gateway path. @@ -105,7 +105,7 @@ docker run --rm -it -v "/path/to/your-project:/workspace" \ In `exec` mode the image disables Codex's built-in sandbox because its Landlock/seccomp sandbox is not reliable inside many container runtimes. Treat the container as the isolation boundary: mount only the required workspace, keep sensitive host paths out, and apply your runtime's user, capability, network, and filesystem restrictions. -That boundary depends entirely on what you mount. Step 3 above ran `lc init` inside the bundle directory, which is where it wrote `.env` — every endpoint, credential, and private header value for every upstream. Mounting the bundle directory therefore hands all of it to a session that executes shell commands without approval, so mount your own project instead. The entrypoint prints a warning when it finds a `.env` in the workspace; it names the path, prints no values, and does not block. See [Credentials in the Codex workspace](threat-model.md#credentials-in-the-codex-workspace). +That boundary depends entirely on what you mount. Step 3 above ran `lc init` inside the bundle directory, which is where it wrote `.env` — every endpoint, credential, and private header value for every upstream. Mounting the bundle directory therefore hands all of it to a session that executes shell commands without approval, so mount your own project instead. The entrypoint refuses to start when it finds a `.env` in the workspace; it names the path, prints no values, and exits non-zero. `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` overrides the refusal and still prints the warning. See [Credentials in the Codex workspace](threat-model.md#credentials-in-the-codex-workspace). ## Verify a GitHub release source archive diff --git a/docs/threat-model.md b/docs/threat-model.md index 0624cf5..f5263e9 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -24,7 +24,7 @@ The operator trusts the host operating system, Docker daemon, selected container | Prompt/source disclosure | No project telemetry; gateway remains operator-controlled | Audit gateway/model logging, retention, and access control | | Malicious model tool call | Protocol tests verify function-calling shape, not intent | Use Codex sandboxing, least privilege, review diffs, and avoid mounting unrelated data | | Credential read out of the agent's own process environment | `lc code` and `lc e2e` pass only the environment variables the generated Codex configuration declares (`env_key`), not the whole `.env`; the container entrypoint already passed only the gateway key. A regression test asserts that no `KEY_*` upstream credential reaches the Codex process | Keep unrelated secrets out of the shell that launches `lc`; the `.env` file itself stays readable to any process running as you, so do not start Codex in a workspace where reading it is acceptable | -| Credential read out of the Codex workspace | `lc code` warns when the `.env` it uses lies inside the directory Codex will run in, naming the file and the number of exposed upstreams; the container entrypoint warns when a `.env` is present in the mounted workspace. Both warn rather than refuse (see [Credentials in the Codex workspace](#credentials-in-the-codex-workspace)) | Run `lc code` from your own project directory and mount your own project into the container — not the airgap-coder directory or an unpacked offline bundle | +| Credential read out of the Codex workspace | `lc code` refuses to start when the `.env` it uses lies inside the directory Codex will run in, naming the file and the number of exposed upstreams; the container entrypoint refuses when a `.env` is present in the mounted workspace. Both are default-deny with an explicit override that still prints the warning (see [Credentials in the Codex workspace](#credentials-in-the-codex-workspace)) | Run `lc code` from your own project directory and mount your own project into the container — not the airgap-coder directory or an unpacked offline bundle | | Malicious repository instructions | None can make an untrusted repository safe automatically | Review repository instructions before running Codex; use a disposable worktree/container | | Dependency or workflow compromise | CI actions and runtime images are pinned; automated update PRs are reviewable | Review update diffs and provenance before merging or mirroring | | Compromised transfer media | Bundle checksums detect accidental or post-build modification | Establish trusted signing, custody, and malware-scanning procedures appropriate to the environment | @@ -37,9 +37,16 @@ First, `.env` is an ordinary file. Its `0600` mode stops other users on the host Second, `workspace-write` bounds writes, not reads. Codex may read files elsewhere on the host, so relocating `.env` narrows the exposure rather than removing it. A hardened sandbox is an explicit non-goal of this project. -airgap-coder warns instead of refusing. When the `.env` it uses lies inside the directory Codex will run in, `lc code` prints a warning that names the file, states how many upstream credentials it holds, and gives the way to avoid it. It does not print any secret value, and it does not block: running Codex against the airgap-coder checkout itself is a supported workflow (see [Codex workflow](codex-workflow.md)), refusing would break it, and an override flag would become permanent for anyone who adds it — the same exposure with one extra step. +The control is default-deny, not a warning. When the `.env` it uses lies inside the directory Codex will run in, `lc code` prints a message that names the file and states how many upstream credentials it holds, prints no secret value, and **exits non-zero without starting Codex**. Running Codex against the airgap-coder checkout itself is still a supported workflow (see [Codex workflow](codex-workflow.md)); it now requires `--allow-workspace-secrets`, which starts the session and still prints the full warning. -The reliable mitigation is directory separation. The tool directory and your working directory are different things: `cd` into your own project and run `lc code` from there, and the checkout's `.env` is outside the workspace entirely. +The difference between refusing and warning matters for the people who actually hit this. Someone developing airgap-coder will put the flag in an alias and see the same warning they saw before — for them a refusal is no better than a warning, but no worse either. Someone unpacking a bundle in an isolated network is following a documented procedure, and a warning at that moment is one line to scroll past, while a refusal is something they have to deal with. Both reported instances of this exposure were on that documented path. + +Two properties keep the override from decaying into a silent channel: + +- **It still prints the warning.** Allowing is not the same as being safe; it only records that you know what you are doing. Anyone who carries the flag permanently keeps seeing exactly what they saw when this was a warning. +- **There is no environment variable form on the host side.** `--allow-workspace-secrets` has to be typed per invocation. An `AIRGAP_ALLOW_*` variable would be exported once and silence the check globally, which is the actual regression to avoid. The container has no command line to add flags to, so it must use a variable; that is a constraint of that path, not a precedent for this one. + +The reliable mitigation is still directory separation. The tool directory and your working directory are different things: `cd` into your own project and run `lc code` from there, and the checkout's `.env` is outside the workspace entirely. Moving `.env` out of the checkout altogether was evaluated and rejected, because it would break the property that an unpacked bundle is self-contained and that deleting its directory removes the credentials with it; the full trade-off analysis is in [issue #52](https://github.com/LouisDM/airgap-coder/issues/52). ### The container path has the same exposure, with less margin @@ -49,7 +56,9 @@ First, the permission level is higher. The entrypoint generates `sandbox_mode = Second, mounting the current directory cancels the entrypoint's credential hygiene. Passing just the gateway key is undone by a mount that delivers the whole `.env` file, including upstreams the session does not use — and the natural way to write the command, mounting whatever directory you happen to be standing in, is exactly the directory `lc init` writes to. -Third, the `lc code` warning cannot fire here: it lives in `bin/lc`, and the container path never runs `lc`. The container therefore needs its own check, and the entrypoint performs it — when a `.env` exists in the workspace it will run in, it writes a warning to stderr naming the path and the permission level, prints no values, and proceeds. Under non-interactive `codex exec` that line is easy to lose in model output, so it is a clue for whoever already stepped on this, not the primary control. +Third, the `lc code` check cannot fire here: it lives in `bin/lc`, and the container path never runs `lc`. The container therefore needs its own, and the entrypoint performs it — when a `.env` exists in the workspace it will run in, it writes the warning to stderr naming the path and the permission level, prints no values, and exits non-zero without starting Codex. `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` allows the session explicitly and still prints the warning; any other value, including `0` and `false`, is a refusal. Under non-interactive `codex exec` a stderr line drowns in model output, which is precisely why this path refuses rather than warns. + +The message here teaches the mount, not `cd`: inside the container there is no other directory to move to, so the only thing the operator can change is `-v`. The primary control is the mount. Mount the project Codex should work on; do not mount the airgap-coder checkout or an unpacked offline bundle, both of which hold the `.env` that `lc init` just wrote. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e2f7919..1359b5e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -78,6 +78,21 @@ Also compare: - generated parameter shape; - proxy environment and network path. +## `lc code` refuses to start: a `.env` in the workspace + +This is intentional, not a malfunction. `lc code` starts Codex in the current directory under `approval_policy = "never"`, so if the `.env` it uses is inside that directory — or any directory above it — the model can read every upstream endpoint, credential, and private header value with one `cat`. The container path refuses for the same reason under `danger-full-access`. + +The normal fix is directory separation, because the tool directory and your working directory are different things: + +```bash +cd ~/your-project +~/airgap-coder/bin/lc code +``` + +For the container, mount the project rather than the airgap-coder checkout or an unpacked bundle: `-v "/path/to/your-project:/workspace"`. + +If that directory really is what you want Codex to work on — the one such case being changing airgap-coder itself — allow it explicitly with `lc code --allow-workspace-secrets`, or `-e AIRGAP_ALLOW_WORKSPACE_SECRETS=1` for the container. The override starts the session and still prints the warning. There is deliberately no environment variable for the host flag, so it cannot be exported once and forgotten; see [Credentials in the Codex workspace](threat-model.md#credentials-in-the-codex-workspace). + ## Codex reports missing model metadata A self-hosted model may not exist in Codex's built-in model catalog. Treat the message as a warning only after confirming that the generated profile has the intended model and context limits and that `lc test` and `lc e2e` pass. diff --git a/scripts/test-entrypoint.sh b/scripts/test-entrypoint.sh index 610c730..063ca7e 100644 --- a/scripts/test-entrypoint.sh +++ b/scripts/test-entrypoint.sh @@ -79,10 +79,13 @@ if [ -e "$WORK/bare/config.toml" ]; then exit 1 fi -# issue #50: the container runs Codex with danger-full-access, so a `.env` inside -# the mounted workspace is readable by one `cat`. Warn on stderr, never block -- -# and, just as importantly, stay quiet when there is no `.env`: a warning that -# always fires is not a warning (same reasoning as the `lc code` check in #46). +# issue #50 / #52: the container runs Codex with danger-full-access, so a `.env` +# inside the mounted workspace is readable by one `cat`. #50 shipped a warning; +# #52 decided that is not enough for the users who actually step on it (they are +# following the documented happy path, and under non-interactive `codex exec` one +# stderr line drowns in model output). So: refuse to start, with an explicit +# escape hatch -- and, just as importantly, stay quiet when there is no `.env`: +# a check that always fires is not a check (same reasoning as `lc code` in #46). mkdir -p "$WORK/ws" run_in_workspace() { cd "$WORK/ws" || exit 1 @@ -106,28 +109,89 @@ if grep -q '\.env' "$WORK/clean.log"; then exit 1 fi +# (1) `.env` in the workspace: non-zero exit, and Codex must not have been started. printf 'KEY_INTRANET=must-not-be-printed\n' > "$WORK/ws/.env" cp /dev/null "$WORK/args" -(run_in_workspace exec "inspect the repository") > "$WORK/warn.log" 2>&1 -grep -q "$WORK/ws/.env" "$WORK/warn.log" || { - echo "❌ .env in the workspace did not produce a warning" >&2 - cat "$WORK/warn.log" >&2 +if (run_in_workspace exec "inspect the repository") > "$WORK/block.log" 2>&1; then + echo "❌ a .env in the workspace must make the entrypoint exit non-zero" >&2 + cat "$WORK/block.log" >&2 + exit 1 +fi +if [ -s "$WORK/args" ]; then + echo "❌ the entrypoint refused but started codex anyway" >&2 + cat "$WORK/args" >&2 + exit 1 +fi +grep -q "$WORK/ws/.env" "$WORK/block.log" || { + echo "❌ the refusal does not name the offending file" >&2 + cat "$WORK/block.log" >&2 exit 1 } -grep -q 'danger-full-access' "$WORK/warn.log" || { - echo "❌ the warning does not say why it matters (full access session)" >&2 +grep -q 'danger-full-access' "$WORK/block.log" || { + echo "❌ the refusal does not say why it matters (full access session)" >&2 exit 1 } -# The warning names the file; it must never read or echo its contents. -if grep -q 'must-not-be-printed' "$WORK/warn.log"; then - echo "❌ the warning printed a value out of .env" >&2 +# The container has no "cd somewhere else" option -- the only thing the user can +# change is the mount, so that is what the message has to teach. +grep -q -- '-v "/path/to/your-project:/workspace"' "$WORK/block.log" || { + echo "❌ the refusal does not show the correct mount syntax" >&2 + exit 1 +} +grep -q 'AIRGAP_ALLOW_WORKSPACE_SECRETS=1' "$WORK/block.log" || { + echo "❌ the refusal does not mention the escape hatch -- a hard stop with no" \ + "documented way out just makes people delete the check" >&2 + exit 1 +} +# The message names the file; it must never read or echo its contents. +if grep -q 'must-not-be-printed' "$WORK/block.log"; then + echo "❌ the refusal printed a value out of .env" >&2 + exit 1 +fi + +# (2) Escape hatch: starts normally *and still prints the warning*. The second +# half is the one that rots -- an override that goes quiet is just the old +# warning with one extra step (issue #52). +cp /dev/null "$WORK/args" +export AIRGAP_ALLOW_WORKSPACE_SECRETS=1 +if ! (run_in_workspace exec "inspect the repository") \ + > "$WORK/allow.log" 2>&1; then + echo "❌ AIRGAP_ALLOW_WORKSPACE_SECRETS=1 should let the session start" >&2 + cat "$WORK/allow.log" >&2 exit 1 fi -# Warn, do not block: codex still runs with the exact same arguments. expected="$(printf '%s\n' exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox 'inspect the repository')" if [ "$(cat "$WORK/args")" != "$expected" ]; then - echo "❌ the warning blocked or altered the codex invocation" >&2 + echo "❌ the escape hatch altered the codex invocation" >&2 + cat "$WORK/args" >&2 exit 1 fi +grep -q "$WORK/ws/.env" "$WORK/allow.log" || { + echo "❌ the escape hatch silenced the warning -- it must still name the file" >&2 + cat "$WORK/allow.log" >&2 + exit 1 +} +grep -q 'danger-full-access' "$WORK/allow.log" || { + echo "❌ the escape hatch dropped the reason the warning matters" >&2 + exit 1 +} +if grep -q 'must-not-be-printed' "$WORK/allow.log"; then + echo "❌ the warning printed a value out of .env" >&2 + exit 1 +fi +# Any value other than exactly "1" must not open the gate: a stray +# `AIRGAP_ALLOW_WORKSPACE_SECRETS=0` or `=false` reads as "off" to whoever wrote +# it, and silently meaning "on" is the worst possible failure for this check. +for val in 0 false ""; do + cp /dev/null "$WORK/args" + export AIRGAP_ALLOW_WORKSPACE_SECRETS="$val" + if (run_in_workspace exec "x") > "$WORK/off.log" 2>&1; then + echo "❌ AIRGAP_ALLOW_WORKSPACE_SECRETS='$val' must not open the gate" >&2 + cat "$WORK/off.log" >&2 + exit 1 + fi +done +unset AIRGAP_ALLOW_WORKSPACE_SECRETS + +rm -f "$WORK/ws/.env" -echo "✅ entrypoint routes exec-only flags, passes version/help through without gateway config, validates required variables, and warns about a .env in the workspace" +echo "✅ entrypoint routes exec-only flags, passes version/help through without gateway config, validates required variables, and refuses to start with a .env in the workspace unless explicitly allowed" diff --git a/scripts/test-lc-commands.sh b/scripts/test-lc-commands.sh index a35883e..3778589 100644 --- a/scripts/test-lc-commands.sh +++ b/scripts/test-lc-commands.sh @@ -135,6 +135,17 @@ run_fail() { # run_fail <日志> ;命令必须失败 refute "lc $*:失败时没有吐 traceback" "$log" "Traceback (most recent call last)" } +run_at_fail() { # run_at_fail <工作目录> <日志> ;命令必须失败 + local dir="$1" log="$2"; shift 2 + if ( cd "$dir" && NO_COLOR=1 PATH="$WORK/bin:$PATH" "${LC[@]}" "$@" < /dev/null ) \ + > "$log" 2>&1; then + cat "$log" + fail "在 $dir 里跑 lc $* 应当以非零退出码失败,实际成功了" + return 1 + fi + refute "lc $*:失败时没有吐 traceback" "$log" "Traceback (most recent call last)" +} + # ── 网关桩:三种形态,都监听 registry 里那个 $PORT ──────────────────────── # 活着的那个对 /health/liveliness 回 200,让 wait_gw 第一次探活就成功;不起它的话 # lc up 会老老实实等满默认预算——那不是在测什么,只是在等。 @@ -731,44 +742,117 @@ else pass "默认上游失效时没有拿不存在的 profile 去启动 Codex" fi -echo "[6f2] .env 就在 Codex 的工作目录里时给一条警告(issue #46)" +echo "[6f2] .env 就在 Codex 的工作目录里时拒绝启动(issue #46 → #52)" # #42 收的是「进程环境」那条读法;`lc code` 在 CWD 里启动 Codex,配置又是 # approval_policy = "never",工作区里放着 .env 的话一句 `cat .env` 就全拿回去了。 -# 两条都要:该响的时候响,**不该响的时候不响**——一条永远都响的警告等于没有警告, -# 用户两周就学会无视它。 +# +# #46 当时只警告,#52 定成「阻断 + 逃生阀」。三条一起才算钉住: +# (1) 工作区里有 .env → 非零退出,且压根没启动 Codex; +# (2) 带上逃生阀 → 正常启动,**并且仍然打印警告**; +# (3) 工作区里没有 .env → 正常启动,不打印任何警告。 +# (3) 照旧是防误报——一条永远都响的检查等于没有检查。 +# (2) 里「仍然打印警告」那半条最容易烂掉:逃生阀退化成静默开关之后,这个检查对 +# 天天带着 flag 的开发者就完全消失了,而 #52 的决策明确不允许这样。 cp "$WORK/reg.saved-code" "$REG" # [6f] 把 default 改坏了,这一节要它是好的 WARN_MARK="工作目录里有 .env" +ALLOW_FLAG="--allow-workspace-secrets" + +# (1) 在工具目录里跑:阻断 +: > "$CODEX_LOG" +if run_at_fail "$SRC" "$WORK/log-code-envblock" code; then + cat "$WORK/log-code-envblock" + assert "在工具目录里跑时报了警" "$WORK/log-code-envblock" "$WARN_MARK" + assert "点名了是哪个文件" "$WORK/log-code-envblock" "$ENVF" + assert "说清了暴露面有多大(几个上游)" "$WORK/log-code-envblock" "2 个上游" + assert "给出了正常的绕开方式" "$WORK/log-code-envblock" "cd 到你自己的项目目录" + assert "明说拒绝启动了" "$WORK/log-code-envblock" "已拒绝启动 Codex" + # 硬停必须同时给出出路,否则用户的第一反应是把这段检查删掉 + assert "给出了逃生阀" "$WORK/log-code-envblock" "$ALLOW_FLAG" +fi +if [ -s "$CODEX_LOG" ]; then + cat "$CODEX_LOG" + fail "拒绝启动之后却还是把 Codex 启起来了" +else + pass "拒绝启动时压根没启动 Codex" +fi +# 检查本身不许把凭证打出来——那就成了它自己在泄漏 +refute "提示里没有 API Key 的值" "$WORK/log-code-envblock" "$CANARY_KEY" +refute "提示里没有 master key 的值" "$WORK/log-code-envblock" "$CANARY_MK" + +# (2) 逃生阀:正常启动,但警告一个字都不许少 +: > "$CODEX_LOG" +run_at "$SRC" "$WORK/log-code-allow" code "$ALLOW_FLAG" --sandbox read-only +cat "$WORK/log-code-allow" +assert "逃生阀让 Codex 启起来了" "$CODEX_LOG" "--profile beta" +assert "逃生阀之后仍然报警" "$WORK/log-code-allow" "$WARN_MARK" +assert "仍然点名了文件" "$WORK/log-code-allow" "$ENVF" +assert "仍然说清了暴露面" "$WORK/log-code-allow" "2 个上游" +assert "说明了这次是显式放行" "$WORK/log-code-allow" "显式放行" +refute "放行时不再说「已拒绝启动」" "$WORK/log-code-allow" "已拒绝启动 Codex" +refute "放行时也不打印凭证" "$WORK/log-code-allow" "$CANARY_KEY" +# 这个 flag 是 lc 自己的,Codex 不认识它。透传下去 Codex 会以 "unexpected +# argument" 直接退出,用户看到的就成了「按提示放行之后反而更坏了」。 +refute "逃生阀没被透传给 Codex" "$CODEX_LOG" "$ALLOW_FLAG" +assert "其它参数照旧透传给 Codex" "$CODEX_LOG" "--sandbox read-only" + +# 变异测试:把阻断改回「只警告」,(1) 必须变红。没有这条,(1) 可能只是碰巧绿的 +# (比如 lc 因为别的原因非零退出,而不是因为这条检查)。 +MUT2="$WORK/mutant-guard" +rm -rf "$MUT2"; cp -r "$SRC" "$MUT2" +python3 - "$MUT2/bin/lc" <<'PY' +import sys +p = sys.argv[1] +src = open(p, encoding="utf-8").read() +old = (' die("已拒绝启动 Codex。确实要在这个目录里干活(比如用 Codex 改 airgap-coder "\n' + ' "自己),加上 %s 显式放行。" % ALLOW_WS_SECRETS)\n') +if old not in src: + # 重构过就直接红,而不是让变异测试静默失效、(1) 从此没人替它把关 + sys.exit("::error::变异测试找不到注入点,guard_env_in_workspace() 被改过了——请同步更新本处") +open(p, "w", encoding="utf-8").write(src.replace(old, " return\n")) +PY : > "$CODEX_LOG" -run_at "$SRC" "$WORK/log-code-envwarn" code -cat "$WORK/log-code-envwarn" -assert "在工具目录里跑时警告了" "$WORK/log-code-envwarn" "$WARN_MARK" -assert "点名了是哪个文件" "$WORK/log-code-envwarn" "$ENVF" -assert "说清了暴露面有多大(几个上游)" "$WORK/log-code-envwarn" "2 个上游" -assert "给出了怎么避开" "$WORK/log-code-envwarn" "cd 到你自己的项目目录" -# 警告不许阻断:维护者就在这个仓库里用 Codex 改这个仓库(docs/codex-workflow.md)。 -assert "警告之后照常启动了 Codex" "$CODEX_LOG" "--profile beta" -# 警告本身不许把凭证打出来——那就成了它自己在泄漏 -refute "警告里没有 API Key 的值" "$WORK/log-code-envwarn" "$CANARY_KEY" -refute "警告里没有 master key 的值" "$WORK/log-code-envwarn" "$CANARY_MK" - -# 用户在自己的项目目录里跑:.env 在别处,Codex 的工作区里没有它,不该响。 +( cd "$SRC" && NO_COLOR=1 PATH="$WORK/bin:$PATH" python3 "$MUT2/bin/lc" code < /dev/null ) \ + > "$WORK/log-code-mut2" 2>&1 || true +if [ -s "$CODEX_LOG" ]; then + pass "变异版本确实放行了,说明 (1) 断的真是这条检查" +else + cat "$WORK/log-code-mut2" + fail "变异版本也没启动 Codex——(1) 的非零退出可能来自别的原因,断言是空的" +fi +rm -rf "$MUT2" + +# (3) 用户在自己的项目目录里跑:.env 在别处,Codex 的工作区里没有它,不该响。 mkdir -p "$WORK/userproj" : > "$CODEX_LOG" run_at "$WORK/userproj" "$WORK/log-code-nowarn" code -refute "在自己的项目目录里跑时不警告" "$WORK/log-code-nowarn" "$WARN_MARK" +refute "在自己的项目目录里跑时不报警" "$WORK/log-code-nowarn" "$WARN_MARK" assert "照常启动 Codex" "$CODEX_LOG" "--profile beta" -# 仓库的上一层:.env 仍然在 Codex 的工作区这棵树里,同样该响。判据是「.env 在 +# 逃生阀在不该响的目录里也不许改变行为:只是被摘掉,不透传、不凭空报警。 +: > "$CODEX_LOG" +run_at "$WORK/userproj" "$WORK/log-code-allow-clean" code "$ALLOW_FLAG" +refute "带着逃生阀但没触发时不报警" "$WORK/log-code-allow-clean" "$WARN_MARK" +assert "照常启动 Codex" "$CODEX_LOG" "--profile beta" +refute "逃生阀同样没被透传给 Codex" "$CODEX_LOG" "$ALLOW_FLAG" + +# 仓库的上一层:.env 仍然在 Codex 的工作区这棵树里,同样该阻断。判据是「.env 在 # CWD 树里」而不是「CWD == ROOT」,这条钉住的就是它。 : > "$CODEX_LOG" -run_at "$WORK" "$WORK/log-code-parent" code -assert "在仓库上一层跑时也警告" "$WORK/log-code-parent" "$WARN_MARK" +if run_at_fail "$WORK" "$WORK/log-code-parent" code; then + assert "在仓库上一层跑时也阻断" "$WORK/log-code-parent" "$WARN_MARK" +fi +if [ -s "$CODEX_LOG" ]; then + cat "$CODEX_LOG" + fail "在仓库上一层被判定为暴露,却还是启动了 Codex" +else + pass "在仓库上一层时也没启动 Codex" +fi # 没有 .env 时不该响(首次 clone 还没 lc init 的状态) mv "$ENVF" "$WORK/env.saved" : > "$CODEX_LOG" run_at "$SRC" "$WORK/log-code-noenv" code -refute "没有 .env 时不警告" "$WORK/log-code-noenv" "$WARN_MARK" +refute "没有 .env 时不报警" "$WORK/log-code-noenv" "$WARN_MARK" mv "$WORK/env.saved" "$ENVF" echo "[6g] 空注册表时 lc code 指向 lc init,和 lc test / lc e2e 一个措辞"