Files
cgalo5758 b245bdb0a9 Fix security audit findings from 2026-09-21 scan
Remediate six confirmed security issues: deployment-only config keys,
bounded provider responses, short-lived registration sessions, private
init file mode, FedWiki workflow authorization, and switch preview
gates.

- Add DeploymentOnly config key declaration; refuse runtime overrides
  for keys that decide where secrets are sent
- Create httplimit package; bound all provider response reads at 8 MiB
- Set fifteen-minute deadline on /register sessions
- Write mc-config.yaml with 0600 permissions
- Derive FedWiki workflow IDs from site IDs; re-authorize sites before
  mutating activities
- Apply switch authorization gates to the proration preview
2026-09-21 13:57:57 -05:00

278 lines
13 KiB
Bash

# Sourced by run-audit.sh and run-ux.sh: everything that differs between the
# coding agents a lane can run. A MODELS entry is "<harness>:<model>"; an
# entry with no prefix is an opencode id, so the original spelling still works.
#
# opencode:<provider>/<model> ids from `opencode models`
# codex:<slug> slugs the Codex CLI lists in its model picker
# (ChatGPT plan or API key, `codex login`)
#
# A credential enters a lane container through harness_mount and nowhere
# else, so each container carries exactly the one file its harness needs:
# opencode the host's auth.json, read-only.
# codex a copy of the host's ~/.codex/auth.json inside a throwaway
# CODEX_HOME, writable because codex rewrites that file when it
# refreshes the ChatGPT token. The copy is deleted with the lane;
# a refreshed token is copied back first, since the refresh may
# have rotated the host's token out of validity.
#
# VARIANT is the reasoning effort in each harness's own vocabulary. opencode
# passes it as --variant. codex passes it as model_reasoning_effort; the set
# is per model (low, medium, high, xhigh, max, ultra), and ultra delegates to
# subagents inside the same container.
#
# A plan's usage window can run out mid-task. When a harness reports that,
# the lane keeps its state, waits QUOTA_POLL seconds, and resumes the same
# session in a fresh container, repeating until the task finishes or
# QUOTA_WAIT seconds of waiting are spent. A rejected retry costs nothing.
# Detection is per harness; opencode has none yet, so its lanes fail as before.
HARNESSES="opencode codex"
QUOTA_POLL="${QUOTA_POLL:-900}"
QUOTA_WAIT="${QUOTA_WAIT:-21600}"
OPENCODE_AUTH="${AUDIT_AUTH_JSON:-$HOME/.local/share/opencode/auth.json}"
CODEX_AUTH="${AUDIT_CODEX_AUTH_JSON:-${CODEX_HOME:-$HOME/.codex}/auth.json}"
# The Codex Security plugin, as `codex plugin add codex-security@openai-curated-remote`
# leaves it on the host. A codex lane ships it in its throwaway CODEX_HOME;
# a task that names it (the codex-security/ shape) runs with it on.
CODEX_SECURITY_HOST="${AUDIT_CODEX_SECURITY_DIR:-$(dirname "$CODEX_AUTH")/plugins/cache/openai-curated-remote/codex-security}"
CODEX_SECURITY_LANE=/root/.codex/plugins/cache/openai-curated-remote/codex-security
HARNESS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# docker compose against this runner from any cwd. An array, not only a
# function, because `timeout` runs a program and cannot run a function.
HARNESS_COMPOSE=(docker compose -f "$HARNESS_DIR/compose.yaml" --project-directory "$HARNESS_DIR")
harness_compose() { "${HARNESS_COMPOSE[@]}" "$@"; }
# harness_of MODEL: the harness a MODELS entry names; opencode when it names none.
harness_of() {
local h="${1%%:*}"
if [[ "$1" == *:* && " $HARNESSES " == *" $h "* ]]; then echo "$h"; else echo opencode; fi
}
# model_of MODEL: the entry with a recognised harness prefix removed.
model_of() {
local h; h="$(harness_of "$1")"
if [[ "$1" == "$h:"* ]]; then echo "${1#"$h:"}"; else echo "$1"; fi
}
# safe_name MODEL: a directory name for the lane.
safe_name() { local s="${1//\//__}"; echo "${s//:/__}"; }
# harness_auth_path HARNESS: the host file that harness signs in with.
harness_auth_path() {
case "$1" in opencode) echo "$OPENCODE_AUTH";; codex) echo "$CODEX_AUTH";; esac
}
# harness_check_auth MODEL...: fail before any container starts if a named
# model's harness has no credential on the host.
harness_check_auth() {
local m h f rc=0
for m in "$@"; do
h="$(harness_of "$m")"; f="$(harness_auth_path "$h")"
if [[ ! -f "$f" ]]; then
echo "ERROR: no $h credential at $f (needed for $m)." >&2
case "$h" in
opencode) echo " Sign in on the host with 'opencode auth login' or set AUDIT_AUTH_JSON." >&2;;
codex) echo " Sign in on the host with 'codex login' or set AUDIT_CODEX_AUTH_JSON." >&2;;
esac
rc=1
fi
done
return $rc
}
# harness_mount HARNESS STATE_DIR: prints, one per line, the docker arguments
# that hand a lane container its credential. STATE_DIR is an absolute path the
# lane owns; codex state is created under it.
harness_mount() {
local h="$1" state="$2"
case "$h" in
opencode)
printf -- '-v\n%s:/root/.local/share/opencode/auth.json:ro\n' "$OPENCODE_AUTH";;
codex)
mkdir -p "$state/codex-home"
install -m 600 "$CODEX_AUTH" "$state/codex-home/auth.json"
# Remember which host token the lane started from, so release can tell
# a lane-side refresh from a host-side re-login.
jq -r '.last_refresh // ""' "$CODEX_AUTH" >"$state/host-last-refresh" 2>/dev/null || : >"$state/host-last-refresh"
codex_security_ship "$state/codex-home"
printf -- '-v\n%s:/root/.codex\n' "$state/codex-home";;
esac
}
# codex_security_ship CODEX_HOME_DIR: copy the plugin from the host into the
# lane's home and write the config that turns it on. codex only reads that
# config when a task drops --ignore-user-config (harness_command's PLUGINS
# argument), so slices and open briefs never see the plugin. The plugin's
# MCP server is registered directly from its .mcp.json, because codex does
# not start a plugin's server from the cache alone; the paths are the
# container's, since the config is read there.
codex_security_ship() {
local home="$1" ver
[[ -d "$CODEX_SECURITY_HOST" ]] || return 0
ver="$(ls "$CODEX_SECURITY_HOST" | grep -E '^[0-9]' | sort -V | tail -1)"
[[ -n "$ver" ]] || return 0
mkdir -p "$home/plugins/cache/openai-curated-remote"
cp -r "$CODEX_SECURITY_HOST" "$home/plugins/cache/openai-curated-remote/"
python3 - "$CODEX_SECURITY_HOST/$ver/.mcp.json" "$CODEX_SECURITY_LANE/$ver" "$home/config.toml" <<'EOF'
import json, sys
mcp_json, lane_dir, out = sys.argv[1:4]
m = json.load(open(mcp_json))["mcpServers"]["codex-security"]
lines = [
'[plugins."codex-security@openai-curated-remote"]', 'enabled = true', '',
'[mcp_servers.codex-security]',
f'command = "{lane_dir}/scripts/launch_codex_security_mcp"',
'args = ["--stdio"]',
f'cwd = "{lane_dir}"',
f'startup_timeout_sec = {m["startup_timeout_sec"]}',
f'tool_timeout_sec = {m["tool_timeout_sec"]}',
'env_vars = ' + json.dumps(m["env_vars"]),
]
open(out, "w").write("\n".join(lines) + "\n")
EOF
echo "$ver" >"$home/codex-security-version"
}
# harness_release HARNESS STATE_DIR: after the lane, hand back what the
# container changed and remove the state.
harness_release() {
local h="$1" state="$2"
if [[ "$h" == codex ]]; then
# The container runs as root and fills CODEX_HOME (system skills, caches,
# sqlite state, possibly a rewritten auth.json) with root-owned files, so
# the state comes back to the invoking user through a container first.
harness_compose run --rm --no-deps -T -v "$state:/state" auditor \
chown -R "$(id -u):$(id -g)" /state >/dev/null 2>&1 || true
if [[ -f "$state/codex-home/auth.json" ]]; then
# The lane's token goes back to the host only when the lane refreshed
# it (strictly newer than what it started from) AND the host still
# holds the token the lane started from. A host that re-logged in
# meanwhile owns the newer token; overwriting it would hand the host a
# revoked one (which is how the sol lane of 2026-09-21 broke the host
# login). Timestamps are RFC 3339 UTC, so string order is time order.
local started now lane
started="$(cat "$state/host-last-refresh" 2>/dev/null || true)"
now="$(jq -r '.last_refresh // ""' "$CODEX_AUTH" 2>/dev/null || true)"
lane="$(jq -r '.last_refresh // ""' "$state/codex-home/auth.json" 2>/dev/null || true)"
if [[ -n "$lane" && "$lane" > "$started" ]]; then
if [[ "$now" == "$started" ]]; then
install -m 600 "$state/codex-home/auth.json" "$CODEX_AUTH"
echo " codex refreshed its ChatGPT token in the lane; the host copy at $CODEX_AUTH now carries it"
else
echo " codex refreshed its token in the lane, but the host signed in again meanwhile; host left alone, lane copy kept at $state/codex-home/auth.json"
return 0
fi
fi
fi
fi
rm -rf "$state"
rmdir --ignore-fail-on-non-empty "$(dirname "$state")" 2>/dev/null || true
}
# harness_command HARNESS MODEL VARIANT PROMPT_SH [PLUGINS]: prints the
# command a lane runs in the container with `bash -c`. PROMPT_SH is a shell
# snippet, run in the container, that prints the prompt. Both agents print
# their final answer on stdout and their progress on stderr, so a lane
# captures stdout as the artifact and stderr as the log. PLUGINS, when
# non-empty, runs the task with the harness's shipped plugins on (codex only).
harness_command() {
local h="$1" model="$2" variant="$3" prompt_sh="$4" plugins="${5:-}" vflag=""
case "$h" in
opencode)
[[ -n "$variant" ]] && vflag="--variant '$variant'"
# stdin closed: the headless quirk.
echo "opencode run --model '$model' $vflag \"\$($prompt_sh)\" </dev/null";;
codex)
# The prompt arrives on stdin (-). The copy has no .git, hence
# --skip-git-repo-check. project_doc_max_bytes=0 keeps the repo's
# AGENTS.md, which addresses the harnesses that develop it, out of the
# auditor's context. The session is recorded (in the lane's throwaway
# CODEX_HOME) so a usage-limit stop can be resumed. The container is the
# sandbox (internal network, disposable copy), which is the case the
# bypass flag is documented for; codex's own Landlock sandbox is not
# relied on inside it.
echo "{ $prompt_sh; } | codex exec $(codex_flags "$model" "$variant" "$plugins") -";;
esac
}
codex_flags() {
local model="$1" variant="$2" plugins="${3:-}" vflag="" cfg="--ignore-user-config"
[[ -n "$variant" ]] && vflag="-c model_reasoning_effort='\"$variant\"'"
# The lane's config.toml holds nothing but the shipped plugins, so reading
# it is what turns them on.
[[ -n "$plugins" ]] && cfg=""
echo "--model '$model' $vflag -c project_doc_max_bytes=0 --skip-git-repo-check" \
"$cfg --dangerously-bypass-approvals-and-sandbox"
}
# harness_quota_hit HARNESS ERR: true when the attempt stopped because the
# plan's usage window is spent (or the provider rate-limited it), which a
# later retry can get past. Anything else is a failure to report. Only the
# harness's own ERROR lines are read: the model's transcript and report also
# talk about rate limits, as findings.
harness_quota_hit() {
local h="$1" err="$2"
case "$h" in
codex) grep -E '^[^a-z]*ERROR' "$err" 2>/dev/null | grep -qiE 'usage.?limit|rate.?limit|spend cap|try again';;
*) return 1;;
esac
}
# harness_resume_command HARNESS MODEL VARIANT [PLUGINS]: the command that
# continues the lane's most recent session with the same flags.
harness_resume_command() {
local h="$1" model="$2" variant="$3" plugins="${4:-}"
case "$h" in
codex)
echo "codex exec resume --last $(codex_flags "$model" "$variant" "$plugins")" \
"'A usage limit interrupted you. Continue from where you stopped and finish the report in the required shape.'";;
esac
}
# harness_run_task HARNESS MODEL VARIANT PROMPT_SH OUT ERR LANE_LOG [ENV_SH]
# [PLUGINS]: runs one task to completion. Every attempt is a fresh --rm
# container; stdout appends to OUT (the artifact), stderr to ERR (the log).
# The caller puts the lane's docker arguments (credential mount, extra
# volumes) in the LANE_DOCKER array and sets TIMEOUT, the budget of a single
# attempt. ENV_SH is a shell snippet run in the container before the agent
# (exports a task needs); PLUGINS turns the shipped plugins on. Returns the
# last exit code.
harness_run_task() {
local h="$1" model="$2" variant="$3" prompt_sh="$4" out="$5" err="$6" lane_log="$7" env_sh="${8:-}" plugins="${9:-}"
local cmd attempt=1 rc deadline=$(( $(date +%s) + QUOTA_WAIT ))
cmd="$(harness_command "$h" "$model" "$variant" "$prompt_sh" "$plugins")"
: >"$out"; : >"$err"
while :; do
echo "== attempt $attempt, $(date -Is)" >>"$err"
if timeout "$TIMEOUT" "${HARNESS_COMPOSE[@]}" run --rm --no-deps -T "${LANE_DOCKER[@]}" auditor \
bash -c "${env_sh:+$env_sh; }$cmd" >>"$out" 2>>"$err"; then rc=0; else rc=$?; fi
if grep -q 'Automatically switched' "$err"; then
echo " note: the harness switched model mid-run (usage limits); see $err" | tee -a "$lane_log"
fi
if [[ $rc -eq 0 ]]; then return 0; fi
if ! harness_quota_hit "$h" "$err"; then
# Not a quota stop: put the harness's own ERROR lines in the lane log so
# a SUSPECT verdict explains itself (a policy refusal, an auth failure).
grep -E '^ERROR:' "$err" | sort -u | sed 's/^/ /' >>"$lane_log" || true
return $rc
fi
if (( $(date +%s) + QUOTA_POLL > deadline )); then
echo " quota: wait budget (QUOTA_WAIT=${QUOTA_WAIT}s) spent after $attempt attempt(s)" | tee -a "$lane_log"
return $rc
fi
echo " quota: usage limit on attempt $attempt at $(date +%H:%M:%S); resuming in ${QUOTA_POLL}s" | tee -a "$lane_log"
sleep "$QUOTA_POLL"
cmd="$(harness_resume_command "$h" "$model" "$variant" "$plugins")"
attempt=$((attempt + 1))
done
}
# harness_own_output DIR: files an agent wrote under the findings mount belong
# to root; hand the lane's directory back to the invoking user. DIR is a host
# path under OUT.
harness_own_output() {
local dir="$1" rel="${1#"$OUT"/}"
[[ -d "$dir" && "$rel" != "$1" ]] || return 0
harness_compose run --rm --no-deps -T auditor chown -R "$(id -u):$(id -g)" "/out/$rel" >/dev/null 2>&1 || true
}