Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d1bfb9b
feat: add secure Covencat autoreview repair loop
CompleteDotTech Jul 14, 2026
9ade8a1
build: add immutable sandboxed worker image
CompleteDotTech Jul 14, 2026
089cb9f
chore: verify production worker boundaries
CompleteDotTech Jul 14, 2026
0b4ef2a
feat: add protected OAuth refresh and ingress relay
CompleteDotTech Jul 14, 2026
7667841
fix: mount ephemeral Codex OAuth credential
CompleteDotTech Jul 14, 2026
b4a710e
fix: review trusted failures before repair
CompleteDotTech Jul 14, 2026
5a1f178
fix: derive findings from mapped trusted failures
CompleteDotTech Jul 14, 2026
33e9538
fix: isolate hosted mode CLI flags
CompleteDotTech Jul 14, 2026
e22c7c7
fix: prefer trusted execution receipts
CompleteDotTech Jul 14, 2026
559dc0f
fix(security): redact command artifact arguments
CompleteDotTech Jul 14, 2026
2367832
fix(review): embed trusted receipt evidence
CompleteDotTech Jul 14, 2026
50063d4
fix(review): require relevant support evidence
CompleteDotTech Jul 15, 2026
6dd3c68
fix(repair): verify pushed revision safely
CompleteDotTech Jul 15, 2026
e205f25
fix(review): classify bounded scope correctly
CompleteDotTech Jul 15, 2026
3a5f83f
Capture complete local PR diff evidence
CompleteDotTech Jul 15, 2026
bb3091e
Retain full local review patches
CompleteDotTech Jul 15, 2026
ad4247b
Capture PR diffs from the merge base
CompleteDotTech Jul 15, 2026
aa73327
Ignore non-material bounded-scope notes
CompleteDotTech Jul 15, 2026
8d90910
Match only real binary diff markers
CompleteDotTech Jul 15, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ keys/
coven-github-policy.json
coven-github-state/
.coven-github-private-key.pem
deploy/artifacts/
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ The deployment expects secrets and mutable state to be supplied outside git:
- `COVEN_CODE_BIN` - absolute coven-code path inside that rootfs
- `COVEN_RUNTIME_NETWORK=shared` - explicit opt-in required when the Codex
provider needs network access; the default is `none`
- `COVEN_REVIEW_FIX_LOOPS` - optional bounded review-fix loop count, clamped
between `0` and `5`; defaults to `0` so hosted repair loops are opt-in
- Automatic review and repair are repository-policy controls, not ambient
environment switches. `autoreview.enabled` and `repair.enabled` must each be
opted into explicitly; `kill_switch` stops new routing and repair pushes.
- Codex OAuth tokens under the deployed account's `.coven-code` directory

The host refreshes an expiring Codex OAuth session through the fixed OpenAI
token endpoint and atomically updates the private token file. Only the resulting
short-lived access token enters the model sandbox; the refresh token never does.

Do not commit private keys, webhook secrets, OAuth tokens, generated task state,
workspaces, or attempt artifacts.

