diff --git a/.github/workflows/smoke-docker-sbx.lock.yml b/.github/workflows/smoke-docker-sbx.lock.yml index a5535ce9e..dab2c7701 100644 --- a/.github/workflows/smoke-docker-sbx.lock.yml +++ b/.github/workflows/smoke-docker-sbx.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"65e24450fff5db8ec05117c15078f2197c6e7c488bf6c24458b45349167db0fc","body_hash":"680db89cd4b40f61c1f4e06aae4fefbe1485dd6b7dd6b15b652324b73738d216","compiler_version":"v0.84.0","agent_id":"copilot","engine_versions":{"copilot":"1.0.34"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4db76a5a5ef3458ed4ecada7bab9ec955936f62f9e82e6770753c50cf8d5f03","body_hash":"680db89cd4b40f61c1f4e06aae4fefbe1485dd6b7dd6b15b652324b73738d216","compiler_version":"v0.84.0","agent_id":"copilot","agent_model":"claude-sonnet-5","engine_versions":{"copilot":"1.0.34"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","DOCKER_PAT","DOCKER_USERNAME","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"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":"f3ca20900e2363607992fb61b46fc687d4b56ba3","version":"v0.84.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"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.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]} # This file was automatically generated by gh-aw (v0.84.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -126,7 +126,7 @@ jobs: 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_MODEL: "claude-sonnet-5" GH_AW_INFO_VERSION: "1.0.34" GH_AW_INFO_AGENT_VERSION: "1.0.34" GH_AW_INFO_CLI_VERSION: "v0.84.0" @@ -1062,7 +1062,7 @@ jobs: 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' }} + COPILOT_MODEL: claude-sonnet-5 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 || '' }} @@ -1523,7 +1523,7 @@ jobs: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-docker-sbx" 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_MODEL: "claude-sonnet-5" GH_AW_ENGINE_VERSION: "1.0.34" GH_AW_LABEL_COMMANDS: "[\"ready-for-aw\"]" GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }} diff --git a/.github/workflows/smoke-docker-sbx.md b/.github/workflows/smoke-docker-sbx.md index 51238c94f..b594b636c 100644 --- a/.github/workflows/smoke-docker-sbx.md +++ b/.github/workflows/smoke-docker-sbx.md @@ -18,6 +18,7 @@ name: Smoke Docker Sbx engine: id: copilot version: 1.0.34 +model: claude-sonnet-5 network: allowed: - defaults diff --git a/.github/workflows/test-gvisor-compat.yml b/.github/workflows/test-gvisor-compat.yml index ea25994f7..b15017907 100644 --- a/.github/workflows/test-gvisor-compat.yml +++ b/.github/workflows/test-gvisor-compat.yml @@ -10,6 +10,40 @@ permissions: contents: read jobs: + bounded-query-isolation: + name: Bounded-query gVisor isolation + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install gVisor + run: | + set -euo pipefail + ARCH=$(uname -m) + URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" + wget -q "${URL}/runsc" "${URL}/containerd-shim-runsc-v1" + chmod +x runsc containerd-shim-runsc-v1 + sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/ + sudo mkdir -p /etc/docker + printf '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc"}}}\n' | + sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + package-manager-cache: false + - name: Exercise bounded-query isolation under runsc + env: + AWF_BOUNDED_QUERY_TEST_RUNTIME: gvisor + run: | + npm ci + npm run build + npm run test:integration -- --runInBand bounded-query-isolation.test.ts + install-gvisor: name: Install gVisor runs-on: ubuntu-latest diff --git a/containers/agent/bounded-query-wrapper.sh b/containers/agent/bounded-query-wrapper.sh index 2ead94713..d015e02da 100755 --- a/containers/agent/bounded-query-wrapper.sh +++ b/containers/agent/bounded-query-wrapper.sh @@ -3,8 +3,9 @@ # # Agent-facing bounded-query CLI (protocol v2). # -# Forwards a *narrow* request to the trusted bounded-query broker over a -# dedicated Unix socket. It is analogous to gh-cli-proxy-wrapper.sh, but the +# Forwards a *narrow* request to the trusted bounded-query broker over either +# the Compose Unix socket or the authenticated sbx HTTP ingress. It is +# analogous to gh-cli-proxy-wrapper.sh, but the # API is deliberately far narrower: this wrapper cannot express a command, an # image, a path, a URL, a ref, a mount, a runtime, a timeout, an environment, # or a credential. It accepts exactly: @@ -29,7 +30,9 @@ # image). CANONICAL_ERROR='{"status":"error"}' -SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-/run/awf-bounded-query/broker.sock}" +SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-}" +ENDPOINT="${AWF_BOUNDED_QUERY_ENDPOINT:-}" +CAPABILITY="${AWF_BOUNDED_QUERY_CAPABILITY:-}" PROTOCOL_VERSION=2 # Keep in sync with MAX_SCHEMA_BYTES in src/bounded-query/protocol.ts and # containers/bounded-query/broker/protocol.js. @@ -87,26 +90,51 @@ SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d # The script must arrive on stdin; an interactive terminal means no script. [ ! -t 0 ] || emit_error -[ -S "$SOCKET" ] || emit_error - -# --noproxy '*' keeps HTTP(S)_PROXY from redirecting a Unix-socket request. -# --max-time bounds the wait comfortably above the largest timing bucket (10 -# minutes); the broker always answers at a fixed bucket boundary, so this -# only guards a dead socket. -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-Query-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-Query-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-Query-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-query/Dockerfile b/containers/bounded-query/Dockerfile index 9ca9d29ea..61629f4dd 100644 --- a/containers/bounded-query/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -57,6 +57,10 @@ RUN chmod -R a-w /opt/awf \ && node --check /opt/awf/broker/docker-client.js \ && node --check /opt/awf/broker/docker-query-runner.js \ && node --check /opt/awf/broker/gvisor-query-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-query-runner-spec.js \ + && node --check /opt/awf/broker/sbx-query-runner.js \ && node --check /opt/awf/broker/healthcheck.js # Fixed broker-only mount points. diff --git a/containers/bounded-query/broker/broker.js b/containers/bounded-query/broker/broker.js index 055a1436d..fe7d1affb 100644 --- a/containers/bounded-query/broker/broker.js +++ b/containers/bounded-query/broker/broker.js @@ -56,11 +56,22 @@ function createBroker(params) { const runner = params.runner; const clock = params.clock || createRealClock(); const ledger = params.ledger || createLedger(seedMap); + const telemetry = params.telemetry || { emit() {} }; let invocationsUsed = 0; let tail = Promise.resolve(); let accepting = true; + function emitQueryTelemetry(category) { + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + lifecycleClass: 'query', + capabilityState: 'supported', + category, + }); + } + /** * Executes one request and reports its canonical result through * `respond` (called exactly once). The invocations run only through @@ -80,6 +91,7 @@ function createBroker(params) { const validation = validateBoundedQueryRequest(request); if (!validation.valid) { audit.failure(invocationId, 'invalid-request', validation.errors.join('; ')); + emitQueryTelemetry('invalid-request'); safeRespond(CANONICAL_ERROR_JSON); return; } @@ -89,6 +101,7 @@ function createBroker(params) { const seed = seedMap.get(repoKey); if (!seed) { audit.failure(invocationId, 'repo-not-allowed', privateRepo); + emitQueryTelemetry('repo-not-allowed'); safeRespond(CANONICAL_ERROR_JSON); return; } @@ -100,6 +113,7 @@ function createBroker(params) { const charge = queryBitsForSchema(schema); if (!ledger.tryDebit(repoKey, charge)) { audit.failure(invocationId, 'bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`); + emitQueryTelemetry('bit-budget-exhausted'); safeRespond(CANONICAL_ERROR_JSON); return; } @@ -175,6 +189,7 @@ function createBroker(params) { // configured bucket — pathological infrastructure latency. Never emit a // successful result at unbucketed timing. audit.failure(invocationId, 'timing-bucket-overflow', failureReason ? failureReason.join(':') : undefined); + emitQueryTelemetry('timing-bucket-overflow'); safeRespond(CANONICAL_ERROR_JSON); } else if (canonicalResult !== undefined) { audit.invocation({ @@ -184,9 +199,12 @@ function createBroker(params) { bits: charge, bucketMs, }); + emitQueryTelemetry('success'); safeRespond(canonicalOkJson(canonicalResult)); } else { - audit.failure(invocationId, failureReason ? failureReason[0] : 'unknown', failureReason ? failureReason[1] : undefined); + const category = failureReason ? failureReason[0] : 'unknown'; + audit.failure(invocationId, category, failureReason ? failureReason[1] : undefined); + emitQueryTelemetry(category); safeRespond(CANONICAL_ERROR_JSON); } @@ -238,6 +256,7 @@ function createBroker(params) { // against it. if (invocationsUsed >= config.maxInvocations) { audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`); + emitQueryTelemetry('invocation-count-exhausted'); safeRespond(CANONICAL_ERROR_JSON); return Promise.resolve(); } @@ -245,6 +264,7 @@ function createBroker(params) { const queued = tail.then(() => execute(request, safeRespond)).catch((error) => { audit.failure('queue', 'unexpected-error', error && error.message); + emitQueryTelemetry('unexpected-error'); safeRespond(CANONICAL_ERROR_JSON); }); tail = queued.then( diff --git a/containers/bounded-query/broker/config.js b/containers/bounded-query/broker/config.js index 3843cb544..dc9a9d0ba 100644 --- a/containers/bounded-query/broker/config.js +++ b/containers/bounded-query/broker/config.js @@ -23,6 +23,7 @@ const CONTROL_DIR = '/run/awf-bounded-query-control'; const AUDIT_DIR = '/var/log/awf-bounded-query'; /** Broker-private readiness marker; the control directory is never agent-mounted. */ const READY_PATH = path.join(CONTROL_DIR, 'broker.ready'); +const SBX_CAPABILITY_PATH = path.join(CONTROL_DIR, 'sbx-ingress.json'); const QUERY_SECCOMP_PATH = '/opt/awf/query-seccomp.json'; /** Mount points inside the query container. Fixed, never caller-supplied. */ @@ -48,9 +49,27 @@ function parsePositiveInt(name, fallback) { if (!Number.isInteger(parsed) || parsed < 1) { throw new Error(`Environment variable ${name} must be a positive integer`); } + return parsed; } +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 }; +} + /** * Parses the per-invocation timeout, additionally re-enforcing (defense in * depth; AWF's host-side preflight already rejects an out-of-range value @@ -75,9 +94,24 @@ function loadConfig() { } const queryBackend = requireEnv('AWF_BOUNDED_QUERY_BACKEND'); - if (queryBackend !== 'docker' && queryBackend !== 'gvisor') { + if (queryBackend !== 'docker' && queryBackend !== 'gvisor' && queryBackend !== 'sbx') { throw new Error(`Unsupported AWF_BOUNDED_QUERY_BACKEND: ${queryBackend}`); } + const primaryBackend = requireEnv('AWF_BOUNDED_QUERY_PRIMARY_BACKEND'); + if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') { + throw new Error(`Unsupported AWF_BOUNDED_QUERY_PRIMARY_BACKEND: ${primaryBackend}`); + } + + const tcpPortRaw = process.env.AWF_BOUNDED_QUERY_TCP_PORT; + const tcpPort = tcpPortRaw === undefined ? undefined : parsePositiveInt('AWF_BOUNDED_QUERY_TCP_PORT'); + if (tcpPort !== undefined && tcpPort > 65535) { + throw new Error('AWF_BOUNDED_QUERY_TCP_PORT must be a valid TCP port'); + } + + const hostWorkDir = requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'); + const sbxWorkDir = queryBackend === 'sbx' + ? requireEnv('AWF_BOUNDED_QUERY_SBX_WORK_DIR') + : undefined; return { seedsDir: SEEDS_DIR, @@ -96,13 +130,21 @@ function loadConfig() { queryImage: requireEnv('AWF_BOUNDED_QUERY_IMAGE'), // The daemon resolves query bind-mount sources in *its* filesystem view, // which is not necessarily the broker's (ARC/DinD split filesystems). - hostWorkDir: requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'), + hostWorkDir, + // sbx and Docker daemons can have different filesystem namespaces (ARC/DinD). + // Never reuse the Docker-daemon-visible path for sbx mounts. + sbxWorkDir, queryBackend, + primaryBackend, timeoutSeconds: parseTimeoutSeconds(), maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32), memoryLimit, socketUid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_UID', 0), socketGid: parsePositiveInt('AWF_BOUNDED_QUERY_SOCKET_GID', 0), + tcpPort, + sbxIngressCapabilities: tcpPort === undefined + ? undefined + : loadSbxIngressCapabilities(SBX_CAPABILITY_PATH), }; } @@ -145,4 +187,4 @@ function loadSeedMap(seedMapPath) { return { runId: parsed.runId, seeds }; } -module.exports = { READY_PATH, loadConfig, loadSeedMap }; +module.exports = { READY_PATH, SBX_CAPABILITY_PATH, loadConfig, loadSeedMap, loadSbxIngressCapabilities }; diff --git a/containers/bounded-query/broker/framing.js b/containers/bounded-query/broker/framing.js index ca0a56e6e..d446b490e 100644 --- a/containers/bounded-query/broker/framing.js +++ b/containers/bounded-query/broker/framing.js @@ -2,6 +2,9 @@ const { MAX_SCHEMA_BYTES, MAX_SCRIPT_BYTES, strictParseJson } = require('./protocol'); +/** A peer that stops sending a request body cannot pin a broker connection. */ +const BODY_READ_TIMEOUT_MS = 5_000; + /** * Wire framing for the agent → broker request (protocol v2). * @@ -120,10 +123,16 @@ function readBoundedBody(req) { const chunks = []; let total = 0; let settled = false; + const timer = setTimeout(() => { + req.pause(); + finish({ error: 'request body deadline exceeded' }); + }, BODY_READ_TIMEOUT_MS); + timer.unref(); const finish = (value) => { if (settled) return; settled = true; + clearTimeout(timer); resolve(value); }; @@ -160,4 +169,5 @@ module.exports = { SCHEMA_HEADER, buildRequestFromFrame, readBoundedBody, + BODY_READ_TIMEOUT_MS, }; diff --git a/containers/bounded-query/broker/query-runner.js b/containers/bounded-query/broker/query-runner.js index 7a0b36d1b..7ca96f672 100644 --- a/containers/bounded-query/broker/query-runner.js +++ b/containers/bounded-query/broker/query-runner.js @@ -2,6 +2,7 @@ const { DockerQueryRunner } = require('./docker-query-runner'); const { GvisorQueryRunner } = require('./gvisor-query-runner'); +const { SbxQueryRunner } = require('./sbx-query-runner'); const { QUERY_MAX_FILE_BYTES, QUERY_WORKSPACE_TMPFS_BYTES, @@ -38,6 +39,9 @@ function createQueryRunner(config, deps = {}) { if (config.queryBackend === 'gvisor') { return new GvisorQueryRunner(config, deps); } + if (config.queryBackend === 'sbx') { + return new SbxQueryRunner(config, deps); + } throw new Error(`Unsupported bounded-query backend: ${config.queryBackend}`); } diff --git a/containers/bounded-query/broker/runtime-telemetry.js b/containers/bounded-query/broker/runtime-telemetry.js new file mode 100644 index 000000000..31be15976 --- /dev/null +++ b/containers/bounded-query/broker/runtime-telemetry.js @@ -0,0 +1,56 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const PRIMARY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const QUERY_BACKENDS = new Set(['docker', 'gvisor', 'sbx']); +const LIFECYCLE_CLASSES = new Set(['preflight', 'startup', 'query', '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-query telemetry ${field}`); +} + +function buildRuntimeTelemetryRecord(event) { + assertTelemetryValue(PRIMARY_BACKENDS, event.primaryBackend, 'primaryBackend'); + assertTelemetryValue(QUERY_BACKENDS, event.queryBackend, 'queryBackend'); + 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-query telemetry category'); + } + return Object.freeze({ + primaryBackend: event.primaryBackend, + queryBackend: event.queryBackend, + lifecycleClass: event.lifecycleClass, + capabilityState: event.capabilityState, + category: event.category, + }); +} + +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-query] 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-query/broker/sbx-capability-probe.js b/containers/bounded-query/broker/sbx-capability-probe.js new file mode 100755 index 000000000..7898aee13 --- /dev/null +++ b/containers/bounded-query/broker/sbx-capability-probe.js @@ -0,0 +1,103 @@ +#!/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 query VM. + * + * sbx v0.37.1 lacks the final five controls. Local or kit network rules are + * insufficient because organization governance can replace them. + */ +const REQUIRED_HARD_ISOLATION_FLAGS = Object.freeze([ + '--network=none', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +]); + +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 : ''; +} + +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 = []; + + // AWF has not published the immutable Python-only sbx template/bootstrap + // because current sbx cannot yet enforce the controls below. + missing.push('pinned AWF Python query template and bootstrap'); + + 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, + probeSbxCapabilities, +}; diff --git a/containers/bounded-query/broker/sbx-client.js b/containers/bounded-query/broker/sbx-client.js new file mode 100644 index 000000000..a44a5c05d --- /dev/null +++ b/containers/bounded-query/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 query 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-query/broker/sbx-query-runner-spec.js b/containers/bounded-query/broker/sbx-query-runner-spec.js new file mode 100644 index 000000000..11a47705a --- /dev/null +++ b/containers/bounded-query/broker/sbx-query-runner-spec.js @@ -0,0 +1,82 @@ +'use strict'; + +const { + QUERY_MAX_FILE_BYTES, + QUERY_WORKSPACE_TMPFS_BYTES, + normalizeTimeoutMs, +} = require('./query-runner-spec'); +const { REQUIRED_HARD_ISOLATION_FLAGS } = require('./sbx-capability-probe'); + +const SBX_CLI_GRACE_MS = 15_000; +const SBX_QUERY_TEMPLATE = 'docker/sandbox-templates:shell-docker@sha256:unsupported-until-pinned'; +const TRUSTED_RUN_ID_PATTERN = /^[0-9a-f]{32}$/; +const TRUSTED_INVOCATION_ID_PATTERN = /^[0-9a-f]{24}$/; + +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 from trusted broker state. + * + * This specification is intentionally not launchable while the capability + * probe reports missing hard-isolation controls. It records the current sbx + * API needed by the runner without accepting any request-owned launch data. + */ +function deriveSbxQuerySpec({ config, runId, invocationId }) { + assertTrustedId('runId', runId, TRUSTED_RUN_ID_PATTERN); + assertTrustedId('invocationId', invocationId, TRUSTED_INVOCATION_ID_PATTERN); + + const runPrefix = `awf-query-sbx-${runId}-`; + const sandboxName = `${runPrefix}${invocationId}`; + const hostInvocationDir = `${config.sbxWorkDir}/${invocationId}`; + const workspaceDir = `${hostInvocationDir}/sbx-workspace`; + const outPath = `${hostInvocationDir}/out`; + const repoDir = `${hostInvocationDir}/repo`; + const scriptPath = `${hostInvocationDir}/script.py`; + + return Object.freeze({ + sandboxName, + runPrefix, + createArgs: freeze([ + 'create', + '--name', sandboxName, + '--cpus', '1', + '--memory', config.memoryLimit, + '--template', SBX_QUERY_TEMPLATE, + '--network=none', + '--pids-limit', '128', + '--disk-limit', String(QUERY_WORKSPACE_TMPFS_BYTES), + '--ulimit-fsize', String(QUERY_MAX_FILE_BYTES), + '--mount-target', `${repoDir}:/awf/seed:ro`, + '--mount-target', `${scriptPath}:${config.queryScriptPath}:ro`, + '--mount-target', `${outPath}:${config.queryMountDir}/out:rw`, + 'shell', + workspaceDir, + ]), + execArgs: freeze([ + 'exec', + '--user', `${config.queryUid}:${config.queryGid}`, + '--workdir', config.queryMountDir, + sandboxName, + '/usr/local/bin/awf-run-query', + ]), + stopArgs: freeze(['stop', sandboxName]), + removeArgs: freeze(['rm', '--force', sandboxName]), + listArgs: freeze(['ls', '--json']), + }); +} + +module.exports = { + SBX_CLI_GRACE_MS, + SBX_QUERY_TEMPLATE, + REQUIRED_HARD_ISOLATION_FLAGS, + deriveSbxQuerySpec, + normalizeTimeoutMs, +}; diff --git a/containers/bounded-query/broker/sbx-query-runner.js b/containers/bounded-query/broker/sbx-query-runner.js new file mode 100644 index 000000000..0f14c0975 --- /dev/null +++ b/containers/bounded-query/broker/sbx-query-runner.js @@ -0,0 +1,132 @@ +'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, + deriveSbxQuerySpec, + normalizeTimeoutMs, +} = require('./sbx-query-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; +} + +class SbxQueryRunner { + 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) { + return deriveSbxQuerySpec({ config: this.config, runId, invocationId }); + } + + async assertAvailable() { + const report = await this.probe(this.sbx); + if (!report.supported) { + throw new Error( + 'sbx bounded-query 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, '000000000000000000000000'); + const listed = await this.sbx.runSbx(spec.listArgs, 30_000); + if (listed.exitCode !== 0) throw new Error('Failed to reconcile bounded-query 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-query sbx VM'); + } + } + const removed = await this.sbx.runSbx(['rm', '--force', name], 30_000); + if (removed.exitCode !== 0) throw new Error('Failed to remove bounded-query sbx VM'); + } + + 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); + await this.serializeCleanup(() => this.removeSandbox(sandboxName)); + } + + async runQueryContainer(params) { + const spec = this.spec(params.runId, params.invocationId); + 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.workDir, 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-query 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 result; + } +} + +module.exports = { SbxQueryRunner, parseSandboxNames }; diff --git a/containers/bounded-query/broker/server.js b/containers/bounded-query/broker/server.js index 249ad6533..fd8176aea 100644 --- a/containers/bounded-query/broker/server.js +++ b/containers/bounded-query/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,13 +9,16 @@ const { loadConfig, loadSeedMap } = require('./config'); const { buildRequestFromFrame, readBoundedBody } = require('./framing'); const { CANONICAL_ERROR_JSON } = require('./protocol'); const { createQueryRunner } = require('./query-runner'); +const { createRuntimeTelemetry } = require('./runtime-telemetry'); /** * Bounded-query broker server. * - * Listens on a single Unix domain socket. The container has - * `network_mode: none`, so this socket — shared with the agent through one - * bind mount — is the broker's entire attack surface. + * Compose agents use a Unix domain socket. sbx 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. * * One route exists: * POST /query the bounded-query API @@ -41,18 +45,31 @@ const RESULT_HEADERS = { // force-removing this run's containers. Longer queries 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; +const PROBE_RESPONSE_DELAY_MS = 10; function sendResult(res, body) { res.writeHead(200, { ...RESULT_HEADERS, 'content-length': Buffer.byteLength(body) }); res.end(body); } -function createServer(deps) { - const { broker, audit } = deps; +function canonicalRawResponse() { + return [ + 'HTTP/1.1 200 OK', + 'content-type: application/json', + 'cache-control: no-store', + `content-length: ${Buffer.byteLength(CANONICAL_ERROR_JSON)}`, + 'connection: close', + '', + CANONICAL_ERROR_JSON, + ].join('\r\n'); +} + +function createHardenedServer(listener, audit) { let accepting = true; let pendingAdmissions = 0; const admissionWaiters = []; - const resolveAdmissionWaiters = () => { if (pendingAdmissions !== 0) return; while (admissionWaiters.length > 0) { @@ -60,15 +77,7 @@ function createServer(deps) { } }; - const server = http.createServer((req, res) => { - if (req.method !== 'POST' || req.url !== '/query') { - // Not part of the API. Answer with the canonical error rather than a - // distinguishable 404/405 so probing the surface yields no extra signal. - sendResult(res, CANONICAL_ERROR_JSON); - req.resume(); - return; - } - + const server = http.createServer({ maxHeaderSize: MAX_HEADER_BYTES }, (req, res) => { if (!accepting) { sendResult(res, CANONICAL_ERROR_JSON); req.resume(); @@ -76,26 +85,7 @@ function createServer(deps) { } pendingAdmissions += 1; - readBoundedBody(req) - .then((body) => { - if (!accepting) { - sendResult(res, CANONICAL_ERROR_JSON); - return; - } - - if (body.error !== undefined) { - audit.failure('framing', 'body-rejected', body.error); - return broker.handle(undefined, (result) => sendResult(res, result)); - } - - const framed = buildRequestFromFrame(req.headers, req.rawHeaders, body.script); - if (framed.error !== undefined) { - audit.failure('framing', 'frame-rejected', framed.error); - return broker.handle(undefined, (result) => sendResult(res, result)); - } - - return broker.handle(framed.request, (result) => sendResult(res, result)); - }) + Promise.resolve(listener(req, res, () => accepting)) .catch((error) => { audit.failure('server', 'unhandled-error', error && error.message); if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON); @@ -105,7 +95,30 @@ function createServer(deps) { resolveAdmissionWaiters(); }); }); + server.headersTimeout = 5_000; + server.requestTimeout = 0; + server.keepAliveTimeout = 1_000; + server.maxRequestsPerSocket = 1; + let activeConnections = 0; + server.on('connection', (socket) => { + activeConnections += 1; + socket.once('close', () => { + activeConnections -= 1; + }); + if (activeConnections > MAX_CONNECTIONS) { + socket.awfRejected = true; + audit.failure('transport', 'connection-limit'); + socket.pause(); + socket.end(canonicalRawResponse()); + } + }); + server.on('clientError', (error, socket) => { + audit.failure('framing', 'header-rejected', error && error.message); + setTimeout(() => { + if (socket.writable) socket.end(canonicalRawResponse()); + }, PROBE_RESPONSE_DELAY_MS); + }); server.freezeAdmissions = () => { accepting = false; }; @@ -117,6 +130,134 @@ function createServer(deps) { return server; } +function processRequest( + req, + res, + broker, + audit, + framedHeaders = req.headers, + framedRawHeaders = req.rawHeaders, + isAccepting = () => true, +) { + if (req.socket.awfRejected) { + req.resume(); + res.destroy(); + return Promise.resolve(); + } + if (req.method !== 'POST' || req.url !== '/query') { + sendResult(res, CANONICAL_ERROR_JSON); + req.resume(); + return Promise.resolve(); + } + + return readBoundedBody(req) + .then((body) => { + if (!isAccepting()) { + sendResult(res, CANONICAL_ERROR_JSON); + return; + } + + if (body.error !== undefined) { + audit.failure('framing', 'body-rejected', body.error); + return broker.handle(undefined, (result) => sendResult(res, result)); + } + + const framed = buildRequestFromFrame(framedHeaders, framedRawHeaders, body.script); + if (framed.error !== undefined) { + audit.failure('framing', 'frame-rejected', framed.error); + return broker.handle(undefined, (result) => sendResult(res, result)); + } + + return broker.handle(framed.request, (result) => sendResult(res, result)); + }) + .catch((error) => { + audit.failure('server', 'unhandled-error', error && error.message); + if (!res.headersSent) sendResult(res, CANONICAL_ERROR_JSON); + }); +} + +function createServer(deps) { + const { broker, audit } = deps; + return createHardenedServer( + (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 }; +} + +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 }); @@ -133,14 +274,23 @@ function listenOnSocket(server, config, audit) { audit.lifecycle('socket-ownership-fallback', error.message); fs.chmodSync(config.socketPath, 0o666); } + resolve(); }); }); } +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 = createQueryRunner(config); @@ -148,11 +298,28 @@ async function main() { // prior broker process for this exact run. Queries never pull or fall back. await runner.assertAvailable(); await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + 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. @@ -163,6 +330,7 @@ async function main() { socket: config.socketPath, repos: seeds.size, backend: config.queryBackend, + ingress: config.tcpPort === undefined ? 'unix' : 'unix+sbx-http', maxInvocations: config.maxInvocations, }); @@ -171,22 +339,38 @@ 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(), + ...servers.map((server) => server.drainAdmissions()), broker.drain(), ]), new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS)), ]); await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'success', + }); process.exit(0); } catch (error) { audit.lifecycle('shutdown-cleanup-failed', error.message); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'cleanup-failed', + }); process.exit(1); } }; @@ -201,4 +385,11 @@ if (require.main === module) { }); } -module.exports = { createServer, listenOnSocket }; +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 319d0aacc..981fa85fb 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -1594,7 +1594,7 @@ The root object MAY contain a `boundedQueries` section: |-------|------|-------------|---------| | `enabled` | boolean | — | `false` | | `privateRepos` | array | Non-empty and unique (by repo slug, case-insensitively) when `enabled` is `true`. Each entry is either an object `{ "repo": "owner/repo", "sensitivity": "public" \| "internal" \| "confidential" \| "sealed" }`, or (one-release legacy compatibility) a bare `owner/repo` string, normalized to `{ repo, sensitivity: "internal" }` with a warning. Each `repo` MUST be a bare `owner/repo` slug — no scheme/host (`://`), path traversal (`..`), query string (`?`), fragment (`#`), wildcard (`*`), or extra path segments. | `[]` | -| `runtime` | string | One of `"docker"`, `"gvisor"` | `"docker"` | +| `runtime` | string | One of `"docker"`, `"gvisor"`, `"sbx"`. The `sbx` value is a fail-closed preview blocked unless its executable capability proof satisfies every mandatory isolation control. | `"docker"` | | `timeout` | integer | `1`–`540` seconds (the final minute of the 10-minute response bucket is reserved for termination, validation, and cleanup; §14.3) | `30` | | `memoryLimit` | string | Docker-style memory limit, e.g. `"512m"`, `"1g"` | `"512m"` | | `interpreter` | string | Only `"python3"` is currently supported | `"python3"` | @@ -1628,15 +1628,58 @@ section. **Preflight (fail-closed).** With `enabled: true`, AWF aborts before the primary agent starts when: `privateRepos` is empty or contains an unsafe or duplicated slug; `runtime` is `"gvisor"` and the `runsc` OCI runtime is not -registered with the Docker daemon; `container.containerRuntime` is a -microVM backend, which cannot receive the broker socket; the resolved Docker -host is not a `unix://` socket, which a `network_mode: none` broker cannot -reach; the interpreter or a limit is unsupported; `timeout` exceeds 540 +registered with the Docker daemon; `runtime` is `"sbx"` and the executable +capability proof cannot establish every mandatory no-network and resource +bound; a Docker/gVisor query resolves to a non-`unix://` Docker host, which a +`network_mode: none` broker cannot reach; the interpreter or a limit is +unsupported; `timeout` exceeds 540 seconds — the 10-minute response bucket reserves its final minute for Docker termination, result validation, container removal, and workspace cleanup; no staging credential is present in `GH_TOKEN`/`GITHUB_TOKEN`; or any seed cannot be materialized and verified. +**`sbx` query backend status.** The configuration value and broker-owned +`SbxQueryRunner` boundary are present, but support is fail-closed as of the +audited Docker Sandboxes CLI `v0.37.1`. The executable broker capability probe +uses `sbx version`, `sbx create --help`, and `sbx exec --help`, exits non-zero, +and reports missing guarantees as JSON. Although this release supports +`sbx create --name --cpus --memory --template`, read-only same-path mounts, +`sbx exec --user --workdir`, `sbx ls --json`, `sbx stop`, and +`sbx rm --force`, it has no enforceable per-VM `network=none`, PID, disk, +per-file size, or explicit guest mount-target control. Local/kit network denies +are not sufficient because organization governance can replace them. AWF +therefore aborts before staging or Compose assembly, passes no Docker socket or +sbx credential to the broker, and never falls back to Docker/gVisor. Enabling +launch requires all missing controls plus a digest-pinned, Python +standard-library-only AWF query template/bootstrap. + +**Independent runtime matrix.** `container.containerRuntime` selects the primary +agent while `boundedQueries.runtime` independently selects a fresh query +sandbox. Every accepted invocation creates one new sandbox and destroys it +before response. The current capability matrix is: + +| Primary agent | Docker query | gVisor query | sbx query | +|---|---|---|---| +| Docker | Supported with Docker | Supported with registered `runsc` | Blocked | +| gVisor | Supported with primary `runsc` | Supported with registered `runsc` | Blocked | +| sbx | Supported after primary ingress probe | Supported after primary ingress and `runsc` probes | Blocked | + +Unavailable cells abort at preflight and never stage. A blocked sbx query is an +expected security result, not runtime success. `"runtime": "sbx"` is both the +explicit experimental selection and a requirement to pass every executable +probe; it never authorizes fallback. + +**Runtime telemetry.** Telemetry records contain exactly `primaryBackend`, +`queryBackend`, `lifecycleClass`, `capabilityState`, and `category`. They MUST +NOT contain repository data or identifiers, scripts, outputs, paths, tokens, +ingress capabilities, or daemon credentials. + +Promotion of sbx queries requires real-VM proof of no network/lateral access, +all resource bounds, mount-target isolation, credential/state separation, +canonical output behavior, and cleanup after timeout, resource failure, and +interruption, plus a digest-pinned AWF Python-only template. Version/help +probing alone is insufficient. + The seed map the broker reads carries each repository's trusted `sensitivity` alongside its opaque seed id — the map is built entirely from AWF configuration, so a request can never choose or override its own @@ -1868,12 +1911,26 @@ guarantees the broker never copies that seed or launches Python for it. ### 14.7 Trusted Broker and Query Sandbox The broker runs as an optional Docker Compose service -(`bounded-query-broker`, container `awf-bounded-query-broker`) with -`network_mode: none`: no `awf-net`, no external bridge, no DNS, no Squid, no -api-proxy/cli-proxy, and no host-network path. Its entire surface is one Unix -socket in a directory bind-mounted into the agent. It also receives the -resolved Docker socket so it can launch queries; that path is never placed in -the agent's environment or volumes. +(`bounded-query-broker`, container `awf-bounded-query-broker`). For Compose +agents it uses `network_mode: none`; its entire surface is one Unix socket in a +directory bind-mounted into the agent. For an sbx primary agent, trusted +preflight first executes a disposable-sandbox probe of Unix-socket passthrough. +If that probe succeeds, the same socket transport is used. Otherwise the +broker is attached only to a dedicated Docker `internal` network with one +ephemeral port narrowly published on host `127.0.0.1`. It is never attached to +`awf-net`, `awf-ext`, Squid, DNS, or an internet-routed network. It also +receives the resolved Docker socket so it can launch queries; that path is +never placed in the agent's environment or volumes. + +The sbx endpoint requires a random per-run capability read by the broker from +broker-private control state. A separate one-shot probe capability proves the +actual sandbox can reach the endpoint before the primary agent starts. Both +capabilities are absent from generated skills, Compose and audit artifacts, +logs, query environments, and query launch arguments. The broker exposes no +health or diagnostic route: both transports use the exact same `POST /query` +framing, limits, canonical result bytes, scheduler/timing buckets, and audit +path. Authentication, malformed framing, oversized requests, and internal +failures all collapse to `{"status":"error"}`. The broker maps a normalized `owner/repo` id through the AWF-generated seed map to an opaque seed directory and its trusted sensitivity. Callers never @@ -1913,9 +1970,9 @@ which is mounted into the broker alone. ### 14.8 Agent Interface -When bounded queries are enabled, the agent receives exactly two bind mounts — -the broker socket directory (read-write) and a generated skill directory -(read-only) — plus three environment variables +When bounded queries are enabled, a Compose agent receives exactly two bind +mounts — the broker socket directory (read-write) and a generated skill/wrapper +directory (read-only) — plus three environment variables (`AWF_BOUNDED_QUERY_SOCKET`, `AWF_BOUNDED_QUERY_SKILL`, `AWF_BOUNDED_QUERY_REPOS`, the last a comma-separated list of configured repo slugs only — never sensitivities or budgets). GitHub tokens are removed from @@ -1936,6 +1993,13 @@ responsibilities are enforcing the fixed CLI shape, base64url-encoding the schema into a request header, transporting the script body unmodified, and passing the broker's response through unmodified. +An sbx agent receives only the generated skill/wrapper directory as a read-only +mount. If Unix passthrough was proven, it additionally receives the socket +directory read-only and `AWF_BOUNDED_QUERY_SOCKET`. Otherwise it receives +`AWF_BOUNDED_QUERY_ENDPOINT` and `AWF_BOUNDED_QUERY_CAPABILITY`. It never +receives the broker-private root, Docker socket, seeds, work/control/audit +state, seed map, probe capability, or query launch authority. + The generated `SKILL.md` is written under the run-specific ingress root and mounted read-only at `/run/awf-bounded-query-skill/SKILL.md`. It documents, per configured repository, its sensitivity and run budget (e.g. `` `octo/alpha` @@ -1951,8 +2015,9 @@ is a documented limitation, not an oversight. All seeds, invocation workspaces, the seed map, broker control state, and protected audit data live below `/var/tmp/awf-bounded-query-private--/`. Only the disjoint -`/var/tmp/awf-bounded-query-ingress--/run/` and generated -skill directory are agent-visible through explicit bind mounts. Before +`/var/tmp/awf-bounded-query-ingress--/run/` (when Unix +transport is selected) and generated skill/wrapper directory are agent-visible +through explicit bind mounts. Before credential-bearing staging, AWF resolves each path through its longest existing ancestor (following symlinks) and rejects any private-root overlap with the union of Docker, gVisor, and sbx agent-visible mounts, @@ -1960,9 +2025,11 @@ including `/tmp`, the workspace, custom volumes, and whitelisted home tool directories. Docker-in-Docker host-path translation is checked and applied to the private broker mounts and ingress mounts symmetrically. -For the same reason, a microVM primary agent runtime (`sbx`) is rejected at -preflight: it does not receive Compose bind mounts, so the socket and skill -could not be exposed. Bounded queries are never partially enabled. +An sbx host that cannot create the disposable capability probe or cannot reach +the selected ingress from the actual primary sandbox fails preflight before +the agent command starts. Transport never silently downgrades after selection. +Query execution remains limited to the Docker and gVisor runners; this ingress +support does not execute query sandboxes inside sbx. ### 14.9 Protocol v1 Compatibility diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 95c59c073..92cfbd3c9 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -889,9 +889,10 @@ "type": "string", "enum": [ "docker", - "gvisor" + "gvisor", + "sbx" ], - "description": "Sandbox runtime backend used to execute the query script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the query script, 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 mandatory no-network, PID, disk, file-size, target-mount, CPU, and memory controls. No backend ever falls back. Default: \"docker\".", "default": "docker" }, "timeout": { diff --git a/docs/bounded-queries.md b/docs/bounded-queries.md index 148144b62..30e391abe 100644 --- a/docs/bounded-queries.md +++ b/docs/bounded-queries.md @@ -36,7 +36,7 @@ The trust boundary operates in four stages: 1. **Trusted host staging.** Before any container starts, AWF clones each configured repository using `GH_TOKEN`/`GITHUB_TOKEN`, strips all credentials, remotes, hooks, and write bits from the resulting seed, and records the resolved commit in trusted staging metadata. Submodules and gitdir pointers are rejected. The staging credential is scrubbed after this phase and never reaches the broker or agent. -2. **Trusted broker over Unix socket.** A dedicated `awf-bounded-query-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It receives no network, no Squid proxy, and no external bridge. Its only connections are the Unix socket and the Docker socket (agent-invisible), used to launch queries. The broker holds the seed map -- including each repository's trusted sensitivity -- which the agent can never read or modify. +2. **Trusted broker over Unix socket.** A dedicated `awf-bounded-query-broker` container with `network_mode: none` serves requests over a Unix socket mounted into the agent. It receives no network, no Squid proxy, and no external bridge. Docker/gVisor query runtimes give it the agent-invisible Docker socket used to launch queries. The blocked sbx preview receives no daemon access. The broker holds the seed map -- including each repository's trusted sensitivity -- which the agent can never read or modify. 3. **Fresh, no-network query sandbox.** For each accepted request the broker creates a private writable copy of exactly one seed, then launches a single-use container with no network, a read-only root filesystem with bounded writable tmpfs mounts at `/tmp` and `/query`, no capabilities, a restrictive seccomp profile, and fixed memory, CPU, PID, and timeout limits. The agent-authored script runs at `/awf/query-script.py` and must write its result to `/query/out`. Stdout, stderr, and exit status are discarded. @@ -69,7 +69,7 @@ Add a `boundedQueries` section to your AWF JSON config file: |---|---|---|---| | `enabled` | boolean | Only explicit `true` enables the feature; omission normalizes to `false` | `false` | | `privateRepos` | array | Required non-empty when `enabled: true`; entries must be unique by slug (case-insensitive) | `[]` | -| `runtime` | string | `"docker"` or `"gvisor"` (gvisor requires `runsc` registered with the Docker daemon) | `"docker"` | +| `runtime` | string | `"docker"`, `"gvisor"`, or fail-closed preview `"sbx"` | `"docker"` | | `timeout` | integer | `1`-`540` seconds; the final 60 seconds before the 600-second bucket boundary are reserved for termination, validation, and cleanup | `30` | | `memoryLimit` | string | Docker memory format, e.g. `"512m"`, `"1g"` | `"512m"` | | `interpreter` | string | Only `"python3"` is currently supported | `"python3"` | @@ -87,7 +87,132 @@ The `sensitivity` value must be `public`, `internal`, `confidential`, or `sealed **Disabled behavior.** When `enabled` is `false` or the section is absent, AWF stages nothing, starts no broker, mounts no socket, sets no environment variable, installs no CLI, and generates no skill. -**Preflight failures** (all fail before the primary agent starts): `privateRepos` is empty, contains an invalid slug, or has duplicates; `runtime` is `"gvisor"` and `runsc` is not registered; the container runtime is a microVM backend (which cannot receive Compose bind mounts); the Docker host is not a `unix://` socket; `timeout` exceeds 540; no staging credential is present; or any seed cannot be materialized and verified. +**Preflight failures** (all fail before the primary agent starts): `privateRepos` is empty, contains an invalid slug, or has duplicates; `runtime` is `"gvisor"` and `runsc` is not registered; `runtime` is `"sbx"` and its executable capability proof is incomplete; a Docker/gVisor query uses a non-Unix Docker host; `timeout` exceeds 540; no staging credential is present; or any seed cannot be materialized and verified. + +### sbx query runtime status + +`"runtime": "sbx"` is a fail-closed preview surface. It is independent of the +primary-agent runtime: selecting it never reuses the primary agent's VM, +transport capability, or credentials, and it never falls back to Docker or +gVisor. + +The broker contains a dedicated `SbxQueryRunner` and executable +`sbx-capability-probe.js`. The audited CLI is Docker Sandboxes `v0.37.1`, using +the exact management surface `sbx version`, `sbx create`, `sbx exec`, +`sbx ls --json`, `sbx stop`, and `sbx rm --force`. AWF requires a unique +`awf-query-sbx--` VM, one CPU, the configured memory bound, an +immutable digest-pinned Python template, read-only seed/script target mounts, +an unprivileged fixed exec, and deterministic stop/delete scoped to that run. + +Current `sbx create` supports `--cpus`, `--memory`, `--name`, `--template`, and +read-only same-path mounts, but it does **not** expose the hard controls AWF +needs for `--network=none`, PID limits, disk limits, per-file size limits, or +explicit guest mount targets. Local and kit network denies are not equivalent: +organization governance can replace them. AWF therefore rejects this runtime +before staging or broker assembly and mounts neither the Docker socket nor any +sbx daemon credential. The probe exits non-zero and reports every missing +capability in JSON. Support remains blocked until sbx provides enforceable +versions of all controls and AWF publishes a digest-pinned standard-library-only +Python template/bootstrap. + +### Primary-agent and query runtime matrix + +The primary agent and each bounded query are separate sandbox decisions: + +- `container.containerRuntime` / `--container-runtime` selects the **primary + agent** runtime. +- `boundedQueries.runtime` selects the **single-use query** runtime. + +The broker never reuses the primary agent sandbox. Every accepted query creates +a new container or VM with a unique run/invocation identity and destroys it +before returning. No combination falls back to a weaker backend. + +| Primary agent | Docker query | gVisor query | sbx query | +|---|---|---|---| +| Docker | Supported when Docker is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx query probes | +| gVisor | Supported when the primary `runsc` runtime is available | Supported when `runsc` is registered | **Blocked** by mandatory sbx query probes | +| sbx | Supported when primary sbx and broker ingress probes pass | Supported when primary sbx, ingress, and `runsc` probes pass | **Blocked** by mandatory sbx query probes | + +“Supported” is capability-dependent, not an instruction to downgrade. An +unavailable primary runtime fails at primary preflight. An unavailable query +runtime fails at query preflight before the private root is created or any +repository is staged. Selecting `"runtime": "sbx"` is the explicit experimental +gate; the additional executable capability proof must also pass. With Docker +Sandboxes `v0.37.1`, all three sbx-query cells remain blocked. + +Examples of independent selection: + +```json +{ + "container": { "containerRuntime": "gvisor" }, + "boundedQueries": { + "enabled": true, + "privateRepos": [ + { "repo": "my-org/private-service", "sensitivity": "internal" } + ], + "runtime": "docker" + } +} +``` + +```json +{ + "container": { "containerRuntime": "sbx" }, + "boundedQueries": { + "enabled": true, + "privateRepos": [ + { "repo": "my-org/private-service", "sensitivity": "confidential" } + ], + "runtime": "gvisor" + } +} +``` + +The second example starts only when sbx primary-agent ingress and Docker +`runsc` query probes both pass. + +### Runtime telemetry + +AWF emits a deliberately narrow runtime telemetry record. It contains exactly: +primary backend, query backend, lifecycle class, capability state, and +success/failure category. It never contains repository identifiers or contents, +scripts, raw outputs, host/container paths, tokens, ingress capabilities, or +daemon credentials. Broker records are written to the protected +`runtime-telemetry.jsonl` file beside the protected audit log and are never +mounted into the agent. + +### Troubleshooting runtime selection + +| Symptom | Meaning | Action | +|---|---|---| +| `runsc ... not available; no fallback` | The gVisor query backend is not registered with Docker | Register `runsc`, verify it appears in `docker info --format '{{json .Runtimes}}'`, and rerun | +| `sbx ... blocked ... mandatory query-isolation controls` | The sbx query security probe failed as designed | Read the complete missing-control 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 host-loopback 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 | +| 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-query-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 query promotion criteria + +The experimental sbx query backend MUST remain blocked until all of these are +demonstrated in real VMs, not only deterministic fakes: + +1. A digest-pinned AWF Python standard-library-only template/bootstrap exists. +2. Per-VM network-none and lateral-connectivity denial are enforceable and + cannot be replaced by organization policy. +3. CPU, memory, PID, aggregate disk, and per-file size limits are enforceable. +4. Read-only seed/script 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 ingress retain byte-identical protocol behavior. + +Passing a version check alone, or passing only the CLI help probe, is not enough +to promote the backend. ## Sensitivity categories @@ -152,12 +277,29 @@ The three timing bits are charged as part of every accepted invocation's budget ## Agent interface -When bounded queries are enabled the agent container receives: +When bounded queries are enabled a Compose agent receives: - A Unix socket directory (read-write) mounted at `$AWF_BOUNDED_QUERY_SOCKET` -- A generated skill file (read-only) at `$AWF_BOUNDED_QUERY_SKILL` +- A generated skill and `bounded-query` executable in one read-only directory - `AWF_BOUNDED_QUERY_REPOS` -- a comma-separated list of configured repo slugs +An sbx primary agent is probed before staging to determine whether its +filesystem passthrough supports connecting to a host Unix socket. When it does, +the same Unix protocol is used. Otherwise the broker listens on a dedicated +Docker `internal` network with one ephemeral port published only on the Docker +host-gateway address; sbx reaches that service through `host.docker.internal`. The +agent receives only the endpoint and a random per-run capability. The +capability is not written to the generated skill, Compose/audit artifacts, +query environments, or logs. A one-shot pre-agent probe proves the selected +path is reachable; failure aborts before the primary agent starts. + +The sbx transport uses the same `/query` framing, body/header caps, canonical +response bytes, serialized scheduler, timing buckets, and protected audit +semantics as the Unix transport. It has no health or diagnostic route. +Authentication failures return the same canonical error as every other +failure. The broker remains absent from `awf-net` and `awf-ext`; its dedicated +network is internal and has no outbound route. + The generated skill lists each repository's configured sensitivity and initial run budget. It does not expose the broker's remaining ledger balance. GitHub tokens are removed from the agent environment whenever bounded queries are enabled, independently of the API and CLI proxies. diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 826ac98ad..8e41a9c52 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -80,6 +80,34 @@ it lets AWF interpose its own Squid proxy *underneath* Docker's sandbox proxy. VMs persist until explicitly removed; stopping an agent does not delete the VM. +### Bounded-query runtime is independent + +`container.containerRuntime: "sbx"` selects the primary agent's execution +model. `boundedQueries.runtime: "sbx"` is a separate backend behind the trusted +broker's `QueryRunner` boundary and must never reuse the primary agent VM, +agent-ingress capability, or agent credentials. + +The bounded-query sbx backend is currently a fail-closed preview. Docker +Sandboxes `v0.37.1` has CPU/memory limits and read-only same-path mounts, but +does not expose enforceable per-VM network-none, PID, disk, per-file size, or +guest mount-target controls. Local/kit network denies can also be replaced by +organization governance. AWF's executable capability probe therefore blocks +this query backend before staging or Compose assembly; no sbx daemon access is +passed to the broker and there is no Docker/gVisor fallback. See +[Bounded Queries](bounded-queries.md#sbx-query-runtime-status). + +The full 3×3 primary/query matrix is documented in +[Bounded Queries](bounded-queries.md#primary-agent-and-query-runtime-matrix). +All sbx-query cells are intentionally blocked; Docker and gVisor query +backends may run under an sbx primary agent only after its independent broker +ingress probe passes. Every query gets a new sandbox and no backend falls back. + +Promotion is gated on a digest-pinned Python-only template and real-VM proof of +network/lateral denial, PID/memory/CPU/disk/file-size enforcement, explicit +guest mount targets, credential and cross-invocation isolation, canonical +failure bytes, timing buckets, and interruption cleanup. Docker Sandboxes +`v0.37.1` cannot satisfy those controls. + ## Part 2 — How AWF uses `sbx` AWF's default backend runs the agent as a **Docker Compose service** alongside @@ -248,10 +276,13 @@ and, when true, substitutes two functions into the shared workflow runner: 2. Builds the agent environment (`buildAgentEnvironment`) using microVM-specific network targets (see below), merging credential env (`buildAgentCredentialEnv`) when the api-proxy is enabled. - 3. Calls `createSandbox({ workspaceDir, squidIp: SQUID_IP, extraMounts })`. - 4. Polls api-proxy health (via `host.docker.internal:10000/health`) since + 3. When bounded queries are enabled, resolves the trusted broker ingress, + mounts only its skill/wrapper directory (plus the socket directory when + Unix passthrough was proven), and probes reachability before agent startup. + 4. Calls `createSandbox({ workspaceDir, squidIp: SQUID_IP, extraMounts })`. + 5. Polls api-proxy health (via `host.docker.internal:10000/health`) since there is no compose `depends_on` gate across the VM boundary. - 5. Runs a Squid connectivity diagnostic (`curl --proxy ... https://api.github.com`). + 6. Runs a Squid connectivity diagnostic (`curl --proxy ... https://api.github.com`). - **`sbxRunAgentCommand`** runs the actual agent command with `execInSandbox`, honoring the agent timeout, workdir, TTY, and computed environment, and dumps api-proxy logs on non-zero exit for debugging. @@ -276,6 +307,14 @@ reachable** — the VM is on its own network. AWF compensates with two indirecti `host.docker.internal`, which resolves to the docker0 bridge from inside the VM. `COPILOT_*` / proxy env vars are pointed there instead of at `172.30.0.30`. +- **The bounded-query broker** uses a mounted Unix socket when an executable + disposable-sandbox probe proves sbx passthrough supports host sockets. + Otherwise it uses an authenticated HTTP endpoint on an ephemeral + host-gateway-only port that `host.docker.internal` can reach from inside the + VM. The broker is attached only to a dedicated Docker + `internal` network, not `awf-net` or `awf-ext`, so this ingress does not add + broker egress. The actual primary sandbox must pass a one-shot endpoint probe + before its agent command starts. The net effect: agent tools that respect `HTTP_PROXY`/`HTTPS_PROXY` route through AWF's Squid domain ACL; credentials are injected by AWF's api-proxy. Tools that diff --git a/scripts/build-bundle.mjs b/scripts/build-bundle.mjs index 2a8c79e2c..8b3e5f380 100644 --- a/scripts/build-bundle.mjs +++ b/scripts/build-bundle.mjs @@ -31,6 +31,9 @@ try { process.exit(1); } +const boundedQueryWrapperPath = join(projectRoot, 'containers', 'agent', 'bounded-query-wrapper.sh'); +const boundedQueryWrapperContent = readFileSync(boundedQueryWrapperPath, 'utf-8'); + // Ensure output directory exists mkdirSync(join(projectRoot, 'release'), { recursive: true }); @@ -47,6 +50,7 @@ await build({ // can produce a duplicate shebang that breaks `node` execution. define: { __AWF_SECCOMP_PROFILE__: JSON.stringify(seccompContent), + __AWF_BOUNDED_QUERY_WRAPPER__: JSON.stringify(boundedQueryWrapperContent), }, // Mark native/optional deps as external if needed // (none expected — all deps are pure JS) diff --git a/scripts/ci/report-bounded-query-runtime-matrix.js b/scripts/ci/report-bounded-query-runtime-matrix.js new file mode 100644 index 000000000..0a58976af --- /dev/null +++ b/scripts/ci/report-bounded-query-runtime-matrix.js @@ -0,0 +1,133 @@ +#!/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 version` only proves that the binary exists. Listing is authenticated + // and non-mutating, so it also proves daemon and credential availability. + const sbxPrimary = commandRunner('sbx', ['ls']).ok; + const sbxQuery = commandRunner( + process.execPath, + ['containers/bounded-query/broker/sbx-capability-probe.js'], + ); + let sbxQuerySupported = false; + if (sbxQuery.stdout) { + try { + sbxQuerySupported = JSON.parse(sbxQuery.stdout).supported === true; + } catch { + sbxQuerySupported = false; + } + } + return { + primary: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxPrimary ? 'supported' : 'unavailable', + }, + query: { + docker: docker.ok ? 'supported' : 'unavailable', + gvisor: gvisor ? 'supported' : 'unavailable', + sbx: sbxQuerySupported ? 'supported' : 'blocked', + }, + }; +} + +function evaluate(primary, query, capabilities) { + if (capabilities.primary[primary] !== 'supported') { + return { + status: 'BLOCKED', + capability: capabilities.primary[primary], + phase: 'primary-preflight', + }; + } + if (capabilities.query[query] !== 'supported') { + return { + status: 'BLOCKED', + capability: capabilities.query[query], + phase: 'query-preflight', + }; + } + return { status: 'SUPPORTED', capability: 'supported', phase: 'ready' }; +} + +function renderMatrix(capabilities) { + const lines = [ + '## Bounded-query runtime capability matrix', + '', + '| Primary agent | Query sandbox | Result | Primary capability | Query capability | Gate |', + '|---|---|---|---|---|---|', + ]; + for (const primary of BACKENDS) { + for (const query of BACKENDS) { + const result = evaluate(primary, query, capabilities); + lines.push( + `| ${primary} | ${query} | ${result.status} | ${capabilities.primary[primary]} | ` + + `${capabilities.query[query]} | ${result.phase} |`, + ); + } + } + lines.push( + '', + '> BLOCKED is an expected fail-closed security result, not runtime success. No fallback is attempted.', + ); + 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, query] = requirement.split('/'); + if (!BACKENDS.includes(primary) || !BACKENDS.includes(query)) { + throw new Error(`Invalid --require combination: ${requirement}`); + } + const result = evaluate(primary, query, 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-query-runtime-matrix.test.ts b/scripts/ci/report-bounded-query-runtime-matrix.test.ts new file mode 100644 index 000000000..62d3f09f6 --- /dev/null +++ b/scripts/ci/report-bounded-query-runtime-matrix.test.ts @@ -0,0 +1,52 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { collectCapabilities, evaluate, renderMatrix } = require( + path.join(__dirname, 'report-bounded-query-runtime-matrix.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-query runtime capability report', () => { + it('reports all nine combinations and preserves the sbx query 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 | query-preflight |'); + expect(report).toContain('BLOCKED is an expected fail-closed security result, not runtime success'); + }); + + it('never promotes an unavailable primary or query runtime through fallback', () => { + const capabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + query: { 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: 'query-preflight', + }); + expect(evaluate('docker', 'sbx', capabilities)).toEqual({ + status: 'BLOCKED', + capability: 'blocked', + phase: 'query-preflight', + }); + }); +}); diff --git a/scripts/ci/smoke-bounded-queries.sh b/scripts/ci/smoke-bounded-queries.sh index d035569a2..d69b4f1dd 100755 --- a/scripts/ci/smoke-bounded-queries.sh +++ b/scripts/ci/smoke-bounded-queries.sh @@ -164,6 +164,8 @@ JSON fi echo "::endgroup::" done + + node "$workspace/scripts/ci/report-bounded-query-runtime-matrix.js" --require docker/docker } if [[ "${1:-}" == "--inside-agent" ]]; then diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index cd5e8dcec..72d67ff63 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -7,8 +7,10 @@ import { fixArtifactPermissionsForRootless } from './artifact-permissions'; import { getLocalDockerEnv } from './host-env'; import { resolveBoundedQueryPaths } from './bounded-query/paths'; -const BOUNDED_QUERY_AUDIT_CONTAINER_PATH = - 'awf-bounded-query-broker:/var/log/awf-bounded-query/bounded-query.jsonl'; +const BOUNDED_QUERY_AUDIT_FILES = [ + 'bounded-query.jsonl', + 'runtime-telemetry.jsonl', +] as const; /** * Copies the iptables audit dump from the init-signal volume to the audit directory. @@ -32,20 +34,23 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void } if (fs.existsSync(boundedQueryRoot)) { - try { - const destination = path.join(targetAuditDir, 'bounded-query.jsonl'); - const result = execa.sync( - 'docker', - ['cp', BOUNDED_QUERY_AUDIT_CONTAINER_PATH, destination], - { env: getLocalDockerEnv(), reject: false }, - ); - if (result.exitCode === 0) { - logger.debug('Copied bounded-query broker audit to audit directory'); - } else { - logger.debug('Could not copy bounded-query audit file:', result.stderr); + for (const auditFile of BOUNDED_QUERY_AUDIT_FILES) { + try { + const source = `awf-bounded-query-broker:/var/log/awf-bounded-query/${auditFile}`; + const destination = path.join(targetAuditDir, auditFile); + const result = execa.sync( + 'docker', + ['cp', source, destination], + { env: getLocalDockerEnv(), reject: false }, + ); + if (result.exitCode === 0) { + logger.debug(`Copied bounded-query broker ${auditFile} to audit directory`); + } else { + logger.debug(`Could not copy bounded-query ${auditFile}:`, result.stderr); + } + } catch (error) { + logger.debug(`Could not copy bounded-query ${auditFile}:`, error); } - } catch (error) { - logger.debug('Could not copy bounded-query audit file:', error); } } } diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 95c59c073..92cfbd3c9 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -889,9 +889,10 @@ "type": "string", "enum": [ "docker", - "gvisor" + "gvisor", + "sbx" ], - "description": "Sandbox runtime backend used to execute the query script, selected independently of the primary agent runtime. \"docker\" uses the daemon default runtime; \"gvisor\" requires the runsc OCI runtime and fails closed when it is unavailable. Default: \"docker\".", + "description": "Sandbox runtime backend used to execute the query script, 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 mandatory no-network, PID, disk, file-size, target-mount, CPU, and memory controls. No backend ever falls back. Default: \"docker\".", "default": "docker" }, "timeout": { diff --git a/src/bounded-query/framing-deadline.test.ts b/src/bounded-query/framing-deadline.test.ts new file mode 100644 index 000000000..011a7cc7a --- /dev/null +++ b/src/bounded-query/framing-deadline.test.ts @@ -0,0 +1,25 @@ +import { EventEmitter } from 'events'; +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { BODY_READ_TIMEOUT_MS, readBoundedBody } = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'framing.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-query body framing deadline', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('terminates a peer that stops sending its request body', async () => { + jest.useFakeTimers(); + const request = Object.assign(new EventEmitter(), { pause: jest.fn() }); + + const result = readBoundedBody(request); + jest.advanceTimersByTime(BODY_READ_TIMEOUT_MS); + + await expect(result).resolves.toEqual({ error: 'request body deadline exceeded' }); + expect(request.pause).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/bounded-query/ingress-conformance.test.ts b/src/bounded-query/ingress-conformance.test.ts new file mode 100644 index 000000000..2ebd5e4c3 --- /dev/null +++ b/src/bounded-query/ingress-conformance.test.ts @@ -0,0 +1,213 @@ +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-query', 'broker'); +const { createServer, createTcpServer, listenOnSocket, listenOnTcp, MAX_CONNECTIONS } = require( + path.join(brokerDir, 'server.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +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'); + +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 = 'print(True)'): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ + method: 'POST', + path: '/query', + ...options, + headers: { + 'content-type': 'application/octet-stream', + 'x-awf-query-version': '2', + '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-query 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-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', 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); + }); + + it('uses a one-shot probe capability without launching or consuming a query request', 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'); + }); + + it('keeps oversized and parallel request behavior identical across transports', async () => { + const oversized = 'x'.repeat(64 * 1024 + 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('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-Query-Version: 2', + '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-query/ingress.test.ts b/src/bounded-query/ingress.test.ts new file mode 100644 index 000000000..812499eb0 --- /dev/null +++ b/src/bounded-query/ingress.test.ts @@ -0,0 +1,93 @@ +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 { resolveBoundedQueryPaths } 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-query ingress resolution', () => { + let workDir: string; + let config: WrapperConfig; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-ingress-resolution-')); + config = { + workDir, + boundedQueryIngressTransport: 'sbx-http', + } as WrapperConfig; + const paths = resolveBoundedQueryPaths(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 = resolveBoundedQueryPaths(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('returns only the endpoint, two capabilities, and agent-visible artifact paths', async () => { + const result = await resolveSbxIngress(config); + const paths = resolveBoundedQueryPaths(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('removes the private capability file after broker startup and sbx probing', () => { + const capabilityPath = resolveBoundedQueryPaths(workDir).capabilityPath; + expect(fs.existsSync(capabilityPath)).toBe(true); + removeSbxIngressCapabilityFile(config); + expect(fs.existsSync(capabilityPath)).toBe(false); + }); +}); diff --git a/src/bounded-query/ingress.ts b/src/bounded-query/ingress.ts new file mode 100644 index 000000000..bc95bc85e --- /dev/null +++ b/src/bounded-query/ingress.ts @@ -0,0 +1,110 @@ +import * as fs from 'fs'; +import execa from 'execa'; +import { BOUNDED_QUERY_BROKER_CONTAINER_NAME } from '../constants'; +import { getLocalDockerEnv } from '../host-env'; +import { resolveDockerHostGateway } from '../services/host-gateway'; +import type { WrapperConfig } from '../types'; +import { resolveBoundedQueryPaths } from './paths'; + +export const BOUNDED_QUERY_TCP_PORT = 18080; +export const BOUNDED_QUERY_INGRESS_NETWORK = 'awf-bounded-query-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 = resolveBoundedQueryPaths(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-query sbx ingress capability file is malformed'); + } + return parsed as SbxIngressCapabilities; +} + +/** Resolves the healthy host-gateway publication without logging capabilities. */ +export async function resolveSbxIngress(config: WrapperConfig): Promise { + if (config.boundedQueryIngressTransport !== 'sbx-http') { + throw new Error('resolveSbxIngress called for a non-HTTP bounded-query transport'); + } + const expectedHostIp = resolveDockerHostGateway(); + if (!expectedHostIp) { + throw new Error('Could not resolve the Docker host-gateway IP for bounded-query 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_QUERY_TCP_PORT}/tcp") 0}}{{.HostIp}}:{{.HostPort}}{{end}}`, + BOUNDED_QUERY_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 = resolveBoundedQueryPaths(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-query sbx ingress is not narrowly published on host-gateway ${expectedHostIp}`); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error( + `Bounded-query 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(resolveBoundedQueryPaths(config.workDir).capabilityPath, { force: true }); +} + +/** @internal */ +// ts-prune-ignore-next +export const ingressTestHelpers = { readCapabilities }; diff --git a/src/bounded-query/manager.test.ts b/src/bounded-query/manager.test.ts index 6267c9b40..9a49196cd 100644 --- a/src/bounded-query/manager.test.ts +++ b/src/bounded-query/manager.test.ts @@ -82,7 +82,7 @@ describe('prepareBoundedQueries', () => { expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false); }); - it('creates the directory layout, seed map, and skill artifact', async () => { + it('creates the directory layout, seed map, skill, and wrapper artifacts', async () => { await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner }); const paths = resolveBoundedQueryPaths(workDir); @@ -92,6 +92,8 @@ describe('prepareBoundedQueries', () => { expect(fs.existsSync(paths.auditDir)).toBe(true); expect(fs.existsSync(paths.controlDir)).toBe(true); expect(fs.existsSync(paths.skillPath)).toBe(true); + expect(fs.existsSync(paths.wrapperPath)).toBe(true); + expect(fs.statSync(paths.wrapperPath).mode & 0o777).toBe(0o555); expect(paths.root.startsWith(workDir)).toBe(false); const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')); @@ -103,6 +105,38 @@ describe('prepareBoundedQueries', () => { expect(fs.statSync(paths.seedMapPath).mode & 0o777).toBe(0o600); }); + it.each([ + [true, 'unix'], + [false, 'sbx-http'], + ] as const)('selects sbx ingress from the executable socket probe (%s)', async (supported, expected) => { + const config = { + ...buildConfig(workDir), + containerRuntime: 'sbx', + }; + const probe = jest.fn().mockResolvedValue(supported); + + await prepareBoundedQueries(config, { + env: { GH_TOKEN: 't' }, + gitRunner, + probeSbxUnixSocket: probe, + }); + + const paths = resolveBoundedQueryPaths(workDir); + expect(config.boundedQueryIngressTransport).toBe(expected); + expect(probe).toHaveBeenCalledTimes(1); + expect(fs.existsSync(paths.capabilityPath)).toBe(!supported); + if (!supported) { + const raw = fs.readFileSync(paths.capabilityPath, 'utf8'); + expect(raw).not.toContain('GH_TOKEN'); + expect(JSON.parse(raw)).toEqual({ + version: 1, + query: expect.stringMatching(/^[0-9a-f]{64}$/), + probe: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(fs.statSync(paths.capabilityPath).mode & 0o777).toBe(0o600); + } + }); + it('keeps the seed map free of host paths and credentials', async () => { await prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 'ghs_secret' }, gitRunner }); const raw = fs.readFileSync(resolveBoundedQueryPaths(workDir).seedMapPath, 'utf8'); @@ -174,7 +208,7 @@ describe('prepareBoundedQueries', () => { await expect(prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner })) .rejects.toThrow(/EEXIST|file already exists/); } finally { - fs.rmSync(paths.ingressRoot, { force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); fs.rmSync(target, { recursive: true, force: true }); } }); @@ -188,6 +222,46 @@ describe('prepareBoundedQueries', () => { prepareBoundedQueries(buildConfig(workDir), { env: { GH_TOKEN: 't' }, gitRunner: failing }), ).rejects.toThrow(/staging failed/); }); + + it.each(['docker', 'gvisor', 'sbx'] as const)( + 'fails query runtime %s capability preflight before directories or staging', + async (runtime) => { + const assertRuntimeAvailable = jest.fn().mockRejectedValue(new Error(`${runtime} unavailable`)); + const probeSbxUnixSocket = jest.fn(); + const config = buildConfig(workDir, { runtime }); + await expect(prepareBoundedQueries(config, { + env: { GH_TOKEN: 't' }, + gitRunner, + assertRuntimeAvailable, + probeSbxUnixSocket, + })).rejects.toThrow(`${runtime} unavailable`); + expect(assertRuntimeAvailable).toHaveBeenCalledTimes(1); + expect(probeSbxUnixSocket).not.toHaveBeenCalled(); + expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false); + }, + ); + + it.each([undefined, 'gvisor', 'sbx'] as const)( + 'fails primary runtime %s capability preflight before query preflight or staging', + async (containerRuntime) => { + const assertPrimaryAvailable = jest.fn().mockRejectedValue(new Error('primary unavailable')); + const assertRuntimeAvailable = jest.fn(); + const probeSbxUnixSocket = jest.fn(); + await expect(prepareBoundedQueries( + { ...buildConfig(workDir), containerRuntime }, + { + env: { GH_TOKEN: 't' }, + gitRunner, + assertPrimaryAvailable, + assertRuntimeAvailable, + probeSbxUnixSocket, + }, + )).rejects.toThrow('primary unavailable'); + expect(assertRuntimeAvailable).not.toHaveBeenCalled(); + expect(probeSbxUnixSocket).not.toHaveBeenCalled(); + expect(fs.existsSync(resolveBoundedQueryPaths(workDir).root)).toBe(false); + }, + ); }); describe('teardownBoundedQueries', () => { diff --git a/src/bounded-query/manager.ts b/src/bounded-query/manager.ts index ef2bb1867..107323ba4 100644 --- a/src/bounded-query/manager.ts +++ b/src/bounded-query/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,12 +10,23 @@ import { resolveBoundedQueryPaths, type BoundedQueryPaths, } from './paths'; -import { assertQueryRuntimeAvailable, validateBoundedQueryConfig } from './preflight'; +import { + assertPrimaryRuntimeAvailable, + assertQueryRuntimeAvailable, + validateBoundedQueryConfig, +} from './preflight'; import { writeBoundedQuerySkill } from './skill'; +import { writeBoundedQueryWrapper } from './wrapper-artifact'; import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from './staging'; import { BOUNDED_QUERY_SEED_MAP_VERSION, type BoundedQuerySeedMap } from './types'; import { assertBoundedQueryPrivateRootIsolated } from './mount-policy'; import { fixArtifactPermissionsForRootless } from '../artifact-permissions'; +import { runtimeUsesComposeAgent } from '../container-runtime'; +import { probeSbxUnixSocketMount } from '../sbx-manager'; +import { + resolveBoundedQueryPrimaryBackend, + serializeBoundedQueryRuntimeTelemetry, +} from './runtime-matrix'; /** * Bounded-query lifecycle orchestration. @@ -132,6 +144,37 @@ export interface PrepareBoundedQueriesDeps { 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 query-runtime capability preflight (tests). */ + assertRuntimeAvailable?: typeof assertQueryRuntimeAvailable; + /** Override primary-runtime capability preflight (tests). */ + assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable; +} + +interface SbxIngressCapabilities { + version: 1; + query: string; + probe: string; +} + +function writeSbxIngressCapabilities(paths: BoundedQueryPaths): 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); + } } /** @@ -153,11 +196,56 @@ export async function prepareBoundedQueries( throw new Error(`Bounded-query configuration is invalid:\n - ${errors.join('\n - ')}`); } + const primaryBackend = resolveBoundedQueryPrimaryBackend(config.containerRuntime); + const telemetryBase = { + primaryBackend, + queryBackend: boundedQueries.runtime, + lifecycleClass: 'preflight' as const, + }; + const assertRuntimeAvailable = deps.assertRuntimeAvailable ?? assertQueryRuntimeAvailable; + const assertPrimaryAvailable = deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable; + try { + await assertPrimaryAvailable(config.containerRuntime); + } catch (error) { + logger.info( + `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'unavailable', + category: 'primary-runtime-unavailable', + })}`, + ); + throw error; + } + try { + await assertRuntimeAvailable(boundedQueries); + } catch (error) { + logger.info( + `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({ + ...telemetryBase, + capabilityState: boundedQueries.runtime === 'sbx' ? 'blocked' : 'unavailable', + category: boundedQueries.runtime === 'sbx' ? 'query-security-block' : 'query-runtime-unavailable', + })}`, + ); + throw error; + } + logger.info( + `Bounded-query runtime telemetry: ${serializeBoundedQueryRuntimeTelemetry({ + ...telemetryBase, + capabilityState: 'supported', + category: 'ready', + })}`, + ); + + if (runtimeUsesComposeAgent(config.containerRuntime)) { + config.boundedQueryIngressTransport = 'unix'; + } else { + const probe = deps.probeSbxUnixSocket ?? probeSbxUnixSocketMount; + config.boundedQueryIngressTransport = (await probe()) ? 'unix' : 'sbx-http'; + } + const paths = resolveBoundedQueryPaths(config.workDir); assertBoundedQueryPrivateRootIsolated(config, paths, env); - await assertQueryRuntimeAvailable(boundedQueries); - const token = resolveStagingToken(env); if (!token) { // Already covered by validateBoundedQueryConfig; re-checked so the token is @@ -182,6 +270,9 @@ export async function prepareBoundedQueries( } prepareDirectories(paths); + if (config.boundedQueryIngressTransport === 'sbx-http') { + writeSbxIngressCapabilities(paths); + } const runId = generateBoundedQueryRunId(); const staging = await stageBoundedQuerySeeds({ @@ -207,6 +298,7 @@ export async function prepareBoundedQueries( timeoutSeconds: boundedQueries.timeout, maxInvocations: boundedQueries.maxInvocations, }); + writeBoundedQueryWrapper(paths); logger.info( `Bounded queries: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`, @@ -299,4 +391,5 @@ export const managerTestHelpers = { readRunId, removeOrphanQueryContainers, removePrivateState, + writeSbxIngressCapabilities, }; diff --git a/src/bounded-query/mount-policy.test.ts b/src/bounded-query/mount-policy.test.ts index 98e333672..ed10b0afa 100644 --- a/src/bounded-query/mount-policy.test.ts +++ b/src/bounded-query/mount-policy.test.ts @@ -138,7 +138,7 @@ describe('bounded-query private-root mount policy', () => { fs.mkdirSync(target); fs.symlinkSync(target, alias); expect(resolvePathThroughExistingAncestor(path.join(alias, 'missing', 'leaf'))) - .toBe(path.join(target, 'missing', 'leaf')); + .toBe(path.join(fs.realpathSync.native(target), 'missing', 'leaf')); }); it('rejects relative paths before filesystem resolution', () => { diff --git a/src/bounded-query/paths.test.ts b/src/bounded-query/paths.test.ts index 562357833..3ce1e3058 100644 --- a/src/bounded-query/paths.test.ts +++ b/src/bounded-query/paths.test.ts @@ -26,8 +26,10 @@ describe('bounded-query paths', () => { expect(paths.controlDir.startsWith(paths.root)).toBe(true); expect(paths.auditDir.startsWith(paths.root)).toBe(true); expect(paths.seedMapPath.startsWith(paths.root)).toBe(true); + expect(paths.capabilityPath.startsWith(paths.controlDir)).toBe(true); expect(paths.runDir.startsWith(paths.ingressRoot)).toBe(true); expect(paths.agentDir.startsWith(paths.ingressRoot)).toBe(true); + expect(paths.wrapperPath.startsWith(paths.agentDir)).toBe(true); }); it('places the socket and skill inside their advertised directories', () => { diff --git a/src/bounded-query/paths.ts b/src/bounded-query/paths.ts index c37834541..360c6a6b7 100644 --- a/src/bounded-query/paths.ts +++ b/src/bounded-query/paths.ts @@ -17,10 +17,11 @@ import * as path from 'path'; * control/ broker readiness and other private control state * audit/ protected broker diagnostics (never agent-visible) * seed-map.json normalized repo -> opaque seed id map (broker input) + * control/sbx-ingress.json ephemeral sbx ingress capabilities * * /var/tmp/awf-bounded-query-ingress--/ * run/ broker Unix socket, shared read-write with the agent - * skill/ generated SKILL.md, shared read-only with the agent + * skill/ generated SKILL.md and wrapper, shared read-only * ``` */ export interface BoundedQueryPaths { @@ -46,6 +47,10 @@ export interface BoundedQueryPaths { socketPath: string; /** Host path of the generated skill document. */ skillPath: string; + /** Host path of the agent-facing bounded-query 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. */ @@ -57,6 +62,12 @@ export const BOUNDED_QUERY_SOCKET_FILENAME = 'broker.sock'; /** Name of the generated skill document inside {@link BoundedQueryPaths.agentDir}. */ export const BOUNDED_QUERY_SKILL_FILENAME = 'SKILL.md'; +/** Name of the generated agent-facing executable. */ +export const BOUNDED_QUERY_WRAPPER_FILENAME = 'bounded-query'; + +/** Name of the broker-private sbx ingress capability file. */ +export const BOUNDED_QUERY_CAPABILITY_FILENAME = 'sbx-ingress.json'; + // ── Fixed container paths ──────────────────────────────────────────────────── // // These are part of the agent-visible contract (the wrapper and the generated @@ -135,6 +146,8 @@ export function resolveBoundedQueryPaths( seedMapPath: path.join(root, 'seed-map.json'), socketPath: path.join(runDir, BOUNDED_QUERY_SOCKET_FILENAME), skillPath: path.join(agentDir, BOUNDED_QUERY_SKILL_FILENAME), + wrapperPath: path.join(agentDir, BOUNDED_QUERY_WRAPPER_FILENAME), + capabilityPath: path.join(root, 'control', BOUNDED_QUERY_CAPABILITY_FILENAME), }; } diff --git a/src/bounded-query/preflight.test.ts b/src/bounded-query/preflight.test.ts index 46db821fb..a8bd6c24a 100644 --- a/src/bounded-query/preflight.test.ts +++ b/src/bounded-query/preflight.test.ts @@ -1,6 +1,11 @@ import type { WrapperConfig } from '../types'; import execa from 'execa'; -import { assertQueryRuntimeAvailable, preflightTestHelpers, validateBoundedQueryConfig } from './preflight'; +import { + assertPrimaryRuntimeAvailable, + assertQueryRuntimeAvailable, + preflightTestHelpers, + validateBoundedQueryConfig, +} from './preflight'; import type { BoundedQueriesConfig } from '../types'; import type { BoundedQueryRepository } from '../types/bounded-query-options'; @@ -78,9 +83,12 @@ describe('validateBoundedQueryConfig', () => { expect(validateBoundedQueryConfig(buildConfig({ runtime: 'gvisor' }), envWithToken)).toEqual([]); }); - it('rejects a microVM primary agent runtime, which cannot receive the socket', () => { - const errors = validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'sbx' }), envWithToken); - expect(errors.join('\n')).toContain('cannot be exposed to a "sbx" primary agent'); + it('accepts the sbx query runtime at the configuration layer for executable preflight', () => { + expect(validateBoundedQueryConfig(buildConfig({ runtime: 'sbx' }), envWithToken)).toEqual([]); + }); + + it('accepts an sbx primary agent; trusted preflight selects and probes its ingress', () => { + expect(validateBoundedQueryConfig(buildConfig({}, { containerRuntime: 'sbx' }), envWithToken)).toEqual([]); }); it('allows a gvisor primary agent runtime (still a Compose service)', () => { @@ -111,6 +119,15 @@ describe('validateBoundedQueryConfig', () => { ).toEqual([]); }); + it('does not apply Docker-daemon transport requirements to the independent sbx query runtime', () => { + expect( + validateBoundedQueryConfig( + buildConfig({ runtime: 'sbx' }, { awfDockerHost: 'tcp://localhost:2375' }), + envWithToken, + ), + ).toEqual([]); + }); + it('accepts GITHUB_TOKEN as the staging credential', () => { expect(validateBoundedQueryConfig(buildConfig(), { GITHUB_TOKEN: 'ghs_x' })).toEqual([]); }); @@ -145,10 +162,25 @@ describe('validateBoundedQueryConfig', () => { }); describe('assertQueryRuntimeAvailable', () => { - it('does not query Docker for the default runtime', async () => { - const query = jest.fn(); - await expect(assertQueryRuntimeAvailable(baseBoundedQueries, query)).resolves.toBeUndefined(); - expect(query).not.toHaveBeenCalled(); + it('requires a reachable Docker daemon for the default query runtime', async () => { + const runtimeQuery = jest.fn(); + const dockerAvailable = jest.fn().mockResolvedValue(true); + await expect( + assertQueryRuntimeAvailable(baseBoundedQueries, runtimeQuery, jest.fn(), dockerAvailable), + ).resolves.toBeUndefined(); + expect(runtimeQuery).not.toHaveBeenCalled(); + expect(dockerAvailable).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the Docker query daemon is unavailable', async () => { + await expect( + assertQueryRuntimeAvailable( + baseBoundedQueries, + jest.fn(), + jest.fn(), + jest.fn().mockResolvedValue(false), + ), + ).rejects.toThrow(/Docker daemon.*not available.*never fall back/s); }); it('accepts gvisor when runsc is registered with the daemon', async () => { @@ -166,6 +198,37 @@ describe('assertQueryRuntimeAvailable', () => { ).rejects.toThrow(/runsc.*not available|not available.*fall back/s); }); + it('fails closed when sbx lacks any mandatory query isolation capability', async () => { + const query = jest.fn().mockResolvedValue({ + supported: false, + version: '0.37.1', + missing: ['sbx create --network=none', 'sbx create --pids-limit'], + }); + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: 'sbx' }, + jest.fn(), + query, + ), + ).rejects.toThrow(/sbx.*blocked.*network=none.*pids-limit.*never fall back/s); + }); + + it('accepts sbx only when the complete executable capability proof succeeds', async () => { + const query = jest.fn().mockResolvedValue({ + supported: true, + version: '0.37.1', + missing: [], + }); + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: 'sbx' }, + jest.fn(), + query, + ), + ).resolves.toBeUndefined(); + expect(query).toHaveBeenCalledTimes(1); + }); + it('detects registered runtimes through Docker info', async () => { mockExeca.mockResolvedValue({ exitCode: 0, stdout: '{"runc":{},"runsc":{}}' }); await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(true); @@ -183,4 +246,108 @@ describe('assertQueryRuntimeAvailable', () => { mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'not-json' }); await expect(preflightTestHelpers.defaultDockerRuntimeQuery('runsc')).resolves.toBe(false); }); + + it('reports the current sbx CLI as unsupported when essential controls are absent', 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', + }); + + await expect(preflightTestHelpers.defaultSbxCapabilityQuery()).resolves.toEqual({ + supported: false, + version: '0.37.1', + missing: expect.arrayContaining([ + 'pinned AWF Python query template and bootstrap', + 'sbx create --network=none', + 'sbx create --pids-limit', + 'sbx create --disk-limit', + 'sbx create --ulimit-fsize', + 'sbx create --mount-target', + ]), + }); + }); + + describe('assertPrimaryRuntimeAvailable', () => { + it.each([ + [undefined, '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/], + ['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('requires authenticated sbx daemon reachability and preserves only its management environment', async () => { + const savedToken = process.env.SBX_AUTH_TOKEN; + const savedProxy = process.env.DOCKER_SANDBOXES_PROXY; + const savedXdg = process.env.XDG_CONFIG_HOME; + process.env.SBX_AUTH_TOKEN = 'daemon-credential'; + process.env.DOCKER_SANDBOXES_PROXY = 'http://proxy.invalid'; + process.env.XDG_CONFIG_HOME = '/wrong/config'; + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Docker Sandboxes v0.37.1' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '' }); + + try { + const report = await preflightTestHelpers.defaultSbxCapabilityQuery(); + expect(report.missing).toContain('authenticated sbx CLI/daemon'); + expect(mockExeca).toHaveBeenCalledWith( + 'sbx', + ['ls'], + expect.objectContaining({ + env: expect.objectContaining({ SBX_AUTH_TOKEN: 'daemon-credential' }), + }), + ); + const lsOptions = mockExeca.mock.calls.find((call) => call[1][0] === 'ls')?.[2]; + expect(lsOptions.env).not.toHaveProperty('DOCKER_SANDBOXES_PROXY'); + expect(lsOptions.env).not.toHaveProperty('XDG_CONFIG_HOME'); + } finally { + if (savedToken === undefined) delete process.env.SBX_AUTH_TOKEN; + else process.env.SBX_AUTH_TOKEN = savedToken; + if (savedProxy === undefined) delete process.env.DOCKER_SANDBOXES_PROXY; + else process.env.DOCKER_SANDBOXES_PROXY = savedProxy; + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + } + }); + + it('uses authenticated sbx listing for primary availability', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: '[]' }); + + await expect(preflightTestHelpers.defaultSbxAvailabilityQuery()).resolves.toBe(true); + expect(mockExeca).toHaveBeenCalledWith( + 'sbx', + ['ls'], + expect.objectContaining({ reject: false }), + ); + }); }); diff --git a/src/bounded-query/preflight.ts b/src/bounded-query/preflight.ts index 1c3a46c64..3172ab798 100644 --- a/src/bounded-query/preflight.ts +++ b/src/bounded-query/preflight.ts @@ -1,6 +1,5 @@ import execa from 'execa'; import { getLocalDockerEnv } from '../host-env'; -import { runtimeUsesComposeAgent } from '../container-runtime'; import type { BoundedQueriesConfig, WrapperConfig } from '../types'; import { normalizeRepoKey } from './paths'; import { MAX_QUERY_TIMEOUT_SECONDS, BOUNDED_QUERY_REPO_PATTERN } from './protocol'; @@ -20,13 +19,26 @@ import { resolveStagingToken } from './staging'; */ /** Query sandbox runtimes with a safe, implemented no-network launcher. */ -const SUPPORTED_QUERY_RUNTIMES = new Set(['docker', 'gvisor']); +const SUPPORTED_QUERY_RUNTIMES = new Set(['docker', 'gvisor', 'sbx']); /** Docker OCI runtime name required for the `gvisor` query runtime. */ const GVISOR_DOCKER_RUNTIME = 'runsc'; /** Detects whether the Docker daemon exposes a named OCI runtime. */ export type DockerRuntimeQuery = (runtimeName: string) => Promise; +/** Detects whether the Docker daemon required by a primary/query backend is reachable. */ +export type DockerAvailabilityQuery = () => Promise; +/** Detects whether the sbx primary-agent runtime is installed and authenticated. */ +export type SbxAvailabilityQuery = () => Promise; + +export interface SbxCapabilityReport { + supported: boolean; + version?: string; + missing: string[]; +} + +/** Executes the minimum host-side capability proof for the sbx query backend. */ +export type SbxCapabilityQuery = () => Promise; const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => { const result = await execa('docker', ['info', '--format', '{{json .Runtimes}}'], { @@ -43,6 +55,101 @@ const defaultDockerRuntimeQuery: DockerRuntimeQuery = async (runtimeName) => { } }; +const defaultDockerAvailabilityQuery: DockerAvailabilityQuery = async () => { + const result = await execa('docker', ['info', '--format', '{{.ServerVersion}}'], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + return result.exitCode === 0; +}; + +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; + } +}; + +const SBX_AUDITED_VERSION = '0.37.1'; +const SBX_REQUIRED_CREATE_FLAGS = [ + '--cpus', + '--memory', + '--name', + '--template', + '--network=none', + '--pids-limit', + '--disk-limit', + '--ulimit-fsize', + '--mount-target', +] as const; +const SBX_REQUIRED_EXEC_FLAGS = ['--user', '--workdir'] as const; + +function helpIncludesFlag(help: string, flag: string): boolean { + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|[\\s,])${escaped}(?=([=\\s,]|$))`, 'm').test(help); +} + +const defaultSbxCapabilityQuery: SbxCapabilityQuery = 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, missing: ['authenticated sbx CLI/daemon'] }; + } + + const version = /\bv?(\d+\.\d+\.\d+)\b/.exec(versionResult.stdout)?.[1]; + const missing: string[] = ['pinned AWF Python query template and bootstrap']; + 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, missing }; +}; + /** * Validates everything about a bounded-query configuration that can be decided * without touching Docker or the network. @@ -83,7 +190,7 @@ export function validateBoundedQueryConfig( errors.push( `boundedQueries.runtime "${boundedQueries.runtime}" is not supported. ` + 'AWF has no no-network, per-invocation bounded-query launcher for it, and bounded queries ' + - 'never downgrade to a weaker runtime. Use "docker" or "gvisor".', + 'never downgrade to a weaker runtime. Use "docker", "gvisor", or "sbx".', ); } @@ -111,16 +218,8 @@ export function validateBoundedQueryConfig( errors.push(`boundedQueries.memoryLimit "${boundedQueries.memoryLimit}" is not a Docker memory limit`); } - if (!runtimeUsesComposeAgent(config.containerRuntime)) { - errors.push( - `bounded queries cannot be exposed to a "${config.containerRuntime}" primary agent: ` + - 'the broker socket is shared through a Docker Compose bind mount, which a microVM agent ' + - 'does not receive. Disable boundedQueries or use a Compose-based container runtime.', - ); - } - const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; - if (dockerHost && !dockerHost.startsWith('unix://')) { + if (boundedQueries.runtime !== 'sbx' && dockerHost && !dockerHost.startsWith('unix://')) { errors.push( `bounded queries require a Unix-socket Docker host, but the resolved host is "${dockerHost}". ` + 'The broker runs with network_mode: none so it can only reach the daemon over a bind-mounted ' + @@ -147,8 +246,30 @@ export function validateBoundedQueryConfig( export async function assertQueryRuntimeAvailable( boundedQueries: BoundedQueriesConfig, queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, + querySbxCapabilities: SbxCapabilityQuery = defaultSbxCapabilityQuery, + queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, ): Promise { - if (boundedQueries.runtime !== 'gvisor') return; + if (boundedQueries.runtime === 'sbx') { + const report = await querySbxCapabilities(); + if (!report.supported) { + throw new Error( + 'boundedQueries.runtime "sbx" is blocked because the installed sbx runtime cannot enforce all ' + + `mandatory query-isolation controls: ${report.missing.join(', ')}. ` + + 'AWF will not launch a query VM and will never fall back to Docker or gVisor.', + ); + } + return; + } + + if (boundedQueries.runtime === 'docker') { + if (!(await queryDockerAvailable())) { + throw new Error( + 'boundedQueries.runtime "docker" requires a reachable Docker daemon. It is not available, ' + + 'and bounded queries never fall back to another runtime.', + ); + } + return; + } if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { throw new Error( @@ -159,10 +280,57 @@ export async function assertQueryRuntimeAvailable( } } +/** Verifies the primary-agent runtime before bounded-query repository staging. */ +export async function assertPrimaryRuntimeAvailable( + containerRuntime: string | undefined, + queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, + queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + querySbxAvailable: SbxAvailabilityQuery = defaultSbxAvailabilityQuery, +): Promise { + if (containerRuntime === 'sbx') { + if (!(await querySbxAvailable())) { + throw new Error( + 'Primary-agent runtime "sbx" is unavailable. Bounded queries abort before staging and never ' + + 'fall back to a Docker or gVisor primary agent.', + ); + } + return; + } + if (containerRuntime === 'gvisor' || containerRuntime === 'runsc') { + 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 queries abort before staging and never fall back.', + ); + } + return; + } + if (containerRuntime) { + if (!(await queryDockerRuntime(containerRuntime))) { + throw new Error( + `Primary-agent OCI runtime "${containerRuntime}" is not registered with Docker. ` + + 'Bounded queries abort before staging and never fall back.', + ); + } + return; + } + if (!(await queryDockerAvailable())) { + throw new Error( + 'The Docker primary-agent runtime is unavailable. Bounded queries abort before staging and never fall back.', + ); + } +} + /** @internal Exported for focused unit tests. */ // ts-prune-ignore-next export const preflightTestHelpers = { SUPPORTED_QUERY_RUNTIMES, GVISOR_DOCKER_RUNTIME, defaultDockerRuntimeQuery, + defaultDockerAvailabilityQuery, + defaultSbxAvailabilityQuery, + defaultSbxCapabilityQuery, + SBX_AUDITED_VERSION, + SBX_REQUIRED_CREATE_FLAGS, + SBX_REQUIRED_EXEC_FLAGS, }; diff --git a/src/bounded-query/query-runner.test.ts b/src/bounded-query/query-runner.test.ts index 21c212537..34fbfd428 100644 --- a/src/bounded-query/query-runner.test.ts +++ b/src/bounded-query/query-runner.test.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { preflightTestHelpers } from './preflight'; /* eslint-disable @typescript-eslint/no-require-imports */ const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); @@ -8,6 +9,17 @@ const { } = require(path.join(brokerDir, 'query-runner.js')); const { DockerQueryRunner } = require(path.join(brokerDir, 'docker-query-runner.js')); const { GvisorQueryRunner } = require(path.join(brokerDir, 'gvisor-query-runner.js')); +const { SbxQueryRunner } = require(path.join(brokerDir, 'sbx-query-runner.js')); +const { + deriveSbxQuerySpec, + SBX_QUERY_TEMPLATE, +} = require(path.join(brokerDir, 'sbx-query-runner-spec.js')); +const { + probeSbxCapabilities, + REQUIRED_CREATE_FLAGS, + REQUIRED_EXEC_FLAGS, + REQUIRED_HARD_ISOLATION_FLAGS, +} = require(path.join(brokerDir, 'sbx-capability-probe.js')); /* eslint-enable @typescript-eslint/no-require-imports */ interface DockerResult { @@ -27,7 +39,9 @@ const ok = (overrides: Partial = {}): DockerResult => ({ const config = { queryBackend: 'docker', + workDir: '/srv/awf/work', hostWorkDir: '/daemon/private/work', + sbxWorkDir: '/sbx-daemon/private/work', queryMountDir: '/query', queryScriptPath: '/awf/query-script.py', querySeccompPath: '/opt/awf/query-seccomp.json', @@ -53,6 +67,21 @@ function createDocker( }; } +function createSbx( + handler: (args: readonly string[]) => DockerResult | Promise = () => ok(), +) { + const calls: string[][] = []; + return { + calls, + client: { + runSbx: async (args: readonly string[]) => { + calls.push([...args]); + return handler(args); + }, + }, + }; +} + describe('trusted bounded-query runner contract', () => { it('derives a frozen launch specification with no request-controlled surface', () => { const maliciousRequest = { @@ -117,6 +146,165 @@ describe('trusted bounded-query runner contract', () => { .toEqual(['--runtime', 'runsc']); }); + it('selects the independent sbx runner without reusing a Docker adapter', () => { + const { client } = createSbx(); + const runner = createQueryRunner( + { ...config, queryBackend: 'sbx' }, + { sbx: client, docker: { runDocker: () => Promise.reject(new Error('must not run')) } }, + ); + expect(runner).toBeInstanceOf(SbxQueryRunner); + }); + + it('derives a unique immutable sbx VM spec only from trusted identifiers', () => { + const runId = 'abcd1234abcd1234abcd1234abcd1234'; + const maliciousRequest = { + name: 'awf-agent-primary', + template: 'attacker/image', + command: ['sh'], + paths: ['/etc'], + network: 'host', + environment: { GH_TOKEN: 'secret' }, + }; + const first = deriveSbxQuerySpec({ + config, + runId, + invocationId: '111111111111111111111111', + request: maliciousRequest, + }); + const second = deriveSbxQuerySpec({ + config, + runId, + invocationId: '222222222222222222222222', + request: maliciousRequest, + }); + + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.createArgs)).toBe(true); + expect(first.sandboxName).not.toBe(second.sandboxName); + expect(first.sandboxName).toMatch(/^awf-query-sbx-/); + expect(first.sandboxName).not.toContain('awf-agent'); + expect(first.createArgs).toContain(SBX_QUERY_TEMPLATE); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + expect(first.createArgs).toContain(flag); + } + expect(first.createArgs.join(' ')).not.toMatch(/attacker|\/etc|GH_TOKEN|secret|network host/); + expect(first.runPrefix).toBe(`awf-query-sbx-${runId}-`); + expect(first.createArgs.join(' ')).toContain( + '/sbx-daemon/private/work/111111111111111111111111/repo:/awf/seed:ro', + ); + expect(second.createArgs.join(' ')).toContain( + '/sbx-daemon/private/work/222222222222222222222222/repo:/awf/seed:ro', + ); + expect(first.createArgs.join(' ')).not.toContain(config.hostWorkDir); + expect(first.execArgs).toContain('65534:65534'); + expect(first.execArgs).toContain('/query'); + expect(first.execArgs.slice(-1)).toEqual(['/usr/local/bin/awf-run-query']); + }); + + it('blocks the audited sbx CLI because hard isolation controls are absent', async () => { + const { client } = createSbx((args) => { + if (args[0] === 'version') return ok({ stdout: 'Docker Sandboxes v0.37.1' }); + if (args[0] === 'create') return ok({ stdout: '--name --cpus --memory --template' }); + if (args[0] === 'exec') return ok({ stdout: '--user --workdir' }); + return ok(); + }); + + const report = await probeSbxCapabilities(client); + expect(report.supported).toBe(false); + for (const flag of REQUIRED_HARD_ISOLATION_FLAGS) { + expect(report.missing).toContain(`sbx create ${flag}`); + } + const runner = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client }); + await expect(runner.assertAvailable()).rejects.toThrow(/blocked.*No fallback/s); + }); + + it('blocks sbx when the CLI exists but its authenticated daemon is unavailable', async () => { + const { client } = createSbx((args) => { + if (args[0] === 'version') return ok({ stdout: 'Docker Sandboxes v0.37.1' }); + if (args[0] === 'ls') return ok({ exitCode: 1, stderr: 'not authenticated' }); + if (args[0] === 'create') { + return ok({ stdout: [...REQUIRED_CREATE_FLAGS, ...REQUIRED_HARD_ISOLATION_FLAGS].join(' ') }); + } + if (args[0] === 'exec') return ok({ stdout: REQUIRED_EXEC_FLAGS.join(' ') }); + return ok(); + }); + + const report = await probeSbxCapabilities(client); + expect(report.supported).toBe(false); + expect(report.missing).toContain('authenticated sbx CLI/daemon'); + }); + + it('keeps host and broker sbx capability contracts byte-for-byte aligned', () => { + expect(preflightTestHelpers.SBX_REQUIRED_CREATE_FLAGS).toEqual([ + ...REQUIRED_CREATE_FLAGS, + ...REQUIRED_HARD_ISOLATION_FLAGS, + ]); + expect(preflightTestHelpers.SBX_REQUIRED_EXEC_FLAGS).toEqual(REQUIRED_EXEC_FLAGS); + }); + + it('always force-removes a uniquely named sbx VM before returning', async () => { + const runId = 'abcd1234abcd1234abcd1234abcd1234'; + const invocationId = '111111111111111111111111'; + const { calls, client } = createSbx((args) => { + if (args[0] === 'ls' && args[1] === '--quiet') return ok({ stdout: '' }); + return ok(); + }); + const runner = createQueryRunner( + { ...config, queryBackend: 'sbx' }, + { + sbx: client, + probe: async () => ({ supported: true, missing: [] }), + files: { mkdirSync: jest.fn() }, + }, + ); + await runner.assertAvailable(); + await expect(runner.runQueryContainer({ + runId, + invocationId, + })).resolves.toMatchObject({ exitCode: 0, timedOut: false }); + + const name = runner.spec(runId, invocationId).sandboxName; + 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('reconciles only sbx VMs with the current trusted run prefix', async () => { + const runId = 'abcd1234abcd1234abcd1234abcd1234'; + const staleName = `awf-query-sbx-${runId}-111111111111111111111111`; + const { calls, client } = createSbx((args) => { + if (args[0] === 'ls' && args[1] === '--json') { + return ok({ + stdout: JSON.stringify([ + { name: staleName }, + { name: 'awf-query-sbx-other-run' }, + { name: 'awf-agent-primary' }, + ]), + }); + } + return ok(); + }); + const runner = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client }); + await runner.reconcileRun(runId); + + expect(calls).toContainEqual(['stop', staleName]); + expect(calls).toContainEqual(['rm', '--force', staleName]); + expect(calls.join(' ')).not.toContain('awf-query-sbx-other-run'); + expect(calls.join(' ')).not.toContain('awf-agent-primary'); + }); + + 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 = createQueryRunner({ ...config, queryBackend: 'sbx' }, { sbx: client }); + await expect( + runner.reconcileRun('abcd1234abcd1234abcd1234abcd1234'), + ).rejects.toThrow(/invalid sandbox name/); + }); + it('fails closed for unknown and unavailable runtimes', async () => { expect(() => createQueryRunner({ ...config, queryBackend: 'runc' })).toThrow( /Unsupported bounded-query backend/, @@ -235,3 +423,13 @@ describe('trusted bounded-query runner contract', () => { await expect(runner.reconcileRun('abcd1234')).rejects.toThrow(/invalid.*container id/); }); }); + +const realSbxCapabilityTest = process.env.AWF_TEST_REAL_SBX_QUERY_CAPABILITIES === '1' ? it : it.skip; +realSbxCapabilityTest('probes the installed sbx CLI/daemon without launching a query VM', async () => { + const report = await probeSbxCapabilities(); + expect(report).toEqual(expect.objectContaining({ + supported: expect.any(Boolean), + auditedVersion: '0.37.1', + missing: expect.any(Array), + })); +}); diff --git a/src/bounded-query/runtime-matrix.test.ts b/src/bounded-query/runtime-matrix.test.ts new file mode 100644 index 000000000..82e8cd196 --- /dev/null +++ b/src/bounded-query/runtime-matrix.test.ts @@ -0,0 +1,363 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + BOUNDED_QUERY_RUNTIME_BACKENDS, + evaluateBoundedQueryRuntimeCombination, + resolveBoundedQueryPrimaryBackend, + serializeBoundedQueryRuntimeTelemetry, + type BoundedQueryPrimaryBackend, + type BoundedQueryRuntimeCapabilities, +} from './runtime-matrix'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const brokerDir = path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker'); +const { createBroker } = require(path.join(brokerDir, 'broker.js')); +const { createQueryRunner } = require(path.join(brokerDir, 'query-runner.js')); +const { createRuntimeTelemetry } = require(path.join(brokerDir, 'runtime-telemetry.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const CANONICAL_ERROR = '{"status":"error"}'; +const CANONICAL_OK = '{"status":"ok","result":true}'; +const BOOLEAN_SCHEMA = { type: 'boolean' }; +const PRIMARY_BACKENDS = BOUNDED_QUERY_RUNTIME_BACKENDS; +const QUERY_BACKENDS = BOUNDED_QUERY_RUNTIME_BACKENDS; + +const deterministicCapabilities: BoundedQueryRuntimeCapabilities = { + primary: { + docker: 'supported', + gvisor: 'supported', + sbx: 'supported', + }, + query: { + docker: 'supported', + gvisor: 'supported', + sbx: 'blocked', + }, +}; + +const combinations = PRIMARY_BACKENDS.flatMap((primaryBackend) => + QUERY_BACKENDS.map((queryBackend) => ({ primaryBackend, queryBackend }))); +const executableCombinations = combinations.filter(({ primaryBackend, queryBackend }) => + evaluateBoundedQueryRuntimeCombination(primaryBackend, queryBackend, deterministicCapabilities).supported); +const blockedCombinations = combinations.filter(({ primaryBackend, queryBackend }) => + !evaluateBoundedQueryRuntimeCombination(primaryBackend, queryBackend, 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: BoundedQueryPrimaryBackend, + queryBackend: '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, + queryBackend, + workDir: '/broker/private/work', + timeoutSeconds: 30, + maxInvocations: options.maxInvocations ?? 8, + }; + const workspace = { + createInvocationWorkspace: ({ + invocationId, + seedId, + script, + }: { + invocationId: string; + seedId: string; + script: string; + }) => { + expect(seedId).toBe('a'.repeat(32)); + expect(script).not.toMatch(/TOKEN|PASSWORD|docker\.sock|broker\/private/); + return { outPath: invocationId }; + }, + readQueryOutput: (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 = { + runQueryContainer: 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, + stdout: '', + stderr: '', + }; + }, + }; + 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-query runtime conformance matrix', () => { + it('contains every independent primary/query combination exactly once', () => { + expect(combinations).toHaveLength(9); + expect(new Set(combinations.map(({ primaryBackend, queryBackend }) => + `${primaryBackend}/${queryBackend}`)).size).toBe(9); + expect(executableCombinations).toHaveLength(6); + expect(blockedCombinations).toHaveLength(3); + }); + + it.each(blockedCombinations)( + '$primaryBackend primary + $queryBackend query fails closed at query preflight', + ({ primaryBackend, queryBackend }) => { + const result = evaluateBoundedQueryRuntimeCombination( + primaryBackend, + queryBackend, + deterministicCapabilities, + ); + expect(result).toEqual({ + primaryBackend, + queryBackend, + supported: false, + capabilityState: 'blocked', + blockedAt: 'query-preflight', + category: 'query-security-block', + }); + }, + ); + + it.each([ + ['gvisor', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['sbx', 'docker', 'primary-preflight', 'primary-runtime-unavailable'], + ['docker', 'gvisor', 'query-preflight', 'query-runtime-unavailable'], + ] as const)( + 'reports precise unavailable capability state for %s/%s', + (primaryBackend, queryBackend, blockedAt, category) => { + const capabilities: BoundedQueryRuntimeCapabilities = { + primary: { docker: 'supported', gvisor: 'unavailable', sbx: 'unavailable' }, + query: { docker: 'supported', gvisor: 'unavailable', sbx: 'blocked' }, + }; + expect(evaluateBoundedQueryRuntimeCombination(primaryBackend, queryBackend, capabilities)) + .toMatchObject({ supported: false, capabilityState: 'unavailable', blockedAt, category }); + }, + ); + + it.each(executableCombinations)( + '$primaryBackend primary + $queryBackend query satisfies the common behavioral contract', + async ({ primaryBackend, queryBackend }) => { + if (queryBackend === 'sbx') throw new Error('blocked sbx query combination entered executable suite'); + + const successful = createHarness(primaryBackend, queryBackend, { processingMs: 50 }); + expect(await invoke(successful.broker, { + privateRepo: 'octo/repo', + schema: BOOLEAN_SCHEMA, + script: 'finite query', + })).toBe(CANONICAL_OK); + expect(successful.launches).toHaveLength(1); + expect(successful.destroyed).toHaveLength(1); + expect(successful.sleeps).toEqual([50]); + expect(successful.telemetry).toContainEqual({ + primaryBackend, + queryBackend, + lifecycleClass: 'query', + capabilityState: 'supported', + category: 'success', + }); + expect(successful.launches[0]).not.toHaveProperty('repo'); + expect(JSON.stringify(successful.launches[0])).not.toMatch(/TOKEN|PASSWORD|docker\.sock/); + + const publicRepo = createHarness(primaryBackend, queryBackend, { + sensitivity: 'public', + maxInvocations: 2, + }); + expect(await invoke(publicRepo.broker, { + privateRepo: 'octo/repo', + schema: BOOLEAN_SCHEMA, + script: 'public query', + })).toBe(CANONICAL_OK); + expect(await invoke(publicRepo.broker, { + privateRepo: 'octo/repo', + schema: BOOLEAN_SCHEMA, + script: 'second public query', + })).toBe(CANONICAL_OK); + expect(new Set(publicRepo.launches.map((launch) => launch.invocationId)).size).toBe(2); + expect(publicRepo.destroyed).toHaveLength(2); + + const wrongRepo = createHarness(primaryBackend, queryBackend); + expect(await invoke(wrongRepo.broker, { + privateRepo: 'octo/not-configured', + schema: BOOLEAN_SCHEMA, + script: 'must not launch', + })).toBe(CANONICAL_ERROR); + expect(wrongRepo.launches).toHaveLength(0); + + const exhausted = createHarness(primaryBackend, queryBackend, { sensitivity: 'confidential' }); + const expensiveSchema = { type: 'integer', minimum: 0, maximum: 255 }; + expect(await invoke(exhausted.broker, { + privateRepo: 'octo/repo', + schema: expensiveSchema, + script: 'must not launch', + })).toBe(CANONICAL_ERROR); + expect(exhausted.launches).toHaveLength(0); + + const capped = createHarness(primaryBackend, queryBackend, { maxInvocations: 1 }); + const request = { privateRepo: 'octo/repo', schema: BOOLEAN_SCHEMA, script: 'cap query' }; + expect(await invoke(capped.broker, request)).toBe(CANONICAL_OK); + expect(await invoke(capped.broker, request)).toBe(CANONICAL_ERROR); + expect(capped.launches).toHaveLength(1); + + for (const failure of [ + { output: '{malformed', runnerResult: undefined }, + { output: 'x'.repeat(8193), runnerResult: undefined }, + { output: 'true', runnerResult: { exitCode: 137, timedOut: true } }, + { output: 'true', runnerResult: { exitCode: 137, timedOut: false } }, // OOM + { output: 'true', runnerResult: { exitCode: 152, timedOut: false } }, // file-size + { output: 'true', runnerResult: { exitCode: 1, timedOut: false } }, // PID/disk + ]) { + const failed = createHarness(primaryBackend, queryBackend, failure); + // eslint-disable-next-line no-await-in-loop + expect(await invoke(failed.broker, request)).toBe(CANONICAL_ERROR); + expect(failed.destroyed).toHaveLength(1); + } + }, + ); + + it.each(executableCombinations)( + '$primaryBackend primary + $queryBackend query derives a fresh no-network sandbox', + async ({ primaryBackend: _primaryBackend, queryBackend }) => { + if (queryBackend === 'sbx') throw new Error('blocked sbx query combination entered executable suite'); + const dockerCalls: string[][] = []; + const docker = { + runDocker: async (args: readonly string[]) => { + dockerCalls.push([...args]); + if (args[0] === 'info') { + return { exitCode: 0, timedOut: false, stdout: '{"runsc":{}}', stderr: '' }; + } + return { exitCode: 0, timedOut: false, stdout: '', stderr: '' }; + }, + }; + const runner = createQueryRunner({ + queryBackend, + hostWorkDir: '/daemon/private/work', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: '/opt/awf/query-seccomp.json', + queryImage: 'ghcr.io/example/bounded-query@sha256:abc', + memoryLimit: '256m', + timeoutSeconds: 30, + queryUid: 65534, + queryGid: 65534, + }, { docker }); + await runner.assertAvailable(); + const first = runner.spec('abcd1234', '1'.repeat(16)); + const second = runner.spec('abcd1234', '2'.repeat(16)); + expect(first.containerName).not.toBe(second.containerName); + expect(first.launchArgs).toEqual(expect.arrayContaining([ + '--network', 'none', + '--read-only', + '--cap-drop', 'ALL', + '--pids-limit', '128', + ])); + expect(first.launchArgs.join(' ')).not.toMatch(/docker\.sock|broker\.sock|seed-map|GH_TOKEN/); + expect(first.launchArgs.filter((arg: string) => arg === '-v')).toHaveLength(3); + if (queryBackend === 'gvisor') expect(first.launchArgs).toEqual(expect.arrayContaining(['--runtime', 'runsc'])); + if (queryBackend === 'docker') expect(first.launchArgs).not.toContain('--runtime'); + await runner.reconcileRun('abcd1234'); + expect(dockerCalls).toContainEqual([ + 'ps', + '-aq', + '--filter', + 'label=awf.bounded-query.run=abcd1234', + ]); + }, + ); +}); + +describe('bounded-query runtime telemetry', () => { + it('serializes only the five approved fields', () => { + const serialized = serializeBoundedQueryRuntimeTelemetry({ + primaryBackend: resolveBoundedQueryPrimaryBackend('runsc'), + queryBackend: 'docker', + lifecycleClass: 'preflight', + capabilityState: 'supported', + category: 'ready', + }); + expect(JSON.parse(serialized)).toEqual({ + primaryBackend: 'gvisor', + queryBackend: '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-runtime-telemetry-')); + try { + const telemetry = createRuntimeTelemetry(root); + telemetry.emit({ + primaryBackend: 'sbx', + queryBackend: 'docker', + lifecycleClass: 'query', + capabilityState: 'supported', + category: 'timeout', + repo: 'must-be-ignored', + script: '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', + 'queryBackend', + 'lifecycleClass', + 'capabilityState', + 'category', + ]); + expect(JSON.stringify(record)).not.toContain('must-be-ignored'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/bounded-query/runtime-matrix.ts b/src/bounded-query/runtime-matrix.ts new file mode 100644 index 000000000..744538e6a --- /dev/null +++ b/src/bounded-query/runtime-matrix.ts @@ -0,0 +1,96 @@ +import type { BoundedQueryRuntime } from '../types'; + +export const BOUNDED_QUERY_RUNTIME_BACKENDS = ['docker', 'gvisor', 'sbx'] as const; + +export type BoundedQueryPrimaryBackend = (typeof BOUNDED_QUERY_RUNTIME_BACKENDS)[number]; +export type BoundedQueryCapabilityState = 'supported' | 'unavailable' | 'blocked'; + +export interface BoundedQueryRuntimeCapabilities { + primary: Readonly>; + query: Readonly>; +} + +export interface BoundedQueryRuntimeCombination { + primaryBackend: BoundedQueryPrimaryBackend; + queryBackend: BoundedQueryRuntime; + supported: boolean; + capabilityState: BoundedQueryCapabilityState; + blockedAt?: 'primary-preflight' | 'query-preflight'; + category: 'ready' | 'primary-runtime-unavailable' | 'query-runtime-unavailable' | 'query-security-block'; +} + +export interface BoundedQueryRuntimeTelemetry { + primaryBackend: BoundedQueryPrimaryBackend; + queryBackend: BoundedQueryRuntime; + lifecycleClass: 'preflight' | 'startup' | 'query' | 'cleanup'; + capabilityState: BoundedQueryCapabilityState; + category: string; +} + +/** Maps AWF's execution setting to the independent primary-agent matrix axis. */ +export function resolveBoundedQueryPrimaryBackend( + containerRuntime: string | undefined, +): BoundedQueryPrimaryBackend { + if (containerRuntime === 'gvisor' || containerRuntime === 'runsc') return 'gvisor'; + if (containerRuntime === 'sbx') return 'sbx'; + return 'docker'; +} + +/** + * Evaluates one primary/query pair without fallback. + * + * Primary availability is checked first because the primary agent cannot be + * started without it. Query availability is then checked before any repository + * staging. A blocked query capability is distinct from an unavailable binary: + * it means the runtime exists but cannot enforce AWF's mandatory controls. + */ +export function evaluateBoundedQueryRuntimeCombination( + primaryBackend: BoundedQueryPrimaryBackend, + queryBackend: BoundedQueryRuntime, + capabilities: BoundedQueryRuntimeCapabilities, +): BoundedQueryRuntimeCombination { + const primaryState = capabilities.primary[primaryBackend]; + if (primaryState !== 'supported') { + return { + primaryBackend, + queryBackend, + supported: false, + capabilityState: primaryState, + blockedAt: 'primary-preflight', + category: 'primary-runtime-unavailable', + }; + } + + const queryState = capabilities.query[queryBackend]; + if (queryState !== 'supported') { + return { + primaryBackend, + queryBackend, + supported: false, + capabilityState: queryState, + blockedAt: 'query-preflight', + category: queryState === 'blocked' ? 'query-security-block' : 'query-runtime-unavailable', + }; + } + + return { + primaryBackend, + queryBackend, + supported: true, + capabilityState: 'supported', + category: 'ready', + }; +} + +/** Serializes the intentionally narrow, path- and content-free telemetry shape. */ +export function serializeBoundedQueryRuntimeTelemetry( + event: BoundedQueryRuntimeTelemetry, +): string { + return JSON.stringify({ + primaryBackend: event.primaryBackend, + queryBackend: event.queryBackend, + lifecycleClass: event.lifecycleClass, + capabilityState: event.capabilityState, + category: event.category, + }); +} diff --git a/src/bounded-query/wrapper-artifact.ts b/src/bounded-query/wrapper-artifact.ts new file mode 100644 index 000000000..1beeec515 --- /dev/null +++ b/src/bounded-query/wrapper-artifact.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { BoundedQueryPaths } from './paths'; + +// In the standalone bundle this global is replaced at build time with the +// wrapper source. Normal source/npm builds read the checked-in shell script. +declare const __AWF_BOUNDED_QUERY_WRAPPER__: string | undefined; + +function loadWrapperSource(): string { + if (typeof __AWF_BOUNDED_QUERY_WRAPPER__ !== 'undefined') { + return __AWF_BOUNDED_QUERY_WRAPPER__; + } + + const candidates = [ + path.join(__dirname, '..', '..', 'containers', 'agent', 'bounded-query-wrapper.sh'), + path.join(__dirname, '..', '..', '..', 'containers', 'agent', 'bounded-query-wrapper.sh'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8'); + } + throw new Error(`Bounded-query wrapper not found at ${candidates.join(' or ')}`); +} + +/** Materializes the wrapper in the agent-only ingress root. */ +export function writeBoundedQueryWrapper(paths: BoundedQueryPaths): string { + const fd = fs.openSync( + paths.wrapperPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + 0o700, + ); + try { + fs.writeSync(fd, loadWrapperSource()); + fs.fchmodSync(fd, 0o555); + } finally { + fs.closeSync(fd); + } + return paths.wrapperPath; +} diff --git a/src/bounded-query/wrapper.test.ts b/src/bounded-query/wrapper.test.ts index 7af334133..2219751f3 100644 --- a/src/bounded-query/wrapper.test.ts +++ b/src/bounded-query/wrapper.test.ts @@ -37,6 +37,13 @@ interface Harness { close: () => Promise; } +interface TcpHarness { + endpoint: string; + curlPathDir: string; + requests: StubRequest[]; + close: () => Promise; +} + async function startStubBroker(respond: (request: StubRequest) => string): Promise { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'awfsp-')); const socketPath = path.join(dir, 'b.sock'); @@ -74,6 +81,45 @@ async function startStubBroker(respond: (request: StubRequest) => string): Promi }; } +async function startStubTcpBroker(respond: (request: StubRequest) => string): Promise { + const requests: StubRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const record = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf8'), + }; + requests.push(record); + const body = respond(record); + res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }); + res.end(body); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('TCP stub did not bind'); + const curlPathDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-curl-')); + const curlPath = path.join(curlPathDir, 'curl'); + fs.writeFileSync( + curlPath, + `#!/bin/sh\nexec /usr/bin/curl --resolve host.docker.internal:${address.port}:127.0.0.1 "$@"\n`, + { mode: 0o700 }, + ); + return { + endpoint: `http://host.docker.internal:${address.port}/query`, + curlPathDir, + requests, + close: () => new Promise((resolve) => server.close(() => { + fs.rmSync(curlPathDir, { recursive: true, force: true }); + resolve(); + })), + }; +} + interface WrapperResult { stdout: string; stderr: string; @@ -87,13 +133,26 @@ interface WrapperResult { */ function runWrapper( args: string[], - options: { socketPath?: string; script?: string } = {}, + options: { + socketPath?: string; + endpoint?: string; + capability?: string; + script?: string; + pathPrefix?: string; + } = {}, ): Promise { return new Promise((resolve, reject) => { const child = spawn('sh', [WRAPPER, ...args], { env: { - PATH: process.env.PATH ?? '/usr/bin:/bin', - AWF_BOUNDED_QUERY_SOCKET: options.socketPath ?? '/nonexistent/awf-bounded-query.sock', + PATH: [ + options.pathPrefix, + process.env.PATH ?? '/usr/bin:/bin', + ].filter(Boolean).join(':'), + AWF_BOUNDED_QUERY_SOCKET: options.endpoint + ? '' + : (options.socketPath ?? '/nonexistent/awf-bounded-query.sock'), + AWF_BOUNDED_QUERY_ENDPOINT: options.endpoint ?? '', + AWF_BOUNDED_QUERY_CAPABILITY: options.capability ?? '', // Deliberately hostile proxy settings: the wrapper must ignore them. HTTP_PROXY: 'http://127.0.0.1:1', HTTPS_PROXY: 'http://127.0.0.1:1', @@ -117,6 +176,54 @@ function runWrapper( const VALID_ARGS = ['--repo', 'octo/private', '--schema', BOOLEAN_SCHEMA]; describe('bounded-query wrapper', () => { + it('uses authenticated HTTP framing for an sbx agent', async () => { + const capability = 'a'.repeat(64); + const harness = await startStubTcpBroker(() => '{"status":"ok","result":true}'); + try { + const result = await runWrapper(VALID_ARGS, { + endpoint: harness.endpoint, + capability, + pathPrefix: harness.curlPathDir, + }); + + expect(result.stdout).toBe('{"status":"ok","result":true}\n'); + expect(harness.requests[0].headers['x-awf-capability']).toBe(capability); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + } finally { + await harness.close(); + } + }); + + it('rejects incomplete or ambiguous transport configuration locally', async () => { + const results = await Promise.all([ + runWrapper(VALID_ARGS, { endpoint: 'http://host.docker.internal:12345/query' }), + runWrapper(VALID_ARGS, { capability: 'a'.repeat(64) }), + runWrapper(VALID_ARGS, { + socketPath: '/tmp/broker.sock', + endpoint: 'http://host.docker.internal:12345/query', + capability: 'a'.repeat(64), + }), + ]); + for (const result of results) { + expect(result).toEqual({ stdout: `${CANONICAL_ERROR}\n`, stderr: '', status: 0 }); + } + }, 10_000); + + it.each([ + 'http://host.docker.internal:0/query', + 'http://host.docker.internal:65536/query', + 'http://host.docker.internal:12345/query/extra', + 'http://host.docker.internal:12345x/query', + 'http://127.0.0.1:12345/query', + 'https://host.docker.internal:12345/query', + ])('rejects an untrusted sbx endpoint shape: %s', async (endpoint) => { + await expect(runWrapper(VALID_ARGS, { + endpoint, + capability: 'a'.repeat(64), + })).resolves.toEqual({ stdout: `${CANONICAL_ERROR}\n`, stderr: '', status: 0 }); + }); + it('forwards a valid request and prints the broker result verbatim', async () => { const harness = await startStubBroker(() => '{"status":"ok","result":true}'); try { diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 984ec6904..04617c1b5 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -18,6 +18,7 @@ jest.mock('./preflight'); jest.mock('./signal-handler'); jest.mock('./validate-options'); jest.mock('../sbx-manager'); +jest.mock('../bounded-query/ingress'); import { logger } from '../logger'; import * as dockerManager from '../docker-manager'; @@ -31,6 +32,7 @@ import * as preflight from './preflight'; 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 { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils'; const { @@ -53,6 +55,7 @@ const mockedPreflight = preflight as jest.Mocked; const mockedSignalHandler = signalHandler as jest.Mocked; const mockedValidateOptions = validateOptions as jest.Mocked; const mockedSbxManager = sbxManager as jest.Mocked; +const mockedBoundedQueryIngress = boundedQueryIngress as jest.Mocked; describe('createMainAction', () => { let processExitSpy: jest.SpyInstance; @@ -319,6 +322,72 @@ describe('createMainAction', () => { }), ); }); + + it('mounts only bounded-query agent artifacts and injects the HTTP capability without logging it', async () => { + const capability = 'a'.repeat(64); + const sbxConfig = { + ...MAIN_ACTION_STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + boundedQueryIngressTransport: 'sbx-http', + boundedQueries: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + runtime: 'docker', + timeout: 30, + memoryLimit: '512m', + interpreter: 'python3', + maxInvocations: 8, + }, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedBoundedQueryIngress.resolveSbxIngress.mockResolvedValue({ + endpoint: 'http://host.docker.internal:49152/query', + queryCapability: capability, + probeCapability: 'b'.repeat(64), + skillPath: '/var/tmp/ingress/skill/SKILL.md', + wrapperDir: '/var/tmp/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-query --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-query-ingress-.*\/skill:ro$/); + expect(mounts.join(' ')).not.toMatch(/seeds|work|control|audit|docker\.sock|seed-map/); + + expect(mockedSbxManager.assertSbxBoundedQueryIngress).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + transport: 'sbx-http', + endpoint: 'http://host.docker.internal:49152/query', + probeCapability: 'b'.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_QUERY_ENDPOINT: 'http://host.docker.internal:49152/query', + AWF_BOUNDED_QUERY_CAPABILITY: capability, + AWF_BOUNDED_QUERY_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(mockedBoundedQueryIngress.removeSbxIngressCapabilityFile).toHaveBeenCalledWith(sbxConfig); + }); }); }); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 963c233a1..5f2bf3c6e 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -27,13 +27,25 @@ import { probeSplitFilesystem } from '../dind-probe'; import { assertTopologySupported, connectTopologyContainers } from '../topology'; import { runDindBootstrap } from '../dind-bootstrap'; import { runtimeUsesComposeAgent } from '../container-runtime'; -import { createSandbox, execInSandbox, removeSandbox, isSbxAvailable, SBX_DEFAULT_NAME } from '../sbx-manager'; +import { + assertSbxBoundedQueryIngress, + createSandbox, + execInSandbox, + removeSandbox, + isSbxAvailable, + SBX_DEFAULT_NAME, +} from '../sbx-manager'; import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager'; import type { WrapperConfig } from '../types'; import { buildAgentEnvironment } from '../services/agent-service'; import { buildAgentCredentialEnv } from '../services/api-proxy-credential-env'; import { DEFAULT_DNS_SERVERS } from '../dns-resolver'; import { AGENT_IP, CLI_PROXY_IP, DOH_PROXY_IP, NETWORK_SUBNET, SQUID_IP } from '../host-iptables-shared'; +import { + removeSbxIngressCapabilityFile, + resolveSbxIngress, +} from '../bounded-query/ingress'; +import { resolveBoundedQueryPaths } from '../bounded-query/paths'; /** Report whether a secret is set (and its length) without exposing the value. */ function redactSecret(value: string | undefined): string { @@ -284,6 +296,36 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { // bridge IP, typically 172.17.0.1). const SBX_GATEWAY_IP = '172.17.0.0'; const SBX_HOST_DOCKER_INTERNAL = 'host.docker.internal'; + const boundedQueryPaths = resolveBoundedQueryPaths(config.workDir); + const sbxMounts = [...(config.volumeMounts ?? [])]; + let sbxBoundedQueryIngress: + | { transport: 'unix'; socketPath: string } + | { + transport: 'sbx-http'; + endpoint: string; + queryCapability: string; + probeCapability: string; + } + | undefined; + + if (config.boundedQueries?.enabled) { + sbxMounts.push(`${boundedQueryPaths.agentDir}:ro`); + if (config.boundedQueryIngressTransport === 'unix') { + sbxMounts.push(`${boundedQueryPaths.runDir}:ro`); + sbxBoundedQueryIngress = { + transport: 'unix', + socketPath: boundedQueryPaths.socketPath, + }; + } else { + const ingress = await resolveSbxIngress(config); + sbxBoundedQueryIngress = { + transport: 'sbx-http', + endpoint: ingress.endpoint, + queryCapability: ingress.queryCapability, + probeCapability: ingress.probeCapability, + }; + } + } sbxEnvironment = buildAgentEnvironment({ config, @@ -328,9 +370,41 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { sbxName = await createSandbox({ workspaceDir, squidIp: SQUID_IP, - extraMounts: config.volumeMounts, + extraMounts: sbxMounts, }); + if (sbxBoundedQueryIngress) { + await assertSbxBoundedQueryIngress( + sbxName, + sbxBoundedQueryIngress.transport === 'unix' + ? sbxBoundedQueryIngress + : { + transport: 'sbx-http', + endpoint: sbxBoundedQueryIngress.endpoint, + probeCapability: sbxBoundedQueryIngress.probeCapability, + }, + sbxEnvironment, + config.containerWorkDir, + ); + + Object.assign(sbxEnvironment, { + AWF_BOUNDED_QUERY_SKILL: boundedQueryPaths.skillPath, + AWF_BOUNDED_QUERY_REPOS: config.boundedQueries!.privateRepos + .map((repository) => repository.repo) + .join(','), + AWF_BOUNDED_QUERY_BIN_DIR: boundedQueryPaths.agentDir, + ...(sbxBoundedQueryIngress.transport === 'unix' + ? { AWF_BOUNDED_QUERY_SOCKET: sbxBoundedQueryIngress.socketPath } + : { + AWF_BOUNDED_QUERY_ENDPOINT: sbxBoundedQueryIngress.endpoint, + AWF_BOUNDED_QUERY_CAPABILITY: sbxBoundedQueryIngress.queryCapability, + }), + }); + if (sbxBoundedQueryIngress.transport === 'sbx-http') { + removeSbxIngressCapabilityFile(config); + } + } + // Wait for api-proxy to be healthy before launching agent. // In Docker mode, depends_on: service_healthy gates this; for sbx we poll // via host.docker.internal which resolves to the docker0 bridge from the VM. diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 0fbb164f1..0e4920e69 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -13,6 +13,7 @@ import { buildComposeNetworks } from './compose-network'; import { runtimeUsesComposeAgent } from './container-runtime'; import { API_PROXY_PORTS } from './types/ports'; import { EXTERNAL_BRIDGE_NAME } from './config/network-policy'; +import { BOUNDED_QUERY_INGRESS_NETWORK } from './bounded-query/ingress'; /** * Generates Docker Compose configuration @@ -161,7 +162,7 @@ export function generateDockerCompose( // ── Assemble and return the compose result ───────────────────────────────── - return buildComposeNetworks({ + const compose = buildComposeNetworks({ services, squidService, agentService, @@ -169,6 +170,19 @@ export function generateDockerCompose( networkConfig, namedVolumes, }); + if ( + config.boundedQueries?.enabled + && ( + config.boundedQueryIngressTransport === 'sbx-http' + || (config.boundedQueryIngressTransport === undefined && !includeAgent) + ) + ) { + compose.networks[BOUNDED_QUERY_INGRESS_NETWORK] = { + driver: 'bridge', + internal: true, + }; + } + return compose; } /** diff --git a/src/config-file-bounded-queries-validation.test.ts b/src/config-file-bounded-queries-validation.test.ts index 2b698c8ad..95828b787 100644 --- a/src/config-file-bounded-queries-validation.test.ts +++ b/src/config-file-bounded-queries-validation.test.ts @@ -99,7 +99,7 @@ describe('validateAwfFileConfig — boundedQueries', () => { expect(errors.length).toBeGreaterThan(0); }); - it.each(['docker', 'gvisor'])('accepts runtime %s', (runtime) => { + it.each(['docker', 'gvisor', 'sbx'])('accepts runtime %s', (runtime) => { expect(validateAwfFileConfig({ boundedQueries: { runtime } })).toEqual([]); }); diff --git a/src/config-file.ts b/src/config-file.ts index 0cd850652..130aaa74b 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -181,7 +181,7 @@ export interface AwfFileConfig { * `{ repo, sensitivity: 'internal' }` with a warning. */ privateRepos?: Array; - runtime?: 'docker' | 'gvisor'; + runtime?: 'docker' | 'gvisor' | 'sbx'; timeout?: number; memoryLimit?: string; interpreter?: 'python3'; diff --git a/src/docker-manager-diagnostics.test.ts b/src/docker-manager-diagnostics.test.ts index 20575a38a..f9261c58c 100644 --- a/src/docker-manager-diagnostics.test.ts +++ b/src/docker-manager-diagnostics.test.ts @@ -161,7 +161,7 @@ describe('docker-manager diagnostics', () => { expect(fs.existsSync(path.join(defaultAuditDir, 'iptables-audit.txt'))).toBe(true); }); - it('should copy the bounded-query broker audit before work directory cleanup', () => { + it('should copy bounded-query audit and safe telemetry before work directory cleanup', () => { const brokerAuditDir = resolveBoundedQueryPaths(getDir()).auditDir; fs.mkdirSync(brokerAuditDir, { recursive: true }); fs.writeFileSync( @@ -183,6 +183,15 @@ describe('docker-manager diagnostics', () => { ], expect.objectContaining({ reject: false }), ); + expect(mockExecaSync).toHaveBeenCalledWith( + 'docker', + [ + 'cp', + 'awf-bounded-query-broker:/var/log/awf-bounded-query/runtime-telemetry.jsonl', + path.join(auditDir, 'runtime-telemetry.jsonl'), + ], + expect.objectContaining({ reject: false }), + ); fs.rmSync(resolveBoundedQueryPaths(getDir()).root, { recursive: true, force: true }); }); }); diff --git a/src/parsers/bounded-query-parser.test.ts b/src/parsers/bounded-query-parser.test.ts index d360e649e..c77a9ae84 100644 --- a/src/parsers/bounded-query-parser.test.ts +++ b/src/parsers/bounded-query-parser.test.ts @@ -119,4 +119,9 @@ describe('normalizeBoundedQueriesConfig', () => { expect(config?.interpreter).toBe(BOUNDED_QUERY_DEFAULTS.interpreter); expect(config?.maxInvocations).toBe(BOUNDED_QUERY_DEFAULTS.maxInvocations); }); + + it('preserves the sbx query runtime independently of the primary-agent runtime', () => { + const config = normalizeBoundedQueriesConfig({ runtime: 'sbx' }); + expect(config?.runtime).toBe('sbx'); + }); }); diff --git a/src/sbx-manager.test.ts b/src/sbx-manager.test.ts index 3d4a0888c..3f0a3c236 100644 --- a/src/sbx-manager.test.ts +++ b/src/sbx-manager.test.ts @@ -1,7 +1,9 @@ import { + assertSbxBoundedQueryIngress, createSandbox, execInSandbox, isSbxAvailable, + probeSbxUnixSocketMount, removeSandbox, SBX_DEFAULT_NAME, testHelpers, @@ -10,7 +12,12 @@ import * as fs from 'fs'; import { mockExecaFn } from './test-helpers/mock-execa.test-utils'; import { logger } from './logger'; -const { restoreHomeCredentials, sanitizeEnvForSbx, withLocalBinOnPath } = testHelpers; +const { + restoreHomeCredentials, + sanitizeEnvForSbx, + withCreateSandboxEnvironment, + withLocalBinOnPath, +} = testHelpers; // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory()); @@ -93,10 +100,76 @@ describe('sbx-manager', () => { }); }); + describe('withCreateSandboxEnvironment', () => { + afterEach(() => { + delete process.env.DOCKER_SANDBOXES_PROXY; + delete process.env.XDG_CONFIG_HOME; + }); + + it('temporarily removes DOCKER_SANDBOXES_PROXY and XDG_CONFIG_HOME and restores them on success', async () => { + process.env.DOCKER_SANDBOXES_PROXY = 'http://old-proxy:3128'; + process.env.XDG_CONFIG_HOME = '/home/runner'; + + await withCreateSandboxEnvironment(async () => { + expect(process.env.DOCKER_SANDBOXES_PROXY).toBeUndefined(); + expect(process.env.XDG_CONFIG_HOME).toBeUndefined(); + }); + + expect(process.env.DOCKER_SANDBOXES_PROXY).toBe('http://old-proxy:3128'); + expect(process.env.XDG_CONFIG_HOME).toBe('/home/runner'); + }); + + it('restores DOCKER_SANDBOXES_PROXY and XDG_CONFIG_HOME after failure', async () => { + process.env.DOCKER_SANDBOXES_PROXY = 'http://old-proxy:3128'; + process.env.XDG_CONFIG_HOME = '/home/runner'; + + await expect(withCreateSandboxEnvironment(async () => { + throw new Error('boom'); + })).rejects.toThrow('boom'); + + expect(process.env.DOCKER_SANDBOXES_PROXY).toBe('http://old-proxy:3128'); + expect(process.env.XDG_CONFIG_HOME).toBe('/home/runner'); + }); + }); + describe('SBX_DEFAULT_NAME', () => { it('has awf-agent prefix and process pid', () => { expect(SBX_DEFAULT_NAME).toMatch(/^awf-agent-\d+$/); }); + + describe('probeSbxUnixSocketMount', () => { + it('returns true only after an executable HTTP exchange over the mounted socket', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '{"status":"error"}', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(probeSbxUnixSocketMount()).resolves.toBe(true); + expect(mockExecaFn.mock.calls[1][1]).toEqual(expect.arrayContaining([ + 'curl', + '--unix-socket', + ])); + expect(mockExecaFn.mock.calls[2][1]).toEqual(expect.arrayContaining(['rm', '--force'])); + }); + + it('returns false when the mount does not carry a connectable Unix socket', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 7, stdout: '', stderr: 'connect failed' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(probeSbxUnixSocketMount()).resolves.toBe(false); + }); + + it('fails closed when the disposable probe sandbox cannot be removed', async () => { + mockExecaFn + .mockResolvedValueOnce({ exitCode: 0, stdout: 'Created sandbox', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 7, stdout: '', stderr: 'connect failed' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'busy' }); + + await expect(probeSbxUnixSocketMount()).rejects.toThrow(/could not be removed/); + }); + }); }); describe('createSandbox', () => { @@ -115,6 +188,29 @@ describe('sbx-manager', () => { mockedRenameSync.mockReturnValue(undefined); }); + describe('assertSbxBoundedQueryIngress', () => { + it('proves Unix ingress with an HTTP exchange over the mounted socket', async () => { + mockExecaFn.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }); + + await expect(assertSbxBoundedQueryIngress( + 'awf-agent-test', + { transport: 'unix', socketPath: '/var/tmp/broker.sock' }, + {}, + '/workspace', + )).resolves.toBeUndefined(); + + expect(mockExecaFn).toHaveBeenCalledWith('sbx', expect.arrayContaining([ + 'exec', + 'awf-agent-test', + 'bash', + '-lc', + expect.stringContaining('--unix-socket "$AWF_BOUNDED_QUERY_SOCKET"'), + ]), expect.objectContaining({ + env: expect.any(Object), + })); + }); + }); + it('uses shell agent, configured mounts, and sanitized env', async () => { // No host $HOME subdirs exist → only workspace, extra mounts, /tmp and // /usr/local/bin are mounted (the whole $HOME is never mounted). @@ -559,7 +655,7 @@ describe('sbx-manager', () => { expect(args).toContain('-lc'); const shellCommand = args[args.length - 1]; expect(shellCommand).toBe( - 'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot --version', ); expect(shellCommand.indexOf('.local/bin')).toBeLessThan( shellCommand.indexOf('copilot --version'), @@ -579,7 +675,7 @@ describe('sbx-manager', () => { describe('withLocalBinOnPath', () => { it('prepends ~/.local/bin using the runtime $HOME', () => { expect(withLocalBinOnPath('copilot')).toBe( - 'export PATH="$HOME/.local/bin${PATH:+:$PATH}"; copilot', + 'export PATH="${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin${PATH:+:$PATH}"; copilot', ); }); diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index 05d0f893b..5cb251d75 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -24,6 +24,8 @@ import execa from 'execa'; import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; import * as path from 'path'; import { copyEnvEntries } from './env-utils'; import { logger } from './logger'; @@ -52,6 +54,9 @@ const SECRET_ENV_PATTERNS = [ /** Default sandbox name (single-sandbox-per-run model). */ export const SBX_DEFAULT_NAME = `${SBX_NAME_PREFIX}-${process.pid}`; +/** Name used by the isolated, short-lived Unix-socket passthrough probe. */ +const SBX_SOCKET_PROBE_NAME = `${SBX_NAME_PREFIX}-socket-probe-${process.pid}`; + /** * Strips secret-bearing env vars from process.env so they never reach * the sbx CLI or the sandbox interior. Returns a shallow copy with @@ -67,6 +72,34 @@ function sanitizeEnvForSbx( return { ...clean, ...overrides }; } +/** + * Runs an sbx management command with create-time environment fixes applied. + * + * `DOCKER_SANDBOXES_PROXY` must be absent before AWF's containers are ready, + * and `XDG_CONFIG_HOME` must not redirect the sbx CLI away from its normal + * credential store. Both variables are always restored, even on failure. + */ +async function withCreateSandboxEnvironment(fn: () => Promise): Promise { + const savedProxy = process.env.DOCKER_SANDBOXES_PROXY; + const savedXdg = process.env.XDG_CONFIG_HOME; + delete process.env.DOCKER_SANDBOXES_PROXY; + delete process.env.XDG_CONFIG_HOME; + try { + return await fn(); + } finally { + if (savedProxy !== undefined) { + process.env.DOCKER_SANDBOXES_PROXY = savedProxy; + } else { + delete process.env.DOCKER_SANDBOXES_PROXY; + } + if (savedXdg !== undefined) { + process.env.XDG_CONFIG_HOME = savedXdg; + } else { + delete process.env.XDG_CONFIG_HOME; + } + } +} + /** Records a credential path that was moved aside before `sbx create`. */ interface ScrubbedCredential { /** Original host path (inside a wholesale-mounted home dir). */ @@ -296,25 +329,12 @@ export async function createSandbox(config: { // XDG_CONFIG_HOME must also be removed — the Copilot harness sets it to $HOME, // which makes the sbx CLI look for credentials in $HOME/ instead of the // default $HOME/.config/ where `sbx login` stored them. - const savedProxy = process.env.DOCKER_SANDBOXES_PROXY; - const savedXdg = process.env.XDG_CONFIG_HOME; - delete process.env.DOCKER_SANDBOXES_PROXY; - delete process.env.XDG_CONFIG_HOME; - - const createResult = await execa('sbx', args, { + const createResult = await withCreateSandboxEnvironment(() => execa('sbx', args, { input: 'y\n', stdio: ['pipe', 'pipe', 'pipe'], reject: false, timeout: 120_000, // 2 minute timeout for sandbox creation - }); - - // Restore env vars - if (savedProxy !== undefined) { - process.env.DOCKER_SANDBOXES_PROXY = savedProxy; - } - if (savedXdg !== undefined) { - process.env.XDG_CONFIG_HOME = savedXdg; - } + })); const stdout = (createResult.stdout || '').trim(); const stderr = (createResult.stderr || '').trim(); @@ -348,7 +368,7 @@ export async function createSandbox(config: { * which matches the wholesale-mounted home tool dirs. */ function withLocalBinOnPath(command: string): string { - return `export PATH="$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; + return `export PATH="\${AWF_BOUNDED_QUERY_BIN_DIR:+$AWF_BOUNDED_QUERY_BIN_DIR:}$HOME/.local/bin\${PATH:+:$PATH}"; ${command}`; } /** @internal Exposed for unit tests only. */ @@ -356,9 +376,105 @@ function withLocalBinOnPath(command: string): string { export const testHelpers = { sanitizeEnvForSbx, restoreHomeCredentials, + withCreateSandboxEnvironment, withLocalBinOnPath, }; +/** + * Executes a real host-Unix-socket passthrough probe in a disposable sandbox. + * + * sbx workspace mounts are filesystem passthroughs, but Unix socket forwarding + * is host/version dependent. A successful HTTP exchange over the mounted + * socket is stronger than checking `test -S`: it proves connect semantics. + * Failure selects the authenticated HTTP fallback; sandbox create/cleanup + * failures are fatal so preflight never silently downgrades an unsupported + * host. + */ +export async function probeSbxUnixSocketMount(): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-sbx-socket-probe-')); + fs.chmodSync(root, 0o700); + const socketPath = path.join(root, 'probe.sock'); + const response = '{"status":"error"}'; + const server = http.createServer((_req, res) => { + res.writeHead(200, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(response), + 'cache-control': 'no-store', + }); + res.end(response); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + + let created = false; + let cleanupError: Error | undefined; + let probeError: unknown; + let supported = false; + try { + const createResult = await withCreateSandboxEnvironment(() => execa( + 'sbx', + ['create', '--name', SBX_SOCKET_PROBE_NAME, 'shell', root], + { + input: 'y\n', + stdio: ['pipe', 'pipe', 'pipe'], + reject: false, + timeout: 120_000, + }, + )); + created = (createResult.exitCode ?? 1) === 0 || (createResult.stdout || '').includes('Created sandbox'); + if (!created) { + throw new Error( + `sbx bounded-query ingress probe could not create a sandbox: ${ + (createResult.stderr || createResult.stdout || 'unknown error').trim() + }`, + ); + } + + const result = await execa( + 'sbx', + [ + 'exec', + SBX_SOCKET_PROBE_NAME, + 'curl', + '--silent', + '--show-error', + '--max-time', + '5', + '--unix-socket', + socketPath, + 'http://localhost/probe', + ], + { + env: sanitizeEnvForSbx(), + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + timeout: 15_000, + }, + ); + supported = (result.exitCode ?? 1) === 0 && result.stdout === response; + } catch (error: unknown) { + probeError = error; + } finally { + if (created) { + const removed = await execa('sbx', ['rm', '--force', SBX_SOCKET_PROBE_NAME], { + stdio: ['ignore', 'pipe', 'pipe'], + reject: false, + timeout: 30_000, + }); + if ((removed.exitCode ?? 1) !== 0) { + cleanupError = new Error('sbx bounded-query ingress probe sandbox could not be removed'); + } + } + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + } + if (cleanupError) throw cleanupError; + if (probeError) throw probeError; + return supported; +} + /** * Executes a command inside the sandbox, streaming stdout/stderr. * Returns the exit code of the command. @@ -379,6 +495,7 @@ export async function execInSandbox( if (options?.workDir) { args.push('--workdir', options.workDir); } + if (options?.tty) { args.push('--tty'); } @@ -417,6 +534,53 @@ export async function execInSandbox( } } +/** + * Proves the selected ingress is reachable from the actual primary sandbox + * before the agent command starts. The HTTP probe uses a separate one-shot + * capability and receives only the canonical error body from `/query`. + */ +export async function assertSbxBoundedQueryIngress( + 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_QUERY_SOCKET = ingress.socketPath; + command = [ + 'response=$(curl --silent --show-error --max-time 15 --unix-socket "$AWF_BOUNDED_QUERY_SOCKET"', + '-X POST -H "Expect:"', + 'http://localhost/query 2>/dev/null) &&', + '[ "$response" = \'{"status":"error"}\' ]', + ].join(' '); + } else { + probeEnvironment.AWF_BOUNDED_QUERY_ENDPOINT = ingress.endpoint; + probeEnvironment.AWF_BOUNDED_QUERY_PROBE_CAPABILITY = ingress.probeCapability; + command = [ + 'response=$(curl --silent --show-error --noproxy "*" --max-time 15', + '-X POST -H "Expect:"', + '-H "X-AWF-Capability: $AWF_BOUNDED_QUERY_PROBE_CAPABILITY"', + '"$AWF_BOUNDED_QUERY_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-query ${ingress.transport} ingress`, + ); + } +} + /** * Stops and removes the sandbox. */ diff --git a/src/services/bounded-query-compose.test.ts b/src/services/bounded-query-compose.test.ts index e1548c762..35983fc7a 100644 --- a/src/services/bounded-query-compose.test.ts +++ b/src/services/bounded-query-compose.test.ts @@ -4,6 +4,9 @@ import type { BoundedQueriesConfig } from '../types'; // Mock execa module (must remain per-file — jest.mock() is hoisted before imports) // eslint-disable-next-line @typescript-eslint/no-require-imports jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaMockFactory()); +jest.mock('./host-gateway', () => ({ + resolveDockerHostGateway: jest.fn(() => '172.17.0.1'), +})); let mockConfig: WrapperConfig; @@ -18,10 +21,9 @@ const boundedQueries: BoundedQueriesConfig = { }; /** - * End-to-end compose assembly checks for bounded queries: the broker must appear - * as an optional, network-less service, gate the agent, and inject only ingress - * mounts plus three environment variables into the agent — and nothing at all - * when the feature is off. + * End-to-end compose assembly checks for bounded queries: Compose agents use a + * networkless broker, while sbx HTTP ingress uses only its dedicated internal + * network. In either case the broker exposes no private state to the agent. */ describe('bounded-query broker in generated Docker Compose', () => { useTempWorkDir( @@ -137,5 +139,32 @@ describe('bounded-query broker in generated Docker Compose', () => { expect(broker.network_mode).toBe('none'); expect(broker.networks).toBeUndefined(); }); + + it.each(['docker', 'gvisor'] as const)( + 'pairs an sbx primary agent with the %s query runner over internal HTTP ingress', + (runtime) => { + const result = generateDockerCompose({ + ...enabled(), + containerRuntime: 'sbx', + boundedQueryIngressTransport: 'sbx-http', + boundedQueries: { ...boundedQueries, runtime }, + }, mockNetworkConfig); + const broker = result.services['bounded-query-broker'] as unknown as Record; + const environment = broker.environment as Record; + + expect(result.services.agent).toBeUndefined(); + expect(broker.network_mode).toBeUndefined(); + expect(broker.networks).toEqual(['awf-bounded-query-ingress']); + expect(broker.ports).toEqual(['172.17.0.1::18080']); + expect(result.networks['awf-bounded-query-ingress']).toEqual({ + driver: 'bridge', + internal: true, + }); + expect(environment.AWF_BOUNDED_QUERY_BACKEND).toBe(runtime); + expect(environment.AWF_BOUNDED_QUERY_TCP_PORT).toBe('18080'); + expect(JSON.stringify(broker)).not.toContain('awf-net'); + expect(JSON.stringify(broker)).not.toContain('awf-ext'); + }, + ); }); }); diff --git a/src/services/bounded-query-service.test.ts b/src/services/bounded-query-service.test.ts index 5388a1f68..c1f50d0b7 100644 --- a/src/services/bounded-query-service.test.ts +++ b/src/services/bounded-query-service.test.ts @@ -9,6 +9,10 @@ import { } from './bounded-query-service'; import type { ImageBuildConfig } from './squid-service'; +jest.mock('./host-gateway', () => ({ + resolveDockerHostGateway: jest.fn(() => '172.17.0.1'), +})); + const WORK_DIR = '/tmp/awf-1700000000'; const boundedQueries: BoundedQueriesConfig = { @@ -50,6 +54,15 @@ describe('buildBoundedQueryService', () => { ).toThrow(/must be enabled/); }); + it('refuses to wire sbx management access while its capability proof is blocked', () => { + expect(() => + buildBoundedQueryService({ + config: buildConfig({}, { runtime: 'sbx' }), + imageConfig: imageConfig(), + }), + ).toThrow(/sbx.*capability proof.*blocked.*no Docker socket.*fallback/s); + }); + describe('broker service', () => { const { queryImageService, service } = buildBoundedQueryService({ config: buildConfig(), @@ -97,6 +110,7 @@ describe('buildBoundedQueryService', () => { expect(environment.AWF_BOUNDED_QUERY_MEMORY).toBe('256m'); expect(environment.AWF_BOUNDED_QUERY_MAX_INVOCATIONS).toBe('9'); expect(environment.AWF_BOUNDED_QUERY_BACKEND).toBe('docker'); + expect(environment.AWF_BOUNDED_QUERY_PRIMARY_BACKEND).toBe('docker'); expect(environment.AWF_BOUNDED_QUERY_HOST_WORK_DIR).toBe(paths.workDir); }); @@ -139,6 +153,20 @@ describe('buildBoundedQueryService', () => { expect((gvisorService.environment as Record).AWF_BOUNDED_QUERY_BACKEND).toBe('gvisor'); }); + it.each([ + [undefined, 'docker'], + ['gvisor', 'gvisor'], + ['runsc', 'gvisor'], + ['sbx', 'sbx'], + ])('records primary runtime %s independently from the query backend', (containerRuntime, expected) => { + const { service: matrixService } = buildBoundedQueryService({ + config: buildConfig({ containerRuntime }, { runtime: 'docker' }), + imageConfig: imageConfig(), + }); + expect((matrixService.environment as Record).AWF_BOUNDED_QUERY_PRIMARY_BACKEND).toBe(expected); + expect((matrixService.environment as Record).AWF_BOUNDED_QUERY_BACKEND).toBe('docker'); + }); + it('uses the AWF Docker host socket when overridden, without leaking it to the agent', () => { const result = buildBoundedQueryService({ config: buildConfig({ awfDockerHost: 'unix:///run/user/1001/docker.sock' }), diff --git a/src/services/bounded-query-service.ts b/src/services/bounded-query-service.ts index 5be61102a..d54b77656 100644 --- a/src/services/bounded-query-service.ts +++ b/src/services/bounded-query-service.ts @@ -3,6 +3,7 @@ import { buildRuntimeImageRef } from '../image-tag'; import { getSafeHostGid, getSafeHostUid } from '../host-identity'; import { BOUNDED_QUERY_BROKER_CONTAINER_NAME } from '../constants'; import type { WrapperConfig } from '../types'; +import { runtimeUsesComposeAgent } from '../container-runtime'; import { AGENT_SKILL_DIR, AGENT_SKILL_PATH, @@ -21,6 +22,12 @@ import { resolveDockerSocketPath } from './agent-volumes/docker-socket'; import { applyHostPathPrefixToVolumes } from './host-path-prefix'; import { buildContainerSecurityHardening } from './service-security'; import type { ImageBuildConfig } from './squid-service'; +import { resolveDockerHostGateway } from './host-gateway'; +import { + BOUNDED_QUERY_INGRESS_NETWORK, + BOUNDED_QUERY_TCP_PORT, +} from '../bounded-query/ingress'; +import { resolveBoundedQueryPrimaryBackend } from '../bounded-query/runtime-matrix'; /** * Compose assembly for the trusted bounded-query broker. @@ -155,10 +162,22 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou if (!boundedQueries?.enabled) { throw new Error('buildBoundedQueryService: boundedQueries must be enabled'); } + if (boundedQueries.runtime === 'sbx') { + throw new Error( + 'buildBoundedQueryService: sbx bounded-query capability proof must pass before broker wiring; ' + + 'current sbx support is blocked and no Docker socket or sbx credential fallback is permitted', + ); + } const paths = resolveBoundedQueryPaths(config.workDir); const { queryImageRef, querySource, brokerSource } = resolveBoundedQueryImages(imageConfig); const dockerSocketPath = resolveDockerSocketPath(config); + const ingressTransport = config.boundedQueryIngressTransport + ?? (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-query sbx ingress'); + } // Compose must pull/build the query target before starting the offline // broker. The one-shot service has no mounts or network and exits only after @@ -174,9 +193,18 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou const service: Record = { container_name: BOUNDED_QUERY_BROKER_CONTAINER_NAME, ...brokerSource, - // SECURITY: no networks key at all. `none` gives the broker a loopback-only - // namespace: no awf-net, no awf-ext, no DNS, no Squid, no host gateway. - network_mode: 'none', + ...(ingressTransport === 'unix' + ? { + // Compose agents need no network at all. + network_mode: 'none', + } + : { + // sbx reaches the broker through host.docker.internal, which maps to + // the host-gateway IP rather than loopback. This dedicated Docker + // `internal` network has no external route and no other member. + networks: [BOUNDED_QUERY_INGRESS_NETWORK], + ports: [`${sbxIngressHostIp}::${BOUNDED_QUERY_TCP_PORT}`], + }), volumes: applyHostPathPrefixToVolumes( [ `${paths.seedsDir}:${BROKER_SEEDS_DIR}:ro`, @@ -194,6 +222,7 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou // The broker selects a fixed QueryRunner from this normalized value. // Runtime flags are never accepted from an invocation. AWF_BOUNDED_QUERY_BACKEND: boundedQueries.runtime, + AWF_BOUNDED_QUERY_PRIMARY_BACKEND: resolveBoundedQueryPrimaryBackend(config.containerRuntime), AWF_BOUNDED_QUERY_TIMEOUT: String(boundedQueries.timeout), AWF_BOUNDED_QUERY_MEMORY: boundedQueries.memoryLimit, AWF_BOUNDED_QUERY_MAX_INVOCATIONS: String(boundedQueries.maxInvocations), @@ -202,6 +231,9 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou AWF_BOUNDED_QUERY_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), AWF_BOUNDED_QUERY_SOCKET_UID: getSafeHostUid(), AWF_BOUNDED_QUERY_SOCKET_GID: getSafeHostGid(), + ...(ingressTransport === 'sbx-http' + ? { AWF_BOUNDED_QUERY_TCP_PORT: String(BOUNDED_QUERY_TCP_PORT) } + : {}), }, depends_on: { 'bounded-query-image': { @@ -225,7 +257,7 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou }; const agentEnvAdditions: Record = { - AWF_BOUNDED_QUERY_SOCKET: AGENT_SOCKET_PATH, + ...(ingressTransport === 'unix' ? { AWF_BOUNDED_QUERY_SOCKET: AGENT_SOCKET_PATH } : {}), AWF_BOUNDED_QUERY_SKILL: AGENT_SKILL_PATH, AWF_BOUNDED_QUERY_REPOS: boundedQueries.privateRepos.map((repository) => repository.repo).join(','), }; @@ -242,9 +274,7 @@ export function buildBoundedQueryService(params: BoundedQueryServiceParams): Bou config.dockerHostPathPrefix, ); - logger.info( - `Bounded queries enabled - offline broker (network_mode: none) exposed to the agent at ${AGENT_SOCKET_PATH}`, - ); + logger.info(`Bounded queries enabled - broker ingress transport: ${ingressTransport}`); return { queryImageService, service, agentEnvAdditions, agentVolumes }; } diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 67b89ce57..387872982 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -234,7 +234,15 @@ function assembleCliProxyService(params: AssembleOptionalServicesParams): void { } function assembleBoundedQueryService(params: AssembleOptionalServicesParams): void { - const { services, agentService, agentVolumes, environment, config, imageConfig } = params; + const { + services, + agentService, + agentVolumes, + environment, + config, + imageConfig, + includeComposeAgent = true, + } = params; if (!config.boundedQueries?.enabled) return; @@ -250,11 +258,13 @@ function assembleBoundedQueryService(params: AssembleOptionalServicesParams): vo services['bounded-query-image'] = queryImageService; services['bounded-query-broker'] = service; - Object.assign(environment, agentEnvAdditions); - agentVolumes.push(...queryVolumes); - agentService.depends_on['bounded-query-broker'] = { - condition: 'service_healthy', - }; + if (includeComposeAgent) { + Object.assign(environment, agentEnvAdditions); + agentVolumes.push(...queryVolumes); + agentService.depends_on['bounded-query-broker'] = { + condition: 'service_healthy', + }; + } } function finalizeSysrootVolumes( @@ -294,8 +304,8 @@ export function assembleOptionalServices( const skipIptables = networkIsolation || !runtimeUsesIptables(config.containerRuntime); presetSidecarIpEnvVars(environment, config, networkConfig); + assembleBoundedQueryService(params); if (includeComposeAgent) { - assembleBoundedQueryService(params); assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); assembleIptablesInitService(params, skipIptables); } diff --git a/src/types/bounded-query-options.ts b/src/types/bounded-query-options.ts index 703a2dd1c..3e68ec5d1 100644 --- a/src/types/bounded-query-options.ts +++ b/src/types/bounded-query-options.ts @@ -9,7 +9,10 @@ */ /** Sandbox runtime backends supported for bounded-query execution. */ -export type BoundedQueryRuntime = 'docker' | 'gvisor'; +export type BoundedQueryRuntime = 'docker' | 'gvisor' | 'sbx'; + +/** Primary-agent transport selected by trusted preflight. */ +export type BoundedQueryIngressTransport = 'unix' | 'sbx-http'; /** Script interpreters supported for bounded-query execution. */ export type BoundedQueryInterpreter = 'python3'; @@ -168,4 +171,15 @@ export interface BoundedQueryOptions { * @default undefined */ boundedQueries?: BoundedQueriesConfig; + + /** + * Trusted runtime state selected by bounded-query 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 + */ + boundedQueryIngressTransport?: BoundedQueryIngressTransport; } diff --git a/tests/integration/bounded-query-isolation.test.ts b/tests/integration/bounded-query-isolation.test.ts index 62e46ef05..0f698905a 100644 --- a/tests/integration/bounded-query-isolation.test.ts +++ b/tests/integration/bounded-query-isolation.test.ts @@ -9,6 +9,7 @@ const { buildQueryArgs } = require('../../containers/bounded-query/broker/query- describe('bounded-query Docker isolation', () => { const image = `awf-bounded-query-integration:${process.pid}`; + const queryBackend = process.env.AWF_BOUNDED_QUERY_TEST_RUNTIME === 'gvisor' ? 'gvisor' : 'docker'; let root: string; beforeAll(() => { @@ -34,7 +35,7 @@ describe('bounded-query Docker isolation', () => { }); it('executes against a writable bounded copy with no network or broker tools', () => { - const invocationId = 'integration'; + const invocationId = `integration-${process.pid}`; const invocationDir = path.join(root, invocationId); const repoDir = path.join(invocationDir, 'repo'); const outPath = path.join(invocationDir, 'out'); @@ -83,19 +84,23 @@ describe('bounded-query Docker isolation', () => { queryScriptPath: '/awf/query-script.py', querySeccompPath: path.resolve(__dirname, '../../containers/bounded-query/query-seccomp.json'), queryImage: image, - queryBackend: 'docker', + queryBackend, memoryLimit: '256m', queryUid: 65534, queryGid: 65534, }, runId: 'integration-run', invocationId, - containerName: `awf-query-integration-${process.pid}`, + runtimeName: queryBackend === 'gvisor' ? 'runsc' : undefined, }); + const containerName = args[args.indexOf('--name') + 1]; - execFileSync('docker', args, { stdio: 'pipe', timeout: 30_000 }); - - expect(fs.readFileSync(outPath, 'utf8')).toBe('{"result":"YES"}'); - expect(fs.existsSync(path.join(repoDir, 'mutation.txt'))).toBe(false); + try { + execFileSync('docker', args, { stdio: 'pipe', timeout: 30_000 }); + expect(fs.readFileSync(outPath, 'utf8')).toBe('{"result":"YES"}'); + expect(fs.existsSync(path.join(repoDir, 'mutation.txt'))).toBe(false); + } finally { + execFileSync('docker', ['rm', '--force', containerName], { stdio: 'ignore' }); + } }, 60_000); });