feat: complete governed run egress (#1080)

* feat: broker scoped egress approvals

* feat: add authenticated SOCKS5 egress

* feat: support operator upstream egress proxies

* test: hoist workflow service mocks
This commit is contained in:
Brad Groux 2026-07-25 20:55:54 -05:00 committed by GitHub
parent e15bd320e8
commit e69bfe8c14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1229 additions and 130 deletions

View file

@ -62,6 +62,11 @@ VERITAS_ADMIN_KEY=
# http://127.0.0.1:5173,http://127.0.0.1:3000
# CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Optional operator HTTP proxy for selective run-scoped egress.
# Destination policy is still evaluated and the pinned IP is sent through CONNECT.
# Credentials are held in memory and are not persisted in launch or telemetry evidence.
# VERITAS_EGRESS_UPSTREAM_PROXY=http://proxy-user:proxy-password@proxy.internal:3128
# ── Logging ──────────────────────────────────────────────────
# Pino log level: fatal | error | warn | info | debug | trace | silent
# LOG_LEVEL=info

View file

@ -817,10 +817,28 @@ the trace ID.
Selective network policies for local task and workflow providers start an
authenticated, run-scoped loopback gateway before dispatch. Durable launch and
gateway evidence records only the injected proxy key names and policy digest;
tokens and proxy URLs are never persisted. HTTP, HTTPS CONNECT, and plaintext WebSocket
transports use the evaluated DNS address, and the gateway is stopped during
terminal cleanup. OpenClaw is provider-managed and cannot receive this local
boundary, so a required fine-grained policy blocks its launch.
tokens and proxy URLs are never persisted. HTTP, HTTPS CONNECT, and plaintext
WebSocket transports use the evaluated DNS address. `ALL_PROXY` exposes a
separate authenticated `socks5h` listener with the same host, address, approval,
and audit policy; encrypted SOCKS tunnels fail closed when method or path
inspection would be required. Both listeners stop during terminal cleanup.
OpenClaw is provider-managed and cannot receive this local boundary, so a
required fine-grained policy blocks its launch.
Operators that require a corporate or audited proxy can set
`VERITAS_EGRESS_UPSTREAM_PROXY` to an HTTP proxy origin. The gateway evaluates
the destination first, pins the resolved address, and then opens an upstream
CONNECT tunnel to that address. Proxy credentials remain memory-only; durable
evidence records only `upstreamMode: http-connect`.
When a preset enables scoped approvals, an otherwise eligible block creates a
durable, exact-action network approval and holds the request at the gateway.
Only an approved request proceeds. Explicit host denies and protected address
classes are never approval-eligible. Gateway shutdown aborts pending waits.
Every final decision also emits a `network.egress` telemetry record containing
the hashed host key, run key, policy digest, protocol, port, decision, reason,
and approval ID when present. URLs, query strings, headers, bodies, proxy
credentials, and raw paths are excluded from telemetry.
Local ACP, Claude Code, Codex app-server, Codex CLI, and Hermes processes use a
version-bound `codex sandbox` wrapper when its credential-free conformance

View file

@ -1144,6 +1144,11 @@ GET /api/telemetry/count # Event counts
GET /api/telemetry/export # Export events (CSV/JSON)
```
The run-scoped gateway emits `network.egress` events internally. These records
contain run and gateway keys, policy digest, hashed host key, protocol, port,
decision, reason, and optional approval correlation. They never contain full
URLs, query strings, headers, bodies, proxy credentials, or raw paths.
---
## Health

View file

@ -536,8 +536,9 @@ All variables are set in `server/.env` (or passed as environment variables in Do
### Networking & Security
| Variable | Default | Description |
| ----------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TRUST_PROXY` | — | Express trust proxy setting for reverse proxy deployments. Common: `1` (single hop), `loopback`. Required for correct rate limiting behind nginx/Caddy/Traefik. `true` is blocked for safety |
| `VERITAS_EGRESS_UPSTREAM_PROXY` | — | Optional operator HTTP proxy for policy-approved run egress. The gateway tunnels to the DNS-pinned destination and never persists proxy credentials |
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173,...` | Comma-separated list of allowed CORS origins |
| `RATE_LIMIT_MAX` | `300` | Max API requests per minute per IP (localhost exempt). Auth endpoints have a stricter 15 req/min limit |
| `CSP_REPORT_ONLY` | `false` | Use Content-Security-Policy-Report-Only instead of enforcing |

View file

@ -1177,10 +1177,18 @@ Reusable launch-time sandbox presets for provider execution guardrails.
rules take precedence, unsafe global allow wildcards fail validation, and a
deterministic policy digest binds later gateway launch evidence.
- Selective local-provider policies start an authenticated loopback gateway
before provider dispatch. Veritas injects HTTP, HTTPS, and all-proxy variables,
clears proxy bypass variables, pins the evaluated DNS address for transport,
and stops the gateway with the run. Remote OpenClaw execution fails closed
when the preset requires this local gateway.
before provider dispatch. Veritas injects HTTP and HTTPS proxy variables plus
an authenticated `socks5h` all-proxy listener, clears proxy bypass variables,
pins the evaluated DNS address for transport, and stops both listeners with
the run. Remote OpenClaw execution fails closed when the preset requires this
local gateway.
- Optional `VERITAS_EGRESS_UPSTREAM_PROXY` routing sends only policy-approved,
DNS-pinned destinations through an operator HTTP CONNECT proxy. Credentials
stay memory-only and evidence exposes only the upstream mode.
- Approval-eligible blocks pause at the gateway on a durable exact-action
approval. Explicit denies and protected address classes cannot be overridden.
Approved requests retain the approval ID in metadata-only governance and
`network.egress` telemetry evidence.
**Enforcement:**
@ -1746,7 +1754,7 @@ New endpoints for advanced metrics and visualization (v1.6):
Event-based telemetry system powering dashboard analytics.
- **Event types**`run.started`, `run.completed`, `run.tokens` for tracking agent execution lifecycle
- **Event types**`run.started`, `run.completed`, `run.tokens`, and metadata-only `network.egress` decisions for tracking execution and network policy outcomes
- **Token tracking** — Input tokens, output tokens, cache tokens, and cost per run
- **Duration tracking** — Millisecond-precision run duration with 7-day cap validation (604,800,000 ms)
- **Retention policy** — Configurable retention period (default: 30 days) with automatic cleanup of old events

View file

@ -63,6 +63,11 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,h
# Reverse proxy support (set when behind nginx, Caddy, Traefik, etc.)
# TRUST_PROXY=1
# Optional operator HTTP proxy for selective run-scoped egress.
# The gateway tunnels only policy-approved, DNS-pinned destinations through it.
# Credentials remain memory-only and are excluded from durable evidence.
# VERITAS_EGRESS_UPSTREAM_PROXY=http://proxy-user:proxy-password@proxy.internal:3128
# ═══════════════════════════════════════════════════════════════════════════════
# DATA & STORAGE
# ═══════════════════════════════════════════════════════════════════════════════

View file

@ -23,12 +23,21 @@ import { workflowAdmissionStub } from './helpers/workflow-admission-stub.js';
// Using module-level fns so vi.mock factory can close over them.
// ─────────────────────────────────────────────────────────────
const mockLoadWorkflow = vi.fn();
const mockPrepareStep = vi.fn(async (step: unknown) => ({ kind: 'non-agent', step }));
const mockApplyPreparation = vi.fn();
const mockExecuteStep = vi.fn();
const mockBroadcastWorkflowStatus = vi.fn();
const mockGetTask = vi.fn();
const {
mockLoadWorkflow,
mockPrepareStep,
mockApplyPreparation,
mockExecuteStep,
mockBroadcastWorkflowStatus,
mockGetTask,
} = vi.hoisted(() => ({
mockLoadWorkflow: vi.fn(),
mockPrepareStep: vi.fn(async (step: unknown) => ({ kind: 'non-agent', step })),
mockApplyPreparation: vi.fn(),
mockExecuteStep: vi.fn(),
mockBroadcastWorkflowStatus: vi.fn(),
mockGetTask: vi.fn(),
}));
vi.mock('../services/workflow-service.js', () => ({
getWorkflowService: () => ({

View file

@ -5,6 +5,8 @@ import type { AddressInfo } from 'node:net';
import { EgressPolicyService } from '../services/egress-policy-service.js';
import {
RunEgressGatewayService,
type RunEgressGatewayApprovalRequest,
type RunEgressGatewayApprovalResult,
type RunEgressGatewayHandle,
} from '../services/run-egress-gateway-service.js';
@ -80,10 +82,10 @@ describe('RunEgressGatewayService', () => {
gatewayId: gateway.gatewayId,
runKey: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
attributionKey: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
protocols: ['http', 'connect', 'ws'],
protocols: ['http', 'connect', 'ws', 'socks5'],
});
expect(JSON.stringify(gateway.evidence)).not.toContain(proxy.password);
expect(gateway.environment.ALL_PROXY).toBe(gateway.environment.HTTP_PROXY);
expect(gateway.environment.ALL_PROXY).toMatch(/^socks5h:\/\/veritas:/);
expect(gateway.environment.NO_PROXY).toBe('');
await expect(gateway.stop()).resolves.toMatchObject({
@ -132,34 +134,192 @@ describe('RunEgressGatewayService', () => {
);
expect(response).toContain('101 Switching Protocols');
});
it('opens an authenticated SOCKS5 tunnel with the same pinned policy decision', async () => {
const echo = net.createServer((socket) => socket.pipe(socket));
const echoPort = await listen(echo);
const audit = vi.fn();
const gateway = await startGateway({
runId: 'run-egress-socks',
onDecision: audit,
});
const tunnel = await connectSocksTunnel(gateway, '127.0.0.1', echoPort);
await expect(roundTrip(tunnel, 'through-socks')).resolves.toBe('through-socks');
tunnel.destroy();
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
decision: expect.objectContaining({
protocol: 'socks',
decision: 'allow',
}),
})
);
const restricted = await startGateway({
runId: 'run-egress-socks-restricted',
allowedMethods: ['GET'],
});
await expect(connectSocksTunnel(restricted, '127.0.0.1', echoPort)).rejects.toMatchObject({
replyCode: 0x02,
});
await expect(
connectSocksTunnel(gateway, '127.0.0.1', echoPort, 'invalid-token')
).rejects.toMatchObject({
authStatus: 0x01,
});
});
it('uses an operator upstream proxy without exposing its credentials in evidence', async () => {
const destination = http.createServer((_request, response) => response.end('via-upstream'));
const destinationPort = await listen(destination);
const observed: Array<{ authority: string; authorization?: string }> = [];
const upstream = http.createServer();
upstream.on('connect', (request, clientSocket, head) => {
observed.push({
authority: request.url ?? '',
authorization:
typeof request.headers['proxy-authorization'] === 'string'
? request.headers['proxy-authorization']
: undefined,
});
const authority = new URL(`http://${request.url}`);
const destinationSocket = net.connect(Number(authority.port), authority.hostname);
destinationSocket.once('connect', () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length > 0) destinationSocket.write(head);
clientSocket.pipe(destinationSocket);
destinationSocket.pipe(clientSocket);
});
destinationSocket.once('error', () => clientSocket.destroy());
});
const upstreamPort = await listen(upstream);
const gateway = await startGateway({
runId: 'run-egress-upstream',
upstreamProxyUrl: `http://proxy-user:proxy-pass@127.0.0.1:${upstreamPort}`,
});
await expect(
proxyRequest(gateway, `http://127.0.0.1:${destinationPort}/`, 'GET')
).resolves.toMatchObject({
statusCode: 200,
body: 'via-upstream',
});
expect(observed).toEqual([
{
authority: `127.0.0.1:${destinationPort}`,
authorization: `Basic ${Buffer.from('proxy-user:proxy-pass').toString('base64')}`,
},
]);
expect(gateway.evidence.upstreamMode).toBe('http-connect');
expect(JSON.stringify(gateway.evidence)).not.toContain('proxy-pass');
});
it('pauses approval-eligible requests and never lets approval override an explicit deny', async () => {
const upstream = http.createServer((_request, response) => response.end('approved-egress'));
const upstreamPort = await listen(upstream);
const onApprovalRequired = vi.fn(async (): Promise<RunEgressGatewayApprovalResult> => ({
approvalId: 'runapproval_network',
approved: true,
}));
const audit = vi.fn();
const approvedGateway = await startGateway({
runId: 'run-egress-approved',
defaultEgress: 'deny',
allowedHosts: [],
allowApprovals: true,
onApprovalRequired,
onDecision: audit,
});
await expect(
proxyRequest(
approvedGateway,
`http://127.0.0.1:${upstreamPort}/approved?secret=not-audited`,
'GET'
)
).resolves.toMatchObject({
statusCode: 200,
body: 'approved-egress',
});
expect(onApprovalRequired).toHaveBeenCalledWith(
expect.objectContaining({
host: '127.0.0.1',
port: upstreamPort,
path: '/approved',
decision: expect.objectContaining({
decision: 'block',
reason: 'default-deny',
approvalEligible: true,
}),
})
);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
decision: expect.objectContaining({
decision: 'allow',
reason: 'allowed-by-approval',
policyReason: 'default-deny',
approvalId: 'runapproval_network',
}),
})
);
expect(JSON.stringify(audit.mock.calls)).not.toContain('not-audited');
const deniedApproval = vi.fn();
const deniedGateway = await startGateway({
runId: 'run-egress-denied',
defaultEgress: 'allow',
deniedHosts: ['127.0.0.1'],
allowApprovals: true,
onApprovalRequired: deniedApproval,
});
await expect(
proxyRequest(deniedGateway, `http://127.0.0.1:${upstreamPort}/`, 'GET')
).resolves.toMatchObject({
statusCode: 403,
body: expect.stringContaining('denied-host'),
});
expect(deniedApproval).not.toHaveBeenCalled();
});
});
async function startGateway(
overrides: {
runId?: string;
defaultEgress?: 'allow' | 'deny';
allowedHosts?: string[];
deniedHosts?: string[];
allowedMethods?: string[];
allowedPathPrefixes?: string[];
allowApprovals?: boolean;
upstreamProxyUrl?: string;
onDecision?: (event: unknown) => void;
onApprovalRequired?: (
request: RunEgressGatewayApprovalRequest
) => Promise<RunEgressGatewayApprovalResult>;
} = {}
): Promise<RunEgressGatewayHandle> {
const service = new RunEgressGatewayService(policyService);
const gateway = await service.start({
runId: overrides.runId ?? `run-egress-${openGateways.length}`,
policy: policyService.compile({
defaultEgress: 'deny',
allowedHosts: ['127.0.0.1'],
deniedHosts: [],
defaultEgress: overrides.defaultEgress ?? 'deny',
allowedHosts: overrides.allowedHosts ?? ['127.0.0.1'],
deniedHosts: overrides.deniedHosts ?? [],
allowedMethods: overrides.allowedMethods ?? [],
allowedPathPrefixes: overrides.allowedPathPrefixes ?? [],
blockPrivateNetwork: false,
blockMetadataEndpoints: false,
blockLoopback: false,
allowApprovals: false,
allowApprovals: overrides.allowApprovals ?? false,
dangerouslyAllowGlobalWildcard: false,
}),
requestTimeoutMs: 5_000,
idleTimeoutMs: 5_000,
upstreamProxyUrl: overrides.upstreamProxyUrl,
onDecision: overrides.onDecision,
onApprovalRequired: overrides.onApprovalRequired,
});
openGateways.push(gateway);
return gateway;
@ -272,6 +432,56 @@ function roundTrip(socket: Socket, value: string): Promise<string> {
});
}
async function connectSocksTunnel(
gateway: RunEgressGatewayHandle,
host: string,
port: number,
passwordOverride?: string
): Promise<Socket> {
const proxy = new URL(gateway.environment.ALL_PROXY);
const socket = net.connect(Number(proxy.port), proxy.hostname);
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
socket.write(Buffer.from([0x05, 0x01, 0x02]));
expect(await readSocketBytes(socket, 2)).toEqual(Buffer.from([0x05, 0x02]));
const username = Buffer.from(proxy.username);
const password = Buffer.from(passwordOverride ?? proxy.password);
socket.write(
Buffer.concat([
Buffer.from([0x01, username.length]),
username,
Buffer.from([password.length]),
password,
])
);
const auth = await readSocketBytes(socket, 2);
if (auth[1] !== 0x00) {
socket.destroy();
throw { authStatus: auth[1] };
}
const address = Buffer.from(host.split('.').map(Number));
const portBytes = Buffer.alloc(2);
portBytes.writeUInt16BE(port);
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00, 0x01]), address, portBytes]));
const reply = await readSocketBytes(socket, 10);
if (reply[1] !== 0x00) {
socket.destroy();
throw { replyCode: reply[1] };
}
return socket;
}
function readSocketBytes(socket: Socket, size: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
socket.once('data', (chunk) => resolve(Buffer.from(chunk).subarray(0, size)));
socket.once('error', reject);
});
}
function websocketUpgrade(gateway: RunEgressGatewayHandle, target: string): Promise<string> {
const proxy = new URL(gateway.environment.HTTP_PROXY);
return new Promise((resolve, reject) => {

View file

@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest';
import { PROVIDER_RUNTIME_PROBE_REVISION } from '@veritas-kanban/shared';
import { EgressPolicyService } from '../services/egress-policy-service.js';
import { getProviderRuntimeAdapterDefinition } from '../services/provider-runtime-adapter-registry.js';
import { runEgressPolicyRequiresGateway } from '../services/run-egress-gateway-service.js';
import {
RunEgressGatewayService,
runEgressPolicyRequiresGateway,
} from '../services/run-egress-gateway-service.js';
const policyService = new EgressPolicyService();
@ -94,4 +97,23 @@ describe('run egress launch policy', () => {
state: 'advisory',
});
});
it('rejects unsupported upstream proxy schemes before binding listeners', async () => {
const gateway = new RunEgressGatewayService(policyService);
await expect(
gateway.start({
runId: 'run-invalid-upstream',
policy: policyService.compile({
defaultEgress: 'deny',
allowedHosts: ['api.example.com'],
allowedMethods: [],
allowedPathPrefixes: [],
blockPrivateNetwork: true,
blockMetadataEndpoints: true,
blockLoopback: true,
}),
upstreamProxyUrl: 'https://proxy.example.com:8443',
})
).rejects.toThrow(/must be an HTTP origin/i);
});
});

