Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 50 additions & 19 deletions containers/agent/bounded-agent-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#
# Agent-facing bounded-agent CLI (protocol v1).
#
# Forwards a *narrow* request to the trusted bounded-agent broker over the
# Compose Unix socket. Like bounded-query-wrapper.sh, the API is deliberately
# Forwards a *narrow* request to the trusted bounded-agent broker over either
# the Compose Unix socket or the authenticated sbx HTTP ingress. Like
# bounded-query-wrapper.sh, the API is deliberately
# far narrower than a general tool: this wrapper cannot express a command, an
# image, an executable, a path, a URL, a ref, a mount, an environment variable,
# an endpoint, a network, a proxy, a credential, a runtime, a timeout, a
Expand All @@ -27,6 +28,8 @@

CANONICAL_ERROR='{"status":"error"}'
SOCKET="${AWF_BOUNDED_AGENT_SOCKET:-}"
ENDPOINT="${AWF_BOUNDED_AGENT_ENDPOINT:-}"
CAPABILITY="${AWF_BOUNDED_AGENT_CAPABILITY:-}"
PROTOCOL_VERSION=1
# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-execution/finite-disclosure.ts
# and containers/bounded-query/bounded-execution/finite-disclosure.js.
Expand Down Expand Up @@ -84,23 +87,51 @@ SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d
# The task must arrive on stdin; an interactive terminal means no task.
[ ! -t 0 ] || emit_error

[ -n "$SOCKET" ] || emit_error
[ -S "$SOCKET" ] || emit_error

RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--unix-socket "$SOCKET" \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"http://localhost/query" 2>/dev/null
) || emit_error
if [ -n "$SOCKET" ] && [ -z "$ENDPOINT" ] && [ -z "$CAPABILITY" ]; then
[ -S "$SOCKET" ] || emit_error
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--unix-socket "$SOCKET" \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"http://localhost/query" 2>/dev/null
) || emit_error
elif [ -z "$SOCKET" ] && [ -n "$ENDPOINT" ] && [ -n "$CAPABILITY" ]; then
case "$ENDPOINT" in
http://host.docker.internal:*/query)
PORT="${ENDPOINT#http://host.docker.internal:}"
PORT="${PORT%/query}"
printf '%s' "$PORT" | LC_ALL=C grep -Eq '^[0-9]{1,5}$' || emit_error
[ "$PORT" -ge 1 ] 2>/dev/null || emit_error
[ "$PORT" -le 65535 ] 2>/dev/null || emit_error
;;
*) emit_error ;;
esac
printf '%s' "$CAPABILITY" | LC_ALL=C grep -Eq '^[0-9a-f]{64}$' || emit_error
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Capability: ${CAPABILITY}" \
-H "X-AWF-Agent-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"$ENDPOINT" 2>/dev/null
) || emit_error
else
emit_error
fi

