Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

Expand All @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

Expand All @@ -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)。

Expand All @@ -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` |
Expand Down
51 changes: 42 additions & 9 deletions bin/lc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -819,19 +820,39 @@ 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 的,而生成的配置是
approval_policy = "never":模型发的 shell 命令不用批准直接执行。工作区里放着
.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
Expand All @@ -840,16 +861,27 @@ 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):
reg = load_registry()
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()))

Expand Down Expand Up @@ -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"),
Expand Down
32 changes: 26 additions & 6 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,48 @@ 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` 把凭证写进那个目录的
# .env 之后——顺着文档走必然把它挂进来。容器里只注入了 LITELLM_MASTER_KEY
# (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 顶层。
Expand Down
Loading