Expand All @@ -82,7 +87,9 @@ records the task as `runtime_isolation_unavailable` with no direct fallback.
The runtime rootfs must contain the configured `coven-code`, `git`, and shell
executables plus their libraries, CA/DNS files, and approved runtime assets. It
must not contain the GitHub App key, webhook state, policy, parent home, or Codex
token store. The runtime receives only its dedicated model credential; it never
token store. The private PID namespace receives an empty `/proc`, avoiding host
procfs exposure and nested procfs-mount authority. The runtime receives only its
dedicated model credential; it never
receives a GitHub token or Git askpass helper. Shared networking is not an
egress-confidentiality boundary, so use a dedicated, revocable model credential
and an externally enforced allowlist that blocks loopback, LAN, and metadata
Expand Down Expand Up @@ -185,7 +192,9 @@ connection guide in
sandbox.
- Uses repository-scoped installation tokens: parent Git gets only
`contents:read`, PR evidence gets read authority, and publication write
authority is minted only after isolated execution has finished.
authority is minted only after isolated execution has finished. An opted-in
repair mints a separate short-lived token with only `contents:write` and
`pull_requests:read`; the model never receives it.
- Persists `publication_pending` before GitHub writes and resumes interrupted
publication on startup or duplicate webhook delivery without rerunning the
agent.
Expand All @@ -198,6 +207,12 @@ connection guide in
- Publishes non-PR task results and operational notices as issue comments,
including structured `reviewed_files`, `supporting_files`, findings, test
evidence, no-findings rationale, and limitations.
- When `COVEN_REVIEW_FIX_LOOPS` is greater than `0`, reruns `coven-code` with
prior structured review findings as explicit repair instructions until no
findings remain or the configured loop count is exhausted.
- With explicit `autoreview.enabled`, routes opened, ready-for-review, reopened,
and synchronized pull-request revisions by repository, PR number, and exact
head SHA. Drafts remain excluded unless `include_drafts` is enabled.
- With separate `repair.enabled`, an evidence-complete REQUEST_CHANGES review
may launch a file-write-only hosted repair. The trusted host rejects forks,
protected branches and paths, oversized or unrelated diffs, stale heads, and
failed validation; it then creates a Covencat-attributed non-force commit and
queues a fresh review of the new SHA. The loop stops after the configured
`max_attempts` (clamped to 1-3) or on repeated findings or non-progress.
34 changes: 32 additions & 2 deletions config/example-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"issues.labeled",
"issue_comment.created",
"pull_request_review_comment.created",
"pull_request.opened",
"pull_request.ready_for_review",
"pull_request.synchronize",
"pull_request.edited",
"pull_request.reopened",
Expand All @@ -31,8 +33,36 @@
]
},
"publication": {
"mode": "record_only"
}
"mode": "record_only",
"validation_commands": [
"npm test"
],
"validation_timeout_seconds": 300
},
"autoreview": {
"enabled": false,
"include_drafts": false
},
"repair": {
"enabled": false,
"max_attempts": 2,
"max_changed_files": 8,
"max_diff_bytes": 262144,
"allowed_paths": [
"src/**",
"tests/**"
],
"protected_branches": [
"release/**"
]
},
"protected_paths": [
".github/**",
"CODEOWNERS",
"**/CODEOWNERS"
],
"kill_switch": false,
"enabled": true
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions deploy/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
ARG NODE_IMAGE=docker.io/library/node@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf

FROM ${NODE_IMAGE} AS egress
ARG COVEN_BUILD_EGRESS_HOSTS="github.com api.github.com chatgpt.com auth.openai.com"
ENV COVEN_BUILD_EGRESS_HOSTS=${COVEN_BUILD_EGRESS_HOSTS}
COPY deploy/resolve-egress.mjs /resolve-egress.mjs
RUN node /resolve-egress.mjs

FROM ${NODE_IMAGE} AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev

FROM ${NODE_IMAGE} AS sandbox-rootfs
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=egress /out/hosts /etc/hosts
COPY deploy/artifacts/coven-code /usr/local/bin/coven-code
RUN chmod 0755 /usr/local/bin/coven-code \
&& mkdir -p /workspace \
&& test -x /usr/bin/git \
&& test -x /bin/sh \
&& test -x /bin/true

