Skip to content

Commit cf1a84b

Browse files
committed
Self-audit: agent could overwrite its own guardrails; missing-env was retryable
Two bugs found by inspecting the built image before the review agents reported. BLOCKER -- the agent owned its own constraints. The runner runs AS the `pi` user, and the Dockerfile COPY --chown=pi:pi'd the guardrails, the runner, and (via chown -R /app) pi's own node_modules. Verified against the real image: as pi, all three were writable. echo x > /opt/pi-dispatch/HARD_RULES.md # succeeded echo x > /app/image/runner/run-job.mjs # succeeded echo x > /app/node_modules/.poison # succeeded /job:ro exists precisely so the agent cannot rewrite its instructions; leaving its code and its safety floor agent-writable is the identical hole through a different door. The container is ephemeral so it is not trivially exploitable within one run, but that argument is too fragile to rest a security property on -- if anything re-reads the guardrails mid-session (compaction, which is OQ-003) the floor is poisonable, and the fix costs nothing. Only ~/.pi/agent (pi writes auth.json) and /ms-playwright (pi reads browsers) are chowned to pi now. The guardrails and /app are root-owned and `chmod -R a-w`; pi reads and executes them, which is all it needs. Verified: all three now Permission denied to pi, pi still imports the package, agent dir still writable. Correctness -- a missing required env var exited 1 (retryable). requireEnv threw "missing required env: PI_PROVIDER", which classifyThrow's config regex did not match, so it fell to EXIT_INFRA. But the worker passes the same absent value on every retry, so the queue would pay to rediscover a deterministic misconfiguration. Now matched -> exit 2. Verified in the image: the runner exits 2. Added classify-throw.test.mjs covering the config/infra split.
1 parent 3e2be9b commit cf1a84b

3 files changed

Lines changed: 55 additions & 18 deletions

File tree

image/Dockerfile

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,28 +91,35 @@ ENV PLAYWRIGHT_MCP_BROWSER=chromium \
9191
# Non-root. CONST-ISOLATION-CONTAINER-PER-JOB.
9292
RUN useradd --create-home --shell /bin/bash --uid 1001 pi
9393

94-
# pi lazily creates ~/.pi/agent (0700) and writes auth.json (0600) on the FIRST credential
95-
# operation -- ensureParentDir/ensureFileExists run inside withLock. Root-owned means the
96-
# job dies EACCES at runtime, inside the container, on a path nothing here hints at.
94+
# ONLY these two are chowned to pi, because they are the only things the runtime user must
95+
# WRITE. Everything else it merely reads or executes, and code the agent can rewrite is not a
96+
# boundary -- see the guardrails/runner COPYs below.
9797
#
98-
# `COPY --chown` alone does NOT fix this: it does not apply to parent directories COPY
99-
# auto-creates, so /home/pi/.pi would still be born root:root. The trap survives the
100-
# obvious fix. Create and chown explicitly.
98+
# ~/.pi/agent: pi lazily creates it (0700) and writes auth.json (0600) on the FIRST credential
99+
# operation (ensureParentDir/ensureFileExists inside withLock). Root-owned means the job dies
100+
# EACCES at runtime, on a path nothing here hints at. `COPY --chown` would not fix this anyway:
101+
# it does not apply to parent dirs COPY auto-creates, so /home/pi/.pi would still be root:root.
102+
# /ms-playwright: the runtime user must be able to read the root-installed browser binaries.
101103
RUN mkdir -p /home/pi/.pi/agent \
102104
&& chown -R pi:pi /home/pi/.pi \
103-
&& chown -R pi:pi /ms-playwright \
104-
&& chown -R pi:pi /app
105+
&& chown -R pi:pi /ms-playwright
105106

