mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix: harden Devin bridge runtime boundaries
This commit is contained in:
@@ -18,6 +18,7 @@ coverage
|
||||
# Runtime data and logs
|
||||
data
|
||||
logs
|
||||
.sandbox
|
||||
|
||||
# Local env files (inject at runtime via --env-file or -e)
|
||||
.env
|
||||
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
omniroute:
|
||||
condition: service_healthy
|
||||
claude-egress-guard:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
working_dir: /workspace
|
||||
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-e2e.sh"]
|
||||
environment:
|
||||
@@ -109,10 +109,21 @@ services:
|
||||
environment:
|
||||
GUARD_LISTEN: 0.0.0.0:8080
|
||||
GUARD_POLICY: deny-all
|
||||
GUARD_LOG: /logs/claude-egress.jsonl
|
||||
GUARD_LOG: /guard-audit/egress.jsonl
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 15
|
||||
volumes:
|
||||
- ./network-guard:/guard:ro
|
||||
- ../../.sandbox/evidence:/logs
|
||||
- ../../.sandbox/guard-audit/claude:/guard-audit
|
||||
networks: [bridge-internal]
|
||||
|
||||
network-guard:
|
||||
@@ -126,17 +137,30 @@ services:
|
||||
environment:
|
||||
GUARD_LISTEN: 0.0.0.0:8080
|
||||
GUARD_POLICY: devin
|
||||
GUARD_LOG: /logs/egress.jsonl
|
||||
GUARD_LOG: /guard-audit/egress.jsonl
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('net').connect(8080,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))",
|
||||
]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 15
|
||||
volumes:
|
||||
- ./network-guard:/guard:ro
|
||||
- ../../.sandbox/evidence:/logs
|
||||
- ../../.sandbox/guard-audit/devin:/guard-audit
|
||||
networks: [devin-guard-internal, guard-egress]
|
||||
|
||||
omniroute-live:
|
||||
<<: *runtime
|
||||
profiles: [live-devin]
|
||||
hostname: omniroute
|
||||
depends_on: [network-guard]
|
||||
depends_on:
|
||||
network-guard:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
<<: *isolated-environment
|
||||
CLI_DEVIN_AGENTIC_BIN: /usr/local/bin/devin
|
||||
@@ -165,7 +189,7 @@ services:
|
||||
omniroute-live:
|
||||
condition: service_healthy
|
||||
claude-egress-guard:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
working_dir: /workspace
|
||||
command: ["bash", "/opt/omniroute/docker/devin-bridge/run-claude-live-e2e.sh"]
|
||||
environment:
|
||||
|
||||
@@ -20,3 +20,111 @@ export function isAllowedGuardHostname(hostname, policy = "deny-all") {
|
||||
(suffix) => value === suffix.slice(1) || value.endsWith(suffix)
|
||||
);
|
||||
}
|
||||
|
||||
const HOP_BY_HOP_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
export function sanitizeForwardHeaders(headers, target) {
|
||||
const connectionTokens = String(headers.connection || "")
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const blocked = new Set([...HOP_BY_HOP_HEADERS, ...connectionTokens]);
|
||||
const sanitized = {};
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined || blocked.has(name.toLowerCase()) || name.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
sanitized[name] = value;
|
||||
}
|
||||
sanitized.host = target.host;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function parseConnectAuthority(authority) {
|
||||
const value = String(authority || "");
|
||||
const match = value.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/);
|
||||
if (!match) return null;
|
||||
const hostname = normalizeHostname(match[1] || match[2]);
|
||||
const port = Number(match[3]);
|
||||
if (!hostname || port !== 443) return null;
|
||||
return { hostname, port };
|
||||
}
|
||||
|
||||
function readUint24(buffer, offset) {
|
||||
return (buffer[offset] << 16) | (buffer[offset + 1] << 8) | buffer[offset + 2];
|
||||
}
|
||||
|
||||
export function parseTlsClientHelloSni(buffer) {
|
||||
if (!Buffer.isBuffer(buffer)) return { status: "invalid", reason: "not_buffer" };
|
||||
let offset = 0;
|
||||
const handshakeParts = [];
|
||||
while (offset < buffer.length) {
|
||||
if (buffer.length - offset < 5) return { status: "need-more" };
|
||||
if (buffer[offset] !== 22) return { status: "invalid", reason: "not_handshake_record" };
|
||||
const recordLength = buffer.readUInt16BE(offset + 3);
|
||||
if (recordLength <= 0 || recordLength > 18432) {
|
||||
return { status: "invalid", reason: "invalid_record_length" };
|
||||
}
|
||||
if (buffer.length - offset - 5 < recordLength) return { status: "need-more" };
|
||||
handshakeParts.push(buffer.subarray(offset + 5, offset + 5 + recordLength));
|
||||
offset += 5 + recordLength;
|
||||
}
|
||||
const handshake = Buffer.concat(handshakeParts);
|
||||
if (handshake.length < 4) return { status: "need-more" };
|
||||
if (handshake[0] !== 1) return { status: "invalid", reason: "not_client_hello" };
|
||||
const helloLength = readUint24(handshake, 1);
|
||||
if (helloLength > 65531) return { status: "invalid", reason: "client_hello_too_large" };
|
||||
if (handshake.length - 4 < helloLength) return { status: "need-more" };
|
||||
const hello = handshake.subarray(4, 4 + helloLength);
|
||||
let cursor = 34;
|
||||
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_hello" };
|
||||
const sessionLength = hello[cursor++];
|
||||
cursor += sessionLength;
|
||||
if (hello.length < cursor + 2) return { status: "invalid", reason: "truncated_ciphers" };
|
||||
const cipherLength = hello.readUInt16BE(cursor);
|
||||
cursor += 2 + cipherLength;
|
||||
if (hello.length < cursor + 1) return { status: "invalid", reason: "truncated_compression" };
|
||||
const compressionLength = hello[cursor++];
|
||||
cursor += compressionLength;
|
||||
if (hello.length < cursor + 2) return { status: "invalid", reason: "missing_extensions" };
|
||||
const extensionsLength = hello.readUInt16BE(cursor);
|
||||
cursor += 2;
|
||||
const extensionsEnd = cursor + extensionsLength;
|
||||
if (extensionsEnd > hello.length) return { status: "invalid", reason: "truncated_extensions" };
|
||||
while (cursor < extensionsEnd) {
|
||||
if (extensionsEnd - cursor < 4) return { status: "invalid", reason: "truncated_extension" };
|
||||
const type = hello.readUInt16BE(cursor);
|
||||
const length = hello.readUInt16BE(cursor + 2);
|
||||
cursor += 4;
|
||||
if (cursor + length > extensionsEnd) {
|
||||
return { status: "invalid", reason: "invalid_extension_length" };
|
||||
}
|
||||
if (type === 0) {
|
||||
const data = hello.subarray(cursor, cursor + length);
|
||||
if (data.length < 5 || data.readUInt16BE(0) !== data.length - 2 || data[2] !== 0) {
|
||||
return { status: "invalid", reason: "invalid_server_name" };
|
||||
}
|
||||
const nameLength = data.readUInt16BE(3);
|
||||
if (nameLength !== data.length - 5) {
|
||||
return { status: "invalid", reason: "invalid_server_name_length" };
|
||||
}
|
||||
const serverName = normalizeHostname(data.subarray(5).toString("ascii"));
|
||||
if (!/^[a-z0-9.-]+$/.test(serverName)) {
|
||||
return { status: "invalid", reason: "invalid_server_name_value" };
|
||||
}
|
||||
return { status: "ok", serverName };
|
||||
}
|
||||
cursor += length;
|
||||
}
|
||||
return { status: "invalid", reason: "missing_sni" };
|
||||
}
|
||||
|
||||
@@ -1,58 +1,136 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import fs from "node:fs";
|
||||
import { isAllowedGuardHostname } from "./policy.mjs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":");
|
||||
const port = Number(portText);
|
||||
const policy = process.env.GUARD_POLICY || "deny-all";
|
||||
if (!new Set(["deny-all", "devin"]).has(policy)) {
|
||||
throw new Error(`Unknown network guard policy: ${policy}`);
|
||||
}
|
||||
const logPath = process.env.GUARD_LOG || "/tmp/egress.jsonl";
|
||||
import {
|
||||
isAllowedGuardHostname,
|
||||
parseConnectAuthority,
|
||||
parseTlsClientHelloSni,
|
||||
sanitizeForwardHeaders,
|
||||
} from "./policy.mjs";
|
||||
|
||||
function allowed(hostname) {
|
||||
return isAllowedGuardHostname(hostname, policy);
|
||||
}
|
||||
const MAX_CLIENT_HELLO_BYTES = 64 * 1024;
|
||||
const CLIENT_HELLO_TIMEOUT_MS = 3000;
|
||||
|
||||
function audit(hostname, decision) {
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({ at: new Date().toISOString(), hostname, decision })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const target = new URL(req.url);
|
||||
if (!allowed(target.hostname)) {
|
||||
audit(target.hostname, "deny");
|
||||
res.writeHead(403).end("egress denied\n");
|
||||
return;
|
||||
export function createGuardProxy({
|
||||
policy = "deny-all",
|
||||
logPath = "/tmp/egress.jsonl",
|
||||
allowHostname = (hostname) => isAllowedGuardHostname(hostname, policy),
|
||||
connectSocket = (port, hostname, onConnect) => net.connect(port, hostname, onConnect),
|
||||
} = {}) {
|
||||
if (!new Set(["deny-all", "devin"]).has(policy)) {
|
||||
throw new Error(`Unknown network guard policy: ${policy}`);
|
||||
}
|
||||
audit(target.hostname, "allow");
|
||||
const upstream = http.request(target, { method: req.method, headers: req.headers }, (reply) => {
|
||||
res.writeHead(reply.statusCode || 502, reply.headers);
|
||||
reply.pipe(res);
|
||||
|
||||
function audit(hostname, decision, reason) {
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`${JSON.stringify({ at: new Date().toISOString(), hostname, decision, reason })}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(req.url);
|
||||
} catch {
|
||||
res.writeHead(400).end("invalid proxy target\n");
|
||||
return;
|
||||
}
|
||||
if (target.protocol !== "http:" || target.username || target.password) {
|
||||
audit(target.hostname, "deny", "invalid_http_target");
|
||||
res.writeHead(403).end("egress denied\n");
|
||||
return;
|
||||
}
|
||||
if (!allowHostname(target.hostname)) {
|
||||
audit(target.hostname, "deny", "host_policy");
|
||||
res.writeHead(403).end("egress denied\n");
|
||||
return;
|
||||
}
|
||||
audit(target.hostname, "allow", "host_policy");
|
||||
const upstream = http.request(
|
||||
target,
|
||||
{
|
||||
method: req.method,
|
||||
headers: sanitizeForwardHeaders(req.headers, target),
|
||||
},
|
||||
(reply) => {
|
||||
res.writeHead(reply.statusCode || 502, reply.headers);
|
||||
reply.pipe(res);
|
||||
}
|
||||
);
|
||||
req.pipe(upstream);
|
||||
upstream.on("error", () => res.writeHead(502).end("upstream error\n"));
|
||||
});
|
||||
req.pipe(upstream);
|
||||
upstream.on("error", () => res.writeHead(502).end("upstream error\n"));
|
||||
});
|
||||
|
||||
server.on("connect", (req, client, head) => {
|
||||
const [hostname, portValue] = req.url.split(":");
|
||||
if (!allowed(hostname)) {
|
||||
audit(hostname, "deny");
|
||||
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
audit(hostname, "allow");
|
||||
const upstream = net.connect(Number(portValue) || 443, hostname, () => {
|
||||
server.on("connect", (req, client, head) => {
|
||||
const authority = parseConnectAuthority(req.url);
|
||||
if (!authority) {
|
||||
audit(req.url, "deny", "invalid_connect_authority");
|
||||
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const { hostname, port } = authority;
|
||||
if (!allowHostname(hostname)) {
|
||||
audit(hostname, "deny", "host_policy");
|
||||
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer = Buffer.from(head);
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => fail("client_hello_timeout"), CLIENT_HELLO_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
client.removeListener("data", onData);
|
||||
};
|
||||
const fail = (reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
audit(hostname, "deny", reason);
|
||||
client.destroy();
|
||||
};
|
||||
const inspect = () => {
|
||||
if (buffer.length > MAX_CLIENT_HELLO_BYTES) return fail("client_hello_too_large");
|
||||
const parsed = parseTlsClientHelloSni(buffer);
|
||||
if (parsed.status === "need-more") return;
|
||||
if (parsed.status !== "ok") return fail(parsed.reason || "invalid_client_hello");
|
||||
if (parsed.serverName !== hostname) return fail("sni_mismatch");
|
||||
settled = true;
|
||||
cleanup();
|
||||
client.pause();
|
||||
const upstream = connectSocket(port, hostname, () => {
|
||||
audit(hostname, "allow", "sni_match");
|
||||
if (buffer.length) upstream.write(buffer);
|
||||
upstream.pipe(client);
|
||||
client.pipe(upstream);
|
||||
client.resume();
|
||||
});
|
||||
upstream.on("error", () => client.destroy());
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
inspect();
|
||||
};
|
||||
|
||||
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length) upstream.write(head);
|
||||
upstream.pipe(client);
|
||||
client.pipe(upstream);
|
||||
client.on("data", onData);
|
||||
if (buffer.length) inspect();
|
||||
client.resume();
|
||||
});
|
||||
upstream.on("error", () => client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"));
|
||||
});
|
||||
|
||||
server.listen(port, host);
|
||||
return server;
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
const [host, portText] = (process.env.GUARD_LISTEN || "0.0.0.0:8080").split(":");
|
||||
const server = createGuardProxy({
|
||||
policy: process.env.GUARD_POLICY || "deny-all",
|
||||
logPath: process.env.GUARD_LOG || "/tmp/egress.jsonl",
|
||||
});
|
||||
server.listen(Number(portText), host);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ The `offline` network is internal, so no runtime container can reach the Interne
|
||||
`live-devin` OmniRoute service is also attached only to that internal network; outbound
|
||||
HTTP(S) goes through `network-guard`, whose only allowed suffixes are `.devin.ai` and
|
||||
`.cognition.ai`. Anthropic, Claude, Statsig, Sentry, and every unrelated destination are
|
||||
denied by default. The guard records decisions in `.sandbox/evidence/egress.jsonl`.
|
||||
denied by default. The guards write their canonical audit logs to the guard-only binds
|
||||
`.sandbox/guard-audit/devin/egress.jsonl` and
|
||||
`.sandbox/guard-audit/claude/egress.jsonl`. Runtime services do not mount those directories.
|
||||
After the guarded services stop, the scripts validate file ownership, mode, link count, and
|
||||
every audit decision before copying the token-free audit record into `.sandbox/evidence`.
|
||||
|
||||
Run the executable proof at any time:
|
||||
|
||||
@@ -85,9 +89,10 @@ host session:
|
||||
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/login-devin
|
||||
```
|
||||
|
||||
After login, the live test checks `devin auth status`, obtains the account's machine-readable
|
||||
model list with `devin models list --format json`, selects a returned model identifier, and
|
||||
runs three disposable Claude Code scenarios:
|
||||
After login, the live test accepts `devin auth status` only when its output contains the exact
|
||||
line `Logged in (via Devin)`. It then obtains the account's machine-readable model list with
|
||||
`devin models list --format json`, selects an identifier from an explicit model-identifier
|
||||
field, and runs three disposable Claude Code scenarios:
|
||||
|
||||
```bash
|
||||
ENABLE_LIVE_DEVIN_TESTS=1 ./scripts/devin-bridge/test-live-devin
|
||||
@@ -126,7 +131,11 @@ the host.
|
||||
shows local routing and sanitized executor failures.
|
||||
- `.sandbox/evidence/mock-acp.jsonl` records deterministic offline provider actions.
|
||||
- `.sandbox/evidence/claude-stream.jsonl` records the real Claude Code offline run.
|
||||
- `.sandbox/evidence/egress.jsonl` records live guard decisions without tokens.
|
||||
- `.sandbox/guard-audit/devin/egress.jsonl` and
|
||||
`.sandbox/guard-audit/claude/egress.jsonl` are the canonical guard-only audit files.
|
||||
- `.sandbox/evidence/egress.jsonl`, `.sandbox/evidence/claude-egress.jsonl`, and
|
||||
`.sandbox/evidence/claude-egress-verifier.jsonl` are validated, post-shutdown copies
|
||||
without tokens.
|
||||
- An ACP timeout, malformed frame, unavailable binary/model, or process exit is an explicit
|
||||
`502`; it never selects a second provider.
|
||||
|
||||
|
||||
@@ -3,17 +3,34 @@ set -euo pipefail
|
||||
BRIDGE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BRIDGE_COMPOSE="$BRIDGE_ROOT/docker/devin-bridge/compose.yml"
|
||||
BRIDGE_SANDBOX="$BRIDGE_ROOT/.sandbox"
|
||||
BRIDGE_GUARD_AUDIT_ROOT="$BRIDGE_SANDBOX/guard-audit"
|
||||
BRIDGE_CLAUDE_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/claude/egress.jsonl"
|
||||
BRIDGE_DEVIN_AUDIT="$BRIDGE_GUARD_AUDIT_ROOT/devin/egress.jsonl"
|
||||
BRIDGE_RUNTIME_POLICY="$BRIDGE_ROOT/scripts/devin-bridge/runtime-policy.mjs"
|
||||
bridge_prepare_sandbox() {
|
||||
mkdir -p "$BRIDGE_SANDBOX/home" "$BRIDGE_SANDBOX/test-data" \
|
||||
"$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \
|
||||
"$BRIDGE_SANDBOX/evidence"
|
||||
"$BRIDGE_SANDBOX/evidence" "$BRIDGE_GUARD_AUDIT_ROOT/claude" \
|
||||
"$BRIDGE_GUARD_AUDIT_ROOT/devin"
|
||||
chmod 0777 "$BRIDGE_SANDBOX/e2e-workspace" "$BRIDGE_SANDBOX/live-workspace" \
|
||||
"$BRIDGE_SANDBOX/evidence"
|
||||
chmod 01777 "$BRIDGE_GUARD_AUDIT_ROOT/claude" "$BRIDGE_GUARD_AUDIT_ROOT/devin"
|
||||
}
|
||||
bridge_reset_guard_audit() {
|
||||
local audit_path="$1"
|
||||
local audit_dir
|
||||
local temp_path
|
||||
bridge_prepare_sandbox
|
||||
audit_dir="$(dirname "$audit_path")"
|
||||
temp_path="$(mktemp "$audit_dir/.egress.jsonl.XXXXXX")"
|
||||
chmod 0666 "$temp_path"
|
||||
mv -f "$temp_path" "$audit_path"
|
||||
}
|
||||
bridge_reset_claude_egress_audit() {
|
||||
bridge_prepare_sandbox
|
||||
: >"$BRIDGE_SANDBOX/evidence/claude-egress.jsonl"
|
||||
chmod 0666 "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl"
|
||||
bridge_reset_guard_audit "$BRIDGE_CLAUDE_AUDIT"
|
||||
}
|
||||
bridge_reset_devin_egress_audit() {
|
||||
bridge_reset_guard_audit "$BRIDGE_DEVIN_AUDIT"
|
||||
}
|
||||
bridge_reset_e2e_fixture() {
|
||||
bridge_prepare_sandbox
|
||||
@@ -35,6 +52,7 @@ bridge_reset_live_fixture() {
|
||||
"$BRIDGE_SANDBOX/evidence/live-models.json" \
|
||||
"$BRIDGE_SANDBOX/evidence/egress.jsonl"
|
||||
bridge_reset_claude_egress_audit
|
||||
bridge_reset_devin_egress_audit
|
||||
}
|
||||
bridge_test_env() {
|
||||
bridge_prepare_sandbox
|
||||
@@ -55,18 +73,13 @@ bridge_run_devin() {
|
||||
bridge_assert_devin_auth_status() {
|
||||
local exit_status="$1"
|
||||
local output="$2"
|
||||
[[ "$exit_status" == 0 ]] || {
|
||||
printf 'FAIL: Devin auth status command failed\n' >&2
|
||||
return 1
|
||||
}
|
||||
[[ "$output" == *"Logged in (via Devin)"* ]] || {
|
||||
printf 'FAIL: Devin auth status did not confirm login\n' >&2
|
||||
return 1
|
||||
}
|
||||
if grep -Fqi 'Failed to fetch from server' <<<"$output"; then
|
||||
printf 'FAIL: Devin auth status could not confirm server access\n' >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$output" | node --input-type=module -e '
|
||||
import { pathToFileURL } from "node:url";
|
||||
import fs from "node:fs";
|
||||
const policy = await import(pathToFileURL(process.argv[1]));
|
||||
const result = policy.validateDevinAuthStatus(process.argv[2], fs.readFileSync(0, "utf8"));
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
' "$BRIDGE_RUNTIME_POLICY" "$exit_status"
|
||||
}
|
||||
|
||||
bridge_check_devin_auth() {
|
||||
@@ -82,34 +95,37 @@ bridge_check_devin_auth() {
|
||||
|
||||
bridge_assert_zero_claude_egress() {
|
||||
local audit_path="$1"
|
||||
[[ -f "$audit_path" ]] || {
|
||||
printf 'FAIL: Claude egress audit file is missing\n' >&2
|
||||
return 1
|
||||
}
|
||||
[[ ! -s "$audit_path" ]] || {
|
||||
printf 'FAIL: Claude attempted external egress during the real run\n' >&2
|
||||
return 1
|
||||
}
|
||||
bridge_validate_guard_audit claude-zero "$audit_path"
|
||||
}
|
||||
|
||||
bridge_assert_claude_guard_denials() {
|
||||
local audit_path="$1"
|
||||
[[ -s "$audit_path" ]] || {
|
||||
printf 'FAIL: Claude egress denial audit is missing or empty\n' >&2
|
||||
return 1
|
||||
}
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const entries = fs.readFileSync(process.argv[1], "utf8")
|
||||
.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
||||
if (!entries.length) throw new Error("Claude egress audit has no records");
|
||||
if (entries.some((entry) => entry.decision !== "deny")) {
|
||||
throw new Error("Claude egress audit contains a non-deny decision");
|
||||
}
|
||||
for (const hostname of ["api.anthropic.com", "claude.ai"]) {
|
||||
if (!entries.some((entry) => entry.hostname === hostname && entry.decision === "deny")) {
|
||||
throw new Error(`Claude egress audit is missing deny for ${hostname}`);
|
||||
}
|
||||
}
|
||||
' "$audit_path"
|
||||
bridge_validate_guard_audit claude-denials "$audit_path"
|
||||
}
|
||||
|
||||
bridge_assert_devin_guard_audit() {
|
||||
local audit_path="$1"
|
||||
bridge_validate_guard_audit devin-allowed "$audit_path"
|
||||
}
|
||||
|
||||
bridge_validate_guard_audit() {
|
||||
local kind="$1"
|
||||
local audit_path="$2"
|
||||
node --input-type=module -e '
|
||||
import { pathToFileURL } from "node:url";
|
||||
const policy = await import(pathToFileURL(process.argv[1]));
|
||||
policy.validateAuditFile(process.argv[2], process.argv[3], process.argv[4]);
|
||||
' "$BRIDGE_RUNTIME_POLICY" "$kind" "$audit_path" "$(id -u)"
|
||||
}
|
||||
|
||||
bridge_export_guard_audit() {
|
||||
local audit_path="$1"
|
||||
local evidence_name="$2"
|
||||
cp "$audit_path" "$BRIDGE_SANDBOX/evidence/$evidence_name"
|
||||
chmod 0644 "$BRIDGE_SANDBOX/evidence/$evidence_name"
|
||||
}
|
||||
|
||||
bridge_cleanup_compose() {
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin \
|
||||
down --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/common"
|
||||
trap bridge_cleanup_compose EXIT
|
||||
bridge_cleanup_compose
|
||||
bridge_prepare_sandbox
|
||||
"$(dirname "$0")/verify-anthropic-isolation"
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard claude-egress-guard
|
||||
bridge_reset_claude_egress_audit
|
||||
bridge_reset_devin_egress_audit
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard claude-egress-guard
|
||||
bridge_check_devin_auth
|
||||
bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json"
|
||||
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
|
||||
@@ -15,5 +18,11 @@ export DEVIN_BRIDGE_OPUS_MODEL="${DEVIN_BRIDGE_OPUS_MODEL:-$DEVIN_BRIDGE_MODEL}"
|
||||
export DEVIN_BRIDGE_HAIKU_MODEL="${DEVIN_BRIDGE_HAIKU_MODEL:-$DEVIN_BRIDGE_MODEL}"
|
||||
export DEVIN_BRIDGE_SUBAGENT_MODEL="${DEVIN_BRIDGE_SUBAGENT_MODEL:-$DEVIN_BRIDGE_MODEL}"
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait omniroute-live
|
||||
exec docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin run --rm --no-deps \
|
||||
claude-live claude
|
||||
bridge_cleanup_compose
|
||||
bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT"
|
||||
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
|
||||
bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl
|
||||
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
|
||||
trap - EXIT
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/common"
|
||||
[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; }
|
||||
trap bridge_cleanup_compose EXIT
|
||||
bridge_cleanup_compose
|
||||
bridge_prepare_sandbox
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard
|
||||
bridge_reset_devin_egress_audit
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard
|
||||
bridge_run_devin auth login --force-manual-token-flow
|
||||
bridge_cleanup_compose
|
||||
trap - EXIT
|
||||
exec env ENABLE_LIVE_DEVIN_TESTS=1 "$(dirname "$0")/test-live-devin"
|
||||
|
||||
101
scripts/devin-bridge/runtime-policy.mjs
Normal file
101
scripts/devin-bridge/runtime-policy.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const ALLOWED_DEVIN_SUFFIXES = [".devin.ai", ".cognition.ai"];
|
||||
const ALLOWED_DEVIN_EXACT = ["server.codeium.com", "unleash.codeium.com"];
|
||||
|
||||
function normalizedHostname(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.$/, "");
|
||||
}
|
||||
|
||||
export function isAllowedDevinAuditHostname(hostname) {
|
||||
const value = normalizedHostname(hostname);
|
||||
return (
|
||||
ALLOWED_DEVIN_EXACT.includes(value) ||
|
||||
ALLOWED_DEVIN_SUFFIXES.some((suffix) => value === suffix.slice(1) || value.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
export function validateDevinAuthStatus(exitStatus, output) {
|
||||
if (Number(exitStatus) !== 0) return { ok: false, error: "auth status command failed" };
|
||||
const lines = String(output)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim());
|
||||
if (!lines.includes("Logged in (via Devin)")) {
|
||||
return { ok: false, error: "auth status did not confirm login" };
|
||||
}
|
||||
if (lines.some((line) => /failed to fetch from server/i.test(line))) {
|
||||
return { ok: false, error: "auth status could not confirm server access" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function parseAuditEntries(text) {
|
||||
const lines = String(text)
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim().length > 0);
|
||||
return lines.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
export function validateZeroClaudeEgress(text) {
|
||||
if (String(text).length !== 0) {
|
||||
return { ok: false, error: "Claude attempted external egress during the real run" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateClaudeGuardDenials(text) {
|
||||
const entries = parseAuditEntries(text);
|
||||
if (!entries.length) return { ok: false, error: "Claude egress audit has no records" };
|
||||
if (entries.some((entry) => entry.decision !== "deny")) {
|
||||
return { ok: false, error: "Claude egress audit contains a non-deny decision" };
|
||||
}
|
||||
for (const hostname of ["api.anthropic.com", "claude.ai"]) {
|
||||
if (!entries.some((entry) => entry.hostname === hostname && entry.decision === "deny")) {
|
||||
return { ok: false, error: `Claude egress audit is missing deny for ${hostname}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateDevinGuardAudit(text) {
|
||||
const entries = parseAuditEntries(text);
|
||||
if (!entries.length) return { ok: false, error: "Devin egress audit has no records" };
|
||||
for (const entry of entries) {
|
||||
if (entry.decision !== "allow" || !isAllowedDevinAuditHostname(entry.hostname)) {
|
||||
return { ok: false, error: `unexpected Devin egress record: ${String(entry.hostname)}` };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function validateAuditFileStat(stat, expectedUid) {
|
||||
if (!stat || !stat.isFile() || stat.isSymbolicLink()) return "audit path is not a regular file";
|
||||
if (stat.nlink !== 1) return "audit file link count is not one";
|
||||
if (stat.uid !== Number(expectedUid)) return "audit file owner mismatch";
|
||||
if ((stat.mode & 0o777) !== 0o666) return "audit file mode mismatch";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readValidatedAuditFile(path, expectedUid) {
|
||||
const stat = fs.lstatSync(path);
|
||||
const statError = validateAuditFileStat(stat, expectedUid);
|
||||
if (statError) throw new Error(statError);
|
||||
return fs.readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
export function validateAuditFile(kind, path, expectedUid) {
|
||||
const text = readValidatedAuditFile(path, expectedUid);
|
||||
const result =
|
||||
kind === "claude-zero"
|
||||
? validateZeroClaudeEgress(text)
|
||||
: kind === "claude-denials"
|
||||
? validateClaudeGuardDenials(text)
|
||||
: kind === "devin-allowed"
|
||||
? validateDevinGuardAudit(text)
|
||||
: { ok: false, error: `unknown audit validation kind: ${kind}` };
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return text;
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts";
|
||||
|
||||
const document = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const candidates = [];
|
||||
const candidateFields = new Set([
|
||||
"id",
|
||||
"model",
|
||||
"model_id",
|
||||
"modelid",
|
||||
"modelId",
|
||||
"model_uid",
|
||||
"modeluid",
|
||||
"modelUid",
|
||||
"family_uid",
|
||||
"familyuid",
|
||||
"slug",
|
||||
"familyUid",
|
||||
]);
|
||||
|
||||
function normalizeModelId(value) {
|
||||
@@ -24,56 +20,82 @@ function normalizeModelId(value) {
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function collect(value) {
|
||||
function collect(value, candidates) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(collect);
|
||||
value.forEach((item) => collect(item, candidates));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (
|
||||
typeof nested === "string" &&
|
||||
candidateFields.has(key.toLowerCase()) &&
|
||||
candidateFields.has(key) &&
|
||||
/^[a-z0-9][a-z0-9._/-]*$/i.test(nested)
|
||||
) {
|
||||
candidates.push(nested);
|
||||
}
|
||||
collect(nested);
|
||||
collect(nested, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
collect(document);
|
||||
const unique = [...new Set(candidates)];
|
||||
const catalogIds = new Set(DEVIN_MODEL_CATALOG.map((entry) => entry.id));
|
||||
const available = [
|
||||
...new Set(
|
||||
unique
|
||||
.map((candidate) => normalizeModelId(candidate))
|
||||
.filter((candidate) => catalogIds.has(candidate))
|
||||
),
|
||||
];
|
||||
|
||||
for (const [name, configured] of [
|
||||
["DEVIN_BRIDGE_SONNET_MODEL", process.env.DEVIN_BRIDGE_SONNET_MODEL],
|
||||
["DEVIN_BRIDGE_OPUS_MODEL", process.env.DEVIN_BRIDGE_OPUS_MODEL],
|
||||
["DEVIN_BRIDGE_HAIKU_MODEL", process.env.DEVIN_BRIDGE_HAIKU_MODEL],
|
||||
["DEVIN_BRIDGE_SUBAGENT_MODEL", process.env.DEVIN_BRIDGE_SUBAGENT_MODEL],
|
||||
]) {
|
||||
if (!configured) continue;
|
||||
const prefix = "devin-cli-agentic/";
|
||||
const modelId = configured.startsWith(prefix) ? configured.slice(prefix.length) : "";
|
||||
if (!modelId || !available.includes(modelId)) {
|
||||
throw new Error(`${name} is not a model returned by Devin and present in OmniRoute`);
|
||||
export function selectLiveModel(
|
||||
document,
|
||||
environment = process.env,
|
||||
catalog = DEVIN_MODEL_CATALOG
|
||||
) {
|
||||
const candidates = [];
|
||||
collect(document, candidates);
|
||||
const unique = [...new Set(candidates)];
|
||||
const normalizedCatalog = new Map();
|
||||
for (const entry of catalog) {
|
||||
const normalized = normalizeModelId(entry.id);
|
||||
const existing = normalizedCatalog.get(normalized) || [];
|
||||
existing.push(entry.id);
|
||||
normalizedCatalog.set(normalized, existing);
|
||||
}
|
||||
for (const [normalized, ids] of normalizedCatalog) {
|
||||
if (ids.length > 1) {
|
||||
throw new Error(
|
||||
`Ambiguous OmniRoute catalog normalization for ${normalized}: ${ids.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const catalogIds = new Set(catalog.map((entry) => entry.id));
|
||||
const available = [
|
||||
...new Set(
|
||||
unique
|
||||
.map((candidate) => normalizeModelId(candidate))
|
||||
.filter((candidate) => normalizedCatalog.has(candidate))
|
||||
),
|
||||
];
|
||||
|
||||
for (const [name, configured] of [
|
||||
["DEVIN_BRIDGE_SONNET_MODEL", environment.DEVIN_BRIDGE_SONNET_MODEL],
|
||||
["DEVIN_BRIDGE_OPUS_MODEL", environment.DEVIN_BRIDGE_OPUS_MODEL],
|
||||
["DEVIN_BRIDGE_HAIKU_MODEL", environment.DEVIN_BRIDGE_HAIKU_MODEL],
|
||||
["DEVIN_BRIDGE_SUBAGENT_MODEL", environment.DEVIN_BRIDGE_SUBAGENT_MODEL],
|
||||
]) {
|
||||
if (!configured) continue;
|
||||
const prefix = "devin-cli-agentic/";
|
||||
const modelId = configured.startsWith(prefix) ? configured.slice(prefix.length) : "";
|
||||
if (!modelId || !catalogIds.has(modelId) || !available.includes(modelId)) {
|
||||
throw new Error(`${name} is not a model returned by Devin and present in OmniRoute`);
|
||||
}
|
||||
}
|
||||
|
||||
const selected =
|
||||
available.find((candidate) => candidate === "swe-1-7-lightning") ||
|
||||
available.find((candidate) => candidate === "swe-1-7") ||
|
||||
available.find((candidate) => /swe|claude|gpt|gemini/i.test(candidate)) ||
|
||||
available[0];
|
||||
|
||||
if (!selected) {
|
||||
throw new Error("Devin returned no model identifier present in OmniRoute's Devin catalog");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
const selected =
|
||||
available.find((candidate) => candidate === "swe-1-7-lightning") ||
|
||||
available.find((candidate) => candidate === "swe-1-7") ||
|
||||
available.find((candidate) => /swe|claude|gpt|gemini/i.test(candidate)) ||
|
||||
available[0];
|
||||
|
||||
if (!selected) {
|
||||
throw new Error("Devin returned no model identifier present in OmniRoute's Devin catalog");
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
const document = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(selectLiveModel(document));
|
||||
}
|
||||
process.stdout.write(selected);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/common"
|
||||
trap bridge_cleanup_compose EXIT
|
||||
bridge_cleanup_compose
|
||||
bridge_reset_e2e_fixture
|
||||
"$(dirname "$0")/verify-anthropic-isolation" --static
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline down --remove-orphans
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline up --abort-on-container-exit \
|
||||
--exit-code-from claude claude
|
||||
grep -q '"action":"final"' "$BRIDGE_SANDBOX/evidence/mock-acp.jsonl"
|
||||
grep -q 'BRIDGE_E2E_COMPLETE' "$BRIDGE_SANDBOX/evidence/claude-stream.jsonl"
|
||||
bridge_assert_zero_claude_egress "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl"
|
||||
bridge_cleanup_compose
|
||||
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
|
||||
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
|
||||
trap - EXIT
|
||||
printf 'PASS: real Claude Code completed the offline agentic fixture\n'
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/common"
|
||||
[[ "${ENABLE_LIVE_DEVIN_TESTS:-}" == 1 ]] || { echo 'Set ENABLE_LIVE_DEVIN_TESTS=1' >&2; exit 1; }
|
||||
trap bridge_cleanup_compose EXIT
|
||||
bridge_cleanup_compose
|
||||
bridge_reset_live_fixture
|
||||
"$(dirname "$0")/verify-anthropic-isolation" --static
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline --profile live-devin down --remove-orphans
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d network-guard
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up -d --wait network-guard
|
||||
bridge_check_devin_auth
|
||||
bridge_run_devin models list --format json >"$BRIDGE_SANDBOX/evidence/live-models.json"
|
||||
devin_model="$(node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs" \
|
||||
@@ -16,18 +17,10 @@ export DEVIN_BRIDGE_OPUS_MODEL="$DEVIN_BRIDGE_MODEL"
|
||||
export DEVIN_BRIDGE_HAIKU_MODEL="$DEVIN_BRIDGE_MODEL"
|
||||
export DEVIN_BRIDGE_SUBAGENT_MODEL="$DEVIN_BRIDGE_MODEL"
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile live-devin up --abort-on-container-exit --exit-code-from claude-live claude-live
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = process.argv[1];
|
||||
if (!fs.existsSync(path)) process.exit(0);
|
||||
for (const line of fs.readFileSync(path, "utf8").trim().split("\n").filter(Boolean)) {
|
||||
const entry = JSON.parse(line);
|
||||
const suffixAllowed = /(^|\.)(devin\.ai|cognition\.ai)$/.test(entry.hostname);
|
||||
const exactAllowed = ["server.codeium.com", "unleash.codeium.com"].includes(entry.hostname);
|
||||
if (entry.decision === "allow" && !suffixAllowed && !exactAllowed) {
|
||||
throw new Error(`unexpected allowed egress: ${entry.hostname}`);
|
||||
}
|
||||
}
|
||||
' "$BRIDGE_SANDBOX/evidence/egress.jsonl"
|
||||
bridge_assert_zero_claude_egress "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl"
|
||||
bridge_cleanup_compose
|
||||
bridge_assert_devin_guard_audit "$BRIDGE_DEVIN_AUDIT"
|
||||
bridge_assert_zero_claude_egress "$BRIDGE_CLAUDE_AUDIT"
|
||||
bridge_export_guard_audit "$BRIDGE_DEVIN_AUDIT" egress.jsonl
|
||||
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress.jsonl
|
||||
trap - EXIT
|
||||
printf 'PASS: live model %s was discovered and validated by three scenarios\n' "$devin_model"
|
||||
|
||||
@@ -4,4 +4,5 @@ source "$(dirname "$0")/common"
|
||||
cd "$BRIDGE_ROOT"
|
||||
bridge_test_env node --import tsx/esm --test \
|
||||
tests/unit/executor-devin-cli-agentic-core.test.ts \
|
||||
tests/unit/executor-devin-cli-agentic-acp.test.ts
|
||||
tests/unit/executor-devin-cli-agentic-acp.test.ts \
|
||||
tests/unit/devin-bridge-network-guard.test.ts
|
||||
|
||||
@@ -35,10 +35,16 @@ node -e '
|
||||
throw new Error("Claude egress guard is not deny-all");
|
||||
}
|
||||
for (const guardName of ["network-guard", "claude-egress-guard"]) {
|
||||
const guard = config.services[guardName];
|
||||
const env = config.services[guardName].environment;
|
||||
if (env.GUARD_ALLOW_SUFFIXES || env.GUARD_ALLOW_HOSTS) {
|
||||
throw new Error(`${guardName} exposes mutable host allowlists`);
|
||||
}
|
||||
if (!guard.healthcheck?.test) throw new Error(`${guardName} has no healthcheck`);
|
||||
const auditMount = (guard.volumes || []).find((mount) => mount.target === "/guard-audit");
|
||||
if (!auditMount || auditMount.type !== "bind" || !auditMount.source.includes("/.sandbox/guard-audit/")) {
|
||||
throw new Error(`${guardName} does not use its guard-only audit bind`);
|
||||
}
|
||||
}
|
||||
const runtimeNames = ["omniroute", "claude", "contract", "omniroute-live", "claude-live"];
|
||||
for (const serviceName of [...runtimeNames, "network-guard", "claude-egress-guard"]) {
|
||||
@@ -49,6 +55,9 @@ node -e '
|
||||
}
|
||||
for (const serviceName of runtimeNames) {
|
||||
const service = config.services[serviceName];
|
||||
if ((service.volumes || []).some((mount) => mount.target === "/guard-audit")) {
|
||||
throw new Error(`${serviceName} can mutate guard audit evidence`);
|
||||
}
|
||||
const namedVolumes = (service.volumes || []).filter((mount) => mount.type === "volume");
|
||||
const hasClaudeConfig = namedVolumes.some(
|
||||
(mount) => mount.target === "/home/bridge/.claude-devin-isolated",
|
||||
@@ -76,6 +85,14 @@ node -e '
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.services["omniroute-live"].depends_on["network-guard"].condition !== "service_healthy") {
|
||||
throw new Error("omniroute-live does not wait for a healthy Devin guard");
|
||||
}
|
||||
for (const serviceName of ["claude", "claude-live"]) {
|
||||
if (config.services[serviceName].depends_on["claude-egress-guard"].condition !== "service_healthy") {
|
||||
throw new Error(`${serviceName} does not wait for a healthy Claude guard`);
|
||||
}
|
||||
}
|
||||
const liveEnv = config.services["omniroute-live"].environment;
|
||||
if (liveEnv.DEVIN_BRIDGE_PROXY_URL !== "http://network-guard:8080") {
|
||||
throw new Error("trusted Devin bridge proxy is missing");
|
||||
@@ -167,6 +184,9 @@ fi
|
||||
if bridge_assert_devin_auth_status 0 $'Logged out\n' 2>/dev/null; then
|
||||
fail "logged-out auth fixture was accepted"
|
||||
fi
|
||||
if bridge_assert_devin_auth_status 0 $'Not Logged in (via Devin)\n' 2>/dev/null; then
|
||||
fail "misleading auth fixture was accepted"
|
||||
fi
|
||||
selected_model="$(printf '%s' '{"models":[{"family_uid":"swe-1.7"},{"modelUid":"swe-1.7-lightning"}]}' | \
|
||||
node --import tsx/esm "$BRIDGE_ROOT/scripts/devin-bridge/select-live-model.mjs")"
|
||||
[[ "$selected_model" == swe-1-7-lightning ]] || fail "live model normalization or preference failed"
|
||||
@@ -184,15 +204,14 @@ grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/test-live-d
|
||||
fail "live test bypasses strict auth status"
|
||||
grep -q 'bridge_check_devin_auth' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \
|
||||
fail "normal launch bypasses strict auth status"
|
||||
grep -q 'up -d network-guard claude-egress-guard' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \
|
||||
grep -q 'up -d --wait network-guard claude-egress-guard' "$BRIDGE_ROOT/scripts/devin-bridge/launch" || \
|
||||
fail "normal launch does not start the audited Claude egress guard"
|
||||
grep -qx '\.sandbox' "$BRIDGE_ROOT/.dockerignore" || fail ".sandbox is not excluded from builds"
|
||||
if [[ "${1:-}" == --static ]]; then printf 'PASS: static bridge isolation checks passed\n'; exit 0; fi
|
||||
trap bridge_cleanup_compose EXIT
|
||||
bridge_cleanup_compose
|
||||
bridge_reset_claude_egress_audit
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline up -d claude-egress-guard
|
||||
cleanup_claude_guard() {
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline stop claude-egress-guard >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_claude_guard EXIT
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline up -d --wait claude-egress-guard
|
||||
docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude bash -ceu '
|
||||
test "$(id -u)" = 10001
|
||||
test "$HOME" = /home/bridge
|
||||
@@ -231,9 +250,10 @@ docker compose -f "$BRIDGE_COMPOSE" --profile offline run --rm --no-deps claude
|
||||
signal: AbortSignal.timeout(3000),
|
||||
}));
|
||||
'
|
||||
bridge_assert_claude_guard_denials "$BRIDGE_SANDBOX/evidence/claude-egress.jsonl" || \
|
||||
bridge_cleanup_compose
|
||||
bridge_assert_claude_guard_denials "$BRIDGE_CLAUDE_AUDIT" || \
|
||||
fail "Claude proxy denial audit proof failed"
|
||||
cleanup_claude_guard
|
||||
bridge_export_guard_audit "$BRIDGE_CLAUDE_AUDIT" claude-egress-verifier.jsonl
|
||||
trap - EXIT
|
||||
bridge_reset_claude_egress_audit
|
||||
printf 'PASS: runtime bridge isolation checks passed\n'
|
||||
|
||||
@@ -1,87 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
|
||||
|
||||
import { isAllowedGuardHostname } from "../../docker/devin-bridge/network-guard/policy.mjs";
|
||||
import {
|
||||
validateAuditFileStat,
|
||||
validateClaudeGuardDenials,
|
||||
validateDevinAuthStatus,
|
||||
validateDevinGuardAudit,
|
||||
validateZeroClaudeEgress,
|
||||
} from "../../scripts/devin-bridge/runtime-policy.mjs";
|
||||
import { selectLiveModel } from "../../scripts/devin-bridge/select-live-model.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const composePath = path.join(root, "docker", "devin-bridge", "compose.yml");
|
||||
const commonPath = path.join(root, "scripts", "devin-bridge", "common");
|
||||
const mockE2ePath = path.join(root, "scripts", "devin-bridge", "test-e2e-mock");
|
||||
const launchPath = path.join(root, "scripts", "devin-bridge", "launch");
|
||||
const loginPath = path.join(root, "scripts", "devin-bridge", "login-devin");
|
||||
const selectorPath = path.join(root, "scripts", "devin-bridge", "select-live-model.mjs");
|
||||
const liveE2ePath = path.join(root, "scripts", "devin-bridge", "test-live-devin");
|
||||
const verifierPath = path.join(root, "scripts", "devin-bridge", "verify-anthropic-isolation");
|
||||
|
||||
function composeConfig() {
|
||||
const result = spawnSync(
|
||||
"docker-compose",
|
||||
[
|
||||
"-f",
|
||||
composePath,
|
||||
"--env-file",
|
||||
"/dev/null",
|
||||
"--profile",
|
||||
"offline",
|
||||
"--profile",
|
||||
"live-devin",
|
||||
"config",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ cwd: root, encoding: "utf8" }
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
interface ComposeService {
|
||||
depends_on?: Record<string, { condition?: string }>;
|
||||
environment?: Record<string, string>;
|
||||
healthcheck?: { test?: unknown };
|
||||
volumes?: Array<string | { source?: string }>;
|
||||
}
|
||||
|
||||
function volumeSources(service: { volumes?: Array<string | { source?: string }> }): string[] {
|
||||
return (service.volumes || []).map((mount) =>
|
||||
interface ComposeConfig {
|
||||
services: Record<string, ComposeService>;
|
||||
}
|
||||
|
||||
function composeConfig(): ComposeConfig {
|
||||
return load(fs.readFileSync(composePath, "utf8"), {
|
||||
filename: composePath,
|
||||
schema: CORE_SCHEMA.withTags(mergeTag),
|
||||
}) as ComposeConfig;
|
||||
}
|
||||
|
||||
function volumeSources(service: ComposeService): string[] {
|
||||
return (service.volumes || []).map((mount: string | { source?: string }) =>
|
||||
typeof mount === "string" ? mount.split(":", 1)[0] : String(mount.source || "")
|
||||
);
|
||||
}
|
||||
|
||||
function networkNames(service: { networks?: string[] | Record<string, unknown> }): string[] {
|
||||
return Array.isArray(service.networks) ? service.networks : Object.keys(service.networks || {});
|
||||
}
|
||||
|
||||
function assertAuthStatus(exitStatus: number, output: string) {
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
'source "$1"; bridge_assert_devin_auth_status "$2" "$3"',
|
||||
"bridge-auth-test",
|
||||
commonPath,
|
||||
String(exitStatus),
|
||||
output,
|
||||
],
|
||||
{ cwd: root, encoding: "utf8" }
|
||||
);
|
||||
}
|
||||
|
||||
function selectModel(document: unknown, env: NodeJS.ProcessEnv = {}) {
|
||||
return spawnSync(process.execPath, ["--import", "tsx/esm", selectorPath], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
input: JSON.stringify(document),
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
function runCommon(functionName: string, filePath: string) {
|
||||
return spawnSync(
|
||||
"bash",
|
||||
["-c", 'source "$1"; "$2" "$3"', "bridge-audit-test", commonPath, functionName, filePath],
|
||||
{ cwd: root, encoding: "utf8" }
|
||||
);
|
||||
}
|
||||
|
||||
test("network policy permits only Devin/Cognition and exact Codeium control-plane hosts", async () => {
|
||||
const { isAllowedGuardHostname } =
|
||||
await import("../../docker/devin-bridge/network-guard/policy.mjs");
|
||||
|
||||
test("network policy permits only the intended Devin destinations", () => {
|
||||
for (const hostname of [
|
||||
"api.devin.ai",
|
||||
"devin.ai",
|
||||
@@ -106,62 +73,68 @@ test("network policy permits only Devin/Cognition and exact Codeium control-plan
|
||||
assert.equal(isAllowedGuardHostname("api.devin.ai", "deny-all"), false);
|
||||
});
|
||||
|
||||
test("compose separates Claude config, Devin auth, and the two egress guards", () => {
|
||||
const config = composeConfig();
|
||||
const services = config.services;
|
||||
test("compose isolates guard audit mounts and waits for healthy guards", () => {
|
||||
const services = composeConfig().services;
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
const sources = volumeSources(service);
|
||||
const hasGuardAudit = sources.some((source) => source.includes(".sandbox/guard-audit"));
|
||||
assert.equal(hasGuardAudit, name === "network-guard" || name === "claude-egress-guard", name);
|
||||
}
|
||||
assert.match(volumeSources(services["network-guard"]).join(" "), /\.sandbox\/guard-audit\/devin/);
|
||||
assert.match(
|
||||
volumeSources(services["claude-egress-guard"]).join(" "),
|
||||
/\.sandbox\/guard-audit\/claude/
|
||||
);
|
||||
for (const guard of ["network-guard", "claude-egress-guard"]) {
|
||||
assert.ok(services[guard].healthcheck?.test, `${guard} healthcheck`);
|
||||
}
|
||||
assert.equal(
|
||||
services["omniroute-live"].depends_on?.["network-guard"].condition,
|
||||
"service_healthy"
|
||||
);
|
||||
assert.equal(services.claude.depends_on?.["claude-egress-guard"].condition, "service_healthy");
|
||||
assert.equal(
|
||||
services["claude-live"].depends_on?.["claude-egress-guard"].condition,
|
||||
"service_healthy"
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, service] of Object.entries(services) as Array<
|
||||
[string, { environment?: Record<string, string>; networks?: object; volumes?: object[] }]
|
||||
>) {
|
||||
test("compose keeps role-separated credentials and proxy settings", () => {
|
||||
const services = composeConfig().services;
|
||||
for (const [name, service] of Object.entries(services)) {
|
||||
const sources = volumeSources(service);
|
||||
assert.equal(
|
||||
sources.some(
|
||||
(source) =>
|
||||
source === "claude-isolated-config" || source.endsWith("_claude-isolated-config")
|
||||
),
|
||||
sources.includes("claude-isolated-config"),
|
||||
name === "claude" || name === "claude-live",
|
||||
`${name} Claude config volume ownership`
|
||||
);
|
||||
assert.equal(
|
||||
sources.some((source) => source === "devin-auth" || source.endsWith("_devin-auth")),
|
||||
name === "omniroute-live",
|
||||
`${name} Devin auth volume ownership`
|
||||
`${name} Claude config ownership`
|
||||
);
|
||||
assert.equal(sources.includes("devin-auth"), name === "omniroute-live", `${name} Devin auth`);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
services["omniroute-live"].environment.DEVIN_BRIDGE_PROXY_URL,
|
||||
services["omniroute-live"].environment?.DEVIN_BRIDGE_PROXY_URL,
|
||||
"http://network-guard:8080"
|
||||
);
|
||||
assert.equal(services["omniroute-live"].environment.HTTP_PROXY, undefined);
|
||||
assert.equal(services["omniroute-live"].environment.HTTPS_PROXY, undefined);
|
||||
|
||||
for (const name of ["claude", "claude-live"]) {
|
||||
const env = services[name].environment;
|
||||
assert.equal(env.NODE_USE_ENV_PROXY, "1");
|
||||
assert.equal(env.HTTP_PROXY, "http://claude-egress-guard:8080");
|
||||
assert.equal(env.HTTPS_PROXY, "http://claude-egress-guard:8080");
|
||||
assert.equal(env.NO_PROXY, "omniroute");
|
||||
assert.notEqual(env.HTTP_PROXY, "http://network-guard:8080");
|
||||
assert.equal(services[name].environment?.HTTP_PROXY, "http://claude-egress-guard:8080");
|
||||
assert.equal(services[name].environment?.NO_PROXY, "omniroute");
|
||||
}
|
||||
|
||||
assert.equal(services["network-guard"].environment.GUARD_POLICY, "devin");
|
||||
assert.equal(services["claude-egress-guard"].environment.GUARD_POLICY, "deny-all");
|
||||
assert.deepEqual(networkNames(services["claude-egress-guard"]), ["bridge-internal"]);
|
||||
});
|
||||
|
||||
test("auth status gate requires a clean server-confirmed login", () => {
|
||||
assert.equal(assertAuthStatus(0, "Logged in (via Devin)\n").status, 0);
|
||||
assert.notEqual(
|
||||
assertAuthStatus(0, "Logged in (via Devin)\nFailed to fetch from server\n").status,
|
||||
0
|
||||
);
|
||||
assert.notEqual(assertAuthStatus(0, "Logged out\n").status, 0);
|
||||
assert.notEqual(assertAuthStatus(1, "Logged in (via Devin)\n").status, 0);
|
||||
test("auth status requires the exact positive line and rejects misleading text", () => {
|
||||
assert.equal(validateDevinAuthStatus(0, "Logged in (via Devin)\n").ok, true);
|
||||
for (const output of [
|
||||
"Not Logged in (via Devin)\n",
|
||||
"prefix Logged in (via Devin) suffix\n",
|
||||
"Logged out\n",
|
||||
"Logged in (via Devin)\nFailed to fetch from server\n",
|
||||
]) {
|
||||
assert.equal(validateDevinAuthStatus(0, output).ok, false, output);
|
||||
}
|
||||
assert.equal(validateDevinAuthStatus(1, "Logged in (via Devin)\n").ok, false);
|
||||
});
|
||||
|
||||
test("live model selection normalizes real family/model uid fields and prefers lightning", () => {
|
||||
const result = selectModel({
|
||||
test("model discovery accepts only explicit uid/id fields and detects catalog ambiguity", () => {
|
||||
const selected = selectLiveModel({
|
||||
models: [
|
||||
{ family_uid: "swe-1.7" },
|
||||
{ familyUid: "swe-1.7-lightning" },
|
||||
@@ -169,82 +142,77 @@ test("live model selection normalizes real family/model uid fields and prefers l
|
||||
{ modelUid: "swe-1.6" },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "swe-1-7-lightning");
|
||||
});
|
||||
|
||||
test("live model selection rejects normalized identifiers absent from the catalog", () => {
|
||||
const result = selectModel({ models: [{ family_uid: "swe-9.9-unknown" }] });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /no model identifier present/i);
|
||||
});
|
||||
|
||||
test("login uses the official manual token flow without accepting tokens as arguments", () => {
|
||||
const login = fs.readFileSync(loginPath, "utf8");
|
||||
const common = fs.readFileSync(commonPath, "utf8");
|
||||
assert.match(login, /bridge_run_devin auth login --force-manual-token-flow/);
|
||||
assert.match(common, /exec devin "\$@"/);
|
||||
assert.doesNotMatch(login, /read\s+.*token|printf\s+.*token|echo\s+.*token/i);
|
||||
});
|
||||
|
||||
test("normal live launch uses the strict auth and proxied Devin helpers", () => {
|
||||
const launch = fs.readFileSync(launchPath, "utf8");
|
||||
assert.match(launch, /up -d network-guard claude-egress-guard/);
|
||||
assert.match(launch, /bridge_check_devin_auth/);
|
||||
assert.match(launch, /bridge_run_devin models list --format json/);
|
||||
assert.doesNotMatch(launch, /\bdevin auth status\b/);
|
||||
});
|
||||
|
||||
test("Claude audit helpers fail closed for missing, empty, partial, or any real-run record", () => {
|
||||
const sandboxRoot = path.join(root, ".sandbox");
|
||||
fs.mkdirSync(sandboxRoot, { recursive: true });
|
||||
const auditRoot = fs.mkdtempSync(path.join(sandboxRoot, "audit-unit-"));
|
||||
const auditPath = path.join(auditRoot, "claude-egress.jsonl");
|
||||
try {
|
||||
assert.notEqual(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0);
|
||||
fs.writeFileSync(auditPath, "");
|
||||
assert.equal(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0);
|
||||
fs.writeFileSync(auditPath, '{"hostname":"api.anthropic.com","decision":"deny"}\n');
|
||||
assert.notEqual(runCommon("bridge_assert_zero_claude_egress", auditPath).status, 0);
|
||||
|
||||
assert.notEqual(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0);
|
||||
fs.appendFileSync(auditPath, '{"hostname":"claude.ai","decision":"deny"}\n');
|
||||
assert.equal(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0);
|
||||
fs.appendFileSync(auditPath, '{"hostname":"example.com","decision":"allow"}\n');
|
||||
assert.notEqual(runCommon("bridge_assert_claude_guard_denials", auditPath).status, 0);
|
||||
} finally {
|
||||
fs.rmSync(auditRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("offline and live resets precreate an empty world-writable Claude audit", () => {
|
||||
const auditPath = path.join(root, ".sandbox", "evidence", "claude-egress.jsonl");
|
||||
for (const resetFunction of ["bridge_reset_e2e_fixture", "bridge_reset_live_fixture"]) {
|
||||
const reset = spawnSync(
|
||||
"bash",
|
||||
["-c", 'source "$1"; "$2"', "bridge-reset-test", commonPath, resetFunction],
|
||||
{ cwd: root, encoding: "utf8" }
|
||||
);
|
||||
assert.equal(reset.status, 0, reset.stderr);
|
||||
assert.equal(fs.existsSync(auditPath), true, resetFunction);
|
||||
assert.equal(fs.statSync(auditPath).size, 0, resetFunction);
|
||||
assert.equal(fs.statSync(auditPath).mode & 0o777, 0o666, resetFunction);
|
||||
fs.writeFileSync(auditPath, "record");
|
||||
}
|
||||
});
|
||||
|
||||
test("verifier proves audited denials while real E2E gates require zero Claude attempts", () => {
|
||||
const verifier = fs.readFileSync(verifierPath, "utf8");
|
||||
const mockE2e = fs.readFileSync(mockE2ePath, "utf8");
|
||||
const liveE2e = fs.readFileSync(liveE2ePath, "utf8");
|
||||
|
||||
assert.match(verifier, /fetch\("https:\/\/api\.anthropic\.com"/);
|
||||
assert.match(verifier, /fetch\("https:\/\/claude\.ai"/);
|
||||
assert.match(verifier, /bridge_assert_claude_guard_denials/);
|
||||
assert.ok(
|
||||
[...verifier.matchAll(/bridge_reset_claude_egress_audit/g)].length >= 2,
|
||||
"deliberate proof must reset the audit before and after its own requests"
|
||||
assert.equal(selected, "swe-1-7-lightning");
|
||||
assert.throws(() => selectLiveModel({ metadata: { id: "swe-1.7" } }), /no model identifier/i);
|
||||
assert.throws(
|
||||
() => selectLiveModel({ models: [{ model_uid: "a.b" }] }, {}, [{ id: "a.b" }, { id: "a-b" }]),
|
||||
/Ambiguous OmniRoute catalog normalization/
|
||||
);
|
||||
assert.match(mockE2e, /bridge_assert_zero_claude_egress/);
|
||||
assert.match(liveE2e, /bridge_assert_zero_claude_egress/);
|
||||
});
|
||||
|
||||
test("audit policies reject forged metadata, missing proof, and unexpected Devin hosts", () => {
|
||||
const safeStat = {
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
nlink: 1,
|
||||
uid: 501,
|
||||
mode: 0o100666,
|
||||
};
|
||||
assert.equal(validateAuditFileStat(safeStat as unknown as fs.Stats, 501), null);
|
||||
assert.match(
|
||||
validateAuditFileStat({ ...safeStat, nlink: 2 } as unknown as fs.Stats, 501) || "",
|
||||
/link count/
|
||||
);
|
||||
assert.match(
|
||||
validateAuditFileStat(
|
||||
{ ...safeStat, isSymbolicLink: () => true } as unknown as fs.Stats,
|
||||
501
|
||||
) || "",
|
||||
/regular file/
|
||||
);
|
||||
assert.equal(validateZeroClaudeEgress("").ok, true);
|
||||
assert.equal(validateZeroClaudeEgress("{}\n").ok, false);
|
||||
assert.equal(
|
||||
validateClaudeGuardDenials(
|
||||
'{"hostname":"api.anthropic.com","decision":"deny"}\n' +
|
||||
'{"hostname":"claude.ai","decision":"deny"}\n'
|
||||
).ok,
|
||||
true
|
||||
);
|
||||
assert.equal(validateDevinGuardAudit("").ok, false);
|
||||
assert.equal(
|
||||
validateDevinGuardAudit('{"hostname":"api.devin.ai","decision":"allow"}\n').ok,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDevinGuardAudit('{"hostname":"o1.ingest.sentry.io","decision":"deny"}\n').ok,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("scripts use atomic audit resets, readiness waits, cleanup traps, and strict gates", () => {
|
||||
const common = fs.readFileSync(commonPath, "utf8");
|
||||
const login = fs.readFileSync(loginPath, "utf8");
|
||||
const launch = fs.readFileSync(launchPath, "utf8");
|
||||
const live = fs.readFileSync(liveE2ePath, "utf8");
|
||||
const mock = fs.readFileSync(mockE2ePath, "utf8");
|
||||
const verifier = fs.readFileSync(verifierPath, "utf8");
|
||||
assert.match(common, /chmod 01777/);
|
||||
assert.match(common, /mktemp/);
|
||||
assert.match(common, /chmod 0666/);
|
||||
assert.match(common, /mv -f/);
|
||||
for (const script of [login, launch, live, verifier]) {
|
||||
assert.match(script, /trap bridge_cleanup_compose EXIT/);
|
||||
}
|
||||
for (const script of [login, launch, live, verifier]) assert.match(script, /up -d --wait/);
|
||||
assert.match(login, /auth login --force-manual-token-flow/);
|
||||
assert.match(live, /bridge_assert_devin_guard_audit/);
|
||||
assert.match(live, /bridge_assert_zero_claude_egress/);
|
||||
assert.match(mock, /bridge_assert_zero_claude_egress/);
|
||||
assert.ok([...verifier.matchAll(/bridge_reset_claude_egress_audit/g)].length >= 2);
|
||||
});
|
||||
|
||||
test("Docker build context excludes sandbox runtime state", () => {
|
||||
const dockerignore = fs.readFileSync(path.join(root, ".dockerignore"), "utf8");
|
||||
assert.match(dockerignore, /^\.sandbox$/m);
|
||||
});
|
||||
|
||||
217
tests/unit/devin-bridge-network-guard.test.ts
Normal file
217
tests/unit/devin-bridge-network-guard.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createGuardProxy } from "../../docker/devin-bridge/network-guard/proxy.mjs";
|
||||
import {
|
||||
parseConnectAuthority,
|
||||
parseTlsClientHelloSni,
|
||||
} from "../../docker/devin-bridge/network-guard/policy.mjs";
|
||||
|
||||
function listen(server: net.Server | http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") return reject(new Error("missing port"));
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: net.Server | http.Server): Promise<void> {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
function uint24(value: number) {
|
||||
return Buffer.from([(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]);
|
||||
}
|
||||
|
||||
function clientHello(serverName?: string) {
|
||||
const extensions: Buffer[] = [];
|
||||
if (serverName) {
|
||||
const name = Buffer.from(serverName, "ascii");
|
||||
const entry = Buffer.concat([
|
||||
Buffer.from([0]),
|
||||
Buffer.from([name.length >> 8, name.length]),
|
||||
name,
|
||||
]);
|
||||
const list = Buffer.concat([Buffer.from([entry.length >> 8, entry.length]), entry]);
|
||||
extensions.push(Buffer.concat([Buffer.from([0, 0, 0, list.length]), list]));
|
||||
}
|
||||
const extensionBytes = Buffer.concat(extensions);
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([3, 3]),
|
||||
Buffer.alloc(32, 1),
|
||||
Buffer.from([0]),
|
||||
Buffer.from([0, 2, 0x13, 0x01]),
|
||||
Buffer.from([1, 0]),
|
||||
Buffer.from([extensionBytes.length >> 8, extensionBytes.length]),
|
||||
extensionBytes,
|
||||
]);
|
||||
const handshake = Buffer.concat([Buffer.from([1]), uint24(body.length), body]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([22, 3, 1, handshake.length >> 8, handshake.length]),
|
||||
handshake,
|
||||
]);
|
||||
}
|
||||
|
||||
test("CONNECT authority is fixed to port 443 and ClientHello SNI is bounded", () => {
|
||||
assert.deepEqual(parseConnectAuthority("api.devin.ai:443"), {
|
||||
hostname: "api.devin.ai",
|
||||
port: 443,
|
||||
});
|
||||
assert.equal(parseConnectAuthority("api.devin.ai:80"), null);
|
||||
assert.equal(parseConnectAuthority("api.devin.ai"), null);
|
||||
|
||||
const hello = clientHello("api.devin.ai");
|
||||
assert.equal(parseTlsClientHelloSni(hello.subarray(0, 8)).status, "need-more");
|
||||
assert.deepEqual(parseTlsClientHelloSni(hello), {
|
||||
status: "ok",
|
||||
serverName: "api.devin.ai",
|
||||
});
|
||||
assert.deepEqual(parseTlsClientHelloSni(clientHello()), {
|
||||
status: "invalid",
|
||||
reason: "missing_sni",
|
||||
});
|
||||
assert.equal(parseTlsClientHelloSni(Buffer.from("not tls")).status, "invalid");
|
||||
});
|
||||
|
||||
test("HTTP proxy overwrites Host and strips proxy and hop-by-hop credentials", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-http-"));
|
||||
const logPath = path.join(tmp, "audit.jsonl");
|
||||
fs.writeFileSync(logPath, "");
|
||||
let receivedHeaders: http.IncomingHttpHeaders = {};
|
||||
const upstream = http.createServer((req, res) => {
|
||||
receivedHeaders = req.headers;
|
||||
res.end("forwarded");
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const proxy = createGuardProxy({ logPath, allowHostname: () => true });
|
||||
const proxyPort = await listen(proxy);
|
||||
try {
|
||||
const body = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxyPort,
|
||||
path: `http://127.0.0.1:${upstreamPort}/proof`,
|
||||
headers: {
|
||||
host: "attacker.example",
|
||||
"proxy-authorization": "Basic forged",
|
||||
"proxy-connection": "keep-alive",
|
||||
connection: "keep-alive, x-remove",
|
||||
"x-remove": "forged",
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let text = "";
|
||||
res.on("data", (chunk) => (text += chunk));
|
||||
res.on("end", () => resolve(text));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
assert.equal(body, "forwarded");
|
||||
assert.equal(receivedHeaders.host, `127.0.0.1:${upstreamPort}`);
|
||||
assert.equal(receivedHeaders["proxy-authorization"], undefined);
|
||||
assert.equal(receivedHeaders["proxy-connection"], undefined);
|
||||
assert.equal(receivedHeaders["x-remove"], undefined);
|
||||
} finally {
|
||||
await close(proxy);
|
||||
await close(upstream);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CONNECT rejects mismatched SNI before opening an upstream socket", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-sni-"));
|
||||
const logPath = path.join(tmp, "audit.jsonl");
|
||||
fs.writeFileSync(logPath, "");
|
||||
let upstreamConnections = 0;
|
||||
const proxy = createGuardProxy({
|
||||
logPath,
|
||||
allowHostname: () => true,
|
||||
connectSocket: () => {
|
||||
upstreamConnections += 1;
|
||||
throw new Error("must not connect");
|
||||
},
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(proxyPort, "127.0.0.1", () => {
|
||||
socket.write("CONNECT api.devin.ai:443 HTTP/1.1\r\nHost: api.devin.ai:443\r\n\r\n");
|
||||
});
|
||||
let sentHello = false;
|
||||
socket.on("data", (chunk) => {
|
||||
if (!sentHello && chunk.toString("latin1").includes("200 Connection Established")) {
|
||||
sentHello = true;
|
||||
socket.write(clientHello("claude.ai"));
|
||||
}
|
||||
});
|
||||
socket.on("close", () => resolve());
|
||||
socket.on("error", reject);
|
||||
setTimeout(() => reject(new Error("CONNECT mismatch test timed out")), 2000).unref();
|
||||
});
|
||||
assert.equal(upstreamConnections, 0);
|
||||
assert.match(fs.readFileSync(logPath, "utf8"), /"reason":"sni_mismatch"/);
|
||||
} finally {
|
||||
await close(proxy);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CONNECT forwards only after matching SNI is validated", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "devin-guard-sni-ok-"));
|
||||
const logPath = path.join(tmp, "audit.jsonl");
|
||||
fs.writeFileSync(logPath, "");
|
||||
let forwardedBytes = 0;
|
||||
const upstream = net.createServer((socket) => {
|
||||
socket.once("data", (chunk) => {
|
||||
forwardedBytes += chunk.length;
|
||||
socket.write("UPSTREAM_OK");
|
||||
});
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const proxy = createGuardProxy({
|
||||
logPath,
|
||||
allowHostname: () => true,
|
||||
connectSocket: (_port, _hostname, onConnect) =>
|
||||
net.connect(upstreamPort, "127.0.0.1", onConnect),
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(proxyPort, "127.0.0.1", () => {
|
||||
socket.write("CONNECT api.devin.ai:443 HTTP/1.1\r\nHost: api.devin.ai:443\r\n\r\n");
|
||||
});
|
||||
let sentHello = false;
|
||||
socket.on("data", (chunk) => {
|
||||
const text = chunk.toString("latin1");
|
||||
if (!sentHello && text.includes("200 Connection Established")) {
|
||||
sentHello = true;
|
||||
socket.write(clientHello("api.devin.ai"));
|
||||
return;
|
||||
}
|
||||
if (text.includes("UPSTREAM_OK")) {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
setTimeout(() => reject(new Error("CONNECT forwarding test timed out")), 2000).unref();
|
||||
});
|
||||
assert.ok(forwardedBytes > 0);
|
||||
assert.match(fs.readFileSync(logPath, "utf8"), /"reason":"sni_match"/);
|
||||
} finally {
|
||||
await close(proxy);
|
||||
await close(upstream);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -4,16 +4,19 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
assertLocalAcpUrl,
|
||||
buildDevinChildEnv,
|
||||
DevinCliAgenticExecutor,
|
||||
} from "../../open-sse/executors/devin-cli-agentic.ts";
|
||||
import { devin_cli_agenticProvider } from "../../open-sse/config/providers/registry/devin-cli-agentic/index.ts";
|
||||
import { getProviderCredentials } from "../../src/sse/services/auth.ts";
|
||||
const isolatedRoot = path.join(process.cwd(), ".sandbox", "direct-acp-test");
|
||||
process.env.HOME = path.join(isolatedRoot, "home");
|
||||
process.env.DATA_DIR = path.join(isolatedRoot, "data");
|
||||
process.env.SQLITE_FILE = path.join(isolatedRoot, "data", "storage.sqlite");
|
||||
process.env.DEVIN_AGENTIC_HOME = process.env.HOME;
|
||||
fs.mkdirSync(process.env.HOME, { recursive: true });
|
||||
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
|
||||
|
||||
process.env.DEVIN_AGENTIC_HOME = path.join(process.cwd(), ".sandbox", "unit-home");
|
||||
fs.mkdirSync(process.env.DEVIN_AGENTIC_HOME, { recursive: true });
|
||||
const { assertLocalAcpUrl, buildDevinChildEnv, DevinCliAgenticExecutor } =
|
||||
await import("../../open-sse/executors/devin-cli-agentic.ts");
|
||||
const { devin_cli_agenticProvider } =
|
||||
await import("../../open-sse/config/providers/registry/devin-cli-agentic/index.ts");
|
||||
const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
async function readResponseText(response: Response) {
|
||||
return await response.text();
|
||||
|
||||
Reference in New Issue
Block a user