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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ jobs:
query_digest: ${{ steps.build_bounded_query.outputs.digest }}
broker_digest: ${{ steps.build_bounded_query_broker.outputs.digest }}
enclave_script_digest: ${{ steps.build_enclave_script.outputs.digest }}
enclave_agent_digest: ${{ steps.build_enclave_agent.outputs.digest }}
enclave_mcp_server_digest: ${{ steps.build_enclave_mcp_server.outputs.digest }}
steps:
- name: Checkout code
Expand Down Expand Up @@ -483,11 +484,50 @@ jobs:
--type spdxjson \
ghcr.io/${{ github.repository }}/enclave-script@${{ steps.build_enclave_script.outputs.digest }}

- name: Build and push Enclave Agent image
id: build_enclave_agent
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
# The unified enclave agent executor reuses the audited native
# bounded-agent enclave target verbatim, published under its own name.
context: ./containers
file: ./containers/bounded-agent/Dockerfile
target: enclave
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}/enclave-agent:${{ needs.bump-version.outputs.version_number }}
ghcr.io/${{ github.repository }}/enclave-agent:latest
cache-from: type=gha,scope=enclave-agent
cache-to: type=gha,mode=max,scope=enclave-agent

- name: Sign Enclave Agent image with cosign
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }}

- name: Generate SBOM for Enclave Agent image
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
with:
image: ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }}
format: spdx-json
output-file: enclave-agent-sbom.spdx.json

- name: Attest SBOM for Enclave Agent image
run: |
cosign attest --yes \
--predicate enclave-agent-sbom.spdx.json \
--type spdxjson \
ghcr.io/${{ github.repository }}/enclave-agent@${{ steps.build_enclave_agent.outputs.digest }}

- name: Build and push Enclave MCP Server image
id: build_enclave_mcp_server
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: ./containers/bounded-query
# The server drives both enclave executors, so its context spans
# containers/bounded-query and containers/bounded-agent.
context: ./containers
file: ./containers/bounded-query/enclave-mcp/Dockerfile
target: enclave-mcp-server
push: true
platforms: linux/amd64,linux/arm64
Expand Down Expand Up @@ -959,6 +999,7 @@ jobs:
"ghcr.io/${{ github.repository }}/bounded-query@${{ needs['build-bounded-query'].outputs.query_digest }}" \
"ghcr.io/${{ github.repository }}/bounded-query-broker@${{ needs['build-bounded-query'].outputs.broker_digest }}" \
"ghcr.io/${{ github.repository }}/enclave-script@${{ needs['build-bounded-query'].outputs.enclave_script_digest }}" \
"ghcr.io/${{ github.repository }}/enclave-agent@${{ needs['build-bounded-query'].outputs.enclave_agent_digest }}" \
"ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ needs['build-bounded-query'].outputs.enclave_mcp_server_digest }}" \
"ghcr.io/${{ github.repository }}/bounded-agent@${{ needs['build-bounded-agent'].outputs.enclave_digest }}" \
"ghcr.io/${{ github.repository }}/bounded-agent-broker@${{ needs['build-bounded-agent'].outputs.broker_digest }}" \
Expand Down
2 changes: 2 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ runs:
API_PROXY_DIGEST="$(extract_digest api-proxy || true)"
CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)"
ENCLAVE_SCRIPT_DIGEST="$(extract_digest enclave-script || true)"
ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)"
ENCLAVE_MCP_SERVER_DIGEST="$(extract_digest enclave-mcp-server || true)"

[ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}")
Expand All @@ -149,6 +150,7 @@ runs:
[ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}")
[ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}")
[ -n "${ENCLAVE_SCRIPT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-script=${ENCLAVE_SCRIPT_DIGEST}")
[ -n "${ENCLAVE_AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-agent=${ENCLAVE_AGENT_DIGEST}")
[ -n "${ENCLAVE_MCP_SERVER_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-mcp-server=${ENCLAVE_MCP_SERVER_DIGEST}")