106-
# The safety floor. Deliberately NOT at ~/.pi/agent/APPEND_SYSTEM.md: a trusted project's
107-
# .pi/APPEND_SYSTEM.md shadows that path via an early return in discoverAppendSystemPromptFile,
108-
# which would delete these rules from the prompt with no error and a job that succeeds. The
109-
# runner reads this path explicitly instead, so discovery cannot shadow it.
110-
COPY --chown=pi:pi guardrails/HARD_RULES.md /opt/pi-dispatch/HARD_RULES.md
107+
# The safety floor and the runner are ROOT-OWNED and NOT writable by pi. The agent runs AS pi;
108+
# if it could overwrite /opt/pi-dispatch/HARD_RULES.md it would own its own constraints, and if
109+
# it could overwrite /app it would own the runner and pi itself. `/job:ro` exists precisely so
110+
# the agent cannot rewrite its instructions -- leaving its code agent-writable is the identical
111+
# hole through a different door. Root owns it; pi reads it (npm's 0644/0755 defaults suffice).
112+
#
113+
# The guardrails path is deliberately NOT ~/.pi/agent/APPEND_SYSTEM.md: a trusted project's
114+
# .pi/APPEND_SYSTEM.md shadows that via an early return in discoverAppendSystemPromptFile, which
115+
# would delete the floor from the prompt with no error. The runner reads this path explicitly.
116+
COPY guardrails/HARD_RULES.md /opt/pi-dispatch/HARD_RULES.md
111117

112118
# Source last: everything above is cacheable and this layer changes on every commit.
113-
COPY --chown=pi:pi image/runner /app/image/runner
114-
COPY --chown=pi:pi image/entrypoint.sh /entrypoint.sh
115-
RUN chmod +x /entrypoint.sh
119+
COPY image/runner /app/image/runner
120+
COPY image/entrypoint.sh /entrypoint.sh
121+
RUN chmod +x /entrypoint.sh \
122+
&& chmod -R a-w /opt/pi-dispatch /app/image/runner /app/node_modules
116123

117124
USER pi
118125
WORKDIR /workspace

image/runner/src/outcome.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ export const STOP_REASONS = ["stop", "length", "toolUse", "error", "aborted"];
2626
export function classifyThrow(error) {
2727
const message = error instanceof Error ? error.message : String(error);
2828

29-
// Config errors. Retrying cannot fix these, so do not let the queue try.
30-
if (/no model|model not|no api key|no.*credential|not authenticated/i.test(message)) {
29+
// Config errors. Retrying cannot fix these, so do not let the queue try. A missing required
30+
// env var is deterministic misconfiguration -- the worker will pass the same (absent) value
31+
// on every retry, so exit 2 (not retried), not 1 (retryable).
32+
if (/no model|model not|no api key|no.*credential|not authenticated|missing required env/i.test(message)) {
3133
return { code: EXIT_POLICY, reason: "config", message };
3234
}
3335

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { classifyThrow, EXIT_INFRA, EXIT_POLICY } from "../src/outcome.mjs";
4+
5+
// Retrying a deterministic config error pays to rediscover it. Every string a real
6+
// deployment throws before the agent loop must land on exit 2, not the retryable 1.
7+
test("config errors are exit 2 (not retried), not exit 1", () => {
8+
for (const message of [
9+
"missing required env: PI_PROVIDER",
10+
"missing required env: PI_MODEL",
11+
"No model selected",
12+
"No API key found for provider anthropic",
13+
"provider anthropic is not authenticated",
14+
]) {
15+
assert.equal(classifyThrow(new Error(message)).code, EXIT_POLICY, message);
16+
}
17+
});
18+
19+
test("genuine infra/our-bug errors stay retryable (exit 1)", () => {
20+
for (const message of ["Agent is already processing.", "ECONNRESET", "socket hang up"]) {
21+
assert.equal(classifyThrow(new Error(message)).code, EXIT_INFRA, message);
22+
}
23+
});
24+
25+
test("a non-Error throw is classified, not crashed on", () => {
26+
assert.equal(classifyThrow("bare string").code, EXIT_INFRA);
27+
assert.equal(classifyThrow(undefined).code, EXIT_INFRA);
28+
});

0 commit comments

Comments
 (0)