Skip to content

Commit ed9fcb2

Browse files
feat: add secure Covencat autoreview and repair loop (#12)
* feat: add secure Covencat autoreview repair loop * build: add immutable sandboxed worker image * chore: verify production worker boundaries * feat: add protected OAuth refresh and ingress relay * fix: mount ephemeral Codex OAuth credential * fix: review trusted failures before repair * fix: derive findings from mapped trusted failures * fix: isolate hosted mode CLI flags * fix: prefer trusted execution receipts * fix(security): redact command artifact arguments * fix(review): embed trusted receipt evidence * fix(review): require relevant support evidence * fix(repair): verify pushed revision safely * fix(review): classify bounded scope correctly * Capture complete local PR diff evidence * Retain full local review patches * Capture PR diffs from the merge base * Ignore non-material bounded-scope notes * Match only real binary diff markers
1 parent 658e542 commit ed9fcb2

17 files changed

Lines changed: 2206 additions & 179 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ keys/
99
coven-github-policy.json
1010
coven-github-state/
1111
.coven-github-private-key.pem
12+
deploy/artifacts/

README.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,15 @@ The deployment expects secrets and mutable state to be supplied outside git:
6464
- `COVEN_CODE_BIN` - absolute coven-code path inside that rootfs
6565
- `COVEN_RUNTIME_NETWORK=shared` - explicit opt-in required when the Codex
6666
provider needs network access; the default is `none`
67-
- `COVEN_REVIEW_FIX_LOOPS` - optional bounded review-fix loop count, clamped
68-
between `0` and `5`; defaults to `0` so hosted repair loops are opt-in
67+
- Automatic review and repair are repository-policy controls, not ambient
68+
environment switches. `autoreview.enabled` and `repair.enabled` must each be
69+
opted into explicitly; `kill_switch` stops new routing and repair pushes.
6970
- Codex OAuth tokens under the deployed account's `.coven-code` directory
7071

72+
The host refreshes an expiring Codex OAuth session through the fixed OpenAI
73+
token endpoint and atomically updates the private token file. Only the resulting
74+
short-lived access token enters the model sandbox; the refresh token never does.
75+
7176
Do not commit private keys, webhook secrets, OAuth tokens, generated task state,
7277
workspaces, or attempt artifacts.
7378

@@ -82,7 +87,9 @@ records the task as `runtime_isolation_unavailable` with no direct fallback.
8287
The runtime rootfs must contain the configured `coven-code`, `git`, and shell
8388
executables plus their libraries, CA/DNS files, and approved runtime assets. It
8489
must not contain the GitHub App key, webhook state, policy, parent home, or Codex
85-
token store. The runtime receives only its dedicated model credential; it never
90+
token store. The private PID namespace receives an empty `/proc`, avoiding host
91+
procfs exposure and nested procfs-mount authority. The runtime receives only its
92+
dedicated model credential; it never
8693
receives a GitHub token or Git askpass helper. Shared networking is not an
8794
egress-confidentiality boundary, so use a dedicated, revocable model credential
8895
and an externally enforced allowlist that blocks loopback, LAN, and metadata
@@ -185,7 +192,9 @@ connection guide in
185192
sandbox.
186193
- Uses repository-scoped installation tokens: parent Git gets only
187194
`contents:read`, PR evidence gets read authority, and publication write
188-
authority is minted only after isolated execution has finished.
195+
authority is minted only after isolated execution has finished. An opted-in
196+
repair mints a separate short-lived token with only `contents:write` and
197+
`pull_requests:read`; the model never receives it.
189198
- Persists `publication_pending` before GitHub writes and resumes interrupted
190199
publication on startup or duplicate webhook delivery without rerunning the
191200
agent.
@@ -198,6 +207,12 @@ connection guide in
198207
- Publishes non-PR task results and operational notices as issue comments,
199208
including structured `reviewed_files`, `supporting_files`, findings, test
200209
evidence, no-findings rationale, and limitations.
201-
- When `COVEN_REVIEW_FIX_LOOPS` is greater than `0`, reruns `coven-code` with
202-
prior structured review findings as explicit repair instructions until no
203-
findings remain or the configured loop count is exhausted.
210+
- With explicit `autoreview.enabled`, routes opened, ready-for-review, reopened,
211+
and synchronized pull-request revisions by repository, PR number, and exact
212+
head SHA. Drafts remain excluded unless `include_drafts` is enabled.
213+
- With separate `repair.enabled`, an evidence-complete REQUEST_CHANGES review
214+
may launch a file-write-only hosted repair. The trusted host rejects forks,
215+
protected branches and paths, oversized or unrelated diffs, stale heads, and
216+
failed validation; it then creates a Covencat-attributed non-force commit and
217+
queues a fresh review of the new SHA. The loop stops after the configured
218+
`max_attempts` (clamped to 1-3) or on repeated findings or non-progress.

config/example-policy.json

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"issues.labeled",
99
"issue_comment.created",
1010
"pull_request_review_comment.created",
11+
"pull_request.opened",
12+
"pull_request.ready_for_review",
1113
"pull_request.synchronize",
1214
"pull_request.edited",
1315
"pull_request.reopened",
@@ -31,8 +33,36 @@
3133
]
3234
},
3335
"publication": {
34-
"mode": "record_only"
35-
}
36+
"mode": "record_only",
37+
"validation_commands": [
38+
"npm test"
39+
],
40+
"validation_timeout_seconds": 300
41+
},
42+
"autoreview": {
43+
"enabled": false,
44+
"include_drafts": false
45+
},
46+
"repair": {
47+
"enabled": false,
48+
"max_attempts": 2,
49+
"max_changed_files": 8,
50+
"max_diff_bytes": 262144,
51+
"allowed_paths": [
52+
"src/**",
53+
"tests/**"
54+
],
55+
"protected_branches": [
56+
"release/**"
57+
]
58+
},
59+
"protected_paths": [
60+
".github/**",
61+
"CODEOWNERS",
62+
"**/CODEOWNERS"
63+
],
64+
"kill_switch": false,
65+
"enabled": true
3666
}
3767
}
3868
}

