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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,18 @@ npm install -g @openai/codex@0.145.0
./bin/lc init # configure a self-hosted upstream
./bin/lc up # start the LiteLLM gateway
./bin/lc test # validate protocol and tool calling
./bin/lc code # start Codex with the selected upstream
```

Then start Codex from the project you want to work on. The tool directory and your working directory are different things:

```bash
cd ~/your-project
~/airgap-coder/bin/lc code # start Codex with the selected upstream
```

> [!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 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`.

This path does **not** require an `OPENAI_API_KEY`. It uses your self-hosted model credential instead. Maintainers can also run [read-only local Codex review](docs/codex-workflow.md) with an existing ChatGPT sign-in.
Expand Down
11 changes: 10 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,18 @@ npm install -g @openai/codex@0.145.0
./bin/lc init # 配置自托管上游
./bin/lc up # 启动 LiteLLM 网关
./bin/lc test # 验证协议和工具调用
./bin/lc code # 使用当前上游启动 Codex
```

然后**到你自己的项目目录里**启动 Codex。工具目录和工作目录是两回事:

```bash
cd ~/your-project
~/airgap-coder/bin/lc code # 使用当前上游启动 Codex
```

> [!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 init` 会询问上游地址、凭证、模型 ID、上下文窗口和后端类型。解析后的地址与凭证只写入 `.env`;可共享的结构写入 `registry.json`。

这条路径**不需要** `OPENAI_API_KEY`,使用的是你的自托管模型凭证。维护者也可以通过现有 ChatGPT 登录态执行[只读的本地 Codex 审查](docs/codex-workflow.md)。
Expand Down
46 changes: 46 additions & 0 deletions bin/lc
Original file line number Diff line number Diff line change
Expand Up @@ -799,11 +799,57 @@ def codex_env():
return env


def env_in_workspace(cwd=None):
"""Codex 这次的工作目录里,是不是躺着我们这份 .env。

判据是「.env 在 CWD 这棵树里」,不是「CWD == ROOT」:用户在仓库的上一层
(`cd ~ && ~/airgap-coder/bin/lc code`)启动时,.env 同样在工作区里。反过来
在自己的项目目录里跑就不该响——一条永远都响的警告等于没有警告。
"""
if not ENVFILE.is_file():
return False
try:
base = pathlib.Path(cwd or os.getcwd()).resolve()
except OSError: # CWD 被删掉了,这不是这条检查该报的错
return False
try:
ENVFILE.relative_to(base)
except ValueError:
return False
return True


def warn_env_in_workspace(reg):
""".env 在工作区里时提醒,但**不阻断**(issue #46)。

#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,等于回到只警告但多一步。真正的解法是目录分离,
所以警告里给的是这条路。
"""
if not env_in_workspace():
return
n = len(reg.get("upstreams") or {})
warn("这次 Codex 的工作目录里有 .env:%s" % ENVFILE)
say(" 会话是 approval_policy = \"never\",模型发的 shell 命令不用批准就执行,"
"一句 `cat .env` 就能读走 %d 个上游的地址与凭证。" % n)
say(" 0600 挡的是别的用户,挡不住以你的身份运行的 Codex。")
say(" %s要避开:cd 到你自己的项目目录再跑 lc code——工具目录和工作目录是"
"两回事。%s" % (C["d"], C["x"]))


def cmd_code(argv):
reg = load_registry()
if not gw_alive(reg):
die("网关没起来,先跑 `lc up`")
profile = _default_target(reg)
warn_env_in_workspace(reg)
sys.exit(subprocess.call(["codex", "--profile", profile] + list(argv),
env=codex_env()))

Expand Down
9 changes: 9 additions & 0 deletions docs/offline-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ cd airgap-coder-0.1.0-YYYYMMDD-HHMMSS
./bin/lc test
```

Run the host-side agent from the project you want to work on, not from the unpacked bundle directory:

```bash
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).

