From 81bc9d2088ceb5d6b9bbe6097f0dc0f8d6ba3931 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 6 Aug 2026 08:39:42 -0700 Subject: [PATCH 1/3] feat: add enclave MCP script executor Implement stack layer 2 with an AWF-owned authenticated MCP server, unified script ledger, hardened enclave runner, lifecycle wiring, release images, and tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 70 ++++ action.yml | 4 + containers/bounded-query/Dockerfile | 16 + .../bounded-execution/sensitivity-ledger.js | 25 +- .../bounded-execution/sensitivity-policy.js | 9 + containers/bounded-query/broker/broker.js | 44 ++- .../bounded-query/broker/query-runner-spec.js | 24 +- containers/bounded-query/broker/workspace.js | 11 +- .../bounded-query/enclave-mcp/config.js | 136 +++++++ .../bounded-query/enclave-mcp/healthcheck.js | 11 + .../bounded-query/enclave-mcp/mcp-protocol.js | 147 ++++++++ .../bounded-query/enclave-mcp/server.js | 210 +++++++++++ docs/awf-config-spec.md | 28 +- docs/enclaves-architecture.md | 56 ++- src/artifact-preservation.ts | 28 ++ src/cli-workflow.ts | 11 + src/commands/main-action.ts | 3 + src/constants.ts | 1 + src/enclave/manager.test.ts | 166 +++++++++ src/enclave/manager.ts | 217 +++++++++++ src/enclave/mcp-server.test.ts | 342 ++++++++++++++++++ src/enclave/paths.test.ts | 14 + src/enclave/paths.ts | 61 ++++ src/enclave/preflight.test.ts | 17 + src/enclave/preflight.ts | 8 + src/enclave/script-runner-spec.test.ts | 123 +++++++ src/enclave/workflow-integration.test.ts | 51 +++ src/image-tag.test.ts | 2 + src/image-tag.ts | 2 +- src/services/enclave-mcp-service.test.ts | 111 ++++++ src/services/enclave-mcp-service.ts | 165 +++++++++ src/services/optional-services.ts | 14 + 32 files changed, 2071 insertions(+), 56 deletions(-) create mode 100644 containers/bounded-query/enclave-mcp/config.js create mode 100644 containers/bounded-query/enclave-mcp/healthcheck.js create mode 100644 containers/bounded-query/enclave-mcp/mcp-protocol.js create mode 100644 containers/bounded-query/enclave-mcp/server.js create mode 100644 src/enclave/manager.test.ts create mode 100644 src/enclave/manager.ts create mode 100644 src/enclave/mcp-server.test.ts create mode 100644 src/enclave/paths.test.ts create mode 100644 src/enclave/paths.ts create mode 100644 src/enclave/script-runner-spec.test.ts create mode 100644 src/enclave/workflow-integration.test.ts create mode 100644 src/services/enclave-mcp-service.test.ts create mode 100644 src/services/enclave-mcp-service.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f391640e..135618174 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -358,6 +358,8 @@ jobs: outputs: 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_mcp_server_digest: ${{ steps.build_enclave_mcp_server.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 @@ -448,6 +450,72 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/bounded-query-broker@${{ steps.build_bounded_query_broker.outputs.digest }} + - name: Build and push Enclave Script image + id: build_enclave_script + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: ./containers/bounded-query + target: query + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/enclave-script:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/enclave-script:latest + cache-from: type=gha,scope=enclave-script + cache-to: type=gha,mode=max,scope=enclave-script + + - name: Sign Enclave Script image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/enclave-script@${{ steps.build_enclave_script.outputs.digest }} + + - name: Generate SBOM for Enclave Script image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/enclave-script@${{ steps.build_enclave_script.outputs.digest }} + format: spdx-json + output-file: enclave-script-sbom.spdx.json + + - name: Attest SBOM for Enclave Script image + run: | + cosign attest --yes \ + --predicate enclave-script-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/enclave-script@${{ steps.build_enclave_script.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 + target: enclave-mcp-server + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/enclave-mcp-server:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/enclave-mcp-server:latest + cache-from: type=gha,scope=enclave-mcp-server + cache-to: type=gha,mode=max,scope=enclave-mcp-server + + - name: Sign Enclave MCP Server image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ steps.build_enclave_mcp_server.outputs.digest }} + + - name: Generate SBOM for Enclave MCP Server image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ steps.build_enclave_mcp_server.outputs.digest }} + format: spdx-json + output-file: enclave-mcp-server-sbom.spdx.json + + - name: Attest SBOM for Enclave MCP Server image + run: | + cosign attest --yes \ + --predicate enclave-mcp-server-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ steps.build_enclave_mcp_server.outputs.digest }} + # Build the native Copilot bounded-agent enclave and its trusted broker from separate # Dockerfile targets. The build context is ./containers (not # ./containers/bounded-agent) because the broker reuses the shared @@ -890,6 +958,8 @@ jobs: "ghcr.io/${{ github.repository }}/cli-proxy@${{ needs['build-cli-proxy'].outputs.digest }}" \ "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-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 }}" \ "ghcr.io/${{ github.repository }}/gh-aw-node@${{ needs['build-gh-aw-node'].outputs.digest }}" \ diff --git a/action.yml b/action.yml index 48312217b..2958f2a99 100644 --- a/action.yml +++ b/action.yml @@ -140,12 +140,16 @@ runs: AGENT_ACT_DIGEST="$(extract_digest agent-act || true)" 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_MCP_SERVER_DIGEST="$(extract_digest enclave-mcp-server || true)" [ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}") [ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}") [ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}") [ -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_MCP_SERVER_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-mcp-server=${ENCLAVE_MCP_SERVER_DIGEST}") if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then DIGEST_CSV="$(IFS=,; echo "${DIGEST_ENTRIES[*]}")" diff --git a/containers/bounded-query/Dockerfile b/containers/bounded-query/Dockerfile index d4896c243..3fbe45bc8 100644 --- a/containers/bounded-query/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -79,3 +79,19 @@ RUN mkdir -p /srv/awf/seeds /srv/awf/work /run/awf-bounded-query /run/awf-bounde 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"] diff --git a/containers/bounded-query/bounded-execution/sensitivity-ledger.js b/containers/bounded-query/bounded-execution/sensitivity-ledger.js index 791bdb727..678cb7532 100644 --- a/containers/bounded-query/bounded-execution/sensitivity-ledger.js +++ b/containers/bounded-query/bounded-execution/sensitivity-ledger.js @@ -1,6 +1,6 @@ 'use strict'; -const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity-policy'); +const { ENCLAVE_INFORMATION_BUDGET_POLICY } = require('./sensitivity-policy'); /** * Per-repository information-budget ledger. @@ -24,10 +24,10 @@ const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity-policy'); * @param seeds `Map` as returned * by `config.loadSeedMap`. */ -function createLedger(seeds) { +function createLedger(seeds, policy = ENCLAVE_INFORMATION_BUDGET_POLICY) { const remaining = new Map(); for (const [repoKey, seed] of seeds) { - remaining.set(repoKey, BOUNDED_QUERY_SENSITIVITY_RUN_BITS[seed.sensitivity]); + remaining.set(repoKey.toLowerCase(), policy.runBits[seed.sensitivity]); } return { @@ -38,20 +38,27 @@ function createLedger(seeds) { * to call synchronously with no intervening `await` — Node's * single-threaded event loop makes this indivisible. */ - tryDebit(repoKey, bits) { - if (!remaining.has(repoKey)) return false; - const current = remaining.get(repoKey); + tryDebit(repoKey, bits, executorKind = 'script') { + if (executorKind !== 'script' && executorKind !== 'agent') return false; + if (!Number.isSafeInteger(bits) || bits < 0) return false; + const normalizedRepoKey = repoKey.toLowerCase(); + if (!remaining.has(normalizedRepoKey)) return false; + const current = remaining.get(normalizedRepoKey); if (current === null) return true; // unmetered (public) if (bits > current) return false; - remaining.set(repoKey, current - bits); + remaining.set(normalizedRepoKey, current - bits); return true; }, /** Returns the remaining balance for a repo, or `undefined` if unknown. */ remainingBits(repoKey) { - return remaining.get(repoKey); + return remaining.get(repoKey.toLowerCase()); }, }; } -module.exports = { createLedger, createSensitivityLedger: createLedger }; +module.exports = { + createEnclaveInformationBudgetLedger: createLedger, + createLedger, + createSensitivityLedger: createLedger, +}; diff --git a/containers/bounded-query/bounded-execution/sensitivity-policy.js b/containers/bounded-query/bounded-execution/sensitivity-policy.js index 476baa8a4..51cf30ee1 100644 --- a/containers/bounded-query/bounded-execution/sensitivity-policy.js +++ b/containers/bounded-query/bounded-execution/sensitivity-policy.js @@ -25,7 +25,16 @@ const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = { sealed: 0, }; +const ENCLAVE_SENSITIVITIES = BOUNDED_QUERY_SENSITIVITIES; +const ENCLAVE_SENSITIVITY_RUN_BITS = BOUNDED_QUERY_SENSITIVITY_RUN_BITS; +const ENCLAVE_INFORMATION_BUDGET_POLICY = Object.freeze({ + runBits: ENCLAVE_SENSITIVITY_RUN_BITS, +}); + module.exports = { + ENCLAVE_INFORMATION_BUDGET_POLICY, + ENCLAVE_SENSITIVITIES, + ENCLAVE_SENSITIVITY_RUN_BITS, BOUNDED_QUERY_SENSITIVITIES, BOUNDED_QUERY_SENSITIVITY_RUN_BITS, SENSITIVITY_LEVELS: BOUNDED_QUERY_SENSITIVITIES, diff --git a/containers/bounded-query/broker/broker.js b/containers/bounded-query/broker/broker.js index fe7d1affb..4050aa1d4 100644 --- a/containers/bounded-query/broker/broker.js +++ b/containers/bounded-query/broker/broker.js @@ -57,6 +57,11 @@ function createBroker(params) { const clock = params.clock || createRealClock(); const ledger = params.ledger || createLedger(seedMap); const telemetry = params.telemetry || { emit() {} }; + const executorKind = params.executorKind || 'script'; + const uniformTiming = params.uniformTiming === true; + if (executorKind !== 'script' && executorKind !== 'agent') { + throw new Error('createBroker requires a known executor kind'); + } let invocationsUsed = 0; let tail = Promise.resolve(); @@ -81,18 +86,25 @@ function createBroker(params) { */ async function execute(request, respond) { const invocationId = crypto.randomBytes(12).toString('hex'); + const admissionStartMs = uniformTiming ? clock.nowMs() : undefined; let responded = false; const safeRespond = (json) => { if (responded) return; responded = true; respond(json); }; + const rejectBeforeExecution = async (reason, detail, telemetryCategory = reason) => { + audit.failure(invocationId, reason, detail); + emitQueryTelemetry(telemetryCategory); + if (admissionStartMs !== undefined) { + await waitForBucket(admissionStartMs, clock.nowMs() - admissionStartMs, clock); + } + safeRespond(CANONICAL_ERROR_JSON); + }; const validation = validateBoundedQueryRequest(request); if (!validation.valid) { - audit.failure(invocationId, 'invalid-request', validation.errors.join('; ')); - emitQueryTelemetry('invalid-request'); - safeRespond(CANONICAL_ERROR_JSON); + await rejectBeforeExecution('invalid-request', validation.errors.join('; ')); return; } const { privateRepo, schema, script } = validation.request; @@ -100,9 +112,7 @@ function createBroker(params) { const seed = seedMap.get(repoKey); if (!seed) { - audit.failure(invocationId, 'repo-not-allowed', privateRepo); - emitQueryTelemetry('repo-not-allowed'); - safeRespond(CANONICAL_ERROR_JSON); + await rejectBeforeExecution('repo-not-allowed', privateRepo); return; } @@ -111,10 +121,8 @@ function createBroker(params) { // different schema; there is no separate per-query cap — only whether // this charge fits the repository's remaining run balance. const charge = queryBitsForSchema(schema); - if (!ledger.tryDebit(repoKey, charge)) { - audit.failure(invocationId, 'bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`); - emitQueryTelemetry('bit-budget-exhausted'); - safeRespond(CANONICAL_ERROR_JSON); + if (!ledger.tryDebit(repoKey, charge, executorKind)) { + await rejectBeforeExecution('bit-budget-exhausted', `repo=${privateRepo} charge=${charge}`); return; } @@ -122,7 +130,7 @@ function createBroker(params) { // response must be time-bucketed: workspace creation and query // execution both run against secret repository content, so their // latency alone is a signal. - const startMs = clock.nowMs(); + const startMs = admissionStartMs ?? clock.nowMs(); let layout; let failureReason; @@ -151,7 +159,7 @@ function createBroker(params) { } else if (run.exitCode !== 0) { failureReason = ['non-zero-exit', `exit=${run.exitCode}`]; } else { - const raw = workspace.readQueryOutput(layout.outPath); + const raw = workspace.readQueryOutput(layout.outPath, config.maxOutputBytes); if (raw === undefined) { // Covers a missing file, an oversized file, invalid UTF-8, and // any non-regular replacement (symlink/FIFO/device/socket). @@ -257,6 +265,18 @@ function createBroker(params) { if (invocationsUsed >= config.maxInvocations) { audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`); emitQueryTelemetry('invocation-count-exhausted'); + if (uniformTiming) { + const startMs = clock.nowMs(); + const queued = tail.then(async () => { + await waitForBucket(startMs, clock.nowMs() - startMs, clock); + safeRespond(CANONICAL_ERROR_JSON); + }); + tail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } safeRespond(CANONICAL_ERROR_JSON); return Promise.resolve(); } diff --git a/containers/bounded-query/broker/query-runner-spec.js b/containers/bounded-query/broker/query-runner-spec.js index 9e44f8ab5..81e54f7b8 100644 --- a/containers/bounded-query/broker/query-runner-spec.js +++ b/containers/bounded-query/broker/query-runner-spec.js @@ -11,6 +11,8 @@ const QUERY_WORKSPACE_TMPFS_BYTES = 1024 * 1024 * 1024; const RUN_LABEL = 'awf.bounded-query.run'; const INVOCATION_LABEL = 'awf.bounded-query.invocation'; +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. */ @@ -39,10 +41,16 @@ function deriveQueryContainerSpec({ config, runId, invocationId, runtimeName }) throw new Error(`Unsupported OCI runtime in query runner: ${runtimeName}`); } - const containerName = `awf-query-${runId.slice(0, 12)}-${invocationId}`; + const runLabelKey = config.runLabelKey || RUN_LABEL; + const invocationLabelKey = config.invocationLabelKey || INVOCATION_LABEL; + const containerPrefix = config.containerPrefix || 'awf-query'; + const containerName = `${containerPrefix}-${runId.slice(0, 12)}-${invocationId}`; const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`; - const runLabel = `${RUN_LABEL}=${runId}`; - const invocationLabel = `${INVOCATION_LABEL}=${invocationId}`; + const runLabel = `${runLabelKey}=${runId}`; + const invocationLabel = `${invocationLabelKey}=${invocationId}`; + const cpuLimit = config.cpuLimit || '1'; + const pidsLimit = config.pidsLimit || 128; + const tmpfsLimit = config.tmpfsLimit; const launchArgs = [ 'run', '--pull', 'never', @@ -57,12 +65,12 @@ function deriveQueryContainerSpec({ config, runId, invocationId, runtimeName }) '--security-opt', `seccomp=${config.querySeccompPath}`, '--memory', config.memoryLimit, '--memory-swap', config.memoryLimit, - '--cpus', '1', - '--pids-limit', '128', + '--cpus', cpuLimit, + '--pids-limit', String(pidsLimit), '--ulimit', `fsize=${QUERY_MAX_FILE_BYTES}`, '--ulimit', 'nofile=1024:1024', - '--tmpfs', '/tmp:rw,noexec,nosuid,nodev,size=16m', - '--tmpfs', `/query:rw,nosuid,nodev,size=${QUERY_WORKSPACE_TMPFS_BYTES},uid=${config.queryUid},gid=${config.queryGid},mode=0700`, + '--tmpfs', `/tmp:rw,noexec,nosuid,nodev,size=${tmpfsLimit || '16m'}`, + '--tmpfs', `/query:rw,nosuid,nodev,size=${tmpfsLimit || QUERY_WORKSPACE_TMPFS_BYTES},uid=${config.queryUid},gid=${config.queryGid},mode=0700`, '--hostname', 'query', '--workdir', config.queryMountDir, '--env', 'HOME=/tmp', @@ -105,6 +113,8 @@ function buildRemoveArgs(containerIds) { module.exports = { CLI_GRACE_MS, + ENCLAVE_INVOCATION_LABEL, + ENCLAVE_RUN_LABEL, INVOCATION_LABEL, QUERY_MAX_FILE_BYTES, QUERY_WORKSPACE_TMPFS_BYTES, diff --git a/containers/bounded-query/broker/workspace.js b/containers/bounded-query/broker/workspace.js index 16c80d99f..53ded94a6 100644 --- a/containers/bounded-query/broker/workspace.js +++ b/containers/bounded-query/broker/workspace.js @@ -119,7 +119,10 @@ function createInvocationWorkspace(params) { * FIFO, device, or socket. Anything unexpected returns `undefined`, which the * caller maps to the canonical error result. */ -function readQueryOutput(outPath) { +function readQueryOutput(outPath, maxResultBytes = MAX_RESULT_BYTES) { + if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < 1 || maxResultBytes > MAX_RESULT_BYTES) { + return undefined; + } let fd; try { fd = fs.openSync(outPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); @@ -130,10 +133,10 @@ function readQueryOutput(outPath) { try { const stat = fs.fstatSync(fd); if (!stat.isFile()) return undefined; - if (stat.size > MAX_RESULT_BYTES) return undefined; + if (stat.size > maxResultBytes) return undefined; - const buffer = Buffer.alloc(MAX_RESULT_BYTES); - const bytesRead = fs.readSync(fd, buffer, 0, MAX_RESULT_BYTES, 0); + const buffer = Buffer.alloc(maxResultBytes); + const bytesRead = fs.readSync(fd, buffer, 0, maxResultBytes, 0); const slice = buffer.subarray(0, bytesRead); // Reject anything that is not valid UTF-8 before it reaches the parser. diff --git a/containers/bounded-query/enclave-mcp/config.js b/containers/bounded-query/enclave-mcp/config.js new file mode 100644 index 000000000..83e830d51 --- /dev/null +++ b/containers/bounded-query/enclave-mcp/config.js @@ -0,0 +1,136 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + MAX_QUERY_TIMEOUT_SECONDS, + MAX_RESULT_BYTES, + MAX_SCRIPT_BYTES, +} = require('../bounded-execution/finite-disclosure'); +const { ENCLAVE_SENSITIVITY_RUN_BITS } = require('../bounded-execution/sensitivity-policy'); +const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging'); +const { + ENCLAVE_INVOCATION_LABEL, + ENCLAVE_RUN_LABEL, +} = require('../broker/query-runner-spec'); + +const SEEDS_DIR = '/srv/awf/seeds'; +const WORK_DIR = '/srv/awf/work'; +const SEED_MAP_PATH = '/srv/awf/seed-map.json'; +const SOCKET_DIR = '/run/awf-enclave-mcp'; +const CAPABILITY_PATH = path.join(SOCKET_DIR, 'auth-token'); +const CONTROL_DIR = '/run/awf-enclave-mcp-control'; +const AUDIT_DIR = '/var/log/awf-enclave'; +const READY_PATH = path.join(CONTROL_DIR, 'server.ready'); + +function requireEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +function positiveInt(name, fallback, maximum = Number.MAX_SAFE_INTEGER) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new Error(`${name} must be an integer between 1 and ${maximum}`); + } + return value; +} + +function nonnegativeInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} + +function dockerSize(name, fallback) { + const value = process.env[name] || fallback; + if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(value)) { + throw new Error(`${name} must be a Docker size such as 64m`); + } + return value.toLowerCase(); +} + +function loadConfig(files = fs) { + const queryBackend = requireEnv('AWF_ENCLAVE_BACKEND'); + if (queryBackend !== 'docker' && queryBackend !== 'gvisor') { + throw new Error('AWF_ENCLAVE_BACKEND must be docker or gvisor'); + } + const primaryBackend = requireEnv('AWF_ENCLAVE_PRIMARY_BACKEND'); + if (primaryBackend !== 'docker' && primaryBackend !== 'gvisor' && primaryBackend !== 'sbx') { + throw new Error('AWF_ENCLAVE_PRIMARY_BACKEND is unsupported'); + } + const cpuLimit = process.env.AWF_ENCLAVE_CPU || '1'; + if (!/^(?:[0-9]{1,2})(?:\.[0-9]{1,3})?$/.test(cpuLimit) || Number(cpuLimit) <= 0) { + throw new Error('AWF_ENCLAVE_CPU must be a positive decimal'); + } + const timeoutSeconds = positiveInt( + 'AWF_ENCLAVE_TIMEOUT', + 30, + MAX_QUERY_TIMEOUT_SECONDS, + ); + 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 { + seedsDir: SEEDS_DIR, + workDir: WORK_DIR, + seedMapPath: SEED_MAP_PATH, + hostWorkDir: requireEnv('AWF_ENCLAVE_HOST_WORK_DIR'), + socketDir: SOCKET_DIR, + socketPath: path.join(SOCKET_DIR, 'server.sock'), + controlDir: CONTROL_DIR, + readyPath: READY_PATH, + auditDir: AUDIT_DIR, + querySeccompPath: '/opt/awf/query-seccomp.json', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + queryUid: 65534, + queryGid: 65534, + queryImage: requireEnv('AWF_ENCLAVE_IMAGE'), + queryBackend, + primaryBackend, + timeoutSeconds, + maxInvocations: positiveInt('AWF_ENCLAVE_MAX_INVOCATIONS', 32), + memoryLimit: dockerSize('AWF_ENCLAVE_MEMORY', '512m'), + cpuLimit, + pidsLimit: positiveInt('AWF_ENCLAVE_PIDS', 128), + tmpfsLimit: dockerSize('AWF_ENCLAVE_TMPFS', '64m'), + maxOutputBytes: positiveInt('AWF_ENCLAVE_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES), + maxScriptBytes: positiveInt('AWF_ENCLAVE_MAX_SCRIPT_BYTES', MAX_SCRIPT_BYTES, MAX_SCRIPT_BYTES), + socketUid: nonnegativeInt('AWF_ENCLAVE_SOCKET_UID', 0), + socketGid: nonnegativeInt('AWF_ENCLAVE_SOCKET_GID', 0), + capability, + runLabelKey: ENCLAVE_RUN_LABEL, + invocationLabelKey: ENCLAVE_INVOCATION_LABEL, + containerPrefix: 'awf-enclave-script', + }; +} + +function loadSeedMap(seedMapPath) { + return parsePrivateRepositorySeedMap( + fs.readFileSync(seedMapPath, 'utf8'), + ENCLAVE_SENSITIVITY_RUN_BITS, + ); +} + +module.exports = { + AUDIT_DIR, + CAPABILITY_PATH, + CONTROL_DIR, + READY_PATH, + SEED_MAP_PATH, + SEEDS_DIR, + SOCKET_DIR, + WORK_DIR, + loadConfig, + loadSeedMap, +}; diff --git a/containers/bounded-query/enclave-mcp/healthcheck.js b/containers/bounded-query/enclave-mcp/healthcheck.js new file mode 100644 index 000000000..9113cbf3c --- /dev/null +++ b/containers/bounded-query/enclave-mcp/healthcheck.js @@ -0,0 +1,11 @@ +'use strict'; + +const fs = require('fs'); +const { READY_PATH } = require('./config'); + +try { + fs.accessSync(READY_PATH, fs.constants.F_OK); + process.exit(0); +} catch { + process.exit(1); +} diff --git a/containers/bounded-query/enclave-mcp/mcp-protocol.js b/containers/bounded-query/enclave-mcp/mcp-protocol.js new file mode 100644 index 000000000..f19d27381 --- /dev/null +++ b/containers/bounded-query/enclave-mcp/mcp-protocol.js @@ -0,0 +1,147 @@ +'use strict'; + +const { + MAX_SCRIPT_BYTES, + MAX_SCHEMA_BYTES, + strictParseJson, +} = require('../bounded-execution/finite-disclosure'); + +const MCP_PROTOCOL_VERSION = '2025-06-18'; +const TOOL_NAME = 'enclave_run_script'; +const JSONRPC_ERROR = Object.freeze({ status: 'error' }); + +const FINITE_SCHEMA_INPUT = Object.freeze({ + type: 'object', + description: 'An AWF finite-disclosure schema (const, boolean, enum, integer, object, tuple, array, or union).', +}); + +const TOOL = Object.freeze({ + name: TOOL_NAME, + description: 'Run a bounded script 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, + script: Object.freeze({ type: 'string', description: 'Bounded UTF-8 Python source.' }), + }), + required: Object.freeze(['privateRepo', 'schema', 'script']), + 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, + }), +}); + +const TOOLS_LIST_RESULT = Object.freeze({ tools: Object.freeze([TOOL]) }); + +function rpcError(id, code, message) { + return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; +} + +function rpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +function hasOnlyKeys(value, allowed) { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && Object.keys(value).every((key) => allowed.has(key)) + ); +} + +function brokerCall(broker, request) { + return new Promise((resolve) => { + broker.handle(request, (canonicalJson) => { + const parsed = strictParseJson(canonicalJson); + if (!parsed || !parsed.value || parsed.value.status !== 'ok') { + resolve({ + content: [{ type: 'text', text: '{"status":"error"}' }], + structuredContent: JSONRPC_ERROR, + }); + return; + } + resolve({ + content: [{ type: 'text', text: canonicalJson }], + structuredContent: { + status: 'ok', + result: parsed.value.result, + }, + }); + }); + }); +} + +async function dispatchJsonRpc(message, deps) { + if (!hasOnlyKeys(message, new Set(['jsonrpc', 'id', 'method', 'params'])) + || message.jsonrpc !== '2.0' + || typeof message.method !== 'string' + || (!Object.prototype.hasOwnProperty.call(message, 'id') && message.method !== 'notifications/initialized')) { + return rpcError(message && message.id, -32600, 'Invalid Request'); + } + + if (message.method === 'notifications/initialized') { + if (Object.prototype.hasOwnProperty.call(message, 'id')) { + return rpcError(message.id, -32600, 'Invalid Request'); + } + return undefined; + } + + if (message.method === 'initialize') { + return rpcResult(message.id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'awf-enclave', version: '1.0.0' }, + }); + } + + if (message.method === 'tools/list') { + if (message.params !== undefined && !hasOnlyKeys(message.params, new Set())) { + return rpcError(message.id, -32602, 'Invalid params'); + } + return rpcResult(message.id, TOOLS_LIST_RESULT); + } + + if (message.method === 'tools/call') { + if (!hasOnlyKeys(message.params, new Set(['name', 'arguments'])) + || message.params.name !== TOOL_NAME + || !Object.prototype.hasOwnProperty.call(message.params, 'arguments')) { + return rpcError(message.id, -32602, 'Invalid params'); + } + const args = message.params.arguments; + const tooLarge = ( + args + && typeof args.script === 'string' + && Buffer.byteLength(args.script, 'utf8') > deps.maxScriptBytes + ); + const request = tooLarge ? undefined : args; + return rpcResult(message.id, await brokerCall(deps.broker, request)); + } + + return rpcError(message.id, -32601, 'Method not found'); +} + +function parseJsonRpcBody(buffer) { + const text = buffer.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(buffer)) return undefined; + if (Buffer.byteLength(text, 'utf8') > ((MAX_SCRIPT_BYTES + MAX_SCHEMA_BYTES) * 6) + 4096) return undefined; + const parsed = strictParseJson(text); + return parsed && parsed.value; +} + +module.exports = { + MCP_PROTOCOL_VERSION, + TOOL, + TOOL_NAME, + TOOLS_LIST_RESULT, + dispatchJsonRpc, + parseJsonRpcBody, +}; diff --git a/containers/bounded-query/enclave-mcp/server.js b/containers/bounded-query/enclave-mcp/server.js new file mode 100644 index 000000000..2cca7b51a --- /dev/null +++ b/containers/bounded-query/enclave-mcp/server.js @@ -0,0 +1,210 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const http = require('http'); +const { createProtectedAuditLog } = require('../bounded-execution/protected-audit'); +const { createEnclaveInformationBudgetLedger } = require('../bounded-execution/sensitivity-ledger'); +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 MAX_HTTP_BODY_BYTES = 420 * 1024; +const RESPONSE_HEADERS = { + 'content-type': 'application/json', + 'cache-control': 'no-store', +}; + +function jsonResponse(res, statusCode, value) { + const body = JSON.stringify(value); + res.writeHead(statusCode, { ...RESPONSE_HEADERS, 'content-length': Buffer.byteLength(body) }); + res.end(body); +} + +function safeCapabilityEquals(header, capability) { + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; + const actual = Buffer.from(header.slice(7), 'utf8'); + const expected = Buffer.from(capability, 'utf8'); + return actual.length === expected.length && crypto.timingSafeEqual(actual, expected); +} + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + let size = 0; + let done = false; + const finish = (value) => { + if (done) return; + done = true; + resolve(value); + }; + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_HTTP_BODY_BYTES) { + req.resume(); + finish(undefined); + } else { + chunks.push(chunk); + } + }); + req.on('end', () => finish(Buffer.concat(chunks))); + req.on('error', () => finish(undefined)); + }); +} + +function createMcpServer(deps) { + const server = http.createServer({ maxHeaderSize: 8 * 1024 }, async (req, res) => { + const authorizationHeaders = req.rawHeaders.filter( + (_value, index) => index % 2 === 0 && req.rawHeaders[index].toLowerCase() === 'authorization', + ); + if (authorizationHeaders.length !== 1 + || !safeCapabilityEquals(req.headers.authorization, deps.capability)) { + req.resume(); + jsonResponse(res, 401, { + jsonrpc: '2.0', + id: null, + error: { code: -32001, message: 'Unauthorized' }, + }); + return; + } + if (req.method !== 'POST' || req.url !== '/mcp') { + req.resume(); + jsonResponse(res, 404, { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' }, + }); + return; + } + + const body = await readBody(req); + const message = body && parseJsonRpcBody(body); + if (!message) { + jsonResponse(res, 400, { + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }); + return; + } + + const response = await dispatchJsonRpc(message, deps); + if (response === undefined) { + res.writeHead(202, { 'cache-control': 'no-store', 'content-length': '0' }); + res.end(); + return; + } + jsonResponse(res, 200, response); + }); + server.headersTimeout = 5_000; + server.requestTimeout = 10_000; + server.keepAliveTimeout = 1_000; + server.maxRequestsPerSocket = 1; + return server; +} + +function listenOnSocket(server, config) { + fs.rmSync(config.socketPath, { force: true }); + fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o700 }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.socketPath, () => { + try { + fs.chownSync(config.socketPath, config.socketUid, config.socketGid); + fs.chmodSync(config.socketPath, 0o660); + resolve(); + } catch (error) { + reject(error); + } + }); + }); +} + +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); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.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, + }); + await listenOnSocket(server, config); + fs.mkdirSync(config.controlDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(config.readyPath, '', { mode: 0o600 }); + audit.lifecycle('listening', { executor: 'script' }); + + let stopping = false; + const shutdown = async () => { + if (stopping) return; + stopping = true; + broker.close(); + server.close(); + try { + await broker.drain(); + await runner.reconcileRun(runId); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'success', + }); + fs.rmSync(config.readyPath, { force: true }); + process.exit(0); + } catch (error) { + audit.lifecycle('shutdown-cleanup-failed', error.message); + telemetry.emit({ + primaryBackend: config.primaryBackend, + queryBackend: config.queryBackend, + lifecycleClass: 'cleanup', + capabilityState: 'supported', + category: 'cleanup-failed', + }); + process.exit(1); + } + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`[awf-enclave] server failed to start: ${error.message}\n`); + process.exit(1); + }); +} + +module.exports = { + MAX_HTTP_BODY_BYTES, + createMcpServer, + listenOnSocket, + safeCapabilityEquals, +}; diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index bd5c74469..39d5b76c4 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2439,13 +2439,14 @@ can answer the question. bounded queries; unlike a bounded query it does have a network interface, to the API proxy only. -## 16. Unified Enclaves (Migration Foundation) +## 16. Unified Enclaves The optional `enclaves` object is the successor configuration model for bounded -private-repository execution. In this foundation release it is parsed, -normalized, and validated but does not create a runtime service or primary-agent -surface. See [Unified Enclave Architecture and Migration](enclaves-architecture.md) -for the target trust boundaries and rollout sequence. +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 +[Unified Enclave Architecture and Migration](enclaves-architecture.md). `enclaves.privateRepos` is the single trusted repository list for every executor. Each entry has the same `public`, `internal`, `confidential`, or @@ -2460,10 +2461,16 @@ 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. -Future invocation protocols MUST reject those controls, including unknown -aliases for them. An enabled agent executor requires a configured model. +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. When `enclaves.enabled` is `true`, at least one executor and one repository are required. `boundedQueries.enabled` or `boundedAgents.enabled` MUST NOT also be @@ -2471,9 +2478,10 @@ 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 foundation does not combine the existing live broker ledgers. Shared-budget -runtime enforcement begins only when the AWF-owned enclave MCP server replaces -both direct brokers in a later migration layer. +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. ## Normative References diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md index 3d8b32e70..455c3e464 100644 --- a/docs/enclaves-architecture.md +++ b/docs/enclaves-architecture.md @@ -2,9 +2,9 @@ ## Status -Foundation accepted for staged migration. This document describes the target -architecture; the first implementation layer adds configuration and shared -contracts without changing either legacy runtime. +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. ## Decision @@ -54,6 +54,34 @@ primary agent learns; it does not bound what the provider sees. ## Startup and readiness +The script service is an offline Compose service. AWF stages immutable seeds and +creates a run-unique private root before Compose generation. Compose pre-pulls or +builds the script image, then starts the MCP server with `network_mode: none`. +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: + +```text +enclave_run_script({ + privateRepo: "owner/repo", + schema: , + script: +}) +``` + +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`. + +Executor outcomes return successful JSON-RPC tool results whose +`structuredContent` is exactly canonical `{"status":"ok","result":...}` or +`{"status":"error"}`. Secret-dependent failures never use JSON-RPC errors or +`isError`. Cleanup remains inside the fixed timing bucket. + `gh-aw-mcpg` startup may precede AWF's enclave server startup. The configured MCP server connection timeout and retry policy are the synchronization mechanism; neither component may silently downgrade or bypass the gateway while waiting. @@ -70,27 +98,29 @@ fails the run before repository staging is exposed or the primary agent starts. ## Migration sequence -1. **Foundation (this layer).** Add strict `enclaves` config, neutral finite +1. **Foundation.** Add strict `enclaves` config, neutral finite 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 MCP server.** Implement the server over the shared contracts, - retaining trusted executor launchers behind adapters. Add authenticated local - transport and readiness proof; do not expose direct broker ingress. -3. **`gh-aw-mcpg` integration.** Register and guard the AWF-owned server, wire +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. +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. -4. **Runtime cutover.** Move staging, auditing, timing, and the shared ledger to +5. **Runtime cutover.** Move all callers to the unified MCP surface and the unified server. Remove direct `bounded-query` and `bounded-agent` agent surfaces after parity tests demonstrate canonical response and isolation equivalence. -5. **Legacy removal.** Remove `boundedQueries`, `boundedAgents`, their brokers, +6. **Legacy removal.** Remove `boundedQueries`, `boundedAgents`, their brokers, compatibility exports, images, docs, and tests only after the unified path is the sole supported runtime. ## Compatibility -This foundation layer is behavior-preserving. It does not launch an MCP server, -change primary-agent mounts or environment, combine live broker ledgers, or +This layer does not change primary-agent mounts or environment and does not alter legacy protocol bytes. Existing `boundedQueries` and `boundedAgents` -configurations continue to run as before. +configurations continue to run as before. Unified and legacy configurations +remain mutually exclusive and fail closed before staging. diff --git a/src/artifact-preservation.ts b/src/artifact-preservation.ts index 4cbf2ac5e..fd4a759bd 100644 --- a/src/artifact-preservation.ts +++ b/src/artifact-preservation.ts @@ -10,6 +10,7 @@ import { import { getLocalDockerEnv } from './host-env'; import { resolveBoundedQueryPaths } from './bounded-query/paths'; import { resolveBoundedAgentPaths } from './bounded-agent/paths'; +import { resolveEnclavePaths } from './enclave/paths'; const BOUNDED_QUERY_AUDIT_FILES = [ 'bounded-query.jsonl', @@ -21,6 +22,10 @@ const BOUNDED_AGENT_AUDIT_FILES = [ { source: 'runtime-telemetry.jsonl', destination: 'bounded-agent-runtime.jsonl' }, ] as const; const BOUNDED_AGENT_SESSION_DIR = 'sessions'; +const ENCLAVE_AUDIT_FILES = [ + { source: 'enclave.jsonl', destination: 'enclave.jsonl' }, + { source: 'runtime-telemetry.jsonl', destination: 'enclave-runtime.jsonl' }, +] as const; /** * Copies the iptables audit dump from the init-signal volume to the audit directory. @@ -31,6 +36,7 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void const iptablesAuditSrc = path.join(workDir, 'init-signal', 'iptables-audit.txt'); const boundedQueryRoot = resolveBoundedQueryPaths(workDir).root; const boundedAgentRoot = resolveBoundedAgentPaths(workDir).root; + const enclaveRoot = resolveEnclavePaths(workDir).root; const targetAuditDir = auditDir || path.join(workDir, 'audit'); if (!fs.existsSync(targetAuditDir)) return; @@ -83,6 +89,7 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void } catch (error) { logger.debug(`Could not copy bounded-agent ${auditFile.source}:`, error); } + } try { const destination = path.join(targetAuditDir, 'bounded-agent-sessions'); @@ -104,6 +111,27 @@ export function preserveIptablesAudit(workDir: string, auditDir?: string): void logger.debug('Could not copy bounded-agent sessions:', error); } } + + 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 destination = path.join(targetAuditDir, auditFile.destination); + const result = execa.sync( + 'docker', + ['cp', source, destination], + { env: getLocalDockerEnv(), reject: false }, + ); + if (result.exitCode === 0) { + logger.debug(`Copied enclave MCP server ${auditFile.source} to audit directory`); + } else { + logger.debug(`Could not copy enclave ${auditFile.source}:`, result.stderr); + } + } catch (error) { + logger.debug(`Could not copy enclave ${auditFile.source}:`, error); + } + } + } } type PreserveDirectoryOptions = { diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 891441d73..ffaa9ea04 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -48,6 +48,8 @@ interface WorkflowDependencies { * anything. */ prepareBoundedAgents?: (config: WrapperConfig) => Promise; + /** Trusted unified enclave preflight and staging. */ + prepareEnclaves?: (config: WrapperConfig) => Promise; /** * Fail-stop preflight for network-isolation mode. Aborts (process exit) when * topology enforcement cannot be supported on the current platform. @@ -120,10 +122,19 @@ export async function runMainWorkflow( 'Bounded agents are enabled but no staging implementation was provided to runMainWorkflow', ); } + logger.info('Staging bounded-agent repository seeds...'); await dependencies.prepareBoundedAgents(config); } + if (config.enclaves?.enabled) { + if (!dependencies.prepareEnclaves) { + throw new Error('Enclaves are enabled but no staging implementation was provided to runMainWorkflow'); + } + logger.info('Staging enclave repository seeds...'); + await dependencies.prepareEnclaves(config); + } + // Step 0: Setup host-level network and iptables // // In network-isolation (topology) mode, egress is enforced purely by Docker diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 197b91c15..6eb6761de 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -38,6 +38,7 @@ import { SBX_DEFAULT_NAME, } from '../sbx-manager'; import { prepareBoundedQueries, teardownBoundedQueries } from '../bounded-query/manager'; +import { prepareEnclaves, teardownEnclaves } from '../enclave/manager'; import { prepareBoundedAgents, reportBoundedAgentSbxIngressResult, @@ -155,6 +156,7 @@ function buildCleanupFn( // directory whose write bit was stripped during staging. await teardownBoundedQueries(config); await teardownBoundedAgents(config); + await teardownEnclaves(config); if (!config.keepContainers) { await cleanup( @@ -578,6 +580,7 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { connectTopologyContainers, prepareBoundedQueries, prepareBoundedAgents, + prepareEnclaves, }, { logger, diff --git a/src/constants.ts b/src/constants.ts index a8db26957..403a3710d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,7 @@ export const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; 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'; // 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/manager.test.ts b/src/enclave/manager.test.ts new file mode 100644 index 000000000..4f8bbabf8 --- /dev/null +++ b/src/enclave/manager.test.ts @@ -0,0 +1,166 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import execa from 'execa'; +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import type { WrapperConfig } from '../types'; +import { prepareEnclaves, teardownEnclaves } from './manager'; +import { releaseSeedPermissions, type GitRunner } from '../bounded-query/staging'; +import { resolveEnclavePaths } from './paths'; + +const gitRunner: GitRunner = async (args) => { + if (args.includes('clone')) { + const destination = args[args.length - 1]; + fs.mkdirSync(path.join(destination, '.git'), { recursive: true }); + fs.writeFileSync(path.join(destination, '.git', 'config'), '[core]\n'); + fs.writeFileSync(path.join(destination, 'README.md'), 'private\n'); + return { stdout: '' }; + } + if (args[0] === 'rev-parse') return { stdout: 'a'.repeat(40) }; + return { stdout: '' }; +}; + +jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); +const mockExeca = execa as unknown as jest.Mock; + +function config(workDir: string, overrides: Parameters[0] = {}): WrapperConfig { + return { + workDir, + enclaves: normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true } }, + ...overrides, + }), + } as WrapperConfig; +} + +describe('prepareEnclaves fail-closed preflight', () => { + let workDir: string; + + beforeEach(() => { + mockExeca.mockReset(); + mockExeca.mockResolvedValue({ exitCode: 0, stdout: '' }); + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-enclave-manager-')); + }); + + afterEach(() => { + const paths = resolveEnclavePaths(workDir); + releaseSeedPermissions(paths.seedsDir); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + it('rejects a network Docker daemon before staging', async () => { + await expect(prepareEnclaves(config(workDir), { + env: { GH_TOKEN: 'secret', DOCKER_HOST: 'tcp://daemon:2375' }, + assertPrimaryAvailable: jest.fn(), + assertScriptRuntimeAvailable: jest.fn(), + })).rejects.toThrow(/Unix-socket Docker host/); + }); + + it('rejects the future agent executor rather than half-enabling it', async () => { + await expect(prepareEnclaves(config(workDir, { + executors: { + script: { enabled: true }, + agent: { enabled: true, model: 'future-model' }, + }, + }), { + env: { GH_TOKEN: 'secret' }, + assertPrimaryAvailable: jest.fn(), + assertScriptRuntimeAvailable: jest.fn(), + })).rejects.toThrow(/reserved for migration layer 3/); + }); + + it('rejects the unimplemented sbx script runtime before staging', async () => { + await expect(prepareEnclaves(config(workDir, { + executors: { script: { enabled: true, runtime: 'sbx' } }, + }), { + env: { GH_TOKEN: 'secret' }, + assertPrimaryAvailable: jest.fn(), + assertScriptRuntimeAvailable: jest.fn(), + })).rejects.toThrow(/runtime "sbx" is not implemented/); + }); + + it('requires a staging credential before runtime probes', async () => { + const assertPrimaryAvailable = jest.fn(); + await expect(prepareEnclaves(config(workDir), { + env: {}, + assertPrimaryAvailable, + assertScriptRuntimeAvailable: jest.fn(), + })).rejects.toThrow(/staging credential/); + expect(assertPrimaryAvailable).not.toHaveBeenCalled(); + }); + + it('stages immutable seeds and a private MCP capability before Compose starts', async () => { + await prepareEnclaves(config(workDir), { + env: { GH_TOKEN: 'secret' }, + gitRunner, + assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), + assertScriptRuntimeAvailable: jest.fn().mockResolvedValue(undefined), + }); + const paths = resolveEnclavePaths(workDir); + const seedMap = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')); + expect(seedMap).toMatchObject({ + version: 2, + runId: expect.stringMatching(/^[0-9a-f]{32}$/), + seeds: [{ + repo: 'octo/private', + seedId: expect.stringMatching(/^[0-9a-f]{32}$/), + sensitivity: 'internal', + }], + }); + expect(fs.readFileSync(paths.capabilityPath, 'utf8').trim()).toMatch(/^[0-9a-f]{64}$/); + expect(fs.statSync(paths.capabilityPath).mode & 0o777).toBe(0o600); + expect(paths.root.startsWith(workDir)).toBe(false); + expect(paths.ingressRoot.startsWith(workDir)).toBe(false); + }); + + it('removes labelled orphan containers and both private roots on teardown', async () => { + const wrapperConfig = config(workDir); + await prepareEnclaves(wrapperConfig, { + env: { GH_TOKEN: 'secret' }, + gitRunner, + assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), + assertScriptRuntimeAvailable: jest.fn().mockResolvedValue(undefined), + }); + mockExeca + .mockResolvedValueOnce({ exitCode: 0, stdout: 'a'.repeat(12) }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '' }); + const paths = resolveEnclavePaths(workDir); + const runId = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')).runId; + await teardownEnclaves(wrapperConfig); + expect(mockExeca).toHaveBeenNthCalledWith( + 1, + 'docker', + ['ps', '-aq', '--filter', `label=awf.enclave.run=${runId}`], + expect.objectContaining({ reject: false }), + ); + expect(mockExeca).toHaveBeenNthCalledWith( + 2, + 'docker', + ['rm', '-f', 'a'.repeat(12)], + expect.objectContaining({ reject: false }), + ); + expect(fs.existsSync(paths.root)).toBe(false); + expect(fs.existsSync(paths.ingressRoot)).toBe(false); + }); + + it('preserves private state and fails loudly when orphan cleanup fails', async () => { + const wrapperConfig = config(workDir); + await prepareEnclaves(wrapperConfig, { + env: { GH_TOKEN: 'secret' }, + gitRunner, + assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), + assertScriptRuntimeAvailable: jest.fn().mockResolvedValue(undefined), + }); + 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/, + ); + 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 new file mode 100644 index 000000000..39b89716f --- /dev/null +++ b/src/enclave/manager.ts @@ -0,0 +1,217 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import execa from 'execa'; +import { fixArtifactPermissionsForRootless } from '../artifact-permissions'; +import { + PRIVATE_REPOSITORY_SEED_MAP_VERSION, + serializePrivateRepositorySeedMap, + type PrivateRepositorySeedMap, +} from '../bounded-execution'; +import { assertPrimaryRuntimeAvailable, assertQueryRuntimeAvailable } from '../bounded-query/preflight'; +import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from '../bounded-query/staging'; +import { getLocalDockerEnv } from '../host-env'; +import { logger } from '../logger'; +import type { BoundedQueriesConfig, WrapperConfig } from '../types'; +import type { EnclaveScriptExecutorConfig } from '../types/enclave-options'; +import { assertPrivateRootIsolated } from '../bounded-query/mount-policy'; +import { validateEnclavesConfig } from './preflight'; +import { generateEnclaveRunId, resolveEnclavePaths, type EnclavePaths } from './paths'; + +export const ENCLAVE_RUN_LABEL = 'awf.enclave.run'; + +export function isEnclaveScriptEnabled(config: WrapperConfig): boolean { + return config.enclaves?.enabled === true && config.enclaves.executors.script.enabled === true; +} + +export function isEnclavesEnabled(config: WrapperConfig): boolean { + return config.enclaves?.enabled === true; +} + +function ensureDirectory(target: string, mode: number): void { + fs.mkdirSync(target, { recursive: true, mode }); + fs.chmodSync(target, mode); +} + +function prepareDirectories(paths: EnclavePaths): 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); +} + +function writeExclusive(target: string, content: string, mode: number): void { + const fd = fs.openSync( + target, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, + mode, + ); + try { + fs.writeSync(fd, content); + fs.fchmodSync(fd, mode); + } finally { + fs.closeSync(fd); + } +} + +export interface PrepareEnclavesDeps { + gitRunner?: GitRunner; + env?: NodeJS.ProcessEnv; + assertScriptRuntimeAvailable?: (config: EnclaveScriptExecutorConfig) => Promise; + assertPrimaryAvailable?: typeof assertPrimaryRuntimeAvailable; +} + +export async function prepareEnclaves( + config: WrapperConfig, + deps: PrepareEnclavesDeps = {}, +): Promise { + if (!isEnclavesEnabled(config)) return; + 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') { + errors.push('enclaves.executors.script.runtime "sbx" is not implemented and never falls back'); + } + 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'); + } + const token = resolveStagingToken(env); + if (!token) { + errors.push('enclaves require a staging credential in GH_TOKEN or GITHUB_TOKEN on the AWF host'); + } + if (errors.length > 0) { + throw new Error(`Enclave configuration is invalid:\n - ${errors.join('\n - ')}`); + } + if (!token) { + throw new Error('Enclave staging credential disappeared during preflight'); + } + + await (deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable)(config.containerRuntime); + const assertRuntime = deps.assertScriptRuntimeAvailable + ?? ((script: EnclaveScriptExecutorConfig) => ( + assertQueryRuntimeAvailable(script as unknown as BoundedQueriesConfig) + )); + await assertRuntime(enclaves.executors.script); + + const paths = resolveEnclavePaths(config.workDir); + assertPrivateRootIsolated(config, paths, env, process.cwd(), 'enclave'); + try { + const workDirStat = fs.lstatSync(config.workDir); + if (workDirStat.isSymbolicLink()) { + throw new Error(`Refusing to stage into a symlink work directory: ${config.workDir}`); + } + } catch (error: unknown) { + if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + prepareDirectories(paths); + + const runId = generateEnclaveRunId(); + const staging = await stageBoundedQuerySeeds({ + repos: enclaves.privateRepos, + paths, + runId, + token, + gitRunner: deps.gitRunner, + label: 'Enclaves', + }); + const seedMap: PrivateRepositorySeedMap = { + version: PRIVATE_REPOSITORY_SEED_MAP_VERSION, + runId: staging.runId, + seeds: staging.seeds.map((seed) => ({ + repo: seed.repoKey, + seedId: seed.seedId, + sensitivity: seed.sensitivity, + })), + }; + writeExclusive(paths.seedMapPath, serializePrivateRepositorySeedMap(seedMap), 0o600); + writeExclusive(paths.capabilityPath, `${crypto.randomBytes(32).toString('hex')}\n`, 0o600); + logger.info(`Enclaves: staged ${staging.seeds.length} immutable seed(s); staging credential discarded.`); +} + +function readRunId(paths: EnclavePaths): string | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(paths.seedMapPath, 'utf8')) as PrivateRepositorySeedMap; + return typeof parsed.runId === 'string' && parsed.runId.length > 0 ? parsed.runId : undefined; + } catch { + return undefined; + } +} + +async function removeOrphanEnclaveContainers(runId: string): Promise { + const listed = await execa('docker', ['ps', '-aq', '--filter', `label=${ENCLAVE_RUN_LABEL}=${runId}`], { + env: getLocalDockerEnv(), + reject: false, + timeout: 30_000, + }); + if (listed.exitCode !== 0) { + throw new Error('Failed to list orphaned enclave script containers'); + } + const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean); + if (ids.length === 0) return; + const removed = await execa('docker', ['rm', '-f', ...ids], { + env: getLocalDockerEnv(), + reject: false, + timeout: 60_000, + }); + if (removed.exitCode !== 0) { + throw new Error('Failed to remove orphaned enclave script containers'); + } +} + +function removePrivateState(config: WrapperConfig, paths: EnclavePaths): void { + try { + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'EACCES') { + fixArtifactPermissionsForRootless( + [paths.root, paths.ingressRoot], + config.dockerHostPathPrefix, + config.imageRegistry, + config.imageTag, + config.agentImage, + ); + fs.rmSync(paths.root, { recursive: true, force: true }); + fs.rmSync(paths.ingressRoot, { recursive: true, force: true }); + return; + } + throw error; + } +} + +export async function teardownEnclaves(config: WrapperConfig): Promise { + if (!isEnclavesEnabled(config)) return; + const paths = resolveEnclavePaths(config.workDir); + const runId = readRunId(paths); + if (runId) { + await removeOrphanEnclaveContainers(runId); + } + if (config.keepContainers) { + logger.info(`Enclave private state preserved at: ${paths.root}`); + logger.info(`Enclave MCP control endpoint preserved at: ${paths.ingressRoot}`); + return; + } + try { + releaseSeedPermissions(paths.seedsDir); + } catch (error) { + logger.warn('Enclaves: failed to restore seed permissions before cleanup', error); + } + removePrivateState(config, paths); +} + +export const enclaveManagerTestHelpers = { + prepareDirectories, + readRunId, + removeOrphanEnclaveContainers, +}; diff --git a/src/enclave/mcp-server.test.ts b/src/enclave/mcp-server.test.ts new file mode 100644 index 000000000..93a368e37 --- /dev/null +++ b/src/enclave/mcp-server.test.ts @@ -0,0 +1,342 @@ +import * as http from 'http'; +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const root = path.join(__dirname, '..', '..', 'containers', 'bounded-query'); +const { + dispatchJsonRpc, + parseJsonRpcBody, + TOOL_NAME, +} = require(path.join(root, 'enclave-mcp', 'mcp-protocol.js')); +const { + createMcpServer, + safeCapabilityEquals, +} = require(path.join(root, 'enclave-mcp', 'server.js')); +const { createBroker } = require(path.join(root, 'broker', 'broker.js')); +const { + CANONICAL_ERROR_JSON, + validateBoundedQueryRequest, +} = 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 capability = '0123456789abcdef0123456789abcdef'; +const validArguments = { + 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('AWF enclave MCP protocol', () => { + it('implements initialization and the initialized notification', async () => { + const deps = { broker: fakeBroker(CANONICAL_ERROR_JSON), maxScriptBytes: 65536 }; + const initialized = await dispatchJsonRpc(rpc('initialize', {}), deps); + expect(initialized).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'awf-enclave' }, + }, + }); + expect(await dispatchJsonRpc({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }, deps)).toBeUndefined(); + }); + + it('publishes one static tool without trusted configuration or repository data', async () => { + const response = await dispatchJsonRpc(rpc('tools/list', {}), { + broker: fakeBroker(CANONICAL_ERROR_JSON), + maxScriptBytes: 65536, + repositories: ['should-never-appear'], + runtime: 'gvisor', + sensitivity: 'confidential', + model: 'private-model', + }); + expect(response.result.tools).toHaveLength(1); + expect(response.result.tools[0].name).toBe(TOOL_NAME); + expect(response.result.tools[0].inputSchema).toMatchObject({ + required: ['privateRepo', 'schema', 'script'], + additionalProperties: false, + }); + expect(JSON.stringify(response)).not.toMatch( + /should-never-appear|gvisor|confidential|private-model|budget/i, + ); + }); + + it('returns canonical structured success without isError', async () => { + const response = await dispatchJsonRpc(rpc('tools/call', { + name: TOOL_NAME, + arguments: validArguments, + }), { + broker: fakeBroker('{"status":"ok","result":true}'), + maxScriptBytes: 65536, + }); + expect(response).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'text', text: '{"status":"ok","result":true}' }], + structuredContent: { status: 'ok', result: true }, + }, + }); + expect(JSON.stringify(response)).not.toContain('isError'); + }); + + it.each([ + CANONICAL_ERROR_JSON, + '{"status":"unexpected"}', + ])('collapses every broker outcome failure to one public result (%s)', async (outcome) => { + const response = await dispatchJsonRpc(rpc('tools/call', { + name: TOOL_NAME, + arguments: validArguments, + }), { + broker: fakeBroker(outcome), + maxScriptBytes: 65536, + }); + expect(response.result.structuredContent).toEqual({ status: 'error' }); + expect(response.result.content).toEqual([ + { type: 'text', text: '{"status":"error"}' }, + ]); + expect(response.result).not.toHaveProperty('isError'); + }); + + it('passes only exact finite-disclosure arguments and canonically rejects extras', async () => { + const requests: unknown[] = []; + const validatingBroker = { + handle(request: unknown, respond: (value: string) => void) { + requests.push(request); + const validation = validateBoundedQueryRequest(request); + respond(validation.valid ? '{"status":"ok","result":true}' : CANONICAL_ERROR_JSON); + return Promise.resolve(); + }, + }; + const response = await dispatchJsonRpc(rpc('tools/call', { + name: TOOL_NAME, + arguments: { ...validArguments, runtime: 'runc' }, + }), { broker: validatingBroker, maxScriptBytes: 65536 }); + expect(requests).toEqual([{ ...validArguments, runtime: 'runc' }]); + expect(response.result.structuredContent).toEqual({ status: 'error' }); + }); + + it('uses JSON-RPC errors only for malformed protocol requests', async () => { + const deps = { broker: fakeBroker(CANONICAL_ERROR_JSON), maxScriptBytes: 65536 }; + await expect(dispatchJsonRpc(rpc('unknown'), deps)).resolves.toMatchObject({ + error: { code: -32601 }, + }); + await expect(dispatchJsonRpc(rpc('tools/call', { name: 'other', arguments: {} }), deps)) + .resolves.toMatchObject({ error: { code: -32602 } }); + expect(parseJsonRpcBody(Buffer.from('{"jsonrpc":"2.0","id":1,"id":2}'))).toBeUndefined(); + }); + + it('authenticates a private bearer capability in constant-length comparisons', () => { + expect(safeCapabilityEquals(`Bearer ${capability}`, capability)).toBe(true); + expect(safeCapabilityEquals(`Bearer ${capability.slice(1)}`, capability)).toBe(false); + expect(safeCapabilityEquals(capability, capability)).toBe(false); + }); +}); + +describe('AWF enclave MCP HTTP framing', () => { + let server: http.Server; + let port: number; + + beforeEach(async () => { + server = createMcpServer({ + broker: fakeBroker(CANONICAL_ERROR_JSON), + capability, + maxScriptBytes: 65536, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing test listener'); + port = address.port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + function request(body: string, authorization?: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: authorization ? { authorization } : {}, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ + status: res.statusCode || 0, + body: Buffer.concat(chunks).toString('utf8'), + })); + }); + req.on('error', reject); + req.end(body); + }); + } + + it('rejects unauthenticated requests before dispatch', async () => { + const response = await request(JSON.stringify(rpc('tools/list'))); + expect(response.status).toBe(401); + expect(JSON.parse(response.body).error.code).toBe(-32001); + }); + + it('accepts authenticated JSON-RPC and emits no notification body', async () => { + const listed = await request( + JSON.stringify(rpc('tools/list')), + `Bearer ${capability}`, + ); + expect(listed.status).toBe(200); + expect(JSON.parse(listed.body).result.tools).toHaveLength(1); + + const notified = await request( + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + `Bearer ${capability}`, + ); + expect(notified).toEqual({ status: 202, body: '' }); + }); +}); + +describe('unified enclave ledger and timing', () => { + it('debits the shared ledger with executor kind script', () => { + const ledger = createEnclaveInformationBudgetLedger(new Map([ + ['Octo/Private', { sensitivity: 'confidential' }], + ])); + expect(ledger.tryDebit('octo/private', 4, 'script')).toBe(true); + expect(ledger.tryDebit('OCTO/PRIVATE', 4, 'agent')).toBe(true); + expect(ledger.tryDebit('octo/private', 1, 'script')).toBe(false); + }); + + it('includes executor cleanup in the selected timing bucket', async () => { + let now = 0; + const sleeps: number[] = []; + const clock = { + nowMs: () => now, + sleep: async (ms: number) => { + sleeps.push(ms); + now += ms; + }, + }; + const ledger = { tryDebit: jest.fn(() => true) }; + const broker = createBroker({ + config: { + maxInvocations: 2, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + }, + 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() }, + ledger, + executorKind: 'script', + uniformTiming: true, + clock, + runner: { + runQueryContainer: async () => { + now += 5; + return { exitCode: 0, timedOut: false }; + }, + }, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'unused' }), + readQueryOutput: () => 'true', + destroyInvocationWorkspace: () => { + now += 70; + }, + }, + }); + let result = ''; + await broker.handle(validArguments, (value: string) => { result = value; }); + expect(result).toBe('{"status":"ok","result":true}'); + expect(ledger.tryDebit).toHaveBeenCalledWith('octo/private', 5, 'script'); + expect(sleeps).toEqual([25]); + expect(now).toBe(100); + }); + + it('buckets repository and budget rejection classes to the same public boundary', async () => { + async function rejected(seedMap: Map, debit: boolean) { + let now = 0; + const broker = createBroker({ + config: { + maxInvocations: 1, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + }, + seedMap, + runId: 'a'.repeat(16), + audit: { failure: jest.fn(), invocation: jest.fn() }, + telemetry: { emit: jest.fn() }, + ledger: { tryDebit: () => debit }, + executorKind: 'script', + uniformTiming: true, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { now += ms; }, + }, + runner: {}, + }); + let result = ''; + await broker.handle(validArguments, (value: string) => { result = value; }); + return { now, result }; + } + const unknown = await rejected(new Map(), true); + const exhausted = await rejected(new Map([ + ['octo/private', { seedId: 'a'.repeat(16), sensitivity: 'confidential' }], + ]), false); + expect(unknown).toEqual({ now: 10, result: CANONICAL_ERROR_JSON }); + expect(exhausted).toEqual(unknown); + }); + + it('buckets invocation-count exhaustion instead of revealing remaining capacity', async () => { + let now = 0; + const clock = { + nowMs: () => now, + sleep: async (ms: number) => { now += ms; }, + }; + const broker = createBroker({ + config: { + maxInvocations: 1, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + }, + seedMap: new Map(), + runId: 'a'.repeat(16), + audit: { failure: jest.fn(), invocation: jest.fn() }, + telemetry: { emit: jest.fn() }, + ledger: { tryDebit: jest.fn() }, + executorKind: 'script', + uniformTiming: true, + clock, + runner: {}, + }); + await broker.handle(validArguments, () => undefined); + const startedAt = now; + let response = ''; + await broker.handle(validArguments, (value: string) => { response = value; }); + expect(response).toBe(CANONICAL_ERROR_JSON); + expect(now - startedAt).toBe(10); + }); +}); diff --git a/src/enclave/paths.test.ts b/src/enclave/paths.test.ts new file mode 100644 index 000000000..6a46235aa --- /dev/null +++ b/src/enclave/paths.test.ts @@ -0,0 +1,14 @@ +import * as path from 'path'; +import { resolveEnclavePaths } from './paths'; + +describe('resolveEnclavePaths', () => { + it('keeps private state and the future mcpg control endpoint disjoint', () => { + const paths = resolveEnclavePaths('/tmp/awf-test', '/private'); + expect(paths.root).toMatch(/^\/private\/awf-enclave-private-/); + expect(paths.ingressRoot).toMatch(/^\/private\/awf-enclave-control-/); + expect(paths.ingressRoot).not.toContain(paths.root); + expect(paths.socketPath).toBe(path.join(paths.runDir, 'server.sock')); + expect(paths.capabilityPath).toBe(path.join(paths.runDir, 'auth-token')); + expect(paths.auditDir.startsWith(paths.root)).toBe(true); + }); +}); diff --git a/src/enclave/paths.ts b/src/enclave/paths.ts new file mode 100644 index 000000000..3da00aa1b --- /dev/null +++ b/src/enclave/paths.ts @@ -0,0 +1,61 @@ +import * as crypto from 'crypto'; +import * as path from 'path'; + +export interface EnclavePaths { + root: string; + seedsDir: string; + workDir: string; + controlDir: string; + auditDir: string; + seedMapPath: string; + ingressRoot: string; + runDir: string; + socketPath: string; + capabilityPath: string; +} + +export const ENCLAVE_PRIVATE_BASE_DIR = '/var/tmp'; +export const ENCLAVE_SOCKET_FILENAME = 'server.sock'; +export const ENCLAVE_CAPABILITY_FILENAME = 'auth-token'; + +export const ENCLAVE_BROKER_SEEDS_DIR = '/srv/awf/seeds'; +export const ENCLAVE_BROKER_WORK_DIR = '/srv/awf/work'; +export const ENCLAVE_BROKER_SEED_MAP_PATH = '/srv/awf/seed-map.json'; +export const ENCLAVE_BROKER_SOCKET_DIR = '/run/awf-enclave-mcp'; +export const ENCLAVE_BROKER_SOCKET_PATH = `${ENCLAVE_BROKER_SOCKET_DIR}/${ENCLAVE_SOCKET_FILENAME}`; +export const ENCLAVE_BROKER_CAPABILITY_PATH = `${ENCLAVE_BROKER_SOCKET_DIR}/${ENCLAVE_CAPABILITY_FILENAME}`; +export const ENCLAVE_BROKER_CONTROL_DIR = '/run/awf-enclave-mcp-control'; +export const ENCLAVE_BROKER_AUDIT_DIR = '/var/log/awf-enclave'; +export const ENCLAVE_BROKER_DOCKER_SOCKET_PATH = '/var/run/docker.sock'; + +function deriveRootIdentity(awfWorkDir: string): string { + const uid = process.getuid?.() ?? 0; + const digest = crypto.createHash('sha256').update(path.resolve(awfWorkDir), 'utf8').digest('hex').slice(0, 20); + return `${uid}-${digest}`; +} + +export function resolveEnclavePaths( + awfWorkDir: string, + privateBaseDir = ENCLAVE_PRIVATE_BASE_DIR, +): EnclavePaths { + const identity = deriveRootIdentity(awfWorkDir); + const root = path.join(privateBaseDir, `awf-enclave-private-${identity}`); + const ingressRoot = path.join(privateBaseDir, `awf-enclave-control-${identity}`); + const runDir = path.join(ingressRoot, 'run'); + return { + root, + seedsDir: path.join(root, 'seeds'), + workDir: path.join(root, 'work'), + controlDir: path.join(root, 'control'), + auditDir: path.join(root, 'audit'), + seedMapPath: path.join(root, 'seed-map.json'), + ingressRoot, + runDir, + socketPath: path.join(runDir, ENCLAVE_SOCKET_FILENAME), + capabilityPath: path.join(runDir, ENCLAVE_CAPABILITY_FILENAME), + }; +} + +export function generateEnclaveRunId(): string { + return crypto.randomBytes(16).toString('hex'); +} diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index f5d02ff76..cb065062e 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -121,4 +121,21 @@ describe('validateEnclavesConfig', () => { expect(errors).toMatch(/positive Docker --cpus value/); expect(errors).toMatch(/must be a positive integer/); }); + + it('rejects script disclosure bounds the container cannot enforce', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + script: { + enabled: true, + maxScriptBytes: 65_537, + maxOutputBytes: 8_193, + }, + }, + }); + const errors = validateEnclavesConfig(config({ enclaves })).join('\n'); + expect(errors).toMatch(/maxScriptBytes must be at most 65536/); + expect(errors).toMatch(/maxOutputBytes must be at most 8192/); + }); }); diff --git a/src/enclave/preflight.ts b/src/enclave/preflight.ts index 305ff048f..f738093dc 100644 --- a/src/enclave/preflight.ts +++ b/src/enclave/preflight.ts @@ -1,6 +1,8 @@ import type { WrapperConfig } from '../types'; import type { EnclavesConfig } from '../types/enclave-options'; import { + MAX_RESULT_BYTES, + MAX_SCRIPT_BYTES, MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS, PRIVATE_REPOSITORY_PATTERN, } from '../bounded-execution'; @@ -55,6 +57,12 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { } validateResourceLimits('enclaves.executors.script', script, errors); validatePositiveInteger('enclaves.executors.script.maxScriptBytes', script.maxScriptBytes, errors); + if (script.maxScriptBytes > MAX_SCRIPT_BYTES) { + errors.push(`enclaves.executors.script.maxScriptBytes must be at most ${MAX_SCRIPT_BYTES}`); + } + if (script.maxOutputBytes > MAX_RESULT_BYTES) { + errors.push(`enclaves.executors.script.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + } validatePositiveInteger('enclaves.executors.script.maxInvocations', script.maxInvocations, errors); } diff --git a/src/enclave/script-runner-spec.test.ts b/src/enclave/script-runner-spec.test.ts new file mode 100644 index 000000000..637065679 --- /dev/null +++ b/src/enclave/script-runner-spec.test.ts @@ -0,0 +1,123 @@ +import * as path from 'path'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const root = path.join(__dirname, '..', '..', 'containers', 'bounded-query'); +const { + deriveQueryContainerSpec, + ENCLAVE_INVOCATION_LABEL, + ENCLAVE_RUN_LABEL, +} = require(path.join(root, 'broker', 'query-runner-spec.js')); +const { loadConfig } = require(path.join(root, 'enclave-mcp', 'config.js')); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('unified enclave script runner specification', () => { + const config = { + hostWorkDir: '/daemon/private/enclave/work', + queryMountDir: '/query', + queryScriptPath: '/awf/query-script.py', + querySeccompPath: '/opt/awf/query-seccomp.json', + queryImage: 'ghcr.io/github/awf-enclave-script:pinned', + memoryLimit: '768m', + cpuLimit: '0.5', + pidsLimit: 47, + tmpfsLimit: '96m', + queryUid: 65534, + queryGid: 65534, + runLabelKey: ENCLAVE_RUN_LABEL, + invocationLabelKey: ENCLAVE_INVOCATION_LABEL, + containerPrefix: 'awf-enclave-script', + }; + + it('uses enclave labels and every trusted isolation/resource control', () => { + const spec = deriveQueryContainerSpec({ + config, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + runtimeName: 'runsc', + request: { + image: 'attacker/image', + memoryLimit: '99g', + network: 'host', + mounts: ['/etc:/host'], + }, + }); + const args = spec.launchArgs; + expect(spec.containerName).toBe('awf-enclave-script-abcdef123456-0123456789abcdef'); + expect(args).toEqual(expect.arrayContaining([ + '--label', 'awf.enclave.run=abcdef1234567890', + '--label', 'awf.enclave.invocation=0123456789abcdef', + '--network', 'none', + '--read-only', + '--memory', '768m', + '--memory-swap', '768m', + '--cpus', '0.5', + '--pids-limit', '47', + '--runtime', 'runsc', + '--security-opt', 'no-new-privileges:true', + ])); + expect(args).toContain('/tmp:rw,noexec,nosuid,nodev,size=96m'); + expect(args).toContain('/query:rw,nosuid,nodev,size=96m,uid=65534,gid=65534,mode=0700'); + expect(args.join(' ')).not.toMatch(/attacker|99g|network host|\/etc:\/host/); + expect(spec.runListArgs).toContain('label=awf.enclave.run=abcdef1234567890'); + expect(spec.invocationListArgs).toContain( + 'label=awf.enclave.invocation=0123456789abcdef', + ); + }); + + it('keeps legacy runner defaults byte-compatible', () => { + const legacy = deriveQueryContainerSpec({ + config: { + ...config, + cpuLimit: undefined, + pidsLimit: undefined, + tmpfsLimit: undefined, + runLabelKey: undefined, + invocationLabelKey: undefined, + containerPrefix: undefined, + }, + runId: 'abcdef1234567890', + invocationId: '0123456789abcdef', + }); + expect(legacy.containerName).toMatch(/^awf-query-/); + expect(legacy.launchArgs).toEqual(expect.arrayContaining([ + '--label', 'awf.bounded-query.run=abcdef1234567890', + '--cpus', '1', + '--pids-limit', '128', + '/tmp:rw,noexec,nosuid,nodev,size=16m', + '/query:rw,nosuid,nodev,size=1073741824,uid=65534,gid=65534,mode=0700', + ])); + }); + + it('loads trusted resource and disclosure bounds only from server environment', () => { + const original = { ...process.env }; + Object.assign(process.env, { + AWF_ENCLAVE_BACKEND: 'gvisor', + AWF_ENCLAVE_PRIMARY_BACKEND: 'docker', + AWF_ENCLAVE_HOST_WORK_DIR: '/daemon/private/enclave/work', + AWF_ENCLAVE_IMAGE: 'image:pinned', + AWF_ENCLAVE_TIMEOUT: '41', + AWF_ENCLAVE_MEMORY: '700m', + AWF_ENCLAVE_CPU: '0.25', + AWF_ENCLAVE_PIDS: '33', + AWF_ENCLAVE_TMPFS: '80m', + AWF_ENCLAVE_MAX_OUTPUT_BYTES: '4096', + AWF_ENCLAVE_MAX_SCRIPT_BYTES: '2048', + }); + try { + expect(loadConfig({ readFileSync: () => 'a'.repeat(64) })).toMatchObject({ + queryBackend: 'gvisor', + timeoutSeconds: 41, + memoryLimit: '700m', + cpuLimit: '0.25', + pidsLimit: 33, + tmpfsLimit: '80m', + maxOutputBytes: 4096, + maxScriptBytes: 2048, + runLabelKey: 'awf.enclave.run', + invocationLabelKey: 'awf.enclave.invocation', + }); + } finally { + process.env = original; + } + }); +}); diff --git a/src/enclave/workflow-integration.test.ts b/src/enclave/workflow-integration.test.ts new file mode 100644 index 000000000..db0cda5b8 --- /dev/null +++ b/src/enclave/workflow-integration.test.ts @@ -0,0 +1,51 @@ +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import type { WrapperConfig } from '../types'; +import { runMainWorkflow } from '../cli-workflow'; + +jest.mock('../container-runtime', () => ({ + runtimeNeedsStaticDns: jest.fn().mockReturnValue(false), + runtimeUsesComposeAgent: jest.fn().mockReturnValue(true), +})); + +function config(): WrapperConfig { + return { + workDir: '/tmp/awf-enclave-test', + networkIsolation: true, + enclaves: normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true } }, + }), + } as WrapperConfig; +} + +describe('unified enclave workflow integration', () => { + it('stages before config generation and container startup', async () => { + const order: string[] = []; + await runMainWorkflow(config(), { + ensureFirewallNetwork: jest.fn(), + setupHostIptables: jest.fn(), + prepareEnclaves: jest.fn(async () => { order.push('prepareEnclaves'); }), + writeConfigs: jest.fn(async () => { order.push('writeConfigs'); }), + startContainers: jest.fn(async () => { order.push('startContainers'); }), + runAgentCommand: jest.fn(async () => ({ exitCode: 0 })), + }, { + logger: { info: jest.fn(), success: jest.fn(), warn: jest.fn() }, + performCleanup: jest.fn(), + }); + expect(order.slice(0, 3)).toEqual(['prepareEnclaves', 'writeConfigs', 'startContainers']); + }); + + it('fails closed when lifecycle staging is absent', async () => { + await expect(runMainWorkflow(config(), { + ensureFirewallNetwork: jest.fn(), + setupHostIptables: jest.fn(), + writeConfigs: jest.fn(), + startContainers: jest.fn(), + runAgentCommand: jest.fn(), + }, { + logger: { info: jest.fn(), success: jest.fn(), warn: jest.fn() }, + performCleanup: jest.fn(), + })).rejects.toThrow(/no staging implementation/); + }); +}); diff --git a/src/image-tag.test.ts b/src/image-tag.test.ts index 04d70fe8c..737ed6bc8 100644 --- a/src/image-tag.test.ts +++ b/src/image-tag.test.ts @@ -12,6 +12,8 @@ const IMAGE_DIGEST_KEYS = [ 'bounded-query-broker', 'bounded-agent', 'bounded-agent-broker', + 'enclave-script', + 'enclave-mcp-server', ] as const; const VALID_DIGEST = 'sha256:' + 'a'.repeat(64); diff --git a/src/image-tag.ts b/src/image-tag.ts index 7ae061a67..c13c8f4d2 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'] 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-mcp-server'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts new file mode 100644 index 000000000..c9ffe994c --- /dev/null +++ b/src/services/enclave-mcp-service.test.ts @@ -0,0 +1,111 @@ +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import { parseImageTag } from '../image-tag'; +import type { WrapperConfig } from '../types'; +import { buildEnclaveMcpService } from './enclave-mcp-service'; +import { generateDockerCompose } from '../compose-generator'; + +function config(overrides: Partial = {}): WrapperConfig { + return { + workDir: '/tmp/awf-test', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + enclaves: normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true } }, + }), + ...overrides, + } as WrapperConfig; +} + +const ghcr = { + useGHCR: true, + registry: 'ghcr.io/github/gh-aw-firewall', + parsedTag: parseImageTag('v1'), + projectRoot: '/repo', +}; + +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({ + image: 'ghcr.io/github/gh-aw-firewall/enclave-script:v1', + network_mode: 'none', + entrypoint: ['/bin/true'], + }); + expect(result.service).toMatchObject({ + container_name: 'awf-enclave-mcp-server', + image: 'ghcr.io/github/gh-aw-firewall/enclave-mcp-server:v1', + network_mode: 'none', + depends_on: { + 'enclave-script-image': { condition: 'service_completed_successfully' }, + }, + }); + expect(result.service).not.toHaveProperty('networks'); + expect(result.service).not.toHaveProperty('ports'); + const environment = result.service.environment as Record; + expect(environment.AWF_ENCLAVE_MAX_SCRIPT_BYTES).toBe('65536'); + expect(environment.AWF_ENCLAVE_CAPABILITY_PATH).toBe('/run/awf-enclave-mcp/auth-token'); + expect(Object.keys(environment).some((key) => /TOKEN|REPO|SENSITIVITY/.test(key))).toBe(false); + }); + + it('derives all sandbox controls from trusted configuration', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + script: { + enabled: true, + runtime: 'gvisor', + timeout: 12, + memoryLimit: '256m', + cpuLimit: '0.5', + pidsLimit: 32, + tmpfsLimit: '24m', + maxOutputBytes: 2048, + maxScriptBytes: 4096, + maxInvocations: 3, + }, + }, + }); + const result = buildEnclaveMcpService({ + config: config({ enclaves }), + imageConfig: ghcr, + }); + expect(result.service.environment).toMatchObject({ + AWF_ENCLAVE_BACKEND: 'gvisor', + AWF_ENCLAVE_TIMEOUT: '12', + AWF_ENCLAVE_MEMORY: '256m', + AWF_ENCLAVE_CPU: '0.5', + AWF_ENCLAVE_PIDS: '32', + AWF_ENCLAVE_TMPFS: '24m', + AWF_ENCLAVE_MAX_OUTPUT_BYTES: '2048', + AWF_ENCLAVE_MAX_SCRIPT_BYTES: '4096', + AWF_ENCLAVE_MAX_INVOCATIONS: '3', + }); + }); + + it('fails closed for the not-yet-proven sbx script runtime', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true, runtime: 'sbx' } }, + }); + expect(() => buildEnclaveMcpService({ config: config({ enclaves }), imageConfig: ghcr })) + .toThrow(/sbx script enclave capability is not yet available/); + }); + + 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', + }); + expect(compose.services['enclave-script-image']).toBeDefined(); + expect(compose.services['enclave-mcp-server']).toBeDefined(); + const agent = compose.services.agent as Record; + expect((agent.depends_on as Record)['enclave-mcp-server']).toBeUndefined(); + expect(JSON.stringify(agent.volumes)).not.toContain('awf-enclave-control'); + expect(JSON.stringify(agent.environment)).not.toContain('AWF_ENCLAVE'); + }); +}); diff --git a/src/services/enclave-mcp-service.ts b/src/services/enclave-mcp-service.ts new file mode 100644 index 000000000..7e2f43739 --- /dev/null +++ b/src/services/enclave-mcp-service.ts @@ -0,0 +1,165 @@ +import { buildRuntimeImageRef } from '../image-tag'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; +import type { WrapperConfig } from '../types'; +import { + ENCLAVE_BROKER_AUDIT_DIR, + ENCLAVE_BROKER_CAPABILITY_PATH, + ENCLAVE_BROKER_CONTROL_DIR, + ENCLAVE_BROKER_DOCKER_SOCKET_PATH, + ENCLAVE_BROKER_SEED_MAP_PATH, + ENCLAVE_BROKER_SEEDS_DIR, + ENCLAVE_BROKER_SOCKET_DIR, + ENCLAVE_BROKER_WORK_DIR, + resolveEnclavePaths, +} from '../enclave/paths'; +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'; + +const LOCAL_ENCLAVE_SCRIPT_IMAGE = 'awf-enclave-script:local'; +const LOCAL_ENCLAVE_MCP_SERVER_IMAGE = 'awf-enclave-mcp-server:local'; +const ENCLAVE_SCRIPT_IMAGE_NAME = 'enclave-script'; +const ENCLAVE_MCP_SERVER_IMAGE_NAME = 'enclave-mcp-server'; + +interface EnclaveMcpServiceParams { + config: WrapperConfig; + imageConfig: ImageBuildConfig; +} + +export interface EnclaveMcpBuildResult { + scriptImageService: Record; + service: Record; +} + +function resolveImages(imageConfig: ImageBuildConfig, scriptImageOverride?: string): { + scriptImageRef: string; + scriptSource: Record; + serverSource: Record; +} { + if (imageConfig.useGHCR) { + const scriptImageRef = scriptImageOverride ?? 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, + ), + }, + }; + } + 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 { + 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' }, + }, + }; +} + +function toDaemonVisiblePath(hostPath: string, prefix: string | undefined): string { + const [translated] = applyHostPathPrefixToVolumes([`${hostPath}:${hostPath}`], prefix); + return translated.split(':')[0]; +} + +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'); + } + if (script.runtime === 'sbx') { + throw new Error('buildEnclaveMcpService: sbx script enclave capability is not yet available'); + } + 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 service: Record = { + container_name: 'awf-enclave-mcp-server', + ...images.serverSource, + network_mode: 'none', + volumes: applyHostPathPrefixToVolumes( + [ + `${paths.seedsDir}:${ENCLAVE_BROKER_SEEDS_DIR}:ro`, + `${paths.workDir}:${ENCLAVE_BROKER_WORK_DIR}:rw`, + `${paths.runDir}:${ENCLAVE_BROKER_SOCKET_DIR}:rw`, + `${paths.controlDir}:${ENCLAVE_BROKER_CONTROL_DIR}:rw`, + `${paths.auditDir}:${ENCLAVE_BROKER_AUDIT_DIR}:rw`, + `${paths.seedMapPath}:${ENCLAVE_BROKER_SEED_MAP_PATH}:ro`, + `${dockerSocketPath}:${ENCLAVE_BROKER_DOCKER_SOCKET_PATH}:rw`, + ], + 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' }, + }, + healthcheck: { + test: ['CMD', 'node', '/opt/awf/enclave-mcp/healthcheck.js'], + interval: '5s', + timeout: '3s', + retries: 10, + start_period: '20s', + }, + ...buildContainerSecurityHardening({ memLimit: '256m', pidsLimit: 100, cpuShares: 256 }), + cap_add: ['CHOWN', 'DAC_OVERRIDE', 'FOWNER'], + restart: 'no', + stop_grace_period: '5s', + }; + return { scriptImageService, service }; +} + +export const enclaveMcpServiceTestHelpers = { + ENCLAVE_SCRIPT_IMAGE_NAME, + ENCLAVE_MCP_SERVER_IMAGE_NAME, + LOCAL_ENCLAVE_SCRIPT_IMAGE, + LOCAL_ENCLAVE_MCP_SERVER_IMAGE, + resolveImages, + toDaemonVisiblePath, +}; diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index cca41e812..6b76ebaf7 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -7,6 +7,7 @@ import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; import { buildBoundedQueryService, isBoundedQueryAgentMount } from './bounded-query-service'; import { buildBoundedAgentService, isBoundedAgentAgentMount } from './bounded-agent-service'; +import { buildEnclaveMcpService } from './enclave-mcp-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; import { runtimeUsesIptables } from '../container-runtime'; @@ -304,6 +305,18 @@ function assembleBoundedAgentService(params: AssembleOptionalServicesParams): vo condition: 'service_healthy', }; } + +} + +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; + 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. } function finalizeSysrootVolumes( @@ -345,6 +358,7 @@ export function assembleOptionalServices( presetSidecarIpEnvVars(environment, config, networkConfig); assembleBoundedQueryService(params); assembleBoundedAgentService(params); + assembleEnclaveMcpService(params); if (includeComposeAgent) { assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); assembleIptablesInitService(params, skipIptables); From 01919814465c81964d770710274a05900d35f22e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:04:37 +0000 Subject: [PATCH 2/3] fix: resolve enclave CI failures --- src/enclave/preflight.test.ts | 17 +++++++++++++++++ src/services/enclave-mcp-service.test.ts | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index cb065062e..ca04e1401 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -48,6 +48,23 @@ describe('validateEnclavesConfig', () => { expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/privateRepos is empty/); }); + it('rejects script disclosure bounds the container cannot enforce', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + script: { + enabled: true, + maxScriptBytes: 65_537, + maxOutputBytes: 8_193, + }, + }, + }); + const errors = validateEnclavesConfig(config({ enclaves })).join('\n'); + expect(errors).toMatch(/maxScriptBytes must be at most 65536/); + expect(errors).toMatch(/maxOutputBytes must be at most 8192/); + }); + it('requires the API proxy and a usable route for the agent executor', () => { const enclaves = normalizeEnclavesConfig({ enabled: true, diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts index c9ffe994c..9c3a242cb 100644 --- a/src/services/enclave-mcp-service.test.ts +++ b/src/services/enclave-mcp-service.test.ts @@ -1,14 +1,19 @@ +import fs from 'fs'; import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; import { parseImageTag } from '../image-tag'; import type { WrapperConfig } from '../types'; import { buildEnclaveMcpService } from './enclave-mcp-service'; import { generateDockerCompose } from '../compose-generator'; +const workDir = fs.mkdtempSync('/tmp/awf-enclave-mcp-service-test-'); + function config(overrides: Partial = {}): WrapperConfig { return { - workDir: '/tmp/awf-test', + workDir, imageRegistry: 'ghcr.io/github/gh-aw-firewall', imageTag: 'latest', + agentCommand: 'echo test', + allowedDomains: [], enclaves: normalizeEnclavesConfig({ enabled: true, privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], @@ -26,6 +31,8 @@ const ghcr = { }; describe('buildEnclaveMcpService', () => { + afterAll(() => fs.rmSync(workDir, { recursive: true, force: true })); + it('builds a no-egress server without exposing it to the primary agent', () => { const result = buildEnclaveMcpService({ config: config(), imageConfig: ghcr }); expect(result.scriptImageService).toMatchObject({ @@ -103,7 +110,7 @@ describe('buildEnclaveMcpService', () => { }); expect(compose.services['enclave-script-image']).toBeDefined(); expect(compose.services['enclave-mcp-server']).toBeDefined(); - const agent = compose.services.agent as Record; + const agent = compose.services.agent as unknown as Record; expect((agent.depends_on as Record)['enclave-mcp-server']).toBeUndefined(); expect(JSON.stringify(agent.volumes)).not.toContain('awf-enclave-control'); expect(JSON.stringify(agent.environment)).not.toContain('AWF_ENCLAVE'); From 2aa79825de4ac2fd4a119b8df6652dba0258a334 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:22 +0000 Subject: [PATCH 3/3] fix: address enclave review feedback --- containers/bounded-query/broker/broker.js | 2 +- src/bounded-query/preflight.test.ts | 12 ++++++ src/bounded-query/preflight.ts | 21 ++++++----- src/docker-manager-diagnostics.test.ts | 37 ++++++++++++++++++ src/enclave/manager.ts | 8 +++- src/enclave/mcp-server.test.ts | 46 +++++++++++++++++++++++ 6 files changed, 114 insertions(+), 12 deletions(-) diff --git a/containers/bounded-query/broker/broker.js b/containers/bounded-query/broker/broker.js index 4050aa1d4..0f475b689 100644 --- a/containers/bounded-query/broker/broker.js +++ b/containers/bounded-query/broker/broker.js @@ -266,8 +266,8 @@ function createBroker(params) { audit.failure('budget', 'invocation-count-exhausted', `max=${config.maxInvocations}`); emitQueryTelemetry('invocation-count-exhausted'); if (uniformTiming) { - const startMs = clock.nowMs(); const queued = tail.then(async () => { + const startMs = clock.nowMs(); await waitForBucket(startMs, clock.nowMs() - startMs, clock); safeRespond(CANONICAL_ERROR_JSON); }); diff --git a/src/bounded-query/preflight.test.ts b/src/bounded-query/preflight.test.ts index 4ad91f61c..473fc34fe 100644 --- a/src/bounded-query/preflight.test.ts +++ b/src/bounded-query/preflight.test.ts @@ -198,6 +198,18 @@ describe('assertQueryRuntimeAvailable', () => { ).rejects.toThrow(/runsc.*not available|not available.*fall back/s); }); + it('reports a caller-provided runtime configuration path', async () => { + await expect( + assertQueryRuntimeAvailable( + { ...baseBoundedQueries, runtime: 'gvisor' }, + jest.fn().mockResolvedValue(false), + jest.fn(), + jest.fn(), + 'enclaves.executors.script.runtime', + ), + ).rejects.toThrow(/enclaves\.executors\.script\.runtime "gvisor"/); + }); + it('routes custom and omitted runtimes through their fixed Docker capability checks', async () => { const runtimeQuery = jest.fn().mockResolvedValue(true); const dockerAvailable = jest.fn().mockResolvedValue(true); diff --git a/src/bounded-query/preflight.ts b/src/bounded-query/preflight.ts index 8298fc483..caf27d8f7 100644 --- a/src/bounded-query/preflight.ts +++ b/src/bounded-query/preflight.ts @@ -253,31 +253,32 @@ export async function assertQueryRuntimeAvailable( queryDockerRuntime: DockerRuntimeQuery = defaultDockerRuntimeQuery, querySbxCapabilities: SbxCapabilityQuery = defaultSbxCapabilityQuery, queryDockerAvailable: DockerAvailabilityQuery = defaultDockerAvailabilityQuery, + runtimeConfigPath = 'boundedQueries.runtime', ): Promise { await assertRuntimeAvailability(boundedQueries.runtime, { sbx: async () => { const report = await querySbxCapabilities(); if (!report.supported) { throw new Error( - 'boundedQueries.runtime "sbx" is blocked because the installed sbx runtime cannot enforce all ' + + `${runtimeConfigPath} "sbx" is blocked because the installed sbx runtime cannot enforce all ` + `mandatory query-isolation controls: ${report.missing.join(', ')}. ` + - 'AWF will not launch a query VM and will never fall back to Docker or gVisor.', + 'AWF will not launch the configured sandbox and will never fall back to Docker or gVisor.', ); } }, docker: async () => { if (!(await queryDockerAvailable())) { throw new Error( - 'boundedQueries.runtime "docker" requires a reachable Docker daemon. It is not available, ' + - 'and bounded queries never fall back to another runtime.', + `${runtimeConfigPath} "docker" requires a reachable Docker daemon. It is not available, ` + + 'and the configured sandbox will never fall back to another runtime.', ); } }, gvisor: async () => { if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { throw new Error( - `boundedQueries.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + - 'registered with the Docker daemon. It is not available, and bounded queries never fall back ' + + `${runtimeConfigPath} "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + + 'registered with the Docker daemon. It is not available, and the configured sandbox will never fall back ' + 'to a weaker runtime.', ); } @@ -285,8 +286,8 @@ export async function assertQueryRuntimeAvailable( custom: async () => { if (!(await queryDockerRuntime(GVISOR_DOCKER_RUNTIME))) { throw new Error( - `boundedQueries.runtime "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + - 'registered with the Docker daemon. It is not available, and bounded queries never fall back ' + + `${runtimeConfigPath} "gvisor" requires the "${GVISOR_DOCKER_RUNTIME}" OCI runtime to be ` + + 'registered with the Docker daemon. It is not available, and the configured sandbox will never fall back ' + 'to a weaker runtime.', ); } @@ -294,8 +295,8 @@ export async function assertQueryRuntimeAvailable( defaultDocker: async () => { if (!(await queryDockerAvailable())) { throw new Error( - 'boundedQueries.runtime "docker" requires a reachable Docker daemon. It is not available, ' + - 'and bounded queries never fall back to another runtime.', + `${runtimeConfigPath} "docker" requires a reachable Docker daemon. It is not available, ` + + 'and the configured sandbox will never fall back to another runtime.', ); } }, diff --git a/src/docker-manager-diagnostics.test.ts b/src/docker-manager-diagnostics.test.ts index 0e304fdc5..6bf139362 100644 --- a/src/docker-manager-diagnostics.test.ts +++ b/src/docker-manager-diagnostics.test.ts @@ -4,6 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { resolveBoundedQueryPaths } from './bounded-query/paths'; import { resolveBoundedAgentPaths } from './bounded-agent/paths'; +import { resolveEnclavePaths } from './enclave/paths'; import { mockExecaFn, mockExecaSync } from './test-helpers/mock-execa.test-utils'; import { useTempDir } from './test-helpers/docker-test-fixtures.test-utils'; @@ -235,5 +236,41 @@ describe('docker-manager diagnostics', () => { fs.rmSync(resolveBoundedAgentPaths(getDir()).root, { recursive: true, force: true }); fs.rmSync(resolveBoundedAgentPaths(getDir()).ingressRoot, { recursive: true, force: true }); }); + + it('should copy enclave protected audit and runtime telemetry before cleanup', () => { + const enclaveRoot = resolveEnclavePaths(getDir()).root; + fs.mkdirSync(enclaveRoot, { recursive: true }); + const auditDir = path.join(getDir(), 'audit'); + fs.mkdirSync(auditDir); + + preserveIptablesAudit(getDir(), auditDir); + + expect(mockExecaSync).toHaveBeenCalledWith( + 'docker', + ['cp', 'awf-enclave-mcp-server:/var/log/awf-enclave/enclave.jsonl', path.join(auditDir, 'enclave.jsonl')], + expect.objectContaining({ reject: false }), + ); + expect(mockExecaSync).toHaveBeenCalledWith( + 'docker', + [ + 'cp', + 'awf-enclave-mcp-server:/var/log/awf-enclave/runtime-telemetry.jsonl', + path.join(auditDir, 'enclave-runtime.jsonl'), + ], + expect.objectContaining({ reject: false }), + ); + fs.rmSync(enclaveRoot, { recursive: true, force: true }); + }); + + it('should not copy enclave audit files when the enclave root is absent', () => { + const enclaveRoot = resolveEnclavePaths(getDir()).root; + fs.rmSync(enclaveRoot, { recursive: true, force: true }); + const auditDir = path.join(getDir(), 'audit'); + fs.mkdirSync(auditDir); + + preserveIptablesAudit(getDir(), auditDir); + + expect(mockExecaSync).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/enclave/manager.ts b/src/enclave/manager.ts index 39b89716f..63c718c27 100644 --- a/src/enclave/manager.ts +++ b/src/enclave/manager.ts @@ -98,7 +98,13 @@ export async function prepareEnclaves( await (deps.assertPrimaryAvailable ?? assertPrimaryRuntimeAvailable)(config.containerRuntime); const assertRuntime = deps.assertScriptRuntimeAvailable ?? ((script: EnclaveScriptExecutorConfig) => ( - assertQueryRuntimeAvailable(script as unknown as BoundedQueriesConfig) + assertQueryRuntimeAvailable( + script as unknown as BoundedQueriesConfig, + undefined, + undefined, + undefined, + 'enclaves.executors.script.runtime', + ) )); await assertRuntime(enclaves.executors.script); diff --git a/src/enclave/mcp-server.test.ts b/src/enclave/mcp-server.test.ts index 93a368e37..19694c5c5 100644 --- a/src/enclave/mcp-server.test.ts +++ b/src/enclave/mcp-server.test.ts @@ -339,4 +339,50 @@ describe('unified enclave ledger and timing', () => { expect(response).toBe(CANONICAL_ERROR_JSON); expect(now - startedAt).toBe(10); }); + + it('starts an exhausted invocation timing bucket after queued work completes', async () => { + let now = 0; + const sleeps: number[] = []; + const broker = createBroker({ + config: { + maxInvocations: 1, + timeoutSeconds: 30, + primaryBackend: 'docker', + queryBackend: 'docker', + }, + 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() }, + ledger: { tryDebit: () => true }, + executorKind: 'script', + uniformTiming: true, + clock: { + nowMs: () => now, + sleep: async (ms: number) => { + sleeps.push(ms); + now += ms; + }, + }, + runner: { + runQueryContainer: async () => { + now += 50; + return { exitCode: 0, timedOut: false }; + }, + }, + workspace: { + createInvocationWorkspace: () => ({ outPath: 'unused' }), + readQueryOutput: () => 'true', + destroyInvocationWorkspace: () => undefined, + }, + }); + const responses: string[] = []; + const first = broker.handle(validArguments, (value: string) => responses.push(value)); + const second = broker.handle(validArguments, (value: string) => responses.push(value)); + await Promise.all([first, second]); + + expect(responses).toEqual(['{"status":"ok","result":true}', CANONICAL_ERROR_JSON]); + expect(sleeps).toEqual([50, 10]); + expect(now).toBe(110); + }); });