FROM ${NODE_IMAGE} AS runtime
ARG WEBHOOK_REVISION
ARG COVEN_CODE_REVISION
ARG COVEN_CODE_BINARY_SHA256
LABEL org.opencontainers.image.revision=${WEBHOOK_REVISION}
LABEL tech.complete.coven-code.revision=${COVEN_CODE_REVISION}
LABEL tech.complete.coven-code.binary-sha256=${COVEN_CODE_BINARY_SHA256}
RUN apt-get update \
&& apt-get install -y --no-install-recommends bubblewrap ca-certificates git nftables tini util-linux \
&& rm -rf /var/lib/apt/lists/*
COPY --from=egress /out/hosts /etc/hosts
COPY --from=egress /out/egress-ipv4.txt /usr/share/coven/egress-ipv4.txt
COPY --from=egress /out/egress-hosts.txt /usr/share/coven/egress-hosts.txt
COPY deploy/probe-runtime.mjs /usr/share/coven/probe-runtime.mjs
COPY deploy/verify-github-app.mjs /usr/share/coven/verify-github-app.mjs
COPY --from=sandbox-rootfs / /opt/coven-runtime/rootfs
COPY --from=build /src/dist /app/dist
COPY --from=build /src/node_modules /app/node_modules
COPY package.json package-lock.json /app/
COPY deploy/worker-entrypoint.sh /usr/local/bin/coven-worker-entrypoint
RUN chmod 0755 /usr/local/bin/coven-worker-entrypoint \
&& mkdir -p /var/lib/coven /run/coven-config /home/coven-service \
&& chown 1000:1000 /var/lib/coven /home/coven-service \
&& chmod 0700 /var/lib/coven /home/coven-service \
&& chmod 0755 /run/coven-config
WORKDIR /app
ENV NODE_ENV=production \
HOME=/home/coven-service \
PORT=3000 \
COVEN_GITHUB_STATE_DIR=/var/lib/coven \
COVEN_GITHUB_POLICY_PATH=/run/coven-config/policy.json \
GITHUB_APP_PRIVATE_KEY_PATH=/run/coven-config/github-app.pem \
COVEN_CODE_CODEX_TOKENS_PATH=/run/coven-config/codex/codex_tokens.json \
COVEN_RUNTIME_ISOLATION=bwrap \
COVEN_RUNTIME_EXTERNAL_ISOLATION=network-egress-and-resource-limits-verified \
COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified \
COVEN_BWRAP_BIN=/usr/bin/bwrap \
COVEN_RUNTIME_ROOTFS=/opt/coven-runtime/rootfs \
COVEN_CODE_BIN=/usr/local/bin/coven-code \
COVEN_RUNTIME_GIT_BIN=/usr/bin/git \
COVEN_RUNTIME_SHELL_BIN=/bin/sh \
COVEN_HOST_GIT_BIN=/usr/bin/git \
COVEN_RUNTIME_NETWORK=shared \
COVEN_STATE_FILESYSTEM_MAX_BYTES=8589934592
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/coven-worker-entrypoint"]
CMD ["node", "dist/src/server.js"]
21 changes: 21 additions & 0 deletions deploy/covencat-ingress-tunnel.service.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[Unit]
Description=Restricted Covencat ingress tunnel
After=network-online.target covencat-worker.service
Wants=network-online.target
Requires=covencat-worker.service

[Service]
Type=simple
ExecStart=/usr/bin/ssh -N -T \
-o BatchMode=yes -o IdentitiesOnly=yes -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=%h/.ssh/covencat_known_hosts \
-i %h/.ssh/covencat_ingress_ed25519 \
-R 127.0.0.1:18096:127.0.0.1:3900 \
replace-with-ingress-user@replace-with-ingress-host
Restart=always
RestartSec=5

[Install]
WantedBy=default.target
20 changes: 20 additions & 0 deletions deploy/covencat-state-volume.service.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[Unit]
Description=Bounded persistent Covencat state filesystem
After=local-fs.target

[Service]
Type=simple
Environment=FUSE2FS_ROOT=%h/.local/opt/fuse2fs
Environment=STATE_IMAGE=%h/covencat-deploy/state-volume/covencat-state.ext4
Environment=STATE_MOUNT=%h/covencat-deploy/state-volume/mount
ExecStartPre=/usr/bin/test -f ${STATE_IMAGE}
ExecStartPre=/usr/bin/mkdir -p ${STATE_MOUNT}
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
ExecStartPost=/usr/bin/chmod 0700 ${STATE_MOUNT}
ExecStop=/usr/bin/fusermount3 -u ${STATE_MOUNT}
Restart=on-failure
RestartSec=5
TimeoutStopSec=30

[Install]
WantedBy=default.target
38 changes: 38 additions & 0 deletions deploy/covencat-worker.service.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[Unit]
Description=Covencat immutable GitHub review worker
Requires=covencat-state-volume.service
After=network-online.target covencat-state-volume.service
Wants=network-online.target

[Service]
Environment=IMAGE_DIGEST=replace-with-local-image-digest
Environment=CONFIG_ROOT=%h/.config/covencat-worker
Environment=STATE_ROOT=%h/.local/share/covencat-worker
# STATE_ROOT must be a persistent filesystem of at most 8 GiB. The container
# independently measures it and refuses startup when it is larger.
ExecStartPre=/usr/bin/mountpoint -q ${STATE_ROOT}
ExecStart=/usr/bin/podman run --rm --replace --name covencat-worker \
--read-only \
--userns=keep-id \
--cap-drop=all --cap-add=net_admin --cap-add=setpcap --cap-add=setuid --cap-add=setgid \
--security-opt=no-new-privileges \
--memory=4g --memory-swap=4g --cpus=2 --pids-limit=256 \
--ulimit=nofile=1024:1024 --ulimit=nproc=128:128 \
--tmpfs=/tmp:rw,nosuid,nodev,noexec,size=512m \
--tmpfs=/run:rw,nosuid,nodev,size=64m \
--network=slirp4netns:allow_host_loopback=false --no-hosts \
--publish=127.0.0.1:3900:3000 \
--env-file=${CONFIG_ROOT}/worker.env \
--volume=${CONFIG_ROOT}/policy.json:/run/coven-config/policy.json:ro,Z \
--volume=${CONFIG_ROOT}/github-app.pem:/run/coven-config/github-app.pem:ro,Z \
--volume=${CONFIG_ROOT}/codex:/run/coven-config/codex:rw,Z \
--volume=${STATE_ROOT}:/var/lib/coven:rw,Z \
localhost/covencat-worker@${IMAGE_DIGEST}
ExecStop=/usr/bin/podman stop --time 30 covencat-worker
Restart=always
RestartSec=5
TimeoutStartSec=180
TimeoutStopSec=45

[Install]
WantedBy=default.target
71 changes: 71 additions & 0 deletions deploy/ingress-relay.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {request as httpRequest} from "node:http";
import {createServer} from "node:http";

const upstreamPort = Number.parseInt(process.env.COVEN_INGRESS_UPSTREAM_PORT || "18096", 10);
const revision = String(process.env.COVEN_INGRESS_REVISION || "unknown");
const baseUri = String(process.env.COVEN_GITHUB_BASE_URI || process.env.PASSENGER_BASE_URI || "/github").replace(/\/$/, "");
const maxBodyBytes = 10 * 1024 * 1024;
const hopHeaders = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);

function normalizedPath(url = "/") {
const [path, query] = url.split("?", 2);
let normalized = path;
if (path === baseUri) normalized = "/";
else if (path.startsWith(`${baseUri}/`)) normalized = path.slice(baseUri.length) || "/";
if (normalized.length > 1) normalized = normalized.replace(/\/+$/, "");
return `${normalized || "/"}${query === undefined ? "" : `?${query}`}`;
}

function jsonError(response, status, message) {
if (response.writableEnded || response.destroyed) return;
const body = Buffer.from(JSON.stringify({ok: false, error: message}));
response.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": body.length,
"X-Coven-Ingress-Revision": revision,
});
response.end(body);
}

const server = createServer((incoming, outgoing) => {
const declaredLength = Number.parseInt(String(incoming.headers["content-length"] || "0"), 10);
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
incoming.resume();
jsonError(outgoing, 413, "payload too large");
return;
}
const headers = Object.fromEntries(
Object.entries(incoming.headers).filter(([name, value]) => value !== undefined && !hopHeaders.has(name.toLowerCase())),
);
headers.host = "127.0.0.1";
const upstream = httpRequest({
hostname: "127.0.0.1",
port: upstreamPort,
method: incoming.method,
path: normalizedPath(incoming.url),
headers,
timeout: 30_000,
}, (response) => {
const responseHeaders = Object.fromEntries(
Object.entries(response.headers).filter(([name, value]) => value !== undefined && !hopHeaders.has(name.toLowerCase())),
);
responseHeaders["x-coven-ingress-revision"] = revision;
outgoing.writeHead(response.statusCode || 502, responseHeaders);
response.pipe(outgoing);
});
let received = 0;
incoming.on("data", (chunk) => {
received += chunk.length;
if (received > maxBodyBytes) {
upstream.destroy();
incoming.destroy();
jsonError(outgoing, 413, "payload too large");
}
});
upstream.on("timeout", () => upstream.destroy(new Error("upstream timeout")));
upstream.on("error", () => jsonError(outgoing, 502, "worker unavailable"));
incoming.pipe(upstream);
});

const port = Number.parseInt(process.env.PORT || "3000", 10);
server.listen(port, () => console.log(`covencat ingress relay ${revision} listening on :${port}`));
22 changes: 22 additions & 0 deletions deploy/probe-runtime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {mkdirSync, rmSync} from "node:fs";
import {randomUUID} from "node:crypto";

import {
createConfig,
probeRuntimeIsolation,
runtimeIsolationIssue,
} from "/app/dist/src/adapter.js";

const config = createConfig();
const configurationIssue = runtimeIsolationIssue(config);
if (configurationIssue) throw new Error(configurationIssue);

const attemptDir = `${config.attemptsDir}/deployment-probe-${randomUUID()}`;
mkdirSync(attemptDir, {mode: 0o700});
try {
const probeIssue = probeRuntimeIsolation(config, attemptDir);
if (probeIssue) throw new Error(probeIssue);
process.stdout.write(`${JSON.stringify({ok: true, isolation: "bwrap", network_probe: "none"})}\n`);
} finally {
rmSync(attemptDir, {recursive: true, force: true});
}
Loading
Loading