From e693f99f75aa832df40dd8aaf9d5c0bda65fcfc0 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 2 Aug 2026 20:40:40 -0700 Subject: [PATCH 1/4] feat: add bounded agent sbx runtime matrix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0ec2c5e4-8277-47ca-b8dd-7bc8d4dd1b94 --- containers/agent/bounded-agent-wrapper.sh | 69 +++- containers/bounded-agent/Dockerfile | 5 + containers/bounded-agent/broker/broker.js | 19 + containers/bounded-agent/broker/config.js | 54 ++- .../bounded-agent/broker/enclave-runner.js | 11 +- .../bounded-agent/broker/runtime-telemetry.js | 64 ++++ .../broker/sbx-capability-probe.js | 134 +++++++ containers/bounded-agent/broker/sbx-client.js | 44 +++ .../broker/sbx-enclave-runner-spec.js | 107 ++++++ .../broker/sbx-enclave-runner.js | 152 ++++++++ containers/bounded-agent/broker/server.js | 179 +++++++++- docs/awf-config-spec.md | 84 ++++- docs/awf-config.schema.json | 2 +- docs/bounded-agents.md | 140 +++++++- .../ci/report-bounded-agent-runtime-matrix.js | 138 ++++++++ ...eport-bounded-agent-runtime-matrix.test.ts | 88 +++++ src/awf-config-schema.json | 2 +- src/bounded-agent/broker.test.ts | 9 +- src/bounded-agent/ingress.test.ts | 104 ++++++ src/bounded-agent/ingress.ts | 119 +++++++ src/bounded-agent/manager.ts | 104 +++++- src/bounded-agent/paths.ts | 6 + src/bounded-agent/preflight.test.ts | 85 ++++- src/bounded-agent/preflight.ts | 102 ++++-- src/bounded-agent/runtime-matrix.test.ts | 330 ++++++++++++++++++ src/bounded-agent/runtime-matrix.ts | 116 ++++++ src/bounded-agent/sbx-capability.test.ts | 156 +++++++++ src/bounded-agent/sbx-capability.ts | 134 +++++++ src/commands/main-action.test.ts | 78 +++++ src/commands/main-action.ts | 67 ++++ src/compose-generator.ts | 14 + src/sbx-manager.test.ts | 89 ++++- src/sbx-manager.ts | 59 +++- src/services/bounded-agent-service.test.ts | 4 +- src/services/bounded-agent-service.ts | 44 ++- src/types/bounded-agent-options.ts | 29 +- 36 files changed, 2817 insertions(+), 124 deletions(-) create mode 100644 containers/bounded-agent/broker/runtime-telemetry.js create mode 100644 containers/bounded-agent/broker/sbx-capability-probe.js create mode 100644 containers/bounded-agent/broker/sbx-client.js create mode 100644 containers/bounded-agent/broker/sbx-enclave-runner-spec.js create mode 100644 containers/bounded-agent/broker/sbx-enclave-runner.js create mode 100644 scripts/ci/report-bounded-agent-runtime-matrix.js create mode 100644 scripts/ci/report-bounded-agent-runtime-matrix.test.ts create mode 100644 src/bounded-agent/ingress.test.ts create mode 100644 src/bounded-agent/ingress.ts create mode 100644 src/bounded-agent/runtime-matrix.test.ts create mode 100644 src/bounded-agent/runtime-matrix.ts create mode 100644 src/bounded-agent/sbx-capability.test.ts create mode 100644 src/bounded-agent/sbx-capability.ts diff --git a/containers/agent/bounded-agent-wrapper.sh b/containers/agent/bounded-agent-wrapper.sh index 2be33975f..1c7373176 100644 --- a/containers/agent/bounded-agent-wrapper.sh +++ b/containers/agent/bounded-agent-wrapper.sh @@ -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 @@ -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. @@ -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 diff --git a/containers/bounded-agent/Dockerfile b/containers/bounded-agent/Dockerfile index 9ff44fd81..ddb5632c1 100644 --- a/containers/bounded-agent/Dockerfile +++ b/containers/bounded-agent/Dockerfile @@ -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 \ diff --git a/containers/bounded-agent/broker/broker.js b/containers/bounded-agent/broker/broker.js index 872e7a218..eb6b48e36 100644 --- a/containers/bounded-agent/broker/broker.js +++ b/containers/bounded-agent/broker/broker.js @@ -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; @@ -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; } @@ -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; } @@ -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; } @@ -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({ @@ -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); } } @@ -224,6 +241,7 @@ 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(); } @@ -231,6 +249,7 @@ function createBroker(params) { 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( diff --git a/containers/bounded-agent/broker/config.js b/containers/bounded-agent/broker/config.js index 3d61d0c44..7d5731511 100644 --- a/containers/bounded-agent/broker/config.js +++ b/containers/bounded-agent/broker/config.js @@ -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]; @@ -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)) { @@ -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, @@ -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'), @@ -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, }; diff --git a/containers/bounded-agent/broker/enclave-runner.js b/containers/bounded-agent/broker/enclave-runner.js index cda50984b..ba1cf31b7 100644 --- a/containers/bounded-agent/broker/enclave-runner.js +++ b/containers/bounded-agent/broker/enclave-runner.js @@ -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, @@ -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} */ @@ -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}`); } diff --git a/containers/bounded-agent/broker/runtime-telemetry.js b/containers/bounded-agent/broker/runtime-telemetry.js new file mode 100644 index 000000000..a7e859285 --- /dev/null +++ b/containers/bounded-agent/broker/runtime-telemetry.js @@ -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 }; diff --git a/containers/bounded-agent/broker/sbx-capability-probe.js b/containers/bounded-agent/broker/sbx-capability-probe.js new file mode 100644 index 000000000..f1aaf1cd4 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-capability-probe.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +'use strict'; + +const defaultSbxClient = require('./sbx-client'); + +const AUDITED_SBX_VERSION = '0.37.1'; +const REQUIRED_CREATE_FLAGS = Object.freeze([ + '--cpus', + '--memory', + '--name', + '--template', +]); +const REQUIRED_EXEC_FLAGS = Object.freeze([ + '--user', + '--workdir', +]); + +/** + * Capabilities that sbx must expose before AWF can safely launch a + * bounded-agent enclave VM. + * + * Unlike bounded queries (which run with `--network=none`), a bounded-agent + * enclave must reach the AWF API proxy and nothing else — so instead of a + * no-network primitive, sbx needs a *named-network attach with mandatory + * lateral-peer denial*: the VM must be able to join a single named network + * with exactly one reachable peer (the API proxy) and no route to any other + * member, including other enclave VMs on the same network. sbx v0.37.1 has no + * such primitive — its `--network` flag (if present at all) does not carry an + * enforced peer-isolation guarantee, so this control is always reported + * missing until AWF can name a specific, verifiable sbx flag or capability + * token that provides it. Local Docker/iptables rules are insufficient + * because organization sbx governance can replace them. + */ +const REQUIRED_HARD_ISOLATION_FLAGS = Object.freeze([ + '--network', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +]); + +/** Capability primitive sbx does not yet expose under any flag name. */ +const LATERAL_PEER_DENIAL_PRIMITIVE = + 'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' + + '(hard network-policy / capability-token ingress primitive)'; + +/** AWF has not published a pinned, immutable bounded-agent sbx template/bootstrap. */ +const PINNED_TEMPLATE_MISSING = 'pinned AWF bounded-agent sbx template and bootstrap'; + +function includesFlag(help, flag) { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help); +} + +async function inspectHelp(sbx, command) { + const result = await sbx.runSbx([command, '--help'], 10_000); + return result.exitCode === 0 ? result.stdout : ''; +} + +/** + * Probes the installed sbx CLI for the exact version, authentication, and + * hard-isolation flags a bounded-agent enclave requires. + * + * This never reports `supported: true` on flag detection alone: even when + * every enumerated flag is present, the two capability primitives AWF cannot + * yet verify (the pinned template/bootstrap and the lateral-peer-denial + * network primitive) are unconditionally appended to `missing`. A future sbx + * release that ships a concrete, checkable primitive for both must replace + * this unconditional block with a real check — it must never be removed + * without one. + */ +async function probeSbxCapabilities(sbx = defaultSbxClient) { + const versionResult = await sbx.runSbx(['version'], 10_000); + const daemonResult = await sbx.runSbx(['ls'], 10_000); + const createHelp = await inspectHelp(sbx, 'create'); + const execHelp = await inspectHelp(sbx, 'exec'); + const versionMatch = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout); + const version = versionMatch ? versionMatch[1] : undefined; + const missing = []; + + missing.push(PINNED_TEMPLATE_MISSING); + missing.push(LATERAL_PEER_DENIAL_PRIMITIVE); + + if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) { + missing.push('authenticated sbx CLI/daemon'); + } + if (version && version !== AUDITED_SBX_VERSION) { + missing.push(`audited sbx version ${AUDITED_SBX_VERSION} (found ${version})`); + } + for (const flag of REQUIRED_CREATE_FLAGS) { + if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`); + } + for (const flag of REQUIRED_EXEC_FLAGS) { + if (!includesFlag(execHelp, flag)) missing.push(`sbx exec ${flag}`); + } + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + if (!includesFlag(createHelp, flag)) missing.push(`sbx create ${flag}`); + } + + return Object.freeze({ + supported: missing.length === 0, + version, + auditedVersion: AUDITED_SBX_VERSION, + missing: Object.freeze(missing), + }); +} + +async function main() { + const report = await probeSbxCapabilities(); + process.stdout.write(`${JSON.stringify(report)}\n`); + process.exitCode = report.supported ? 0 : 1; +} + +if (require.main === module) { + main().catch((error) => { + process.stdout.write(`${JSON.stringify({ + supported: false, + auditedVersion: AUDITED_SBX_VERSION, + missing: ['capability probe failed'], + error: error.message, + })}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + AUDITED_SBX_VERSION, + REQUIRED_CREATE_FLAGS, + REQUIRED_EXEC_FLAGS, + REQUIRED_HARD_ISOLATION_FLAGS, + LATERAL_PEER_DENIAL_PRIMITIVE, + PINNED_TEMPLATE_MISSING, + probeSbxCapabilities, +}; diff --git a/containers/bounded-agent/broker/sbx-client.js b/containers/bounded-agent/broker/sbx-client.js new file mode 100644 index 000000000..ab4327147 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-client.js @@ -0,0 +1,44 @@ +'use strict'; + +const { execFile } = require('child_process'); + +const SBX_OUTPUT_LIMIT = 64 * 1024; +const SBX_SAFE_PATH = '/usr/local/bin:/usr/bin:/bin'; + +/** + * Executes an sbx management command with the broker's narrowly provisioned + * daemon credentials. The broker container never receives staging credentials, + * and this environment is not forwarded to enclave execution inside the VM. + * + * Proxy variables and XDG_CONFIG_HOME are removed for parity with the primary + * sbx management path: they can redirect daemon/credential lookup. + */ +function runSbx(args, timeoutMs) { + const env = { ...process.env }; + delete env.DOCKER_SANDBOXES_PROXY; + delete env.XDG_CONFIG_HOME; + env.PATH = process.env.PATH || SBX_SAFE_PATH; + + return new Promise((resolve) => { + execFile( + 'sbx', + args, + { + timeout: timeoutMs, + killSignal: 'SIGKILL', + maxBuffer: SBX_OUTPUT_LIMIT, + env, + }, + (error, stdout, stderr) => { + resolve({ + exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0, + timedOut: Boolean(error && error.killed), + stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '', + stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '', + }); + }, + ); + }); +} + +module.exports = { runSbx }; diff --git a/containers/bounded-agent/broker/sbx-enclave-runner-spec.js b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js new file mode 100644 index 000000000..d7cc9aad8 --- /dev/null +++ b/containers/bounded-agent/broker/sbx-enclave-runner-spec.js @@ -0,0 +1,107 @@ +'use strict'; + +const { + ENCLAVE_MAX_FILE_BYTES, + normalizeTimeoutMs, +} = require('./enclave-runner-spec'); +const { REQUIRED_HARD_ISOLATION_FLAGS } = require('./sbx-capability-probe'); + +const SBX_CLI_GRACE_MS = 15_000; + +/** + * Pinned placeholder template/bootstrap reference. + * + * This is intentionally not a real, resolvable template: AWF has not + * published a bounded-agent sbx template because current sbx cannot enforce + * the mandatory isolation controls a real template would depend on (see + * `./sbx-capability-probe.js`). The value documents the exact shape a future + * pinned reference must take (a content-addressed tag), and is never used to + * launch a real enclave while the capability probe reports it missing. + */ +const SBX_ENCLAVE_TEMPLATE = 'awf/bounded-agent-sandbox-templates:sbx-enclave@sha256:unsupported-until-pinned'; + +const TRUSTED_RUN_ID_PATTERN = /^[0-9a-f]{32}$/; +const TRUSTED_INVOCATION_ID_PATTERN = /^[0-9a-f]{24}$/; +const TRUSTED_SEED_ID_PATTERN = /^[0-9a-f]{16,64}$/; + +function assertTrustedId(name, value, pattern) { + if (typeof value !== 'string' || !pattern.test(value)) { + throw new Error(`${name} is not a broker-generated identifier`); + } +} + +function freeze(values) { + return Object.freeze(values); +} + +/** + * Derives the entire sbx CLI surface for one bounded-agent enclave invocation + * from trusted broker state. + * + * This specification is intentionally not launchable while the capability + * probe reports missing hard-isolation controls (see + * `SbxEnclaveRunner.assertAvailable`, which always throws for the currently + * audited sbx CLI). It records the exact sbx API a future, capability-proven + * sbx CLI would be driven with — a fixed uid/workdir, mandatory resource + * limits, and mount targets for the seed/task/schema/out channels — without + * ever accepting request-owned launch data. + */ +function deriveSbxEnclaveSpec({ config, runId, invocationId, seedId }) { + assertTrustedId('runId', runId, TRUSTED_RUN_ID_PATTERN); + assertTrustedId('invocationId', invocationId, TRUSTED_INVOCATION_ID_PATTERN); + assertTrustedId('seedId', seedId, TRUSTED_SEED_ID_PATTERN); + + const runPrefix = `awf-bounded-agent-sbx-${runId}-`; + const sandboxName = `${runPrefix}${invocationId}`; + const hostInvocationDir = `${config.sbxWorkDir}/${invocationId}`; + const hostSeedDir = `${config.sbxSeedsDir}/${seedId}`; + const workspaceDir = `${hostInvocationDir}/sbx-workspace`; + const taskPath = `${hostInvocationDir}/task.txt`; + const schemaPath = `${hostInvocationDir}/schema.json`; + const outPath = `${hostInvocationDir}/out`; + + return Object.freeze({ + sandboxName, + runPrefix, + createArgs: freeze([ + 'create', + '--name', sandboxName, + '--cpus', String(config.cpuLimit), + '--memory', config.memoryLimit, + '--template', SBX_ENCLAVE_TEMPLATE, + // Distinct from bounded queries' `--network=none`: a bounded-agent + // enclave must reach the API proxy and *only* the API proxy. sbx has + // no verified lateral-peer-denial primitive today (see + // REQUIRED_HARD_ISOLATION_FLAGS), so this argument is never issued + // against a real launch while the capability probe reports it missing. + '--network', config.network, + '--pids-limit', String(config.pidsLimit), + '--disk-limit', config.tmpfsLimit, + '--ulimit-fsize', String(ENCLAVE_MAX_FILE_BYTES), + '--mount-target', `${hostSeedDir}:${config.enclaveSeedPath}:ro`, + '--mount-target', `${taskPath}:${config.enclaveTaskPath}:ro`, + '--mount-target', `${schemaPath}:${config.enclaveSchemaPath}:ro`, + '--mount-target', `${outPath}:${config.enclaveMountDir}/out:rw`, + 'shell', + workspaceDir, + ]), + execArgs: freeze([ + 'exec', + '--user', `${config.enclaveUid}:${config.enclaveGid}`, + '--workdir', config.enclaveMountDir, + sandboxName, + '/usr/local/bin/run-bounded-agent', + ]), + stopArgs: freeze(['stop', sandboxName]), + removeArgs: freeze(['rm', '--force', sandboxName]), + listArgs: freeze(['ls', '--json']), + }); +} + +module.exports = { + SBX_CLI_GRACE_MS, + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, + deriveSbxEnclaveSpec, + normalizeTimeoutMs, +}; diff --git a/containers/bounded-agent/broker/sbx-enclave-runner.js b/containers/bounded-agent/broker/sbx-enclave-runner.js new file mode 100644 index 000000000..f3f91d7ec --- /dev/null +++ b/containers/bounded-agent/broker/sbx-enclave-runner.js @@ -0,0 +1,152 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const defaultSbxClient = require('./sbx-client'); +const { probeSbxCapabilities } = require('./sbx-capability-probe'); +const { + SBX_CLI_GRACE_MS, + deriveSbxEnclaveSpec, + normalizeTimeoutMs, +} = require('./sbx-enclave-runner-spec'); + +function parseSandboxNames(stdout) { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error('sbx returned malformed sandbox inventory'); + } + if (!Array.isArray(parsed)) { + throw new Error('sbx returned malformed sandbox inventory'); + } + const names = parsed.map((entry) => entry && entry.name); + if (names.some((name) => typeof name !== 'string' || !/^[a-z0-9][a-z0-9+.-]{0,127}$/.test(name))) { + throw new Error('sbx returned an invalid sandbox name'); + } + return names; +} + +/** + * EnclaveRunner backed by the sbx microVM CLI. + * + * Deliberately mirrors DockerEnclaveRunner's lifecycle contract exactly + * (assertAvailable / reconcileRun / cleanupInvocation / runEnclaveContainer) + * so the dispatcher in `./enclave-runner.js` can select it without any + * special-casing, and so its cleanup ordering (serialize, always run, throw + * last) is provably the same. `assertAvailable` always throws today: the + * audited sbx CLI cannot yet prove the mandatory isolation and + * API-proxy-only network controls (see `./sbx-capability-probe.js`), and + * this runner never falls back to Docker or gVisor. + */ +class SbxEnclaveRunner { + constructor(config, deps = {}) { + this.config = config; + this.sbx = deps.sbx || defaultSbxClient; + this.probe = deps.probe || probeSbxCapabilities; + this.files = deps.files || fs; + this.nowMs = deps.nowMs || Date.now; + this.cleanupTail = Promise.resolve(); + } + + spec(runId, invocationId, seedId) { + return deriveSbxEnclaveSpec({ config: this.config, runId, invocationId, seedId }); + } + + async assertAvailable() { + const report = await this.probe(this.sbx); + if (!report.supported) { + throw new Error( + 'sbx bounded-agent enclave backend is blocked: the installed sbx runtime cannot enforce all ' + + `mandatory isolation controls (${report.missing.join(', ')}). No fallback is permitted.`, + ); + } + } + + serializeCleanup(operation) { + const queued = this.cleanupTail.then(operation, operation); + this.cleanupTail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + + async listRunSandboxes(runId) { + const spec = this.spec(runId, '0'.repeat(24), '0'.repeat(32)); + const listed = await this.sbx.runSbx(spec.listArgs, 30_000); + if (listed.exitCode !== 0) throw new Error('Failed to reconcile bounded-agent sbx VMs'); + return parseSandboxNames(listed.stdout).filter((name) => name.startsWith(spec.runPrefix)); + } + + async removeSandbox(name) { + const stopped = await this.sbx.runSbx(['stop', name], 30_000); + if (stopped.exitCode !== 0) { + const inventory = await this.sbx.runSbx(['ls', '--quiet'], 30_000); + if (inventory.exitCode !== 0 || inventory.stdout.split('\n').includes(name)) { + throw new Error('Failed to stop bounded-agent sbx VM'); + } + } + const removed = await this.sbx.runSbx(['rm', '--force', name], 30_000); + if (removed.exitCode !== 0) throw new Error('Failed to remove bounded-agent sbx VM'); + } + + /** Deterministic orphan cleanup for every VM name-prefixed with this run. */ + async reconcileRun(runId) { + await this.serializeCleanup(async () => { + for (const name of await this.listRunSandboxes(runId)) { + await this.removeSandbox(name); + } + }); + } + + async cleanupInvocation(runId, invocationId) { + const { sandboxName } = this.spec(runId, invocationId, '0'.repeat(32)); + await this.serializeCleanup(() => this.removeSandbox(sandboxName)); + } + + /** + * Runs one enclave to completion and always removes the VM before + * returning — including on timeout or a create/exec failure. + * + * stdout/stderr are intentionally dropped: the broker never reads, logs, or + * forwards enclave output, matching DockerEnclaveRunner. + */ + async runEnclaveContainer(params) { + const spec = this.spec(params.runId, params.invocationId, params.seedId); + const totalTimeoutMs = normalizeTimeoutMs( + (params.timeoutMs ?? this.config.timeoutSeconds * 1000) + SBX_CLI_GRACE_MS, + ); + const deadlineMs = this.nowMs() + totalTimeoutMs; + const remainingMs = () => normalizeTimeoutMs(deadlineMs - this.nowMs()); + let result; + let runError; + try { + this.files.mkdirSync(path.join(this.config.sbxWorkDir, params.invocationId, 'sbx-workspace'), { + mode: 0o700, + }); + const created = await this.sbx.runSbx(spec.createArgs, Math.min(120_000, remainingMs())); + if (created.timedOut) { + result = created; + } else if (created.exitCode !== 0) { + throw new Error('Failed to create bounded-agent sbx VM'); + } else if (this.nowMs() >= deadlineMs) { + result = { exitCode: 124, timedOut: true, stdout: '', stderr: '' }; + } else { + result = await this.sbx.runSbx(spec.execArgs, remainingMs()); + } + } catch (error) { + runError = error; + } + + try { + await this.cleanupInvocation(params.runId, params.invocationId); + } catch (cleanupError) { + throw cleanupError; + } + if (runError) throw runError; + return { exitCode: result.exitCode, timedOut: result.timedOut }; + } +} + +module.exports = { SbxEnclaveRunner, parseSandboxNames }; diff --git a/containers/bounded-agent/broker/server.js b/containers/bounded-agent/broker/server.js index 766151ab6..b37f64421 100644 --- a/containers/bounded-agent/broker/server.js +++ b/containers/bounded-agent/broker/server.js @@ -1,6 +1,7 @@ 'use strict'; const fs = require('fs'); +const crypto = require('crypto'); const http = require('http'); const { createAuditLog } = require('./audit'); const { createBroker } = require('./broker'); @@ -8,15 +9,20 @@ const { loadConfig, loadSeedMap } = require('./config'); const { buildRequestFromFrame, readBoundedBody } = require('./framing'); const { CANONICAL_ERROR_JSON } = require('./protocol'); const { createEnclaveRunner } = require('./enclave-runner'); +const { createRuntimeTelemetry } = require('./runtime-telemetry'); /** * Bounded-agent broker server. * - * The broker itself has `network_mode: none`: it is not on `awf-net`, not on - * `awf-ext`, and not on the dedicated bounded-agent enclave network. Its whole - * agent-facing surface is one Unix domain socket shared through a tightly - * scoped bind mount. It receives the Docker socket only because it launches - * enclave containers. + * Compose agents (docker/gvisor primary) reach the broker over a Unix domain + * socket shared through a tightly scoped bind mount; the broker itself has + * `network_mode: none` in that mode -- not on `awf-net`, not on `awf-ext`, and + * not on the dedicated bounded-agent enclave network. sbx primary agents use + * the same protocol over authenticated HTTP only when a disposable capability + * probe proves that sbx cannot connect through a mounted host socket. In that + * mode the broker is attached only to a dedicated internal Docker network and + * published on an ephemeral host-gateway-only port; it is never on the + * enclave egress network either way. * * One route exists: * POST /query the bounded-agent API @@ -27,7 +33,7 @@ const { createEnclaveRunner } = require('./enclave-runner'); * response on the agent-observable surface. * * `/query` always answers `200` with a canonical result body: - * `{"status":"ok","result":}` or `{"status":"error"}` — status code and + * `{"status":"ok","result":}` or `{"status":"error"}` -- status code and * headers are identical either way, and every failure class collapses to the * same error body. For any invocation that reached workspace creation, the * response is additionally held until a fixed timing-bucket boundary. @@ -37,6 +43,9 @@ const RESULT_HEADERS = { 'content-type': 'application/json', 'cache-control': 'no-store', }; +// Give a nearly-complete invocation a chance to finish broker cleanup before +// force-removing this run's enclaves. Longer invocations are interrupted so +// Compose shutdown remains bounded; host teardown owns private-root removal. const SHUTDOWN_GRACE_MS = 1_000; const MAX_HEADER_BYTES = 8 * 1024; const MAX_CONNECTIONS = 32; @@ -123,7 +132,15 @@ function createHardenedServer(listener, audit) { return server; } -function processRequest(req, res, broker, audit, isAccepting = () => true) { +function processRequest( + req, + res, + broker, + audit, + framedHeaders = req.headers, + framedRawHeaders = req.rawHeaders, + isAccepting = () => true, +) { if (req.socket.awfRejected) { req.resume(); res.destroy(); @@ -147,7 +164,7 @@ function processRequest(req, res, broker, audit, isAccepting = () => true) { return broker.handle(undefined, (result) => sendResult(res, result)); } - const framed = buildRequestFromFrame(req.headers, req.rawHeaders, body.task); + const framed = buildRequestFromFrame(framedHeaders, framedRawHeaders, body.task); if (framed.error !== undefined) { audit.failure('framing', 'frame-rejected', framed.error); return broker.handle(undefined, (result) => sendResult(res, result)); @@ -164,11 +181,94 @@ function processRequest(req, res, broker, audit, isAccepting = () => true) { function createServer(deps) { const { broker, audit } = deps; return createHardenedServer( - (req, res, isAccepting) => processRequest(req, res, broker, audit, isAccepting), + (req, res, isAccepting) => processRequest( + req, + res, + broker, + audit, + req.headers, + req.rawHeaders, + isAccepting, + ), audit, ); } +function safeCapabilityEquals(actual, expected) { + if (typeof actual !== 'string') return false; + const actualBytes = Buffer.from(actual, 'utf8'); + const expectedBytes = Buffer.from(expected, 'utf8'); + return actualBytes.length === expectedBytes.length + && crypto.timingSafeEqual(actualBytes, expectedBytes); +} + +function stripCapabilityHeader(req) { + const headers = { ...req.headers }; + delete headers['x-awf-capability']; + const rawHeaders = []; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === 'x-awf-capability') continue; + rawHeaders.push(req.rawHeaders[i], req.rawHeaders[i + 1]); + } + return { headers, rawHeaders }; +} + +/** + * Authenticated HTTP listener for sbx-primary reachability only. Every + * request must carry exactly one `x-awf-capability` header matching the + * broker-generated `query` token; the distinct `probe` token is accepted + * exactly once (pre-agent reachability proof), then permanently retired for + * the lifetime of this process. Neither token is ever logged, telemetered, or + * written to the audit ledger -- only the fixed category strings + * `'auth-rejected'` / `'sbx-ingress-probe'` are. + */ +function createTcpServer(deps) { + const { broker, audit, capabilities } = deps; + let probeAvailable = true; + return createHardenedServer((req, res, isAccepting) => { + const capabilityHeaders = req.rawHeaders.filter( + (_value, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'x-awf-capability', + ); + const supplied = req.headers['x-awf-capability']; + const isQuery = capabilityHeaders.length === 1 && safeCapabilityEquals(supplied, capabilities.query); + const isProbe = ( + probeAvailable + && capabilityHeaders.length === 1 + && safeCapabilityEquals(supplied, capabilities.probe) + ); + + if (isProbe) { + probeAvailable = false; + audit.lifecycle('sbx-ingress-probe'); + req.resume(); + return new Promise((resolve) => { + setTimeout(() => { + sendResult(res, CANONICAL_ERROR_JSON); + resolve(); + }, PROBE_RESPONSE_DELAY_MS); + }); + } + + if (!isQuery) { + audit.failure('transport', 'auth-rejected'); + req.resume(); + sendResult(res, CANONICAL_ERROR_JSON); + return Promise.resolve(); + } + + const framed = stripCapabilityHeader(req); + return processRequest( + req, + res, + broker, + audit, + framed.headers, + framed.rawHeaders, + isAccepting, + ); + }, audit); +} + function listenOnSocket(server, config, audit) { fs.rmSync(config.socketPath, { force: true }); fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o770 }); @@ -191,22 +291,47 @@ function listenOnSocket(server, config, audit) { }); } +function listenOnTcp(server, config) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.tcpPort, '0.0.0.0', resolve); + }); +} + async function main() { const config = loadConfig(); const audit = createAuditLog(config.auditDir); + const telemetry = createRuntimeTelemetry(config.auditDir); const { runId, seeds } = loadSeedMap(config.seedMapPath); const runner = createEnclaveRunner(config); // Fail closed before accepting requests and deterministically reconcile - // containers left by a prior broker process for this exact run. Enclaves + // enclaves left by a prior broker process for this exact run. Enclaves // never pull and never fall back. await runner.assertAvailable(); await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'startup', + capabilityState: 'supported', + category: 'ready', + }); - const broker = createBroker({ config, seedMap: seeds, runId, audit, runner }); - const server = createServer({ broker, audit }); + const broker = createBroker({ config, seedMap: seeds, runId, audit, runner, telemetry }); + const unixServer = createServer({ broker, audit }); + const servers = [unixServer]; - await listenOnSocket(server, config, audit); + await listenOnSocket(unixServer, config, audit); + if (config.tcpPort !== undefined) { + const tcpServer = createTcpServer({ + broker, + audit, + capabilities: config.sbxIngressCapabilities, + }); + await listenOnTcp(tcpServer, config); + servers.push(tcpServer); + } // Write the ready file AFTER the socket is accepting connections. The compose // healthcheck polls this file in the broker-only control mount. @@ -219,6 +344,7 @@ async function main() { repos: seeds.size, backend: config.backend, profile: config.profile, + ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http', maxInvocations: config.maxInvocations, }); @@ -227,20 +353,39 @@ async function main() { if (shuttingDown) return; shuttingDown = true; broker.close(); - server.freezeAdmissions(); - server.close(); + for (const server of servers) { + server.freezeAdmissions(); + server.close(); + } const forcedExit = setTimeout(() => process.exit(1), 5000); forcedExit.unref(); try { await Promise.race([ - Promise.all([server.drainAdmissions(), broker.drain()]), + Promise.all([ + ...servers.map((server) => server.drainAdmissions()), + broker.drain(), + ]), new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS)), ]); // Interrupted invocations leave no enclave behind: reconcile again. await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'success', + }); process.exit(0); } catch (error) { audit.lifecycle('shutdown-cleanup-failed', error.message); + telemetry.emit({ + primaryBackend: config.primaryBackend, + boundedAgentBackend: config.backend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'cleanup-failed', + }); process.exit(1); } }; @@ -257,7 +402,9 @@ if (require.main === module) { module.exports = { createServer, + createTcpServer, listenOnSocket, + listenOnTcp, MAX_HEADER_BYTES, MAX_CONNECTIONS, }; diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index cccf8af89..c87f6751e 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2117,7 +2117,7 @@ The root object MAY contain a `boundedAgents` section: |-------|------|---------|-------| | `enabled` | boolean | `false` | Only an explicit `true` enables the subsystem. | | `privateRepos` | array | — | Required when enabled. Each entry is `{ repo, sensitivity }`; `repo` MUST be a bare `owner/repo` slug and MUST be unique case-insensitively. There is no legacy bare-string form. | -| `runtime` | `docker` \| `gvisor` \| `sbx` | `docker` | `docker` and `gvisor` are implemented; `sbx` fails closed (§15.7). | +| `runtime` | `docker` \| `gvisor` \| `sbx` | `docker` | `docker` and `gvisor` are implemented; `sbx` is capability-blocked (§15.7). | | `profile` | `openai` \| `anthropic` | `openai` | Trusted provider protocol the enclave speaks to the API proxy. | | `model` | string | — | Required when enabled. A request can never choose or override it. | | `timeout` | integer (1–540) | `120` | Wall-clock bound for one enclave invocation. Capped so the 10-minute response bucket reserves its final minute for termination, validation, and cleanup. | @@ -2143,8 +2143,16 @@ Bounded agents additionally REQUIRE, at preflight: credential for `openai`, an Anthropic credential for `anthropic`); - a staging credential in `GH_TOKEN`/`GITHUB_TOKEN` on the AWF host; - a Unix-socket Docker host; +- the **primary agent** runtime to be proven available — `docker`, + `runsc` registration for `gvisor`, or a proven ingress path (Unix + passthrough or authenticated `sbx-http`) for a primary `sbx` runtime. There + is no blanket rejection of a primary microVM runtime; availability is + proven independently for each run (§15.7.1); +- the selected **bounded-agent enclave** `runtime` to be proven available; - `enableDind` to be disabled, because primary-agent access to the enclave's - Docker daemon would bypass every finite-disclosure boundary. + Docker daemon would bypass every finite-disclosure boundary. This holds + regardless of primary or enclave backend — there is no runtime combination + in which exposing that socket to the primary agent is safe. Any failure aborts the run before the primary agent starts. @@ -2256,7 +2264,13 @@ to derive a Compose project prefix at runtime). channel through the primary agent's API-proxy telemetry. - The **broker** runs with `network_mode: none` and never joins the enclave network. It receives the Docker socket only because it launches enclaves; - that path never enters the agent's environment or volumes. + that path never enters the agent's environment or volumes. When the + runtime-backend proof requires it (a primary `sbx` runtime unable to prove a + direct Unix-socket passthrough, §15.7.1), the broker instead exposes a + dedicated ingress network with one ephemeral port published only on the + Docker host-gateway address, gated by a random, single-run capability token + proven reachable before the primary agent starts; the broker itself never + joins the enclave's `awf-bounded-agent` network either way. ### 15.7 Runtime Backends @@ -2265,11 +2279,65 @@ OCI runtime to be registered with the daemon; availability is proven exactly at preflight and again at broker startup, and an unavailable `runsc` NEVER downgrades to the default runtime. -`sbx` is accepted by the JSON Schema but **fails closed** at preflight with an -explicit not-yet-implemented capability error, and is rejected again by compose -assembly and by the broker's runner factory. AWF has no audited single-use, -API-proxy-only enclave launcher for it. Implementing it is deliberately out of -scope for this release. +`sbx` is accepted by the JSON Schema but is **capability-blocked**: AWF ships a +dedicated bounded-agent sbx capability probe (host-side +`src/bounded-agent/sbx-capability.ts`, container-side +`containers/bounded-agent/broker/sbx-capability-probe.js`) that runs the exact +audited Docker Sandboxes CLI surface (`sbx version`, `sbx create`, `sbx exec`, +`sbx ls --json`, `sbx stop`, `sbx rm --force`) against the audited version +(`v0.37.1`) and reports every missing capability in structured JSON — never a +single collapsed boolean, and never a "not yet implemented" placeholder. + +The bounded-agent enclave's network requirement is strictly harder than a +bounded query's: it must reach *exactly one* peer (the dedicated API proxy), +not "no network at all". Current `sbx create` exposes `--cpus`, `--memory`, +`--name`, `--template`, and read-only same-path mounts, but no enforceable, +mandatory API-proxy-only network policy (an advisory `HTTP_PROXY` env var is +not a hard network policy and is never treated as one), no PID limits, no disk +limits, no per-file size limits, and no pinned, digest-verified AWF +bounded-agent template/bootstrap. The probe therefore always reports these +missing and `supported` can never be `true` for the currently audited +version — an intentional, structural "no false pass" design, not an +oversight. AWF rejects this runtime before staging or compose assembly, mounts +neither the Docker socket nor any sbx daemon credential, and the broker's +`SbxEnclaveRunner.assertAvailable()` throws immediately if ever invoked. +Support remains blocked until sbx provides enforceable versions of all +controls and AWF publishes a digest-pinned, standard-library-only enclave +bootstrap. + +#### 15.7.1 Primary-agent and bounded-agent runtime matrix + +The primary agent runtime and the bounded-agent enclave runtime are separate, +independently-proven sandbox decisions — mirroring §14's primary-agent/query +matrix. `container.containerRuntime` selects the primary agent; +`boundedAgents.runtime` selects the single-use enclave. The broker never +reuses the primary agent sandbox; every accepted invocation creates a new +container with a unique run identity and destroys it before returning. No +combination ever falls back to a weaker or different backend. + +`src/bounded-agent/runtime-matrix.ts` evaluates all nine +`primaryBackend` × `boundedAgentBackend` combinations independently and +records `lifecycleClass: 'invocation'`, `capabilityState`, and `category` per +cell for telemetry — never the task, repository name, provider payload, or +capability token. + +| Primary agent | Docker enclave | gVisor enclave | sbx enclave | +|---|---|---|---| +| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes | + +Six of the nine cells are supported once the relevant runtime(s) are proven +available; the three `sbx`-enclave cells are not, and remain blocked until the +capability proof in §15.7 can report `supported: true` for an audited sbx +version. An unavailable primary runtime fails at primary preflight, before any +repository is staged; an unavailable enclave runtime fails at enclave +preflight, for the same reason. + +`scripts/ci/report-bounded-agent-runtime-matrix.js` renders this matrix from +live host probes for CI/local use and reports an explicit `BLOCKED` result — +exiting non-zero under `--require /` — rather than a +false pass when no real sbx binary is present. ### 15.8 Agent Interface diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 1cd457c06..9df2571fc 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -987,7 +987,7 @@ "gvisor", "sbx" ], - "description": "Sandbox runtime backend used to execute the enclave. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is accepted by this schema but fails closed at preflight with an explicit not-yet-implemented capability error. No backend ever falls back. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the bounded-agent enclave, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves the mandatory API-proxy-only network policy, read-only targeted mounts, unprivileged exec/workdir, and pids/disk/fsize/lifecycle controls this enclave requires. No backend ever falls back. Default: \"docker\".", "default": "docker" }, "profile": { diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md index 223403dc5..56d035fea 100644 --- a/docs/bounded-agents.md +++ b/docs/bounded-agents.md @@ -56,12 +56,20 @@ Four trust stages, mirroring bounded queries: and gitdir pointers. The staging credential is scrubbed before any container exists. A run that could never launch an enclave never clones anything. -2. **Trusted broker over a Unix socket.** A dedicated `awf-bounded-agent-broker` +2. **Trusted broker over a private ingress.** A dedicated `awf-bounded-agent-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It has no network at all — not even the enclave network it launches enclaves onto. It holds the seed map (including each repository's trusted sensitivity), which the agent can never read or modify, - and it keeps a ledger **separate** from bounded queries. + and it keeps a ledger **separate** from bounded queries. When the primary + agent itself runs under `containerRuntime: "sbx"`, AWF first probes whether + the microVM's filesystem passthrough can bind the broker's Unix socket + directly; when it cannot, the broker instead listens on a dedicated Docker + `internal` network with one ephemeral port published only on the Docker + host-gateway address, and the agent is given only the endpoint plus a + random, single-run capability token proven reachable before the agent + starts (see [Primary-agent and bounded-agent runtime + matrix](#primary-agent-and-bounded-agent-runtime-matrix)). 3. **Single-use enclave on an API-proxy-only network.** For each accepted request the broker launches one fresh, uniquely named, labelled container @@ -110,7 +118,7 @@ Four trust stages, mirroring bounded queries: |-------|---------|---------| | `enabled` | `false` | Only an explicit `true` enables the subsystem. | | `privateRepos` | — | Required when enabled. `{ repo, sensitivity }` entries; `repo` must be a bare `owner/repo` slug, unique case-insensitively. | -| `runtime` | `docker` | `docker` or `gvisor`. `sbx` is accepted by the schema but fails closed (see below). | +| `runtime` | `docker` | `docker` or `gvisor`. `sbx` is accepted by the schema but remains capability-blocked (see below). | | `profile` | `openai` | Provider protocol the enclave speaks to the API proxy: `openai` (`POST /v1/chat/completions`) or `anthropic` (`POST /v1/messages`). | | `model` | — | Required when enabled. A request can never choose or override it. | | `timeout` | `120` | Wall-clock seconds for one invocation (max 540). | @@ -139,7 +147,13 @@ unless all of the following hold: - `model` is set; - a staging credential is present in `GH_TOKEN` or `GITHUB_TOKEN`; - the Docker host is a Unix socket; -- the selected runtime is actually available. +- the **primary agent** runtime is actually available (`docker`, `runsc` + registration for `gvisor`, or a proven sbx ingress path for `sbx` — see + [Primary-agent and bounded-agent runtime + matrix](#primary-agent-and-bounded-agent-runtime-matrix)); a blanket + rejection of a primary `sbx` runtime is no longer applied — availability is + proven, not assumed; +- the selected **bounded-agent enclave** runtime is actually available. ## Docker and gVisor @@ -158,14 +172,118 @@ run aborts instead. Nothing else about the topology, mounts, budgets, or protocol changes between the two backends. -## `sbx` fails closed +## `sbx` capability-blocked + +`runtime: "sbx"` is accepted by the JSON Schema so configurations can be +written ahead of support landing, but it is **capability-blocked** — never a +blanket "not yet implemented" refusal, and never a false pass. AWF ships a +dedicated bounded-agent sbx capability probe +(`src/bounded-agent/sbx-capability.ts`, mirrored in +`containers/bounded-agent/broker/sbx-capability-probe.js`) that runs the exact +audited Docker Sandboxes CLI (`v0.37.1`) surface — `sbx version`, `sbx create`, +`sbx exec`, `sbx ls --json`, `sbx stop`, and `sbx rm --force` — and reports +every missing capability in structured JSON rather than a single boolean. + +The enclave requirement is strictly harder than a bounded query's: an +enclave must reach *exactly one* peer (the dedicated API proxy), not "no +network at all". Current `sbx create` supports `--cpus`, `--memory`, `--name`, +`--template`, and read-only same-path mounts, but does **not** expose the hard +controls AWF requires for a mandatory, enforceable API-proxy-only network +policy (not an advisory `HTTP_PROXY`), PID limits, disk limits, per-file size +limits, or a pinned, digest-verified AWF bounded-agent template/bootstrap. +The probe therefore always reports these as missing and `supported` can never +be `true` for the currently audited version — by design, not by omission. + +AWF rejects this runtime before staging or compose assembly, mounts neither +the Docker socket nor any sbx daemon credential, and the broker's +`SbxEnclaveRunner` throws immediately if ever invoked. Support remains blocked +until sbx provides enforceable versions of all controls and AWF publishes a +digest-pinned, standard-library-only bootstrap for the enclave — the same +promotion bar as bounded queries. + +## Primary-agent and bounded-agent runtime matrix + +The primary agent and the bounded-agent enclave are separate sandbox +decisions, each with its own availability proof: + +- `container.containerRuntime` / `--container-runtime` selects the **primary + agent** runtime. +- `boundedAgents.runtime` selects the **single-use enclave** runtime. + +The broker never reuses the primary agent sandbox. Every accepted invocation +creates a new container with a unique run identity and destroys it before +returning. No combination ever falls back to a weaker or different backend. + +| Primary agent | Docker enclave | gVisor enclave | sbx enclave | +|---|---|---|---| +| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx enclave probes | +| sbx | Supported when primary sbx ingress (Unix passthrough or authenticated `sbx-http`) is proven | Supported when primary sbx ingress and `runsc` are proven | **Blocked** by mandatory sbx enclave probes | + +"Supported" is capability-dependent, not an instruction to downgrade. An +unavailable primary runtime fails at primary preflight, before any repository +is staged. An unavailable enclave runtime fails at enclave preflight, for the +same reason. Selecting `"runtime": "sbx"` for the enclave is an explicit, +still-experimental gate; the additional executable capability proof must also +pass. With Docker Sandboxes `v0.37.1`, all three sbx-enclave cells remain +blocked — six of the nine combinations are supported once the relevant +runtime(s) are proven available, and the three `sbx`-enclave cells are not. + +`src/bounded-agent/runtime-matrix.ts` evaluates all nine combinations +independently (`primaryBackend` × `boundedAgentBackend`) and records +`lifecycleClass`, `capabilityState`, and `category` per cell for telemetry — +never the task, repository name, or provider payload. +`scripts/ci/report-bounded-agent-runtime-matrix.js` renders the same matrix +from live host probes for CI/local use; it reports an explicit `BLOCKED` +result (and exits non-zero under `--require`) rather than a false pass when no +real sbx binary is present. + +Examples of independent selection: -`runtime: "sbx"` is accepted by the JSON Schema so configurations can be written -ahead of support landing, but it **fails closed** with an explicit -not-yet-implemented capability error at preflight, and is rejected again by -compose assembly and by the broker's runner factory. AWF has no audited -single-use, API-proxy-only enclave launcher for `sbx`; support is deliberately -deferred. +```json +{ "container": { "containerRuntime": "sbx" }, + "boundedAgents": { "enabled": true, "runtime": "docker", "model": "gpt-4o-mini", + "privateRepos": [{ "repo": "my-org/private-service", "sensitivity": "internal" }] } } +``` + +### Troubleshooting runtime selection + +| Symptom | Meaning | Action | +|---|---|---| +| `runsc ... not available; no fallback` | The gVisor enclave backend is not registered with Docker | Register `runsc`, verify it appears in `docker info --format '{{json .Runtimes}}'`, and rerun | +| `sbx ... blocked ... mandatory` capability error | The sbx enclave capability probe failed as designed | Read the complete missing-capability list; do not substitute local policy or a weaker runtime | +| sbx primary ingress probe fails | The primary VM cannot reach the broker through either proven ingress | Verify sbx Unix passthrough or authenticated `sbx-http` ingress; the agent must not start | +| Docker host must be `unix://` | The networkless broker cannot reach a TCP daemon | Use a local Unix socket; AWF will not attach the broker to a network | +| `bounded agents cannot be combined with enableDind` | Docker-socket exposure to the primary agent would bypass every finite-disclosure boundary | Disable `enableDind`; there is no runtime combination in which this is safe | +| Matrix report says `BLOCKED` | Capability or security preflight prevented launch | Treat this as expected fail-closed status, not successful runtime execution | + +Run `node scripts/ci/report-bounded-agent-runtime-matrix.js` after `npm run +build` to print all nine local capability results. Use `--require +docker/docker` (or another pair) when a smoke job must require one executable +combination. + +### sbx enclave promotion criteria + +The experimental sbx enclave backend MUST remain blocked until all of these are +demonstrated in real VMs, not only deterministic fakes: + +1. A digest-pinned AWF bounded-agent template/bootstrap exists. +2. A mandatory, enforceable API-proxy-only network policy is available and + enforced by the sbx runtime itself — not an advisory `HTTP_PROXY` env var, + and not organization-level network policy that can be replaced. +3. CPU, memory, PID, aggregate disk, and per-file size limits are enforceable. +4. Read-only seed/task/schema/result mounts have explicit guest targets and + expose no broker state, credentials, sibling repository, or prior + invocation. +5. Timeout, OOM, PID, disk, file-size, malformed/oversized output, and + interruption cleanup tests all pass. +6. Unix and authenticated `sbx-http` primary ingress retain byte-identical + protocol behavior. +7. Direct and lateral reachability to anything other than the dedicated API + proxy is proven denied, not merely unconfigured. + +Passing a version check alone, or passing only the CLI help probe, is not +enough to promote the backend. ## Agent interface diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.js b/scripts/ci/report-bounded-agent-runtime-matrix.js new file mode 100644 index 000000000..15b5728c7 --- /dev/null +++ b/scripts/ci/report-bounded-agent-runtime-matrix.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const { spawnSync } = require('child_process'); + +const BACKENDS = ['docker', 'gvisor', 'sbx']; + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { + ok: !result.error && result.status === 0, + stdout: result.stdout || '', + }; +} + +function collectCapabilities(commandRunner = run) { + const docker = commandRunner('docker', ['info', '--format', '{{json .Runtimes}}']); + let runtimes = {}; + if (docker.ok) { + try { + runtimes = JSON.parse(docker.stdout); + } catch { + runtimes = {}; + } + } + const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc'); + // `sbx ls` only proves that the binary exists and is reachable. It is + // authenticated and non-mutating, so it also proves daemon and credential + // availability for the primary microVM axis. + const sbxPrimary = commandRunner('sbx', ['ls']).ok; + const sbxBoundedAgent = commandRunner( + process.execPath, + ['containers/bounded-agent/broker/sbx-capability-probe.js'], + ); + let sbxBoundedAgentSupported = false; + if (sbxBoundedAgent.stdout) { + try { + sbxBoundedAgentSupported = JSON.parse(sbxBoundedAgent.stdout).supported === true; + } catch { + sbxBoundedAgentSupported = false; + } + } + return { + primary: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxPrimary ? 'supported' : 'unavailable', + }, + boundedAgent: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxBoundedAgentSupported ? 'supported' : 'blocked', + }, + }; +} + +function evaluate(primary, boundedAgent, capabilities) { + if (capabilities.primary[primary] !== 'supported') { + return { + status: 'BLOCKED', + capability: capabilities.primary[primary], + phase: 'primary-preflight', + }; + } + if (capabilities.boundedAgent[boundedAgent] !== 'supported') { + return { + status: 'BLOCKED', + capability: capabilities.boundedAgent[boundedAgent], + phase: 'bounded-agent-preflight', + }; + } + return { status: 'SUPPORTED', capability: 'supported', phase: 'ready' }; +} + +function renderMatrix(capabilities) { + const lines = [ + '## Bounded-agent runtime capability matrix', + '', + '| Primary agent | Bounded-agent enclave | Result | Primary capability | ' + + 'Bounded-agent capability | Gate |', + '|---|---|---|---|---|---|', + ]; + for (const primary of BACKENDS) { + for (const boundedAgent of BACKENDS) { + const result = evaluate(primary, boundedAgent, capabilities); + lines.push( + `| ${primary} | ${boundedAgent} | ${result.status} | ${capabilities.primary[primary]} | ` + + `${capabilities.boundedAgent[boundedAgent]} | ${result.phase} |`, + ); + } + } + lines.push( + '', + '> BLOCKED is an expected fail-closed security result, not runtime success. No fallback is attempted.', + '> The bounded-agent sbx enclave is BLOCKED unconditionally today: the audited sbx CLI cannot yet ' + + 'prove the mandatory API-proxy-only network, RO-targeted-mount, pids/disk/fsize, or lifecycle ' + + 'isolation primitives this enclave requires.', + ); + return `${lines.join('\n')}\n`; +} + +function main() { + const capabilities = collectCapabilities(); + const report = renderMatrix(capabilities); + process.stdout.write(report); + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report); + } + + const requiredIndex = process.argv.indexOf('--require'); + if (requiredIndex !== -1) { + const requirement = process.argv[requiredIndex + 1] || ''; + const [primary, boundedAgent] = requirement.split('/'); + if (!BACKENDS.includes(primary) || !BACKENDS.includes(boundedAgent)) { + throw new Error(`Invalid --require combination: ${requirement}`); + } + const result = evaluate(primary, boundedAgent, capabilities); + if (result.status !== 'SUPPORTED') { + throw new Error(`Required runtime combination ${requirement} is ${result.status} at ${result.phase}`); + } + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { collectCapabilities, evaluate, renderMatrix }; diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts new file mode 100644 index 000000000..26242cab1 --- /dev/null +++ b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts @@ -0,0 +1,88 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { collectCapabilities, evaluate, renderMatrix } = require( + path.join(__dirname, 'report-bounded-agent-runtime-matrix.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-agent runtime capability report', () => { + it('reports all nine combinations and preserves the sbx bounded-agent security block', () => { + const capabilities = collectCapabilities((command: string, args: string[]) => { + if (command === 'docker') { + return { ok: true, stdout: '{"runc":{},"runsc":{}}' }; + } + if (command === 'sbx') { + expect(args).toEqual(['ls']); + return { ok: true, stdout: 'Docker Sandboxes v0.37.1' }; + } + if (args.includes('sbx-capability-probe.js')) { + return { ok: false, stdout: '{"supported":false}' }; + } + return { ok: false, stdout: '' }; + }); + const report = renderMatrix(capabilities); + const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line)); + expect(rows).toHaveLength(9); + expect(report).toContain( + '| sbx | sbx | BLOCKED | supported | blocked | bounded-agent-preflight |', + ); + expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success'); + expect(report).toContain('bounded-agent sbx enclave is BLOCKED unconditionally today'); + }); + + it('never promotes an unavailable primary or bounded-agent runtime through fallback', () => { + const capabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + boundedAgent: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' }, + }; + expect(evaluate('gvisor', 'docker', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'primary-preflight', + }); + expect(evaluate('docker', 'gvisor', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'bounded-agent-preflight', + }); + expect(evaluate('docker', 'sbx', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'blocked', + phase: 'bounded-agent-preflight', + }); + }); + + it('supports primary sbx paired with docker/gvisor bounded-agent enclaves once primary sbx is proven', () => { + const capabilities = { + primary: { docker: 'supported', gvisor: 'supported', sbx: 'supported' }, + boundedAgent: { docker: 'supported', gvisor: 'supported', sbx: 'blocked' }, + }; + expect(evaluate('sbx', 'docker', capabilities).status).toBe('SUPPORTED'); + expect(evaluate('sbx', 'gvisor', capabilities).status).toBe('SUPPORTED'); + expect(evaluate('sbx', 'sbx', capabilities).status).toBe('BLOCKED'); + }); + + it('emits an explicit capability-blocked report (not a false pass) when no real sbx binary is present', () => { + const capabilities = collectCapabilities((command: string) => { + if (command === 'docker') { + return { ok: true, stdout: '{"runc":{}}' }; + } + // Simulate the local/CI environment used in this task: no `sbx` binary + // installed at all, and no bounded-agent broker probe reachable. + return { ok: false, stdout: '' }; + }); + expect(capabilities.primary.sbx).toBe('unavailable'); + expect(capabilities.boundedAgent.sbx).toBe('blocked'); + expect(() => { + const report = renderMatrix(capabilities); + const requirement = evaluate('sbx', 'sbx', capabilities); + if (requirement.status !== 'SUPPORTED') { + throw new Error( + `Required runtime combination sbx/sbx is ${requirement.status} at ${requirement.phase}`, + ); + } + return report; + }).toThrow(/sbx\/sbx is BLOCKED at primary-preflight/); + }); +}); diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 1cd457c06..9df2571fc 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -987,7 +987,7 @@ "gvisor", "sbx" ], - "description": "Sandbox runtime backend used to execute the enclave. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is accepted by this schema but fails closed at preflight with an explicit not-yet-implemented capability error. No backend ever falls back. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the bounded-agent enclave, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime; \"sbx\" is a fail-closed preview and is blocked until the installed sbx CLI proves the mandatory API-proxy-only network policy, read-only targeted mounts, unprivileged exec/workdir, and pids/disk/fsize/lifecycle controls this enclave requires. No backend ever falls back. Default: \"docker\".", "default": "docker" }, "profile": { diff --git a/src/bounded-agent/broker.test.ts b/src/bounded-agent/broker.test.ts index 249dba2e6..00dd3a259 100644 --- a/src/bounded-agent/broker.test.ts +++ b/src/bounded-agent/broker.test.ts @@ -9,6 +9,7 @@ const { } = require(path.join(brokerDir, 'enclave-runner.js')); const { DockerEnclaveRunner } = require(path.join(brokerDir, 'docker-enclave-runner.js')); const { GvisorEnclaveRunner } = require(path.join(brokerDir, 'gvisor-enclave-runner.js')); +const { SbxEnclaveRunner } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); const { createLedger } = require(path.join(brokerDir, 'ledger.js')); const { CANONICAL_ERROR_JSON } = require(path.join(brokerDir, 'protocol.js')); const { BOUNDED_AGENT_AUDIT_FILENAME } = require(path.join(brokerDir, 'audit.js')); @@ -574,8 +575,12 @@ describe('bounded-agent enclave runner selection', () => { expect(createEnclaveRunner({ ...config, backend: 'gvisor' })).toBeInstanceOf(GvisorEnclaveRunner); }); - it('fails closed for any other backend, including sbx', () => { - expect(() => createEnclaveRunner({ ...config, backend: 'sbx' })).toThrow(/Unsupported/); + it('selects the sbx runner for the sbx backend, which fails closed on assertAvailable', () => { + const runner = createEnclaveRunner({ ...config, backend: 'sbx' }); + expect(runner).toBeInstanceOf(SbxEnclaveRunner); + }); + + it('fails closed for any other backend, not sbx', () => { expect(() => createEnclaveRunner({ ...config, backend: 'firecracker' })).toThrow(/Unsupported/); }); diff --git a/src/bounded-agent/ingress.test.ts b/src/bounded-agent/ingress.test.ts new file mode 100644 index 000000000..7f2a35b19 --- /dev/null +++ b/src/bounded-agent/ingress.test.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import execa from 'execa'; +import type { WrapperConfig } from '../types'; +import { + removeSbxIngressCapabilityFile, + resolveSbxIngress, +} from './ingress'; +import { resolveBoundedAgentPaths } from './paths'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../services/host-gateway', () => ({ + resolveDockerHostGateway: jest.fn(() => '172.17.0.1'), +})); +const mockExeca = execa as unknown as jest.Mock; + +describe('sbx bounded-agent ingress resolution', () => { + let workDir: string; + let config: WrapperConfig; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-resolution-')); + config = { + workDir, + boundedAgentIngressTransport: 'sbx-http', + } as WrapperConfig; + const paths = resolveBoundedAgentPaths(workDir); + fs.mkdirSync(paths.controlDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(paths.capabilityPath, JSON.stringify({ + version: 1, + query: 'a'.repeat(64), + probe: 'b'.repeat(64), + }), { mode: 0o600 }); + mockExeca.mockReset(); + mockExeca.mockResolvedValue({ + exitCode: 0, + stdout: 'healthy|172.17.0.1:49152\n', + stderr: '', + }); + }); + + afterEach(() => { + const paths = resolveBoundedAgentPaths(workDir); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects a transport other than sbx-http', async () => { + await expect(resolveSbxIngress({ ...config, boundedAgentIngressTransport: 'unix' } as WrapperConfig)) + .rejects.toThrow(/non-HTTP bounded-agent transport/); + }); + + it('returns only the endpoint, two capabilities, and agent-visible artifact paths', async () => { + const result = await resolveSbxIngress(config); + const paths = resolveBoundedAgentPaths(workDir); + + expect(result).toEqual({ + endpoint: 'http://host.docker.internal:49152/query', + queryCapability: 'a'.repeat(64), + probeCapability: 'b'.repeat(64), + skillPath: paths.skillPath, + wrapperDir: paths.agentDir, + }); + const dockerArgs = mockExeca.mock.calls[0][1] as string[]; + expect(dockerArgs.join(' ')).not.toContain('a'.repeat(64)); + expect(dockerArgs.join(' ')).not.toContain('b'.repeat(64)); + }); + + it.each([ + '0.0.0.0:49152', + '[::1]:49152', + '172.17.0.1:0', + '172.17.0.1:70000', + '', + ])('rejects a broad or malformed publication: %s', async (published) => { + mockExeca.mockResolvedValue({ exitCode: 0, stdout: `healthy|${published}`, stderr: '' }); + await expect(resolveSbxIngress(config)).rejects.toThrow(/narrowly published/); + }); + + it('waits for broker health before returning the endpoint', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'starting|', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: 'healthy|172.17.0.1:49152', stderr: '' }); + + const result = await resolveSbxIngress(config); + expect(result.endpoint).toBe('http://host.docker.internal:49152/query'); + expect(mockExeca.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('rejects a malformed on-disk capability file', async () => { + const paths = resolveBoundedAgentPaths(workDir); + fs.writeFileSync(paths.capabilityPath, JSON.stringify({ version: 1, query: 'not-hex', probe: 'b'.repeat(64) })); + await expect(resolveSbxIngress(config)).rejects.toThrow(/malformed/); + }); + + it('removes the private capability file after broker startup and sbx probing', () => { + const capabilityPath = resolveBoundedAgentPaths(workDir).capabilityPath; + expect(fs.existsSync(capabilityPath)).toBe(true); + removeSbxIngressCapabilityFile(config); + expect(fs.existsSync(capabilityPath)).toBe(false); + }); +}); diff --git a/src/bounded-agent/ingress.ts b/src/bounded-agent/ingress.ts new file mode 100644 index 000000000..a420764b0 --- /dev/null +++ b/src/bounded-agent/ingress.ts @@ -0,0 +1,119 @@ +import * as fs from 'fs'; +import execa from 'execa'; +import { BOUNDED_AGENT_BROKER_CONTAINER_NAME } from '../constants'; +import { getLocalDockerEnv } from '../host-env'; +import { resolveDockerHostGateway } from '../services/host-gateway'; +import type { WrapperConfig } from '../types'; +import { resolveBoundedAgentPaths } from './paths'; + +export const BOUNDED_AGENT_TCP_PORT = 18081; +export const BOUNDED_AGENT_INGRESS_NETWORK = 'awf-bounded-agent-ingress'; +export const SBX_HOST_ALIAS = 'host.docker.internal'; + +interface SbxIngressCapabilities { + version: 1; + query: string; + probe: string; +} + +export interface ResolvedSbxIngress { + endpoint: string; + queryCapability: string; + probeCapability: string; + skillPath: string; + wrapperDir: string; +} + +function readCapabilities(config: WrapperConfig): SbxIngressCapabilities { + const paths = resolveBoundedAgentPaths(config.workDir); + const parsed = JSON.parse(fs.readFileSync(paths.capabilityPath, 'utf8')) as Partial; + const capabilityPattern = /^[0-9a-f]{64}$/; + if ( + parsed.version !== 1 + || typeof parsed.query !== 'string' + || typeof parsed.probe !== 'string' + || !capabilityPattern.test(parsed.query) + || !capabilityPattern.test(parsed.probe) + || parsed.query === parsed.probe + ) { + throw new Error('Bounded-agent sbx ingress capability file is malformed'); + } + return parsed as SbxIngressCapabilities; +} + +/** + * Resolves the healthy host-gateway publication without logging capabilities. + * + * Mirrors bounded-query's `resolveSbxIngress` exactly: a primary sbx microVM + * cannot receive the broker's Unix-socket bind mount, so when the executable + * passthrough probe fails, the broker is instead published on an ephemeral, + * host-gateway-only port on a dedicated internal Docker network, and the + * microVM authenticates with a broker-generated, random per-run capability + * that is never logged, put in telemetry, or written to any audit/skill file. + */ +export async function resolveSbxIngress(config: WrapperConfig): Promise { + if (config.boundedAgentIngressTransport !== 'sbx-http') { + throw new Error('resolveSbxIngress called for a non-HTTP bounded-agent transport'); + } + const expectedHostIp = resolveDockerHostGateway(); + if (!expectedHostIp) { + throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress'); + } + + const deadline = Date.now() + 30_000; + let lastPublished = ''; + let lastHealth = ''; + while (Date.now() < deadline) { + const result = await execa( + 'docker', + [ + 'inspect', + '--format', + `{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{with index (index .NetworkSettings.Ports "${BOUNDED_AGENT_TCP_PORT}/tcp") 0}}{{.HostIp}}:{{.HostPort}}{{end}}`, + BOUNDED_AGENT_BROKER_CONTAINER_NAME, + ], + { + env: getLocalDockerEnv(), + reject: false, + timeout: 5_000, + }, + ); + const [health = '', published = ''] = result.stdout.trim().split('|', 2); + lastHealth = health; + lastPublished = published; + const separator = published.lastIndexOf(':'); + const publishedHostIp = separator === -1 ? '' : published.slice(0, separator); + const publishedPort = separator === -1 ? '' : published.slice(separator + 1); + const publishedPortNumber = Number(publishedPort); + const hasValidPort = /^[1-9][0-9]{0,4}$/.test(publishedPort) && publishedPortNumber <= 65535; + if (result.exitCode === 0 && health === 'healthy' && publishedHostIp === expectedHostIp && hasValidPort) { + const paths = resolveBoundedAgentPaths(config.workDir); + const capabilities = readCapabilities(config); + return { + endpoint: `http://${SBX_HOST_ALIAS}:${publishedPort}/query`, + queryCapability: capabilities.query, + probeCapability: capabilities.probe, + skillPath: paths.skillPath, + wrapperDir: paths.agentDir, + }; + } + if (result.exitCode === 0 && health === 'healthy') { + throw new Error(`Bounded-agent sbx ingress is not narrowly published on host-gateway ${expectedHostIp}`); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error( + `Bounded-agent sbx ingress did not become healthy on host-gateway ${expectedHostIp} ` + + `(health=${lastHealth || 'unknown'}, published=${lastPublished || 'none'})`, + ); +} + +/** Deletes the on-disk secret after the running broker has loaded it. */ +export function removeSbxIngressCapabilityFile(config: WrapperConfig): void { + fs.rmSync(resolveBoundedAgentPaths(config.workDir).capabilityPath, { force: true }); +} + +/** @internal */ +// ts-prune-ignore-next +export const ingressTestHelpers = { readCapabilities }; diff --git a/src/bounded-agent/manager.ts b/src/bounded-agent/manager.ts index dd1abf7f8..62dc59fe8 100644 --- a/src/bounded-agent/manager.ts +++ b/src/bounded-agent/manager.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import * as crypto from 'crypto'; import execa from 'execa'; import { logger } from '../logger'; import { getLocalDockerEnv } from '../host-env'; @@ -9,7 +10,11 @@ import { resolveBoundedAgentPaths, type BoundedAgentPaths, } from './paths'; -import { assertEnclaveRuntimeAvailable, validateBoundedAgentConfig } from './preflight'; +import { + assertEnclaveRuntimeAvailable, + assertPrimaryRuntimeAvailable, + validateBoundedAgentConfig, +} from './preflight'; import { writeBoundedAgentSkill } from './skill'; import { writeBoundedAgentWrapper } from './wrapper-artifact'; import { releaseSeedPermissions, resolveStagingToken, stageBoundedAgentSeeds, type GitRunner } from './staging'; @@ -20,6 +25,12 @@ import { } from '../bounded-execution/repository-staging'; import { assertBoundedAgentPrivateRootIsolated } from './mount-policy'; import { fixArtifactPermissionsForRootless } from '../artifact-permissions'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { probeSbxUnixSocketMount } from '../sbx-manager'; +import { + resolveBoundedAgentPrimaryBackend, + serializeBoundedAgentRuntimeTelemetry, +} from './runtime-matrix'; /** * Bounded-agent lifecycle orchestration. @@ -138,18 +149,51 @@ export interface PrepareBoundedAgentsDeps { gitRunner?: GitRunner; /** Override the host environment the staging credential is read from. */ env?: NodeJS.ProcessEnv; + /** Override the sbx Unix-socket passthrough probe (tests). */ + probeSbxUnixSocket?: () => Promise; /** Override enclave-runtime capability preflight (tests). */ assertRuntimeAvailable?: typeof assertEnclaveRuntimeAvailable; + /** Override primary-runtime capability preflight (tests). */ + assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable; +} + +interface SbxIngressCapabilities { + version: 1; + query: string; + probe: string; +} + +function writeSbxIngressCapabilities(paths: BoundedAgentPaths): void { + const capabilities: SbxIngressCapabilities = { + version: 1, + query: crypto.randomBytes(32).toString('hex'), + probe: crypto.randomBytes(32).toString('hex'), + }; + const fd = fs.openSync( + paths.capabilityPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o600, + ); + try { + fs.writeSync(fd, JSON.stringify(capabilities)); + fs.fchmodSync(fd, 0o600); + } finally { + fs.closeSync(fd); + } } /** - * Validates configuration, proves the enclave runtime is available, stages one - * immutable seed per configured repository, and writes the broker/agent - * artifacts. + * Validates configuration, proves both the primary-agent runtime and the + * enclave runtime are independently available, stages one immutable seed per + * configured repository, and writes the broker/agent artifacts. * * Ordering is a security property: preflight runs *before* staging, so a run * that could never launch an enclave never clones a private repository, and - * the staging credential is discarded before any container exists. + * the staging credential is discarded before any container exists. Each + * preflight axis is proven independently and neither ever falls back to a + * weaker backend on failure; every terminal state is reported as narrow, + * content-free runtime telemetry (backend names and capability state only — + * never secrets, paths, prompts, repo names, or model payloads). * * Throws on any failure — the caller must abort the run. */ @@ -166,8 +210,52 @@ export async function prepareBoundedAgents( throw new Error(`Bounded-agent configuration is invalid:\n - ${errors.join('\n - ')}`); } + const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime); + const telemetryBase = { + primaryBackend, + boundedAgentBackend: boundedAgents.runtime, + lifecycleClass: 'preflight' as const, + }; const assertRuntimeAvailable = deps.assertRuntimeAvailable ?? assertEnclaveRuntimeAvailable; - await assertRuntimeAvailable(boundedAgents); + const assertPrimaryAvailable = deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable; + try { + await assertPrimaryAvailable(config.containerRuntime); + } catch (error) { + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'unavailable', + category: 'primary-runtime-unavailable', + })}`, + ); + throw error; + } + try { + await assertRuntimeAvailable(boundedAgents); + } catch (error) { + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: boundedAgents.runtime === 'sbx' ? 'blocked' : 'unavailable', + category: boundedAgents.runtime === 'sbx' ? 'enclave-security-block' : 'enclave-runtime-unavailable', + })}`, + ); + throw error; + } + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'supported', + category: 'ready', + })}`, + ); + + if (runtimeUsesComposeAgent(config.containerRuntime)) { + config.boundedAgentIngressTransport = 'unix'; + } else { + const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount; + config.boundedAgentIngressTransport = (await probe()) ? 'unix' : 'sbx-http'; + } const paths = resolveBoundedAgentPaths(config.workDir); assertBoundedAgentPrivateRootIsolated(config, paths, env); @@ -192,6 +280,9 @@ export async function prepareBoundedAgents( } prepareDirectories(paths); + if (config.boundedAgentIngressTransport === 'sbx-http') { + writeSbxIngressCapabilities(paths); + } const runId = generateBoundedAgentRunId(); const staging = await stageBoundedAgentSeeds({ @@ -313,4 +404,5 @@ export const boundedAgentManagerTestHelpers = { readRunId, removeOrphanEnclaveContainers, removePrivateState, + writeSbxIngressCapabilities, }; diff --git a/src/bounded-agent/paths.ts b/src/bounded-agent/paths.ts index a248ed3ec..34fc0afaa 100644 --- a/src/bounded-agent/paths.ts +++ b/src/bounded-agent/paths.ts @@ -52,6 +52,8 @@ export interface BoundedAgentPaths { skillPath: string; /** Host path of the agent-facing bounded-agent executable. */ wrapperPath: string; + /** Broker-private path containing ephemeral sbx ingress capabilities. */ + capabilityPath: string; } /** Broker-private state is deliberately outside the agent's broad `/tmp` mount. */ @@ -66,6 +68,9 @@ export const BOUNDED_AGENT_SKILL_FILENAME = 'SKILL.md'; /** Name of the generated agent-facing executable. */ export const BOUNDED_AGENT_WRAPPER_FILENAME = 'bounded-agent'; +/** Name of the broker-private sbx ingress capability file. */ +export const BOUNDED_AGENT_CAPABILITY_FILENAME = 'sbx-ingress.json'; + // ── Fixed container paths ──────────────────────────────────────────────────── // // These are part of the agent-visible contract (the wrapper and the generated @@ -152,6 +157,7 @@ export function resolveBoundedAgentPaths( socketPath: path.join(runDir, BOUNDED_AGENT_SOCKET_FILENAME), skillPath: path.join(agentDir, BOUNDED_AGENT_SKILL_FILENAME), wrapperPath: path.join(agentDir, BOUNDED_AGENT_WRAPPER_FILENAME), + capabilityPath: path.join(root, 'control', BOUNDED_AGENT_CAPABILITY_FILENAME), }; } diff --git a/src/bounded-agent/preflight.test.ts b/src/bounded-agent/preflight.test.ts index 217084a28..b49122f43 100644 --- a/src/bounded-agent/preflight.test.ts +++ b/src/bounded-agent/preflight.test.ts @@ -1,10 +1,12 @@ import { assertEnclaveRuntimeAvailable, + assertPrimaryRuntimeAvailable, resolveApiProxyRoute, validateBoundedAgentConfig, } from './preflight'; import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; import type { WrapperConfig } from '../types'; +import * as boundedQueryPreflight from '../bounded-query/preflight'; /** * Fail-closed preflight coverage. @@ -111,26 +113,36 @@ describe('validateBoundedAgentConfig', () => { ).toMatch(/bare owner\/repo slug/); }); - it('fails closed on the not-yet-implemented sbx backend with an explicit capability error', () => { - const errors = validateBoundedAgentConfig( - config({ boundedAgents: boundedAgents({ runtime: 'sbx' }) }), - env, - ); - expect(errors.join('\n')).toMatch(/"sbx" is not yet implemented/); - expect(errors.join('\n')).toMatch(/never downgrade/); + it('accepts sbx as a schema-level enclave runtime (capability-gated, not config-rejected)', () => { + // sbx is fully schema-accepted at the configuration level: whether it is + // actually usable is decided later by assertEnclaveRuntimeAvailable's + // capability proof, never by blanket config rejection. + expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime: 'sbx' }) }), env)) + .toEqual([]); }); - it('accepts the implemented docker and gvisor backends', () => { - for (const runtime of ['docker', 'gvisor'] as const) { + it('accepts every implemented and capability-gated backend', () => { + for (const runtime of ['docker', 'gvisor', 'sbx'] as const) { expect(validateBoundedAgentConfig(config({ boundedAgents: boundedAgents({ runtime }) }), env)) .toEqual([]); } }); - it('fails closed before staging for a primary microVM agent runtime', () => { - const errors = validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env); - expect(errors.join('\n')).toMatch(/require a Docker Compose primary agent/); - expect(errors.join('\n')).toMatch(/fails closed before staging/); + it('rejects an unknown enclave runtime name with no downgrade', () => { + const errors = validateBoundedAgentConfig( + config({ boundedAgents: boundedAgents({ runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] }) }), + env, + ); + expect(errors.join('\n')).toMatch(/"wasm" is not supported/); + expect(errors.join('\n')).toMatch(/never downgrade/); + }); + + it('no longer rejects a primary sbx microVM at the config-validation level', () => { + // The primary-agent runtime axis is proven independently by + // assertPrimaryRuntimeAvailable (delegated to bounded-query's + // implementation), not blanket-rejected here: a primary sbx microVM is + // supported once its bounded-agent ingress is proven (see ./ingress.ts). + expect(validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env)).toEqual([]); }); it('rejects exposing the enclave Docker daemon to the primary agent', () => { @@ -218,9 +230,52 @@ describe('assertEnclaveRuntimeAvailable', () => { ).rejects.toThrow(/never fall back to a weaker runtime/); }); - it('has no launcher for sbx', async () => { + it('accepts sbx when the capability probe reports full support', async () => { + const querySbxCapabilities = jest.fn(async () => ({ supported: true, missing: [], auditedVersion: '0.37.1' })); await expect( - assertEnclaveRuntimeAvailable(boundedAgents({ runtime: 'sbx' }), async () => true, async () => true), + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + querySbxCapabilities, + ), + ).resolves.toBeUndefined(); + expect(querySbxCapabilities).toHaveBeenCalledTimes(1); + }); + + it('blocks sbx with the exact missing capabilities and never falls back, honestly reflecting audited 0.37.1', async () => { + const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network']; + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + async () => ({ supported: false, missing, auditedVersion: '0.37.1' }), + ), + ).rejects.toThrow(/pinned AWF bounded-agent sbx template and bootstrap.*sbx create --network/); + await expect( + assertEnclaveRuntimeAvailable( + boundedAgents({ runtime: 'sbx' }), + async () => true, + async () => true, + async () => ({ supported: false, missing, auditedVersion: '0.37.1' }), + ), + ).rejects.toThrow(/never fall back to Docker or gVisor/); + }); + + it('rejects an unrecognized runtime with no implemented launcher', async () => { + await expect( + assertEnclaveRuntimeAvailable( + { ...boundedAgents(), runtime: 'wasm' as unknown as BoundedAgentsConfig['runtime'] }, + async () => true, + async () => true, + ), ).rejects.toThrow(/no implemented enclave launcher/); }); }); + +describe('assertPrimaryRuntimeAvailable', () => { + it('is the bounded-query implementation, reused rather than duplicated', () => { + expect(assertPrimaryRuntimeAvailable).toBe(boundedQueryPreflight.assertPrimaryRuntimeAvailable); + }); +}); diff --git a/src/bounded-agent/preflight.ts b/src/bounded-agent/preflight.ts index b44d1457b..f1a9b7947 100644 --- a/src/bounded-agent/preflight.ts +++ b/src/bounded-agent/preflight.ts @@ -9,28 +9,45 @@ import { MAX_TASK_BYTES, } from './protocol'; import { resolveStagingToken } from '../bounded-query/staging'; -import { runtimeUsesComposeAgent } from '../container-runtime'; +import { + assertPrimaryRuntimeAvailable as assertBoundedQueryPrimaryRuntimeAvailable, +} from '../bounded-query/preflight'; +import { + defaultBoundedAgentSbxCapabilityQuery, + type BoundedAgentSbxCapabilityQuery, +} from './sbx-capability'; /** * Fail-closed preflight for bounded agents. * * JSON Schema already constrains the *shape* of `boundedAgents`. This module * covers everything the schema cannot: credential availability, the mandatory - * API-proxy model route, sandbox runtime availability, and combinations of AWF - * settings under which a bounded agent cannot be exposed securely. + * API-proxy model route, sandbox runtime availability — for *both* the + * primary agent and the bounded-agent enclave, evaluated as independent + * matrix axes — and combinations of AWF settings under which a bounded agent + * cannot be exposed securely. * * Every check here is fatal. A bounded-agent run that cannot satisfy its - * isolation guarantees must abort before the primary agent starts rather than - * silently downgrading — in particular, an unavailable `runsc` never falls - * back to the default Docker runtime, and the not-yet-implemented `sbx` - * backend never falls back to Docker or gVisor. + * isolation guarantees must abort before the primary agent starts and before + * any repository is staged, rather than silently downgrading — in + * particular, an unavailable `runsc` never falls back to the default Docker + * runtime, and the `sbx` enclave backend never falls back to Docker or + * gVisor when its capability proof fails (which it always currently does; + * see `./sbx-capability.ts`). + * + * The *primary* agent runtime is a completely separate axis from the + * *enclave* runtime: a primary `sbx` microVM can be paired with a `docker` or + * `gvisor` bounded-agent enclave once the primary-sbx ingress is proven (see + * `./ingress.ts`), and a `docker`/`gvisor` primary can never be paired with a + * `sbx` enclave while sbx's capability report is incomplete. See + * `./runtime-matrix.ts` for the full evaluation of all nine combinations. */ /** Enclave runtimes with a safe, implemented launcher. */ const IMPLEMENTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor']); -/** Enclave runtimes the schema accepts but preflight blocks. */ -const BLOCKED_ENCLAVE_RUNTIMES = new Set(['sbx']); +/** Every enclave runtime the schema accepts, implemented or capability-gated. */ +const SUPPORTED_ENCLAVE_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']); /** Docker OCI runtime name required for the `gvisor` enclave runtime. */ const GVISOR_DOCKER_RUNTIME = 'runsc'; @@ -41,6 +58,9 @@ export type DockerRuntimeQuery = (runtimeName: string) => Promise; /** Detects whether the Docker daemon required by the enclave backend is reachable. */ export type DockerAvailabilityQuery = () => Promise; +/** Detects whether the sbx primary-agent runtime is installed and authenticated. */ +export type SbxAvailabilityQuery = () => Promise; + const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => { const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], { env: getLocalDockerEnv(), @@ -111,13 +131,10 @@ export function validateBoundedAgentConfig( const errors: string[] = []; - if (!runtimeUsesComposeAgent(config.containerRuntime)) { - errors.push( - `bounded agents require a Docker Compose primary agent, but container runtime ` + - `"${config.containerRuntime}" uses an external microVM agent. No audited bounded-agent ` + - 'ingress exists for that execution model, so AWF fails closed before staging.', - ); - } + // The primary-agent runtime (docker / gvisor / sbx) is validated as its own + // matrix axis by assertPrimaryRuntimeAvailable, not rejected here. A primary + // sbx microVM is supported once its bounded-agent ingress is proven (see + // ./ingress.ts and ./manager.ts). if (config.enableDind) { errors.push( @@ -148,15 +165,11 @@ export function validateBoundedAgentConfig( seenKeys.add(key); } - if (BLOCKED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) { + if (!SUPPORTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) { errors.push( - `boundedAgents.runtime "${boundedAgents.runtime}" is not yet implemented. AWF has no audited ` + - 'single-use, API-proxy-only enclave launcher for it, and bounded agents never downgrade to a ' + - 'weaker runtime. Use "docker" or "gvisor".', - ); - } else if (!IMPLEMENTED_ENCLAVE_RUNTIMES.has(boundedAgents.runtime)) { - errors.push( - `boundedAgents.runtime "${boundedAgents.runtime}" is not supported. Use "docker" or "gvisor".`, + `boundedAgents.runtime "${boundedAgents.runtime}" is not supported. ` + + 'AWF has no audited, single-use, API-proxy-only enclave launcher for it, and bounded agents ' + + 'never downgrade to a weaker runtime. Use "docker", "gvisor", or "sbx".', ); } @@ -227,8 +240,13 @@ export function validateBoundedAgentConfig( errors.push(`boundedAgents.cpuLimit "${boundedAgents.cpuLimit}" is not a positive Docker --cpus value`); } + // The broker/enclave subsystem always runs via Docker Compose (Squid and + // the API proxy are always compose services), independent of the primary + // agent's own runtime. sbx *enclave* runtime is exempted because the sbx + // enclave runner never mounts the Docker socket into the broker at all — + // see buildBoundedAgentService and containers/bounded-agent/broker/config.js. const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; - if (dockerHost && !dockerHost.startsWith('unix://')) { + if (boundedAgents.runtime !== 'sbx' && dockerHost && !dockerHost.startsWith('unix://')) { errors.push( `bounded agents require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` + 'The broker has no route to a TCP daemon and AWF will not weaken that isolation.', @@ -249,13 +267,17 @@ export function validateBoundedAgentConfig( * Verifies that the requested enclave runtime is actually available. * * Only reached after {@link validateBoundedAgentConfig} accepted the runtime - * name, so the only remaining question is daemon support. gVisor requires an - * exact `runsc` registration and is never downgraded. + * name, so the only remaining question is capability. gVisor requires an + * exact `runsc` registration and is never downgraded. The `sbx` enclave + * backend runs a full capability proof — {@link defaultBoundedAgentSbxCapabilityQuery} + * — and is blocked with the exact missing controls whenever the proof is + * incomplete, which it always currently is for audited sbx 0.37.1. */ export async function assertEnclaveRuntimeAvailable( boundedAgents: BoundedAgentsConfig, queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + querySbxCapabilities: BoundedAgentSbxCapabilityQuery = defaultBoundedAgentSbxCapabilityQuery, ): Promise { if (boundedAgents.runtime === 'gvisor') { if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { @@ -278,17 +300,41 @@ export async function assertEnclaveRuntimeAvailable( return; } + if (boundedAgents.runtime === 'sbx') { + const report = await querySbxCapabilities(); + if (!report.supported) { + throw new Error( + 'boundedAgents.runtime "sbx" is blocked because the installed sbx runtime cannot enforce all ' + + `mandatory enclave-isolation controls: ${report.missing.join(', ')}. ` + + 'AWF will not launch an enclave VM and will never fall back to Docker or gVisor.', + ); + } + return; + } + throw new Error( `boundedAgents.runtime "${boundedAgents.runtime}" has no implemented enclave launcher. ` + 'Bounded agents fail closed rather than downgrading to Docker or gVisor.', ); } +/** + * Verifies the primary-agent runtime before bounded-agent repository staging. + * + * The primary-agent runtime is bounded queries' own matrix axis + * (`docker` / `gvisor` / `sbx`, independent of `containerRuntime` capability + * flags elsewhere in AWF), so this delegates to the audited bounded-query + * implementation rather than restating it — the check is identical: does the + * primary runtime actually exist? Bounded agents and bounded queries can be + * enabled independently or together, and neither ever falls back. + */ +export const assertPrimaryRuntimeAvailable = assertBoundedQueryPrimaryRuntimeAvailable; + /** @internal Exported for focused unit tests. */ // ts-prune-ignore-next export const boundedAgentPreflightTestHelpers = { IMPLEMENTED_ENCLAVE_RUNTIMES, - BLOCKED_ENCLAVE_RUNTIMES, + SUPPORTED_ENCLAVE_RUNTIMES, GVISOR_DOCKER_RUNTIME, defaultDockerRuntimeQuery, defaultDockerAvailabilityQuery, diff --git a/src/bounded-agent/runtime-matrix.test.ts b/src/bounded-agent/runtime-matrix.test.ts new file mode 100644 index 000000000..de565febe --- /dev/null +++ b/src/bounded-agent/runtime-matrix.test.ts @@ -0,0 +1,330 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + BOUNDED_AGENT_RUNTIME_BACKENDS, + evaluateBoundedAgentRuntimeCombination, + evaluateBoundedAgentRuntimeMatrix, + resolveBoundedAgentPrimaryBackend, + serializeBoundedAgentRuntimeTelemetry, + type BoundedAgentPrimaryBackend, + type BoundedAgentRuntimeCapabilities, +} from './runtime-matrix'; +import type { BoundedAgentRuntime } from '../types'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { createBroker } = require(path.join(brokerDir, 'broker.js')); +const { createRuntimeTelemetry } = require(path.join(brokerDir, 'runtime-telemetry.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const CANONICAL_ERROR = '{"status":"error"}'; +const PRIMARY_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS; +const BOUNDED_AGENT_BACKENDS = BOUNDED_AGENT_RUNTIME_BACKENDS; + +/** + * The real-world capability state: every primary backend is available (once + * its own runtime preflight passes), docker and gvisor enclaves are + * available once their preflight passes, and the sbx enclave backend is + * always `blocked` — never `unavailable` — because the CLI/daemon exists but + * cannot prove the mandatory isolation controls (see ./sbx-capability.ts). + */ +const deterministicCapabilities: BoundedAgentRuntimeCapabilities = { + primary: { + docker: 'supported', + gvisor: 'supported', + sbx: 'supported', + }, + enclave: { + docker: 'supported', + gvisor: 'supported', + sbx: 'blocked', + }, +}; + +const combinations = PRIMARY_BACKENDS.flatMap((primaryBackend) => + BOUNDED_AGENT_BACKENDS.map((boundedAgentBackend) => ({ primaryBackend, boundedAgentBackend }))); +const executableCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) => + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported); +const blockedCombinations = combinations.filter(({ primaryBackend, boundedAgentBackend }) => + !evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities).supported); + +interface HarnessOptions { + maxInvocations?: number; + sensitivity?: 'public' | 'internal' | 'confidential'; + output?: string; + runnerResult?: { exitCode: number; timedOut: boolean }; + processingMs?: number; +} + +async function invoke( + broker: { handle: (request: unknown, respond: (json: string) => void) => Promise }, + request: unknown, +): Promise { + let response = ''; + await broker.handle(request, (json: string) => { + response = json; + }); + return response; +} + +function createHarness( + primaryBackend: BoundedAgentPrimaryBackend, + boundedAgentBackend: 'docker' | 'gvisor', + options: HarnessOptions = {}, +) { + const outputs = new Map(); + const launches: Array> = []; + const destroyed: string[] = []; + const telemetry: Array> = []; + let now = 0; + const sleeps: number[] = []; + const config = { + primaryBackend, + backend: boundedAgentBackend, + workDir: '/broker/private/work', + timeoutSeconds: 30, + maxInvocations: options.maxInvocations ?? 8, + maxTaskBytes: 4096, + }; + const workspace = { + createInvocationWorkspace: ({ + invocationId, + task, + }: { + invocationId: string; + task: string; + }) => { + expect(task).not.toMatch(/TOKEN|PASSWORD|docker\.sock|broker\/private/); + return { outPath: invocationId }; + }, + readEnclaveOutput: (outPath: string) => { + const output = outputs.get(outPath); + return output !== undefined && Buffer.byteLength(output) <= 8192 ? output : undefined; + }, + destroyInvocationWorkspace: (_workDir: string, invocationId: string) => { + destroyed.push(invocationId); + outputs.delete(invocationId); + }, + }; + const runner = { + runEnclaveContainer: async (params: Record) => { + launches.push(params); + now += options.processingMs ?? 0; + outputs.set(String(params.invocationId), options.output ?? 'true'); + return { + exitCode: options.runnerResult?.exitCode ?? 0, + timedOut: options.runnerResult?.timedOut ?? false, + }; + }, + }; + const broker = createBroker({ + config, + seedMap: new Map([ + ['octo/repo', { seedId: 'a'.repeat(32), sensitivity: options.sensitivity ?? 'internal' }], + ]), + runId: 'abcd1234', + audit: { invocation() {}, failure() {}, lifecycle() {} }, + telemetry: { emit: (event: Record) => telemetry.push(event) }, + workspace, + runner, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { + sleeps.push(ms); + now += ms; + }, + }, + }); + return { broker, destroyed, launches, sleeps, telemetry }; +} + +describe('bounded-agent runtime conformance matrix', () => { + it('contains every independent primary/boundedAgent combination exactly once', () => { + expect(combinations).toHaveLength(9); + expect(new Set(combinations.map(({ primaryBackend, boundedAgentBackend }) => + `${primaryBackend}/${boundedAgentBackend}`)).size).toBe(9); + expect(executableCombinations).toHaveLength(6); + expect(blockedCombinations).toHaveLength(3); + }); + + it('supports every primary backend paired with docker/gvisor bounded agents, and blocks sbx bounded agents everywhere', () => { + const readyPairs = new Set(executableCombinations.map( + ({ primaryBackend, boundedAgentBackend }) => `${primaryBackend}/${boundedAgentBackend}`, + )); + for (const primaryBackend of PRIMARY_BACKENDS) { + expect(readyPairs.has(`${primaryBackend}/docker`)).toBe(true); + expect(readyPairs.has(`${primaryBackend}/gvisor`)).toBe(true); + expect(readyPairs.has(`${primaryBackend}/sbx`)).toBe(false); + } + }); + + it.each(blockedCombinations)( + '$primaryBackend primary + $boundedAgentBackend bounded agent fails closed at enclave preflight', + ({ primaryBackend, boundedAgentBackend }) => { + const result = evaluateBoundedAgentRuntimeCombination( + primaryBackend, + boundedAgentBackend, + deterministicCapabilities, + ); + expect(result).toEqual({ + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: 'blocked', + blockedAt: 'enclave-preflight', + category: 'enclave-security-block', + }); + }, + ); + + it.each([ + ['gvisor', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['sbx', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['docker', 'gvisor', 'enclave-preflight', 'enclave-runtime-unavailable'], + ] as const)( + 'reports precise unavailable capability state for %s/%s', + (primaryBackend, boundedAgentBackend, blockedAt, category) => { + const capabilities: BoundedAgentRuntimeCapabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + enclave: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' }, + }; + expect(evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities)) + .toMatchObject({ supported: false, capabilityState: 'unavailable', blockedAt, category }); + }, + ); + + it('evaluates the full matrix via evaluateBoundedAgentRuntimeMatrix in the same order', () => { + const matrix = evaluateBoundedAgentRuntimeMatrix(deterministicCapabilities); + expect(matrix).toHaveLength(9); + expect(matrix).toEqual(combinations.map(({ primaryBackend, boundedAgentBackend }) => + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, deterministicCapabilities))); + }); + + it.each(executableCombinations)( + '$primaryBackend primary + $boundedAgentBackend bounded agent satisfies the common behavioral contract', + async ({ primaryBackend, boundedAgentBackend }) => { + if (boundedAgentBackend === 'sbx') throw new Error('blocked sbx bounded-agent combination entered executable suite'); + + const successful = createHarness(primaryBackend, boundedAgentBackend, { processingMs: 50 }); + expect(await invoke(successful.broker, { + privateRepo: 'octo/repo', + schema: { type: 'boolean' }, + task: 'is this finite?', + })).toBe('{"status":"ok","result":true}'); + expect(successful.launches).toHaveLength(1); + expect(successful.destroyed).toHaveLength(1); + expect(successful.sleeps).toEqual([50]); + expect(successful.telemetry).toContainEqual({ + primaryBackend, + boundedAgentBackend, + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'success', + }); + expect(successful.launches[0]).not.toHaveProperty('repo'); + expect(JSON.stringify(successful.launches[0])).not.toMatch(/TOKEN|PASSWORD|docker\.sock/); + + const wrongRepo = createHarness(primaryBackend, boundedAgentBackend); + expect(await invoke(wrongRepo.broker, { + privateRepo: 'octo/not-configured', + schema: { type: 'boolean' }, + task: 'must not launch', + })).toBe(CANONICAL_ERROR); + expect(wrongRepo.launches).toHaveLength(0); + + const capped = createHarness(primaryBackend, boundedAgentBackend, { maxInvocations: 1 }); + const request = { privateRepo: 'octo/repo', schema: { type: 'boolean' }, task: 'cap invocation' }; + expect(await invoke(capped.broker, request)).toBe('{"status":"ok","result":true}'); + expect(await invoke(capped.broker, request)).toBe(CANONICAL_ERROR); + expect(capped.launches).toHaveLength(1); + + for (const failure of [ + { output: '{malformed', runnerResult: undefined }, + { output: 'true', runnerResult: { exitCode: 137, timedOut: true } }, + { output: 'true', runnerResult: { exitCode: 1, timedOut: false } }, + ]) { + const failed = createHarness(primaryBackend, boundedAgentBackend, failure); + // eslint-disable-next-line no-await-in-loop + expect(await invoke(failed.broker, request)).toBe(CANONICAL_ERROR); + expect(failed.destroyed).toHaveLength(1); + } + }, + ); +}); + +describe('resolveBoundedAgentPrimaryBackend', () => { + it.each([ + [undefined, 'docker'], + ['docker', 'docker'], + ['gvisor', 'gvisor'], + ['runsc', 'gvisor'], + ['sbx', 'sbx'], + ['kata', 'docker'], + ] as const)('maps containerRuntime %s to primary backend %s', (containerRuntime, expected) => { + expect(resolveBoundedAgentPrimaryBackend(containerRuntime)).toBe(expected); + }); +}); + +describe('bounded-agent runtime telemetry', () => { + it('serializes only the five approved fields', () => { + const serialized = serializeBoundedAgentRuntimeTelemetry({ + primaryBackend: resolveBoundedAgentPrimaryBackend('runsc'), + boundedAgentBackend: 'docker' as BoundedAgentRuntime, + lifecycleClass: 'preflight', + capabilityState: 'supported', + category: 'ready', + }); + expect(JSON.parse(serialized)).toEqual({ + primaryBackend: 'gvisor', + boundedAgentBackend: 'docker', + lifecycleClass: 'preflight', + capabilityState: 'supported', + category: 'ready', + }); + }); + + it('persists exact-field records without content, paths, outputs, or credentials', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-')); + try { + const telemetry = createRuntimeTelemetry(root); + telemetry.emit({ + primaryBackend: 'sbx', + boundedAgentBackend: 'docker', + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'timeout', + repo: 'must-be-ignored', + task: 'must-be-ignored', + output: 'must-be-ignored', + path: '/must-be-ignored', + token: 'must-be-ignored', + capability: 'must-be-ignored', + }); + const record = JSON.parse(fs.readFileSync(path.join(root, 'runtime-telemetry.jsonl'), 'utf8')); + expect(Object.keys(record)).toEqual([ + 'primaryBackend', + 'boundedAgentBackend', + 'lifecycleClass', + 'capabilityState', + 'category', + ]); + expect(JSON.stringify(record)).not.toContain('must-be-ignored'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects an enclave-runtime value outside the fixed enum', () => { + const brokerRuntimeTelemetry = createRuntimeTelemetry( + fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-runtime-telemetry-invalid-')), + ); + expect(() => brokerRuntimeTelemetry.emit({ + primaryBackend: 'docker', + boundedAgentBackend: 'firecracker', + lifecycleClass: 'invocation', + capabilityState: 'supported', + category: 'success', + })).toThrow(/Invalid bounded-agent telemetry/); + }); +}); diff --git a/src/bounded-agent/runtime-matrix.ts b/src/bounded-agent/runtime-matrix.ts new file mode 100644 index 000000000..59db0b879 --- /dev/null +++ b/src/bounded-agent/runtime-matrix.ts @@ -0,0 +1,116 @@ +import type { BoundedAgentRuntime } from '../types'; + +export const BOUNDED_AGENT_RUNTIME_BACKENDS = ['docker', 'gvisor', 'sbx'] as const; + +export type BoundedAgentPrimaryBackend = (typeof BOUNDED_AGENT_RUNTIME_BACKENDS)[number]; +export type BoundedAgentCapabilityState = 'supported' | 'unavailable' | 'blocked'; + +export interface BoundedAgentRuntimeCapabilities { + primary: Readonly>; + enclave: Readonly>; +} + +export interface BoundedAgentRuntimeCombination { + primaryBackend: BoundedAgentPrimaryBackend; + boundedAgentBackend: BoundedAgentRuntime; + supported: boolean; + capabilityState: BoundedAgentCapabilityState; + blockedAt?: 'primary-preflight' | 'enclave-preflight'; + category: 'ready' | 'primary-runtime-unavailable' | 'enclave-runtime-unavailable' | 'enclave-security-block'; +} + +export interface BoundedAgentRuntimeTelemetry { + primaryBackend: BoundedAgentPrimaryBackend; + boundedAgentBackend: BoundedAgentRuntime; + lifecycleClass: 'preflight' | 'startup' | 'invocation' | 'cleanup'; + capabilityState: BoundedAgentCapabilityState; + category: string; +} + +/** Maps AWF's execution setting to the independent primary-agent matrix axis. */ +export function resolveBoundedAgentPrimaryBackend( + containerRuntime: string | undefined, +): BoundedAgentPrimaryBackend { + if (containerRuntime === 'gvisor' || containerRuntime === 'runsc') return 'gvisor'; + if (containerRuntime === 'sbx') return 'sbx'; + return 'docker'; +} + +/** + * Evaluates one primary/enclave pair without fallback. + * + * Primary availability is checked first because the primary agent cannot be + * started without it. Enclave availability is then checked before any + * repository staging. A blocked enclave capability is distinct from an + * unavailable binary: it means the runtime exists but cannot enforce AWF's + * mandatory isolation and API-proxy-only network controls. + * + * All nine (primary x boundedAgent) combinations are evaluated independently: + * a supported primary backend never implies a supported enclave backend, and + * vice versa. + */ +export function evaluateBoundedAgentRuntimeCombination( + primaryBackend: BoundedAgentPrimaryBackend, + boundedAgentBackend: BoundedAgentRuntime, + capabilities: BoundedAgentRuntimeCapabilities, +): BoundedAgentRuntimeCombination { + const primaryState = capabilities.primary[primaryBackend]; + if (primaryState !== 'supported') { + return { + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: primaryState, + blockedAt: 'primary-preflight', + category: 'primary-runtime-unavailable', + }; + } + + const enclaveState = capabilities.enclave[boundedAgentBackend]; + if (enclaveState !== 'supported') { + return { + primaryBackend, + boundedAgentBackend, + supported: false, + capabilityState: enclaveState, + blockedAt: 'enclave-preflight', + category: enclaveState === 'blocked' ? 'enclave-security-block' : 'enclave-runtime-unavailable', + }; + } + + return { + primaryBackend, + boundedAgentBackend, + supported: true, + capabilityState: 'supported', + category: 'ready', + }; +} + +/** Evaluates every (primary x boundedAgent) combination independently. */ +export function evaluateBoundedAgentRuntimeMatrix( + capabilities: BoundedAgentRuntimeCapabilities, +): BoundedAgentRuntimeCombination[] { + const combinations: BoundedAgentRuntimeCombination[] = []; + for (const primaryBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) { + for (const boundedAgentBackend of BOUNDED_AGENT_RUNTIME_BACKENDS) { + combinations.push( + evaluateBoundedAgentRuntimeCombination(primaryBackend, boundedAgentBackend, capabilities), + ); + } + } + return combinations; +} + +/** Serializes the intentionally narrow, path- and content-free telemetry shape. */ +export function serializeBoundedAgentRuntimeTelemetry( + event: BoundedAgentRuntimeTelemetry, +): string { + return JSON.stringify({ + primaryBackend: event.primaryBackend, + boundedAgentBackend: event.boundedAgentBackend, + lifecycleClass: event.lifecycleClass, + capabilityState: event.capabilityState, + category: event.category, + }); +} diff --git a/src/bounded-agent/sbx-capability.test.ts b/src/bounded-agent/sbx-capability.test.ts new file mode 100644 index 000000000..26d51f041 --- /dev/null +++ b/src/bounded-agent/sbx-capability.test.ts @@ -0,0 +1,156 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- container-side broker + module is loaded at runtime for a byte-for-byte cross-check; it is a plain + .js file with no TS types, so `require()` is the correct (and only) way to + pull it in, matching the pattern used by src/bounded-query/*.test.ts. */ +import execa from 'execa'; +import path from 'path'; +import { + boundedAgentSbxCapabilityTestHelpers as helpers, + defaultBoundedAgentSbxCapabilityQuery, +} from './sbx-capability'; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +const mockExeca = execa as unknown as jest.Mock; + +/** + * Host-side capability probe coverage for the bounded-agent sbx enclave + * backend. + * + * This backend has a strictly harder network requirement than bounded + * queries: an enclave must reach exactly one peer (the API proxy), not + * "no network at all". So `missing` always includes the pinned-template and + * lateral-peer-denial entries regardless of what flags are detected — the + * probe can never report `supported: true` for the currently audited sbx + * 0.37.1 CLI, by design. + */ +describe('defaultBoundedAgentSbxCapabilityQuery', () => { + beforeEach(() => { + mockExeca.mockReset(); + }); + + it('never reports supported even when every flag is present, because the network primitive is unverifiable', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) // version + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) // ls (daemon reachability) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target', + }) // create --help + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); // exec --help + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + + expect(report.supported).toBe(false); + expect(report.version).toBe('0.37.1'); + expect(report.auditedVersion).toBe('0.37.1'); + expect(report.missing).toContain('pinned AWF bounded-agent sbx template and bootstrap'); + expect(report.missing).toContain( + 'sbx named-network attach with mandatory lateral-peer denial to enforce API-proxy-only egress ' + + '(hard network-policy / capability-token ingress primitive)', + ); + // Every enumerated flag was detected, so nothing else should be missing. + expect(report.missing).not.toContain('sbx create --network'); + expect(report.missing).not.toContain('authenticated sbx CLI/daemon'); + }); + + it('reports every missing lifecycle/resource flag when help output lacks them', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + + expect(report.supported).toBe(false); + expect(report.missing).toEqual(expect.arrayContaining([ + 'pinned AWF bounded-agent sbx template and bootstrap', + 'sbx create --pids-limit', + 'sbx create --disk-limit', + 'sbx create --ulimit-fsize', + 'sbx create --mount-target', + ])); + }); + + it('reports an unsupported audited version distinctly from missing flags', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.99.0' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: '--name --cpus --memory --template --pids-limit --disk-limit --ulimit-fsize --mount-target', + }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report.missing).toContain('audited sbx version 0.37.1 (found 0.99.0)'); + }); + + it('reports an unauthenticated or unreachable daemon', async () => { + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '' }) // ls fails: daemon unreachable/unauthenticated + .mockResolvedValueOnce({ exitCode: 0, stdout: '--name --cpus --memory --template' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '--user --workdir' }); + + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report.missing).toContain('authenticated sbx CLI/daemon'); + }); + + it('fails closed when the sbx binary is entirely absent', async () => { + mockExeca.mockRejectedValue(new Error('spawn sbx ENOENT')); + const report = await defaultBoundedAgentSbxCapabilityQuery(); + expect(report).toEqual({ + supported: false, + auditedVersion: '0.37.1', + missing: ['authenticated sbx CLI/daemon'], + }); + }); + + it('never uses request-scoped or credential-bearing environment beyond the process env', async () => { + mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); + await defaultBoundedAgentSbxCapabilityQuery(); + for (const call of mockExeca.mock.calls) { + const options = call[2] as { env?: Record } | undefined; + expect(options?.env).not.toHaveProperty('DOCKER_SANDBOXES_PROXY'); + expect(options?.env).not.toHaveProperty('XDG_CONFIG_HOME'); + } + }); + + it('keeps the required-flag lists byte-for-byte aligned with the container-side probe', () => { + const containerProbe = require(path.join( + __dirname, + '..', + '..', + 'containers', + 'bounded-agent', + 'broker', + 'sbx-capability-probe.js', + )); + expect(helpers.SBX_AUDITED_VERSION).toBe(containerProbe.AUDITED_SBX_VERSION); + // The host-side probe collapses REQUIRED_CREATE_FLAGS and + // REQUIRED_HARD_ISOLATION_FLAGS into one list (it stops before staging + // rather than launching, so it has no reason to distinguish lifecycle + // flags from hard-isolation flags), except `--network`: host-side never + // treats its presence as informative, because the unconditional + // lateral-peer-denial entry already reports the network requirement + // missing regardless of flag detection — checking the flag too would + // only invite a false sense of partial progress. + const containerHardIsolationWithoutNetwork = containerProbe.REQUIRED_HARD_ISOLATION_FLAGS + .filter((flag: string) => flag !== '--network'); + expect(new Set(helpers.SBX_REQUIRED_CREATE_FLAGS)).toEqual(new Set([ + ...containerProbe.REQUIRED_CREATE_FLAGS, + ...containerHardIsolationWithoutNetwork, + ])); + expect(helpers.SBX_REQUIRED_EXEC_FLAGS).toEqual(containerProbe.REQUIRED_EXEC_FLAGS); + }); +}); + +describe('helpIncludesFlag', () => { + it('matches a flag as a standalone token, not a substring of another flag', () => { + expect(helpers.helpIncludesFlag('--network, --network-mode', '--network')).toBe(true); + expect(helpers.helpIncludesFlag('--network-mode', '--network')).toBe(false); + expect(helpers.helpIncludesFlag(' --cpus= Number of vCPUs', '--cpus')).toBe(true); + expect(helpers.helpIncludesFlag('no matching flags here', '--cpus')).toBe(false); + }); +}); diff --git a/src/bounded-agent/sbx-capability.ts b/src/bounded-agent/sbx-capability.ts new file mode 100644 index 000000000..780fab629 --- /dev/null +++ b/src/bounded-agent/sbx-capability.ts @@ -0,0 +1,134 @@ +import execa from 'execa'; + +/** + * Host-side capability probe for the bounded-agent `sbx` enclave runtime. + * + * This is deliberately its own module (not a re-export of the bounded-query + * probe) because bounded agents have a strictly harder requirement: a bounded + * *query* sandbox needs `--network=none` (no egress at all), while a bounded + * *agent* enclave must reach exactly one peer — the dedicated, API-proxy-only + * enclave network — and nothing else. sbx has no primitive that can attach a + * sandbox to a named Docker network while also enforcing that no other peer + * on that network (or the internet) is reachable, so that requirement is + * always reported missing below rather than inferred from a flag that would + * only prove the weaker no-network case. + */ + +const SBX_AUDITED_VERSION = '0.37.1'; + +/** Flags proven by `sbx create --help` inspection. */ +const SBX_REQUIRED_CREATE_FLAGS = [ + '--cpus', + '--memory', + '--name', + '--template', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +] as const; + +/** Flags proven by `sbx exec --help` inspection. */ +const SBX_REQUIRED_EXEC_FLAGS = ['--user', '--workdir'] as const; + +export interface BoundedAgentSbxCapabilityReport { + supported: boolean; + version?: string; + auditedVersion: string; + missing: string[]; +} + +/** Executes the minimum host-side capability proof for the sbx enclave backend. */ +export type BoundedAgentSbxCapabilityQuery = () => Promise; + +function helpIncludesFlag(help: string, flag: string): boolean { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help); +} + +/** + * Probes the installed `sbx` CLI for every capability the bounded-agent + * enclave requires: lifecycle (create/exec/stop/rm), read-only targeted + * mounts, unprivileged exec identity/workdir, resource and storage limits, + * and — the category current sbx cannot satisfy — a hard, API-proxy-only + * network-isolation primitive with mandatory lateral-peer denial. + * + * Help/version output alone never marks the runtime supported: every + * unconditional architectural gap below is always reported so a future sbx + * release cannot be silently treated as capable of this feature by CLI-flag + * drift alone. + */ +export const defaultBoundedAgentSbxCapabilityQuery: BoundedAgentSbxCapabilityQuery = async () => { + const managementEnv = { ...process.env }; + delete managementEnv.DOCKER_SANDBOXES_PROXY; + delete managementEnv.XDG_CONFIG_HOME; + + const run = async (args: string[]): Promise<{ exitCode: number; stdout: string }> => { + const result = await execa('sbx', args, { + reject: false, + timeout: 10_000, + env: managementEnv, + }); + return { exitCode: result.exitCode ?? 1, stdout: result.stdout }; + }; + + let versionResult: { exitCode: number; stdout: string }; + let daemonResult: { exitCode: number; stdout: string }; + let createHelp: { exitCode: number; stdout: string }; + let execHelp: { exitCode: number; stdout: string }; + try { + [versionResult, daemonResult, createHelp, execHelp] = await Promise.all([ + run(['version']), + // sbx has no auth-status command; listing is authenticated and non-mutating. + run(['ls']), + run(['create', '--help']), + run(['exec', '--help']), + ]); + } catch { + return { + supported: false, + auditedVersion: SBX_AUDITED_VERSION, + missing: ['authenticated sbx CLI/daemon'], + }; + } + + const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout)?.[1]; + const missing: string[] = [ + // AWF has not published the immutable, AWF-authored enclave template and + // bootstrap for sbx because current sbx cannot yet enforce the network + // primitive below — publishing one would imply a false capability claim. + 'pinned AWF bounded-agent sbx template and bootstrap', + // sbx v0.37.1 has no primitive that attaches a sandbox to a named network + // while denying every peer except one configured endpoint. Local + // HTTP_PROXY / org-level network policy is advisory, not a hard control, + // and organization governance can replace it — so it never counts here. + 'sbx named-network attach with mandatory lateral-peer denial to enforce ' + + 'API-proxy-only egress (hard network-policy / capability-token ingress primitive)', + ]; + if (versionResult.exitCode !== 0 || !version || daemonResult.exitCode !== 0) { + missing.push('authenticated sbx CLI/daemon'); + } + if (version && version !== SBX_AUDITED_VERSION) { + missing.push(`audited sbx version ${SBX_AUDITED_VERSION} (found ${version})`); + } + for (const flag of SBX_REQUIRED_CREATE_FLAGS) { + if (createHelp.exitCode !== 0 || !helpIncludesFlag(createHelp.stdout, flag)) { + missing.push(`sbx create ${flag}`); + } + } + for (const flag of SBX_REQUIRED_EXEC_FLAGS) { + if (execHelp.exitCode !== 0 || !helpIncludesFlag(execHelp.stdout, flag)) { + missing.push(`sbx exec ${flag}`); + } + } + return { supported: missing.length === 0, version, auditedVersion: SBX_AUDITED_VERSION, missing }; +}; + +/** @internal Exported for focused unit tests. */ +// ts-prune-ignore-next +export const boundedAgentSbxCapabilityTestHelpers = { + SBX_AUDITED_VERSION, + SBX_REQUIRED_CREATE_FLAGS, + SBX_REQUIRED_EXEC_FLAGS, + helpIncludesFlag, +}; diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 3d5b09e6a..94fba7b79 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -19,6 +19,7 @@ jest.mock('./signal-handler'); jest.mock('./validate-options'); jest.mock('../sbx-manager'); jest.mock('../bounded-query/ingress'); +jest.mock('../bounded-agent/ingress'); import { logger } from '../logger'; import * as dockerManager from '../docker-manager'; @@ -33,6 +34,7 @@ import * as signalHandler from './signal-handler'; import * as validateOptions from './validate-options'; import * as sbxManager from '../sbx-manager'; import * as boundedQueryIngress from '../bounded-query/ingress'; +import * as boundedAgentIngress from '../bounded-agent/ingress'; import { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils'; const { @@ -56,6 +58,7 @@ const mockedSignalHandler = signalHandler as jest.Mocked; const mockedValidateOptions = validateOptions as jest.Mocked; const mockedSbxManager = sbxManager as jest.Mocked; const mockedBoundedQueryIngress = boundedQueryIngress as jest.Mocked; +const mockedBoundedAgentIngress = boundedAgentIngress as jest.Mocked; describe('createMainAction', () => { let processExitSpy: jest.SpyInstance; @@ -395,6 +398,81 @@ describe('createMainAction', () => { expect(JSON.stringify(logCalls)).not.toContain(capability); expect(mockedBoundedQueryIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); }); + + it('mounts only bounded-agent agent artifacts and injects the HTTP capability without logging it', async () => { + const capability = 'c'.repeat(64); + const sbxConfig = { + ...MAIN_ACTION_STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + enableApiProxy: true, + boundedAgentIngressTransport: 'sbx-http', + boundedAgents: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + profile: 'openai', + model: 'gpt-4o-mini', + timeout: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, + maxOutputBytes: 8192, + maxTaskBytes: 4096, + }, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({ + endpoint: 'http://host.docker.internal:49153/query', + queryCapability: capability, + probeCapability: 'd'.repeat(64), + skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md', + wrapperDir: '/var/tmp/bounded-agent-ingress/skill', + }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['bounded-agent --repo octo/private'], {}); + + const createOptions = mockedSbxManager.createSandbox.mock.calls[0][0]; + const mounts = createOptions.extraMounts ?? []; + expect(mounts).toHaveLength(1); + expect(mounts[0]).toMatch(/awf-bounded-agent-ingress-.*\/skill:ro$/); + expect(mounts.join(' ')).not.toMatch(/seeds|work|control|audit|docker\.sock|seed-map/); + + expect(mockedSbxManager.assertSbxBoundedAgentIngress).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + transport: 'sbx-http', + endpoint: 'http://host.docker.internal:49153/query', + probeCapability: 'd'.repeat(64), + }), + expect.any(Object), + '/workspace', + ); + const execCalls = mockedSbxManager.execInSandbox.mock.calls; + const agentEnvironment = execCalls[execCalls.length - 1]?.[2]?.environment; + expect(agentEnvironment).toEqual(expect.objectContaining({ + AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:49153/query', + AWF_BOUNDED_AGENT_CAPABILITY: capability, + AWF_BOUNDED_AGENT_BIN_DIR: expect.stringMatching(/\/skill$/), + })); + const logCalls = [ + ...mockedLogger.debug.mock.calls, + ...mockedLogger.info.mock.calls, + ...mockedLogger.warn.mock.calls, + ...mockedLogger.error.mock.calls, + ]; + expect(JSON.stringify(logCalls)).not.toContain(capability); + expect(mockedBoundedAgentIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); + }); }); }); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index ec4cc32af..a3c88b718 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -30,6 +30,7 @@ import { runtimeUsesComposeAgent } from '../container-runtime'; import { assertSbxApiProxyReflect, assertSbxBoundedQueryIngress, + assertSbxBoundedAgentIngress, createSandbox, execInSandbox, removeSandbox, @@ -48,6 +49,11 @@ import { resolveSbxIngress, } from '../bounded-query/ingress'; import { resolveBoundedQueryPaths } from '../bounded-query/paths'; +import { + removeSbxIngressCapabilityFile as removeBoundedAgentSbxIngressCapabilityFile, + resolveSbxIngress as resolveBoundedAgentSbxIngress, +} from '../bounded-agent/ingress'; +import { resolveBoundedAgentPaths } from '../bounded-agent/paths'; /** Report whether a secret is set (and its length) without exposing the value. */ function redactSecret(value: string | undefined): string { @@ -300,6 +306,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { const SBX_GATEWAY_IP = '172.17.0.0'; const SBX_HOST_DOCKER_INTERNAL = 'host.docker.internal'; const boundedQueryPaths = resolveBoundedQueryPaths(config.workDir); + const boundedAgentPaths = resolveBoundedAgentPaths(config.workDir); const sbxMounts = [...(config.volumeMounts ?? [])]; let sbxBoundedQueryIngress: | { transport: 'unix'; socketPath: string } @@ -310,6 +317,15 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { probeCapability: string; } | undefined; + let sbxBoundedAgentIngress: + | { transport: 'unix'; socketPath: string } + | { + transport: 'sbx-http'; + endpoint: string; + queryCapability: string; + probeCapability: string; + } + | undefined; if (config.boundedQueries?.enabled) { sbxMounts.push(`${boundedQueryPaths.agentDir}:ro`); @@ -330,6 +346,25 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } } + if (config.boundedAgents?.enabled) { + sbxMounts.push(`${boundedAgentPaths.agentDir}:ro`); + if (config.boundedAgentIngressTransport === 'unix') { + sbxMounts.push(`${boundedAgentPaths.runDir}:ro`); + sbxBoundedAgentIngress = { + transport: 'unix', + socketPath: boundedAgentPaths.socketPath, + }; + } else { + const ingress = await resolveBoundedAgentSbxIngress(config); + sbxBoundedAgentIngress = { + transport: 'sbx-http', + endpoint: ingress.endpoint, + queryCapability: ingress.queryCapability, + probeCapability: ingress.probeCapability, + }; + } + } + sbxEnvironment = buildAgentEnvironment({ config, networkConfig: { @@ -408,6 +443,38 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } } + if (sbxBoundedAgentIngress) { + await assertSbxBoundedAgentIngress( + sbxName, + sbxBoundedAgentIngress.transport === 'unix' + ? sbxBoundedAgentIngress + : { + transport: 'sbx-http', + endpoint: sbxBoundedAgentIngress.endpoint, + probeCapability: sbxBoundedAgentIngress.probeCapability, + }, + sbxEnvironment, + config.containerWorkDir, + ); + + Object.assign(sbxEnvironment, { + AWF_BOUNDED_AGENT_SKILL: boundedAgentPaths.skillPath, + AWF_BOUNDED_AGENT_REPOS: config.boundedAgents!.privateRepos + .map((repository) => repository.repo) + .join(','), + AWF_BOUNDED_AGENT_BIN_DIR: boundedAgentPaths.agentDir, + ...(sbxBoundedAgentIngress.transport === 'unix' + ? { AWF_BOUNDED_AGENT_SOCKET: sbxBoundedAgentIngress.socketPath } + : { + AWF_BOUNDED_AGENT_ENDPOINT: sbxBoundedAgentIngress.endpoint, + AWF_BOUNDED_AGENT_CAPABILITY: sbxBoundedAgentIngress.queryCapability, + }), + }); + if (sbxBoundedAgentIngress.transport === 'sbx-http') { + removeBoundedAgentSbxIngressCapabilityFile(config); + } + } + // gh-aw fetches reflection data from the fixed api-proxy hostname. The // microVM reaches the sidecar through its published host ports, so install // that alias and prove the real endpoint before launching the agent. diff --git a/src/compose-generator.ts b/src/compose-generator.ts index d864c82ca..8d4041b35 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -14,6 +14,7 @@ import { runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runt import { API_PROXY_PORTS } from './types/ports'; import { EXTERNAL_BRIDGE_NAME } from './config/network-policy'; import { BOUNDED_QUERY_INGRESS_NETWORK } from './bounded-query/ingress'; +import { BOUNDED_AGENT_INGRESS_NETWORK } from './bounded-agent/ingress'; import { BOUNDED_AGENT_EGRESS_NETWORK, BOUNDED_AGENT_NETWORK, @@ -216,6 +217,19 @@ export function generateDockerCompose( name: BOUNDED_AGENT_EGRESS_NETWORK, driver: 'bridge', }; + if ( + config.boundedAgentIngressTransport === 'sbx-http' + || (config.boundedAgentIngressTransport === undefined && !includeAgent) + ) { + // Distinct from BOUNDED_AGENT_NETWORK (the enclave/API-proxy network): + // this is a dedicated `internal` bridge whose only members are the + // broker and, transiently, the primary sbx microVM's host-gateway + // route — never an enclave, never the primary agent's own network. + compose.networks[BOUNDED_AGENT_INGRESS_NETWORK] = { + driver: 'bridge', + internal: true, + }; + } } return compose; } diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index 9e2a33051..5ec49b6d6 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -1,5 +1,6 @@ import { assertSbxApiProxyReflect, + assertSbxBoundedAgentIngress, assertSbxBoundedQueryIngress, createSandbox, execInSandbox, @@ -212,6 +213,90 @@ describe('sbx-manager', () => { })); }); + it('proves sbx-http ingress with a capability-authenticated HTTP exchange', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedQueryIngress( + 'awf-agent-test', + { transport: 'sbx-http', endpoint: 'http://host.docker.internal:49152/query', probeCapability: 'p'.repeat(64) }, + {}, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('$AWF_BOUNDED_QUERY_ENDPOINT'); + expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_QUERY_PROBE_CAPABILITY'); + expect(command).not.toContain('p'.repeat(64)); + }); + + it('fails closed when the probe exchange does not return the canonical error body', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedQueryIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/broker.sock' }, + {}, + )).rejects.toThrow(/sbx host does not support the selected bounded-query unix ingress/); + }); + + describe('assertSbxBoundedAgentIngress', () => { + it('proves Unix ingress with an HTTP exchange over the mounted socket, using its own env vars', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' }, + {}, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('--unix-socket "$AWF_BOUNDED_AGENT_SOCKET"'); + expect(command).toContain('http://localhost/query'); + expect(command).toContain('{"status":"error"}'); + // Must never reuse the bounded-query env var names. + expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET'); + }); + + it('proves sbx-http ingress with a capability-authenticated HTTP exchange, distinct from bounded queries', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + const environment: Record = {}; + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { + transport: 'sbx-http', + endpoint: 'http://host.docker.internal:49153/query', + probeCapability: 'q'.repeat(64), + }, + environment, + '/workspace', + )).resolves.toBeUndefined(); + + const args: string[] = mockExecaFn.mock.calls[0][1]; + const command = args[args.length - 1]; + expect(command).toContain('$AWF_BOUNDED_AGENT_ENDPOINT'); + expect(command).toContain('X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY'); + expect(command).not.toContain('AWF_BOUNDED_QUERY_SOCKET'); + expect(command).not.toContain('AWF_BOUNDED_QUERY_ENDPOINT'); + // The capability value itself is passed only via the execInSandbox + // environment map, never inlined into the shell command string. + expect(command).not.toContain('q'.repeat(64)); + }); + + it('fails closed when the bounded-agent probe exchange fails, with a distinct error message', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedAgentIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/bounded-agent-broker.sock' }, + {}, + )).rejects.toThrow(/sbx host does not support the selected bounded-agent unix ingress/); + }); + }); + describe('assertSbxApiProxyReflect', () => { it('installs a resolver alias and probes the reflection endpoint with Node fetch', async () => { mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); @@ -702,7 +787,7 @@ describe('sbx-manager', () => { expect(args).toContain('-lc'); const shellCommand = args[args.length - 1]; expect(shellCommand).toBe( - 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', ); expect(shellCommand.indexOf('.local/bin')).toBeLessThan( shellCommand.indexOf('copilot --version'), @@ -722,7 +807,7 @@ describe('sbx-manager', () => { describe('withLocalBinOnPath', () => { it('prepends ~/.local/bin using the runtime $HOME', () => { expect(withLocalBinOnPath('copilot')).toBe( - 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot', ); }); diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index dff0b2ef1..0e0866bde 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -366,9 +366,15 @@ export async function createSandbox(config: { * installed rootless to ~/.local/bin (install_copilot_cli.sh --rootless) stays * resolvable by name. `$HOME` resolves to the injected HOME (getRealUserHome), * which matches the wholesale-mounted home tool dirs. + * + * Also prepends the bounded-query and/or bounded-agent wrapper directories + * (`AWF_BOUNDED_QUERY_BIN_DIR` / `AWF_BOUNDED_AGENT_BIN_DIR`) when those + * subsystems are enabled, since a primary sbx microVM has no `/tmp/awf-lib` + * chroot-relative PATH entry to fall back on. Either variable is empty (and + * therefore a no-op) unless its subsystem is enabled for this run. */ function withLocalBinOnPath(command: string): string { - return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; + return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}\${AWF_BOUNDED_AGENT_BIN_DIR:+$AWF_BOUNDED_AGENT_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; } /** @internal Exposed for unit tests only. */ @@ -581,6 +587,57 @@ export async function assertSbxBoundedQueryIngress( } } +/** + * Proves the selected bounded-agent broker ingress is reachable from the + * actual primary sandbox before the agent command starts. Mirrors + * {@link assertSbxBoundedQueryIngress} exactly (same `/query` route and + * canonical `{"status":"error"}` body), against the bounded-agent broker's + * own env var names and capability. The HTTP probe uses a separate one-shot + * capability distinct from the query capability that is injected only after + * this proof succeeds. + */ +export async function assertSbxBoundedAgentIngress( + name: string, + ingress: + | { transport: 'unix'; socketPath: string } + | { transport: 'sbx-http'; endpoint: string; probeCapability: string }, + environment: Record, + workDir?: string, +): Promise { + const probeEnvironment = { ...environment }; + let command: string; + if (ingress.transport === 'unix') { + probeEnvironment.AWF_BOUNDED_AGENT_SOCKET = ingress.socketPath; + command = [ + 'response=$(curl --silent --show-error --max-time 15 --unix-socket "$AWF_BOUNDED_AGENT_SOCKET"', + '-X POST -H "Expect:"', + 'http://localhost/query 2>/dev/null) &&', + '[ "$response" = \'{"status":"error"}\' ]', + ].join(' '); + } else { + probeEnvironment.AWF_BOUNDED_AGENT_ENDPOINT = ingress.endpoint; + probeEnvironment.AWF_BOUNDED_AGENT_PROBE_CAPABILITY = ingress.probeCapability; + command = [ + 'response=$(curl --silent --show-error --noproxy "*" --max-time 15', + '-X POST -H "Expect:"', + '-H "X-AWF-Capability: $AWF_BOUNDED_AGENT_PROBE_CAPABILITY"', + '"$AWF_BOUNDED_AGENT_ENDPOINT" 2>/dev/null) &&', + '[ "$response" = \'{"status":"error"}\' ]', + ].join(' '); + } + + const result = await execInSandbox(name, command, { + timeoutMinutes: 1, + workDir, + environment: probeEnvironment, + }); + if (result.exitCode !== 0) { + throw new Error( + `sbx host does not support the selected bounded-agent ${ingress.transport} ingress`, + ); + } +} + /** * Adds a resolver alias for the published API proxy and proves that the * hard-coded gh-aw reflection endpoint is reachable before the agent starts. diff --git a/src/services/bounded-agent-service.test.ts b/src/services/bounded-agent-service.test.ts index 8ecd9ea68..a605b6728 100644 --- a/src/services/bounded-agent-service.test.ts +++ b/src/services/bounded-agent-service.test.ts @@ -257,7 +257,7 @@ describe('buildBoundedAgentService guards', () => { ).toThrow(/must be enabled/); }); - it('fails closed for the not-yet-implemented sbx backend', () => { + it('fails closed for boundedAgents.runtime "sbx" because current sbx cannot prove mandatory isolation controls', () => { expect(() => buildBoundedAgentService({ config: { @@ -268,7 +268,7 @@ describe('buildBoundedAgentService guards', () => { imageConfig, networkConfig, }), - ).toThrow(/sbx bounded-agent backend is not implemented/); + ).toThrow(/boundedAgents\.runtime "sbx" is capability-blocked/); }); it('refuses to wire an enclave with no API proxy to talk to', () => { diff --git a/src/services/bounded-agent-service.ts b/src/services/bounded-agent-service.ts index 78e42e46b..b5d3f1b69 100644 --- a/src/services/bounded-agent-service.ts +++ b/src/services/bounded-agent-service.ts @@ -28,6 +28,13 @@ import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; import type { ImageBuildConfig, NetworkConfig } from './squid-service'; import { buildApiProxyServiceConfig } from './api-proxy-service-config'; +import { resolveDockerHostGateway } from './host-gateway'; +import { + BOUNDED_AGENT_INGRESS_NETWORK, + BOUNDED_AGENT_TCP_PORT, +} from '../bounded-agent/ingress'; +import { resolveBoundedAgentPrimaryBackend } from '../bounded-agent/runtime-matrix'; +import { runtimeUsesComposeAgent } from '../container-runtime'; import { ANTHROPIC_ENV, COPILOT_ENV, @@ -162,8 +169,10 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou } if (boundedAgents.runtime === 'sbx') { throw new Error( - 'buildBoundedAgentService: the sbx bounded-agent backend is not implemented; bounded agents ' + - 'fail closed rather than downgrading to a Docker or gVisor enclave', + 'buildBoundedAgentService: boundedAgents.runtime "sbx" is capability-blocked — the installed sbx ' + + 'runtime cannot yet prove all mandatory enclave-isolation controls (see assertEnclaveRuntimeAvailable ' + + 'and BoundedAgentSbxCapabilityReport.missing), so no enclave broker wiring is generated and there is ' + + 'no Docker-socket or credential fallback', ); } if (!config.enableApiProxy) { @@ -177,6 +186,12 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou const { enclaveImageRef, enclaveSource, brokerSource } = resolveBoundedAgentImages(imageConfig); const dockerSocketPath = resolveDockerSocketPath(config); const apiPort = resolveBoundedAgentApiPort(boundedAgents.profile); + const ingressTransport = config.boundedAgentIngressTransport + ?? (runtimeUsesComposeAgent(config.containerRuntime) ? 'unix' : 'sbx-http'); + const sbxIngressHostIp = ingressTransport === 'sbx-http' ? resolveDockerHostGateway() : undefined; + if (ingressTransport === 'sbx-http' && !sbxIngressHostIp) { + throw new Error('Could not resolve the Docker host-gateway IP for bounded-agent sbx ingress'); + } const apiProxyService = buildApiProxyServiceConfig({ config, @@ -226,9 +241,19 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou const service: Record = { container_name: BOUNDED_AGENT_BROKER_CONTAINER_NAME, ...brokerSource, - // The broker is deliberately networkless: it never joins the enclave - // network it launches enclaves onto. - network_mode: 'none', + // The broker is deliberately networkless when the primary agent shares a + // Unix-socket-mountable host with it: it never joins the enclave network + // it launches enclaves onto. When the primary agent is a microVM that + // cannot receive that bind mount (sbx-http transport), the broker instead + // joins a *separate*, dedicated `internal` ingress bridge — distinct from + // BOUNDED_AGENT_NETWORK — so it still never shares a network with an + // enclave, the primary agent's own network, Squid, or the API proxy. + ...(ingressTransport === 'unix' + ? { network_mode: 'none' } + : { + networks: [BOUNDED_AGENT_INGRESS_NETWORK], + ports: [`${sbxIngressHostIp}::${BOUNDED_AGENT_TCP_PORT}`], + }), volumes: applyHostPathPrefixToVolumes( [ `${paths.seedsDir}:${BROKER_SEEDS_DIR}:ro`, @@ -246,6 +271,7 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou // The broker selects a fixed EnclaveRunner from this normalized value. // Runtime flags are never accepted from an invocation. AWF_BOUNDED_AGENT_BACKEND: boundedAgents.runtime, + AWF_BOUNDED_AGENT_PRIMARY_BACKEND: resolveBoundedAgentPrimaryBackend(config.containerRuntime), AWF_BOUNDED_AGENT_NETWORK: BOUNDED_AGENT_NETWORK, AWF_BOUNDED_AGENT_API_ENDPOINT: `http://${BOUNDED_AGENT_API_PROXY_IP}:${apiPort}`, AWF_BOUNDED_AGENT_PROFILE: boundedAgents.profile, @@ -266,6 +292,9 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou AWF_BOUNDED_AGENT_HOST_SEEDS_DIR: toDaemonVisiblePath(paths.seedsDir, config.dockerHostPathPrefix), AWF_BOUNDED_AGENT_SOCKET_UID: getSafeHostUid(), AWF_BOUNDED_AGENT_SOCKET_GID: getSafeHostGid(), + ...(ingressTransport === 'sbx-http' + ? { AWF_BOUNDED_AGENT_TCP_PORT: String(BOUNDED_AGENT_TCP_PORT) } + : {}), }, depends_on: { 'bounded-agent-image': { @@ -292,7 +321,7 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou }; const agentEnvAdditions: Record = { - AWF_BOUNDED_AGENT_SOCKET: AGENT_SOCKET_PATH, + ...(ingressTransport === 'unix' ? { AWF_BOUNDED_AGENT_SOCKET: AGENT_SOCKET_PATH } : {}), AWF_BOUNDED_AGENT_SKILL: AGENT_SKILL_PATH, AWF_BOUNDED_AGENT_REPOS: boundedAgents.privateRepos.map((repository) => repository.repo).join(','), }; @@ -311,7 +340,8 @@ export function buildBoundedAgentService(params: BoundedAgentServiceParams): Bou logger.info( `Bounded agents enabled - enclave runtime: ${boundedAgents.runtime}, ` + - `profile: ${boundedAgents.profile}, enclave network: ${BOUNDED_AGENT_NETWORK} (API proxy only)`, + `profile: ${boundedAgents.profile}, enclave network: ${BOUNDED_AGENT_NETWORK} (API proxy only), ` + + `broker ingress transport: ${ingressTransport}`, ); return { enclaveImageService, service, apiProxyService, agentEnvAdditions, agentVolumes }; diff --git a/src/types/bounded-agent-options.ts b/src/types/bounded-agent-options.ts index dfb00090a..5a5045d0c 100644 --- a/src/types/bounded-agent-options.ts +++ b/src/types/bounded-agent-options.ts @@ -100,8 +100,10 @@ export interface BoundedAgentsConfig { * Sandbox runtime backend used to execute the enclave. * * `docker` and `gvisor` are implemented. `sbx` is accepted by the schema - * but fails closed at preflight with an explicit not-yet-implemented - * capability error — no backend ever downgrades. + * but is capability-gated: preflight probes the installed sbx CLI and + * blocks before any repository is staged unless every mandatory + * isolation and API-proxy-only network primitive can be proven. No + * backend ever downgrades. * * @default 'docker' */ @@ -228,6 +230,18 @@ export const BOUNDED_AGENT_DEFAULTS: Readonly< maxModelTokens: 1024, }; +/** + * Transport used between the primary agent and the bounded-agent broker. + * + * Compose agents (docker, gvisor) always use `unix`: the broker's socket is + * bind-mounted directly into the agent container. A primary sbx microVM + * cannot receive that bind mount, so it uses `unix` only when an executable + * passthrough probe proves the microVM can reach a host-mounted Unix socket; + * otherwise it falls back to `sbx-http`, an authenticated loopback-only HTTP + * transport on a dedicated internal network (see `./ingress.ts`). + */ +export type BoundedAgentIngressTransport = 'unix' | 'sbx-http'; + export interface BoundedAgentOptions { /** * Normalized bounded-agent enclave configuration. @@ -239,4 +253,15 @@ export interface BoundedAgentOptions { * @default undefined */ boundedAgents?: BoundedAgentsConfig; + + /** + * Trusted runtime state selected by bounded-agent preflight. + * + * This is not a user-configurable field and is never accepted from the AWF + * config file. Compose agents always use `unix`; sbx uses `unix` only when + * an executable passthrough probe succeeds, otherwise `sbx-http`. + * + * @internal + */ + boundedAgentIngressTransport?: BoundedAgentIngressTransport; } From cbe0cdc2c80bd5ab2a3075e7b88188e032afbd77 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 2 Aug 2026 20:50:24 -0700 Subject: [PATCH 2/4] test: cover bounded agent sbx ingress wrapper Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0ec2c5e4-8277-47ca-b8dd-7bc8d4dd1b94 --- src/bounded-agent/workspace-artifacts.test.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/bounded-agent/workspace-artifacts.test.ts b/src/bounded-agent/workspace-artifacts.test.ts index 84e2ae22d..021ead0c2 100644 --- a/src/bounded-agent/workspace-artifacts.test.ts +++ b/src/bounded-agent/workspace-artifacts.test.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { spawnSync } from 'child_process'; import { generateBoundedAgentSkill, writeBoundedAgentSkill } from './skill'; import { writeBoundedAgentWrapper } from './wrapper-artifact'; import { resolveBoundedAgentPaths } from './paths'; @@ -207,13 +208,57 @@ describe('bounded-agent CLI wrapper source', () => { expect(wrapper).not.toMatch(/exit\s+[1-9]/); }); - it('never forwards a proxy, credential, endpoint, or runtime control', () => { + it('never forwards a proxy, credential, or runtime control', () => { expect(wrapper).toContain("--noproxy '*'"); - for (const forbidden of ['AWF_BOUNDED_AGENT_MODEL', 'Authorization', 'X-AWF-Runtime', 'X-AWF-Capability']) { + for (const forbidden of ['AWF_BOUNDED_AGENT_MODEL', 'Authorization', 'X-AWF-Runtime']) { expect(wrapper).not.toContain(forbidden); } }); + it('uses the authenticated host-gateway endpoint only when the sbx transport is complete', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-wrapper-')); + const argsPath = path.join(root, 'curl.args'); + const fakeCurl = path.join(root, 'curl'); + fs.writeFileSync( + fakeCurl, + `#!/bin/sh\nprintf '%s\\n' "$@" > "$AWF_TEST_CURL_ARGS"\nprintf '%s' '{"status":"error"}'\n`, + { mode: 0o755 }, + ); + try { + const capability = 'a'.repeat(64); + const result = spawnSync( + '/bin/sh', + [ + path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-agent-wrapper.sh'), + '--repo', + 'octo/alpha', + '--schema', + '{"type":"boolean"}', + ], + { + input: 'bounded task', + encoding: 'utf8', + env: { + PATH: `${root}:${process.env.PATH ?? ''}`, + AWF_TEST_CURL_ARGS: argsPath, + AWF_BOUNDED_AGENT_ENDPOINT: 'http://host.docker.internal:18081/query', + AWF_BOUNDED_AGENT_CAPABILITY: capability, + }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('{"status":"error"}\n'); + expect(result.stderr).toBe(''); + const curlArgs = fs.readFileSync(argsPath, 'utf8'); + expect(curlArgs).toContain('X-AWF-Capability: ' + capability); + expect(curlArgs).toContain('http://host.docker.internal:18081/query'); + expect(curlArgs).toContain('--noproxy'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('only ever passes through the two canonical response shapes', () => { expect(wrapper).toContain('\'{"status":"error"}\')'); expect(wrapper).toContain('\'{"status":"ok","result":\'*\'}\')'); From 0fd002e1231235f7c5206b55980a8b8bd049292a Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 2 Aug 2026 21:55:09 -0700 Subject: [PATCH 3/4] fix: address bounded agent sbx review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0ec2c5e4-8277-47ca-b8dd-7bc8d4dd1b94 --- docs/awf-config-spec.md | 16 +- docs/bounded-agents.md | 14 +- .../ci/report-bounded-agent-runtime-matrix.js | 41 +- ...eport-bounded-agent-runtime-matrix.test.ts | 26 +- src/bounded-agent/ingress-conformance.test.ts | 254 ++++++++++ src/bounded-agent/manager.test.ts | 146 ++++++ src/bounded-agent/manager.ts | 61 ++- src/bounded-agent/preflight.test.ts | 78 ++- src/bounded-agent/preflight.ts | 106 +++- src/bounded-agent/sbx-enclave-runner.test.ts | 457 ++++++++++++++++++ src/commands/main-action.test.ts | 69 +++ src/commands/main-action.ts | 42 +- 12 files changed, 1263 insertions(+), 47 deletions(-) create mode 100644 src/bounded-agent/ingress-conformance.test.ts create mode 100644 src/bounded-agent/sbx-enclave-runner.test.ts diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index c87f6751e..28e252077 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2282,11 +2282,17 @@ downgrades to the default runtime. `sbx` is accepted by the JSON Schema but is **capability-blocked**: AWF ships a dedicated bounded-agent sbx capability probe (host-side `src/bounded-agent/sbx-capability.ts`, container-side -`containers/bounded-agent/broker/sbx-capability-probe.js`) that runs the exact -audited Docker Sandboxes CLI surface (`sbx version`, `sbx create`, `sbx exec`, -`sbx ls --json`, `sbx stop`, `sbx rm --force`) against the audited version -(`v0.37.1`) and reports every missing capability in structured JSON — never a -single collapsed boolean, and never a "not yet implemented" placeholder. +`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the +audited Docker Sandboxes CLI's version/auth/help surface — `sbx version`, +`sbx ls` (authenticated, non-mutating daemon reachability), and the +`sbx create --help` / `sbx exec --help` flag listings — against the audited +version (`v0.37.1`) and reports every missing capability in structured JSON — +never a single collapsed boolean, and never a "not yet implemented" +placeholder. This is help-surface inspection, not an executed lifecycle proof: +the probe never runs `sbx create`, `sbx exec`, `sbx stop`, or `sbx rm`. Those +lifecycle operations exist only in the broker's `SbxEnclaveRunner` — covered by +runner contract tests, not by preflight — and remain unreachable while the +unconditional capability block below stays in force. The bounded-agent enclave's network requirement is strictly harder than a bounded query's: it must reach *exactly one* peer (the dedicated API proxy), diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md index 56d035fea..d146c94ea 100644 --- a/docs/bounded-agents.md +++ b/docs/bounded-agents.md @@ -179,10 +179,16 @@ written ahead of support landing, but it is **capability-blocked** — never a blanket "not yet implemented" refusal, and never a false pass. AWF ships a dedicated bounded-agent sbx capability probe (`src/bounded-agent/sbx-capability.ts`, mirrored in -`containers/bounded-agent/broker/sbx-capability-probe.js`) that runs the exact -audited Docker Sandboxes CLI (`v0.37.1`) surface — `sbx version`, `sbx create`, -`sbx exec`, `sbx ls --json`, `sbx stop`, and `sbx rm --force` — and reports -every missing capability in structured JSON rather than a single boolean. +`containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the +audited Docker Sandboxes CLI (`v0.37.1`) version/auth/help surface — +`sbx version`, `sbx ls` (authenticated, non-mutating daemon reachability), and +the `sbx create --help` / `sbx exec --help` flag listings — and reports every +missing capability in structured JSON rather than a single boolean. This is +help-surface inspection, not an executed lifecycle proof: the probe never +runs `sbx create`, `sbx exec`, `sbx stop`, or `sbx rm`. Those lifecycle +commands exist only in the broker's `SbxEnclaveRunner`, which the +unconditional capability block below keeps unreachable — they are covered by +runner contract tests, not by preflight. The enclave requirement is strictly harder than a bounded query's: an enclave must reach *exactly one* peer (the dedicated API proxy), not "no diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.js b/scripts/ci/report-bounded-agent-runtime-matrix.js index 15b5728c7..e513056c9 100644 --- a/scripts/ci/report-bounded-agent-runtime-matrix.js +++ b/scripts/ci/report-bounded-agent-runtime-matrix.js @@ -29,9 +29,15 @@ function collectCapabilities(commandRunner = run) { } } const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc'); - // `sbx ls` only proves that the binary exists and is reachable. It is - // authenticated and non-mutating, so it also proves daemon and credential - // availability for the primary microVM axis. + // `sbx ls` only proves the CLI/daemon is installed, authenticated, and + // reachable. It is deliberately NOT reported as `supported`: this static CI + // report never starts a sandbox, mounts the broker's Unix socket, or drives + // the authenticated HTTP capability exchange, so it cannot execute the + // ingress proof (`assertSbxBoundedAgentIngress` in `main-action.ts`) that + // this PR's "supported after ingress proof" condition requires. Promoting + // primary sbx to `supported` from this alone would be a false positive. + // It is reported as `available`: CLI/daemon reachability confirmed, ingress + // unproven. const sbxPrimary = commandRunner('sbx', ['ls']).ok; const sbxBoundedAgent = commandRunner( process.execPath, @@ -49,7 +55,7 @@ function collectCapabilities(commandRunner = run) { primary: { docker: docker.ok ? 'supported' : 'unavailable', gvisor: gvisor ? 'supported' : 'unavailable', - sbx: sbxPrimary ? 'supported' : 'unavailable', + sbx: sbxPrimary ? 'available' : 'unavailable', }, boundedAgent: { docker: docker.ok ? 'supported' : 'unavailable', @@ -59,11 +65,30 @@ function collectCapabilities(commandRunner = run) { }; } +/** + * Evaluates one primary/bounded-agent combination without ever promoting a + * primary sbx CLI/daemon reachability check (`available`) to `SUPPORTED`. + * + * A primary sbx combination can only reach `SUPPORTED` once its capability is + * literally `supported` — a value this static reporter never assigns to + * primary sbx (see {@link collectCapabilities}) because it cannot execute the + * pre-agent ingress proof. `available` is therefore always reported as + * `BLOCKED` at a distinct `primary-sbx-ingress-unproven` phase so it is never + * confused with an outright-unavailable CLI/daemon. + */ function evaluate(primary, boundedAgent, capabilities) { - if (capabilities.primary[primary] !== 'supported') { + const primaryState = capabilities.primary[primary]; + if (primaryState === 'available') { return { status: 'BLOCKED', - capability: capabilities.primary[primary], + capability: primaryState, + phase: 'primary-sbx-ingress-unproven', + }; + } + if (primaryState !== 'supported') { + return { + status: 'BLOCKED', + capability: primaryState, phase: 'primary-preflight', }; } @@ -100,6 +125,10 @@ function renderMatrix(capabilities) { '> The bounded-agent sbx enclave is BLOCKED unconditionally today: the audited sbx CLI cannot yet ' + 'prove the mandatory API-proxy-only network, RO-targeted-mount, pids/disk/fsize, or lifecycle ' + 'isolation primitives this enclave requires.', + '> Primary capability `available` (sbx only) means the CLI/daemon is installed, authenticated, and ' + + 'reachable, but the pre-agent ingress proof this static report cannot execute has not run — it is ' + + 'never promoted to SUPPORTED here. Primary sbx becomes SUPPORTED only after ' + + '`assertSbxBoundedAgentIngress` proves the selected ingress during an actual run.', ); return `${lines.join('\n')}\n`; } diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts index 26242cab1..650d2ca37 100644 --- a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts +++ b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts @@ -25,12 +25,31 @@ describe('bounded-agent runtime capability report', () => { const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line)); expect(rows).toHaveLength(9); expect(report).toContain( - '| sbx | sbx | BLOCKED | supported | blocked | bounded-agent-preflight |', + '| sbx | sbx | BLOCKED | available | blocked | primary-sbx-ingress-unproven |', ); expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success'); expect(report).toContain('bounded-agent sbx enclave is BLOCKED unconditionally today'); }); + it('never reports SUPPORTED for primary sbx from `sbx ls` alone, only `available`', () => { + const capabilities = collectCapabilities((command: string) => { + if (command === 'docker') return { ok: true, stdout: '{"runc":{}}' }; + // `sbx ls` succeeds: the CLI/daemon is installed, authenticated, and + // reachable, but no ingress proof was executed by this static report. + if (command === 'sbx') return { ok: true, stdout: 'Docker Sandboxes v0.37.1' }; + return { ok: false, stdout: '' }; + }); + expect(capabilities.primary.sbx).toBe('available'); + expect(capabilities.primary.sbx).not.toBe('supported'); + + for (const boundedAgent of ['docker', 'gvisor', 'sbx']) { + const result = evaluate('sbx', boundedAgent, capabilities); + expect(result.status).toBe('BLOCKED'); + expect(result.phase).toBe('primary-sbx-ingress-unproven'); + expect(result.capability).toBe('available'); + } + }); + it('never promotes an unavailable primary or bounded-agent runtime through fallback', () => { const capabilities = { primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, @@ -54,6 +73,11 @@ describe('bounded-agent runtime capability report', () => { }); it('supports primary sbx paired with docker/gvisor bounded-agent enclaves once primary sbx is proven', () => { + // `evaluate` only reaches SUPPORTED for a primary sbx combination once the + // capability is literally `supported` — a value this collector never + // assigns to primary sbx. This exercises that promotion path directly with + // a hand-built capabilities object, standing in for a future collector + // (or a live run) that has actually executed the ingress proof. const capabilities = { primary: { docker: 'supported', gvisor: 'supported', sbx: 'supported' }, boundedAgent: { docker: 'supported', gvisor: 'supported', sbx: 'blocked' }, diff --git a/src/bounded-agent/ingress-conformance.test.ts b/src/bounded-agent/ingress-conformance.test.ts new file mode 100644 index 000000000..bc303c6ba --- /dev/null +++ b/src/bounded-agent/ingress-conformance.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import type { AddressInfo } from 'net'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { createServer, createTcpServer, listenOnSocket, listenOnTcp, MAX_CONNECTIONS } = require( + path.join(brokerDir, 'server.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +/** + * TCP ingress conformance tests for the bounded-agent broker, mirroring the + * coverage bounded queries already have + * (`src/bounded-query/ingress-conformance.test.ts`): missing/duplicate/wrong + * capability rejection, one-shot probe retirement, capability-header + * stripping before framing, byte-identical Unix/TCP canonical responses, body + * size limits, and connection-limit behavior. + * + * Only `server.js`'s existing exports (`createServer`, `createTcpServer`, + * `listenOnSocket`, `listenOnTcp`, `MAX_CONNECTIONS`) are used — no + * production surface is widened for these tests. + */ + +const CAPABILITY = 'a'.repeat(64); +const PROBE_CAPABILITY = 'b'.repeat(64); +const CANONICAL_ERROR = '{"status":"error"}'; +const CANONICAL_OK = '{"status":"ok","result":true}'; +const SCHEMA = Buffer.from('{"type":"boolean"}').toString('base64url'); +const MAX_TASK_BYTES = 64 * 1024; + +interface Response { + status: number | undefined; + headers: http.IncomingHttpHeaders; + body: string; +} + +function stableResponse(response: Response) { + return { + status: response.status, + body: response.body, + contentType: response.headers['content-type'], + cacheControl: response.headers['cache-control'], + contentLength: response.headers['content-length'], + }; +} + +function request(options: http.RequestOptions, body = 'do the task'): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ + method: 'POST', + path: '/query', + ...options, + headers: { + 'content-type': 'application/octet-stream', + 'x-awf-agent-version': '1', + 'x-awf-repo': 'octo/private', + 'x-awf-schema-b64': SCHEMA, + ...options.headers, + }, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ + status: res.statusCode, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf8'), + })); + }); + req.on('error', reject); + req.end(body); + }); +} + +describe('bounded-agent ingress conformance', () => { + let root: string; + let unixServer: http.Server; + let tcpServer: http.Server; + let socketPath: string; + let tcpPort: number; + let handled: unknown[]; + const audit = { + failure: jest.fn(), + lifecycle: jest.fn(), + }; + + beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-agent-ingress-test-')); + socketPath = path.join(root, 'broker.sock'); + handled = []; + const broker = { + handle: (incoming: unknown, respond: (body: string) => void) => { + handled.push(incoming); + respond(incoming === undefined ? CANONICAL_ERROR : CANONICAL_OK); + return Promise.resolve(); + }, + }; + unixServer = createServer({ broker, audit }); + tcpServer = createTcpServer({ + broker, + audit, + capabilities: { query: CAPABILITY, probe: PROBE_CAPABILITY }, + }); + await listenOnSocket(unixServer, { + socketPath, + socketDir: root, + socketUid: process.getuid?.() ?? 0, + socketGid: process.getgid?.() ?? 0, + }, audit); + await listenOnTcp(tcpServer, { tcpPort: 0 }); + tcpPort = (tcpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await Promise.all([ + new Promise((resolve) => unixServer.close(() => resolve())), + new Promise((resolve) => tcpServer.close(() => resolve())), + ]); + fs.rmSync(root, { recursive: true, force: true }); + jest.clearAllMocks(); + }); + + const unixRequest = (body?: string) => request({ socketPath }, body); + const tcpRequest = (body?: string, capability = CAPABILITY) => request({ + host: '127.0.0.1', + port: tcpPort, + headers: { 'x-awf-capability': capability }, + }, body); + + it('returns byte-identical status, headers, and canonical result bytes across transports', async () => { + const [unix, tcp] = await Promise.all([unixRequest(), tcpRequest()]); + expect(stableResponse(tcp)).toEqual(stableResponse(unix)); + expect(stableResponse(tcp)).toEqual(expect.objectContaining({ + status: 200, + body: CANONICAL_OK, + contentType: 'application/json', + cacheControl: 'no-store', + contentLength: String(Buffer.byteLength(CANONICAL_OK)), + })); + expect(handled).toHaveLength(2); + expect(handled[0]).toEqual(handled[1]); + expect(handled[0]).not.toHaveProperty('capability'); + }); + + it('collapses missing, wrong, and duplicated authentication to canonical failure bytes', async () => { + const missing = request({ host: '127.0.0.1', port: tcpPort }); + const wrong = tcpRequest(undefined, 'c'.repeat(64)); + const duplicated = request({ + host: '127.0.0.1', + port: tcpPort, + headers: { 'x-awf-capability': [CAPABILITY, CAPABILITY] }, + }); + const responses = await Promise.all([missing, wrong, duplicated]); + for (const response of responses) { + expect(response.status).toBe(200); + expect(response.body).toBe(CANONICAL_ERROR); + } + expect(handled).toHaveLength(0); + expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected'); + }); + + it('uses a one-shot probe capability without launching or consuming a request, then permanently retires it', async () => { + const before = handled.length; + const first = await tcpRequest('', PROBE_CAPABILITY); + const second = await tcpRequest('', PROBE_CAPABILITY); + expect(first.body).toBe(CANONICAL_ERROR); + expect(second.body).toBe(CANONICAL_ERROR); + expect(handled.length).toBe(before); + expect(audit.lifecycle).toHaveBeenCalledWith('sbx-ingress-probe'); + expect(audit.lifecycle).toHaveBeenCalledTimes(1); + // The second attempt with the same (now-retired) probe capability must be + // rejected as an ordinary auth failure, not treated as another probe. + expect(audit.failure).toHaveBeenCalledWith('transport', 'auth-rejected'); + }); + + it('strips the capability header before handing the request to framing/broker logic', async () => { + await tcpRequest(); + expect(handled).toHaveLength(1); + expect(handled[0]).not.toHaveProperty('capability'); + expect(JSON.stringify(handled[0])).not.toContain(CAPABILITY); + }); + + it('keeps oversized and parallel request behavior identical across transports', async () => { + const oversized = 'x'.repeat(MAX_TASK_BYTES + 1); + const [unixOversized, tcpOversized] = await Promise.all([ + unixRequest(oversized), + tcpRequest(oversized), + ]); + expect(unixOversized.body).toBe(CANONICAL_ERROR); + expect(stableResponse(tcpOversized)).toEqual(stableResponse(unixOversized)); + + const results = await Promise.all([ + unixRequest(), + unixRequest(), + tcpRequest(), + tcpRequest(), + ]); + expect(results.map((result) => result.body)).toEqual(Array(4).fill(CANONICAL_OK)); + }); + + it('accepts a task body exactly at the size limit and rejects one byte over it, identically on both transports', async () => { + const atLimit = 'x'.repeat(MAX_TASK_BYTES); + const overLimit = 'x'.repeat(MAX_TASK_BYTES + 1); + const [unixAtLimit, tcpAtLimit] = await Promise.all([unixRequest(atLimit), tcpRequest(atLimit)]); + expect(unixAtLimit.body).toBe(CANONICAL_OK); + expect(tcpAtLimit.body).toBe(CANONICAL_OK); + + const [unixOverLimit, tcpOverLimit] = await Promise.all([ + unixRequest(overLimit), + tcpRequest(overLimit), + ]); + expect(unixOverLimit.body).toBe(CANONICAL_ERROR); + expect(tcpOverLimit.body).toBe(CANONICAL_ERROR); + }); + + it('does not dispatch broker work for a request that arrives on an over-limit socket', async () => { + const holders = await Promise.all(Array.from({ length: MAX_CONNECTIONS }, () => new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => resolve(socket)); + socket.on('error', reject); + }))); + + try { + const rawResponse = await new Promise((resolve, reject) => { + const socket = net.createConnection({ host: '127.0.0.1', port: tcpPort }, () => { + socket.write([ + 'POST /query HTTP/1.1', + 'Host: 127.0.0.1', + `X-AWF-Capability: ${CAPABILITY}`, + 'Content-Type: application/octet-stream', + 'X-AWF-Agent-Version: 1', + 'X-AWF-Repo: octo/private', + `X-AWF-Schema-B64: ${SCHEMA}`, + 'Content-Length: 0', + '', + '', + ].join('\r\n')); + }); + const chunks: Uint8Array[] = []; + socket.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + socket.on('error', reject); + }); + + expect(rawResponse).toContain(CANONICAL_ERROR); + expect(handled).toHaveLength(0); + expect(audit.failure).toHaveBeenCalledWith('transport', 'connection-limit'); + } finally { + for (const socket of holders) socket.destroy(); + } + }); +}); diff --git a/src/bounded-agent/manager.test.ts b/src/bounded-agent/manager.test.ts index db9715363..6b4f08830 100644 --- a/src/bounded-agent/manager.test.ts +++ b/src/bounded-agent/manager.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import execa from 'execa'; +import { logger } from '../logger'; import type { WrapperConfig } from '../types'; import { BOUNDED_AGENT_DEFAULTS, type BoundedAgentsConfig } from '../types/bounded-agent-options'; import { resolveBoundedAgentPaths } from './paths'; @@ -10,6 +11,7 @@ import { boundedAgentManagerTestHelpers, isBoundedAgentsEnabled, prepareBoundedAgents, + reportBoundedAgentSbxIngressResult, teardownBoundedAgents, } from './manager'; import { releaseSeedPermissions, type GitRunner } from './staging'; @@ -234,6 +236,150 @@ describe('prepareBoundedAgents', () => { prepareBoundedAgents(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner, assertRuntimeAvailable }), ).rejects.toThrow(/EEXIST/); }); + + describe('runtime telemetry lifecycle (never `ready` before sbx ingress is proven)', () => { + function collectTelemetry(infoSpy: jest.SpyInstance): Array> { + return infoSpy.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + } + + it('reports `ready` immediately after preflight for a compose (docker) primary', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await prepareBoundedAgents(buildConfig(workDir), { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + }); + const events = collectTelemetry(infoSpy); + const terminal = events[events.length - 1]; + expect(terminal).toEqual(expect.objectContaining({ + primaryBackend: 'docker', + capabilityState: 'supported', + category: 'ready', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('never reports `ready` for a primary-sbx run before ingress is proven', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await prepareBoundedAgents( + { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + probeSbxUnixSocket: async () => true, + }, + ); + const events = collectTelemetry(infoSpy); + expect(events.some((event) => event.category === 'ready')).toBe(false); + const terminal = events[events.length - 1]; + expect(terminal).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'supported', + category: 'primary-sbx-ingress-pending', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reports ingress unavailable when primary-sbx transport selection fails', async () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + await expect(prepareBoundedAgents( + { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + probeSbxUnixSocket: async () => { + throw new Error('socket probe failed'); + }, + }, + )).rejects.toThrow('socket probe failed'); + + const events = collectTelemetry(infoSpy); + expect(events.some((event) => event.category === 'ready')).toBe(false); + expect(events[events.length - 1]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult reports `ready` only once ingress proof succeeds', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + const events = collectTelemetry(infoSpy); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'supported', + category: 'ready', + })); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult reports a terminal unavailable event when ingress proof fails', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'sbx' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'failed'); + const events = collectTelemetry(infoSpy); + expect(events).toHaveLength(1); + expect(events[0]).toEqual(expect.objectContaining({ + primaryBackend: 'sbx', + lifecycleClass: 'startup', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); + expect(events.some((event) => event.category === 'ready')).toBe(false); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult is a no-op for a non-sbx primary', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { ...buildConfig(workDir), containerRuntime: 'gvisor' } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + expect(collectTelemetry(infoSpy)).toHaveLength(0); + } finally { + infoSpy.mockRestore(); + } + }); + + it('reportBoundedAgentSbxIngressResult is a no-op when bounded agents are disabled', () => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + try { + const config = { + ...buildConfig(workDir, { enabled: false }), + containerRuntime: 'sbx', + } as WrapperConfig; + reportBoundedAgentSbxIngressResult(config, 'proven'); + expect(collectTelemetry(infoSpy)).toHaveLength(0); + } finally { + infoSpy.mockRestore(); + } + }); + }); }); describe('teardownBoundedAgents', () => { diff --git a/src/bounded-agent/manager.ts b/src/bounded-agent/manager.ts index d6c73728b..82a1aeb95 100644 --- a/src/bounded-agent/manager.ts +++ b/src/bounded-agent/manager.ts @@ -248,11 +248,21 @@ export async function prepareBoundedAgents( ); throw error; } + // A primary-sbx run is never reported `ready` here: preflight only proves the + // sbx CLI and enclave capability exist, not that the selected ingress + // transport (unix-in-sbx or sbx-http) is actually reachable from inside the + // sandbox. That executable proof happens later in `main-action`, after the + // sandbox is created, via `assertSbxBoundedAgentIngress`. Reporting `ready` + // here would be a false promotion — see + // `reportBoundedAgentSbxIngressResult` for the deferred terminal event. + // Compose primaries (docker/gvisor) have no equivalent later proof step — + // Compose either mounts the broker socket successfully or fails outright — + // so `ready` is accurate immediately after preflight for those backends. logger.info( `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry({ ...telemetryBase, capabilityState: 'supported', - category: 'ready', + category: primaryBackend === 'sbx' ? 'primary-sbx-ingress-pending' : 'ready', })}`, ); @@ -260,7 +270,12 @@ export async function prepareBoundedAgents( config.boundedAgentIngressTransport = 'unix'; } else { const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount; - config.boundedAgentIngressTransport = (await probe()) ? 'unix' : 'sbx-http'; + try { + config.boundedAgentIngressTransport = (await probe()) ? 'unix' : 'sbx-http'; + } catch (error) { + reportBoundedAgentSbxIngressResult(config, 'failed'); + throw error; + } } const paths = resolveBoundedAgentPaths(config.workDir); @@ -324,6 +339,48 @@ export async function prepareBoundedAgents( ); } +/** + * Emits the terminal bounded-agent runtime telemetry for a primary-sbx run, + * once `assertSbxBoundedAgentIngress` has actually been attempted in + * `main-action` after the sandbox exists. + * + * `prepareBoundedAgents` deliberately never reports `ready` for a primary-sbx + * run by itself (see the `primary-sbx-ingress-pending` telemetry emitted + * there): preflight only proves the sbx CLI and enclave capability are + * present, not that the selected ingress transport is reachable from inside + * the sandbox. This function is the only place that reports the outcome of + * that later, executable proof — `ready`/`supported` only on success, a + * distinct terminal `unavailable` category on failure. It is a no-op when + * bounded agents are disabled or the primary backend is not sbx, so callers + * may invoke it unconditionally around the ingress-proof call site. + */ +export function reportBoundedAgentSbxIngressResult( + config: WrapperConfig, + outcome: 'proven' | 'failed', +): void { + const boundedAgents = config.boundedAgents; + if (!boundedAgents?.enabled) return; + const primaryBackend = resolveBoundedAgentPrimaryBackend(config.containerRuntime); + if (primaryBackend !== 'sbx') return; + + const telemetryBase = { + primaryBackend, + boundedAgentBackend: boundedAgents.runtime, + lifecycleClass: 'startup' as const, + }; + logger.info( + `Bounded-agent runtime telemetry: ${serializeBoundedAgentRuntimeTelemetry( + outcome === 'proven' + ? { ...telemetryBase, capabilityState: 'supported', category: 'ready' } + : { + ...telemetryBase, + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + }, + )}`, + ); +} + /** Reads back the run id recorded during staging, if it is still available. */ function readRunId(paths: BoundedAgentPaths): string | undefined { try { diff --git a/src/bounded-agent/preflight.test.ts b/src/bounded-agent/preflight.test.ts index b49122f43..58b2610ba 100644 --- a/src/bounded-agent/preflight.test.ts +++ b/src/bounded-agent/preflight.test.ts @@ -138,10 +138,10 @@ describe('validateBoundedAgentConfig', () => { }); it('no longer rejects a primary sbx microVM at the config-validation level', () => { - // The primary-agent runtime axis is proven independently by - // assertPrimaryRuntimeAvailable (delegated to bounded-query's - // implementation), not blanket-rejected here: a primary sbx microVM is - // supported once its bounded-agent ingress is proven (see ./ingress.ts). + // The primary-agent runtime axis is proven independently by the + // bounded-agent-specific assertPrimaryRuntimeAvailable, not blanket-rejected + // here: a primary sbx microVM is supported once its bounded-agent ingress + // is proven (see ./ingress.ts). expect(validateBoundedAgentConfig(config({ containerRuntime: 'sbx' }), env)).toEqual([]); }); @@ -275,7 +275,73 @@ describe('assertEnclaveRuntimeAvailable', () => { }); describe('assertPrimaryRuntimeAvailable', () => { - it('is the bounded-query implementation, reused rather than duplicated', () => { - expect(assertPrimaryRuntimeAvailable).toBe(boundedQueryPreflight.assertPrimaryRuntimeAvailable); + it('is not the bounded-query implementation: bounded-agent errors must never leak bounded-query wording', () => { + expect(assertPrimaryRuntimeAvailable).not.toBe(boundedQueryPreflight.assertPrimaryRuntimeAvailable); + }); + + it.each([ + [undefined, 'docker'], + ['docker', 'docker'], + ['gvisor', 'gvisor'], + ['runsc', 'gvisor'], + ['sbx', 'sbx'], + ] as const)('accepts an available %s primary backend (%s)', async (runtime, _backend) => { + await expect(assertPrimaryRuntimeAvailable( + runtime, + jest.fn().mockResolvedValue(true), + jest.fn().mockResolvedValue(true), + jest.fn().mockResolvedValue(true), + )).resolves.toBeUndefined(); + }); + + it.each([ + [undefined, /Docker primary-agent runtime is unavailable/], + ['docker', /OCI runtime "docker" is not registered.*never fall back/s], + ['gvisor', /Primary-agent runtime "gvisor".*runsc.*never fall back/s], + ['sbx', /Primary-agent runtime "sbx" is unavailable.*never fall back/s], + ['kata', /OCI runtime "kata" is not registered.*never fall back/s], + ] as const)('fails %s before staging when its primary capability is unavailable', async (runtime, message) => { + await expect(assertPrimaryRuntimeAvailable( + runtime, + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + )).rejects.toThrow(message); + }); + + it('always identifies failures as bounded-agent, never bounded-query', async () => { + await expect(assertPrimaryRuntimeAvailable( + undefined, + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + )).rejects.toThrow(/Bounded agents abort before staging/); + + let sbxError: Error | undefined; + try { + await assertPrimaryRuntimeAvailable( + 'sbx', + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + jest.fn().mockResolvedValue(false), + ); + } catch (error) { + sbxError = error as Error; + } + expect(sbxError?.message).toMatch(/Bounded agents abort before staging/); + expect(sbxError?.message).not.toMatch(/[Bb]ounded quer(y|ies)/); + }); + + it('checks explicit docker runtime registration instead of Docker daemon availability', async () => { + const runtimeQuery = jest.fn().mockResolvedValue(true); + const dockerAvailable = jest.fn().mockResolvedValue(false); + await expect(assertPrimaryRuntimeAvailable( + 'docker', + runtimeQuery, + dockerAvailable, + jest.fn().mockResolvedValue(true), + )).resolves.toBeUndefined(); + expect(runtimeQuery).toHaveBeenCalledWith('docker'); + expect(dockerAvailable).not.toHaveBeenCalled(); }); }); diff --git a/src/bounded-agent/preflight.ts b/src/bounded-agent/preflight.ts index f1a9b7947..71116de01 100644 --- a/src/bounded-agent/preflight.ts +++ b/src/bounded-agent/preflight.ts @@ -9,9 +9,6 @@ import { MAX_TASK_BYTES, } from './protocol'; import { resolveStagingToken } from '../bounded-query/staging'; -import { - assertPrimaryRuntimeAvailable as assertBoundedQueryPrimaryRuntimeAvailable, -} from '../bounded-query/preflight'; import { defaultBoundedAgentSbxCapabilityQuery, type BoundedAgentSbxCapabilityQuery, @@ -85,6 +82,38 @@ const defaultDockerAvailabilityQuery: DockerAvailabilityQuery = async () => { return result.exitCode === 0; }; +/** + * Executes the minimum host-side primary-agent capability proof for sbx: an + * authenticated, non-mutating `sbx ls`. This is deliberately narrower than + * {@link defaultBoundedAgentSbxCapabilityQuery}, which proves the *enclave* + * axis; the primary axis only needs to know the CLI/daemon is reachable. + */ +const defaultSbxAvailabilityQuery: SbxAvailabilityQuery = async () => { + try { + const managementEnv = { ...process.env }; + delete managementEnv.DOCKER_SANDBOXES_PROXY; + delete managementEnv.XDG_CONFIG_HOME; + const result = await execa('sbx', ['ls'], { + reject: false, + timeout: 10_000, + env: managementEnv, + }); + return result.exitCode === 0; + } catch { + return false; + } +}; + +type PrimaryRuntimeCase = 'sbx' | 'docker' | 'gvisor' | 'custom' | 'default-docker'; + +function classifyPrimaryRuntime(runtime: string | undefined): PrimaryRuntimeCase { + if (runtime === 'sbx') return 'sbx'; + if (runtime === 'docker') return 'docker'; + if (runtime === 'gvisor' || runtime === 'runsc') return 'gvisor'; + if (runtime) return 'custom'; + return 'default-docker'; +} + /** * Resolves whether the configured profile has a usable API-proxy model route. * @@ -321,14 +350,70 @@ export async function assertEnclaveRuntimeAvailable( /** * Verifies the primary-agent runtime before bounded-agent repository staging. * - * The primary-agent runtime is bounded queries' own matrix axis - * (`docker` / `gvisor` / `sbx`, independent of `containerRuntime` capability - * flags elsewhere in AWF), so this delegates to the audited bounded-query - * implementation rather than restating it — the check is identical: does the - * primary runtime actually exist? Bounded agents and bounded queries can be - * enabled independently or together, and neither ever falls back. + * The primary-agent runtime (`docker` / `gvisor` / `sbx`, independent of the + * `boundedAgents.runtime` enclave axis) is proven by a bounded-agent-specific + * check with bounded-agent wording in every failure, rather than reusing + * bounded queries' implementation: reusing it would surface bounded-query + * error text (e.g. "Bounded queries abort before staging") on a run that may + * not even have bounded queries enabled. The underlying capability probes + * (Docker runtime registration, Docker daemon reachability, sbx CLI/daemon + * reachability) are identical in substance to bounded queries' own primary + * check; only the identifying language differs. Bounded agents and bounded + * queries can be enabled independently or together, and neither ever falls + * back to a weaker runtime. */ -export const assertPrimaryRuntimeAvailable = assertBoundedQueryPrimaryRuntimeAvailable; +export async function assertPrimaryRuntimeAvailable( + containerRuntime: string | undefined, + queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, + queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery, +): Promise { + const runtimeCase = classifyPrimaryRuntime(containerRuntime); + switch (runtimeCase) { + case 'sbx': + if (!(await querySbxAvailable())) { + throw new Error( + 'Primary-agent runtime "sbx" is unavailable. Bounded agents abort before staging and never ' + + 'fall back to a Docker or gVisor primary agent.', + ); + } + return; + case 'docker': + if (!(await queryDockerRuntime('docker'))) { + throw new Error( + 'Primary-agent OCI runtime "docker" is not registered with Docker. ' + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + case 'gvisor': + if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { + throw new Error( + `Primary-agent runtime "${containerRuntime}" requires the "${GVISOR_DOCKER_RUNTIME}" OCI ` + + 'runtime. It is not available, so bounded agents abort before staging and never fall back.', + ); + } + return; + case 'custom': + if (!(await queryDockerRuntime(containerRuntime!))) { + throw new Error( + `Primary-agent OCI runtime "${containerRuntime}" is not registered with Docker. ` + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + case 'default-docker': + if (!(await queryDockerAvailable())) { + throw new Error( + 'The Docker primary-agent runtime is unavailable. ' + + 'Bounded agents abort before staging and never fall back.', + ); + } + return; + default: + throw new Error(`Unreachable primary runtime case: ${runtimeCase satisfies never}`); + } +} /** @internal Exported for focused unit tests. */ // ts-prune-ignore-next @@ -338,5 +423,6 @@ export const boundedAgentPreflightTestHelpers = { GVISOR_DOCKER_RUNTIME, defaultDockerRuntimeQuery, defaultDockerAvailabilityQuery, + defaultSbxAvailabilityQuery, isDockerSize, }; diff --git a/src/bounded-agent/sbx-enclave-runner.test.ts b/src/bounded-agent/sbx-enclave-runner.test.ts new file mode 100644 index 000000000..50bc5f6c9 --- /dev/null +++ b/src/bounded-agent/sbx-enclave-runner.test.ts @@ -0,0 +1,457 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); +const { + deriveSbxEnclaveSpec, + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, +} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +/** + * Security-critical contract tests for the bounded-agent sbx enclave runner. + * + * This mirrors the coverage bounded queries already have for their sbx + * backend (`src/bounded-query/query-runner.test.ts:158-305`): a fixed launch + * specification derived only from trusted identifiers, capability rejection, + * trusted-ID validation, create/exec timeout accounting, prefix-scoped + * reconciliation, malformed-inventory rejection, and guaranteed stop/remove + * cleanup — including when cleanup itself fails. + */ + +interface SbxResult { + exitCode: number; + timedOut: boolean; + stdout: string; + stderr: string; +} + +const ok = (overrides: Partial = {}): SbxResult => ({ + exitCode: 0, + timedOut: false, + stdout: '', + stderr: '', + ...overrides, +}); + +const config = { + sbxWorkDir: '/sbx-daemon/private/work', + sbxSeedsDir: '/sbx-daemon/private/seeds', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveMountDir: '/agent', + enclaveUid: 65534, + enclaveGid: 65534, + cpuLimit: '1', + memoryLimit: '512m', + network: 'awf-bounded-agent', + pidsLimit: 128, + tmpfsLimit: '64m', + timeoutSeconds: 120, +}; + +const RUN_ID = 'abcd1234abcd1234abcd1234abcd1234'; +const INVOCATION_ID = '111111111111111111111111'; +const SEED_ID = 'a'.repeat(32); + +type SbxHandler = (args: readonly string[], timeoutMs: number) => SbxResult | Promise; + +function createSbx(handler: SbxHandler = () => ok()) { + const calls: string[][] = []; + const timeouts: number[] = []; + return { + calls, + timeouts, + client: { + runSbx: async (args: readonly string[], timeoutMs: number) => { + calls.push([...args]); + timeouts.push(timeoutMs); + return handler(args, timeoutMs); + }, + }, + }; +} + +function createFiles() { + const created: string[] = []; + return { + created, + files: { + mkdirSync: (target: string) => { + created.push(target); + }, + }, + }; +} + +const availableProbe = async () => ({ supported: true, missing: [] }); + +describe('bounded-agent sbx enclave runner contract', () => { + describe('deriveSbxEnclaveSpec: fixed spec derived only from trusted identifiers', () => { + it('derives a frozen, unique-per-invocation launch specification', () => { + const first = deriveSbxEnclaveSpec({ + config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID, + }); + const second = deriveSbxEnclaveSpec({ + config, runId: RUN_ID, invocationId: '222222222222222222222222', seedId: SEED_ID, + }); + + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.createArgs)).toBe(true); + expect(Object.isFrozen(first.execArgs)).toBe(true); + expect(first.sandboxName).not.toBe(second.sandboxName); + expect(first.runPrefix).toBe(`awf-bounded-agent-sbx-${RUN_ID}-`); + expect(first.sandboxName).toBe(`${first.runPrefix}${INVOCATION_ID}`); + expect(first.createArgs).toContain(SBX_ENCLAVE_TEMPLATE); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + expect(first.createArgs).toContain(flag); + } + expect(first.createArgs.join(' ')).toContain( + `${config.sbxSeedsDir}/${SEED_ID}:${config.enclaveSeedPath}:ro`, + ); + expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/task.txt`); + expect(first.createArgs.join(' ')).toContain(`${config.sbxWorkDir}/${INVOCATION_ID}/schema.json`); + expect(first.execArgs).toContain(`${config.enclaveUid}:${config.enclaveGid}`); + expect(first.execArgs).toContain(config.enclaveMountDir); + expect(first.execArgs).toContain(first.sandboxName); + expect(first.execArgs.slice(-1)).toEqual(['/usr/local/bin/run-bounded-agent']); + expect(first.stopArgs).toEqual(['stop', first.sandboxName]); + expect(first.removeArgs).toEqual(['rm', '--force', first.sandboxName]); + expect(first.listArgs).toEqual(['ls', '--json']); + }); + + it.each([ + ['runId', { runId: 'not-hex', invocationId: INVOCATION_ID, seedId: SEED_ID }, /runId/], + ['invocationId', { runId: RUN_ID, invocationId: 'short', seedId: SEED_ID }, /invocationId/], + ['seedId', { runId: RUN_ID, invocationId: INVOCATION_ID, seedId: 'zz' }, /seedId/], + ['runId with injection', { + runId: `${RUN_ID}; rm -rf /`, invocationId: INVOCATION_ID, seedId: SEED_ID, + }, /runId/], + ])('rejects a malformed or untrusted %s', (_name, params, message) => { + expect(() => deriveSbxEnclaveSpec({ config, ...params })).toThrow(message); + }); + }); + + describe('assertAvailable: capability rejection', () => { + it('blocks the audited sbx CLI and reports every missing capability', async () => { + const missing = ['pinned AWF bounded-agent sbx template and bootstrap', 'sbx create --network']; + const runner = new SbxEnclaveRunner(config, { + probe: async () => ({ supported: false, missing }), + }); + + await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s); + await expect(runner.assertAvailable()).rejects.toThrow( + 'pinned AWF bounded-agent sbx template and bootstrap', + ); + await expect(runner.assertAvailable()).rejects.toThrow('sbx create --network'); + }); + + it('never launches when the probe throws instead of returning a report', async () => { + const runner = new SbxEnclaveRunner(config, { + probe: async () => { + throw new Error('sbx CLI not found'); + }, + }); + await expect(runner.assertAvailable()).rejects.toThrow('sbx CLI not found'); + }); + }); + + describe('runEnclaveContainer: create/exec timeout accounting', () => { + it('runs exec with the remaining budget after a successful create', async () => { + let now = 0; + const { calls, timeouts, client } = createSbx((args) => { + if (args[0] === 'create') { + now += 10_000; // simulate elapsed wall-clock time during create + } + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + probe: availableProbe, + files, + nowMs: () => now, + }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 60_000, + }); + + expect(result).toEqual({ exitCode: 0, timedOut: false }); + const createIndex = calls.findIndex((call) => call[0] === 'create'); + const execIndex = calls.findIndex((call) => call[0] === 'exec'); + expect(createIndex).toBeGreaterThanOrEqual(0); + expect(execIndex).toBeGreaterThan(createIndex); + const orderedTimeouts = [...timeouts]; + const [createTimeoutMs, execTimeoutMs] = createIndex < execIndex + ? [orderedTimeouts[createIndex], orderedTimeouts[execIndex]] + : [orderedTimeouts[execIndex], orderedTimeouts[createIndex]]; + // create is capped at 120s even though the full budget (60s + grace) is larger. + expect(createTimeoutMs).toBeLessThanOrEqual(120_000); + // exec receives the budget remaining after create's simulated 10s elapsed. + expect(execTimeoutMs).toBeLessThanOrEqual(60_000 + 15_000); + expect(execTimeoutMs).toBeLessThan(createTimeoutMs); + }); + + it('returns a timed-out result and never execs when create itself times out', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'create' ? ok({ timedOut: true, exitCode: 124 }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1_000, + }); + + expect(result).toEqual({ exitCode: 124, timedOut: true }); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + // Cleanup still runs deterministically after a create timeout. + expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + }); + + it('skips exec and reports a synthetic timeout when the deadline elapses between create and exec', async () => { + let now = 0; + const { calls, client } = createSbx((args) => { + if (args[0] === 'create') { + now += 1_000_000; // blow through the deadline entirely during create + } + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { + sbx: client, probe: availableProbe, files, nowMs: () => now, + }); + + const result = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1_000, + }); + + expect(result).toEqual({ exitCode: 124, timedOut: true }); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + }); + + it('throws and still cleans up when create fails outright', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'create' ? ok({ exitCode: 1 }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to create bounded-agent sbx VM'); + expect(calls.some((call) => call[0] === 'exec')).toBe(false); + expect(calls).toContainEqual(['stop', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + expect(calls).toContainEqual(['rm', '--force', `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`]); + }); + }); + + describe('deterministic stop/rm cleanup, including cleanup failures', () => { + it('always force-removes the uniquely named VM before returning a success', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--quiet' ? ok({ stdout: '' }) : ok() + )); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).resolves.toEqual({ exitCode: 0, timedOut: false }); + + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + expect(calls.find((args) => args[0] === 'create')).toContain(name); + expect(calls.find((args) => args[0] === 'exec')).toContain(name); + expect(calls).toContainEqual(['stop', name]); + expect(calls).toContainEqual(['rm', '--force', name]); + expect(calls[calls.length - 1]).toEqual(['rm', '--force', name]); + }); + + it('preserves a successful result when stop fails but inventory confirms the VM is already gone', async () => { + const { calls, client } = createSbx((args) => { + if (args[0] === 'stop') return ok({ exitCode: 1 }); + if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: '' }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).resolves.toEqual({ exitCode: 0, timedOut: false }); + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + expect(calls).toContainEqual(['rm', '--force', name]); + }); + + it('fails closed when stop fails and inventory still lists the VM', async () => { + const name = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + const { client } = createSbx((args) => { + if (args[0] === 'stop') return ok({ exitCode: 1 }); + if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: `${name}\n` }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to stop bounded-agent sbx VM'); + }); + + it('fails closed when remove fails after a successful stop', async () => { + const { client } = createSbx((args) => (args[0] === 'rm' ? ok({ exitCode: 1 }) : ok())); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to remove bounded-agent sbx VM'); + }); + + it('surfaces the cleanup failure even when the run itself also failed (cleanup takes priority)', async () => { + const { client } = createSbx((args) => { + if (args[0] === 'create') return ok({ exitCode: 1 }); + if (args[0] === 'rm') return ok({ exitCode: 1 }); + return ok(); + }); + const { files } = createFiles(); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe, files }); + + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow('Failed to remove bounded-agent sbx VM'); + }); + + it('serializes interruption reconciliation with per-invocation cleanup', async () => { + const events: string[] = []; + let releaseStop: (() => void) | undefined; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + let stopCount = 0; + const { client } = createSbx(async (args) => { + if (args[0] === 'stop') { + stopCount += 1; + const label = `stop-${stopCount}`; + events.push(`${label}-start`); + if (stopCount === 1) await stopGate; + events.push(`${label}-end`); + return ok(); + } + if (args[0] === 'ls' && args[1] === '--json') { + events.push('reconcile-list'); + return ok({ stdout: '[]' }); + } + return ok(); + }); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + const invocationCleanup = runner.cleanupInvocation(RUN_ID, INVOCATION_ID); + const reconciliation = runner.reconcileRun(RUN_ID); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(['stop-1-start']); + releaseStop?.(); + await Promise.all([invocationCleanup, reconciliation]); + expect(events).toEqual(['stop-1-start', 'stop-1-end', 'reconcile-list']); + }); + }); + + describe('reconcileRun: prefix-scoped reconciliation', () => { + it('reconciles only sbx VMs with the current trusted run prefix', async () => { + const staleName = `awf-bounded-agent-sbx-${RUN_ID}-222222222222222222222222`; + const { calls, client } = createSbx((args) => { + if (args[0] === 'ls' && args[1] === '--json') { + return ok({ + stdout: JSON.stringify([ + { name: staleName }, + { name: 'awf-bounded-agent-sbx-other-run-333333333333333333333333' }, + { name: 'awf-query-sbx-primary' }, + ]), + }); + } + return ok(); + }); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await runner.reconcileRun(RUN_ID); + + expect(calls).toContainEqual(['stop', staleName]); + expect(calls).toContainEqual(['rm', '--force', staleName]); + expect(calls.join(' ')).not.toContain('other-run'); + expect(calls.join(' ')).not.toContain('awf-query-sbx-primary'); + }); + + it('removes nothing when no VM in inventory matches this run prefix', async () => { + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' + ? ok({ stdout: JSON.stringify([{ name: 'awf-bounded-agent-sbx-unrelated-000000000000000000000000' }]) }) + : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await runner.reconcileRun(RUN_ID); + + expect(calls.some((call) => call[0] === 'stop' || call[0] === 'rm')).toBe(false); + }); + + it('fails closed when listing sandboxes itself fails', async () => { + const { client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' ? ok({ exitCode: 1 }) : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow('Failed to reconcile bounded-agent sbx VMs'); + }); + }); + + describe('parseSandboxNames: malformed inventory rejection', () => { + it('rejects non-JSON, non-array, and shell-metacharacter-bearing inventory', () => { + expect(() => parseSandboxNames('not json')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/); + expect(() => parseSandboxNames('[{"name":"; rm -rf /"}]')).toThrow(/invalid sandbox name/); + expect(() => parseSandboxNames('[{}]')).toThrow(/invalid sandbox name/); + }); + + it('accepts a well-formed sandbox name list', () => { + expect(parseSandboxNames('[{"name":"awf-bounded-agent-sbx-abc-123"}]')).toEqual([ + 'awf-bounded-agent-sbx-abc-123', + ]); + }); + + it('rejects malformed sbx inventory rather than accepting cleanup injection', async () => { + const { client } = createSbx((args) => ( + args[0] === 'ls' ? ok({ stdout: '[{"name":"--all"}]' }) : ok() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client, probe: availableProbe }); + + await expect(runner.reconcileRun(RUN_ID)).rejects.toThrow(/invalid sandbox name/); + }); + }); +}); diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 94fba7b79..09fd37f81 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -472,10 +472,79 @@ describe('createMainAction', () => { ]; expect(JSON.stringify(logCalls)).not.toContain(capability); expect(mockedBoundedAgentIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); + + // Thread 4: telemetry must never report `ready` before the sbx ingress + // proof (assertSbxBoundedAgentIngress) actually succeeds. + const telemetryEvents = mockedLogger.info.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + expect(telemetryEvents).toContainEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'supported', + category: 'ready', + })); + }); + + it('reports a terminal unproven event and never `ready` when sbx ingress proof fails', async () => { + const sbxConfig = { + ...MAIN_ACTION_STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + enableApiProxy: true, + boundedAgentIngressTransport: 'sbx-http', + boundedAgents: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + profile: 'openai', + model: 'gpt-4o-mini', + timeout: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, + maxOutputBytes: 8192, + maxTaskBytes: 4096, + }, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedBoundedAgentIngress.resolveSbxIngress.mockResolvedValue({ + endpoint: 'http://host.docker.internal:49154/query', + queryCapability: 'e'.repeat(64), + probeCapability: 'f'.repeat(64), + skillPath: '/var/tmp/bounded-agent-ingress/skill/SKILL.md', + wrapperDir: '/var/tmp/bounded-agent-ingress/skill', + }); + mockedSbxManager.assertSbxBoundedAgentIngress.mockRejectedValueOnce( + new Error('sbx host does not support the selected bounded-agent sbx-http ingress'), + ); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + return (await deps.runAgentCommand('/tmp/awf-test', ['github.com'])).exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await expect(action(['bounded-agent --repo octo/private'], {})).rejects.toThrow('process.exit: 1'); + + const telemetryEvents = mockedLogger.info.mock.calls + .map((call) => String(call[0])) + .filter((line) => line.startsWith('Bounded-agent runtime telemetry: ')) + .map((line) => JSON.parse(line.slice('Bounded-agent runtime telemetry: '.length))); + expect(telemetryEvents.some((event) => event.category === 'ready')).toBe(false); + expect(telemetryEvents).toContainEqual(expect.objectContaining({ + primaryBackend: 'sbx', + capabilityState: 'unavailable', + category: 'primary-sbx-ingress-unproven', + })); }); }); }); + describe('when runMainWorkflow throws', () => { it('calls performCleanup and exits with code 1', async () => { mockedCliWorkflow.runMainWorkflow.mockRejectedValue(new Error('docker failed')); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index a3c88b718..6734a68ce 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -38,7 +38,11 @@ import { SBX_DEFAULT_NAME, } from '../sbx-manager'; import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager'; -import { prepareBoundedAgents, teardownBoundedAgents } from '../bounded-agent/manager'; +import { + prepareBoundedAgents, + reportBoundedAgentSbxIngressResult, + teardownBoundedAgents, +} from '../bounded-agent/manager'; import type { WrapperConfig } from '../types'; import { buildAgentEnvironment } from '../services/agent-service'; import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; @@ -444,18 +448,30 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { } if (sbxBoundedAgentIngress) { - await assertSbxBoundedAgentIngress( - sbxName, - sbxBoundedAgentIngress.transport === 'unix' - ? sbxBoundedAgentIngress - : { - transport: 'sbx-http', - endpoint: sbxBoundedAgentIngress.endpoint, - probeCapability: sbxBoundedAgentIngress.probeCapability, - }, - sbxEnvironment, - config.containerWorkDir, - ); + try { + await assertSbxBoundedAgentIngress( + sbxName, + sbxBoundedAgentIngress.transport === 'unix' + ? sbxBoundedAgentIngress + : { + transport: 'sbx-http', + endpoint: sbxBoundedAgentIngress.endpoint, + probeCapability: sbxBoundedAgentIngress.probeCapability, + }, + sbxEnvironment, + config.containerWorkDir, + ); + } catch (error) { + // Preflight only proved the sbx CLI and enclave capability exist; + // this is the executable proof that the selected ingress + // transport is actually reachable from inside the sandbox. Never + // report `ready` telemetry when that proof fails. + reportBoundedAgentSbxIngressResult(config, 'failed'); + throw error; + } + // Ingress is proven reachable now — this is the only point a + // primary-sbx run is ever reported `ready`. + reportBoundedAgentSbxIngressResult(config, 'proven'); Object.assign(sbxEnvironment, { AWF_BOUNDED_AGENT_SKILL: boundedAgentPaths.skillPath, From 3f57089614ac6cef7d91310ce3a256cda98a235e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 2 Aug 2026 22:19:16 -0700 Subject: [PATCH 4/4] feat(bounded-agent): finish enclave hardening (#6876) --- .../smoke-bounded-agents-gvisor.lock.yml | 1382 +++++++++++++++++ .../workflows/smoke-bounded-agents-gvisor.md | 121 ++ .../workflows/smoke-bounded-agents.lock.yml | 1369 ++++++++++++++++ .github/workflows/smoke-bounded-agents.md | 104 ++ .../test-bounded-agent-runtime-matrix.yml | 93 ++ README.md | 1 + docs/awf-config-spec.md | 14 +- docs/bounded-agents.md | 29 +- scripts/ci/probe-bounded-agent-primary-sbx.js | 25 + .../ci/report-bounded-agent-runtime-matrix.js | 39 +- ...eport-bounded-agent-runtime-matrix.test.ts | 44 +- scripts/ci/smoke-bounded-agent-enclave.sh | 144 ++ src/bounded-agent/manager.test.ts | 41 + src/bounded-agent/sbx-runner.test.ts | 169 ++ src/services/bounded-agent-service.test.ts | 34 + 15 files changed, 3540 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/smoke-bounded-agents-gvisor.lock.yml create mode 100644 .github/workflows/smoke-bounded-agents-gvisor.md create mode 100644 .github/workflows/smoke-bounded-agents.lock.yml create mode 100644 .github/workflows/smoke-bounded-agents.md create mode 100644 .github/workflows/test-bounded-agent-runtime-matrix.yml create mode 100755 scripts/ci/probe-bounded-agent-primary-sbx.js create mode 100755 scripts/ci/smoke-bounded-agent-enclave.sh create mode 100644 src/bounded-agent/sbx-runner.test.ts diff --git a/.github/workflows/smoke-bounded-agents-gvisor.lock.yml b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml new file mode 100644 index 000000000..01a081fd5 --- /dev/null +++ b/.github/workflows/smoke-bounded-agents-gvisor.lock.yml @@ -0,0 +1,1382 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"815e1a6139f2221439fefb96de95038a0cbfa55a948673004935913824ef04be","body_hash":"245ddc5ea0c938d471a36a6fd125e26f7b84f455eeb2706f3d9fa8a4a3c5ee16","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} +# This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# End-to-end smoke test for finite-schema gVisor bounded-agent enclaves +# +# Frontmatter env variables: +# - GH_TOKEN: (main workflow) +# - OPENAI_API_KEY: (main workflow) +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# - OPENAI_API_KEY +# +# Custom actions used: +# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 +# +# Container images used: +# - +# - +# - +# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 +# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b +# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + +name: "Smoke Bounded Agents gVisor" +on: + schedule: + - cron: "41 */12 * * *" # Friendly format: every 12h (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + cancel-in-progress: false + group: smoke-bounded-agents-gvisor + +run-name: "Smoke Bounded Agents gVisor" + +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + env: + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + comment_id: "" + comment_repo: "" + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AGENT_VERSION: "1.0.34" + GH_AW_INFO_CLI_VERSION: "v0.84.2" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "false" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Enforce strict mode policy + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} + run: | + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + exit 1 + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs'); + await main(); + - name: Check daily workflow token guardrail + id: daily-effective-workflow-guardrail + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs'); + await main(); + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "smoke-bounded-agents-gvisor.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.84.2" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + Tools: create_issue, missing_tool, missing_data, noop + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_2cf238e256fdffdc_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_2cf238e256fdffdc_EOF' + + {{#runtime-import .github/workflows/smoke-bounded-agents-gvisor.md}} + GH_AW_PROMPT_2cf238e256fdffdc_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/models.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + if: needs.activation.outputs.daily_ai_credits_exceeded != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + copilot-requests: write + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagentsgvisor + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-mcp-gateway.outputs.aic }} + ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} + max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} + missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Build unreleased AWF + run: |- + npm ci + npm run build + + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 + env: + GH_HOST: github.com + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"gvisor\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE" + + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_4ac93f4cf2185140_EOF' + {"create_issue":{"labels":["smoke-bounded-agents-gvisor"],"max":1,"title_prefix":"[smoke-bounded-agents-gvisor]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_4ac93f4cf2185140_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents-gvisor]\". Labels [\"smoke-bounded-agents-gvisor\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000, + "minLength": 20 + }, + "fields": { + "type": "array" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} + GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.7' + + mkdir -p "$HOME/.copilot" + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "env": { + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ], + "sink-visibility": "${GH_AW_SINK_VISIBILITY}" + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120 + } + } + GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 30 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ github.token }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_TIMEOUT_MINUTES: 30 + GH_AW_VERSION: v0.84.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + S2STOKENS: true + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl + if: always() + name: Validate gVisor bounded-agent invocation + run: "node - \"$AUDIT_LOG\" \"$TELEMETRY_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, telemetryPath, outputsPath] = process.argv.slice(2);\nconst read = (file) => fs.readFileSync(file, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = read(auditPath).filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1 || invocations[0].outcome !== \"ok\") {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst successes = read(telemetryPath).filter((record) =>\n record.primaryBackend === \"docker\" &&\n record.boundedAgentBackend === \"gvisor\" &&\n record.lifecycleClass === \"invocation\" &&\n record.category === \"success\");\nif (successes.length !== 1) {\n throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`);\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE" + + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.daily_ai_credits_exceeded == 'true') + runs-on: ubuntu-slim + permissions: + issues: write + concurrency: + group: "gh-aw-conclusion-smoke-bounded-agents-gvisor" + cancel-in-progress: false + queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download safe outputs items manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: safe-outputs-items + path: /tmp/gh-aw/ + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: | + mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + echo "Usage artifact source file status:" + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" + done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true + [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true + [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true + [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true + [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl + [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" + find /tmp/gh-aw/usage -type f -print | sort + - name: Upload usage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagentsgvisor- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagentsgvisor-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} + GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} + GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} + GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "30" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + permissions: + issues: write + timeout-minutes: 45 + env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents-gvisor" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.34" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents-gvisor" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents-gvisor.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents gVisor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents-gvisor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents-gvisor\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents-gvisor]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/process-safe-outputs.stdout.log + /tmp/gh-aw/process-safe-outputs.stderr.log + if-no-files-found: ignore diff --git a/.github/workflows/smoke-bounded-agents-gvisor.md b/.github/workflows/smoke-bounded-agents-gvisor.md new file mode 100644 index 000000000..1c3c0c9ae --- /dev/null +++ b/.github/workflows/smoke-bounded-agents-gvisor.md @@ -0,0 +1,121 @@ +--- +name: Smoke Bounded Agents gVisor +description: End-to-end smoke test for finite-schema gVisor bounded-agent enclaves +on: + schedule: every 12h + workflow_dispatch: +permissions: + contents: read + copilot-requests: write +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +engine: + id: copilot + version: 1.0.34 +network: + allowed: + - defaults + - github +tools: + github: + toolsets: [context] + allowed: [] +sandbox: + agent: + id: awf + version: v0.28.0 + args: + - --build-local +steps: + - name: Build unreleased AWF + run: | + npm ci + npm run build +pre-agent-steps: + - name: Install gVisor + run: | + set -euo pipefail + arch="$(uname -m)" + url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}" + curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc" + curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512" + (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512) + sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc + sudo runsc install + sudo systemctl restart docker + docker info --format '{{json .Runtimes}}' | grep -F '"runsc"' + - name: Replace release bootstrap with current AWF build + run: | + mkdir -p "$HOME/.local/bin" + printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \ + "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf" + chmod +x "$HOME/.local/bin/awf" + node <<'NODE' + const fs = require("fs"); + const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`; + const config = JSON.parse(fs.readFileSync(file, "utf8")); + config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } }; + config.boundedAgents = { + enabled: true, + privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }], + runtime: "gvisor", + profile: "openai", + model: "gpt-4o-mini", + memoryLimit: "512m" + }; + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + NODE +safe-outputs: + threat-detection: + enabled: false +timeout-minutes: 30 +strict: false +concurrency: + group: smoke-bounded-agents-gvisor + cancel-in-progress: false +post-steps: + - name: Validate gVisor bounded-agent invocation + if: always() + env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + TELEMETRY_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent-runtime.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + node - "$AUDIT_LOG" "$TELEMETRY_LOG" "$OUTPUTS_FILE" <<'NODE' + const fs = require("fs"); + const [auditPath, telemetryPath, outputsPath] = process.argv.slice(2); + const read = (file) => fs.readFileSync(file, "utf8").trim().split("\n") + .filter(Boolean).map((line) => JSON.parse(line)); + const invocations = read(auditPath).filter((record) => + record.kind === "invocation" && record.sensitivity === "internal"); + if (invocations.length !== 1 || invocations[0].outcome !== "ok") { + throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`); + } + const successes = read(telemetryPath).filter((record) => + record.primaryBackend === "docker" && + record.boundedAgentBackend === "gvisor" && + record.lifecycleClass === "invocation" && + record.category === "success"); + if (successes.length !== 1) { + throw new Error(`expected one successful gVisor telemetry record, found ${successes.length}`); + } + const outputs = fs.readFileSync(outputsPath, "utf8"); + if (!outputs.includes('"noop"') || !outputs.includes("PASS")) { + throw new Error("agent did not report PASS through noop"); + } + NODE +--- + +# Smoke Test: gVisor Bounded Agent + +Use the generated `bounded-agent` skill exactly once to answer this boolean +question about `github/gh-aw`: does the repository root contain a `go.mod` +file? + +Use a boolean schema. Do not use GitHub tools, network requests, shell commands, +or the current checkout to answer. The test passes only when a fresh gVisor +enclave returns `true`. + +Call `noop` with `PASS true` only when the result is true. Otherwise call +`safeoutputs-missing_data`. Never report failure through `noop`. diff --git a/.github/workflows/smoke-bounded-agents.lock.yml b/.github/workflows/smoke-bounded-agents.lock.yml new file mode 100644 index 000000000..fc01bf40c --- /dev/null +++ b/.github/workflows/smoke-bounded-agents.lock.yml @@ -0,0 +1,1369 @@ +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"764391cb1121f45d194e98863b8369c07ed4e3cc392f5bc79d91728508d57a36","body_hash":"e86fc590352c926acbc317b5f565ea542ef92789a891987365678bf5365c6308","compiler_version":"v0.84.2","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"fd783ac87efde5e0c0e05d593f1906ea25b5d92e","version":"v0.84.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.0"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} +# This file was automatically generated by gh-aw (v0.84.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# End-to-end smoke test for finite-schema Docker bounded-agent enclaves +# +# Frontmatter env variables: +# - GH_TOKEN: (main workflow) +# - OPENAI_API_KEY: (main workflow) +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# - OPENAI_API_KEY +# +# Custom actions used: +# - actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 +# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 +# +# Container images used: +# - +# - +# - +# - ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 +# - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b +# - ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + +name: "Smoke Bounded Agents" +on: + schedule: + - cron: "12 */12 * * *" # Friendly format: every 12h (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + cancel-in-progress: false + group: smoke-bounded-agents + +run-name: "Smoke Bounded Agents" + +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + env: + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + comment_id: "" + comment_repo: "" + daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }} + daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }} + daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AGENT_VERSION: "1.0.34" + GH_AW_INFO_CLI_VERSION: "v0.84.2" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "false" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Enforce strict mode policy + if: ${{ vars.GH_AW_POLICY_STRICT == 'true' }} + run: | + echo "::error::GH_AW_POLICY_STRICT=true but this workflow was not compiled in strict mode. Recompile with --strict or strict: true." + exit 1 + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagents- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Restore daily AIC usage cache (artifact fallback) + id: restore-daily-aic-cache-fallback + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_RESTORE_DAILY_AIC_CACHE_HIT: ${{ steps.restore-daily-aic-cache.outputs.cache-hit }} + GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY: ${{ steps.restore-daily-aic-cache.outputs.cache-matched-key }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/restore_aic_usage_cache_fallback.cjs'); + await main(); + - name: Check daily workflow token guardrail + id: daily-effective-workflow-guardrail + if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }} + GH_AW_HAS_SLASH_COMMAND: "false" + GH_AW_HAS_LABEL_COMMAND: "false" + GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs'); + await main(); + - name: Check for OAuth tokens + id: check-oauth-tokens + run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh" + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "smoke-bounded-agents.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.84.2" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Log runtime features + if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + Tools: create_issue, missing_tool, missing_data, noop + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_auto_create_issue.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_1498a4d66b22842d_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_1498a4d66b22842d_EOF' + + {{#runtime-import .github/workflows/smoke-bounded-agents.md}} + GH_AW_PROMPT_1498a4d66b22842d_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/models.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + if: needs.activation.outputs.daily_ai_credits_exceeded != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + copilot-requests: write + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + queue: max + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID_SANITIZED: smokeboundedagents + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }} + aic: ${{ steps.parse-mcp-gateway.outputs.aic }} + ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }} + max_cache_misses_exceeded: ${{ steps.detect-agent-errors.outputs.max_cache_misses_exceeded || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }} + missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Build unreleased AWF + run: |- + npm ci + npm run build + + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.34 + env: + GH_HOST: github.com + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null < \"$HOME/.local/bin/awf\"\nchmod +x \"$HOME/.local/bin/awf\"\nnode <<'NODE'\nconst fs = require(\"fs\");\nconst file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`;\nconst config = JSON.parse(fs.readFileSync(file, \"utf8\"));\nconfig.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } };\nconfig.boundedAgents = {\n enabled: true,\n privateRepos: [{ repo: \"github/gh-aw\", sensitivity: \"internal\" }],\n runtime: \"docker\",\n profile: \"openai\",\n model: \"gpt-4o-mini\",\n memoryLimit: \"512m\"\n};\nfs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\\n`, { mode: 0o600 });\nNODE" + + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520 + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_3112521b44aaa676_EOF' + {"create_issue":{"labels":["smoke-bounded-agents"],"max":1,"title_prefix":"[smoke-bounded-agents]"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_3112521b44aaa676_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[smoke-bounded-agents]\". Labels [\"smoke-bounded-agents\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000, + "minLength": 20 + }, + "fields": { + "type": "array" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }} + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }} + GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="awmg-mcpg" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.7' + + mkdir -p "$HOME/.copilot" + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.8.0", + "env": { + "GITHUB_FEATURES": "fields_param", + "GITHUB_HOST": "${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "stdio", + "container": "ghcr.io/github/gh-aw-node", + "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"], + "args": ["-w", "\${GITHUB_WORKSPACE}"], + "entrypoint": "sh", + "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"], + "env": { + "DEBUG": "*", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "RUNNER_TEMP": "\${RUNNER_TEMP}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ], + "sink-visibility": "${GH_AW_SINK_VISIBILITY}" + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}", + "startupTimeout": 120 + } + } + GH_AW_MCP_CONFIG_a59a0690e5cab2b2_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT + mkdir -p "$HOME/.copilot" + printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + export XDG_CONFIG_HOME="$HOME" + export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + GH_AW_MAX_AI_CREDITS="${GH_AW_MAX_AI_CREDITS:-1000}" + printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.28.0/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"*.githubusercontent.com\",\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"codeload.github.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"docs.github.com\",\"github-cloud.githubusercontent.com\",\"github-cloud.s3.amazonaws.com\",\"github.blog\",\"github.com\",\"github.githubassets.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"lfs.github.com\",\"objects.githubusercontent.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"patch-diff.githubusercontent.com\",\"patchdiff.githubusercontent.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"],\"isolation\":true,\"topologyAttach\":[\"awmg-mcpg\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"maxCacheMisses\":5,\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.5\",\"gpt-5.6\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"auto\":[\"copilot/auto\",\"large\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\",\"kimi\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"fable\":[\"copilot/*fable*\",\"anthropic/*fable*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-3.6-flash\":[\"copilot/gemini-3.6*flash*\",\"google/gemini-3.6*flash*\",\"gemini/gemini-3.6*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-omni\":[\"copilot/gemini-omni*\",\"google/gemini-omni*\",\"gemini/gemini-omni*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.1\":[\"copilot/gpt-5.1*\",\"openai/gpt-5.1*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"gpt-5.6\":[\"copilot/gpt-5.6*\",\"openai/gpt-5.6*\"],\"grok\":[\"copilot/*grok*\",\"openai/*grok*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"image-generation\":[\"copilot/gpt-image*\",\"openai/gpt-image*\",\"openai/chatgpt-image*\",\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"google/imagen*\"],\"kimi\":[\"copilot/kimi*\",\"openai/kimi*\"],\"kiwi\":[\"copilot/kiwi*\",\"openai/kiwi*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"lyria\":[\"google/lyria*\",\"gemini/lyria*\",\"copilot/lyria*\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mai-code-1-flash-picker\":[\"copilot/MAI-Code-1-Flash-picker*\",\"copilot/mai-code-1-flash-picker*\",\"openai/MAI-Code-1-Flash-picker*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"raptor-mini\":[\"copilot/raptor*\",\"openai/raptor*\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-5*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\",\"anthropic/*sonnet-5*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"veo\":[\"google/veo*\",\"gemini/veo*\"],\"vision\":[\"copilot/gemini-*image*\",\"google/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.28.0\"},\"logging\":{\"proxyLogsDir\":\"/tmp/gh-aw/sandbox/firewall/logs\",\"auditDir\":\"/tmp/gh-aw/sandbox/firewall/audit\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json" + GH_AW_DOCKER_HOST="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST="${DOCKER_HOST}" + fi + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs" + fi + GH_AW_TOOL_CACHE_MOUNT="" + GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}" + if [ -d "$GH_AW_TOOL_CACHE" ]; then + if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then + GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro" + fi + fi + # shellcheck disable=SC1003,SC2016,SC2086 + awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --build-local \ + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ github.token }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} + GH_AW_LLM_PROVIDER: github + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }} + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_TIMEOUT_MINUTES: 20 + GH_AW_VERSION: v0.84.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + RUNNER_TEMP: ${{ runner.temp }} + S2STOKENS: true + TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }} + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + if: always() + name: Validate bounded-agent invocation + run: "node - \"$AUDIT_LOG\" \"$OUTPUTS_FILE\" <<'NODE'\nconst fs = require(\"fs\");\nconst [auditPath, outputsPath] = process.argv.slice(2);\nconst records = fs.readFileSync(auditPath, \"utf8\").trim().split(\"\\n\")\n .filter(Boolean).map((line) => JSON.parse(line));\nconst invocations = records.filter((record) =>\n record.kind === \"invocation\" && record.sensitivity === \"internal\");\nif (invocations.length !== 1 || invocations[0].outcome !== \"ok\") {\n throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`);\n}\nconst serialized = JSON.stringify(records);\nif (serialized.includes(\"github/gh-aw\") || serialized.includes(\"SECURITY.md\")) {\n throw new Error(\"protected audit disclosed repository-derived content\");\n}\nconst outputs = fs.readFileSync(outputsPath, \"utf8\");\nif (!outputs.includes('\"noop\"') || !outputs.includes(\"PASS\")) {\n throw new Error(\"agent did not report PASS through noop\");\n}\nNODE" + + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true' || + needs.activation.outputs.daily_ai_credits_exceeded == 'true') + runs-on: ubuntu-slim + permissions: + issues: write + concurrency: + group: "gh-aw-conclusion-smoke-bounded-agents" + cancel-in-progress: false + queue: max + env: + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download safe outputs items manifest + id: download-safe-outputs-manifest + if: always() + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: safe-outputs-items + path: /tmp/gh-aw/ + - name: Collect usage artifact files + if: always() + continue-on-error: true + run: | + mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection + echo "Usage artifact source file status:" + for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do + [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file" + done + [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true + [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true + [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true + [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true + [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true + [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true + [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true + [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl + [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl + mkdir -p /tmp/gh-aw/usage/activity + node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs" + find /tmp/gh-aw/usage -type f -print | sort + - name: Upload usage artifact + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: usage + path: | + /tmp/gh-aw/usage/aw_info.json + /tmp/gh-aw/usage/aw-info.jsonl + /tmp/gh-aw/usage/agent_usage.json + /tmp/gh-aw/usage/agent_usage.jsonl + /tmp/gh-aw/usage/detection_usage.jsonl + /tmp/gh-aw/usage/evals.jsonl + /tmp/gh-aw/usage/github_rate_limits.jsonl + /tmp/gh-aw/usage/agent/token_usage.jsonl + /tmp/gh-aw/usage/detection/token_usage.jsonl + /tmp/gh-aw/usage/activity/summary.json + if-no-files-found: ignore + - name: Restore daily AIC usage cache + id: restore-daily-aic-cache-conclusion + if: always() + continue-on-error: true + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + restore-keys: agentic-workflow-usage-smokeboundedagents- + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Write daily AIC usage cache entry + id: write-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context); + const { main } = require('${{ runner.temp }}/gh-aw/actions/write_daily_aic_usage_cache.cjs'); + await main(); + - name: Save daily AIC usage cache + id: save-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + key: agentic-workflow-usage-smokeboundedagents-${{ github.run_id }} + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + - name: Upload daily AIC usage cache artifact + id: upload-daily-aic-cache + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aic-usage-cache + path: /tmp/gh-aw/agentic-workflow-usage-cache.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }} + GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }} + GH_AW_MAX_CACHE_MISSES_EXCEEDED: ${{ needs.agent.outputs.max_cache_misses_exceeded }} + GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }} + GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }} + GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }} + GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + permissions: + issues: write + timeout-minutes: 45 + env: + GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AIC: ${{ needs.agent.outputs.aic }} + GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }} + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-bounded-agents" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.34" + GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} + GH_AW_WORKFLOW_ID: "smoke-bounded-agents" + GH_AW_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/smoke-bounded-agents.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@fd783ac87efde5e0c0e05d593f1906ea25b5d92e # v0.84.2 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Smoke Bounded Agents" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/smoke-bounded-agents.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.34" + GH_AW_INFO_AWF_VERSION: "v0.28.0" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input. + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"smoke-bounded-agents\"],\"max\":1,\"title_prefix\":\"[smoke-bounded-agents]\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + /tmp/gh-aw/process-safe-outputs.stdout.log + /tmp/gh-aw/process-safe-outputs.stderr.log + if-no-files-found: ignore diff --git a/.github/workflows/smoke-bounded-agents.md b/.github/workflows/smoke-bounded-agents.md new file mode 100644 index 000000000..a6d701d89 --- /dev/null +++ b/.github/workflows/smoke-bounded-agents.md @@ -0,0 +1,104 @@ +--- +name: Smoke Bounded Agents +description: End-to-end smoke test for finite-schema Docker bounded-agent enclaves +on: + schedule: every 12h + workflow_dispatch: +permissions: + contents: read + copilot-requests: write +env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +engine: + id: copilot + version: 1.0.34 +network: + allowed: + - defaults + - github +tools: + github: + toolsets: [context] + allowed: [] +sandbox: + agent: + id: awf + version: v0.28.0 + args: + - --build-local +steps: + - name: Build unreleased AWF + run: | + npm ci + npm run build +pre-agent-steps: + - name: Replace release bootstrap with current AWF build + run: | + mkdir -p "$HOME/.local/bin" + printf '#!/bin/bash\nexec "%s" "%s/dist/cli.js" "$@"\n' \ + "$(command -v node)" "$GITHUB_WORKSPACE" > "$HOME/.local/bin/awf" + chmod +x "$HOME/.local/bin/awf" + node <<'NODE' + const fs = require("fs"); + const file = `${process.env.RUNNER_TEMP}/gh-aw/awf-config.json`; + const config = JSON.parse(fs.readFileSync(file, "utf8")); + config.apiProxy = { ...(config.apiProxy || {}), targets: { openai: {} } }; + config.boundedAgents = { + enabled: true, + privateRepos: [{ repo: "github/gh-aw", sensitivity: "internal" }], + runtime: "docker", + profile: "openai", + model: "gpt-4o-mini", + memoryLimit: "512m" + }; + fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + NODE +safe-outputs: + threat-detection: + enabled: false +timeout-minutes: 20 +strict: false +concurrency: + group: smoke-bounded-agents + cancel-in-progress: false +post-steps: + - name: Validate bounded-agent invocation + if: always() + env: + AUDIT_LOG: /tmp/gh-aw/sandbox/firewall/audit/bounded-agent.jsonl + OUTPUTS_FILE: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + node - "$AUDIT_LOG" "$OUTPUTS_FILE" <<'NODE' + const fs = require("fs"); + const [auditPath, outputsPath] = process.argv.slice(2); + const records = fs.readFileSync(auditPath, "utf8").trim().split("\n") + .filter(Boolean).map((line) => JSON.parse(line)); + const invocations = records.filter((record) => + record.kind === "invocation" && record.sensitivity === "internal"); + if (invocations.length !== 1 || invocations[0].outcome !== "ok") { + throw new Error(`expected one successful bounded-agent invocation, found ${invocations.length}`); + } + const serialized = JSON.stringify(records); + if (serialized.includes("github/gh-aw") || serialized.includes("SECURITY.md")) { + throw new Error("protected audit disclosed repository-derived content"); + } + const outputs = fs.readFileSync(outputsPath, "utf8"); + if (!outputs.includes('"noop"') || !outputs.includes("PASS")) { + throw new Error("agent did not report PASS through noop"); + } + NODE +--- + +# Smoke Test: Docker Bounded Agent + +Use the generated `bounded-agent` skill exactly once to answer this boolean +question about `github/gh-aw`: does the repository root contain a `go.mod` +file? + +Use a boolean schema. Do not use GitHub tools, network requests, shell commands, +or the current checkout to answer. The test passes only when the bounded agent +returns `true`. + +Call `noop` with `PASS true` only when the result is true. Otherwise call +`safeoutputs-missing_data`. Never report failure through `noop`. diff --git a/.github/workflows/test-bounded-agent-runtime-matrix.yml b/.github/workflows/test-bounded-agent-runtime-matrix.yml new file mode 100644 index 000000000..2ff38534e --- /dev/null +++ b/.github/workflows/test-bounded-agent-runtime-matrix.yml @@ -0,0 +1,93 @@ +name: Bounded-Agent Runtime Matrix + +on: + pull_request: + paths: + - 'containers/bounded-agent/**' + - 'scripts/ci/report-bounded-agent-runtime-matrix*' + - 'scripts/ci/probe-bounded-agent-primary-sbx.js' + - 'scripts/ci/smoke-bounded-agent-enclave.sh' + - 'src/bounded-agent/**' + - '.github/workflows/test-bounded-agent-runtime-matrix.yml' + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + docker: + name: Docker enclave matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Report all nine cells and require Docker/Docker + run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/docker + - name: Run live Docker enclave smoke + run: bash scripts/ci/smoke-bounded-agent-enclave.sh docker + + gvisor: + name: gVisor enclave matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install registered runsc + run: | + set -euo pipefail + arch="$(uname -m)" + url="https://storage.googleapis.com/gvisor/releases/release/20250707.0/${arch}" + curl -fsSL "${url}/runsc" -o "$RUNNER_TEMP/runsc" + curl -fsSL "${url}/runsc.sha512" -o "$RUNNER_TEMP/runsc.sha512" + (cd "$RUNNER_TEMP" && sha512sum -c runsc.sha512) + sudo install -m 755 "$RUNNER_TEMP/runsc" /usr/local/bin/runsc + sudo runsc install + sudo systemctl restart docker + docker info --format '{{json .Runtimes}}' | grep -F '"runsc"' + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Report all nine cells and require Docker/gVisor + run: node scripts/ci/report-bounded-agent-runtime-matrix.js --require docker/gvisor + - name: Run live gVisor enclave smoke + run: bash scripts/ci/smoke-bounded-agent-enclave.sh gvisor + + sbx-capability: + name: Docker Sandbox capability gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - run: npm ci + - run: npm run build + - name: Require explicit blocked result without fallback + run: | + set -euo pipefail + report="$(node scripts/ci/report-bounded-agent-runtime-matrix.js)" + printf '%s\n' "$report" + rows="$(printf '%s\n' "$report" | grep -cE '^\| (docker|gvisor|sbx) \|')" + test "$rows" -eq 9 + printf '%s\n' "$report" | grep -F '| docker | sbx | BLOCKED |' + printf '%s\n' "$report" | grep -F '| gvisor | sbx | BLOCKED |' + printf '%s\n' "$report" | grep -F '| sbx | sbx | BLOCKED |' diff --git a/README.md b/README.md index 86029e798..d07006ab6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [AWF config schema](docs/awf-config.schema.json) — machine-readable JSON Schema for JSON/YAML configs (also published as a [versioned release asset](https://github.com/github/gh-aw-firewall/releases/latest/download/awf-config.schema.json) for IDE autocomplete) - [AWF config spec](docs/awf-config-spec.md) — normative processing and precedence rules for tooling/compiler integration - [Bounded queries](docs/bounded-queries.md) — run information-budgeted queries against private repositories without exposing their contents +- [Bounded agents](docs/bounded-agents.md) — delegate finite-schema repository analysis to API-proxy-only Docker or gVisor enclaves - [Audit log schema](schemas/audit.schema.json) — JSON Schema for L7 traffic audit records (`audit.jsonl`) - [Token usage schema](schemas/token-usage.schema.json) — JSON Schema for per-call token usage records (`token-usage.jsonl`) - [Schemas README](schemas/README.md) — versioning policy, record identification, and validation examples diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 28e252077..ddaa7e537 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2283,16 +2283,12 @@ downgrades to the default runtime. dedicated bounded-agent sbx capability probe (host-side `src/bounded-agent/sbx-capability.ts`, container-side `containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the -audited Docker Sandboxes CLI's version/auth/help surface — `sbx version`, -`sbx ls` (authenticated, non-mutating daemon reachability), and the -`sbx create --help` / `sbx exec --help` flag listings — against the audited -version (`v0.37.1`) and reports every missing capability in structured JSON — +exact audited Docker Sandboxes CLI surface using `sbx version`, authenticated +non-mutating `sbx ls`, and `create --help` / `exec --help` against the audited +version (`v0.37.1`). It reports every missing capability in structured JSON — never a single collapsed boolean, and never a "not yet implemented" -placeholder. This is help-surface inspection, not an executed lifecycle proof: -the probe never runs `sbx create`, `sbx exec`, `sbx stop`, or `sbx rm`. Those -lifecycle operations exist only in the broker's `SbxEnclaveRunner` — covered by -runner contract tests, not by preflight — and remain unreachable while the -unconditional capability block below stays in force. +placeholder. The blocked runner defines `create`, `exec`, `stop`, and +`rm --force`, but preflight does not claim to execute that lifecycle. The bounded-agent enclave's network requirement is strictly harder than a bounded query's: it must reach *exactly one* peer (the dedicated API proxy), diff --git a/docs/bounded-agents.md b/docs/bounded-agents.md index d146c94ea..5a1cd1d29 100644 --- a/docs/bounded-agents.md +++ b/docs/bounded-agents.md @@ -180,15 +180,11 @@ blanket "not yet implemented" refusal, and never a false pass. AWF ships a dedicated bounded-agent sbx capability probe (`src/bounded-agent/sbx-capability.ts`, mirrored in `containers/bounded-agent/broker/sbx-capability-probe.js`) that inspects the -audited Docker Sandboxes CLI (`v0.37.1`) version/auth/help surface — -`sbx version`, `sbx ls` (authenticated, non-mutating daemon reachability), and -the `sbx create --help` / `sbx exec --help` flag listings — and reports every -missing capability in structured JSON rather than a single boolean. This is -help-surface inspection, not an executed lifecycle proof: the probe never -runs `sbx create`, `sbx exec`, `sbx stop`, or `sbx rm`. Those lifecycle -commands exist only in the broker's `SbxEnclaveRunner`, which the -unconditional capability block below keeps unreachable — they are covered by -runner contract tests, not by preflight. +exact audited Docker Sandboxes CLI (`v0.37.1`) surface with `sbx version`, the +authenticated non-mutating `sbx ls`, and `create --help` / `exec --help`. +Lifecycle commands belong to the blocked runner and are not claimed as an +executed proof. The probe reports every missing capability in structured JSON +rather than a single boolean. The enclave requirement is strictly harder than a bounded query's: an enclave must reach *exactly one* peer (the dedicated API proxy), not "no @@ -291,6 +287,14 @@ demonstrated in real VMs, not only deterministic fakes: Passing a version check alone, or passing only the CLI help probe, is not enough to promote the backend. +The locally available `docker sandbox` plugin (`v0.12.0`) was also inspected +through its executable `create shell`, `exec`, `network proxy`, `stop`, and +`rm` interfaces. It offers same-path workspace mounts and host/CIDR proxy +policy, but no explicit guest mount targets, create-time CPU/memory/PID/disk/ +file-size bounds, or digest-enforced AWF bootstrap contract. Those are +mandatory controls, so this older interface is reported as capability-blocked; +its help text is not treated as execution evidence. + ## Agent interface When enabled, the agent gets a `bounded-agent` CLI on its `PATH` and a @@ -317,6 +321,13 @@ search) confined to the immutable seed, plus one terminal tool that records the final answer. There is no shell, no `gh`, no git, no package manager, no host state, no safe outputs, and no MCP. +That exclusion is deliberate: authenticated `gh`, safe outputs, the CLI proxy, +and MCP gateways are authority-bearing interfaces whose output is not covered +by the finite result schema. `mcpg` may become an enclave implementation detail +only after it can preserve this fixed authority-free tool set and canonical +result boundary. Its raw Podman arguments, logs, stdio, and `jq` filters are not +the result boundary and must never be exposed to the caller. + ## Budget Every invocation reserves a fixed information charge from its repository's run diff --git a/scripts/ci/probe-bounded-agent-primary-sbx.js b/scripts/ci/probe-bounded-agent-primary-sbx.js new file mode 100755 index 000000000..f49095b1c --- /dev/null +++ b/scripts/ci/probe-bounded-agent-primary-sbx.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Executable primary-sbx ingress proof used by runtime-matrix reporting. + * + * This intentionally proves only the Unix-socket passthrough path. The + * authenticated HTTP path requires a live run-specific broker and is proven by + * main-action before the primary agent starts; a standalone report cannot + * safely synthesize that capability. + */ +async function main() { + const { probeSbxUnixSocketMount } = require('../../dist/sbx-manager.js'); + if (!(await probeSbxUnixSocketMount())) { + process.stderr.write('BLOCKED: primary sbx Unix-socket ingress was not proven\n'); + process.exitCode = 1; + return; + } + process.stdout.write('SUPPORTED: primary sbx Unix-socket ingress proven\n'); +} + +main().catch(() => { + process.stderr.write('BLOCKED: primary sbx ingress capability probe failed\n'); + process.exitCode = 1; +}); diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.js b/scripts/ci/report-bounded-agent-runtime-matrix.js index e513056c9..eafa78263 100644 --- a/scripts/ci/report-bounded-agent-runtime-matrix.js +++ b/scripts/ci/report-bounded-agent-runtime-matrix.js @@ -29,16 +29,13 @@ function collectCapabilities(commandRunner = run) { } } const gvisor = Object.prototype.hasOwnProperty.call(runtimes, 'runsc'); - // `sbx ls` only proves the CLI/daemon is installed, authenticated, and - // reachable. It is deliberately NOT reported as `supported`: this static CI - // report never starts a sandbox, mounts the broker's Unix socket, or drives - // the authenticated HTTP capability exchange, so it cannot execute the - // ingress proof (`assertSbxBoundedAgentIngress` in `main-action.ts`) that - // this PR's "supported after ingress proof" condition requires. Promoting - // primary sbx to `supported` from this alone would be a false positive. - // It is reported as `available`: CLI/daemon reachability confirmed, ingress - // unproven. - const sbxPrimary = commandRunner('sbx', ['ls']).ok; + // Primary sbx is supported only after a disposable sandbox proves the actual + // bounded-agent Unix-socket ingress path. CLI/daemon availability alone is + // not an ingress proof and must never produce a SUPPORTED matrix cell. + const sbxPrimary = commandRunner( + process.execPath, + ['scripts/ci/probe-bounded-agent-primary-sbx.js'], + ).ok; const sbxBoundedAgent = commandRunner( process.execPath, ['containers/bounded-agent/broker/sbx-capability-probe.js'], @@ -55,7 +52,7 @@ function collectCapabilities(commandRunner = run) { primary: { docker: docker.ok ? 'supported' : 'unavailable', gvisor: gvisor ? 'supported' : 'unavailable', - sbx: sbxPrimary ? 'available' : 'unavailable', + sbx: sbxPrimary ? 'supported' : 'unavailable', }, boundedAgent: { docker: docker.ok ? 'supported' : 'unavailable', @@ -66,15 +63,10 @@ function collectCapabilities(commandRunner = run) { } /** - * Evaluates one primary/bounded-agent combination without ever promoting a - * primary sbx CLI/daemon reachability check (`available`) to `SUPPORTED`. - * - * A primary sbx combination can only reach `SUPPORTED` once its capability is - * literally `supported` — a value this static reporter never assigns to - * primary sbx (see {@link collectCapabilities}) because it cannot execute the - * pre-agent ingress proof. `available` is therefore always reported as - * `BLOCKED` at a distinct `primary-sbx-ingress-unproven` phase so it is never - * confused with an outright-unavailable CLI/daemon. + * Evaluates one primary/bounded-agent combination. Primary sbx reaches + * `supported` only when the collector's disposable sandbox has completed the + * real Unix-socket broker-ingress exchange; CLI/daemon availability alone + * remains `unavailable`. */ function evaluate(primary, boundedAgent, capabilities) { const primaryState = capabilities.primary[primary]; @@ -125,10 +117,9 @@ function renderMatrix(capabilities) { '> The bounded-agent sbx enclave is BLOCKED unconditionally today: the audited sbx CLI cannot yet ' + 'prove the mandatory API-proxy-only network, RO-targeted-mount, pids/disk/fsize, or lifecycle ' + 'isolation primitives this enclave requires.', - '> Primary capability `available` (sbx only) means the CLI/daemon is installed, authenticated, and ' + - 'reachable, but the pre-agent ingress proof this static report cannot execute has not run — it is ' + - 'never promoted to SUPPORTED here. Primary sbx becomes SUPPORTED only after ' + - '`assertSbxBoundedAgentIngress` proves the selected ingress during an actual run.', + '> Primary sbx is SUPPORTED here only after a disposable sandbox proves the Unix-socket broker ' + + 'ingress path. Authenticated HTTP fallback is proven only by `assertSbxBoundedAgentIngress` ' + + 'during an actual run.', ); return `${lines.join('\n')}\n`; } diff --git a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts index 650d2ca37..0f971592f 100644 --- a/scripts/ci/report-bounded-agent-runtime-matrix.test.ts +++ b/scripts/ci/report-bounded-agent-runtime-matrix.test.ts @@ -12,8 +12,7 @@ describe('bounded-agent runtime capability report', () => { if (command === 'docker') { return { ok: true, stdout: '{"runc":{},"runsc":{}}' }; } - if (command === 'sbx') { - expect(args).toEqual(['ls']); + if (args.includes('scripts/ci/probe-bounded-agent-primary-sbx.js')) { return { ok: true, stdout: 'Docker Sandboxes v0.37.1' }; } if (args.includes('sbx-capability-probe.js')) { @@ -25,31 +24,12 @@ describe('bounded-agent runtime capability report', () => { const rows = report.split('\n').filter((line: string) => /^\| (docker|gvisor|sbx) /.test(line)); expect(rows).toHaveLength(9); expect(report).toContain( - '| sbx | sbx | BLOCKED | available | blocked | primary-sbx-ingress-unproven |', + '| sbx | sbx | BLOCKED | supported | blocked | bounded-agent-preflight |', ); expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success'); expect(report).toContain('bounded-agent sbx enclave is BLOCKED unconditionally today'); }); - it('never reports SUPPORTED for primary sbx from `sbx ls` alone, only `available`', () => { - const capabilities = collectCapabilities((command: string) => { - if (command === 'docker') return { ok: true, stdout: '{"runc":{}}' }; - // `sbx ls` succeeds: the CLI/daemon is installed, authenticated, and - // reachable, but no ingress proof was executed by this static report. - if (command === 'sbx') return { ok: true, stdout: 'Docker Sandboxes v0.37.1' }; - return { ok: false, stdout: '' }; - }); - expect(capabilities.primary.sbx).toBe('available'); - expect(capabilities.primary.sbx).not.toBe('supported'); - - for (const boundedAgent of ['docker', 'gvisor', 'sbx']) { - const result = evaluate('sbx', boundedAgent, capabilities); - expect(result.status).toBe('BLOCKED'); - expect(result.phase).toBe('primary-sbx-ingress-unproven'); - expect(result.capability).toBe('available'); - } - }); - it('never promotes an unavailable primary or bounded-agent runtime through fallback', () => { const capabilities = { primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, @@ -73,11 +53,7 @@ describe('bounded-agent runtime capability report', () => { }); it('supports primary sbx paired with docker/gvisor bounded-agent enclaves once primary sbx is proven', () => { - // `evaluate` only reaches SUPPORTED for a primary sbx combination once the - // capability is literally `supported` — a value this collector never - // assigns to primary sbx. This exercises that promotion path directly with - // a hand-built capabilities object, standing in for a future collector - // (or a live run) that has actually executed the ingress proof. + // `supported` means the collector's executable ingress probe completed. const capabilities = { primary: { docker: 'supported', gvisor: 'supported', sbx: 'supported' }, boundedAgent: { docker: 'supported', gvisor: 'supported', sbx: 'blocked' }, @@ -87,6 +63,20 @@ describe('bounded-agent runtime capability report', () => { expect(evaluate('sbx', 'sbx', capabilities).status).toBe('BLOCKED'); }); + it('does not promote primary sbx when only its CLI and daemon are available', () => { + const capabilities = collectCapabilities((command: string, args: string[]) => { + if (command === 'docker') return { ok: true, stdout: '{"runc":{}}' }; + if (command === 'sbx' && args[0] === 'ls') return { ok: true, stdout: '[]' }; + return { ok: false, stdout: '' }; + }); + expect(capabilities.primary.sbx).toBe('unavailable'); + expect(evaluate('sbx', 'docker', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'unavailable', + phase: 'primary-preflight', + }); + }); + it('emits an explicit capability-blocked report (not a false pass) when no real sbx binary is present', () => { const capabilities = collectCapabilities((command: string) => { if (command === 'docker') { diff --git a/scripts/ci/smoke-bounded-agent-enclave.sh b/scripts/ci/smoke-bounded-agent-enclave.sh new file mode 100755 index 000000000..a5c3175d0 --- /dev/null +++ b/scripts/ci/smoke-bounded-agent-enclave.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +runtime="${1:-docker}" +case "$runtime" in + docker) runtime_args=() ;; + gvisor) + if ! docker info --format '{{range $name, $_ := .Runtimes}}{{println $name}}{{end}}' | + grep -qx runsc; then + echo "BLOCKED: gVisor bounded-agent smoke requires registered runsc" + exit 0 + fi + runtime_args=(--runtime runsc) + ;; + *) + echo "BLOCKED: unsupported bounded-agent smoke runtime: $runtime" >&2 + exit 2 + ;; +esac + +if ! docker info >/dev/null 2>&1; then + echo "BLOCKED: Docker daemon is unavailable" + exit 0 +fi + +image="awf-bounded-agent-smoke:${runtime}" +run_id="$(printf '%08x%08x' "$$" "$RANDOM")" +network="awf-bounded-agent-smoke-${run_id}" +proxy="awf-bounded-agent-smoke-proxy-${run_id}" +root="$(mktemp -d "${TMPDIR:-/tmp}/awf-bounded-agent-smoke.XXXXXX")" + +cleanup() { + docker rm -f "$proxy" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + rm -rf "$root" +} +trap cleanup EXIT INT TERM + +docker build --quiet --target enclave -t "$image" -f containers/bounded-agent/Dockerfile containers >/dev/null +docker network create --internal "$network" >/dev/null + +mkdir -p "$root/seed" +printf 'LIVE-SMOKE-MARKER\n' > "$root/seed/SECURITY.md" +printf 'Does SECURITY.md exist?\n' > "$root/task.txt" +printf '{"type":"boolean"}\n' > "$root/schema.json" +: > "$root/out" +chmod -R a+rX "$root/seed" "$root/task.txt" "$root/schema.json" +chmod a+rw "$root/out" + +proxy_program=' +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + payload = json.dumps({"choices":[{"message":{"role":"assistant","tool_calls":[{"id":"live","type":"function","function":{"name":"finish","arguments":"{\"result\":true}"}}]}}]}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + def log_message(self, *_args): + pass +HTTPServer(("0.0.0.0", 10000), Handler).serve_forever() +' +docker run -d --name "$proxy" --network "$network" --network-alias api-proxy \ + --read-only --cap-drop ALL --security-opt no-new-privileges:true \ + --entrypoint python3 "$image" -c "$proxy_program" >/dev/null + +proxy_ready=false +for _ in $(seq 1 30); do + if docker exec "$proxy" python3 -c \ + 'import socket; socket.create_connection(("127.0.0.1",10000),1).close()' >/dev/null 2>&1; then + proxy_ready=true + break + fi + sleep 1 +done +if [[ "$proxy_ready" != true ]]; then + echo "FAIL: bounded-agent fake API proxy did not become ready" >&2 + exit 1 +fi +proxy_ip="$( + docker inspect "$proxy" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' +)" +if [[ -z "$proxy_ip" ]]; then + echo "FAIL: bounded-agent fake API proxy has no enclave-network address" >&2 + exit 1 +fi + +set +e +logs="$( + docker run --rm "${runtime_args[@]}" \ + --name "awf-bounded-agent-smoke-${run_id}" \ + --network "$network" \ + --read-only \ + --user 65534:65534 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --memory 512m --memory-swap 512m --cpus 1 --pids-limit 128 \ + --ulimit fsize=33554432 --ulimit nofile=1024:1024 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \ + -v "$root/seed:/awf/seed:ro" \ + -v "$root/task.txt:/awf/task.txt:ro" \ + -v "$root/schema.json:/awf/schema.json:ro" \ + -v "$root/out:/agent/out:rw" \ + -e AWF_BOUNDED_AGENT_API_ENDPOINT="http://${proxy_ip}:10000" \ + -e AWF_BOUNDED_AGENT_PROFILE=openai \ + -e AWF_BOUNDED_AGENT_MODEL=live-smoke \ + -e AWF_BOUNDED_AGENT_MAX_MODEL_REQUESTS=2 \ + -e AWF_BOUNDED_AGENT_MAX_MODEL_TOKENS=64 \ + -e AWF_BOUNDED_AGENT_MAX_OUTPUT_BYTES=64 \ + -e AWF_BOUNDED_AGENT_DEADLINE_SECONDS=30 \ + -e HOME=/tmp -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONUNBUFFERED=1 \ + --entrypoint /usr/local/bin/run-bounded-agent \ + "$image" 2>&1 +)" +status=$? +set -e +if [[ $status -ne 0 || -n "$logs" || "$(cat "$root/out")" != "true" ]]; then + echo "FAIL: $runtime enclave did not produce one silent canonical result" \ + "(status=$status, streamBytes=${#logs}, resultBytes=$(wc -c < "$root/out"))" >&2 + exit 1 +fi + +docker run --rm "${runtime_args[@]}" --network "$network" --entrypoint python3 "$image" -c ' +import socket, sys, urllib.request +socket.create_connection((sys.argv[1], 10000), 2).close() +try: + urllib.request.urlopen("https://example.com", timeout=2) +except Exception: + sys.exit(0) +sys.exit(1) +' "$proxy_ip" + +peers="$(docker network inspect "$network" --format '{{len .Containers}}')" +if [[ "$peers" != "1" ]]; then + echo "FAIL: API-proxy-only network retained unexpected peers: $peers" >&2 + exit 1 +fi + +echo "SUPPORTED: $runtime bounded-agent enclave live smoke passed" diff --git a/src/bounded-agent/manager.test.ts b/src/bounded-agent/manager.test.ts index 6b4f08830..646c6edd8 100644 --- a/src/bounded-agent/manager.test.ts +++ b/src/bounded-agent/manager.test.ts @@ -169,6 +169,47 @@ describe('prepareBoundedAgents', () => { expect(skill).not.toContain('ghs_super_secret'); }); + it('gives staging git no GitHub Actions, OIDC, or inherited credential environment', async () => { + const observed: NodeJS.ProcessEnv[] = []; + const capturingGitRunner: GitRunner = async (args, options) => { + observed.push(options.env); + return gitRunner(args, options); + }; + await prepareBoundedAgents(buildConfig(workDir), { + env: { + GH_TOKEN: 'ghs_super_secret', + GITHUB_TOKEN: 'github-fallback', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret', + GITHUB_ACTIONS: 'true', + GITHUB_WORKSPACE: '/sensitive/workspace', + }, + gitRunner: capturingGitRunner, + assertRuntimeAvailable, + }); + expect(observed.length).toBeGreaterThan(0); + for (const env of observed) { + expect(env).not.toHaveProperty('GH_TOKEN'); + expect(env).not.toHaveProperty('GITHUB_TOKEN'); + expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_URL'); + expect(env).not.toHaveProperty('ACTIONS_ID_TOKEN_REQUEST_TOKEN'); + expect(env).not.toHaveProperty('GITHUB_ACTIONS'); + expect(env).not.toHaveProperty('GITHUB_WORKSPACE'); + expect(Object.keys(env).sort()).toEqual([ + 'GIT_ASKPASS', + 'GIT_CONFIG_COUNT', + 'GIT_CONFIG_KEY_0', + 'GIT_CONFIG_NOSYSTEM', + 'GIT_CONFIG_VALUE_0', + 'GIT_TERMINAL_PROMPT', + 'HOME', + 'PATH', + 'XDG_CONFIG_HOME', + 'AWF_BOUNDED_QUERY_STAGING_TOKEN_FILE', + ].sort()); + } + }); + it('writes a seed map with opaque seed ids and trusted sensitivity only', async () => { await prepareBoundedAgents(buildConfig(workDir), { env: { GH_TOKEN: 't' }, diff --git a/src/bounded-agent/sbx-runner.test.ts b/src/bounded-agent/sbx-runner.test.ts new file mode 100644 index 000000000..cfa20338f --- /dev/null +++ b/src/bounded-agent/sbx-runner.test.ts @@ -0,0 +1,169 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-agent', 'broker'); +const { SbxEnclaveRunner, parseSandboxNames } = require(path.join(brokerDir, 'sbx-enclave-runner.js')); +const { + SBX_ENCLAVE_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, + deriveSbxEnclaveSpec, +} = require(path.join(brokerDir, 'sbx-enclave-runner-spec.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const RUN_ID = 'a'.repeat(32); +const INVOCATION_ID = 'b'.repeat(24); +const SEED_ID = 'c'.repeat(32); +const config = { + sbxWorkDir: '/sbx-daemon/private/work', + sbxSeedsDir: '/sbx-daemon/private/seeds', + enclaveMountDir: '/agent', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveUid: 65534, + enclaveGid: 65534, + network: 'awf-bounded-agent', + timeoutSeconds: 120, + memoryLimit: '512m', + tmpfsLimit: '64m', + cpuLimit: '1', + pidsLimit: 128, +}; + +const result = (overrides: Record = {}) => ({ + exitCode: 0, + stdout: '', + stderr: '', + timedOut: false, + ...overrides, +}); + +function createSbx(handler: (args: string[], timeout: number) => Record = () => result()) { + const calls: Array<{ args: string[]; timeout: number }> = []; + return { + calls, + client: { + runSbx: async (args: string[], timeout: number) => { + calls.push({ args, timeout }); + return handler(args, timeout); + }, + }, + }; +} + +describe('bounded-agent sbx enclave runner contract', () => { + it('derives a frozen launch surface only from trusted identifiers', () => { + const spec = deriveSbxEnclaveSpec({ config, runId: RUN_ID, invocationId: INVOCATION_ID, seedId: SEED_ID }); + expect(Object.isFrozen(spec)).toBe(true); + expect(Object.isFrozen(spec.createArgs)).toBe(true); + expect(spec.createArgs).toContain(SBX_ENCLAVE_TEMPLATE); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) expect(spec.createArgs).toContain(flag); + expect(spec.createArgs.join(' ')).toContain( + `/sbx-daemon/private/seeds/${SEED_ID}:/awf/seed:ro`, + ); + expect(spec.execArgs).toEqual([ + 'exec', '--user', '65534:65534', '--workdir', '/agent', + spec.sandboxName, '/usr/local/bin/run-bounded-agent', + ]); + }); + + it('rejects untrusted identifiers before constructing CLI arguments', () => { + for (const value of ['', '../escape', '--all', 'UPPER']) { + expect(() => deriveSbxEnclaveSpec({ + config, + runId: RUN_ID, + invocationId: value, + seedId: SEED_ID, + })).toThrow(/broker-generated identifier/); + } + }); + + it('blocks launch unless every executable capability is proven', async () => { + const runner = new SbxEnclaveRunner(config, { + probe: async () => ({ supported: false, missing: ['mandatory network policy'] }), + }); + await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s); + }); + + it('always stops and force-removes the invocation while discarding streams', async () => { + const { calls, client } = createSbx((args) => { + if (args[0] === 'exec') return result({ stdout: 'SECRET', stderr: 'DIAGNOSTIC' }); + if (args[0] === 'ls' && args[1] === '--quiet') return result(); + return result(); + }); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + probe: async () => ({ supported: true, missing: [] }), + files: { mkdirSync: jest.fn() }, + }); + await runner.assertAvailable(); + const runResult = await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1000, + }); + expect(runResult).toEqual({ exitCode: 0, timedOut: false }); + const name = runner.spec(RUN_ID, INVOCATION_ID, SEED_ID).sandboxName; + expect(calls.map((call) => call.args)).toContainEqual(['stop', name]); + expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', name]); + expect(JSON.stringify(runResult)).not.toContain('SECRET'); + }); + + it('shares one deadline across create and exec', async () => { + let now = 1000; + const { calls, client } = createSbx((args) => { + if (args[0] === 'create') now += 400; + return result(); + }); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + files: { mkdirSync: jest.fn() }, + nowMs: () => now, + }); + await runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + timeoutMs: 1000, + }); + const createCall = calls.find((call) => call.args[0] === 'create'); + const execCall = calls.find((call) => call.args[0] === 'exec'); + expect(createCall?.timeout).toBeLessThanOrEqual(16_000); + expect(execCall?.timeout).toBe(15_600); + }); + + it('reconciles only the current trusted run prefix', async () => { + const stale = `awf-bounded-agent-sbx-${RUN_ID}-${INVOCATION_ID}`; + const { calls, client } = createSbx((args) => ( + args[0] === 'ls' && args[1] === '--json' + ? result({ stdout: JSON.stringify([{ name: stale }, { name: 'awf-agent-primary' }]) }) + : result() + )); + const runner = new SbxEnclaveRunner(config, { sbx: client }); + await runner.reconcileRun(RUN_ID); + expect(calls.map((call) => call.args)).toContainEqual(['rm', '--force', stale]); + expect(calls.map((call) => call.args).flat()).not.toContain('awf-agent-primary'); + }); + + it('rejects malformed and option-shaped inventory names', () => { + expect(() => parseSandboxNames('not-json')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('{"name":"x"}')).toThrow(/malformed sandbox inventory/); + expect(() => parseSandboxNames('[{"name":"--all"}]')).toThrow(/invalid sandbox name/); + }); + + it('fails closed when cleanup fails after successful execution', async () => { + const { client } = createSbx((args) => ( + args[0] === 'rm' ? result({ exitCode: 1 }) : result() + )); + const runner = new SbxEnclaveRunner(config, { + sbx: client, + files: { mkdirSync: jest.fn() }, + }); + await expect(runner.runEnclaveContainer({ + runId: RUN_ID, + invocationId: INVOCATION_ID, + seedId: SEED_ID, + })).rejects.toThrow(/remove bounded-agent sbx VM/); + }); +}); diff --git a/src/services/bounded-agent-service.test.ts b/src/services/bounded-agent-service.test.ts index a605b6728..b66d3e5e8 100644 --- a/src/services/bounded-agent-service.test.ts +++ b/src/services/bounded-agent-service.test.ts @@ -122,6 +122,40 @@ describe('bounded-agent broker in generated Docker Compose', () => { } }); + it('gives the dedicated proxy only the selected provider credential and no external telemetry authority', () => { + const result = generateDockerCompose( + { + ...enabled(), + anthropicApiKey: 'sk-ant-unused', + copilotGithubToken: 'gh-unused', + geminiApiKey: 'gemini-unused', + additionalEnv: { + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.invalid', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'oidc-secret', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://telemetry.invalid', + OTEL_EXPORTER_OTLP_HEADERS: 'authorization=secret', + }, + }, + networkConfig, + ); + const proxy = result.services['bounded-agent-api-proxy'] as unknown as Record; + const env = proxy.environment as Record; + expect(env.OPENAI_API_KEY).toBe('sk-real'); + for (const forbidden of [ + 'ANTHROPIC_API_KEY', + 'COPILOT_GITHUB_TOKEN', + 'GEMINI_API_KEY', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'HTTP_PROXY', + 'HTTPS_PROXY', + ]) { + expect(env).not.toHaveProperty(forbidden); + } + }); + it('keeps squid, the primary agent, and the broker off the enclave network', () => { const result = generateDockerCompose(enabled(), networkConfig); for (const name of ['squid-proxy', 'agent', 'bounded-agent-broker']) {