deploy/Containerfile

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
ARG NODE_IMAGE=docker.io/library/node@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf
2+
3+
FROM ${NODE_IMAGE} AS egress
4+
ARG COVEN_BUILD_EGRESS_HOSTS="github.com api.github.com chatgpt.com auth.openai.com"
5+
ENV COVEN_BUILD_EGRESS_HOSTS=${COVEN_BUILD_EGRESS_HOSTS}
6+
COPY deploy/resolve-egress.mjs /resolve-egress.mjs
7+
RUN node /resolve-egress.mjs
8+
9+
FROM ${NODE_IMAGE} AS build
10+
WORKDIR /src
11+
COPY package.json package-lock.json ./
12+
RUN npm ci
13+
COPY tsconfig.json ./
14+
COPY src ./src
15+
RUN npm run build && npm prune --omit=dev
16+
17+
FROM ${NODE_IMAGE} AS sandbox-rootfs
18+
RUN apt-get update \
19+
&& apt-get install -y --no-install-recommends ca-certificates git libasound2 \
20+
&& rm -rf /var/lib/apt/lists/*
21+
COPY --from=egress /out/hosts /etc/hosts
22+
COPY deploy/artifacts/coven-code /usr/local/bin/coven-code
23+
RUN chmod 0755 /usr/local/bin/coven-code \
24+
&& mkdir -p /workspace \
25+
&& test -x /usr/bin/git \
26+
&& test -x /bin/sh \
27+
&& test -x /bin/true
28+
29+
FROM ${NODE_IMAGE} AS runtime
30+
ARG WEBHOOK_REVISION
31+
ARG COVEN_CODE_REVISION
32+
ARG COVEN_CODE_BINARY_SHA256
33+
LABEL org.opencontainers.image.revision=${WEBHOOK_REVISION}
34+
LABEL tech.complete.coven-code.revision=${COVEN_CODE_REVISION}
35+
LABEL tech.complete.coven-code.binary-sha256=${COVEN_CODE_BINARY_SHA256}
36+
RUN apt-get update \
37+
&& apt-get install -y --no-install-recommends bubblewrap ca-certificates git nftables tini util-linux \
38+
&& rm -rf /var/lib/apt/lists/*
39+
COPY --from=egress /out/hosts /etc/hosts
40+
COPY --from=egress /out/egress-ipv4.txt /usr/share/coven/egress-ipv4.txt
41+
COPY --from=egress /out/egress-hosts.txt /usr/share/coven/egress-hosts.txt
42+
COPY deploy/probe-runtime.mjs /usr/share/coven/probe-runtime.mjs
43+
COPY deploy/verify-github-app.mjs /usr/share/coven/verify-github-app.mjs
44+
COPY --from=sandbox-rootfs / /opt/coven-runtime/rootfs
45+
COPY --from=build /src/dist /app/dist
46+
COPY --from=build /src/node_modules /app/node_modules
47+
COPY package.json package-lock.json /app/
48+
COPY deploy/worker-entrypoint.sh /usr/local/bin/coven-worker-entrypoint
49+
RUN chmod 0755 /usr/local/bin/coven-worker-entrypoint \
50+
&& mkdir -p /var/lib/coven /run/coven-config /home/coven-service \
51+
&& chown 1000:1000 /var/lib/coven /home/coven-service \
52+
&& chmod 0700 /var/lib/coven /home/coven-service \
53+
&& chmod 0755 /run/coven-config
54+
WORKDIR /app
55+
ENV NODE_ENV=production \
56+
HOME=/home/coven-service \
57+
PORT=3000 \
58+
COVEN_GITHUB_STATE_DIR=/var/lib/coven \
59+
COVEN_GITHUB_POLICY_PATH=/run/coven-config/policy.json \
60+
GITHUB_APP_PRIVATE_KEY_PATH=/run/coven-config/github-app.pem \
61+
COVEN_CODE_CODEX_TOKENS_PATH=/run/coven-config/codex/codex_tokens.json \
62+
COVEN_RUNTIME_ISOLATION=bwrap \
63+
COVEN_RUNTIME_EXTERNAL_ISOLATION=network-egress-and-resource-limits-verified \
64+
COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified \
65+
COVEN_BWRAP_BIN=/usr/bin/bwrap \
66+
COVEN_RUNTIME_ROOTFS=/opt/coven-runtime/rootfs \
67+
COVEN_CODE_BIN=/usr/local/bin/coven-code \
68+
COVEN_RUNTIME_GIT_BIN=/usr/bin/git \
69+
COVEN_RUNTIME_SHELL_BIN=/bin/sh \
70+
COVEN_HOST_GIT_BIN=/usr/bin/git \
71+
COVEN_RUNTIME_NETWORK=shared \
72+
COVEN_STATE_FILESYSTEM_MAX_BYTES=8589934592
73+
EXPOSE 3000
74+
ENTRYPOINT ["/usr/local/bin/coven-worker-entrypoint"]
75+
CMD ["node", "dist/src/server.js"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[Unit]
2+
Description=Restricted Covencat ingress tunnel
3+
After=network-online.target covencat-worker.service
4+
Wants=network-online.target
5+
Requires=covencat-worker.service
6+
7+
[Service]
8+
Type=simple
9+
ExecStart=/usr/bin/ssh -N -T \
10+
-o BatchMode=yes -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes \
11+
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
12+
-o StrictHostKeyChecking=yes \
13+
-o UserKnownHostsFile=%h/.ssh/covencat_known_hosts \
14+
-i %h/.ssh/covencat_ingress_ed25519 \
15+
-R 127.0.0.1:18096:127.0.0.1:3900 \
16+
replace-with-ingress-user@replace-with-ingress-host
17+
Restart=always
18+
RestartSec=5
19+
20+
[Install]
21+
WantedBy=default.target
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[Unit]
2+
Description=Bounded persistent Covencat state filesystem
3+
After=local-fs.target
4+
5+
[Service]
6+
Type=simple
7+
Environment=FUSE2FS_ROOT=%h/.local/opt/fuse2fs
8+
Environment=STATE_IMAGE=%h/covencat-deploy/state-volume/covencat-state.ext4
9+
Environment=STATE_MOUNT=%h/covencat-deploy/state-volume/mount
10+
ExecStartPre=/usr/bin/test -f ${STATE_IMAGE}
11+
ExecStartPre=/usr/bin/mkdir -p ${STATE_MOUNT}
12+
ExecStart=/usr/bin/env LD_LIBRARY_PATH=${FUSE2FS_ROOT}/usr/lib/x86_64-linux-gnu:${FUSE2FS_ROOT}/lib/x86_64-linux-gnu ${FUSE2FS_ROOT}/usr/bin/fuse2fs ${STATE_IMAGE} ${STATE_MOUNT} -f -o rw,nosuid,nodev,fakeroot,uid=1000,gid=1000
13+
ExecStartPost=/usr/bin/chmod 0700 ${STATE_MOUNT}
14+
ExecStop=/usr/bin/fusermount3 -u ${STATE_MOUNT}
15+
Restart=on-failure
16+
RestartSec=5
17+
TimeoutStopSec=30
18+
19+
[Install]
20+
WantedBy=default.target
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[Unit]
2+
Description=Covencat immutable GitHub review worker
3+
Requires=covencat-state-volume.service
4+
After=network-online.target covencat-state-volume.service
5+
Wants=network-online.target
6+
7+
[Service]
8+
Environment=IMAGE_DIGEST=replace-with-local-image-digest
9+
Environment=CONFIG_ROOT=%h/.config/covencat-worker
10+
Environment=STATE_ROOT=%h/.local/share/covencat-worker
11+
# STATE_ROOT must be a persistent filesystem of at most 8 GiB. The container
12+
# independently measures it and refuses startup when it is larger.
13+
ExecStartPre=/usr/bin/mountpoint -q ${STATE_ROOT}
14+
ExecStart=/usr/bin/podman run --rm --replace --name covencat-worker \
15+
--read-only \
16+
--userns=keep-id \
17+
--cap-drop=all --cap-add=net_admin --cap-add=setpcap --cap-add=setuid --cap-add=setgid \
18+
--security-opt=no-new-privileges \
19+
--memory=4g --memory-swap=4g --cpus=2 --pids-limit=256 \
20+
--ulimit=nofile=1024:1024 --ulimit=nproc=128:128 \
21+
--tmpfs=/tmp:rw,nosuid,nodev,noexec,size=512m \
22+
--tmpfs=/run:rw,nosuid,nodev,size=64m \
23+
--network=slirp4netns:allow_host_loopback=false --no-hosts \
24+
--publish=127.0.0.1:3900:3000 \
25+
--env-file=${CONFIG_ROOT}/worker.env \
26+
--volume=${CONFIG_ROOT}/policy.json:/run/coven-config/policy.json:ro,Z \
27+
--volume=${CONFIG_ROOT}/github-app.pem:/run/coven-config/github-app.pem:ro,Z \
28+
--volume=${CONFIG_ROOT}/codex:/run/coven-config/codex:rw,Z \
29+
--volume=${STATE_ROOT}:/var/lib/coven:rw,Z \
30+
localhost/covencat-worker@${IMAGE_DIGEST}
31+
ExecStop=/usr/bin/podman stop --time 30 covencat-worker
32+
Restart=always
33+
RestartSec=5
34+
TimeoutStartSec=180
35+
TimeoutStopSec=45
36+
37+
[Install]
38+
WantedBy=default.target

deploy/ingress-relay.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {request as httpRequest} from "node:http";
2+
import {createServer} from "node:http";
3+
4+
const upstreamPort = Number.parseInt(process.env.COVEN_INGRESS_UPSTREAM_PORT || "18096", 10);
5+
const revision = String(process.env.COVEN_INGRESS_REVISION || "unknown");
6+
const baseUri = String(process.env.COVEN_GITHUB_BASE_URI || process.env.PASSENGER_BASE_URI || "/github").replace(/\/$/, "");
7+
const maxBodyBytes = 10 * 1024 * 1024;
8+
const hopHeaders = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
9+
10+
function normalizedPath(url = "/") {
11+
const [path, query] = url.split("?", 2);
12+
let normalized = path;
13+
if (path === baseUri) normalized = "/";
14+
else if (path.startsWith(`${baseUri}/`)) normalized = path.slice(baseUri.length) || "/";
15+
if (normalized.length > 1) normalized = normalized.replace(/\/+$/, "");
16+
return `${normalized || "/"}${query === undefined ? "" : `?${query}`}`;
17+
}
18+
19+
function jsonError(response, status, message) {
20+
if (response.writableEnded || response.destroyed) return;
21+
const body = Buffer.from(JSON.stringify({ok: false, error: message}));
22+
response.writeHead(status, {
23+
"Content-Type": "application/json",
24+
"Content-Length": body.length,
25+
"X-Coven-Ingress-Revision": revision,
26+
});
27+
response.end(body);
28+
}
29+
30+
const server = createServer((incoming, outgoing) => {
31+
const declaredLength = Number.parseInt(String(incoming.headers["content-length"] || "0"), 10);
32+
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
33+
incoming.resume();
34+
jsonError(outgoing, 413, "payload too large");
35+
return;
36+
}
37+
const headers = Object.fromEntries(
38+
Object.entries(incoming.headers).filter(([name, value]) => value !== undefined && !hopHeaders.has(name.toLowerCase())),
39+
);
40+
headers.host = "127.0.0.1";
41+
const upstream = httpRequest({
42+
hostname: "127.0.0.1",
43+
port: upstreamPort,
44+
method: incoming.method,
45+
path: normalizedPath(incoming.url),
46+
headers,
47+
timeout: 30_000,
48+
}, (response) => {
49+
const responseHeaders = Object.fromEntries(
50+
Object.entries(response.headers).filter(([name, value]) => value !== undefined && !hopHeaders.has(name.toLowerCase())),
51+
);
52+
responseHeaders["x-coven-ingress-revision"] = revision;
53+
outgoing.writeHead(response.statusCode || 502, responseHeaders);
54+
response.pipe(outgoing);
55+
});
56+
let received = 0;
57+
incoming.on("data", (chunk) => {
58+
received += chunk.length;
59+
if (received > maxBodyBytes) {
60+
upstream.destroy();
61+
incoming.destroy();
62+
jsonError(outgoing, 413, "payload too large");
63+
}
64+
});
65+
upstream.on("timeout", () => upstream.destroy(new Error("upstream timeout")));
66+
upstream.on("error", () => jsonError(outgoing, 502, "worker unavailable"));
67+
incoming.pipe(upstream);
68+
});
69+
70+
const port = Number.parseInt(process.env.PORT || "3000", 10);
71+
server.listen(port, () => console.log(`covencat ingress relay ${revision} listening on :${port}`));

deploy/probe-runtime.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {mkdirSync, rmSync} from "node:fs";
2+
import {randomUUID} from "node:crypto";
3+
4+
import {
5+
createConfig,
6+
probeRuntimeIsolation,
7+
runtimeIsolationIssue,
8+
} from "/app/dist/src/adapter.js";
9+
10+
const config = createConfig();
11+
const configurationIssue = runtimeIsolationIssue(config);
12+
if (configurationIssue) throw new Error(configurationIssue);
13+
14+
const attemptDir = `${config.attemptsDir}/deployment-probe-${randomUUID()}`;
15+
mkdirSync(attemptDir, {mode: 0o700});
16+
try {
17+
const probeIssue = probeRuntimeIsolation(config, attemptDir);
18+
if (probeIssue) throw new Error(probeIssue);
19+
process.stdout.write(`${JSON.stringify({ok: true, isolation: "bwrap", network_probe: "none"})}\n`);
20+
} finally {
21+
rmSync(attemptDir, {recursive: true, force: true});
22+
}

0 commit comments

Comments
 (0)