`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.

## 4. Run the Codex container
Expand Down
13 changes: 13 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ 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; it warns rather than refusing (see [Credentials in the Codex workspace](#credentials-in-the-codex-workspace)) | Run `lc code` from your own project directory, not from the airgap-coder directory |
| 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 |

## Credentials in the Codex workspace

`lc code` starts Codex in the **current working directory**, and the generated profile sets `approval_policy = "never"` with `sandbox_mode = "workspace-write"`. Two consequences follow.

First, `.env` is an ordinary file. Its `0600` mode stops other users on the host; it does not stop a Codex process running as you. If the working directory is the airgap-coder directory — or any directory above it — then `.env` is inside the workspace, and one `cat .env` discloses every upstream endpoint, credential, and private header value, including those of upstreams the current profile does not use. The value can then be copied into a workspace file, a patch, or a session log. Passing only the declared `env_key` into the Codex process closes the process-environment path; it does not close this one.

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 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. In the container path this separation already holds, because only the mounted workspace is visible and the entrypoint passes just the gateway key.

## Explicit non-goals

airgap-coder does not provide a hardened container sandbox, content filtering, model safety evaluation, host endpoint protection, secret manager, network firewall, image registry, artifact-signing PKI, or defense against a compromised Docker daemon or host administrator.
Expand Down
47 changes: 47 additions & 0 deletions scripts/test-lc-commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ run_any() { # run_any <日志> <lc 参数...>;不管退出码,只看输出
NO_COLOR=1 PATH="$WORK/bin:$PATH" "${LC[@]}" "$@" < /dev/null > "$log" 2>&1 || true
}

run_at() { # run_at <工作目录> <日志> <lc 参数...>;不管退出码,只看输出
local dir="$1" log="$2"; shift 2
# lc 的 ROOT 由自身路径推导,和 CWD 无关;改 CWD 改的正是「Codex 在哪跑」。
( cd "$dir" && NO_COLOR=1 PATH="$WORK/bin:$PATH" "${LC[@]}" "$@" < /dev/null ) \
> "$log" 2>&1 || true
}

run_fail() { # run_fail <日志> <lc 参数...>;命令必须失败
local log="$1"; shift
if NO_COLOR=1 PATH="$WORK/bin:$PATH" "${LC[@]}" "$@" < /dev/null > "$log" 2>&1; then
Expand Down Expand Up @@ -724,6 +731,46 @@ else
pass "默认上游失效时没有拿不存在的 profile 去启动 Codex"
fi

echo "[6f2] .env 就在 Codex 的工作目录里时给一条警告(issue #46)"
# #42 收的是「进程环境」那条读法;`lc code` 在 CWD 里启动 Codex,配置又是
# approval_policy = "never",工作区里放着 .env 的话一句 `cat .env` 就全拿回去了。
# 两条都要:该响的时候响,**不该响的时候不响**——一条永远都响的警告等于没有警告,
# 用户两周就学会无视它。
cp "$WORK/reg.saved-code" "$REG" # [6f] 把 default 改坏了,这一节要它是好的
WARN_MARK="工作目录里有 .env"
: > "$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 的工作区里没有它,不该响。
mkdir -p "$WORK/userproj"
: > "$CODEX_LOG"
run_at "$WORK/userproj" "$WORK/log-code-nowarn" code
refute "在自己的项目目录里跑时不警告" "$WORK/log-code-nowarn" "$WARN_MARK"
assert "照常启动 Codex" "$CODEX_LOG" "--profile beta"

# 仓库的上一层:.env 仍然在 Codex 的工作区这棵树里,同样该响。判据是「.env 在
# CWD 树里」而不是「CWD == ROOT」,这条钉住的就是它。
: > "$CODEX_LOG"
run_at "$WORK" "$WORK/log-code-parent" code
assert "在仓库上一层跑时也警告" "$WORK/log-code-parent" "$WARN_MARK"

# 没有 .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"
mv "$WORK/env.saved" "$ENVF"

echo "[6g] 空注册表时 lc code 指向 lc init,和 lc test / lc e2e 一个措辞"
echo "{\"default\": null, \"gateway_port\": $PORT, \"upstreams\": {}}" > "$REG"
: > "$CODEX_LOG"
Expand Down