View file

@ -187,6 +187,7 @@ describe('Common Schemas', () => {
'run.completed',
'run.error',
'run.tokens',
'network.egress',
'admission.tree_control',
];
for (const type of validTypes) {

View file

@ -505,12 +505,15 @@ describe('WorkflowStepExecutor Codex integration', () => {
VERITAS_ADMIN_KEY: process.env.VERITAS_ADMIN_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
VK_API_URL: process.env.VK_API_URL,
VERITAS_EGRESS_UPSTREAM_PROXY: process.env.VERITAS_EGRESS_UPSTREAM_PROXY,
};
process.env.GITHUB_TOKEN = 'test-github-token';
process.env.DATABASE_URL = 'postgres://test-secret';
process.env.VERITAS_ADMIN_KEY = 'test-admin-key';
process.env.OPENAI_API_KEY = 'test-openai-key';
process.env.VK_API_URL = 'http://127.0.0.1:3001';
process.env.VERITAS_EGRESS_UPSTREAM_PROXY =
'http://proxy-user:proxy-password@proxy.internal:3128';
try {
const executor = new WorkflowStepExecutor(tmpDir, { runtimeManifestResolver });
@ -563,6 +566,7 @@ describe('WorkflowStepExecutor Codex integration', () => {
expect(env?.GITHUB_TOKEN).toBeUndefined();
expect(env?.DATABASE_URL).toBeUndefined();
expect(env?.VERITAS_ADMIN_KEY).toBeUndefined();
expect(env?.VERITAS_EGRESS_UPSTREAM_PROXY).toBeUndefined();
} finally {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {

View file

@ -81,6 +81,7 @@ export const TelemetryEventTypeSchema = z.enum([
'run.completed',
'run.error',
'run.tokens',
'network.egress',
'admission.tree_control',
]);

View file

@ -25,7 +25,10 @@ import { getSandboxPolicyService, type SandboxPolicyService } from './sandbox-po
import {
getRunEgressGatewayService,
RUN_EGRESS_PROXY_ENVIRONMENT_KEYS,
RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY,
runEgressPolicyRequiresGateway,
type RunEgressGatewayApprovalRequest,
type RunEgressGatewayApprovalResult,
type RunEgressGatewayHandle,
type RunEgressGatewayService,
} from './run-egress-gateway-service.js';
@ -78,6 +81,7 @@ import type {
RunStartedEvent,
RunCompletedEvent,
RunErrorEvent,
NetworkEgressTelemetryEvent,
TokenTelemetryEvent,
TaskReadinessSummary,
SandboxPolicyDryRunResult,
@ -258,6 +262,7 @@ import {
} from './run-tool-bridge-service.js';
import { getToolPolicyService } from './tool-policy-service.js';
import { RunRecoveryPolicyService } from './run-recovery-policy-service.js';
import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js';
import {
FilesystemSandboxService,
getFilesystemSandboxService,
@ -2966,7 +2971,17 @@ export class ClawdbotAgentService {
pending.egressGateway = await this.runEgressGateway.start({
runId: attemptId,
policy: egressPolicy,
onDecision: (event) => {
upstreamProxyUrl: process.env[RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY],
onApprovalRequired: (request) =>
this.resolveRunEgressApproval(
task,
attemptId,
provider,
agent,
runLaunchManifest,
request
),
onDecision: async (event) => {
this.recordTraceStep(attemptId, 'execute', {
eventType: 'network.egress.decision',
gatewayId: event.gatewayId,
@ -2981,6 +2996,29 @@ export class ClawdbotAgentService {
reason: event.decision.reason,
blockedAddressClass: event.decision.blockedAddressClass,
approvalEligible: event.decision.approvalEligible,
approvalId: event.decision.approvalId,
policyReason: event.decision.policyReason,
});
await telemetry.emit<NetworkEgressTelemetryEvent>({
type: 'network.egress',
taskId,
attemptId,
agent,
provider,
project: task.project,
gatewayId: event.gatewayId,
runKey: event.runKey,
policyHash: event.decision.policyHash,
protocol: event.decision.protocol,
hostKey: event.decision.hostKey,
port: event.decision.port,
method: event.decision.method,
decision: event.decision.decision,
reason: event.decision.reason,
policyReason: event.decision.policyReason,
blockedAddressClass: event.decision.blockedAddressClass,
approvalEligible: event.decision.approvalEligible,
approvalId: event.decision.approvalId,
});
},
});
@ -5884,6 +5922,88 @@ export class ClawdbotAgentService {
return updateType === 'agent_message_chunk' ? summary : undefined;
}
private async resolveRunEgressApproval(
task: Task,
attemptId: string,
provider: ExecutableAgentProvider,
agentId: string,
runLaunchManifest: RunLaunchManifest,
request: RunEgressGatewayApprovalRequest
): Promise<RunEgressGatewayApprovalResult> {
const phase = await this.bindPhaseApproval(task.id, attemptId, runLaunchManifest, [
{
dimension: 'network.egress',
requestedScopes: [request.host],
},
]);
const providerRequestId = `egress:${digestRunLaunchValue({
gatewayId: request.gatewayId,
protocol: request.protocol,
host: request.host,
port: request.port,
method: request.method,
path: request.path,
policyHash: request.decision.policyHash,
}).slice('sha256:'.length, 'sha256:'.length + 32)}`;
const approval = await this.approvalBroker.request({
workspaceId: 'local',
taskId: task.id,
attemptId,
provider,
agentId,
providerRequestId,
requestKind: 'approval',
actionClass: 'network',
action: `Allow ${request.protocol} egress to ${request.host}:${request.port}`,
details: `Blocked by ${request.decision.reason}; method ${request.method ?? 'not available'}.`,
resourceScope: [request.host, `${request.protocol}:${request.port}`],
workingDirectory: task.git?.worktreePath,
riskClass: 'high',
policyReason: request.decision.reason,
evidenceRevision: runLaunchManifest.digest,
mobileSafe: false,
exactAction: {
protocol: request.protocol,
host: request.host,
port: request.port,
method: request.method,
path: request.path,
policyHash: request.decision.policyHash,
blockedReason: request.decision.reason,
},
...(phase ? { phase } : {}),
});
if (request.signal.aborted) {
await this.approvalBroker.cancelAttempt(
'local',
task.id,
attemptId,
'Run egress gateway stopped.'
);
return { approvalId: approval.id, approved: false };
}
try {
const resolved = await this.approvalBroker.awaitDecision(approval.id, {
signal: request.signal,
});
return {
approvalId: approval.id,
approved: resolved.request.status === 'approved',
};
} catch (error) {
if (request.signal.aborted) {
await this.approvalBroker.cancelAttempt(
'local',
task.id,
attemptId,
'Run egress gateway stopped.'
);
return { approvalId: approval.id, approved: false };
}
throw error;
}
}
private async resolveAcpPermission(
task: Task,
attemptId: string,
@ -10061,7 +10181,10 @@ export class ClawdbotAgentService {
Object.entries({
...environment,
...pending.egressGateway?.environment,
}).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
}).filter(
(entry): entry is [string, string] =>
entry[0] !== RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY && typeof entry[1] === 'string'
)
);
}

View file

@ -590,6 +590,8 @@ function telemetryTitle(event: AnyTelemetryEvent): string {
return `Agent run error from ${event.agent}`;
case 'run.tokens':
return `Token usage recorded for ${event.agent}`;
case 'network.egress':
return `Network egress ${event.decision} for ${event.agent}`;
case 'admission.tree_control':
return `Execution tree ${event.action}`;
case 'task.status_changed':
@ -609,6 +611,9 @@ function telemetryDetail(event: AnyTelemetryEvent): string | undefined {
if (event.type === 'run.tokens') {
return `${(event.totalTokens ?? event.inputTokens + event.outputTokens).toLocaleString()} tokens`;
}
if (event.type === 'network.egress') {
return `${event.protocol} ${event.decision}: ${event.reason}`;
}
if (event.type === 'task.status_changed') {
return `${event.previousStatus ?? 'unknown'} -> ${event.status ?? 'unknown'}`;
}
@ -636,6 +641,16 @@ function telemetryMetadata(
metadata.rootObjectiveKey = event.rootObjectiveKey;
metadata.unresolvedAttempts = event.unresolvedAttempts ?? null;
}
if (event.type === 'network.egress') {
metadata.gatewayId = event.gatewayId;
metadata.runKey = event.runKey;
metadata.policyHash = event.policyHash;
metadata.hostKey = event.hostKey;
metadata.port = event.port;
metadata.decision = event.decision;
metadata.reason = event.reason;
metadata.approvalId = event.approvalId ?? null;
}
return metadata;
}

View file

@ -4,7 +4,7 @@ import http, {
type IncomingMessage,
type ServerResponse,
} from 'node:http';
import net, { type Socket } from 'node:net';
import net, { type Server as NetServer, type Socket } from 'node:net';
import { URL } from 'node:url';
import {
RUN_EGRESS_GATEWAY_EVIDENCE_SCHEMA_VERSION,
@ -19,6 +19,7 @@ const LOOPBACK_HOST = '127.0.0.1';
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_CONNECTIONS = 64;
export const RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY = 'VERITAS_EGRESS_UPSTREAM_PROXY' as const;
export const RUN_EGRESS_PROXY_ENVIRONMENT_KEYS = [
'HTTP_PROXY',
'HTTPS_PROXY',
@ -48,13 +49,36 @@ export interface RunEgressGatewayAuditEvent {
decision: RunEgressDecision;
}
export interface RunEgressGatewayApprovalRequest {
gatewayId: string;
runKey: string;
protocol: RunEgressDecision['protocol'];
host: string;
port: number;
method?: string;
/** Path only. Query strings and fragments are never included. */
path?: string;
decision: RunEgressDecision;
signal: AbortSignal;
}
export interface RunEgressGatewayApprovalResult {
approvalId: string;
approved: boolean;
}
export interface StartRunEgressGatewayInput {
runId: string;
policy: RunEgressPolicy;
/** Optional operator-managed HTTP proxy. Credentials remain memory-only. */
upstreamProxyUrl?: string;
requestTimeoutMs?: number;
idleTimeoutMs?: number;
maxConnections?: number;
onDecision?: (event: RunEgressGatewayAuditEvent) => Promise<void> | void;
onApprovalRequired?: (
request: RunEgressGatewayApprovalRequest
) => Promise<RunEgressGatewayApprovalResult>;
}
export interface RunEgressGatewayHandle {
@ -67,9 +91,12 @@ export interface RunEgressGatewayHandle {
interface ActiveGateway {
runId: string;
server: http.Server;
socksServer: NetServer;
sockets: Set<Socket>;
evidence: RunEgressGatewayEvidence;
environment: RunEgressGatewayHandle['environment'];
abortController: AbortController;
upstreamKey: string;
stop(): Promise<RunEgressGatewayEvidence>;
}
@ -85,10 +112,15 @@ export class RunEgressGatewayService {
async start(input: StartRunEgressGatewayInput): Promise<RunEgressGatewayHandle> {
const runId = input.runId.trim();
if (!runId) throw new ConflictError('Run-scoped egress gateway requires a run id.');
const upstream = parseUpstreamProxy(input.upstreamProxyUrl);
const upstreamKey = upstream?.key ?? identity('upstream-proxy', 'direct');
const existing = this.activeByRunId.get(runId);
if (existing) {
if (existing.evidence.policyHash !== input.policy.policyHash) {
throw new ConflictError('Run egress gateway already uses a different policy.', {
if (
existing.evidence.policyHash !== input.policy.policyHash ||
existing.upstreamKey !== upstreamKey
) {
throw new ConflictError('Run egress gateway already uses a different policy or upstream.', {
gatewayId: existing.evidence.gatewayId,
policyHash: existing.evidence.policyHash,
});
@ -117,7 +149,9 @@ export class RunEgressGatewayService {
const maxConnections = boundedInteger(input.maxConnections, DEFAULT_MAX_CONNECTIONS, 1, 1_000);
const sockets = new Set<Socket>();
const clientSockets = new Set<Socket>();
const abortController = new AbortController();
const server = http.createServer();
const socksServer = net.createServer();
server.requestTimeout = requestTimeoutMs;
server.headersTimeout = requestTimeoutMs;
server.keepAliveTimeout = Math.min(idleTimeoutMs, requestTimeoutMs);
@ -130,7 +164,10 @@ export class RunEgressGatewayService {
requestTimeoutMs,
idleTimeoutMs,
policyService: this.policyService,
upstream,
onDecision: input.onDecision,
onApprovalRequired: input.onApprovalRequired,
signal: abortController.signal,
now: this.now,
sockets,
};
@ -144,45 +181,44 @@ export class RunEgressGatewayService {
void handleUpgrade(context, request, socket as Socket, head);
});
server.on('connection', (socket) => {
if (clientSockets.size >= maxConnections) {
socket.destroy();
registerClientSocket(socket, clientSockets, sockets, maxConnections, idleTimeoutMs);
});
socksServer.on('connection', (socket) => {
if (!registerClientSocket(socket, clientSockets, sockets, maxConnections, idleTimeoutMs)) {
return;
}
clientSockets.add(socket);
sockets.add(socket);
socket.setTimeout(idleTimeoutMs, () => socket.destroy());
socket.once('close', () => {
clientSockets.delete(socket);
sockets.delete(socket);
});
void handleSocksConnection(context, socket);
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, LOOPBACK_HOST);
});
await listenLoopback(server);
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
throw new Error('Run egress gateway did not bind a TCP port.');
}
try {
await listenLoopback(socksServer);
} catch (error) {
server.close();
server.closeAllConnections?.();
throw error;
}
const socksAddress = socksServer.address();
if (!socksAddress || typeof socksAddress === 'string') {
server.close();
server.closeAllConnections?.();
socksServer.close();
throw new Error('Run egress SOCKS gateway did not bind a TCP port.');
}
const proxyUrl = `http://veritas:${encodeURIComponent(token)}@${LOOPBACK_HOST}:${address.port}`;
const socksProxyUrl = `socks5h://veritas:${encodeURIComponent(token)}@${LOOPBACK_HOST}:${socksAddress.port}`;
const environment: ActiveGateway['environment'] = {
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
ALL_PROXY: proxyUrl,
ALL_PROXY: socksProxyUrl,
http_proxy: proxyUrl,
https_proxy: proxyUrl,
all_proxy: proxyUrl,
all_proxy: socksProxyUrl,
NO_PROXY: '',
no_proxy: '',
};
@ -193,7 +229,8 @@ export class RunEgressGatewayService {
attributionKey: identity('attribution', token),
policyHash: input.policy.policyHash,
state: 'enforced',
protocols: ['http', 'connect', 'ws'],
protocols: ['http', 'connect', 'ws', 'socks5'],
upstreamMode: upstream ? 'http-connect' : 'direct',
proxyEnvironmentKeys: [...RUN_EGRESS_PROXY_ENVIRONMENT_KEYS],
startedAt: this.now().toISOString(),
};
@ -201,9 +238,12 @@ export class RunEgressGatewayService {
const active: ActiveGateway = {
runId,
server,
socksServer,
sockets,
evidence,
environment,
abortController,
upstreamKey,
stop: () => {
stopPromise ??= this.stopActive(active);
return stopPromise;
@ -235,11 +275,9 @@ export class RunEgressGatewayService {
private async stopActive(active: ActiveGateway): Promise<RunEgressGatewayEvidence> {
this.activeByRunId.delete(active.runId);
this.activeByGatewayId.delete(active.evidence.gatewayId);
active.abortController.abort(new Error('Run egress gateway stopped.'));
for (const socket of active.sockets) socket.destroy();
await new Promise<void>((resolve) => {
active.server.close(() => resolve());
active.server.closeAllConnections?.();
});
await Promise.all([closeServer(active.server), closeServer(active.socksServer)]);
active.evidence = {
...active.evidence,
state: 'stopped',
@ -267,11 +305,21 @@ interface GatewayRequestContext {
requestTimeoutMs: number;
idleTimeoutMs: number;
policyService: EgressPolicyService;
upstream?: UpstreamProxy;
onDecision?: StartRunEgressGatewayInput['onDecision'];
onApprovalRequired?: StartRunEgressGatewayInput['onApprovalRequired'];
signal: AbortSignal;
now: () => Date;
sockets: Set<Socket>;
}
interface UpstreamProxy {
key: string;
host: string;
port: number;
authorization?: string;
}
async function handleHttpRequest(
context: GatewayRequestContext,
request: IncomingMessage,
@ -293,9 +341,19 @@ async function handleHttpRequest(
method: request.method,
path: target.pathname,
});
await audit(context, resolution.decision);
if (resolution.decision.decision === 'block') {
rejectHttp(response, 403, resolution.decision.reason);
const decision = await withApprovalIdlePause(request.socket, context.idleTimeoutMs, () =>
authorizeBlockedRequest(context, {
protocol: 'http',
host: target.hostname,
port: portFor(target, 80),
method: request.method,
path: target.pathname,
decision: resolution.decision,
})
);
await audit(context, decision);
if (decision.decision === 'block') {
rejectHttp(response, 403, decision.reason);
return;
}
const address = resolution.resolvedAddresses[0];
@ -303,6 +361,14 @@ async function handleHttpRequest(
rejectHttp(response, 502, 'destination-resolution-failed');
return;
}
let transport: Awaited<ReturnType<typeof openPinnedTransport>>;
try {
transport = await openPinnedTransport(context, address, portFor(target, 80));
} catch {
rejectHttp(response, 502, 'upstream-failure');
return;
}
if (transport.head.length > 0) transport.socket.unshift(transport.head);
const headers = forwardHeaders(request.headers, target.host, false);
const upstream = http.request({
host: address,
@ -312,10 +378,8 @@ async function handleHttpRequest(
headers,
family: net.isIP(address),
timeout: context.requestTimeoutMs,
});
upstream.once('socket', (socket) => {
context.sockets.add(socket);
socket.once('close', () => context.sockets.delete(socket));
agent: false,
createConnection: () => transport.socket,
});
upstream.once('response', (upstreamResponse) => {
response.writeHead(
@ -352,9 +416,17 @@ async function handleConnect(
host: authority.host,
port: authority.port,
});
await audit(context, resolution.decision);
if (resolution.decision.decision === 'block') {
rejectSocket(client, 403, resolution.decision.reason);
const decision = await withApprovalIdlePause(client, context.idleTimeoutMs, () =>
authorizeBlockedRequest(context, {
protocol: 'https',
host: authority.host,
port: authority.port,
decision: resolution.decision,
})
);
await audit(context, decision);
if (decision.decision === 'block') {
rejectSocket(client, 403, decision.reason);
return;
}
const address = resolution.resolvedAddresses[0];
@ -362,26 +434,20 @@ async function handleConnect(
rejectSocket(client, 502, 'destination-resolution-failed');
return;
}
const upstream = net.connect({
host: address,
port: authority.port,
family: net.isIP(address),
});
context.sockets.add(upstream);
let connected = false;
upstream.setTimeout(context.idleTimeoutMs, () => upstream.destroy());
upstream.once('close', () => context.sockets.delete(upstream));
upstream.once('connect', () => {
connected = true;
let transport: Awaited<ReturnType<typeof openPinnedTransport>>;
try {
transport = await openPinnedTransport(context, address, authority.port);
} catch {
rejectSocket(client, 502, 'upstream-failure');
return;
}
const upstream = transport.socket;
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (transport.head.length > 0) client.write(transport.head);
if (head.length > 0) upstream.write(head);
client.pipe(upstream);
upstream.pipe(client);
});
upstream.once('error', () => {
if (connected) client.destroy();
else rejectSocket(client, 502, 'upstream-failure');
});
upstream.once('error', () => client.destroy());
}
async function handleUpgrade(
@ -411,9 +477,19 @@ async function handleUpgrade(
method: request.method ?? 'GET',
path: target.pathname,
});
await audit(context, resolution.decision);
if (resolution.decision.decision === 'block') {
rejectSocket(client, 403, resolution.decision.reason);
const decision = await withApprovalIdlePause(client, context.idleTimeoutMs, () =>
authorizeBlockedRequest(context, {
protocol: 'ws',
host: target.hostname,
port: portFor(target, 80),
method: request.method ?? 'GET',
path: target.pathname,
decision: resolution.decision,
})
);
await audit(context, decision);
if (decision.decision === 'block') {
rejectSocket(client, 403, decision.reason);
return;
}
const address = resolution.resolvedAddresses[0];
@ -421,36 +497,223 @@ async function handleUpgrade(
rejectSocket(client, 502, 'destination-resolution-failed');
return;
}
const upstream = net.connect({
host: address,
port: portFor(target, 80),
family: net.isIP(address),
});
context.sockets.add(upstream);
let connected = false;
upstream.setTimeout(context.idleTimeoutMs, () => upstream.destroy());
upstream.once('close', () => context.sockets.delete(upstream));
upstream.once('connect', () => {
connected = true;
let transport: Awaited<ReturnType<typeof openPinnedTransport>>;
try {
transport = await openPinnedTransport(context, address, portFor(target, 80));
} catch {
rejectSocket(client, 502, 'upstream-failure');
return;
}
const upstream = transport.socket;
upstream.write(
`${request.method ?? 'GET'} ${target.pathname}${target.search} HTTP/${request.httpVersion}\r\n`
);
for (const [name, value] of Object.entries(
forwardHeaders(request.headers, target.host, true)
)) {
for (const [name, value] of Object.entries(forwardHeaders(request.headers, target.host, true))) {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined) upstream.write(`${name}: ${item}\r\n`);
}
}
upstream.write('\r\n');
if (transport.head.length > 0) client.write(transport.head);
if (head.length > 0) upstream.write(head);
client.pipe(upstream);
upstream.pipe(client);
upstream.once('error', () => client.destroy());
}
async function handleSocksConnection(
context: GatewayRequestContext,
client: Socket
): Promise<void> {
const reader = new SocketByteReader(client);
try {
const greeting = await reader.readExactly(2);
if (greeting[0] !== 0x05 || greeting[1] === 0) {
client.end(Buffer.from([0x05, 0xff]));
return;
}
const methods = await reader.readExactly(greeting[1]);
if (!methods.includes(0x02)) {
client.end(Buffer.from([0x05, 0xff]));
return;
}
client.write(Buffer.from([0x05, 0x02]));
const authHeader = await reader.readExactly(2);
if (authHeader[0] !== 0x01 || authHeader[1] === 0) {
client.end(Buffer.from([0x01, 0x01]));
return;
}
const username = (await reader.readExactly(authHeader[1])).toString('utf8');
const passwordLength = (await reader.readExactly(1))[0] ?? 0;
if (passwordLength === 0) {
client.end(Buffer.from([0x01, 0x01]));
return;
}
const password = (await reader.readExactly(passwordLength)).toString('utf8');
if (!safeEqual(username, 'veritas') || !safeEqual(password, context.token)) {
client.end(Buffer.from([0x01, 0x01]));
return;
}
client.write(Buffer.from([0x01, 0x00]));
const requestHeader = await reader.readExactly(4);
if (requestHeader[0] !== 0x05 || requestHeader[2] !== 0x00) {
endSocks(client, 0x01);
return;
}
if (requestHeader[1] !== 0x01) {
endSocks(client, 0x07);
return;
}
const host = await readSocksHost(reader, requestHeader[3] ?? 0);
if (!host) {
endSocks(client, 0x08);
return;
}
const port = (await reader.readExactly(2)).readUInt16BE(0);
const resolution = await context.policyService.resolveAndEvaluate(context.policy, {
protocol: 'socks',
host,
port,
});
upstream.once('error', () => {
if (connected) client.destroy();
else rejectSocket(client, 502, 'upstream-failure');
const decision = await withApprovalIdlePause(client, context.idleTimeoutMs, () =>
authorizeBlockedRequest(context, {
protocol: 'socks',
host,
port,
decision: resolution.decision,
})
);
await audit(context, decision);
if (decision.decision === 'block') {
endSocks(client, 0x02);
return;
}
const address = resolution.resolvedAddresses[0];
if (!address) {
endSocks(client, 0x04);
return;
}
const transport = await openPinnedTransport(context, address, port);
const upstream = transport.socket;
client.write(socksReply(0x00));
const remainder = reader.release();
client.on('error', () => client.destroy());
upstream.on('error', () => client.destroy());
if (transport.head.length > 0) client.write(transport.head);
if (remainder.length > 0) upstream.write(remainder);
client.pipe(upstream);
upstream.pipe(client);
} catch {
reader.release();
if (!client.destroyed) endSocks(client, 0x01);
}
}
class SocketByteReader {
private buffer = Buffer.alloc(0);
private requestedBytes = 0;
private resolveRead?: (value: Buffer) => void;
private rejectRead?: (error: Error) => void;
private released = false;
constructor(private readonly socket: Socket) {
socket.on('data', this.onData);
socket.once('close', this.onClose);
}
readExactly(size: number): Promise<Buffer> {
if (!Number.isSafeInteger(size) || size < 1 || size > 65_535) {
return Promise.reject(new Error('Invalid SOCKS frame length.'));
}
if (this.released || this.resolveRead) {
return Promise.reject(new Error('SOCKS frame reader is unavailable.'));
}
if (this.buffer.length >= size) return Promise.resolve(this.take(size));
this.requestedBytes = size;
return new Promise<Buffer>((resolve, reject) => {
this.resolveRead = resolve;
this.rejectRead = reject;
});
}
release(): Buffer {
if (this.released) return Buffer.alloc(0);
this.released = true;
this.socket.off('data', this.onData);
this.socket.off('close', this.onClose);
const buffered = this.buffer;
this.buffer = Buffer.alloc(0);
return buffered;
}
private readonly onData = (chunk: Buffer): void => {
if (this.released) return;
this.buffer = Buffer.concat([this.buffer, chunk]);
if (this.buffer.length > 65_535) {
this.rejectPending(new Error('SOCKS handshake exceeded the supported size.'));
this.socket.destroy();
return;
}
if (!this.resolveRead || this.buffer.length < this.requestedBytes) return;
const value = this.take(this.requestedBytes);
const resolve = this.resolveRead;
this.resolveRead = undefined;
this.rejectRead = undefined;
this.requestedBytes = 0;
resolve(value);
};
private readonly onClose = (): void => {
this.rejectPending(new Error('SOCKS client disconnected during negotiation.'));
};
private take(size: number): Buffer {
const value = this.buffer.subarray(0, size);
this.buffer = this.buffer.subarray(size);
return value;
}
private rejectPending(error: Error): void {
const reject = this.rejectRead;
this.resolveRead = undefined;
this.rejectRead = undefined;
this.requestedBytes = 0;
reject?.(error);
}
}
async function readSocksHost(
reader: SocketByteReader,
addressType: number
): Promise<string | null> {
if (addressType === 0x01) {
return [...(await reader.readExactly(4))].join('.');
}
if (addressType === 0x03) {
const length = (await reader.readExactly(1))[0] ?? 0;
if (length === 0) return null;
return (await reader.readExactly(length)).toString('utf8').toLowerCase();
}
if (addressType === 0x04) {
const address = await reader.readExactly(16);
const groups: string[] = [];
for (let offset = 0; offset < address.length; offset += 2) {
groups.push(address.readUInt16BE(offset).toString(16));
}
return groups.join(':');
}
return null;
}
function socksReply(code: number): Buffer {
return Buffer.from([0x05, code, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
function endSocks(socket: Socket, code: number): void {
if (!socket.destroyed) socket.end(socksReply(code));
}
async function audit(context: GatewayRequestContext, decision: RunEgressDecision): Promise<void> {
@ -467,6 +730,144 @@ async function audit(context: GatewayRequestContext, decision: RunEgressDecision
}
}
async function authorizeBlockedRequest(
context: GatewayRequestContext,
input: Omit<RunEgressGatewayApprovalRequest, 'gatewayId' | 'runKey' | 'signal'>
): Promise<RunEgressDecision> {
if (
input.decision.decision !== 'block' ||
!input.decision.approvalEligible ||
!context.onApprovalRequired ||
context.signal.aborted
) {
return input.decision;
}
const policyReason = input.decision.reason;
if (
policyReason === 'allowed-by-default' ||
policyReason === 'allowed-by-host-rule' ||
policyReason === 'allowed-by-approval'
) {
return input.decision;
}
try {
const result = await context.onApprovalRequired({
gatewayId: context.gatewayId,
runKey: context.runKey,
...input,
signal: context.signal,
});
if (!result.approved) {
return { ...input.decision, approvalId: result.approvalId };
}
return {
...input.decision,
decision: 'allow',
reason: 'allowed-by-approval',
approvalEligible: false,
approvalId: result.approvalId,
policyReason,
};
} catch {
return input.decision;
}
}
async function withApprovalIdlePause<T>(
socket: Socket,
idleTimeoutMs: number,
action: () => Promise<T>
): Promise<T> {
socket.setTimeout(0);
try {
return await action();
} finally {
if (!socket.destroyed) socket.setTimeout(idleTimeoutMs, () => socket.destroy());
}
}
async function openPinnedTransport(
context: GatewayRequestContext,
address: string,
port: number
): Promise<{ socket: Socket; head: Buffer }> {
const upstream = context.upstream;
const socket = net.connect({
host: upstream?.host ?? address,
port: upstream?.port ?? port,
...(upstream ? {} : { family: net.isIP(address) }),
});
context.sockets.add(socket);
socket.setTimeout(context.idleTimeoutMs, () => socket.destroy());
socket.once('close', () => context.sockets.delete(socket));
await new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
});
if (!upstream) return { socket, head: Buffer.alloc(0) };
const authority = formatAuthority(address, port);
socket.write(
`CONNECT ${authority} HTTP/1.1\r\n` +
`Host: ${authority}\r\n` +
'Connection: keep-alive\r\n' +
(upstream.authorization ? `Proxy-Authorization: ${upstream.authorization}\r\n` : '') +
'\r\n'
);
const response = await readHttpResponseHead(socket);
if (response.statusCode !== 200) {
socket.destroy();
throw new Error(`Upstream proxy rejected CONNECT with status ${response.statusCode}.`);
}
return { socket, head: response.head };
}
function readHttpResponseHead(socket: Socket): Promise<{ statusCode: number; head: Buffer }> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const cleanup = () => {
socket.off('data', onData);
socket.off('close', onClose);
socket.off('error', onError);
};
const onClose = () => {
cleanup();
reject(new Error('Upstream proxy disconnected during CONNECT.'));
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
if (buffer.length > 16 * 1024) {
cleanup();
socket.destroy();
reject(new Error('Upstream proxy response headers exceeded the supported size.'));
return;
}
const boundary = buffer.indexOf('\r\n\r\n');
if (boundary < 0) return;
const firstLine = buffer.subarray(0, boundary).toString('latin1').split('\r\n', 1)[0] ?? '';
const match = /^HTTP\/1\.[01] ([1-5]\d{2})(?:\s|$)/.exec(firstLine);
socket.pause();
cleanup();
if (!match) {
socket.destroy();
reject(new Error('Upstream proxy returned an invalid CONNECT response.'));
return;
}
resolve({
statusCode: Number(match[1]),
head: buffer.subarray(boundary + 4),
});
};
socket.on('data', onData);
socket.once('close', onClose);
socket.once('error', onError);
});
}
function authenticated(request: IncomingMessage, token: string): boolean {
const header = request.headers['proxy-authorization'];
if (typeof header !== 'string') return false;
@ -481,6 +882,54 @@ function safeEqual(left: string, right: string): boolean {
return leftDigest.equals(rightDigest);
}
function parseUpstreamProxy(value: string | undefined): UpstreamProxy | undefined {
const source = value?.trim();
if (!source) return undefined;
let parsed: URL;
try {
parsed = new URL(source);
} catch {
throw new ConflictError('Run egress upstream proxy URL is invalid.');
}
if (
parsed.protocol !== 'http:' ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash ||
!parsed.hostname
) {
throw new ConflictError(
'Run egress upstream proxy must be an HTTP origin without a path, query, or fragment.'
);
}
const port = parsed.port ? Number.parseInt(parsed.port, 10) : 80;
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new ConflictError('Run egress upstream proxy port is invalid.');
}
let username: string;
let password: string;
try {
username = decodeURIComponent(parsed.username);
password = decodeURIComponent(parsed.password);
} catch {
throw new ConflictError('Run egress upstream proxy credentials have invalid encoding.');
}
return {
key: identity('upstream-proxy', parsed.toString()),
host: parsed.hostname.replace(/^\[|\]$/g, ''),
port,
...(username || password
? {
authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
}
: {}),
};
}
function formatAuthority(host: string, port: number): string {
return `${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`;
}
function parseProxyUrl(value: string | undefined): URL | null {
if (!value) return null;
try {
@ -547,6 +996,50 @@ function rejectSocket(socket: Socket, statusCode: number, reason: string): void
);
}
function registerClientSocket(
socket: Socket,
clientSockets: Set<Socket>,
sockets: Set<Socket>,
maxConnections: number,
idleTimeoutMs: number
): boolean {
if (clientSockets.size >= maxConnections) {
socket.destroy();
return false;
}
clientSockets.add(socket);
sockets.add(socket);
socket.setTimeout(idleTimeoutMs, () => socket.destroy());
socket.once('close', () => {
clientSockets.delete(socket);
sockets.delete(socket);
});
return true;
}
function listenLoopback(server: http.Server | NetServer): Promise<void> {
return new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, LOOPBACK_HOST);
});
}
function closeServer(server: http.Server | NetServer): Promise<void> {
return new Promise<void>((resolve) => {
server.close(() => resolve());
if ('closeAllConnections' in server) server.closeAllConnections();
});
}
function boundedDuration(
value: number | undefined,
fallback: number,

View file

@ -11,6 +11,7 @@ import type {
AgentConfig,
AgentHostRoutingDecision,
ExecutableAgentProvider,
NetworkEgressTelemetryEvent,
ProviderRuntimeCapabilityId,
ProviderRuntimeManifest,
RunLaunchPhaseAuthority,
@ -43,9 +44,17 @@ import { getAgentHostService } from './agent-host-service.js';
import { getSandboxPolicyService } from './sandbox-policy-service.js';
import {
getRunEgressGatewayService,
RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY,
runEgressPolicyRequiresGateway,
type RunEgressGatewayApprovalRequest,
type RunEgressGatewayApprovalResult,
type RunEgressGatewayService,
} from './run-egress-gateway-service.js';
import {
getRunApprovalBrokerService,
type RunApprovalBrokerService,
} from './run-approval-broker-service.js';
import { getTelemetryService } from './telemetry-service.js';
import {
assertProviderRuntimeCapabilities,
assertProviderRuntimeControl,
@ -86,6 +95,7 @@ interface WorkflowStepExecutorOptions {
persistRun?: (run: WorkflowRun) => Promise<void>;
phaseAuthority?: PhaseLaunchAuthorityService;
runEgressGateway?: Pick<RunEgressGatewayService, 'start'>;
approvalBroker?: Pick<RunApprovalBrokerService, 'request' | 'awaitDecision' | 'cancelAttempt'>;
}
type WorkflowExecutableProvider = Extract<ExecutableAgentProvider, 'codex-sdk' | 'openclaw'>;
@ -129,6 +139,10 @@ export class WorkflowStepExecutor {
private persistRun: (run: WorkflowRun) => Promise<void>;
private phaseAuthority: PhaseLaunchAuthorityService;
private runEgressGateway: Pick<RunEgressGatewayService, 'start'>;
private approvalBroker: Pick<
RunApprovalBrokerService,
'request' | 'awaitDecision' | 'cancelAttempt'
>;
private appendCountCache?: Map<string, number>; // Performance: Track append counts to reduce stat() calls
constructor(runsDir?: string, options: WorkflowStepExecutorOptions = {}) {
@ -143,6 +157,7 @@ export class WorkflowStepExecutor {
this.persistRun = options.persistRun ?? (async () => undefined);
this.phaseAuthority = options.phaseAuthority ?? new PhaseLaunchAuthorityService();
this.runEgressGateway = options.runEgressGateway ?? getRunEgressGatewayService();
this.approvalBroker = options.approvalBroker ?? getRunApprovalBrokerService();
}
/**
@ -626,6 +641,96 @@ export class WorkflowStepExecutor {
return `${run.id}:${stepId}:${sequence}`;
}
private workflowTaskId(run: WorkflowRun): string {
const contextTask = run.context.task as { id?: string } | undefined;
return run.taskId ?? contextTask?.id ?? run.id;
}
private workflowProject(run: WorkflowRun): string | undefined {
const contextTask = run.context.task as { project?: string } | undefined;
return contextTask?.project;
}
private async resolveWorkflowEgressApproval(
run: WorkflowRun,
step: WorkflowStep,
agentId: string,
runtimeManifest: ProviderRuntimeManifest,
sandboxPolicy: SandboxPolicyDryRunResult,
request: RunEgressGatewayApprovalRequest
): Promise<RunEgressGatewayApprovalResult> {
const taskId = this.workflowTaskId(run);
const attemptId = this.workflowStepAttemptId(run, step.id);
const providerRequestId = `egress:${digestRunLaunchValue({
gatewayId: request.gatewayId,
protocol: request.protocol,
host: request.host,
port: request.port,
method: request.method,
path: request.path,
policyHash: request.decision.policyHash,
}).slice('sha256:'.length, 'sha256:'.length + 32)}`;
const approval = await this.approvalBroker.request({
workspaceId: 'local',
taskId,
attemptId,
provider: 'codex-sdk',
agentId,
providerRequestId,
requestKind: 'approval',
actionClass: 'network',
action: `Allow ${request.protocol} egress to ${request.host}:${request.port}`,
details: `Blocked by ${request.decision.reason}; method ${request.method ?? 'not available'}.`,
resourceScope: [request.host, `${request.protocol}:${request.port}`],
workingDirectory: this.expandPath(this.getWorkflowWorkingDirectory(run)),
riskClass: 'high',
policyReason: request.decision.reason,
evidenceRevision: digestRunLaunchValue({
runtimeManifestDigest: runtimeManifest.digest,
policyHash: sandboxPolicy.effective.networkPolicy?.policyHash,
}),
mobileSafe: false,
exactAction: {
protocol: request.protocol,
host: request.host,
port: request.port,
method: request.method,
path: request.path,
policyHash: request.decision.policyHash,
blockedReason: request.decision.reason,
},
});
if (request.signal.aborted) {
await this.approvalBroker.cancelAttempt(
'local',
taskId,
attemptId,
'Workflow egress gateway stopped.'
);
return { approvalId: approval.id, approved: false };
}
try {
const resolved = await this.approvalBroker.awaitDecision(approval.id, {
signal: request.signal,
});
return {
approvalId: approval.id,
approved: resolved.request.status === 'approved',
};
} catch (error) {
if (request.signal.aborted) {
await this.approvalBroker.cancelAttempt(
'local',
taskId,
attemptId,
'Workflow egress gateway stopped.'
);
return { approvalId: approval.id, approved: false };
}
throw error;
}
}
private recordAgentHostRouting(
run: WorkflowRun,
step: WorkflowStep,
@ -863,11 +968,24 @@ export class WorkflowStepExecutor {
`Workflow agent ${agentDef?.id || step.agent || step.id}`
);
const egressPolicy = sandboxPolicy.effective.networkPolicy;
const attemptId = this.workflowStepAttemptId(run, step.id);
const taskId = this.workflowTaskId(run);
const agentId = step.agent ?? agentDef?.id ?? step.id;
const egressGateway =
egressPolicy && runEgressPolicyRequiresGateway(egressPolicy)
? await this.runEgressGateway.start({
runId: this.workflowStepAttemptId(run, step.id),
runId: attemptId,
policy: egressPolicy,
upstreamProxyUrl: process.env[RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY],
onApprovalRequired: (request) =>
this.resolveWorkflowEgressApproval(
run,
step,
agentId,
runtimeManifest,
sandboxPolicy,
request
),
onDecision: async (event) => {
await getGovernanceTraceService().record({
kind: 'policy',
@ -875,7 +993,7 @@ export class WorkflowStepExecutor {
title: 'Workflow egress decision',
summary: `${event.decision.protocol} request ${event.decision.decision}: ${event.decision.reason}`,
subject: {
taskId: run.taskId,
taskId,
agentId: step.agent,
actionType: 'workflow.network-egress',
},
@ -902,8 +1020,31 @@ export class WorkflowStepExecutor {
reason: event.decision.reason,
blockedAddressClass: event.decision.blockedAddressClass,
approvalEligible: event.decision.approvalEligible,
approvalId: event.decision.approvalId,
policyReason: event.decision.policyReason,
},
});
await getTelemetryService().emit<NetworkEgressTelemetryEvent>({
type: 'network.egress',
taskId,
attemptId,
agent: agentId,
provider: 'codex-sdk',
project: this.workflowProject(run),
gatewayId: event.gatewayId,
runKey: event.runKey,
policyHash: event.decision.policyHash,
protocol: event.decision.protocol,
hostKey: event.decision.hostKey,
port: event.decision.port,
method: event.decision.method,
decision: event.decision.decision,
reason: event.decision.reason,
policyReason: event.decision.policyReason,
blockedAddressClass: event.decision.blockedAddressClass,
approvalEligible: event.decision.approvalEligible,
approvalId: event.decision.approvalId,
});
},
})
: undefined;
@ -912,10 +1053,12 @@ export class WorkflowStepExecutor {
const workingDirectory = this.expandPath(this.getWorkflowWorkingDirectory(run));
const codex = new Codex({
codexPathOverride,
env: {
env: Object.fromEntries(
Object.entries({
...buildSafeCodexEnv(process.env, sandboxPolicy.effective.envPassthrough),
...egressGateway?.environment,
},
}).filter(([key]) => key !== RUN_EGRESS_UPSTREAM_PROXY_ENV_KEY)
),
});
const sessionKey = step.agent || agentDef?.id || step.id;

View file

@ -87,6 +87,7 @@ export interface RunEgressDecisionInput {
export type RunEgressDecisionReason =
| 'allowed-by-default'
| 'allowed-by-host-rule'
| 'allowed-by-approval'
| 'default-deny'
| 'denied-host'
| 'method-not-allowed'
@ -109,6 +110,13 @@ export interface RunEgressDecision {
matchedRule?: RunEgressHostRule;
blockedAddressClass?: 'private' | 'loopback' | 'link-local' | 'metadata';
approvalEligible: boolean;
/** Durable approval correlated to this decision without exposing the destination URL. */
approvalId?: string;
/** Original blocking reason when a scoped approval changed the transport decision. */
policyReason?: Exclude<
RunEgressDecisionReason,
'allowed-by-default' | 'allowed-by-host-rule' | 'allowed-by-approval'
>;
}
export interface RunEgressGatewayEvidence {
@ -118,7 +126,9 @@ export interface RunEgressGatewayEvidence {
attributionKey: string;
policyHash: string;
state: 'enforced' | 'stopped';
protocols: Array<'http' | 'connect' | 'ws'>;
protocols: Array<'http' | 'connect' | 'ws' | 'socks5'>;
/** Absent on early v1 evidence and interpreted as direct. */
upstreamMode?: 'direct' | 'http-connect';
proxyEnvironmentKeys: Array<
| 'HTTP_PROXY'
| 'HTTPS_PROXY'

View file

@ -4,6 +4,8 @@ import type { TaskStatus, AgentType } from './task.types.js';
import type { HarnessSupportFailureClass, HarnessSupportTier } from './provider-runtime.types.js';
import type { AdmissionLaunchSource } from './admission-control.types.js';
import type { ExecutionTreeBreakerEvidence } from './execution-tree-budget.types.js';
import type { ExecutableAgentProvider } from './config.types.js';
import type { RunEgressDecisionReason, RunEgressProtocol } from './sandbox-policy.types.js';
export type TelemetryEventType =
| 'task.created'
@ -14,6 +16,7 @@ export type TelemetryEventType =
| 'run.completed'
| 'run.error'
| 'run.tokens'
| 'network.egress'
| 'admission.tree_control';
/** Base telemetry event - all events extend this */
@ -125,6 +128,28 @@ export interface AdmissionTreeControlTelemetryEvent extends TelemetryEvent {
unresolvedAttempts?: number;
}
/** Metadata-only run-scoped network policy decision. */
export interface NetworkEgressTelemetryEvent extends TelemetryEvent {
type: 'network.egress';
taskId: string;
attemptId: string;
agent: string;
provider: ExecutableAgentProvider;
gatewayId: string;
runKey: string;
policyHash: string;
protocol: RunEgressProtocol;
hostKey: string;
port: number;
method?: string;
decision: 'allow' | 'block';
reason: RunEgressDecisionReason;
policyReason?: RunEgressDecisionReason;
blockedAddressClass?: 'private' | 'loopback' | 'link-local' | 'metadata';
approvalEligible: boolean;
approvalId?: string;
}
/** Union type for all telemetry events */
export type AnyTelemetryEvent =
| TaskTelemetryEvent
@ -133,6 +158,7 @@ export type AnyTelemetryEvent =
| RunCompletedEvent
| RunErrorEvent
| TokenTelemetryEvent
| NetworkEgressTelemetryEvent
| AdmissionTreeControlTelemetryEvent;
/** Telemetry configuration */