# Pass the broker's canonical response through unmodified, but only if it has
# one of the two shapes the protocol ever produces. Anything else (a dead or
Expand Down
5 changes: 5 additions & 0 deletions containers/bounded-agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ RUN chmod -R a-w /opt/awf \
&& node --check /opt/awf/broker/docker-client.js \
&& node --check /opt/awf/broker/docker-enclave-runner.js \
&& node --check /opt/awf/broker/gvisor-enclave-runner.js \
&& node --check /opt/awf/broker/sbx-client.js \
&& node --check /opt/awf/broker/sbx-capability-probe.js \
&& node --check /opt/awf/broker/sbx-enclave-runner-spec.js \
&& node --check /opt/awf/broker/sbx-enclave-runner.js \
&& node --check /opt/awf/broker/runtime-telemetry.js \
&& node --check /opt/awf/broker/healthcheck.js \
&& node --check /opt/awf/bounded-execution/finite-disclosure.js \
&& node --check /opt/awf/bounded-execution/sensitivity-ledger.js \
Expand Down
19 changes: 19 additions & 0 deletions containers/bounded-agent/broker/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,22 @@ function createBroker(params) {
// a ledger with bounded queries: the two brokers are separate processes with
// separate seed maps and separate private roots.
const ledger = params.ledger || createLedger(seedMap);
const telemetry = params.telemetry || { emit() {} };

let invocationsUsed = 0;
let tail = Promise.resolve();
let accepting = true;

function emitInvocationTelemetry(category) {
telemetry.emit({
primaryBackend: config.primaryBackend,
boundedAgentBackend: config.backend,
lifecycleClass: 'invocation',
capabilityState: 'supported',
category,
});
}

async function execute(request, respond) {
const invocationId = crypto.randomBytes(12).toString('hex');
let responded = false;
Expand All @@ -77,6 +88,7 @@ function createBroker(params) {
const validation = validateBoundedAgentRequest(request, { maxTaskBytes: config.maxTaskBytes });
if (!validation.valid) {
audit.failure(invocationId, 'invalid-request', validation.errors.join('; '));
emitInvocationTelemetry('invalid-request');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -87,6 +99,7 @@ function createBroker(params) {
if (!seed) {
// Deliberately does not record which repository was requested.
audit.failure(invocationId, 'repo-not-allowed');
emitInvocationTelemetry('repo-not-allowed');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand All @@ -97,6 +110,7 @@ function createBroker(params) {
const charge = queryBitsForSchema(schema);
if (!ledger.tryDebit(repoKey, charge)) {
audit.failure(invocationId, 'bit-budget-exhausted', `charge=${charge}`);
emitInvocationTelemetry('bit-budget-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return;
}
Expand Down Expand Up @@ -168,6 +182,7 @@ function createBroker(params) {
// Fail closed: processing overran every configured bucket. Never emit a
// successful result at unbucketed timing.
audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason[0] : undefined);
emitInvocationTelemetry('timing-bucket-overflow');
safeRespond(CANONICAL_ERROR_JSON);
} else if (canonicalResult !== undefined) {
audit.invocation({
Expand All @@ -178,10 +193,12 @@ function createBroker(params) {
bits: charge,
bucketMs,
});
emitInvocationTelemetry('success');
safeRespond(canonicalOkJson(canonicalResult));
} else {
const category = failureReason ? failureReason[0] : 'unknown';
audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined);
emitInvocationTelemetry(category);
safeRespond(CANONICAL_ERROR_JSON);
}
}
Expand Down Expand Up @@ -224,13 +241,15 @@ function createBroker(params) {
// observes — including a rejection — counts against it.
if (invocationsUsed >= config.maxInvocations) {
audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`);
emitInvocationTelemetry('invocation-count-exhausted');
safeRespond(CANONICAL_ERROR_JSON);
return Promise.resolve();
}
invocationsUsed += 1;

const queued = tail.then(() => execute(request, safeRespond)).catch((error) => {
audit.failure('queue', 'unexpected-error', error && error.message);
emitInvocationTelemetry('unexpected-error');
safeRespond(CANONICAL_ERROR_JSON);
});
tail = queued.then(
Expand Down
54 changes: 53 additions & 1 deletion containers/bounded-agent/broker/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ const ENCLAVE_GID = 65534;
/** Hard ceiling on the caller-supplied task text, mirrored from the TS protocol. */
const MAX_TASK_BYTES = 64 * 1024;

const SUPPORTED_BACKENDS = new Set(['docker', 'gvisor']);
const SUPPORTED_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const SUPPORTED_PROFILES = new Set(['openai', 'anthropic']);
const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json');

function requireEnv(name) {
const value = process.env[name];
Expand Down Expand Up @@ -93,6 +95,30 @@ function parseDockerSize(name, fallback) {
return value;
}

/**
* Loads the two capability tokens the broker's TCP listener requires on
* every request when reachability is via authenticated primary-sbx ingress
* (never used for the Unix-socket transport). Generated fresh per run on the
* trusted host only after runtime proofs succeed; never logged, telemetered,
* or written to any audit/skill surface.
*/
function loadSbxIngressCapabilities(capabilityPath) {
const parsed = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
const pattern = /^[0-9a-f]{64}$/;
if (
!parsed
|| parsed.version !== 1
|| typeof parsed.query !== 'string'
|| typeof parsed.probe !== 'string'
|| !pattern.test(parsed.query)
|| !pattern.test(parsed.probe)
|| parsed.query === parsed.probe
) {
throw new Error('SBX ingress capability file is malformed');
}
return { query: parsed.query, probe: parsed.probe };
}

function loadConfig() {
const backend = requireEnv('AWF_BOUNDED_AGENT_BACKEND');
if (!SUPPORTED_BACKENDS.has(backend)) {
Expand All @@ -114,6 +140,22 @@ function loadConfig() {
throw new Error('AWF_BOUNDED_AGENT_NETWORK is not a Docker network name');
}

const primaryBackend = requireEnv('AWF_BOUNDED_AGENT_PRIMARY_BACKEND');
if (!PRIMARY_BACKENDS.has(primaryBackend)) {
throw new Error(`Unsupported AWF_BOUNDED_AGENT_PRIMARY_BACKEND: ${primaryBackend}`);
}

const tcpPortRaw = process.env.AWF_BOUNDED_AGENT_TCP_PORT;
const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_AGENT_TCP_PORT');
if (tcpPort !== undefined && tcpPort > 65535) {
throw new Error('AWF_BOUNDED_AGENT_TCP_PORT must be a valid TCP port');
}

// sbx and Docker daemons can have different filesystem namespaces
// (ARC/DinD); never reuse the Docker-daemon-visible paths for sbx mounts.
const sbxWorkDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_WORK_DIR') : undefined;
const sbxSeedsDir = backend === 'sbx' ? requireEnv('AWF_BOUNDED_AGENT_SBX_SEEDS_DIR') : undefined;

return {
seedsDir: SEEDS_DIR,
workDir: WORK_DIR,
Expand All @@ -140,6 +182,13 @@ function loadConfig() {
// which is not necessarily the broker's (ARC/DinD split filesystems).
hostWorkDir: requireEnv('AWF_BOUNDED_AGENT_HOST_WORK_DIR'),
hostSeedsDir: requireEnv('AWF_BOUNDED_AGENT_HOST_SEEDS_DIR'),
sbxWorkDir,
sbxSeedsDir,
primaryBackend,
tcpPort,
sbxIngressCapabilities: tcpPort === undefined
? undefined
: loadSbxIngressCapabilities(SBX_CAPABILITY_PATH),
timeoutSeconds: parseTimeoutSeconds(),
memoryLimit: parseDockerSize('AWF_BOUNDED_AGENT_MEMORY', '512m'),
tmpfsLimit: parseDockerSize('AWF_BOUNDED_AGENT_TMPFS', '64m'),
Expand Down Expand Up @@ -174,9 +223,12 @@ function loadSeedMap(seedMapPath) {

module.exports = {
READY_PATH,
SBX_CAPABILITY_PATH,
MAX_TASK_BYTES,
SUPPORTED_BACKENDS,
SUPPORTED_PROFILES,
PRIMARY_BACKENDS,
loadConfig,
loadSeedMap,
loadSbxIngressCapabilities,
};
11 changes: 9 additions & 2 deletions containers/bounded-agent/broker/enclave-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { DockerEnclaveRunner } = require('./docker-enclave-runner');
const { GvisorEnclaveRunner } = require('./gvisor-enclave-runner');
const { SbxEnclaveRunner } = require('./sbx-enclave-runner');
const {
ENCLAVE_MAX_FILE_BYTES,
buildEnclaveArgs,
Expand All @@ -28,8 +29,11 @@ const {
*
* Unknown values fail closed. In particular, gVisor never falls back to the
* daemon's default OCI runtime when runsc is unavailable, and the `sbx`
* backend has no launcher at all — it is rejected by host-side preflight long
* before this code runs, and rejected again here.
* backend's `assertAvailable` always throws for the currently audited sbx CLI
* (see `./sbx-capability-probe.js`) — host-side preflight already blocks sbx
* long before this code runs, so reaching this branch at all would mean the
* defense-in-depth check inside the runner is the only thing standing between
* the request and an unproven enclave, and it fails closed too.
*
* @returns {EnclaveRunner}
*/
Expand All @@ -40,6 +44,9 @@ function createEnclaveRunner(config, deps = {}) {
if (config.backend === 'gvisor') {
return new GvisorEnclaveRunner(config, deps);
}
if (config.backend === 'sbx') {
return new SbxEnclaveRunner(config, deps);
}
throw new Error(`Unsupported bounded-agent backend: ${config.backend}`);
}

Expand Down
64 changes: 64 additions & 0 deletions containers/bounded-agent/broker/runtime-telemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

const fs = require('fs');
const path = require('path');

const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const BOUNDED_AGENT_BACKENDS = new Set(['docker', 'gvisor', 'sbx']);
const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'invocation', 'cleanup']);
const CAPABILITY_STATES = new Set(['supported', 'unavailable', 'blocked']);
const CATEGORY_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;

function assertTelemetryValue(allowed, value, field) {
if (!allowed.has(value)) throw new Error(`Invalid bounded-agent telemetry ${field}`);
}

function buildRuntimeTelemetryRecord(event) {
assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend');
assertTelemetryValue(BOUNDED_AGENT_BACKENDS, event.boundedAgentBackend, 'boundedAgentBackend');
assertTelemetryValue(LIFECYCLE_CLASSES, event.lifecycleClass, 'lifecycleClass');
assertTelemetryValue(CAPABILITY_STATES, event.capabilityState, 'capabilityState');
if (typeof event.category !== 'string' || !CATEGORY_PATTERN.test(event.category)) {
throw new Error('Invalid bounded-agent telemetry category');
}
return Object.freeze({
primaryBackend: event.primaryBackend,
boundedAgentBackend: event.boundedAgentBackend,
lifecycleClass: event.lifecycleClass,
capabilityState: event.capabilityState,
category: event.category,
});
}

/**
* Runtime-matrix telemetry sink, mirroring bounded-query's.
*
* Only the five fixed enum fields above are ever written — never a secret,
* capability token, path, prompt, repository id, model payload, or provider
* response. This is intentionally the *only* channel this broker writes
* besides the disjoint audit ledger in `./audit.js`.
*/
function createRuntimeTelemetry(auditDir) {
fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
const telemetryPath = path.join(auditDir, 'runtime-telemetry.jsonl');
let fd = fs.openSync(telemetryPath, 'a', 0o600);
return {
emit(event) {
const record = buildRuntimeTelemetryRecord(event);
if (fd === undefined) return;
try {
fs.writeSync(fd, `${JSON.stringify(record)}\n`);
} catch {
process.stderr.write('[bounded-agent] runtime telemetry unavailable\n');
try {
fs.closeSync(fd);
} catch {
// The generic telemetry failure above is the only safe diagnostic.
}
fd = undefined;
}
},
};
}

module.exports = { buildRuntimeTelemetryRecord, createRuntimeTelemetry };
Loading
Loading