diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 135618174..695f63c89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,6 +359,7 @@ jobs: query_digest: ${{ steps.build_bounded_query.outputs.digest }} broker_digest: ${{ steps.build_bounded_query_broker.outputs.digest }} enclave_script_digest: ${{ steps.build_enclave_script.outputs.digest }} + enclave_agent_digest: ${{ steps.build_enclave_agent.outputs.digest }} enclave_mcp_server_digest: ${{ steps.build_enclave_mcp_server.outputs.digest }} steps: - name: Checkout code @@ -483,11 +484,50 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/enclave-script@${{ steps.build_enclave_script.outputs.digest }} + - name: Build and push Enclave Agent image + id: build_enclave_agent + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + # The unified enclave agent executor reuses the audited native + # bounded-agent enclave target verbatim, published under its own name. + context: ./containers + file: ./containers/bounded-agent/Dockerfile + target: enclave + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/enclave-agent:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/enclave-agent:latest + cache-from: type=gha,scope=enclave-agent + cache-to: type=gha,mode=max,scope=enclave-agent + + - name: Sign Enclave Agent image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }} + + - name: Generate SBOM for Enclave Agent image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }} + format: spdx-json + output-file: enclave-agent-sbom.spdx.json + + - name: Attest SBOM for Enclave Agent image + run: | + cosign attest --yes \ + --predicate enclave-agent-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }} + - name: Build and push Enclave MCP Server image id: build_enclave_mcp_server uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: - context: ./containers/bounded-query + # The server drives both enclave executors, so its context spans + # containers/bounded-query and containers/bounded-agent. + context: ./containers + file: ./containers/bounded-query/enclave-mcp/Dockerfile target: enclave-mcp-server push: true platforms: linux/amd64,linux/arm64 @@ -959,6 +999,7 @@ jobs: "ghcr.io/${{ github.repository }}/bounded-query@${{ needs['build-bounded-query'].outputs.query_digest }}" \ "ghcr.io/${{ github.repository }}/bounded-query-broker@${{ needs['build-bounded-query'].outputs.broker_digest }}" \ "ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-bounded-query'].outputs.enclave_script_digest }}" \ + "ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-bounded-query'].outputs.enclave_agent_digest }}" \ "ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ needs['build-bounded-query'].outputs.enclave_mcp_server_digest }}" \ "ghcr.io/${{ github.repository }}/bounded-agent@${{ needs['build-bounded-agent'].outputs.enclave_digest }}" \ "ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ needs['build-bounded-agent'].outputs.broker_digest }}" \ diff --git a/action.yml b/action.yml index 2958f2a99..90345731a 100644 --- a/action.yml +++ b/action.yml @@ -141,6 +141,7 @@ runs: API_PROXY_DIGEST="$(extract_digest api-proxy || true)" CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)" ENCLAVE_SCRIPT_DIGEST="$(extract_digest enclave-script || true)" + ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)" ENCLAVE_MCP_SERVER_DIGEST="$(extract_digest enclave-mcp-server || true)" [ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}") @@ -149,6 +150,7 @@ runs: [ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}") [ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}") [ -n "${ENCLAVE_SCRIPT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-script=${ENCLAVE_SCRIPT_DIGEST}") + [ -n "${ENCLAVE_AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-agent=${ENCLAVE_AGENT_DIGEST}") [ -n "${ENCLAVE_MCP_SERVER_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-mcp-server=${ENCLAVE_MCP_SERVER_DIGEST}") if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then diff --git a/containers/api-proxy/server.models.test.js b/containers/api-proxy/server.models.test.js index 7e59e1dc2..e96ce0422 100644 --- a/containers/api-proxy/server.models.test.js +++ b/containers/api-proxy/server.models.test.js @@ -250,18 +250,22 @@ describe('makeModelBodyTransform', () => { const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); try { - let isolatedServer; + const refreshModels = jest.fn().mockResolvedValue(undefined); + let transform; jest.isolateModules(() => { - isolatedServer = require('./server'); + const { makeModelBodyTransform: makeTransform } = require('./model-config'); + transform = makeTransform( + 'openai', + { openai: ['gpt-5.2', 'gpt-4.1', 'gpt-3.5-turbo'] }, + refreshModels, + () => new Set(['openai']), + ); }); stdoutSpy.mockClear(); - isolatedServer.resetModelCacheState(); - isolatedServer.cachedModels.openai = ['gpt-5.2', 'gpt-4.1', 'gpt-3.5-turbo']; - - const transform = isolatedServer.makeModelBodyTransform('openai'); const transformed = await transform(Buffer.from(JSON.stringify({ model: 'sonnet', messages: [] }))); expect(transformed).toBeInstanceOf(Buffer); + expect(refreshModels).toHaveBeenCalledWith('openai'); const records = stdoutSpy.mock.calls .map(([line]) => String(line).trim()) diff --git a/containers/bounded-agent/broker/enclave-runner-spec.js b/containers/bounded-agent/broker/enclave-runner-spec.js index 9e6dbec2e..27c5837ec 100644 --- a/containers/bounded-agent/broker/enclave-runner-spec.js +++ b/containers/bounded-agent/broker/enclave-runner-spec.js @@ -30,6 +30,17 @@ const ENCLAVE_MAX_FILE_BYTES = 32 * 1024 * 1024; const RUN_LABEL = 'awf.bounded-agent.run'; const INVOCATION_LABEL = 'awf.bounded-agent.invocation'; + +/** + * Unified-enclave labels. + * + * The unified enclave MCP server launches agent enclaves with these labels so + * one AWF-side reconciliation pass (`awf.enclave.run=`) deterministically + * removes every orphaned enclave container, script or agent, without knowing + * which executor created it. Legacy bounded agents keep the labels above. + */ +const ENCLAVE_RUN_LABEL = 'awf.enclave.run'; +const ENCLAVE_INVOCATION_LABEL = 'awf.enclave.invocation'; const TRUSTED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; /** Converts a monotonic-clock duration to the integer milliseconds Node requires. */ @@ -65,11 +76,17 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti throw new Error(`Unsupported OCI runtime in enclave runner: ${runtimeName}`); } - const containerName = `awf-bounded-agent-${runId.slice(0, 12)}-${invocationId}`; + // Label keys and the container prefix are trusted broker configuration, not + // request data. Omitting them preserves the legacy bounded-agent naming + // byte-for-byte. + const runLabelKey = config.runLabelKey || RUN_LABEL; + const invocationLabelKey = config.invocationLabelKey || INVOCATION_LABEL; + const containerPrefix = config.containerPrefix || 'awf-bounded-agent'; + const containerName = `${containerPrefix}-${runId.slice(0, 12)}-${invocationId}`; const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`; const hostSeedDir = `${config.hostSeedsDir}/${seedId}`; - const runLabel = `${RUN_LABEL}=${runId}`; - const invocationLabel = `${INVOCATION_LABEL}=${invocationId}`; + const runLabel = `${runLabelKey}=${runId}`; + const invocationLabel = `${invocationLabelKey}=${invocationId}`; const launchArgs = [ 'run', '--pull', 'never', @@ -92,7 +109,7 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti '--tmpfs', `${config.enclaveMountDir}:rw,nosuid,nodev,size=${config.tmpfsLimit},` + `uid=${config.enclaveUid},gid=${config.enclaveGid},mode=0700`, - '--hostname', 'bounded-agent', + '--hostname', config.enclaveHostname || 'bounded-agent', '--workdir', config.enclaveSeedPath, '--env', `AWF_BOUNDED_AGENT_ENGINE=${config.engine}`, '--env', `HOME=${config.enclaveMountDir}/home`, @@ -148,7 +165,9 @@ function buildRemoveArgs(containerIds) { module.exports = { CLI_GRACE_MS, + ENCLAVE_INVOCATION_LABEL, ENCLAVE_MAX_FILE_BYTES, + ENCLAVE_RUN_LABEL, INVOCATION_LABEL, RUN_LABEL, buildEnclaveArgs, diff --git a/containers/bounded-agent/broker/enclave-runner.js b/containers/bounded-agent/broker/enclave-runner.js index ba1cf31b7..92325aa73 100644 --- a/containers/bounded-agent/broker/enclave-runner.js +++ b/containers/bounded-agent/broker/enclave-runner.js @@ -4,7 +4,9 @@ const { DockerEnclaveRunner } = require('./docker-enclave-runner'); const { GvisorEnclaveRunner } = require('./gvisor-enclave-runner'); const { SbxEnclaveRunner } = require('./sbx-enclave-runner'); const { + ENCLAVE_INVOCATION_LABEL, ENCLAVE_MAX_FILE_BYTES, + ENCLAVE_RUN_LABEL, buildEnclaveArgs, deriveEnclaveContainerSpec, normalizeTimeoutMs, @@ -51,7 +53,9 @@ function createEnclaveRunner(config, deps = {}) { } module.exports = { + ENCLAVE_INVOCATION_LABEL, ENCLAVE_MAX_FILE_BYTES, + ENCLAVE_RUN_LABEL, buildEnclaveArgs, createEnclaveRunner, deriveEnclaveContainerSpec, diff --git a/containers/bounded-agent/broker/framing.js b/containers/bounded-agent/broker/framing.js index cbde16442..8162852d9 100644 --- a/containers/bounded-agent/broker/framing.js +++ b/containers/bounded-agent/broker/framing.js @@ -39,6 +39,16 @@ const ALLOWED_AWF_HEADERS = new Set([VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER] /** The complete set of keys a bounded-agent request may contain. */ const ALLOWED_REQUEST_KEYS = ['privateRepo', 'schema', 'task']; +/** + * Every accepted spelling of the single free-form payload field. + * + * Exactly one of these is accepted per caller surface (`task` for the legacy + * bounded-agent wrapper protocol, `prompt` for the unified enclave MCP tool); + * the other is an explicitly forbidden control so a request can never smuggle + * a second payload past the finite-disclosure charge. + */ +const PAYLOAD_KEYS = ['task', 'prompt']; + /** * Controls a request may never express. * @@ -46,18 +56,26 @@ const ALLOWED_REQUEST_KEYS = ['privateRepo', 'schema', 'task']; * an accidental future widening of the accepted key set fails a test instead of * silently granting a capability. */ -const FORBIDDEN_REQUEST_KEYS = [ +const BASE_FORBIDDEN_REQUEST_KEYS = [ 'image', 'images', 'command', 'cmd', 'args', 'argv', 'entrypoint', 'executable', 'interpreter', 'script', 'shell', 'mount', 'mounts', 'volume', 'volumes', 'bind', 'path', 'paths', 'workdir', 'env', 'environment', 'endpoint', 'endpoints', 'baseUrl', 'url', 'host', 'network', 'networks', 'dns', 'proxy', 'httpProxy', 'httpsProxy', 'credential', 'credentials', 'apiKey', 'token', 'authorization', 'headers', 'timeout', 'timeoutSeconds', 'deadline', 'memory', 'memoryLimit', 'cpu', 'cpuLimit', - 'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'sandbox', + 'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'engine', 'sandbox', 'profile', 'model', 'provider', 'temperature', 'maxTokens', 'maxModelRequests', 'tool', 'tools', 'toolChoice', 'functions', 'systemPrompt', 'system', 'messages', ]; +/** Forbidden controls for one caller surface: everything plus the other payload spelling. */ +function forbiddenKeysFor(payloadKey) { + return BASE_FORBIDDEN_REQUEST_KEYS.concat(PAYLOAD_KEYS.filter((key) => key !== payloadKey)); +} + +/** Forbidden controls for the legacy `task` wrapper surface. */ +const FORBIDDEN_REQUEST_KEYS = forbiddenKeysFor('task'); + /** Base64url alphabet only (no padding, no `+`/`/`). */ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; @@ -155,19 +173,24 @@ function validateBoundedAgentRequest(raw, options = {}) { return { valid: false, errors: ['request must be a JSON object'] }; } - const forbidden = FORBIDDEN_REQUEST_KEYS.filter( + // Trusted caller-surface selection, never request data. Exactly one payload + // spelling is accepted; the others stay forbidden controls. + const payloadKey = PAYLOAD_KEYS.includes(options.payloadKey) ? options.payloadKey : 'task'; + const allowedKeys = ['privateRepo', 'schema', payloadKey]; + const forbidden = forbiddenKeysFor(payloadKey).filter( (key) => Object.prototype.hasOwnProperty.call(raw, key), ); for (const key of forbidden) { errors.push(`request may not specify "${key}"`); } for (const key of Object.keys(raw)) { - if (!ALLOWED_REQUEST_KEYS.includes(key) && !forbidden.includes(key)) { + if (!allowedKeys.includes(key) && !forbidden.includes(key)) { errors.push(`unknown request key: "${key}"`); } } - const { privateRepo, schema, task } = raw; + const { privateRepo, schema } = raw; + const task = raw[payloadKey]; if (typeof privateRepo !== 'string') { errors.push('privateRepo must be a string'); @@ -187,18 +210,18 @@ function validateBoundedAgentRequest(raw, options = {}) { : MAX_TASK_BYTES; const taskLimit = Math.min(configuredLimit, MAX_TASK_BYTES); if (typeof task !== 'string') { - errors.push('task must be a string'); + errors.push(`${payloadKey} must be a string`); } else if (task.length === 0) { - errors.push('task must not be empty'); + errors.push(`${payloadKey} must not be empty`); } else if (Buffer.byteLength(task, 'utf8') > taskLimit) { - errors.push('task exceeds the maximum size'); + errors.push(`${payloadKey} exceeds the maximum size`); } if (errors.length > 0) return { valid: false, errors }; return { valid: true, - request: { privateRepo, schema: schemaValidation.schema, task }, + request: { privateRepo, schema: schemaValidation.schema, [payloadKey]: task }, }; } @@ -252,8 +275,11 @@ function readBoundedBody(req) { module.exports = { AGENT_PROTOCOL_VERSION, ALLOWED_REQUEST_KEYS, + MAX_TASK_BYTES, + PAYLOAD_KEYS, BODY_READ_TIMEOUT_MS, FORBIDDEN_REQUEST_KEYS, + forbiddenKeysFor, REPO_HEADER, SCHEMA_HEADER, VERSION_HEADER, diff --git a/containers/bounded-query/Dockerfile b/containers/bounded-query/Dockerfile index 3fbe45bc8..b8670fecd 100644 --- a/containers/bounded-query/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -80,18 +80,7 @@ USER root ENTRYPOINT ["node", "/opt/awf/broker/server.js"] -# AWF-owned unified enclave MCP server. This distinct image owns the Docker -# socket and private seed/work/audit mounts; its later Compose service must use -# network_mode: none. Script sandboxes remain the existing minimal query image. -FROM broker AS enclave-mcp-server - -COPY enclave-mcp/ /opt/awf/enclave-mcp/ -RUN chmod -R a-w /opt/awf/enclave-mcp \ - && node --check /opt/awf/enclave-mcp/config.js \ - && node --check /opt/awf/enclave-mcp/mcp-protocol.js \ - && node --check /opt/awf/enclave-mcp/server.js \ - && node --check /opt/awf/enclave-mcp/healthcheck.js \ - && mkdir -p /srv/awf/seeds /srv/awf/work \ - /run/awf-enclave-mcp /run/awf-enclave-mcp-control /var/log/awf-enclave - -ENTRYPOINT ["node", "/opt/awf/enclave-mcp/server.js"] +# The AWF-owned unified enclave MCP server is built from its own Dockerfile +# (`enclave-mcp/Dockerfile`) with the wider `containers/` build context, +# because it drives both the bounded-script executor in this directory and the +# audited bounded-agent enclave executor under `containers/bounded-agent/`. diff --git a/containers/bounded-query/agent-broker/enclave-runner.js b/containers/bounded-query/agent-broker/enclave-runner.js new file mode 100644 index 000000000..025dbadf4 --- /dev/null +++ b/containers/bounded-query/agent-broker/enclave-runner.js @@ -0,0 +1,13 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the enclave MCP server image. +// +// The published enclave-mcp-server image receives the real, audited +// bounded-agent enclave modules at /opt/awf/agent-broker (see +// bounded-query/enclave-mcp/Dockerfile, which COPYs +// containers/bounded-agent/broker/ there). This file exists only so the same +// `../agent-broker/enclave-runner` specifier also resolves when the enclave MCP +// modules are required directly from the source tree (unit tests, +// `node --check`), without duplicating a security-critical implementation +// into a second directory. +module.exports = require('../../bounded-agent/broker/enclave-runner'); diff --git a/containers/bounded-query/agent-broker/framing.js b/containers/bounded-query/agent-broker/framing.js new file mode 100644 index 000000000..ca4b66503 --- /dev/null +++ b/containers/bounded-query/agent-broker/framing.js @@ -0,0 +1,13 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the enclave MCP server image. +// +// The published enclave-mcp-server image receives the real, audited +// bounded-agent enclave modules at /opt/awf/agent-broker (see +// bounded-query/enclave-mcp/Dockerfile, which COPYs +// containers/bounded-agent/broker/ there). This file exists only so the same +// `../agent-broker/framing` specifier also resolves when the enclave MCP +// modules are required directly from the source tree (unit tests, +// `node --check`), without duplicating a security-critical implementation +// into a second directory. +module.exports = require('../../bounded-agent/broker/framing'); diff --git a/containers/bounded-query/agent-broker/workspace.js b/containers/bounded-query/agent-broker/workspace.js new file mode 100644 index 000000000..748b6ac16 --- /dev/null +++ b/containers/bounded-query/agent-broker/workspace.js @@ -0,0 +1,13 @@ +'use strict'; + +// Source-tree resolution shim — NOT shipped in the enclave MCP server image. +// +// The published enclave-mcp-server image receives the real, audited +// bounded-agent enclave modules at /opt/awf/agent-broker (see +// bounded-query/enclave-mcp/Dockerfile, which COPYs +// containers/bounded-agent/broker/ there). This file exists only so the same +// `../agent-broker/workspace` specifier also resolves when the enclave MCP +// modules are required directly from the source tree (unit tests, +// `node --check`), without duplicating a security-critical implementation +// into a second directory. +module.exports = require('../../bounded-agent/broker/workspace'); diff --git a/containers/bounded-query/broker/broker.js b/containers/bounded-query/broker/broker.js index 0f475b689..d428f0269 100644 --- a/containers/bounded-query/broker/broker.js +++ b/containers/bounded-query/broker/broker.js @@ -62,9 +62,21 @@ function createBroker(params) { if (executorKind !== 'script' && executorKind !== 'agent') { throw new Error('createBroker requires a known executor kind'); } + // Trusted, executor-specific request grammar. The default is the bounded + // *script* grammar, so the legacy bounded-query broker is unchanged. + const validateRequest = params.validateRequest || validateBoundedQueryRequest; + // Name of the single free-form payload field this executor accepts. + const payloadKey = params.payloadKey || 'script'; + // Optional trusted exit-status → protected-audit category map. Categories + // never reach the caller; every failure is still the canonical error. + const exitCategories = params.exitCategories || {}; + + // Optional shared serialization lane. When several executors are exposed by + // one server they share a lane so at most one sandbox — script or agent — + // holds private repository content at a time. + const lane = params.lane || { tail: Promise.resolve() }; let invocationsUsed = 0; - let tail = Promise.resolve(); let accepting = true; function emitQueryTelemetry(category) { @@ -102,12 +114,13 @@ function createBroker(params) { safeRespond(CANONICAL_ERROR_JSON); }; - const validation = validateBoundedQueryRequest(request); + const validation = validateRequest(request); if (!validation.valid) { await rejectBeforeExecution('invalid-request', validation.errors.join('; ')); return; } - const { privateRepo, schema, script } = validation.request; + const { privateRepo, schema } = validation.request; + const payload = validation.request[payloadKey]; const repoKey = privateRepo.toLowerCase(); const seed = seedMap.get(repoKey); @@ -141,7 +154,8 @@ function createBroker(params) { config, invocationId, seedId: seed.seedId, - script, + schema, + [payloadKey]: payload, }); } catch (error) { failureReason = ['workspace-create-failed', error.message]; @@ -153,11 +167,20 @@ function createBroker(params) { failureReason = ['timeout', 'workspace-creation-overran-deadline']; } else { try { - const run = await runner.runQueryContainer({ config, runId, invocationId, timeoutMs: remainingMs }); + const run = await runner.runQueryContainer({ + config, + runId, + invocationId, + seedId: seed.seedId, + timeoutMs: remainingMs, + }); if (run.timedOut) { failureReason = ['timeout']; } else if (run.exitCode !== 0) { - failureReason = ['non-zero-exit', `exit=${run.exitCode}`]; + failureReason = [ + exitCategories[run.exitCode] || 'non-zero-exit', + `exit=${run.exitCode}`, + ]; } else { const raw = workspace.readQueryOutput(layout.outPath, config.maxOutputBytes); if (raw === undefined) { @@ -183,6 +206,20 @@ function createBroker(params) { // shape can affect deletion time, and queued requests must not expose that // duration outside the charged timing bucket. Destroy by invocation id // even when creation threw after materializing only part of the workspace. + // Executor-specific protected artifacts (never agent-visible) are captured + // before teardown and inside the charged timing bucket. + if (layout && typeof workspace.preserveInvocationArtifacts === 'function') { + try { + workspace.preserveInvocationArtifacts({ layout, config, invocationId }); + } catch (error) { + if (failureReason === undefined) { + failureReason = ['artifact-preservation-failed', error.message]; + } else { + audit.failure(invocationId, 'artifact-preservation-failed', error.message); + } + canonicalResult = undefined; + } + } if (!safeDestroy(invocationId)) { failureReason = ['cleanup-failed']; canonicalResult = undefined; @@ -266,12 +303,12 @@ function createBroker(params) { audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`); emitQueryTelemetry('invocation-count-exhausted'); if (uniformTiming) { - const queued = tail.then(async () => { + const queued = lane.tail.then(async () => { const startMs = clock.nowMs(); await waitForBucket(startMs, clock.nowMs() - startMs, clock); safeRespond(CANONICAL_ERROR_JSON); }); - tail = queued.then( + lane.tail = queued.then( () => undefined, () => undefined, ); @@ -282,12 +319,12 @@ function createBroker(params) { } invocationsUsed += 1; - const queued = tail.then(() => execute(request, safeRespond)).catch((error) => { + const queued = lane.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( + lane.tail = queued.then( () => undefined, () => undefined, ); @@ -296,7 +333,7 @@ function createBroker(params) { /** Resolves when every admitted invocation has finished broker-side work. */ drain() { - return tail; + return lane.tail; }, /** @internal Exposed for tests. */ diff --git a/containers/bounded-query/enclave-mcp/Dockerfile b/containers/bounded-query/enclave-mcp/Dockerfile new file mode 100644 index 000000000..399a377aa --- /dev/null +++ b/containers/bounded-query/enclave-mcp/Dockerfile @@ -0,0 +1,81 @@ +# AWF unified enclave MCP server image. +# +# This image owns the Docker socket and the private seed/work/audit mounts for +# *both* enclave executors, and its Compose service always runs with +# `network_mode: none` — it has no `awf-net`, no enclave network, no DNS, no +# Squid, no host gateway, and no egress of any kind. Its only agent-facing +# surface is one authenticated Unix socket. +# +# BUILD CONTEXT: `containers/` (not `containers/bounded-query/`). The server +# drives two audited executors that live in two directories: +# +# * the bounded-script sandbox pipeline under `containers/bounded-query/` +# * the bounded-agent enclave pipeline under `containers/bounded-agent/` +# +# A wider context is preferred over duplicating a security-critical +# implementation into a third source tree. +# +# docker build -f bounded-query/enclave-mcp/Dockerfile containers/ +# +# The executor sandboxes themselves are separate, minimal images +# (`enclave-script`, `enclave-agent`); nothing in this image ever executes +# caller-supplied code. + +FROM node:22.23.2-alpine3.24 AS enclave-mcp-server + +# docker-cli — used by the server to launch single-use executor containers. +RUN apk add --no-cache docker-cli \ + && test -x /usr/bin/docker + +WORKDIR /opt/awf/enclave-mcp + +# Shared bounded-execution foundation (finite schema algebra, bit charge, +# strict JSON parsing/canonicalization, fixed timing buckets, protected audit, +# seed-map parsing, sensitivity policy and ledger). +COPY bounded-query/bounded-execution/ /opt/awf/bounded-execution/ +# Bounded-script executor pipeline (workspace, runner, runner spec, runtimes). +COPY bounded-query/broker/ /opt/awf/broker/ +# Bounded-agent enclave pipeline, reused verbatim from the audited +# bounded-agent broker rather than copied into a second implementation. +COPY bounded-agent/broker/ /opt/awf/agent-broker/ +# The MCP protocol/server and the executor adapters. +COPY bounded-query/enclave-mcp/ /opt/awf/enclave-mcp/ +# One audited no-network sandbox seccomp profile, pinned for both executors. +COPY bounded-query/query-seccomp.json /opt/awf/query-seccomp.json +COPY bounded-query/query-seccomp.json /opt/awf/enclave-seccomp.json + +RUN rm -f /opt/awf/enclave-mcp/Dockerfile \ + && chmod -R a-w /opt/awf \ + && node --check /opt/awf/enclave-mcp/config.js \ + && node --check /opt/awf/enclave-mcp/mcp-protocol.js \ + && node --check /opt/awf/enclave-mcp/agent-executor.js \ + && node --check /opt/awf/enclave-mcp/server.js \ + && node --check /opt/awf/enclave-mcp/healthcheck.js \ + && node --check /opt/awf/broker/broker.js \ + && node --check /opt/awf/broker/query-runner.js \ + && node --check /opt/awf/broker/query-runner-spec.js \ + && node --check /opt/awf/broker/workspace.js \ + && node --check /opt/awf/agent-broker/enclave-runner.js \ + && node --check /opt/awf/agent-broker/enclave-runner-spec.js \ + && node --check /opt/awf/agent-broker/docker-enclave-runner.js \ + && node --check /opt/awf/agent-broker/gvisor-enclave-runner.js \ + && node --check /opt/awf/agent-broker/framing.js \ + && node --check /opt/awf/agent-broker/workspace.js \ + && node --check /opt/awf/bounded-execution/finite-disclosure.js \ + && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \ + && node --check /opt/awf/bounded-execution/fixed-timing.js \ + && node --check /opt/awf/bounded-execution/protected-audit.js \ + && node --check /opt/awf/bounded-execution/repository-staging.js \ + && node -e "require('/opt/awf/enclave-mcp/agent-executor.js')" + +# Fixed server-only mount points. +RUN mkdir -p /srv/awf/seeds /srv/awf/work \ + /run/awf-enclave-mcp /run/awf-enclave-mcp-control /var/log/awf-enclave + +# The server is root only to copy host-owned read-only seeds into private +# workspaces and hand those workspaces to the unprivileged executor uid. +# Compose keeps the default capability set dropped and restores only those +# filesystem duties. +USER root + +ENTRYPOINT ["node", "/opt/awf/enclave-mcp/server.js"] diff --git a/containers/bounded-query/enclave-mcp/agent-executor.js b/containers/bounded-query/enclave-mcp/agent-executor.js new file mode 100644 index 000000000..b71cce3c7 --- /dev/null +++ b/containers/bounded-query/enclave-mcp/agent-executor.js @@ -0,0 +1,118 @@ +'use strict'; + +const { createEnclaveRunner } = require('../agent-broker/enclave-runner'); +const agentWorkspace = require('../agent-broker/workspace'); +const { validateBoundedAgentRequest } = require('../agent-broker/framing'); + +/** + * Adapters that let the unified enclave MCP server drive the audited + * bounded-agent enclave through the shared broker execution pipeline. + * + * Nothing here re-implements isolation. The runner, the container + * specification (single-use enclave, immutable seed mounted `ro`, `--read-only` + * root, bounded tmpfs, fixed non-root uid/gid, `--cap-drop ALL`, + * `no-new-privileges`, seccomp, memory/CPU/PID/file-size/timeout bounds, the + * dedicated API-proxy-only network), the native entrypoint, the bounded result + * file contract, the runtime availability proofs, the run/invocation labels, + * and the orphan reconciliation all come from the audited bounded-agent + * modules verbatim. This file only maps the shared broker's script-shaped + * calls onto them and fixes the caller-facing payload name to `prompt`. + */ + +/** Trusted enclave exit status → protected audit category. Never sent to a caller. */ +const ENCLAVE_EXIT_CATEGORIES = Object.freeze({ + 10: 'enclave-configuration-invalid', + 11: 'enclave-input-invalid', + 20: 'enclave-deadline-exceeded', + 21: 'enclave-provider-http-error', + 22: 'enclave-provider-transport-error', + 23: 'enclave-provider-response-invalid', + 24: 'enclave-engine-failed', + 30: 'enclave-result-write-failed', + 31: 'enclave-model-loop-exhausted', +}); + +/** The only free-form field the agent tool accepts from a caller. */ +const AGENT_PAYLOAD_KEY = 'prompt'; + +/** + * Validates one `enclave_run_agent` request against the fixed agent grammar. + * + * Delegates to the audited bounded-agent validator with the caller-facing + * payload name, so every forbidden control (image, command, mounts, env, + * endpoints, network, credentials, resources, runtime, profile, model, + * provider, tools, system prompt, messages, and the alternate payload + * spelling) is rejected by exactly one implementation. + */ +function createAgentRequestValidator(maxPromptBytes) { + return (request) => validateBoundedAgentRequest(request, { + maxTaskBytes: maxPromptBytes, + payloadKey: AGENT_PAYLOAD_KEY, + }); +} + +/** + * Workspace adapter. + * + * The shared broker speaks `createInvocationWorkspace`/`readQueryOutput`/ + * `destroyInvocationWorkspace`; the bounded-agent workspace speaks the same + * operations with an enclave-specific result reader and a protected session + * transcript. `preserveInvocationArtifacts` is the broker's optional hook, + * invoked inside the charged timing bucket and before teardown. + */ +const agentWorkspaceAdapter = { + createInvocationWorkspace({ config, invocationId, schema, prompt }) { + return agentWorkspace.createInvocationWorkspace({ + config, + invocationId, + schema, + task: prompt, + }); + }, + readQueryOutput(outPath, maxOutputBytes) { + return agentWorkspace.readEnclaveOutput(outPath, maxOutputBytes); + }, + preserveInvocationArtifacts({ layout, config, invocationId }) { + const preserved = agentWorkspace.preserveInvocationSession( + layout.sessionLogPath, + config.auditDir, + invocationId, + ); + if (!preserved) { + throw new Error('failed to preserve protected enclave session transcript'); + } + }, + destroyInvocationWorkspace(workDir, invocationId) { + agentWorkspace.destroyInvocationWorkspace(workDir, invocationId); + }, +}; + +/** + * Runner adapter around the audited bounded-agent EnclaveRunner. + * + * The backend is selected only from normalized trusted configuration; unknown + * values fail closed and gVisor never downgrades to the daemon's default OCI + * runtime. + */ +function createAgentRunner(config, deps = {}) { + const runner = createEnclaveRunner(config, deps); + return { + assertAvailable: () => runner.assertAvailable(), + reconcileRun: (runId) => runner.reconcileRun(runId), + runQueryContainer: ({ runId, invocationId, seedId, timeoutMs }) => runner.runEnclaveContainer({ + config, + runId, + invocationId, + seedId, + timeoutMs, + }), + }; +} + +module.exports = { + AGENT_PAYLOAD_KEY, + ENCLAVE_EXIT_CATEGORIES, + agentWorkspaceAdapter, + createAgentRequestValidator, + createAgentRunner, +}; diff --git a/containers/bounded-query/enclave-mcp/config.js b/containers/bounded-query/enclave-mcp/config.js index 83e830d51..8cce73956 100644 --- a/containers/bounded-query/enclave-mcp/config.js +++ b/containers/bounded-query/enclave-mcp/config.js @@ -13,6 +13,7 @@ const { ENCLAVE_INVOCATION_LABEL, ENCLAVE_RUN_LABEL, } = require('../broker/query-runner-spec'); +const { MAX_TASK_BYTES } = require('../agent-broker/framing'); const SEEDS_DIR = '/srv/awf/seeds'; const WORK_DIR = '/srv/awf/work'; @@ -23,6 +24,25 @@ const CONTROL_DIR = '/run/awf-enclave-mcp-control'; const AUDIT_DIR = '/var/log/awf-enclave'; const READY_PATH = path.join(CONTROL_DIR, 'server.ready'); +/** + * Fixed agent-enclave mount points and identity. Never caller-supplied. + * + * The seccomp profile is the audited no-network sandbox profile the script + * executor already uses, shipped into the server image a second time under an + * enclave-specific name so both executors stay pinned to one reviewed policy. + */ +const AGENT_SECCOMP_PATH = '/opt/awf/enclave-seccomp.json'; +const AGENT_MOUNT_DIR = '/agent'; +const AGENT_SEED_PATH = '/awf/seed'; +const AGENT_TASK_PATH = '/awf/task.txt'; +const AGENT_SCHEMA_PATH = '/awf/schema.json'; +const AGENT_UID = 65534; +const AGENT_GID = 65534; +const AGENT_SUPPORTED_BACKENDS = new Set(['docker', 'gvisor']); +const AGENT_SUPPORTED_ENGINES = new Set(['copilot']); +const AGENT_SUPPORTED_PROFILES = new Set(['openai', 'anthropic']); +const AGENT_CONTAINER_PREFIX = 'awf-enclave-agent'; + function requireEnv(name) { const value = process.env[name]; if (!value) throw new Error(`Missing required environment variable: ${name}`); @@ -115,6 +135,120 @@ function loadConfig(files = fs) { }; } +/** True when this run exposes the bounded-script executor. */ +function isScriptExecutorEnabled() { + return process.env.AWF_ENCLAVE_SCRIPT_ENABLED === 'true'; +} + +/** True when this run exposes the bounded-agent executor. */ +function isAgentExecutorEnabled() { + return process.env.AWF_ENCLAVE_AGENT_ENABLED === 'true'; +} + +/** + * Loads the shared, executor-independent server settings. + * + * Used on every start, including agent-only runs where no script-executor + * environment is present at all. + */ +function loadServerConfig(files = fs) { + const primaryBackend = requireEnv('AWF_ENCLAVE_PRIMARY_BACKEND'); + if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') { + throw new Error('AWF_ENCLAVE_PRIMARY_BACKEND is unsupported'); + } + const capability = files.readFileSync(CAPABILITY_PATH, 'utf8').trim(); + if (!/^[0-9a-f]{64}$/.test(capability)) { + throw new Error('Enclave capability file does not contain an AWF capability'); + } + return { + seedMapPath: SEED_MAP_PATH, + socketDir: SOCKET_DIR, + socketPath: path.join(SOCKET_DIR, 'server.sock'), + controlDir: CONTROL_DIR, + readyPath: READY_PATH, + auditDir: AUDIT_DIR, + primaryBackend, + socketUid: nonnegativeInt('AWF_ENCLAVE_SOCKET_UID', 0), + socketGid: nonnegativeInt('AWF_ENCLAVE_SOCKET_GID', 0), + capability, + }; +} + +/** + * Loads the trusted bounded-agent executor configuration. + * + * Every value here is AWF configuration delivered through the server's own + * environment: image, runtime backend, engine, profile, model, API-proxy + * endpoint, dedicated network, mount points, identity, resource bounds, and + * disclosure bounds. A request can express none of them. + */ +function loadAgentConfig(server) { + const backend = requireEnv('AWF_ENCLAVE_AGENT_BACKEND'); + if (!AGENT_SUPPORTED_BACKENDS.has(backend)) { + throw new Error(`Unsupported AWF_ENCLAVE_AGENT_BACKEND: ${backend}`); + } + const engine = requireEnv('AWF_ENCLAVE_AGENT_ENGINE'); + if (!AGENT_SUPPORTED_ENGINES.has(engine)) { + throw new Error(`Unsupported AWF_ENCLAVE_AGENT_ENGINE: ${engine}`); + } + const profile = requireEnv('AWF_ENCLAVE_AGENT_PROFILE'); + if (!AGENT_SUPPORTED_PROFILES.has(profile)) { + throw new Error(`Unsupported AWF_ENCLAVE_AGENT_PROFILE: ${profile}`); + } + const apiEndpoint = requireEnv('AWF_ENCLAVE_AGENT_API_ENDPOINT'); + if (!/^http:\/\/[0-9a-zA-Z.:-]+$/.test(apiEndpoint)) { + throw new Error('AWF_ENCLAVE_AGENT_API_ENDPOINT must be a bare http origin'); + } + const network = requireEnv('AWF_ENCLAVE_AGENT_NETWORK'); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(network)) { + throw new Error('AWF_ENCLAVE_AGENT_NETWORK is not a Docker network name'); + } + const cpuLimit = process.env.AWF_ENCLAVE_AGENT_CPU || '1'; + if (!/^(?:[0-9]{1,2})(?:\.[0-9]{1,3})?$/.test(cpuLimit) || Number(cpuLimit) <= 0) { + throw new Error('AWF_ENCLAVE_AGENT_CPU must be a positive decimal'); + } + + return { + seedsDir: SEEDS_DIR, + workDir: WORK_DIR, + auditDir: server.auditDir, + hostWorkDir: requireEnv('AWF_ENCLAVE_AGENT_HOST_WORK_DIR'), + hostSeedsDir: requireEnv('AWF_ENCLAVE_AGENT_HOST_SEEDS_DIR'), + enclaveSeccompPath: AGENT_SECCOMP_PATH, + enclaveMountDir: AGENT_MOUNT_DIR, + enclaveSeedPath: AGENT_SEED_PATH, + enclaveTaskPath: AGENT_TASK_PATH, + enclaveSchemaPath: AGENT_SCHEMA_PATH, + enclaveUid: AGENT_UID, + enclaveGid: AGENT_GID, + enclaveHostname: 'enclave-agent', + enclaveImage: requireEnv('AWF_ENCLAVE_AGENT_IMAGE'), + backend, + // Mirrored under the shared broker's telemetry field name so both + // executors emit one narrow, content-free runtime shape. + queryBackend: backend, + primaryBackend: server.primaryBackend, + engine, + profile, + model: requireEnv('AWF_ENCLAVE_AGENT_MODEL'), + apiEndpoint, + network, + timeoutSeconds: positiveInt('AWF_ENCLAVE_AGENT_TIMEOUT', 120, MAX_QUERY_TIMEOUT_SECONDS), + memoryLimit: dockerSize('AWF_ENCLAVE_AGENT_MEMORY', '512m'), + cpuLimit, + pidsLimit: positiveInt('AWF_ENCLAVE_AGENT_PIDS', 128), + tmpfsLimit: dockerSize('AWF_ENCLAVE_AGENT_TMPFS', '64m'), + maxOutputBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES), + maxPromptBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES', 4096, MAX_TASK_BYTES), + maxInvocations: positiveInt('AWF_ENCLAVE_AGENT_MAX_INVOCATIONS', 8), + maxModelRequests: positiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS', 8, 64), + maxModelTokens: positiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS', 1024, 32768), + runLabelKey: ENCLAVE_RUN_LABEL, + invocationLabelKey: ENCLAVE_INVOCATION_LABEL, + containerPrefix: AGENT_CONTAINER_PREFIX, + }; +} + function loadSeedMap(seedMapPath) { return parsePrivateRepositorySeedMap( fs.readFileSync(seedMapPath, 'utf8'), @@ -123,6 +257,11 @@ function loadSeedMap(seedMapPath) { } module.exports = { + AGENT_CONTAINER_PREFIX, + AGENT_SECCOMP_PATH, + AGENT_SUPPORTED_BACKENDS, + AGENT_SUPPORTED_ENGINES, + AGENT_SUPPORTED_PROFILES, AUDIT_DIR, CAPABILITY_PATH, CONTROL_DIR, @@ -131,6 +270,10 @@ module.exports = { SEEDS_DIR, SOCKET_DIR, WORK_DIR, + isAgentExecutorEnabled, + isScriptExecutorEnabled, + loadAgentConfig, loadConfig, loadSeedMap, + loadServerConfig, }; diff --git a/containers/bounded-query/enclave-mcp/mcp-protocol.js b/containers/bounded-query/enclave-mcp/mcp-protocol.js index f19d27381..8cdee0e64 100644 --- a/containers/bounded-query/enclave-mcp/mcp-protocol.js +++ b/containers/bounded-query/enclave-mcp/mcp-protocol.js @@ -8,6 +8,7 @@ const { const MCP_PROTOCOL_VERSION = '2025-06-18'; const TOOL_NAME = 'enclave_run_script'; +const AGENT_TOOL_NAME = 'enclave_run_agent'; const JSONRPC_ERROR = Object.freeze({ status: 'error' }); const FINITE_SCHEMA_INPUT = Object.freeze({ @@ -39,8 +40,89 @@ const TOOL = Object.freeze({ }), }); +/** + * Static prompt-driven agent tool. + * + * The caller supplies exactly a configured repository selector, a finite + * response schema, and the prompt text. Everything else about the enclave — + * runtime, engine, model, provider, profile, endpoints, mounts, network, + * tools, credentials, resource bounds, system prompt, and message construction + * — is trusted AWF configuration and an AWF-authored fixed model loop. The + * schema deliberately forbids additional properties so an unknown control is + * rejected rather than ignored. + */ +const AGENT_TOOL = Object.freeze({ + name: AGENT_TOOL_NAME, + description: + 'Run a bounded, single-use agent enclave against one configured private repository and return ' + + 'one finite value.', + inputSchema: Object.freeze({ + type: 'object', + properties: Object.freeze({ + privateRepo: Object.freeze({ type: 'string', description: 'Bare configured owner/repository selector.' }), + schema: FINITE_SCHEMA_INPUT, + prompt: Object.freeze({ type: 'string', description: 'Bounded UTF-8 task prompt.' }), + }), + required: Object.freeze(['privateRepo', 'schema', 'prompt']), + additionalProperties: false, + }), + outputSchema: Object.freeze({ + type: 'object', + properties: Object.freeze({ + status: Object.freeze({ enum: Object.freeze(['ok', 'error']) }), + result: Object.freeze({}), + }), + required: Object.freeze(['status']), + additionalProperties: false, + }), +}); + +/** Every tool the server can publish, keyed by its wire name. */ +const TOOLS_BY_NAME = Object.freeze({ + [TOOL_NAME]: TOOL, + [AGENT_TOOL_NAME]: AGENT_TOOL, +}); + +/** Byte bound applied to a tool's single free-form payload argument. */ +const TOOL_PAYLOAD_KEYS = Object.freeze({ + [TOOL_NAME]: 'script', + [AGENT_TOOL_NAME]: 'prompt', +}); + const TOOLS_LIST_RESULT = Object.freeze({ tools: Object.freeze([TOOL]) }); +/** + * Resolves the brokers this server exposes. + * + * `deps.brokers` is the unified form: a map from tool name to the trusted + * broker for that executor. `deps.broker` remains supported as the + * script-executor-only shorthand. + */ +function resolveBrokers(deps) { + if (deps.brokers) return deps.brokers; + return deps.broker ? { [TOOL_NAME]: deps.broker } : {}; +} + +/** + * Publishes exactly the tools whose executor is enabled for this run. + * + * The listing carries no repository, budget, sensitivity, model, engine, + * profile, endpoint, or runtime information: it is a fixed, static document + * per tool. + */ +function toolsListResult(deps) { + const brokers = resolveBrokers(deps); + const tools = Object.keys(TOOLS_BY_NAME) + .filter((name) => brokers[name] !== undefined) + .map((name) => TOOLS_BY_NAME[name]); + return { tools }; +} + +/** Per-tool byte bound for the single free-form payload argument. */ +function payloadLimitFor(name, deps) { + return name === AGENT_TOOL_NAME ? deps.maxPromptBytes : deps.maxScriptBytes; +} + function rpcError(id, code, message) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; } @@ -107,23 +189,34 @@ async function dispatchJsonRpc(message, deps) { if (message.params !== undefined && !hasOnlyKeys(message.params, new Set())) { return rpcError(message.id, -32602, 'Invalid params'); } - return rpcResult(message.id, TOOLS_LIST_RESULT); + return rpcResult(message.id, toolsListResult(deps)); } if (message.method === 'tools/call') { + const brokers = resolveBrokers(deps); if (!hasOnlyKeys(message.params, new Set(['name', 'arguments'])) - || message.params.name !== TOOL_NAME + || typeof message.params.name !== 'string' + || !Object.prototype.hasOwnProperty.call(brokers, message.params.name) || !Object.prototype.hasOwnProperty.call(message.params, 'arguments')) { return rpcError(message.id, -32602, 'Invalid params'); } + const name = message.params.name; const args = message.params.arguments; + if (!Object.prototype.hasOwnProperty.call(TOOL_PAYLOAD_KEYS, name)) { + return rpcError(message.id, -32602, 'Invalid params'); + } + const payloadKey = TOOL_PAYLOAD_KEYS[name]; + const limit = payloadLimitFor(name, deps); + // An oversized payload is dropped here so the broker never buffers it; the + // caller still observes only the canonical error the broker emits. const tooLarge = ( args - && typeof args.script === 'string' - && Buffer.byteLength(args.script, 'utf8') > deps.maxScriptBytes + && typeof args[payloadKey] === 'string' + && typeof limit === 'number' + && Buffer.byteLength(args[payloadKey], 'utf8') > limit ); const request = tooLarge ? undefined : args; - return rpcResult(message.id, await brokerCall(deps.broker, request)); + return rpcResult(message.id, await brokerCall(brokers[name], request)); } return rpcError(message.id, -32601, 'Method not found'); @@ -138,10 +231,15 @@ function parseJsonRpcBody(buffer) { } module.exports = { + AGENT_TOOL, + AGENT_TOOL_NAME, MCP_PROTOCOL_VERSION, TOOL, + TOOLS_BY_NAME, TOOL_NAME, + TOOL_PAYLOAD_KEYS, TOOLS_LIST_RESULT, dispatchJsonRpc, parseJsonRpcBody, + toolsListResult, }; diff --git a/containers/bounded-query/enclave-mcp/server.js b/containers/bounded-query/enclave-mcp/server.js index 2cca7b51a..c3192f9b2 100644 --- a/containers/bounded-query/enclave-mcp/server.js +++ b/containers/bounded-query/enclave-mcp/server.js @@ -8,8 +8,21 @@ const { createEnclaveInformationBudgetLedger } = require('../bounded-execution/s const { createBroker } = require('../broker/broker'); const { createQueryRunner } = require('../broker/query-runner'); const { createRuntimeTelemetry } = require('../broker/runtime-telemetry'); -const { loadConfig, loadSeedMap } = require('./config'); -const { dispatchJsonRpc, parseJsonRpcBody } = require('./mcp-protocol'); +const { + isAgentExecutorEnabled, + isScriptExecutorEnabled, + loadAgentConfig, + loadConfig, + loadSeedMap, + loadServerConfig, +} = require('./config'); +const { + ENCLAVE_EXIT_CATEGORIES, + agentWorkspaceAdapter, + createAgentRequestValidator, + createAgentRunner, +} = require('./agent-executor'); +const { AGENT_TOOL_NAME, TOOL_NAME, dispatchJsonRpc, parseJsonRpcBody } = require('./mcp-protocol'); const MAX_HTTP_BODY_BYTES = 420 * 1024; const RESPONSE_HEADERS = { @@ -90,7 +103,17 @@ function createMcpServer(deps) { return; } - const response = await dispatchJsonRpc(message, deps); + let response; + try { + response = await dispatchJsonRpc(message, deps); + } catch { + jsonResponse(res, 200, { + jsonrpc: '2.0', + id: Object.prototype.hasOwnProperty.call(message, 'id') ? message.id : null, + error: { code: -32603, message: 'Internal error' }, + }); + return; + } if (response === undefined) { res.writeHead(202, { 'cache-control': 'no-store', 'content-length': '0' }); res.end(); @@ -123,67 +146,122 @@ function listenOnSocket(server, config) { } async function main() { - const config = loadConfig(); - fs.rmSync(config.readyPath, { force: true }); - const audit = createProtectedAuditLog(config.auditDir, 'enclave.jsonl'); - const telemetry = createRuntimeTelemetry(config.auditDir); - const { runId, seeds } = loadSeedMap(config.seedMapPath); - const runner = createQueryRunner(config); - await runner.assertAvailable(); - await runner.reconcileRun(runId); + const serverConfig = loadServerConfig(); + fs.rmSync(serverConfig.readyPath, { force: true }); + const audit = createProtectedAuditLog(serverConfig.auditDir, 'enclave.jsonl'); + const telemetry = createRuntimeTelemetry(serverConfig.auditDir); + const { runId, seeds } = loadSeedMap(serverConfig.seedMapPath); + + const scriptEnabled = isScriptExecutorEnabled(); + const agentEnabled = isAgentExecutorEnabled(); + if (!scriptEnabled && !agentEnabled) { + throw new Error('No enclave executor is enabled'); + } + + // One ledger for the whole run. Script and agent invocations debit the same + // live per-repository balance, so switching executor kinds can never reset or + // fork a repository's disclosure budget. + const ledger = createEnclaveInformationBudgetLedger(seeds); + // One serialization lane for the whole run: at most one enclave — script or + // agent — holds private repository content at a time. + const lane = { tail: Promise.resolve() }; + const brokers = {}; + const runners = []; + const executors = []; + let maxScriptBytes; + let maxPromptBytes; + + if (scriptEnabled) { + const config = loadConfig(); + const runner = createQueryRunner(config); + await runner.assertAvailable(); + await runner.reconcileRun(runId); + runners.push({ runner, config }); + maxScriptBytes = config.maxScriptBytes; + brokers[TOOL_NAME] = createBroker({ + config, + seedMap: seeds, + runId, + audit, + runner, + ledger, + telemetry, + lane, + executorKind: 'script', + uniformTiming: true, + }); + executors.push('script'); + } + + if (agentEnabled) { + const config = loadAgentConfig(serverConfig); + const runner = createAgentRunner(config); + await runner.assertAvailable(); + await runner.reconcileRun(runId); + runners.push({ runner, config }); + maxPromptBytes = config.maxPromptBytes; + brokers[AGENT_TOOL_NAME] = createBroker({ + config, + seedMap: seeds, + runId, + audit, + runner, + ledger, + telemetry, + lane, + workspace: agentWorkspaceAdapter, + validateRequest: createAgentRequestValidator(config.maxPromptBytes), + payloadKey: 'prompt', + exitCategories: ENCLAVE_EXIT_CATEGORIES, + executorKind: 'agent', + uniformTiming: true, + }); + executors.push('agent'); + } + + const backends = runners[0].config; telemetry.emit({ - primaryBackend: config.primaryBackend, - queryBackend: config.queryBackend, + primaryBackend: serverConfig.primaryBackend, + queryBackend: backends.queryBackend, lifecycleClass: 'startup', capabilityState: 'supported', category: 'ready', }); - const ledger = createEnclaveInformationBudgetLedger(seeds); - const broker = createBroker({ - config, - seedMap: seeds, - runId, - audit, - runner, - ledger, - telemetry, - executorKind: 'script', - uniformTiming: true, - }); const server = createMcpServer({ - broker, - capability: config.capability, - maxScriptBytes: config.maxScriptBytes, + brokers, + capability: serverConfig.capability, + maxScriptBytes, + maxPromptBytes, }); - await listenOnSocket(server, config); - fs.mkdirSync(config.controlDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync(config.readyPath, '', { mode: 0o600 }); - audit.lifecycle('listening', { executor: 'script' }); + await listenOnSocket(server, serverConfig); + fs.mkdirSync(serverConfig.controlDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(serverConfig.readyPath, '', { mode: 0o600 }); + audit.lifecycle('listening', { executors }); let stopping = false; const shutdown = async () => { if (stopping) return; stopping = true; - broker.close(); + for (const broker of Object.values(brokers)) broker.close(); server.close(); try { - await broker.drain(); - await runner.reconcileRun(runId); + await lane.tail; + for (const { runner } of runners) await runner.reconcileRun(runId); telemetry.emit({ - primaryBackend: config.primaryBackend, - queryBackend: config.queryBackend, + primaryBackend: serverConfig.primaryBackend, + queryBackend: backends.queryBackend, lifecycleClass: 'cleanup', capabilityState: 'supported', category: 'success', }); - fs.rmSync(config.readyPath, { force: true }); + fs.rmSync(serverConfig.readyPath, { force: true }); process.exit(0); } catch (error) { audit.lifecycle('shutdown-cleanup-failed', error.message); telemetry.emit({ - primaryBackend: config.primaryBackend, - queryBackend: config.queryBackend, + primaryBackend: serverConfig.primaryBackend, + queryBackend: backends.queryBackend, lifecycleClass: 'cleanup', capabilityState: 'supported', category: 'cleanup-failed', diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 39d5b76c4..fb13d5846 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2442,10 +2442,12 @@ can answer the question. ## 16. Unified Enclaves The optional `enclaves` object is the successor configuration model for bounded -private-repository execution. The script executor launches an AWF-owned, -no-egress MCP service and hardened single-use script containers. The service is -not yet attached to the primary agent; a later migration layer registers it -exclusively through `gh-aw-mcpg`. See +private-repository execution. One AWF-owned, no-egress MCP service exposes the +enabled executors: the script executor launches hardened single-use script +containers with no network, and the agent executor launches hardened single-use +enclaves that run a fixed, AWF-authored model loop on a dedicated +API-proxy-only network. The service is not yet attached to the primary agent; a +later migration layer registers it exclusively through `gh-aw-mcpg`. See [Unified Enclave Architecture and Migration](enclaves-architecture.md). `enclaves.privateRepos` is the single trusted repository list for every @@ -2461,16 +2463,26 @@ defaults preserve the bounded-agent limits (`docker`, API-proxy-only network, Copilot/OpenAI profile, 120 seconds, 512 MiB, 8 invocations, 8 model requests, 1024 completion tokens). Neither executor is enabled by omission. -Layer 2 implements script execution for `docker` and exactly registered -`gvisor`/`runsc`. The schema reserves `sbx`, but preflight fails closed because -the unified MCP script launcher has not yet proved that backend; it never -downgrades to Docker or gVisor. - -Images, runtimes, interpreters, engines, provider profiles, models, networks, -timeouts, resource limits, and operational limits are trusted configuration. -The `enclave_run_script` MCP tool accepts exactly `privateRepo`, a finite -response `schema`, and bounded `script` bytes. It rejects trusted controls and -unknown aliases for them. An enabled agent executor requires a configured model. +Both executors are implemented for `docker` and exactly registered +`gvisor`/`runsc`. The schema reserves `sbx`, but preflight fails closed for +either executor because the unified launchers have not proved that backend; it +never downgrades to Docker or gVisor. The agent executor is implemented only for +`engine: copilot`, which is the sole engine with a published, audited enclave +image; another engine fails closed rather than falling back. + +An enabled agent executor additionally requires `enableApiProxy` and a +configured provider route for its engine/profile (Copilot token or BYOK route, +`ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`), a configured `model`, and the absence +of `enableDind`. All of these are validated before repository staging. + +Images, runtimes, interpreters, engines, provider profiles, models, endpoints, +networks, mounts, tool sets, system prompts, credentials, timeouts, resource +limits, and operational limits are trusted configuration. The +`enclave_run_script` MCP tool accepts exactly `privateRepo`, a finite response +`schema`, and bounded `script` bytes; the `enclave_run_agent` MCP tool accepts +exactly `privateRepo`, a finite response `schema`, and a bounded `prompt`. Both +reject trusted controls, unknown aliases for them, and the other tool's payload +key. When `enclaves.enabled` is `true`, at least one executor and one repository are required. `boundedQueries.enabled` or `boundedAgents.enabled` MUST NOT also be @@ -2478,10 +2490,33 @@ true. AWF rejects that mixed configuration before any legacy broker, enclave server, repository staging, or primary agent starts. Disabled sections may coexist because they do not activate a runtime. -The AWF-owned MCP server enforces the unified per-repository ledger for script -calls. The later agent executor will debit this same ledger rather than creating -an executor-specific balance. Legacy brokers retain their existing independent -behavior until runtime cutover. +The AWF-owned MCP server enforces the unified per-repository ledger for both +executors: a script call and an agent call debit the same live balance, and +switching executor kinds never resets or forks it. Both executors also share one +serialization lane inside the server. Legacy brokers retain their existing +independent behavior until runtime cutover. + +### 16.1 Agent executor topology and disclosure + +Agent enclaves join only the dedicated `internal` `awf-enclave-agent` network +(172.31.0.0/24). Its only other member is a dedicated API proxy that also joins +a separate egress bridge and is the only holder of a real provider credential. +The MCP server runs `network_mode: none` and is never on that network; neither +is the primary agent, Squid, the general API proxy, the safe-outputs collector, +the MCP gateway, or the CLI proxy. The dedicated proxy's credentials are +minimized to the configured route, its external telemetry export and Actions +OIDC token-exchange state are removed, and its logs stay in the enclave-private +root. + +Each enclave is single-use: immutable seed mounted read-only, `--read-only` +root, bounded `tmpfs`, fixed non-root uid/gid, `--cap-drop ALL`, +`no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds. Every +enclave container carries `awf.enclave.run` and `awf.enclave.invocation` labels +so one AWF reconciliation pass removes orphans from either executor. + +**Provider disclosure caveat.** Repository-derived content reaches the +configured model provider through the dedicated API proxy. The ledger bounds +what the *calling agent* learns, not what the *provider* sees. ## Normative References diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index e2460cb35..c9421a7e7 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -1105,7 +1105,7 @@ }, "enclaves": { "type": "object", - "description": "Unified private-repository enclave foundation. Repositories and sensitivities are shared by script and agent executors, and every invocation debits one per-repository information budget regardless of executor kind. This layer validates trusted configuration only; it does not expose an MCP server or a primary-agent surface.", + "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes the enabled executors through one AWF-owned, no-egress MCP server; that server is not yet attached to the primary agent.", "additionalProperties": false, "properties": { "enabled": { diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md index 455c3e464..5bb821b60 100644 --- a/docs/enclaves-architecture.md +++ b/docs/enclaves-architecture.md @@ -2,9 +2,11 @@ ## Status -Layer 2 of the staged migration implements the AWF-owned MCP server and the -script executor. It remains deliberately disconnected from the primary agent -until the `gh-aw-mcpg` attachment layer. Both legacy runtimes remain unchanged. +Layer 3 of the staged migration adds the **agent executor** to the same +AWF-owned MCP server, behind the same authenticated private socket and the same +shared per-repository ledger. The subsystem remains deliberately disconnected +from the primary agent until the `gh-aw-mcpg` attachment layer. Both legacy +runtimes remain unchanged. ## Decision @@ -61,7 +63,12 @@ The server owns the Docker socket, seed map, shared ledger, protected audit state, and a private Unix socket plus capability token. Neither the socket nor the token is mounted into the primary agent in this layer. -The server exposes one static MCP tool: +When the agent executor is enabled, AWF additionally pre-pulls or builds the +`enclave-agent` image, creates the dedicated `internal` `awf-enclave-agent` +network (172.31.0.0/24), and starts a dedicated API proxy on that network plus a +separate egress bridge. The MCP server itself never joins either network. + +The server exposes one static MCP tool per **enabled** executor: ```text enclave_run_script({ @@ -69,13 +76,57 @@ enclave_run_script({ schema: , script: }) + +enclave_run_agent({ + privateRepo: "owner/repo", + schema: , + prompt: +}) ``` -No image, runtime, interpreter path, command, mount, network, credential, -timeout, or resource setting is accepted in a tool call. `tools/list` is static -and does not reveal repositories, sensitivity, remaining budget, runtime, or -model configuration. Admitted executions debit the unified per-repository -ledger under executor kind `script`. +Both tool schemas set `additionalProperties: false`. No image, runtime, engine, +model, provider, profile, endpoint, mount, network, tool definition, system +prompt, message list, credential, timeout, or resource setting is accepted in a +tool call, and the alternate payload spelling (`task` for the agent tool, +`prompt` for the script tool) is an explicitly forbidden control so a second +payload can never be smuggled past the finite-disclosure charge. The agent +executor runs a fixed, AWF-authored model loop inside the enclave — the caller +supplies a prompt, never a system prompt, a message list, or a tool set. + +`tools/list` publishes exactly the enabled tools and does not reveal +repositories, sensitivity, remaining budget, invocation counts, runtime, engine, +profile, or model configuration. Admitted executions debit the *same* live +per-repository ledger under executor kind `script` or `agent`; both executors +also share one serialization lane, so at most one enclave holds private +repository content at a time. + +### Agent executor isolation + +Every agent invocation gets a fresh, single-use, labelled enclave with: + +- the immutable repository seed bind-mounted read-only and a `--read-only` root; +- bounded `tmpfs` for `/tmp` and the `/agent` work/result root; +- a fixed non-root uid/gid, `--cap-drop ALL`, `no-new-privileges`, and the + audited sandbox seccomp profile; +- memory, CPU, PID, per-file size, and wall-clock timeout bounds; +- `--network awf-enclave-agent` as its only network, whose only other member is + the dedicated API proxy — no primary agent, Squid, general API proxy, MCP + server, safe-outputs collector, MCP gateway, or CLI proxy is on it. + +Containers carry the `awf.enclave.run` and `awf.enclave.invocation` labels, so +one AWF-side reconciliation pass deterministically removes orphans from both +executors. `runtime: "sbx"` is schema-accepted but fails closed before staging; +`gvisor` requires an exactly registered `runsc` and never downgrades. + +### Credential and provider disclosure + +The dedicated API proxy is the only component that holds a real provider +credential. The MCP server, the enclave, and the primary agent never do. That +proxy's environment is minimized to the single provider route the configured +engine/profile uses, and external telemetry export (OTLP endpoints/headers, +trace propagation) plus Actions OIDC token-exchange state are removed from it, +exactly as for legacy bounded agents. Its telemetry is written only to the +enclave-private log root. Executor outcomes return successful JSON-RPC tool results whose `structuredContent` is exactly canonical `{"status":"ok","result":...}` or @@ -102,11 +153,13 @@ fails the run before repository staging is exposed or the primary agent starts. disclosure/staging/budget contracts, shared-ledger semantics, and compatibility exports. Keep both legacy systems fully functional and reject simultaneous enablement of a unified and legacy surface. -2. **AWF-owned script MCP server (this layer).** Implement the authenticated, - offline local server and hardened script executor over the shared contracts; - do not expose its private transport to the primary agent. -3. **Agent executor.** Add the fixed model loop and API-proxy-only enclave - network behind the same MCP server and shared ledger. +2. **AWF-owned script MCP server.** Implement the authenticated, offline local + server and hardened script executor over the shared contracts; do not expose + its private transport to the primary agent. +3. **Agent executor (this layer).** Add the fixed model loop, the dedicated + API-proxy-only enclave network, and the `enclave_run_agent` tool behind the + same MCP server, the same private socket, and the same shared ledger. The + private transport still is not exposed to the primary agent. 4. **`gh-aw-mcpg` integration.** Register and guard the AWF-owned server, wire startup retry/timeouts, require end-to-end readiness before primary-agent startup, and route both executor tools exclusively through the gateway. diff --git a/package-lock.json b/package-lock.json index 47de9019b..bbb0a75f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "chalk": "^4.1.2", "commander": "^12.1.0", "execa": "^5.1.1", - "js-yaml": "^4.3.0" + "js-yaml": "^5.2.2" }, "bin": { "awf": "dist/cli.js" @@ -7117,9 +7117,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha1-0ZAFcqf3zwtfVAyDZz5gutNDZZI=", + "version": "5.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha1-SXv+Y/Cw2xHHu8XOi8Vo6DbIsIw=", "funding": [ { "type": "github", @@ -7135,7 +7135,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { diff --git a/package.json b/package.json index de3874d06..12b7c5eb7 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "chalk": "^4.1.2", "commander": "^12.1.0", "execa": "^5.1.1", - "js-yaml": "^4.3.0" + "js-yaml": "^5.2.2" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index fd4a759bd..436b48b61 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -11,6 +11,7 @@ import { getLocalDockerEnv } from './host-env'; import { resolveBoundedQueryPaths } from './bounded-query/paths'; import { resolveBoundedAgentPaths } from './bounded-agent/paths'; import { resolveEnclavePaths } from './enclave/paths'; +import { ENCLAVE_MCP_SERVER_CONTAINER_NAME } from './constants'; const BOUNDED_QUERY_AUDIT_FILES = [ 'bounded-query.jsonl', @@ -115,7 +116,7 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void if (fs.existsSync(enclaveRoot)) { for (const auditFile of ENCLAVE_AUDIT_FILES) { try { - const source = `awf-enclave-mcp-server:/var/log/awf-enclave/${auditFile.source}`; + const source = `${ENCLAVE_MCP_SERVER_CONTAINER_NAME}:/var/log/awf-enclave/${auditFile.source}`; const destination = path.join(targetAuditDir, auditFile.destination); const result = execa.sync( 'docker', @@ -131,6 +132,25 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void logger.debug(`Could not copy enclave ${auditFile.source}:`, error); } } + try { + const destination = path.join(targetAuditDir, 'enclave-agent-sessions'); + const result = execa.sync( + 'docker', + [ + 'cp', + `${ENCLAVE_MCP_SERVER_CONTAINER_NAME}:/var/log/awf-enclave/${BOUNDED_AGENT_SESSION_DIR}`, + destination, + ], + { env: getLocalDockerEnv(), reject: false }, + ); + if (result.exitCode === 0) { + logger.debug('Copied enclave agent sessions to audit directory'); + } else { + logger.debug('Could not copy enclave agent sessions:', result.stderr); + } + } catch (error) { + logger.debug('Could not copy enclave agent sessions:', error); + } } } diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index e2460cb35..c9421a7e7 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -1105,7 +1105,7 @@ }, "enclaves": { "type": "object", - "description": "Unified private-repository enclave foundation. Repositories and sensitivities are shared by script and agent executors, and every invocation debits one per-repository information budget regardless of executor kind. This layer validates trusted configuration only; it does not expose an MCP server or a primary-agent surface.", + "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes the enabled executors through one AWF-owned, no-egress MCP server; that server is not yet attached to the primary agent.", "additionalProperties": false, "properties": { "enabled": { diff --git a/src/bounded-agent/protocol.ts b/src/bounded-agent/protocol.ts index 93703deae..bf4717d55 100644 --- a/src/bounded-agent/protocol.ts +++ b/src/bounded-agent/protocol.ts @@ -57,6 +57,22 @@ export const MAX_TASK_BYTES = 64 * 1024; /** The complete set of keys a bounded-agent request may contain. */ export const ALLOWED_REQUEST_KEYS: readonly string[] = ['privateRepo', 'schema', 'task']; +/** + * Every accepted spelling of the single free-form payload field. + * + * Exactly one is accepted per caller surface (`task` for the legacy + * bounded-agent wrapper protocol, `prompt` for the unified enclave MCP tool); + * the other is an explicitly forbidden control so a request can never smuggle + * a second payload past the finite-disclosure charge. + */ +export const PAYLOAD_REQUEST_KEYS: readonly string[] = ['task', 'prompt']; + +/** The payload spelling this legacy bounded-agent protocol accepts. */ +const PAYLOAD_KEY = 'task'; + +/** The alternate payload spellings this surface must reject. */ +const FORBIDDEN_PAYLOAD_KEYS = PAYLOAD_REQUEST_KEYS.filter((key) => key !== PAYLOAD_KEY); + /** * Controls a request may never express. * @@ -117,6 +133,7 @@ export const FORBIDDEN_REQUEST_KEYS: readonly string[] = [ 'resources', 'runtime', 'backend', + 'engine', 'sandbox', 'profile', 'model', @@ -131,6 +148,7 @@ export const FORBIDDEN_REQUEST_KEYS: readonly string[] = [ 'systemPrompt', 'system', 'messages', + ...FORBIDDEN_PAYLOAD_KEYS, ]; /** A validated bounded-agent request. */ diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 8d4041b35..205eb5ffa 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -20,6 +20,11 @@ import { BOUNDED_AGENT_NETWORK, BOUNDED_AGENT_SUBNET, } from './bounded-agent/network'; +import { + ENCLAVE_AGENT_EGRESS_NETWORK, + ENCLAVE_AGENT_NETWORK, + ENCLAVE_AGENT_SUBNET, +} from './enclave/network'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; /** @@ -231,6 +236,28 @@ export function generateDockerCompose( }; } } + if (config.enclaves?.enabled && config.enclaves.executors.agent.enabled) { + // Dedicated `internal` network whose only members are unified-enclave + // agent enclaves and the dual-homed dedicated API proxy. An explicit + // `name:` is required because the enclave MCP server launches enclaves + // with a fixed `docker run --network ` argument and must not have to + // derive a Compose project prefix at runtime. + compose.networks[ENCLAVE_AGENT_NETWORK] = { + name: ENCLAVE_AGENT_NETWORK, + driver: 'bridge', + internal: true, + ipam: { + config: [{ subnet: ENCLAVE_AGENT_SUBNET }], + }, + }; + // Only the dedicated credential sidecar joins this bridge. It receives + // direct upstream egress while enclaves remain confined to the internal + // network and the primary agent cannot observe its metrics or state. + compose.networks[ENCLAVE_AGENT_EGRESS_NETWORK] = { + name: ENCLAVE_AGENT_EGRESS_NETWORK, + driver: 'bridge', + }; + } return compose; } diff --git a/src/constants.ts b/src/constants.ts index 403a3710d..43d7cdd77 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,7 @@ export const BOUNDED_QUERY_BROKER_CONTAINER_NAME = 'awf-bounded-query-broker'; export const BOUNDED_AGENT_BROKER_CONTAINER_NAME = 'awf-bounded-agent-broker'; export const BOUNDED_AGENT_API_PROXY_CONTAINER_NAME = 'awf-bounded-agent-api-proxy'; export const ENCLAVE_MCP_SERVER_CONTAINER_NAME = 'awf-enclave-mcp-server'; +export const ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME = 'awf-enclave-agent-api-proxy'; // SQUID_PORT is centralized in src/config/sandbox-network-policy.json and // re-exported here so existing import sites keep working unchanged. diff --git a/src/enclave/agent-mcp-server.test.ts b/src/enclave/agent-mcp-server.test.ts new file mode 100644 index 000000000..4121f6a7b --- /dev/null +++ b/src/enclave/agent-mcp-server.test.ts @@ -0,0 +1,480 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const root = path.join(__dirname, '..', '..', 'containers', 'bounded-query'); +const { + AGENT_TOOL_NAME, + TOOL_NAME, + dispatchJsonRpc, +} = require(path.join(root, 'enclave-mcp', 'mcp-protocol.js')); +const { + ENCLAVE_EXIT_CATEGORIES, + agentWorkspaceAdapter, + createAgentRequestValidator, +} = require(path.join(root, 'enclave-mcp', 'agent-executor.js')); +const { createBroker } = require(path.join(root, 'broker', 'broker.js')); +const { + CANONICAL_ERROR_JSON, +} = require(path.join(root, 'bounded-execution', 'finite-disclosure.js')); +const { + createEnclaveInformationBudgetLedger, +} = require(path.join(root, 'bounded-execution', 'sensitivity-ledger.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const validAgentArguments = { + privateRepo: 'octo/private', + schema: { type: 'boolean' }, + prompt: 'Does this repository ship a release workflow?', +}; + +const validScriptArguments = { + privateRepo: 'octo/private', + schema: { type: 'boolean' }, + script: 'import json\nopen("out", "w").write(json.dumps(True))', +}; + +function rpc(method: string, params?: unknown, id = 1) { + return { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }; +} + +function fakeBroker(response: string, requests: unknown[] = []) { + return { + handle(request: unknown, respond: (value: string) => void) { + requests.push(request); + respond(response); + return Promise.resolve(); + }, + }; +} + +describe('enclave_run_agent tool contract', () => { + const deps = { + brokers: { + [TOOL_NAME]: fakeBroker(CANONICAL_ERROR_JSON), + [AGENT_TOOL_NAME]: fakeBroker(CANONICAL_ERROR_JSON), + }, + maxScriptBytes: 65536, + maxPromptBytes: 4096, + }; + + it('publishes exactly the enabled tools and nothing about the trusted configuration', async () => { + const response = await dispatchJsonRpc(rpc('tools/list', {}), { + ...deps, + repositories: ['should-never-appear'], + runtime: 'gvisor', + engine: 'copilot', + profile: 'anthropic', + model: 'private-model', + sensitivity: 'confidential', + }); + expect(response.result.tools.map((tool: { name: string }) => tool.name)) + .toEqual([TOOL_NAME, AGENT_TOOL_NAME]); + expect(JSON.stringify(response)).not.toMatch( + /should-never-appear|gvisor|confidential|private-model|anthropic|budget|bits|invocations/i, + ); + }); + + it('publishes only the agent tool when the script executor is disabled', async () => { + const response = await dispatchJsonRpc(rpc('tools/list'), { + brokers: { [AGENT_TOOL_NAME]: fakeBroker(CANONICAL_ERROR_JSON) }, + maxPromptBytes: 4096, + }); + expect(response.result.tools).toHaveLength(1); + const [tool] = response.result.tools; + expect(tool.name).toBe(AGENT_TOOL_NAME); + expect(tool.inputSchema).toMatchObject({ + required: ['privateRepo', 'schema', 'prompt'], + additionalProperties: false, + }); + expect(Object.keys(tool.inputSchema.properties)).toEqual(['privateRepo', 'schema', 'prompt']); + }); + + it('rejects a disabled tool with a protocol error rather than executing it', async () => { + const response = await dispatchJsonRpc(rpc('tools/call', { + name: AGENT_TOOL_NAME, + arguments: validAgentArguments, + }), { brokers: { [TOOL_NAME]: fakeBroker(CANONICAL_ERROR_JSON) }, maxScriptBytes: 65536 }); + expect(response).toMatchObject({ error: { code: -32602 } }); + }); + + it.each(['toString', 'constructor', '__proto__', 'valueOf'])( + 'rejects inherited broker-map name "%s" without dispatching it', + async (name) => { + const response = await dispatchJsonRpc(rpc('tools/call', { + name, + arguments: validAgentArguments, + }), deps); + expect(response).toMatchObject({ error: { code: -32602 } }); + }, + ); + + it('routes each tool to its own executor without crossing payloads', async () => { + const scriptRequests: unknown[] = []; + const agentRequests: unknown[] = []; + const routed = { + brokers: { + [TOOL_NAME]: fakeBroker('{"status":"ok","result":true}', scriptRequests), + [AGENT_TOOL_NAME]: fakeBroker('{"status":"ok","result":false}', agentRequests), + }, + maxScriptBytes: 65536, + maxPromptBytes: 4096, + }; + await dispatchJsonRpc(rpc('tools/call', { + name: TOOL_NAME, + arguments: validScriptArguments, + }), routed); + const agentResponse = await dispatchJsonRpc(rpc('tools/call', { + name: AGENT_TOOL_NAME, + arguments: validAgentArguments, + }), routed); + expect(scriptRequests).toEqual([validScriptArguments]); + expect(agentRequests).toEqual([validAgentArguments]); + expect(agentResponse.result).toEqual({ + content: [{ type: 'text', text: '{"status":"ok","result":false}' }], + structuredContent: { status: 'ok', result: false }, + }); + expect(agentResponse.result).not.toHaveProperty('isError'); + }); + + it('drops an oversized prompt before the executor sees it', async () => { + const requests: unknown[] = []; + const response = await dispatchJsonRpc(rpc('tools/call', { + name: AGENT_TOOL_NAME, + arguments: { ...validAgentArguments, prompt: 'a'.repeat(4097) }, + }), { + brokers: { [AGENT_TOOL_NAME]: fakeBroker(CANONICAL_ERROR_JSON, requests) }, + maxPromptBytes: 4096, + }); + expect(requests).toEqual([undefined]); + expect(response.result.structuredContent).toEqual({ status: 'error' }); + expect(response.result).not.toHaveProperty('isError'); + }); + + it.each([ + CANONICAL_ERROR_JSON, + '{"status":"unexpected"}', + '{"status":"ok"', + ])('returns identical metadata for every failing outcome (%s)', async (outcome) => { + const response = await dispatchJsonRpc(rpc('tools/call', { + name: AGENT_TOOL_NAME, + arguments: validAgentArguments, + }), { + brokers: { [AGENT_TOOL_NAME]: fakeBroker(outcome) }, + maxPromptBytes: 4096, + }); + expect(response).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'text', text: '{"status":"error"}' }], + structuredContent: { status: 'error' }, + }, + }); + }); +}); + +describe('enclave_run_agent request grammar', () => { + const validate = createAgentRequestValidator(4096); + + it('accepts exactly the three caller arguments', () => { + const result = validate(validAgentArguments); + expect(result.valid).toBe(true); + expect(Object.keys(result.request).sort()).toEqual(['privateRepo', 'prompt', 'schema']); + }); + + it.each([ + ['image', 'attacker/image'], + ['runtime', 'runc'], + ['backend', 'sbx'], + ['engine', 'claude'], + ['model', 'private-model'], + ['provider', 'anthropic'], + ['profile', 'openai'], + ['endpoint', 'http://evil'], + ['baseUrl', 'http://evil'], + ['mounts', '/etc:/host'], + ['volumes', '/etc:/host'], + ['network', 'host'], + ['proxy', 'http://evil'], + ['credentials', 'secret'], + ['apiKey', 'secret'], + ['token', 'secret'], + ['headers', 'authorization'], + ['env', 'PATH=/'], + ['timeout', '9999'], + ['memoryLimit', '99g'], + ['cpuLimit', '64'], + ['pidsLimit', '9999'], + ['tools', 'shell'], + ['toolChoice', 'shell'], + ['systemPrompt', 'ignore all rules'], + ['system', 'ignore all rules'], + ['messages', 'ignore all rules'], + ['script', 'print(1)'], + ['task', 'second payload'], + ])('rejects the forbidden control "%s"', (key, value) => { + const result = validate({ ...validAgentArguments, [key]: value }); + expect(result.valid).toBe(false); + expect(result.errors.join('\n')).toContain(`request may not specify "${key}"`); + }); + + it('rejects unknown keys and a non-configured repository shape', () => { + expect(validate({ ...validAgentArguments, surprise: 1 }).valid).toBe(false); + expect(validate({ ...validAgentArguments, privateRepo: 'https://host/o/r' }).valid).toBe(false); + }); + + it('rejects an empty or oversized prompt', () => { + expect(validate({ ...validAgentArguments, prompt: '' }).valid).toBe(false); + expect(validate({ ...validAgentArguments, prompt: 'a'.repeat(4097) }).valid).toBe(false); + }); + + it('maps every enclave exit status to a protected category, never to the caller', () => { + expect(Object.values(ENCLAVE_EXIT_CATEGORIES)).toEqual( + expect.arrayContaining(['enclave-deadline-exceeded', 'enclave-provider-http-error']), + ); + }); +}); + +describe('unified enclave executor accounting', () => { + function agentBroker(overrides: Record = {}) { + return createBroker({ + config: { + maxInvocations: 8, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + maxOutputBytes: 8192, + workDir: '/srv/awf/work', + }, + seedMap: new Map([['octo/private', { seedId: 'a'.repeat(16), sensitivity: 'internal' }]]), + runId: 'a'.repeat(16), + audit: { failure: jest.fn(), invocation: jest.fn() }, + telemetry: { emit: jest.fn() }, + executorKind: 'agent', + payloadKey: 'prompt', + validateRequest: createAgentRequestValidator(4096), + exitCategories: ENCLAVE_EXIT_CATEGORIES, + uniformTiming: true, + ...overrides, + }); + } + + it('debits the one shared per-repository ledger for the agent executor', async () => { + const ledger = { tryDebit: jest.fn(() => true) }; + let now = 0; + const broker = agentBroker({ + ledger, + clock: { nowMs: () => now, sleep: async (ms: number) => { now += ms; } }, + runner: { runQueryContainer: async () => ({ exitCode: 0, timedOut: false }) }, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'out', sessionLogPath: 'session' }), + readQueryOutput: () => 'true', + destroyInvocationWorkspace: () => undefined, + }, + }); + let result = ''; + await broker.handle(validAgentArguments, (value: string) => { result = value; }); + expect(result).toBe('{"status":"ok","result":true}'); + expect(ledger.tryDebit).toHaveBeenCalledWith('octo/private', 5, 'agent'); + }); + + it('exhausts one live balance across script and agent invocations', () => { + const ledger = createEnclaveInformationBudgetLedger(new Map([ + ['octo/private', { sensitivity: 'confidential' }], + ])); + expect(ledger.tryDebit('octo/private', 5, 'agent')).toBe(true); + expect(ledger.tryDebit('octo/private', 5, 'script')).toBe(false); + expect(ledger.tryDebit('OCTO/PRIVATE', 3, 'script')).toBe(true); + expect(ledger.tryDebit('octo/private', 1, 'agent')).toBe(false); + }); + + it('serializes both executors through one shared lane', async () => { + const order: string[] = []; + const lane = { tail: Promise.resolve() }; + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { release = resolve; }); + const workspace = { + createInvocationWorkspace: () => ({ outPath: 'out', sessionLogPath: 'session' }), + readQueryOutput: () => 'true', + destroyInvocationWorkspace: () => undefined, + }; + const shared = { + config: { + maxInvocations: 8, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + maxOutputBytes: 8192, + workDir: '/srv/awf/work', + }, + seedMap: new Map([['octo/private', { seedId: 'a'.repeat(16), sensitivity: 'public' }]]), + runId: 'a'.repeat(16), + audit: { failure: jest.fn(), invocation: jest.fn() }, + telemetry: { emit: jest.fn() }, + ledger: { tryDebit: () => true }, + workspace, + lane, + clock: { nowMs: () => 0, sleep: async () => undefined }, + }; + const script = createBroker({ + ...shared, + executorKind: 'script', + runner: { + runQueryContainer: async () => { + order.push('script-start'); + await gate; + order.push('script-end'); + return { exitCode: 0, timedOut: false }; + }, + }, + }); + const agent = createBroker({ + ...shared, + executorKind: 'agent', + payloadKey: 'prompt', + validateRequest: createAgentRequestValidator(4096), + runner: { + runQueryContainer: async () => { + order.push('agent-start'); + return { exitCode: 0, timedOut: false }; + }, + }, + }); + + const scriptCall = script.handle(validScriptArguments, () => undefined); + const agentCall = agent.handle(validAgentArguments, () => undefined); + release(); + await Promise.all([scriptCall, agentCall]); + expect(order).toEqual(['script-start', 'script-end', 'agent-start']); + }); + + it('selects the timing bucket only after enclave and workspace cleanup', async () => { + let now = 0; + const sleeps: number[] = []; + const broker = agentBroker({ + ledger: { tryDebit: () => true }, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { sleeps.push(ms); now += ms; }, + }, + runner: { + runQueryContainer: async () => { + now += 5; + return { exitCode: 0, timedOut: false }; + }, + }, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'out', sessionLogPath: 'session' }), + readQueryOutput: () => 'true', + preserveInvocationArtifacts: () => { now += 20; }, + destroyInvocationWorkspace: () => { now += 50; }, + }, + }); + let result = ''; + await broker.handle(validAgentArguments, (value: string) => { result = value; }); + expect(result).toBe('{"status":"ok","result":true}'); + expect(sleeps).toEqual([25]); + expect(now).toBe(100); + }); + + it('still cleans up and buckets the canonical error when artifact preservation fails', async () => { + let now = 0; + const order: string[] = []; + const broker = agentBroker({ + ledger: { tryDebit: () => true }, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { + order.push(`sleep:${ms}`); + now += ms; + }, + }, + runner: { + runQueryContainer: async () => ({ exitCode: 0, timedOut: false }), + }, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'out', sessionLogPath: 'session' }), + readQueryOutput: () => 'true', + preserveInvocationArtifacts: () => { + now += 20; + order.push('preserve'); + throw new Error('protected audit storage unavailable'); + }, + destroyInvocationWorkspace: () => { + now += 30; + order.push('destroy'); + }, + }, + }); + let result = ''; + await broker.handle(validAgentArguments, (value: string) => { result = value; }); + expect(result).toBe('{"status":"error"}'); + expect(order).toEqual(['preserve', 'destroy', 'sleep:50']); + expect(now).toBe(100); + }); + + it('buckets an enclave engine failure identically to a rejected repository', async () => { + async function run(runner: Record, seedMap: Map) { + let now = 0; + const broker = agentBroker({ + seedMap, + ledger: { tryDebit: () => true }, + clock: { nowMs: () => now, sleep: async (ms: number) => { now += ms; } }, + runner, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'out', sessionLogPath: 'session' }), + readQueryOutput: () => 'true', + destroyInvocationWorkspace: () => undefined, + }, + }); + let result = ''; + await broker.handle(validAgentArguments, (value: string) => { result = value; }); + return { now, result }; + } + const engineFailure = await run( + { runQueryContainer: async () => ({ exitCode: 24, timedOut: false }) }, + new Map([['octo/private', { seedId: 'a'.repeat(16), sensitivity: 'internal' }]]), + ); + const unknownRepo = await run({}, new Map()); + expect(engineFailure.result).toBe(CANONICAL_ERROR_JSON); + expect(unknownRepo.result).toBe(CANONICAL_ERROR_JSON); + expect(engineFailure.now).toBe(unknownRepo.now); + }); + + it('never leaks an enclave workspace when preservation and teardown are wired', async () => { + const destroyed: string[] = []; + const preserved: unknown[] = []; + const broker = agentBroker({ + ledger: { tryDebit: () => true }, + clock: { nowMs: () => 0, sleep: async () => undefined }, + runner: { runQueryContainer: async () => ({ exitCode: 0, timedOut: false }) }, + workspace: { + createInvocationWorkspace: ({ invocationId }: { invocationId: string }) => ({ + outPath: `out-${invocationId}`, + sessionLogPath: `session-${invocationId}`, + }), + readQueryOutput: () => 'true', + preserveInvocationArtifacts: (params: unknown) => { preserved.push(params); }, + destroyInvocationWorkspace: (_workDir: string, id: string) => { destroyed.push(id); }, + }, + }); + await broker.handle(validAgentArguments, () => undefined); + expect(destroyed).toHaveLength(1); + expect(preserved).toHaveLength(1); + }); +}); + +describe('agent workspace adapter', () => { + it('exposes exactly the shared broker workspace contract', () => { + expect(Object.keys(agentWorkspaceAdapter).sort()).toEqual([ + 'createInvocationWorkspace', + 'destroyInvocationWorkspace', + 'preserveInvocationArtifacts', + 'readQueryOutput', + ]); + }); + + it('reads the enclave result defensively rather than trusting the file', () => { + expect(agentWorkspaceAdapter.readQueryOutput('/nonexistent/enclave/out', 8192)).toBeUndefined(); + }); +}); diff --git a/src/enclave/agent-runner-spec.test.ts b/src/enclave/agent-runner-spec.test.ts new file mode 100644 index 000000000..93dc16ee1 --- /dev/null +++ b/src/enclave/agent-runner-spec.test.ts @@ -0,0 +1,239 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const boundedQueryRoot = path.join(__dirname, '..', '..', 'containers', 'bounded-query'); +const boundedAgentRoot = path.join(__dirname, '..', '..', 'containers', 'bounded-agent'); +const { + deriveEnclaveContainerSpec, + ENCLAVE_INVOCATION_LABEL, + ENCLAVE_RUN_LABEL, + ENCLAVE_MAX_FILE_BYTES, +} = require(path.join(boundedAgentRoot, 'broker', 'enclave-runner-spec.js')); +const { createEnclaveRunner } = require(path.join(boundedAgentRoot, 'broker', 'enclave-runner.js')); +const { loadAgentConfig, loadServerConfig } = require(path.join( + boundedQueryRoot, + 'enclave-mcp', + 'config.js', +)); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const trustedConfig = { + hostWorkDir: '/daemon/private/enclave/work', + hostSeedsDir: '/daemon/private/enclave/seeds', + enclaveMountDir: '/agent', + enclaveSeedPath: '/awf/seed', + enclaveTaskPath: '/awf/task.txt', + enclaveSchemaPath: '/awf/schema.json', + enclaveSeccompPath: '/opt/awf/enclave-seccomp.json', + enclaveImage: 'ghcr.io/github/awf/enclave-agent:pinned', + enclaveUid: 65534, + enclaveGid: 65534, + enclaveHostname: 'enclave-agent', + network: 'awf-enclave-agent', + engine: 'copilot', + profile: 'openai', + model: 'trusted-model', + apiEndpoint: 'http://172.31.0.30:10002', + memoryLimit: '768m', + tmpfsLimit: '96m', + cpuLimit: '0.5', + pidsLimit: 47, + timeoutSeconds: 120, + maxOutputBytes: 8192, + maxModelRequests: 4, + maxModelTokens: 512, + runLabelKey: ENCLAVE_RUN_LABEL, + invocationLabelKey: ENCLAVE_INVOCATION_LABEL, + containerPrefix: 'awf-enclave-agent', +}; + +describe('unified enclave agent runner specification', () => { + const spec = deriveEnclaveContainerSpec({ + config: trustedConfig, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + seedId: 'b'.repeat(32), + }); + + it('uses unified enclave labels so one reconcile pass covers both executors', () => { + expect(spec.containerName).toBe('awf-enclave-agent-abcdef123456-0123456789abcdef'); + expect(spec.launchArgs).toEqual(expect.arrayContaining([ + '--label', 'awf.enclave.run=abcdef1234567890', + '--label', 'awf.enclave.invocation=0123456789abcdef', + ])); + expect(spec.runListArgs).toContain('label=awf.enclave.run=abcdef1234567890'); + expect(spec.invocationListArgs).toContain('label=awf.enclave.invocation=0123456789abcdef'); + }); + + it('preserves every mandatory single-use isolation control', () => { + expect(spec.launchArgs).toEqual(expect.arrayContaining([ + '--network', 'awf-enclave-agent', + '--read-only', + '--user', '65534:65534', + '--cap-drop', 'ALL', + '--security-opt', 'no-new-privileges:true', + '--security-opt', 'seccomp=/opt/awf/enclave-seccomp.json', + '--memory', '768m', + '--memory-swap', '768m', + '--cpus', '0.5', + '--pids-limit', '47', + '--ulimit', `fsize=${ENCLAVE_MAX_FILE_BYTES}`, + '--pull', 'never', + ])); + expect(spec.launchArgs).toContain('/tmp:rw,noexec,nosuid,nodev,size=96m'); + expect(spec.launchArgs).toContain( + '/agent:rw,nosuid,nodev,size=96m,uid=65534,gid=65534,mode=0700', + ); + expect(spec.launchArgs).toContain(`${trustedConfig.hostSeedsDir}/${'b'.repeat(32)}:/awf/seed:ro`); + expect(spec.launchArgs).toContain('--entrypoint'); + }); + + it('never accepts an invocation-supplied control', () => { + const hostile = deriveEnclaveContainerSpec({ + config: trustedConfig, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + seedId: 'b'.repeat(32), + request: { + image: 'attacker/image', + network: 'host', + memoryLimit: '99g', + mounts: ['/etc:/host'], + model: 'attacker-model', + }, + }); + expect(hostile.launchArgs).toEqual(spec.launchArgs); + expect(spec.launchArgs.join(' ')).not.toMatch(/attacker|99g|--network host|\/etc:\/host/); + }); + + it('rejects an untrusted OCI runtime name and never downgrades gVisor', () => { + expect(() => deriveEnclaveContainerSpec({ + config: trustedConfig, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + seedId: 'b'.repeat(32), + runtimeName: 'kata', + })).toThrow(/Unsupported OCI runtime/); + expect(deriveEnclaveContainerSpec({ + config: trustedConfig, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + seedId: 'b'.repeat(32), + runtimeName: 'runsc', + }).launchArgs).toEqual(expect.arrayContaining(['--runtime', 'runsc'])); + }); + + it('keeps the legacy bounded-agent naming byte-compatible', () => { + const legacy = deriveEnclaveContainerSpec({ + config: { + ...trustedConfig, + runLabelKey: undefined, + invocationLabelKey: undefined, + containerPrefix: undefined, + enclaveHostname: undefined, + }, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + seedId: 'b'.repeat(32), + }); + expect(legacy.containerName).toBe('awf-bounded-agent-abcdef123456-0123456789abcdef'); + expect(legacy.launchArgs).toEqual(expect.arrayContaining([ + '--label', 'awf.bounded-agent.run=abcdef1234567890', + '--label', 'awf.bounded-agent.invocation=0123456789abcdef', + '--hostname', 'bounded-agent', + ])); + }); + + it('fails closed for an unimplemented enclave backend', () => { + expect(() => createEnclaveRunner({ ...trustedConfig, backend: 'firecracker' })) + .toThrow(/Unsupported bounded-agent backend/); + }); +}); + +describe('unified enclave agent server configuration', () => { + const original = { ...process.env }; + + afterEach(() => { + process.env = { ...original }; + }); + + function setEnv(overrides: Record = {}): void { + Object.assign(process.env, { + AWF_ENCLAVE_PRIMARY_BACKEND: 'docker', + AWF_ENCLAVE_AGENT_BACKEND: 'gvisor', + AWF_ENCLAVE_AGENT_ENGINE: 'copilot', + AWF_ENCLAVE_AGENT_PROFILE: 'anthropic', + AWF_ENCLAVE_AGENT_MODEL: 'trusted-model', + AWF_ENCLAVE_AGENT_IMAGE: 'image:pinned', + AWF_ENCLAVE_AGENT_NETWORK: 'awf-enclave-agent', + AWF_ENCLAVE_AGENT_API_ENDPOINT: 'http://172.31.0.30:10001', + AWF_ENCLAVE_AGENT_HOST_WORK_DIR: '/daemon/private/enclave/work', + AWF_ENCLAVE_AGENT_HOST_SEEDS_DIR: '/daemon/private/enclave/seeds', + AWF_ENCLAVE_AGENT_TIMEOUT: '90', + AWF_ENCLAVE_AGENT_MEMORY: '700m', + AWF_ENCLAVE_AGENT_CPU: '0.25', + AWF_ENCLAVE_AGENT_PIDS: '33', + AWF_ENCLAVE_AGENT_TMPFS: '80m', + AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES: '4096', + AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES: '2048', + AWF_ENCLAVE_AGENT_MAX_INVOCATIONS: '3', + AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS: '2', + AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS: '256', + ...overrides, + }); + } + + const server = { auditDir: '/var/log/awf-enclave', primaryBackend: 'docker' }; + + it('derives every enclave control from the trusted server environment', () => { + setEnv(); + expect(loadAgentConfig(server)).toMatchObject({ + backend: 'gvisor', + engine: 'copilot', + profile: 'anthropic', + model: 'trusted-model', + network: 'awf-enclave-agent', + apiEndpoint: 'http://172.31.0.30:10001', + timeoutSeconds: 90, + memoryLimit: '700m', + cpuLimit: '0.25', + pidsLimit: 33, + tmpfsLimit: '80m', + maxOutputBytes: 4096, + maxPromptBytes: 2048, + maxInvocations: 3, + maxModelRequests: 2, + maxModelTokens: 256, + enclaveUid: 65534, + enclaveGid: 65534, + enclaveSeccompPath: '/opt/awf/enclave-seccomp.json', + runLabelKey: 'awf.enclave.run', + invocationLabelKey: 'awf.enclave.invocation', + containerPrefix: 'awf-enclave-agent', + }); + }); + + it.each([ + ['AWF_ENCLAVE_AGENT_BACKEND', 'sbx'], + ['AWF_ENCLAVE_AGENT_ENGINE', 'claude'], + ['AWF_ENCLAVE_AGENT_PROFILE', 'vertex'], + ['AWF_ENCLAVE_AGENT_API_ENDPOINT', 'https://api.example.com'], + ['AWF_ENCLAVE_AGENT_NETWORK', 'not a network!'], + ['AWF_ENCLAVE_AGENT_CPU', '0'], + ])('fails closed for an unsupported %s', (name, value) => { + setEnv({ [name]: value }); + expect(() => loadAgentConfig(server)).toThrow(); + }); + + it('requires an AWF capability before serving either executor', () => { + setEnv(); + expect(() => loadServerConfig({ readFileSync: () => 'not-a-capability' })).toThrow( + /does not contain an AWF capability/, + ); + expect(loadServerConfig({ readFileSync: () => 'a'.repeat(64) })).toMatchObject({ + primaryBackend: 'docker', + socketPath: '/run/awf-enclave-mcp/server.sock', + auditDir: '/var/log/awf-enclave', + }); + }); +}); diff --git a/src/enclave/image-layout.test.ts b/src/enclave/image-layout.test.ts new file mode 100644 index 000000000..c1b65aca1 --- /dev/null +++ b/src/enclave/image-layout.test.ts @@ -0,0 +1,102 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** + * The unified enclave MCP server image reuses two audited source trees rather + * than duplicating them. These tests pin that contract: the Dockerfile must + * copy both trees into the layout the server's `require` specifiers assume, and + * the release pipeline must publish every image the server references. + */ + +const repoRoot = path.join(__dirname, '..', '..'); +const containersRoot = path.join(repoRoot, 'containers'); +const dockerfilePath = path.join(containersRoot, 'bounded-query', 'enclave-mcp', 'Dockerfile'); + +function readDockerfile(): string { + return fs.readFileSync(dockerfilePath, 'utf8'); +} + +describe('enclave MCP server image contract', () => { + it('copies both executor source trees plus the shared foundation', () => { + const dockerfile = readDockerfile(); + for (const copy of [ + 'COPY bounded-query/bounded-execution/ /opt/awf/bounded-execution/', + 'COPY bounded-query/broker/ /opt/awf/broker/', + 'COPY bounded-agent/broker/ /opt/awf/agent-broker/', + 'COPY bounded-query/enclave-mcp/ /opt/awf/enclave-mcp/', + 'COPY bounded-query/query-seccomp.json /opt/awf/enclave-seccomp.json', + ]) { + expect(dockerfile).toContain(copy); + } + expect(dockerfile).toContain('AS enclave-mcp-server'); + expect(dockerfile).toContain('ENTRYPOINT ["node", "/opt/awf/enclave-mcp/server.js"]'); + }); + + it('no longer ships the server stage from the bounded-query image', () => { + const boundedQuery = fs.readFileSync( + path.join(containersRoot, 'bounded-query', 'Dockerfile'), + 'utf8', + ); + expect(boundedQuery).not.toContain('AS enclave-mcp-server'); + expect(boundedQuery).toContain('FROM python:3.12-alpine3.21 AS query'); + expect(boundedQuery).toContain('AS broker'); + }); + + it('resolves the whole server module graph from the published layout', () => { + const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-enclave-image-')); + const awf = path.join(stage, 'opt', 'awf'); + try { + fs.mkdirSync(awf, { recursive: true }); + fs.cpSync( + path.join(containersRoot, 'bounded-query', 'bounded-execution'), + path.join(awf, 'bounded-execution'), + { recursive: true }, + ); + fs.cpSync( + path.join(containersRoot, 'bounded-query', 'broker'), + path.join(awf, 'broker'), + { recursive: true }, + ); + fs.cpSync( + path.join(containersRoot, 'bounded-agent', 'broker'), + path.join(awf, 'agent-broker'), + { recursive: true }, + ); + fs.cpSync( + path.join(containersRoot, 'bounded-query', 'enclave-mcp'), + path.join(awf, 'enclave-mcp'), + { recursive: true }, + ); + fs.rmSync(path.join(awf, 'enclave-mcp', 'Dockerfile'), { force: true }); + + for (const relative of [ + 'enclave-mcp/server.js', + 'enclave-mcp/agent-executor.js', + 'enclave-mcp/config.js', + 'enclave-mcp/mcp-protocol.js', + 'agent-broker/enclave-runner.js', + 'agent-broker/workspace.js', + 'agent-broker/framing.js', + 'broker/broker.js', + 'broker/query-runner.js', + ]) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(require(path.join(awf, relative))).toBeDefined(); + } + } finally { + fs.rmSync(stage, { recursive: true, force: true }); + } + }); + + it('publishes the enclave-agent image and the wider-context server build', () => { + const release = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'release.yml'), + 'utf8', + ); + expect(release).toContain('file: ./containers/bounded-query/enclave-mcp/Dockerfile'); + expect(release).toMatch(/enclave-agent:\$\{\{ needs\.bump-version\.outputs\.version_number \}\}/); + expect(release).toContain('enclave_agent_digest'); + expect(release).toContain('id: build_enclave_agent'); + }); +}); diff --git a/src/enclave/manager.test.ts b/src/enclave/manager.test.ts index 4f8bbabf8..08f7db09e 100644 --- a/src/enclave/manager.test.ts +++ b/src/enclave/manager.test.ts @@ -35,6 +35,34 @@ function config(workDir: string, overrides: Parameters[0] = {}, +): WrapperConfig { + return { + ...config(workDir, overrides), + enableApiProxy: true, + copilotGithubToken: 'copilot-token', + } as WrapperConfig; +} + +/** + * Runs staging but tolerates a sandboxed host that cannot create the private + * `/var/tmp` root. Every other failure still fails the test, and the ordering + * assertions below run either way because runtime proofs precede staging. + */ +async function prepareToleratingPrivateRootIo( + wrapperConfig: WrapperConfig, + deps: Parameters[1], +): Promise { + try { + await prepareEnclaves(wrapperConfig, deps); + } catch (error) { + if (!/EPERM|EACCES/.test(String(error))) throw error; + } +} + describe('prepareEnclaves fail-closed preflight', () => { let workDir: string; @@ -60,17 +88,67 @@ describe('prepareEnclaves fail-closed preflight', () => { })).rejects.toThrow(/Unix-socket Docker host/); }); - it('rejects the future agent executor rather than half-enabling it', async () => { - await expect(prepareEnclaves(config(workDir, { + it('proves both executor runtimes before staging when both are enabled', async () => { + const assertScriptRuntimeAvailable = jest.fn().mockResolvedValue(undefined); + const assertAgentRuntimeAvailable = jest.fn().mockResolvedValue(undefined); + await prepareToleratingPrivateRootIo(agentConfig(workDir, { executors: { script: { enabled: true }, - agent: { enabled: true, model: 'future-model' }, + agent: { enabled: true, model: 'gpt-test' }, }, + }), { + env: { GH_TOKEN: 'secret' }, + gitRunner, + assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), + assertScriptRuntimeAvailable, + assertAgentRuntimeAvailable, + }); + expect(assertScriptRuntimeAvailable).toHaveBeenCalledTimes(1); + expect(assertAgentRuntimeAvailable).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, runtime: 'docker', model: 'gpt-test' }), + ); + }); + + it('never probes a disabled executor runtime', async () => { + const assertScriptRuntimeAvailable = jest.fn().mockResolvedValue(undefined); + const assertAgentRuntimeAvailable = jest.fn().mockResolvedValue(undefined); + await prepareToleratingPrivateRootIo(agentConfig(workDir, { + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }), { + env: { GH_TOKEN: 'secret' }, + gitRunner, + assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), + assertScriptRuntimeAvailable, + assertAgentRuntimeAvailable, + }); + expect(assertScriptRuntimeAvailable).not.toHaveBeenCalled(); + expect(assertAgentRuntimeAvailable).toHaveBeenCalledTimes(1); + }); + + it('rejects the unproven sbx agent runtime before staging and never downgrades', async () => { + const assertAgentRuntimeAvailable = jest.fn(); + await expect(prepareEnclaves(agentConfig(workDir, { + executors: { agent: { enabled: true, model: 'gpt-test', runtime: 'sbx' } }, }), { env: { GH_TOKEN: 'secret' }, assertPrimaryAvailable: jest.fn(), assertScriptRuntimeAvailable: jest.fn(), - })).rejects.toThrow(/reserved for migration layer 3/); + assertAgentRuntimeAvailable, + })).rejects.toThrow(/agent.runtime "sbx" is not implemented/); + expect(assertAgentRuntimeAvailable).not.toHaveBeenCalled(); + }); + + it('rejects an agent executor without the mandatory API proxy', async () => { + await expect(prepareEnclaves({ + ...agentConfig(workDir, { + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }), + enableApiProxy: false, + } as WrapperConfig, { + env: { GH_TOKEN: 'secret' }, + assertPrimaryAvailable: jest.fn(), + assertAgentRuntimeAvailable: jest.fn(), + })).rejects.toThrow(/agent executor requires the AWF API proxy/); }); it('rejects the unimplemented sbx script runtime before staging', async () => { @@ -158,7 +236,7 @@ describe('prepareEnclaves fail-closed preflight', () => { mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'daemon unavailable' }); const paths = resolveEnclavePaths(workDir); await expect(teardownEnclaves(wrapperConfig)).rejects.toThrow( - /Failed to list orphaned enclave script containers/, + /Failed to list orphaned enclave containers/, ); expect(fs.existsSync(paths.root)).toBe(true); expect(fs.existsSync(paths.ingressRoot)).toBe(true); diff --git a/src/enclave/manager.ts b/src/enclave/manager.ts index 63c718c27..ada2a27b4 100644 --- a/src/enclave/manager.ts +++ b/src/enclave/manager.ts @@ -10,9 +10,15 @@ import { import { assertPrimaryRuntimeAvailable, assertQueryRuntimeAvailable } from '../bounded-query/preflight'; import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from '../bounded-query/staging'; import { getLocalDockerEnv } from '../host-env'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; import { logger } from '../logger'; import type { BoundedQueriesConfig, WrapperConfig } from '../types'; -import type { EnclaveScriptExecutorConfig } from '../types/enclave-options'; +import type { + EnclaveAgentExecutorConfig, + EnclaveScriptExecutorConfig, +} from '../types/enclave-options'; +import { assertEnclaveRuntimeAvailable } from '../bounded-agent/preflight'; +import type { BoundedAgentsConfig } from '../types'; import { assertPrivateRootIsolated } from '../bounded-query/mount-policy'; import { validateEnclavesConfig } from './preflight'; import { generateEnclaveRunId, resolveEnclavePaths, type EnclavePaths } from './paths'; @@ -23,6 +29,10 @@ export function isEnclaveScriptEnabled(config: WrapperConfig): boolean { return config.enclaves?.enabled === true && config.enclaves.executors.script.enabled === true; } +export function isEnclaveAgentEnabled(config: WrapperConfig): boolean { + return config.enclaves?.enabled === true && config.enclaves.executors.agent.enabled === true; +} + export function isEnclavesEnabled(config: WrapperConfig): boolean { return config.enclaves?.enabled === true; } @@ -32,14 +42,24 @@ function ensureDirectory(target: string, mode: number): void { fs.chmodSync(target, mode); } -function prepareDirectories(paths: EnclavePaths): void { +function prepareDirectories( + paths: EnclavePaths, + chown: typeof fs.chownSync = fs.chownSync, +): void { fs.mkdirSync(paths.root, { mode: 0o700 }); fs.mkdirSync(paths.ingressRoot, { mode: 0o700 }); ensureDirectory(paths.seedsDir, 0o700); ensureDirectory(paths.workDir, 0o700); ensureDirectory(paths.controlDir, 0o700); ensureDirectory(paths.auditDir, 0o700); - ensureDirectory(paths.runDir, 0o700); + ensureDirectory(paths.apiProxyLogsDir, 0o700); + ensureDirectory(paths.runDir, 0o770); + if (process.getuid?.() === 0) { + const hostUid = parseInt(getSafeHostUid(), 10); + const hostGid = parseInt(getSafeHostGid(), 10); + chown(paths.runDir, hostUid, hostGid); + chown(paths.apiProxyLogsDir, hostUid, hostGid); + } } function writeExclusive(target: string, content: string, mode: number): void { @@ -60,6 +80,7 @@ export interface PrepareEnclavesDeps { gitRunner?: GitRunner; env?: NodeJS.ProcessEnv; assertScriptRuntimeAvailable?: (config: EnclaveScriptExecutorConfig) => Promise; + assertAgentRuntimeAvailable?: (config: EnclaveAgentExecutorConfig) => Promise; assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable; } @@ -71,18 +92,20 @@ export async function prepareEnclaves( const enclaves = config.enclaves!; const env = deps.env ?? process.env; const errors = validateEnclavesConfig(config); - if (enclaves.executors.agent.enabled) { - errors.push('enclaves.executors.agent is reserved for migration layer 3 and is not implemented'); - } - if (!enclaves.executors.script.enabled) { - errors.push('this migration layer requires enclaves.executors.script.enabled'); - } - if (enclaves.executors.script.runtime === 'sbx') { + if (enclaves.executors.script.enabled && enclaves.executors.script.runtime === 'sbx') { errors.push('enclaves.executors.script.runtime "sbx" is not implemented and never falls back'); } + if (enclaves.executors.agent.enabled && enclaves.executors.agent.runtime === 'sbx') { + errors.push( + 'enclaves.executors.agent.runtime "sbx" is not implemented: the installed sbx runtime cannot ' + + 'prove every mandatory enclave-isolation control, and enclaves never fall back to Docker or gVisor', + ); + } const dockerHost = config.awfDockerHost ?? env.DOCKER_HOST; if (dockerHost && !dockerHost.startsWith('unix://')) { - errors.push('enclave script execution requires a Unix-socket Docker host because its MCP server has no network'); + errors.push( + 'enclave execution requires a Unix-socket Docker host because the enclave MCP server has no network', + ); } const token = resolveStagingToken(env); if (!token) { @@ -96,17 +119,29 @@ export async function prepareEnclaves( } await (deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable)(config.containerRuntime); - const assertRuntime = deps.assertScriptRuntimeAvailable - ?? ((script: EnclaveScriptExecutorConfig) => ( - assertQueryRuntimeAvailable( - script as unknown as BoundedQueriesConfig, - undefined, - undefined, - undefined, - 'enclaves.executors.script.runtime', - ) - )); - await assertRuntime(enclaves.executors.script); + if (enclaves.executors.script.enabled) { + const assertScriptRuntime = deps.assertScriptRuntimeAvailable + ?? ((script: EnclaveScriptExecutorConfig) => ( + assertQueryRuntimeAvailable( + script as unknown as BoundedQueriesConfig, + undefined, + undefined, + undefined, + 'enclaves.executors.script.runtime', + ) + )); + await assertScriptRuntime(enclaves.executors.script); + } + if (enclaves.executors.agent.enabled) { + // The agent executor reuses the audited bounded-agent runtime proof: an + // unregistered `runsc` aborts the run and never downgrades to the daemon's + // default OCI runtime, and `sbx` stays blocked until every control is proven. + const assertAgentRuntime = deps.assertAgentRuntimeAvailable + ?? ((agent: EnclaveAgentExecutorConfig) => ( + assertEnclaveRuntimeAvailable(agent as unknown as BoundedAgentsConfig) + )); + await assertAgentRuntime(enclaves.executors.agent); + } const paths = resolveEnclavePaths(config.workDir); assertPrivateRootIsolated(config, paths, env, process.cwd(), 'enclave'); @@ -154,6 +189,13 @@ function readRunId(paths: EnclavePaths): string | undefined { } } +/** + * Removes every orphaned enclave container for this run. + * + * Script and agent enclaves share the `awf.enclave.run` label, so one pass + * reconciles both executors without AWF having to know which one created a + * container. + */ async function removeOrphanEnclaveContainers(runId: string): Promise { const listed = await execa('docker', ['ps', '-aq', '--filter', `label=${ENCLAVE_RUN_LABEL}=${runId}`], { env: getLocalDockerEnv(), @@ -161,7 +203,7 @@ async function removeOrphanEnclaveContainers(runId: string): Promise { timeout: 30_000, }); if (listed.exitCode !== 0) { - throw new Error('Failed to list orphaned enclave script containers'); + throw new Error('Failed to list orphaned enclave containers'); } const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean); if (ids.length === 0) return; @@ -171,7 +213,7 @@ async function removeOrphanEnclaveContainers(runId: string): Promise { timeout: 60_000, }); if (removed.exitCode !== 0) { - throw new Error('Failed to remove orphaned enclave script containers'); + throw new Error('Failed to remove orphaned enclave containers'); } } diff --git a/src/enclave/network.ts b/src/enclave/network.ts new file mode 100644 index 000000000..24368fb23 --- /dev/null +++ b/src/enclave/network.ts @@ -0,0 +1,47 @@ +/** + * Dedicated network for the unified enclave agent executor. + * + * An agent enclave is deliberately *not* a member of `awf-net` or `awf-ext`: + * it has no Squid route, no general proxy, no DNS route to the internet, and + * no path to the primary agent, the enclave MCP server, the safe-outputs + * collector, the MCP gateway, or the CLI proxy. Its only reachable peer is a + * dedicated AWF API proxy instance that joins a separate egress bridge and is + * the only component holding a real provider credential. That proxy's logs, + * metrics, and quota state are private to this subsystem. + * + * The enclave MCP server that *launches* these enclaves never joins this + * network: it runs `network_mode: none` and reaches the Docker daemon only + * through a bind-mounted Unix socket. + * + * The network is created by Compose with an explicit `name:` so the server — + * which launches enclaves with a fixed `docker run --network ` argument + * vector — never has to derive a Compose project prefix at runtime. + */ + +/** Compose key and concrete Docker network name for the agent-enclave network. */ +export const ENCLAVE_AGENT_NETWORK = 'awf-enclave-agent'; + +/** Egress bridge joined only by the dedicated agent-enclave API proxy. */ +export const ENCLAVE_AGENT_EGRESS_NETWORK = 'awf-enclave-agent-egress'; + +/** + * Fixed subnet for the agent-enclave network. + * + * Deliberately disjoint from the `awf-net` subnet (172.30.0.0/24). The legacy + * bounded-agent network uses the same range, which can never collide because + * `enclaves` and `boundedAgents` are mutually exclusive by fail-closed + * configuration validation. + */ +export const ENCLAVE_AGENT_SUBNET = '172.31.0.0/24'; + +/** Fixed API-proxy address on the agent-enclave network. */ +export const ENCLAVE_AGENT_API_PROXY_IP = '172.31.0.30'; + +/** + * Fixed DNS alias for the API proxy on the agent-enclave network. + * + * The enclave addresses the proxy by IP (Docker's embedded resolver is not + * guaranteed to be reachable from every runtime), but the alias is published + * so operators can reason about the topology. + */ +export const ENCLAVE_AGENT_API_PROXY_ALIAS = 'awf-enclave-agent-api-proxy'; diff --git a/src/enclave/paths.ts b/src/enclave/paths.ts index 3da00aa1b..3a3658897 100644 --- a/src/enclave/paths.ts +++ b/src/enclave/paths.ts @@ -7,6 +7,8 @@ export interface EnclavePaths { workDir: string; controlDir: string; auditDir: string; + /** Dedicated agent-enclave API-proxy telemetry. Never agent-visible. */ + apiProxyLogsDir: string; seedMapPath: string; ingressRoot: string; runDir: string; @@ -48,6 +50,7 @@ export function resolveEnclavePaths( workDir: path.join(root, 'work'), controlDir: path.join(root, 'control'), auditDir: path.join(root, 'audit'), + apiProxyLogsDir: path.join(root, 'api-proxy-logs'), seedMapPath: path.join(root, 'seed-map.json'), ingressRoot, runDir, diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index ca04e1401..6e4817d1d 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -139,6 +139,147 @@ describe('validateEnclavesConfig', () => { expect(errors).toMatch(/must be a positive integer/); }); + it('accepts an agent executor with a routed API-proxy model target', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }); + expect(validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotGithubToken: 'token', + }))).toEqual([]); + }); + + it('rejects an agent executor whose engine has no audited enclave image', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'claude-test', engine: 'claude' } }, + }); + const errors = validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + anthropicApiKey: 'key', + })).join('\n'); + expect(errors).toMatch(/engine "claude" is not implemented/); + expect(errors).toMatch(/never fall back to a different engine/); + }); + + it('rejects an agent executor without a configured provider route', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }); + expect(validateEnclavesConfig(config({ enclaves, enableApiProxy: true })).join('\n')) + .toMatch(/requires a configured API target for engine "copilot"/); + }); + + it('rejects a Copilot base URL without a credential', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }); + expect(validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotProviderBaseUrl: 'https://models.example.test', + })).join('\n')).toMatch(/requires a configured API target for engine "copilot"/); + }); + + it('rejects any enclave executor combined with a Docker socket in the primary agent', () => { + expect(validateEnclavesConfig(config({ enableDind: true })).join('\n')) + .toMatch(/enclaves cannot be combined with enableDind/); + + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'gpt-test' } }, + }); + expect(validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotGithubToken: 'token', + enableDind: true, + })).join('\n')).toMatch(/enclaves cannot be combined with enableDind/); + }); + + it('rejects an agent executor that cannot reach a model or drops its network', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true } }, + }); + const errors = validateEnclavesConfig(config({ enclaves })).join('\n'); + expect(errors).toMatch(/agent.model is required/); + expect(errors).toMatch(/agent executor requires the AWF API proxy/); + }); + + it('rejects agent disclosure and resource bounds the enclave cannot enforce', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + agent: { + enabled: true, + model: 'gpt-test', + timeout: 100_000, + memoryLimit: 'huge', + cpuLimit: '0', + pidsLimit: 0, + maxOutputBytes: 0, + maxModelRequests: 0, + maxModelTokens: 0, + }, + }, + }); + const errors = validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotGithubToken: 'token', + })).join('\n'); + for (const pattern of [ + /agent.timeout must be between/, + /agent.memoryLimit is not a Docker size/, + /agent.cpuLimit must be a positive/, + /agent.pidsLimit must be a positive integer/, + /agent.maxOutputBytes must be a positive integer/, + /agent.maxModelRequests must be a positive integer/, + /agent.maxModelTokens must be a positive integer/, + ]) { + expect(errors).toMatch(pattern); + } + }); + + it('rejects agent bounds above the server and native-loop hard ceilings', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + agent: { + enabled: true, + model: 'gpt-test', + maxOutputBytes: 8193, + maxTaskBytes: 65_537, + maxModelRequests: 65, + maxModelTokens: 32_769, + }, + }, + }); + const errors = validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotGithubToken: 'token', + })).join('\n'); + expect(errors).toMatch(/agent.maxOutputBytes must be at most 8192/); + expect(errors).toMatch(/agent.maxTaskBytes must be at most 65536/); + expect(errors).toMatch(/agent.maxModelRequests must be at most 64/); + expect(errors).toMatch(/agent.maxModelTokens must be at most 32768/); + }); + it('rejects script disclosure bounds the container cannot enforce', () => { const enclaves = normalizeEnclavesConfig({ enabled: true, diff --git a/src/enclave/preflight.ts b/src/enclave/preflight.ts index f738093dc..27e083e2e 100644 --- a/src/enclave/preflight.ts +++ b/src/enclave/preflight.ts @@ -1,17 +1,54 @@ import type { WrapperConfig } from '../types'; -import type { EnclavesConfig } from '../types/enclave-options'; +import type { EnclaveAgentExecutorConfig, EnclavesConfig } from '../types/enclave-options'; import { MAX_RESULT_BYTES, MAX_SCRIPT_BYTES, MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS, PRIVATE_REPOSITORY_PATTERN, } from '../bounded-execution'; +import { MAX_TASK_BYTES } from '../bounded-agent/protocol'; import { normalizePrivateRepositoryKey } from '../bounded-execution/repository-staging'; -import { resolveApiProxyRoute } from '../bounded-agent/preflight'; const RUNTIMES = new Set(['docker', 'gvisor', 'sbx']); const ENGINES = new Set(['copilot', 'claude', 'codex', 'gemini']); +/** Engines with a published, audited enclave image and a fixed AWF model loop. */ +const IMPLEMENTED_AGENT_ENGINES = new Set(['copilot']); + +/** + * Resolves whether the configured agent profile has a usable API-proxy route. + * + * An agent enclave holds no credentials: it can only reach a model through the + * dedicated AWF API proxy, which injects the real key. If the profile's + * provider is not routed for this run the enclave would sit on an internal + * network with nothing to talk to, so the run is rejected rather than started + * in a state where every invocation returns the canonical error. + */ +export function resolveEnclaveAgentApiRoute( + config: WrapperConfig, + agent: Pick, +): { routed: boolean; detail: string } { + if (agent.engine === 'copilot') { + return { + routed: Boolean( + config.copilotGithubToken + || config.copilotProviderApiKey, + ), + detail: 'apiProxy.targets.copilot (COPILOT_GITHUB_TOKEN or Copilot BYOK route) is not configured', + }; + } + if (agent.profile === 'anthropic') { + return { + routed: Boolean(config.anthropicApiKey), + detail: 'apiProxy.targets.anthropic (ANTHROPIC_API_KEY) is not configured', + }; + } + return { + routed: Boolean(config.openaiApiKey), + detail: 'apiProxy.targets.openai (OPENAI_API_KEY) is not configured', + }; +} + function validateRepositoryList(enclaves: EnclavesConfig, errors: string[]): void { if (enclaves.privateRepos.length === 0) { errors.push('enclaves.enabled is true but enclaves.privateRepos is empty'); @@ -39,6 +76,13 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { 'enclaves cannot be enabled with boundedQueries or boundedAgents; choose the unified enclaves section or the legacy sections', ); } + if (config.enableDind) { + errors.push( + 'enclaves cannot be combined with enableDind: exposing the Docker socket to the primary agent ' + + 'would allow it to inspect private seed mounts, join enclave networks, and bypass the ' + + 'finite-disclosure ledger', + ); + } validateRepositoryList(enclaves, errors); const { script, agent } = enclaves.executors; @@ -68,7 +112,15 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { if (agent.enabled) { if (!RUNTIMES.has(agent.runtime)) errors.push(`enclaves.executors.agent.runtime "${agent.runtime}" is not supported`); - if (!ENGINES.has(agent.engine)) errors.push(`enclaves.executors.agent.engine "${agent.engine}" is not supported`); + if (!ENGINES.has(agent.engine)) { + errors.push(`enclaves.executors.agent.engine "${agent.engine}" is not supported`); + } else if (!IMPLEMENTED_AGENT_ENGINES.has(agent.engine)) { + errors.push( + `enclaves.executors.agent.engine "${agent.engine}" is not implemented. Only "copilot" has a ` + + 'pinned native enclave image and an AWF-authored model loop; enclaves never fall back to a ' + + 'different engine.', + ); + } if (agent.network !== 'api-proxy-only') { errors.push('enclaves.executors.agent.network must be "api-proxy-only"'); } @@ -76,9 +128,12 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { if (!config.enableApiProxy) { errors.push('enclaves agent executor requires the AWF API proxy'); } else { - const route = resolveApiProxyRoute(config, agent); + const route = resolveEnclaveAgentApiRoute(config, agent); if (!route.routed) { - errors.push(`enclaves agent executor has no usable model route: ${route.detail}`); + errors.push( + `enclaves agent executor requires a configured API target for engine "${agent.engine}": ` + + `${route.detail}`, + ); } } if (!Number.isInteger(agent.timeout) || agent.timeout < 1 || agent.timeout > MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS) { @@ -88,9 +143,21 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { } validateResourceLimits('enclaves.executors.agent', agent, errors); validatePositiveInteger('enclaves.executors.agent.maxTaskBytes', agent.maxTaskBytes, errors); + if (agent.maxTaskBytes > MAX_TASK_BYTES) { + errors.push(`enclaves.executors.agent.maxTaskBytes must be at most ${MAX_TASK_BYTES}`); + } + if (agent.maxOutputBytes > MAX_RESULT_BYTES) { + errors.push(`enclaves.executors.agent.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + } validatePositiveInteger('enclaves.executors.agent.maxInvocations', agent.maxInvocations, errors); validatePositiveInteger('enclaves.executors.agent.maxModelRequests', agent.maxModelRequests, errors); + if (agent.maxModelRequests > 64) { + errors.push('enclaves.executors.agent.maxModelRequests must be at most 64'); + } validatePositiveInteger('enclaves.executors.agent.maxModelTokens', agent.maxModelTokens, errors); + if (agent.maxModelTokens > 32768) { + errors.push('enclaves.executors.agent.maxModelTokens must be at most 32768'); + } } return errors; diff --git a/src/image-tag.test.ts b/src/image-tag.test.ts index 737ed6bc8..a2e734e43 100644 --- a/src/image-tag.test.ts +++ b/src/image-tag.test.ts @@ -13,6 +13,7 @@ const IMAGE_DIGEST_KEYS = [ 'bounded-agent', 'bounded-agent-broker', 'enclave-script', + 'enclave-agent', 'enclave-mcp-server', ] as const; diff --git a/src/image-tag.ts b/src/image-tag.ts index c13c8f4d2..29546bd14 100644 --- a/src/image-tag.ts +++ b/src/image-tag.ts @@ -1,6 +1,6 @@ import path from 'path'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'bounded-query', 'bounded-query-broker', 'bounded-agent', 'bounded-agent-broker', 'enclave-script', 'enclave-mcp-server'] as const; +const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'bounded-query', 'bounded-query-broker', 'bounded-agent', 'bounded-agent-broker', 'enclave-script', 'enclave-agent', 'enclave-mcp-server'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/services/enclave-agent-service.test.ts b/src/services/enclave-agent-service.test.ts new file mode 100644 index 000000000..328687422 --- /dev/null +++ b/src/services/enclave-agent-service.test.ts @@ -0,0 +1,365 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import { parseImageTag } from '../image-tag'; +import type { WrapperConfig } from '../types'; +import { buildEnclaveMcpService, resolveEnclaveAgentApiPort } from './enclave-mcp-service'; +import { generateDockerCompose } from '../compose-generator'; +import { + ENCLAVE_AGENT_API_PROXY_IP, + ENCLAVE_AGENT_EGRESS_NETWORK, + ENCLAVE_AGENT_NETWORK, + ENCLAVE_AGENT_SUBNET, +} from '../enclave/network'; + +function config(overrides: Partial = {}): WrapperConfig { + return { + workDir: '/tmp/awf-test', + agentCommand: 'echo enclave', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + enclaves: normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'trusted-model' } }, + }), + enableApiProxy: true, + copilotGithubToken: 'copilot-token', + openaiApiKey: 'openai-key', + anthropicApiKey: 'anthropic-key', + ...overrides, + } as WrapperConfig; +} + +const ghcr = { + useGHCR: true, + registry: 'ghcr.io/github/gh-aw-firewall', + parsedTag: parseImageTag('v1'), + projectRoot: '/repo', +}; + +const networkConfig = { + subnet: '172.30.0.0/24', + squidIp: '172.30.0.10', + agentIp: '172.30.0.20', + proxyIp: '172.30.0.30', +}; + +function build(overrides: Partial = {}) { + return buildEnclaveMcpService({ + config: config(overrides), + imageConfig: ghcr, + networkConfig, + }); +} + +describe('unified enclave agent executor compose assembly', () => { + it('pins the published enclave-agent image and its one-shot pull service', () => { + const result = build(); + expect(result.agentImageService).toMatchObject({ + image: 'ghcr.io/github/gh-aw-firewall/enclave-agent:v1', + network_mode: 'none', + entrypoint: ['/bin/true'], + restart: 'no', + }); + expect(result.service.depends_on).toMatchObject({ + 'enclave-agent-image': { condition: 'service_completed_successfully' }, + 'enclave-agent-api-proxy': { condition: 'service_healthy' }, + }); + const environment = result.service.environment as Record; + expect(environment.AWF_ENCLAVE_AGENT_IMAGE) + .toBe('ghcr.io/github/gh-aw-firewall/enclave-agent:v1'); + }); + + it('builds the enclave-agent and server images from their audited sources locally', () => { + const local = buildEnclaveMcpService({ + config: config(), + imageConfig: { ...ghcr, useGHCR: false }, + networkConfig, + }); + expect(local.agentImageService).toMatchObject({ + image: 'awf-enclave-agent:local', + build: { + context: '/repo/containers', + dockerfile: 'bounded-agent/Dockerfile', + target: 'enclave', + }, + }); + expect(local.service).toMatchObject({ + build: { + context: '/repo/containers', + dockerfile: 'bounded-query/enclave-mcp/Dockerfile', + target: 'enclave-mcp-server', + }, + }); + }); + + it('keeps the MCP server networkless and free of provider credentials', () => { + const result = build(); + expect(result.service.network_mode).toBe('none'); + expect(result.service).not.toHaveProperty('networks'); + expect(result.service).not.toHaveProperty('ports'); + const environment = result.service.environment as Record; + for (const key of [ + 'COPILOT_GITHUB_TOKEN', + 'COPILOT_PROVIDER_API_KEY', + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'GEMINI_API_KEY', + 'GH_TOKEN', + 'GITHUB_TOKEN', + ]) { + expect(environment[key]).toBeUndefined(); + } + expect(JSON.stringify(environment)).not.toContain('copilot-token'); + expect(JSON.stringify(environment)).not.toContain('openai-key'); + expect(JSON.stringify(environment)).not.toContain('octo/private'); + }); + + it('derives every agent enclave control from trusted configuration', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + agent: { + enabled: true, + runtime: 'gvisor', + engine: 'copilot', + profile: 'anthropic', + model: 'trusted-model', + timeout: 77, + memoryLimit: '256m', + cpuLimit: '0.5', + pidsLimit: 32, + tmpfsLimit: '24m', + maxOutputBytes: 2048, + maxTaskBytes: 1024, + maxInvocations: 3, + maxModelRequests: 2, + maxModelTokens: 256, + }, + }, + }); + const environment = build({ enclaves }).service.environment as Record; + expect(environment).toMatchObject({ + AWF_ENCLAVE_AGENT_ENABLED: 'true', + AWF_ENCLAVE_SCRIPT_ENABLED: 'false', + AWF_ENCLAVE_AGENT_BACKEND: 'gvisor', + AWF_ENCLAVE_AGENT_ENGINE: 'copilot', + AWF_ENCLAVE_AGENT_PROFILE: 'anthropic', + AWF_ENCLAVE_AGENT_MODEL: 'trusted-model', + AWF_ENCLAVE_AGENT_NETWORK: ENCLAVE_AGENT_NETWORK, + AWF_ENCLAVE_AGENT_TIMEOUT: '77', + AWF_ENCLAVE_AGENT_MEMORY: '256m', + AWF_ENCLAVE_AGENT_CPU: '0.5', + AWF_ENCLAVE_AGENT_PIDS: '32', + AWF_ENCLAVE_AGENT_TMPFS: '24m', + AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES: '2048', + AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES: '1024', + AWF_ENCLAVE_AGENT_MAX_INVOCATIONS: '3', + AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS: '2', + AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS: '256', + }); + // Copilot always speaks the Copilot API-proxy port, regardless of profile. + expect(environment.AWF_ENCLAVE_AGENT_API_ENDPOINT) + .toBe(`http://${ENCLAVE_AGENT_API_PROXY_IP}:10002`); + }); + + it('routes non-copilot profiles to their own API-proxy port', () => { + expect(resolveEnclaveAgentApiPort('claude', 'anthropic')).toBe(10001); + expect(resolveEnclaveAgentApiPort('codex', 'openai')).toBe(10000); + expect(resolveEnclaveAgentApiPort('copilot', 'anthropic')).toBe(10002); + }); + + it('fails closed for the not-yet-proven sbx agent runtime', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { agent: { enabled: true, model: 'trusted-model', runtime: 'sbx' } }, + }); + expect(() => build({ enclaves })) + .toThrow(/sbx agent enclave capability is not yet available/); + }); + + it('refuses to wire an agent executor without the API proxy', () => { + expect(() => build({ enableApiProxy: false })) + .toThrow(/requires the API proxy/); + }); + + it('refuses to build with no executor enabled at all', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: {}, + }); + expect(() => build({ enclaves })) + .toThrow(/at least one enclave executor must be enabled/); + }); +}); + +describe('dedicated enclave agent API proxy', () => { + it('is the sole peer of the enclave network and holds the only credential', () => { + const proxy = build().agentApiProxyService as Record; + expect(proxy.container_name).toBe('awf-enclave-agent-api-proxy'); + expect(Object.keys(proxy.networks)).toEqual([ + ENCLAVE_AGENT_NETWORK, + ENCLAVE_AGENT_EGRESS_NETWORK, + ]); + expect(proxy.networks[ENCLAVE_AGENT_NETWORK]).toMatchObject({ + ipv4_address: ENCLAVE_AGENT_API_PROXY_IP, + aliases: ['awf-enclave-agent-api-proxy'], + }); + }); + + it('minimizes credentials to the configured provider route', () => { + const proxy = build().agentApiProxyService as Record; + const environment = proxy.environment as Record; + expect(environment.COPILOT_GITHUB_TOKEN).toBe('copilot-token'); + for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY']) { + expect(environment[key]).toBeUndefined(); + } + }); + + it('drops the copilot credential for a non-copilot engine route', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + agent: { enabled: true, model: 'trusted-model', engine: 'codex', profile: 'openai' }, + }, + }); + const proxy = build({ enclaves }).agentApiProxyService as Record; + const environment = proxy.environment as Record; + expect(environment.OPENAI_API_KEY).toBe('openai-key'); + expect(environment.ANTHROPIC_API_KEY).toBeUndefined(); + expect(environment.COPILOT_GITHUB_TOKEN).toBeUndefined(); + }); + + it('removes external telemetry, OIDC state, and the Squid proxy chain', () => { + const proxy = build({ + otlpEndpoints: 'https://collector.example.com', + } as Partial).agentApiProxyService as Record; + const environment = proxy.environment as Record; + for (const key of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'https_proxy', + 'GH_AW_OTLP_ENDPOINTS', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'GH_AW_OTLP_WORKLOAD_IDENTITY', + 'GITHUB_AW_OTEL_TRACE_ID', + 'GITHUB_AW_OTEL_PARENT_SPAN_ID', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + 'AWF_AUTH_ANTHROPIC_TOKEN_URL', + ]) { + expect(environment[key]).toBeUndefined(); + } + }); + + it('writes telemetry only to the enclave-private log root', () => { + const proxy = build().agentApiProxyService as Record; + expect(JSON.stringify(proxy.volumes)).toContain('awf-enclave-private-'); + expect(JSON.stringify(proxy.volumes)).toContain('api-proxy-logs'); + }); +}); + +describe('unified enclave compose topology', () => { + // generateDockerCompose materializes a chroot hosts stage under the work + // directory, so this suite needs a real one. + let composeWorkDir: string; + + beforeAll(() => { + composeWorkDir = fs.mkdtempSync(path.join(__dirname, 'awf-enclave-compose-')); + }); + + afterAll(() => { + fs.rmSync(composeWorkDir, { recursive: true, force: true }); + }); + + function composeConfig(overrides: Partial = {}): WrapperConfig { + return config({ workDir: composeWorkDir, allowedDomains: [], ...overrides } as Partial); + } + + it('creates an internal enclave network plus a proxy-only egress bridge', () => { + const compose = generateDockerCompose(composeConfig(), networkConfig); + expect(compose.networks[ENCLAVE_AGENT_NETWORK]).toMatchObject({ + name: ENCLAVE_AGENT_NETWORK, + internal: true, + ipam: { config: [{ subnet: ENCLAVE_AGENT_SUBNET }] }, + }); + expect(compose.networks[ENCLAVE_AGENT_EGRESS_NETWORK]).toMatchObject({ + name: ENCLAVE_AGENT_EGRESS_NETWORK, + driver: 'bridge', + }); + expect(compose.networks[ENCLAVE_AGENT_EGRESS_NETWORK]).not.toHaveProperty('internal'); + }); + + it('puts nothing except the dedicated proxy on the enclave network', () => { + const compose = generateDockerCompose(composeConfig(), networkConfig); + const members = Object.entries(compose.services) + .filter(([, service]) => { + const networks = (service as Record).networks; + if (!networks) return false; + return Array.isArray(networks) + ? networks.includes(ENCLAVE_AGENT_NETWORK) + : Object.keys(networks).includes(ENCLAVE_AGENT_NETWORK); + }) + .map(([name]) => name); + expect(members).toEqual(['enclave-agent-api-proxy']); + expect((compose.services['enclave-mcp-server'] as Record).network_mode) + .toBe('none'); + expect((compose.services['enclave-agent-image'] as Record).network_mode) + .toBe('none'); + }); + + it('never exposes the enclave subsystem to the primary agent in this layer', () => { + const compose = generateDockerCompose(composeConfig(), networkConfig); + const agent = compose.services.agent as unknown as Record; + expect((agent.depends_on as Record)['enclave-mcp-server']).toBeUndefined(); + expect((agent.depends_on as Record)['enclave-agent-api-proxy']) + .toBeUndefined(); + expect(JSON.stringify(agent.volumes)).not.toContain('awf-enclave-control'); + expect(JSON.stringify(agent.volumes)).not.toContain('awf-enclave-private'); + expect(JSON.stringify(agent.environment)).not.toContain('AWF_ENCLAVE'); + expect(JSON.stringify(agent.networks ?? {})).not.toContain(ENCLAVE_AGENT_NETWORK); + }); + + it('creates no enclave network when only the script executor runs', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true } }, + }); + const compose = generateDockerCompose(composeConfig({ enclaves }), networkConfig); + expect(compose.networks[ENCLAVE_AGENT_NETWORK]).toBeUndefined(); + expect(compose.services['enclave-agent-image']).toBeUndefined(); + expect(compose.services['enclave-agent-api-proxy']).toBeUndefined(); + expect(compose.services['enclave-script-image']).toBeDefined(); + }); + + it('runs both executors from one server, one socket, and one audit root', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + script: { enabled: true }, + agent: { enabled: true, model: 'trusted-model' }, + }, + }); + const compose = generateDockerCompose(composeConfig({ enclaves }), networkConfig); + const servers = Object.keys(compose.services).filter((name) => name.includes('mcp-server')); + expect(servers).toEqual(['enclave-mcp-server']); + const server = compose.services['enclave-mcp-server'] as Record; + expect(server.environment).toMatchObject({ + AWF_ENCLAVE_SCRIPT_ENABLED: 'true', + AWF_ENCLAVE_AGENT_ENABLED: 'true', + }); + expect(server.depends_on).toMatchObject({ + 'enclave-script-image': { condition: 'service_completed_successfully' }, + 'enclave-agent-image': { condition: 'service_completed_successfully' }, + 'enclave-agent-api-proxy': { condition: 'service_healthy' }, + }); + }); +}); diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts index 9c3a242cb..d46fead72 100644 --- a/src/services/enclave-mcp-service.test.ts +++ b/src/services/enclave-mcp-service.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import * as path from 'path'; import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; import { parseImageTag } from '../image-tag'; import type { WrapperConfig } from '../types'; @@ -35,7 +36,7 @@ describe('buildEnclaveMcpService', () => { it('builds a no-egress server without exposing it to the primary agent', () => { const result = buildEnclaveMcpService({ config: config(), imageConfig: ghcr }); - expect(result.scriptImageService).toMatchObject({ + expect(result.scriptImageService!).toMatchObject({ image: 'ghcr.io/github/gh-aw-firewall/enclave-script:v1', network_mode: 'none', entrypoint: ['/bin/true'], @@ -103,11 +104,23 @@ describe('buildEnclaveMcpService', () => { }); it('assembles the service without primary-agent mounts or dependency wiring', () => { - const compose = generateDockerCompose(config(), { - subnet: '172.30.0.0/24', - squidIp: '172.30.0.10', - agentIp: '172.30.0.20', - }); + // generateDockerCompose materializes a chroot hosts stage under the work + // directory, so this assertion needs a real one. + const workDir = fs.mkdtempSync(path.join(__dirname, 'awf-enclave-script-compose-')); + let compose; + try { + compose = generateDockerCompose(config({ + workDir, + agentCommand: 'echo enclave', + allowedDomains: [], + } as Partial), { + subnet: '172.30.0.0/24', + squidIp: '172.30.0.10', + agentIp: '172.30.0.20', + }); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } expect(compose.services['enclave-script-image']).toBeDefined(); expect(compose.services['enclave-mcp-server']).toBeDefined(); const agent = compose.services.agent as unknown as Record; diff --git a/src/services/enclave-mcp-service.ts b/src/services/enclave-mcp-service.ts index 7e2f43739..213a917cd 100644 --- a/src/services/enclave-mcp-service.ts +++ b/src/services/enclave-mcp-service.ts @@ -1,6 +1,12 @@ import { buildRuntimeImageRef } from '../image-tag'; import { getSafeHostGid, getSafeHostUid } from '../host-identity'; +import { + ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME, + ENCLAVE_MCP_SERVER_CONTAINER_NAME, +} from '../constants'; import type { WrapperConfig } from '../types'; +import { API_PROXY_PORTS } from '../types/ports'; +import type { EnclaveAgentEngine, EnclaveAgentProfile } from '../types/enclave-options'; import { ENCLAVE_BROKER_AUDIT_DIR, ENCLAVE_BROKER_CAPABILITY_PATH, @@ -12,70 +18,141 @@ import { ENCLAVE_BROKER_WORK_DIR, resolveEnclavePaths, } from '../enclave/paths'; +import { + ENCLAVE_AGENT_API_PROXY_ALIAS, + ENCLAVE_AGENT_API_PROXY_IP, + ENCLAVE_AGENT_EGRESS_NETWORK, + ENCLAVE_AGENT_NETWORK, +} from '../enclave/network'; import { resolveBoundedQueryPrimaryBackend } from '../bounded-query/runtime-matrix'; 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 type { ImageBuildConfig, NetworkConfig } from './squid-service'; +import { buildApiProxyServiceConfig } from './api-proxy-service-config'; +import { + ANTHROPIC_ENV, + COPILOT_ENV, + GEMINI_ENV, + OIDC_AUTH_ENV_VARS, + OPENAI_ENV, + VERTEX_ENV, +} from '../api-proxy-env-constants'; + +/** + * Compose assembly for the unified enclave MCP server and its executors. + * + * Topology, which is the whole point of the feature: + * + * - the **MCP server** runs with `network_mode: none` — no `awf-net`, no + * `awf-ext`, no agent-enclave network, no DNS, no Squid, no host gateway. + * It holds the Docker socket and the private seed/work/audit mounts, and it + * never holds a provider credential. + * - **script enclaves** run with `--network none`. + * - **agent enclaves** join *only* the dedicated `internal` + * {@link ENCLAVE_AGENT_NETWORK}. The sole other member is a dedicated + * API-proxy instance whose logs, metrics, and quota state are private to + * this subsystem. No primary agent, Squid, general proxy, MCP server, safe + * outputs, MCP gateway, or CLI proxy is on that network, and the API proxy + * is the only holder of a real credential. + * - the **primary agent** receives nothing at all in this migration layer: + * gh-aw-mcpg owns attaching the private socket in a later layer. + */ const LOCAL_ENCLAVE_SCRIPT_IMAGE = 'awf-enclave-script:local'; +const LOCAL_ENCLAVE_AGENT_IMAGE = 'awf-enclave-agent:local'; const LOCAL_ENCLAVE_MCP_SERVER_IMAGE = 'awf-enclave-mcp-server:local'; const ENCLAVE_SCRIPT_IMAGE_NAME = 'enclave-script'; +const ENCLAVE_AGENT_IMAGE_NAME = 'enclave-agent'; const ENCLAVE_MCP_SERVER_IMAGE_NAME = 'enclave-mcp-server'; interface EnclaveMcpServiceParams { config: WrapperConfig; imageConfig: ImageBuildConfig; + networkConfig?: NetworkConfig; } export interface EnclaveMcpBuildResult { - scriptImageService: Record; + /** One-shot service making the script sandbox image locally available. */ + scriptImageService?: Record; + /** One-shot service making the agent enclave image locally available. */ + agentImageService?: Record; + /** Dedicated credential sidecar for agent enclaves, when that executor runs. */ + agentApiProxyService?: Record; service: Record; } -function resolveImages(imageConfig: ImageBuildConfig, scriptImageOverride?: string): { - scriptImageRef: string; - scriptSource: Record; - serverSource: Record; -} { +function resolveServerImage(imageConfig: ImageBuildConfig): Record { if (imageConfig.useGHCR) { - const scriptImageRef = scriptImageOverride ?? buildRuntimeImageRef( + return { + image: buildRuntimeImageRef( + imageConfig.registry, + ENCLAVE_MCP_SERVER_IMAGE_NAME, + imageConfig.parsedTag, + ), + }; + } + return { + image: LOCAL_ENCLAVE_MCP_SERVER_IMAGE, + build: { + // The server drives both executors, so its build context spans + // containers/bounded-query and containers/bounded-agent. + context: `${imageConfig.projectRoot}/containers`, + dockerfile: 'bounded-query/enclave-mcp/Dockerfile', + target: 'enclave-mcp-server', + }, + }; +} + +function resolveScriptImage( + imageConfig: ImageBuildConfig, + override?: string, +): { imageRef: string; source: Record } { + if (override) return { imageRef: override, source: { image: override } }; + if (imageConfig.useGHCR) { + const imageRef = buildRuntimeImageRef( imageConfig.registry, ENCLAVE_SCRIPT_IMAGE_NAME, imageConfig.parsedTag, ); - return { - scriptImageRef, - scriptSource: { image: scriptImageRef }, - serverSource: { - image: buildRuntimeImageRef( - imageConfig.registry, - ENCLAVE_MCP_SERVER_IMAGE_NAME, - imageConfig.parsedTag, - ), - }, - }; + return { imageRef, source: { image: imageRef } }; } - const build = { - context: `${imageConfig.projectRoot}/containers/bounded-query`, - dockerfile: 'Dockerfile', - }; - if (scriptImageOverride) { - return { - scriptImageRef: scriptImageOverride, - scriptSource: { image: scriptImageOverride }, - serverSource: { - image: LOCAL_ENCLAVE_MCP_SERVER_IMAGE, - build: { ...build, target: 'enclave-mcp-server' }, + return { + imageRef: LOCAL_ENCLAVE_SCRIPT_IMAGE, + source: { + image: LOCAL_ENCLAVE_SCRIPT_IMAGE, + build: { + context: `${imageConfig.projectRoot}/containers/bounded-query`, + dockerfile: 'Dockerfile', + target: 'query', }, - }; + }, + }; +} + +function resolveAgentImage( + imageConfig: ImageBuildConfig, + override?: string, +): { imageRef: string; source: Record } { + if (override) return { imageRef: override, source: { image: override } }; + if (imageConfig.useGHCR) { + const imageRef = buildRuntimeImageRef( + imageConfig.registry, + ENCLAVE_AGENT_IMAGE_NAME, + imageConfig.parsedTag, + ); + return { imageRef, source: { image: imageRef } }; } return { - scriptImageRef: LOCAL_ENCLAVE_SCRIPT_IMAGE, - scriptSource: { image: LOCAL_ENCLAVE_SCRIPT_IMAGE, build: { ...build, target: 'query' } }, - serverSource: { - image: LOCAL_ENCLAVE_MCP_SERVER_IMAGE, - build: { ...build, target: 'enclave-mcp-server' }, + imageRef: LOCAL_ENCLAVE_AGENT_IMAGE, + source: { + image: LOCAL_ENCLAVE_AGENT_IMAGE, + build: { + // Reuses the audited native enclave image target verbatim. + context: `${imageConfig.projectRoot}/containers`, + dockerfile: 'bounded-agent/Dockerfile', + target: 'enclave', + }, }, }; } @@ -85,28 +162,192 @@ function toDaemonVisiblePath(hostPath: string, prefix: string | undefined): stri return translated.split(':')[0]; } +/** Resolves the API-proxy port the enclave's configured profile speaks to. */ +export function resolveEnclaveAgentApiPort( + engine: EnclaveAgentEngine, + profile: EnclaveAgentProfile, +): number { + if (engine === 'copilot') return API_PROXY_PORTS.COPILOT; + return profile === 'anthropic' ? API_PROXY_PORTS.ANTHROPIC : API_PROXY_PORTS.OPENAI; +} + +/** + * Builds the dedicated agent-enclave API proxy. + * + * The proxy is the only component on the enclave network that holds a real + * credential; the MCP server, the enclave itself, and the primary agent never + * do. Its environment is minimized to the single provider route the configured + * engine/profile actually uses, and every external telemetry and OIDC control + * is stripped so private-repository-derived provider traffic can never be + * exported to a third-party collector or exchanged for another identity. + */ +function buildAgentApiProxyService(params: { + config: WrapperConfig; + imageConfig: ImageBuildConfig; + networkConfig: NetworkConfig; + apiProxyLogsPath: string; + engine: EnclaveAgentEngine; + profile: EnclaveAgentProfile; +}): Record { + const service = buildApiProxyServiceConfig({ + config: params.config, + networkConfig: params.networkConfig, + apiProxyLogsPath: params.apiProxyLogsPath, + imageConfig: params.imageConfig, + }) as Record; + + service.container_name = ENCLAVE_AGENT_API_PROXY_CONTAINER_NAME; + service.networks = { + [ENCLAVE_AGENT_NETWORK]: { + ipv4_address: ENCLAVE_AGENT_API_PROXY_IP, + aliases: [ENCLAVE_AGENT_API_PROXY_ALIAS], + }, + [ENCLAVE_AGENT_EGRESS_NETWORK]: {}, + }; + + const environment = service.environment as Record; + // The dedicated proxy has direct upstream egress; it is never routed through + // Squid or the primary agent's proxy chain. + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'https_proxy']) delete environment[key]; + for (const key of [ + 'GH_AW_OTLP_ENDPOINTS', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + 'OTEL_EXPORTER_OTLP_HEADERS', + 'GH_AW_OTLP_WORKLOAD_IDENTITY', + 'GITHUB_AW_OTEL_TRACE_ID', + 'GITHUB_AW_OTEL_PARENT_SPAN_ID', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + 'AWF_AUTH_ANTHROPIC_TOKEN_URL', + ...OIDC_AUTH_ENV_VARS, + ]) { + delete environment[key]; + } + const unusedProviderCredentials = params.engine === 'copilot' + ? [OPENAI_ENV.KEY, ANTHROPIC_ENV.KEY, GEMINI_ENV.KEY, VERTEX_ENV.KEY] + : params.profile === 'openai' + ? [ANTHROPIC_ENV.KEY, COPILOT_ENV.GITHUB_TOKEN, COPILOT_ENV.PROVIDER_API_KEY, GEMINI_ENV.KEY, VERTEX_ENV.KEY] + : [OPENAI_ENV.KEY, COPILOT_ENV.GITHUB_TOKEN, COPILOT_ENV.PROVIDER_API_KEY, GEMINI_ENV.KEY, VERTEX_ENV.KEY]; + for (const key of unusedProviderCredentials) delete environment[key]; + + return service; +} + export function buildEnclaveMcpService(params: EnclaveMcpServiceParams): EnclaveMcpBuildResult { const { config, imageConfig } = params; - const script = config.enclaves?.executors.script; - if (!config.enclaves?.enabled || !script?.enabled) { - throw new Error('buildEnclaveMcpService: enclaves script executor must be enabled'); + const enclaves = config.enclaves; + const script = enclaves?.executors.script; + const agent = enclaves?.executors.agent; + if (!enclaves?.enabled || (!script?.enabled && !agent?.enabled)) { + throw new Error('buildEnclaveMcpService: at least one enclave executor must be enabled'); } - if (script.runtime === 'sbx') { + if (script?.enabled && script.runtime === 'sbx') { throw new Error('buildEnclaveMcpService: sbx script enclave capability is not yet available'); } + if (agent?.enabled && agent.runtime === 'sbx') { + throw new Error('buildEnclaveMcpService: sbx agent enclave capability is not yet available'); + } + if (agent?.enabled && !config.enableApiProxy) { + throw new Error( + 'buildEnclaveMcpService: the enclave agent executor requires the API proxy, which is the ' + + "enclave's only permitted upstream egress", + ); + } + const paths = resolveEnclavePaths(config.workDir); - const images = resolveImages(imageConfig, script.image); const dockerSocketPath = resolveDockerSocketPath(config); - const scriptImageService: Record = { - ...images.scriptSource, - network_mode: 'none', - entrypoint: ['/bin/true'], - ...buildContainerSecurityHardening({ memLimit: '32m', pidsLimit: 16, cpuShares: 64 }), - restart: 'no', + const primaryBackend = resolveBoundedQueryPrimaryBackend(config.containerRuntime); + const imageServiceHardening = { memLimit: '32m', pidsLimit: 16, cpuShares: 64 }; + + const environment: Record = { + AWF_ENCLAVE_PRIMARY_BACKEND: primaryBackend, + AWF_ENCLAVE_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), + AWF_ENCLAVE_SOCKET_UID: getSafeHostUid(), + AWF_ENCLAVE_SOCKET_GID: getSafeHostGid(), + AWF_ENCLAVE_CAPABILITY_PATH: ENCLAVE_BROKER_CAPABILITY_PATH, + AWF_ENCLAVE_SCRIPT_ENABLED: String(script?.enabled === true), + AWF_ENCLAVE_AGENT_ENABLED: String(agent?.enabled === true), }; - const service: Record = { - container_name: 'awf-enclave-mcp-server', - ...images.serverSource, + const dependsOn: Record> = {}; + const result: EnclaveMcpBuildResult = { service: {} }; + + if (script?.enabled) { + const { imageRef, source } = resolveScriptImage(imageConfig, script.image); + result.scriptImageService = { + ...source, + network_mode: 'none', + entrypoint: ['/bin/true'], + ...buildContainerSecurityHardening(imageServiceHardening), + restart: 'no', + }; + dependsOn['enclave-script-image'] = { condition: 'service_completed_successfully' }; + Object.assign(environment, { + AWF_ENCLAVE_IMAGE: imageRef, + AWF_ENCLAVE_BACKEND: script.runtime, + AWF_ENCLAVE_TIMEOUT: String(script.timeout), + AWF_ENCLAVE_MEMORY: script.memoryLimit, + AWF_ENCLAVE_CPU: script.cpuLimit, + AWF_ENCLAVE_PIDS: String(script.pidsLimit), + AWF_ENCLAVE_TMPFS: script.tmpfsLimit, + AWF_ENCLAVE_MAX_OUTPUT_BYTES: String(script.maxOutputBytes), + AWF_ENCLAVE_MAX_SCRIPT_BYTES: String(script.maxScriptBytes), + AWF_ENCLAVE_MAX_INVOCATIONS: String(script.maxInvocations), + }); + } + + if (agent?.enabled) { + if (!params.networkConfig) { + throw new Error('buildEnclaveMcpService: the enclave agent executor requires network configuration'); + } + const { imageRef, source } = resolveAgentImage(imageConfig, agent.image); + result.agentImageService = { + ...source, + network_mode: 'none', + entrypoint: ['/bin/true'], + ...buildContainerSecurityHardening(imageServiceHardening), + restart: 'no', + }; + dependsOn['enclave-agent-image'] = { condition: 'service_completed_successfully' }; + dependsOn['enclave-agent-api-proxy'] = { condition: 'service_healthy' }; + result.agentApiProxyService = buildAgentApiProxyService({ + config, + imageConfig, + networkConfig: params.networkConfig, + apiProxyLogsPath: paths.apiProxyLogsDir, + engine: agent.engine, + profile: agent.profile, + }); + const apiPort = resolveEnclaveAgentApiPort(agent.engine, agent.profile); + Object.assign(environment, { + AWF_ENCLAVE_AGENT_IMAGE: imageRef, + // The server selects a fixed EnclaveRunner from this normalized value. + // Runtime flags are never accepted from an invocation. + AWF_ENCLAVE_AGENT_BACKEND: agent.runtime, + AWF_ENCLAVE_AGENT_NETWORK: ENCLAVE_AGENT_NETWORK, + AWF_ENCLAVE_AGENT_API_ENDPOINT: `http://${ENCLAVE_AGENT_API_PROXY_IP}:${apiPort}`, + AWF_ENCLAVE_AGENT_ENGINE: agent.engine, + AWF_ENCLAVE_AGENT_PROFILE: agent.profile, + AWF_ENCLAVE_AGENT_MODEL: agent.model, + AWF_ENCLAVE_AGENT_TIMEOUT: String(agent.timeout), + AWF_ENCLAVE_AGENT_MEMORY: agent.memoryLimit, + AWF_ENCLAVE_AGENT_CPU: agent.cpuLimit, + AWF_ENCLAVE_AGENT_PIDS: String(agent.pidsLimit), + AWF_ENCLAVE_AGENT_TMPFS: agent.tmpfsLimit, + AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES: String(agent.maxOutputBytes), + AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES: String(agent.maxTaskBytes), + AWF_ENCLAVE_AGENT_MAX_INVOCATIONS: String(agent.maxInvocations), + AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS: String(agent.maxModelRequests), + AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS: String(agent.maxModelTokens), + // Enclave bind-mount sources are handed to the daemon, not opened by the + // server, so they must be daemon-visible paths. + AWF_ENCLAVE_AGENT_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), + AWF_ENCLAVE_AGENT_HOST_SEEDS_DIR: toDaemonVisiblePath(paths.seedsDir, config.dockerHostPathPrefix), + }); + } + + result.service = { + container_name: ENCLAVE_MCP_SERVER_CONTAINER_NAME, + ...resolveServerImage(imageConfig), network_mode: 'none', volumes: applyHostPathPrefixToVolumes( [ @@ -120,26 +361,8 @@ export function buildEnclaveMcpService(params: EnclaveMcpServiceParams): Enclave ], config.dockerHostPathPrefix, ), - environment: { - AWF_ENCLAVE_IMAGE: images.scriptImageRef, - AWF_ENCLAVE_BACKEND: script.runtime, - AWF_ENCLAVE_PRIMARY_BACKEND: resolveBoundedQueryPrimaryBackend(config.containerRuntime), - AWF_ENCLAVE_TIMEOUT: String(script.timeout), - AWF_ENCLAVE_MEMORY: script.memoryLimit, - AWF_ENCLAVE_CPU: script.cpuLimit, - AWF_ENCLAVE_PIDS: String(script.pidsLimit), - AWF_ENCLAVE_TMPFS: script.tmpfsLimit, - AWF_ENCLAVE_MAX_OUTPUT_BYTES: String(script.maxOutputBytes), - AWF_ENCLAVE_MAX_SCRIPT_BYTES: String(script.maxScriptBytes), - AWF_ENCLAVE_MAX_INVOCATIONS: String(script.maxInvocations), - AWF_ENCLAVE_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), - AWF_ENCLAVE_SOCKET_UID: getSafeHostUid(), - AWF_ENCLAVE_SOCKET_GID: getSafeHostGid(), - AWF_ENCLAVE_CAPABILITY_PATH: ENCLAVE_BROKER_CAPABILITY_PATH, - }, - depends_on: { - 'enclave-script-image': { condition: 'service_completed_successfully' }, - }, + environment, + depends_on: dependsOn, healthcheck: { test: ['CMD', 'node', '/opt/awf/enclave-mcp/healthcheck.js'], interval: '5s', @@ -152,14 +375,18 @@ export function buildEnclaveMcpService(params: EnclaveMcpServiceParams): Enclave restart: 'no', stop_grace_period: '5s', }; - return { scriptImageService, service }; + return result; } export const enclaveMcpServiceTestHelpers = { ENCLAVE_SCRIPT_IMAGE_NAME, + ENCLAVE_AGENT_IMAGE_NAME, ENCLAVE_MCP_SERVER_IMAGE_NAME, LOCAL_ENCLAVE_SCRIPT_IMAGE, + LOCAL_ENCLAVE_AGENT_IMAGE, LOCAL_ENCLAVE_MCP_SERVER_IMAGE, - resolveImages, + resolveAgentImage, + resolveScriptImage, + resolveServerImage, toDaemonVisiblePath, }; diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index 6b76ebaf7..ee781e9d1 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -310,13 +310,22 @@ function assembleBoundedAgentService(params: AssembleOptionalServicesParams): vo function assembleEnclaveMcpService(params: AssembleOptionalServicesParams): void { const { services, config, imageConfig } = params; - if (!config.enclaves?.enabled || !config.enclaves.executors.script.enabled) return; - const { scriptImageService, service } = buildEnclaveMcpService({ config, imageConfig }); - services['enclave-script-image'] = scriptImageService; + const executors = config.enclaves?.executors; + if (!config.enclaves?.enabled) return; + if (!executors?.script.enabled && !executors?.agent.enabled) return; + const { + scriptImageService, + agentImageService, + agentApiProxyService, + service, + } = buildEnclaveMcpService({ config, imageConfig, networkConfig: params.networkConfig }); + if (scriptImageService) services['enclave-script-image'] = scriptImageService; + if (agentImageService) services['enclave-agent-image'] = agentImageService; + if (agentApiProxyService) services['enclave-agent-api-proxy'] = agentApiProxyService; services['enclave-mcp-server'] = service; - // Layer 2 intentionally does not mount the MCP socket/capability into the - // primary agent or make agent startup depend on this service. gh-aw-mcpg owns - // that attachment in layer 4. + // This migration layer intentionally does not mount the MCP socket/capability + // into the primary agent or make agent startup depend on this service. + // gh-aw-mcpg owns that attachment in a later layer. } function finalizeSysrootVolumes(