if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then
Expand Down
16 changes: 10 additions & 6 deletions containers/api-proxy/server.models.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,22 @@ describe('makeModelBodyTransform', () => {
const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);

try {
let isolatedServer;
const refreshModels = jest.fn().mockResolvedValue(undefined);
let transform;
jest.isolateModules(() => {
isolatedServer = require('./server');
const { makeModelBodyTransform: makeTransform } = require('./model-config');
transform = makeTransform(
'openai',
{ openai: ['gpt-5.2', 'gpt-4.1', 'gpt-3.5-turbo'] },
refreshModels,
() => new Set(['openai']),
);
});

stdoutSpy.mockClear();
isolatedServer.resetModelCacheState();
isolatedServer.cachedModels.openai = ['gpt-5.2', 'gpt-4.1', 'gpt-3.5-turbo'];

const transform = isolatedServer.makeModelBodyTransform('openai');
const transformed = await transform(Buffer.from(JSON.stringify({ model: 'sonnet', messages: [] })));
expect(transformed).toBeInstanceOf(Buffer);
expect(refreshModels).toHaveBeenCalledWith('openai');

const records = stdoutSpy.mock.calls
.map(([line]) => String(line).trim())
Expand Down
27 changes: 23 additions & 4 deletions containers/bounded-agent/broker/enclave-runner-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ const ENCLAVE_MAX_FILE_BYTES = 32 * 1024 * 1024;

const RUN_LABEL = 'awf.bounded-agent.run';
const INVOCATION_LABEL = 'awf.bounded-agent.invocation';

/**
* Unified-enclave labels.
*
* The unified enclave MCP server launches agent enclaves with these labels so
* one AWF-side reconciliation pass (`awf.enclave.run=<runId>`) deterministically
* removes every orphaned enclave container, script or agent, without knowing
* which executor created it. Legacy bounded agents keep the labels above.
*/
const ENCLAVE_RUN_LABEL = 'awf.enclave.run';
const ENCLAVE_INVOCATION_LABEL = 'awf.enclave.invocation';
const TRUSTED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;

/** Converts a monotonic-clock duration to the integer milliseconds Node requires. */
Expand Down Expand Up @@ -65,11 +76,17 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
throw new Error(`Unsupported OCI runtime in enclave runner: ${runtimeName}`);
}

const containerName = `awf-bounded-agent-${runId.slice(0, 12)}-${invocationId}`;
// Label keys and the container prefix are trusted broker configuration, not
// request data. Omitting them preserves the legacy bounded-agent naming
// byte-for-byte.
const runLabelKey = config.runLabelKey || RUN_LABEL;
const invocationLabelKey = config.invocationLabelKey || INVOCATION_LABEL;
const containerPrefix = config.containerPrefix || 'awf-bounded-agent';
const containerName = `${containerPrefix}-${runId.slice(0, 12)}-${invocationId}`;
const hostInvocationDir = `${config.hostWorkDir}/${invocationId}`;
const hostSeedDir = `${config.hostSeedsDir}/${seedId}`;
const runLabel = `${RUN_LABEL}=${runId}`;
const invocationLabel = `${INVOCATION_LABEL}=${invocationId}`;
const runLabel = `${runLabelKey}=${runId}`;
const invocationLabel = `${invocationLabelKey}=${invocationId}`;
const launchArgs = [
'run',
'--pull', 'never',
Expand All @@ -92,7 +109,7 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti
'--tmpfs',
`${config.enclaveMountDir}:rw,nosuid,nodev,size=${config.tmpfsLimit},` +
`uid=${config.enclaveUid},gid=${config.enclaveGid},mode=0700`,
'--hostname', 'bounded-agent',
'--hostname', config.enclaveHostname || 'bounded-agent',
'--workdir', config.enclaveSeedPath,
'--env', `AWF_BOUNDED_AGENT_ENGINE=${config.engine}`,
'--env', `HOME=${config.enclaveMountDir}/home`,
Expand Down Expand Up @@ -148,7 +165,9 @@ function buildRemoveArgs(containerIds) {

module.exports = {
CLI_GRACE_MS,
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
INVOCATION_LABEL,
RUN_LABEL,
buildEnclaveArgs,
Expand Down
4 changes: 4 additions & 0 deletions containers/bounded-agent/broker/enclave-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ const { DockerEnclaveRunner } = require('./docker-enclave-runner');
const { GvisorEnclaveRunner } = require('./gvisor-enclave-runner');
const { SbxEnclaveRunner } = require('./sbx-enclave-runner');
const {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
buildEnclaveArgs,
deriveEnclaveContainerSpec,
normalizeTimeoutMs,
Expand Down Expand Up @@ -51,7 +53,9 @@ function createEnclaveRunner(config, deps = {}) {
}

module.exports = {
ENCLAVE_INVOCATION_LABEL,
ENCLAVE_MAX_FILE_BYTES,
ENCLAVE_RUN_LABEL,
buildEnclaveArgs,
createEnclaveRunner,
deriveEnclaveContainerSpec,
Expand Down
44 changes: 35 additions & 9 deletions containers/bounded-agent/broker/framing.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,43 @@ const ALLOWED_AWF_HEADERS = new Set([VERSION_HEADER, REPO_HEADER, SCHEMA_HEADER]
/** The complete set of keys a bounded-agent request may contain. */
const ALLOWED_REQUEST_KEYS = ['privateRepo', 'schema', 'task'];

/**
* Every accepted spelling of the single free-form payload field.
*
* Exactly one of these is accepted per caller surface (`task` for the legacy
* bounded-agent wrapper protocol, `prompt` for the unified enclave MCP tool);
* the other is an explicitly forbidden control so a request can never smuggle
* a second payload past the finite-disclosure charge.
*/
const PAYLOAD_KEYS = ['task', 'prompt'];

/**
* Controls a request may never express.
*
* Redundant with the unknown-key rule below by construction; kept explicit so
* an accidental future widening of the accepted key set fails a test instead of
* silently granting a capability.
*/
const FORBIDDEN_REQUEST_KEYS = [
const BASE_FORBIDDEN_REQUEST_KEYS = [
'image', 'images', 'command', 'cmd', 'args', 'argv', 'entrypoint', 'executable',
'interpreter', 'script', 'shell', 'mount', 'mounts', 'volume', 'volumes', 'bind',
'path', 'paths', 'workdir', 'env', 'environment', 'endpoint', 'endpoints', 'baseUrl',
'url', 'host', 'network', 'networks', 'dns', 'proxy', 'httpProxy', 'httpsProxy',
'credential', 'credentials', 'apiKey', 'token', 'authorization', 'headers',
'timeout', 'timeoutSeconds', 'deadline', 'memory', 'memoryLimit', 'cpu', 'cpuLimit',
'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'sandbox',
'pids', 'pidsLimit', 'tmpfs', 'ulimit', 'resources', 'runtime', 'backend', 'engine', 'sandbox',
'profile', 'model', 'provider', 'temperature', 'maxTokens', 'maxModelRequests',
'tool', 'tools', 'toolChoice', 'functions', 'systemPrompt', 'system', 'messages',
];

/** Forbidden controls for one caller surface: everything plus the other payload spelling. */
function forbiddenKeysFor(payloadKey) {
return BASE_FORBIDDEN_REQUEST_KEYS.concat(PAYLOAD_KEYS.filter((key) => key !== payloadKey));
}

/** Forbidden controls for the legacy `task` wrapper surface. */
const FORBIDDEN_REQUEST_KEYS = forbiddenKeysFor('task');

/** Base64url alphabet only (no padding, no `+`/`/`). */
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;

Expand Down Expand Up @@ -155,19 +173,24 @@ function validateBoundedAgentRequest(raw, options = {}) {
return { valid: false, errors: ['request must be a JSON object'] };
}

const forbidden = FORBIDDEN_REQUEST_KEYS.filter(
// Trusted caller-surface selection, never request data. Exactly one payload
// spelling is accepted; the others stay forbidden controls.
const payloadKey = PAYLOAD_KEYS.includes(options.payloadKey) ? options.payloadKey : 'task';
const allowedKeys = ['privateRepo', 'schema', payloadKey];
const forbidden = forbiddenKeysFor(payloadKey).filter(
(key) => Object.prototype.hasOwnProperty.call(raw, key),
);
for (const key of forbidden) {
errors.push(`request may not specify "${key}"`);
}
for (const key of Object.keys(raw)) {
if (!ALLOWED_REQUEST_KEYS.includes(key) && !forbidden.includes(key)) {
if (!allowedKeys.includes(key) && !forbidden.includes(key)) {
errors.push(`unknown request key: "${key}"`);
}
}

const { privateRepo, schema, task } = raw;
const { privateRepo, schema } = raw;
const task = raw[payloadKey];

if (typeof privateRepo !== 'string') {
errors.push('privateRepo must be a string');
Expand All @@ -187,18 +210,18 @@ function validateBoundedAgentRequest(raw, options = {}) {
: MAX_TASK_BYTES;
const taskLimit = Math.min(configuredLimit, MAX_TASK_BYTES);
if (typeof task !== 'string') {
errors.push('task must be a string');
errors.push(`${payloadKey} must be a string`);
} else if (task.length === 0) {
errors.push('task must not be empty');
errors.push(`${payloadKey} must not be empty`);
} else if (Buffer.byteLength(task, 'utf8') > taskLimit) {
errors.push('task exceeds the maximum size');
errors.push(`${payloadKey} exceeds the maximum size`);
}

if (errors.length > 0) return { valid: false, errors };

return {
valid: true,
request: { privateRepo, schema: schemaValidation.schema, task },
request: { privateRepo, schema: schemaValidation.schema, [payloadKey]: task },
};
}

Expand Down Expand Up @@ -252,8 +275,11 @@ function readBoundedBody(req) {
module.exports = {
AGENT_PROTOCOL_VERSION,
ALLOWED_REQUEST_KEYS,
MAX_TASK_BYTES,
PAYLOAD_KEYS,
BODY_READ_TIMEOUT_MS,
FORBIDDEN_REQUEST_KEYS,
forbiddenKeysFor,
REPO_HEADER,
SCHEMA_HEADER,
VERSION_HEADER,
Expand Down
19 changes: 4 additions & 15 deletions containers/bounded-query/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,7 @@ USER root

ENTRYPOINT ["node", "/opt/awf/broker/server.js"]

# AWF-owned unified enclave MCP server. This distinct image owns the Docker
# socket and private seed/work/audit mounts; its later Compose service must use
# network_mode: none. Script sandboxes remain the existing minimal query image.
FROM broker AS enclave-mcp-server

COPY enclave-mcp/ /opt/awf/enclave-mcp/
RUN chmod -R a-w /opt/awf/enclave-mcp \
&& node --check /opt/awf/enclave-mcp/config.js \
&& node --check /opt/awf/enclave-mcp/mcp-protocol.js \
&& node --check /opt/awf/enclave-mcp/server.js \
&& node --check /opt/awf/enclave-mcp/healthcheck.js \
&& mkdir -p /srv/awf/seeds /srv/awf/work \
/run/awf-enclave-mcp /run/awf-enclave-mcp-control /var/log/awf-enclave

ENTRYPOINT ["node", "/opt/awf/enclave-mcp/server.js"]
# The AWF-owned unified enclave MCP server is built from its own Dockerfile
# (`enclave-mcp/Dockerfile`) with the wider `containers/` build context,
# because it drives both the bounded-script executor in this directory and the
# audited bounded-agent enclave executor under `containers/bounded-agent/`.
13 changes: 13 additions & 0 deletions containers/bounded-query/agent-broker/enclave-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
//
// The published enclave-mcp-server image receives the real, audited
// bounded-agent enclave modules at /opt/awf/agent-broker (see
// bounded-query/enclave-mcp/Dockerfile, which COPYs
// containers/bounded-agent/broker/ there). This file exists only so the same
// `../agent-broker/enclave-runner` specifier also resolves when the enclave MCP
// modules are required directly from the source tree (unit tests,
// `node --check`), without duplicating a security-critical implementation
// into a second directory.
module.exports = require('../../bounded-agent/broker/enclave-runner');
13 changes: 13 additions & 0 deletions containers/bounded-query/agent-broker/framing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
//
// The published enclave-mcp-server image receives the real, audited
// bounded-agent enclave modules at /opt/awf/agent-broker (see
// bounded-query/enclave-mcp/Dockerfile, which COPYs
// containers/bounded-agent/broker/ there). This file exists only so the same
// `../agent-broker/framing` specifier also resolves when the enclave MCP
// modules are required directly from the source tree (unit tests,
// `node --check`), without duplicating a security-critical implementation
// into a second directory.
module.exports = require('../../bounded-agent/broker/framing');
13 changes: 13 additions & 0 deletions containers/bounded-query/agent-broker/workspace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

// Source-tree resolution shim — NOT shipped in the enclave MCP server image.
//
// The published enclave-mcp-server image receives the real, audited
// bounded-agent enclave modules at /opt/awf/agent-broker (see
// bounded-query/enclave-mcp/Dockerfile, which COPYs
// containers/bounded-agent/broker/ there). This file exists only so the same
// `../agent-broker/workspace` specifier also resolves when the enclave MCP
// modules are required directly from the source tree (unit tests,
// `node --check`), without duplicating a security-critical implementation
// into a second directory.
module.exports = require('../../bounded-agent/broker/workspace');
Loading
Loading