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
6 changes: 3 additions & 3 deletions containers/bounded-query/enclave-mcp/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
#
# This image owns the Docker socket and the private seed/work/audit mounts for
# *both* enclave executors, and its Compose service always runs with
# `network_mode: none` — it has no `awf-net`, no enclave network, no DNS, no
# Squid, no host gateway, and no egress of any kind. Its only agent-facing
# surface is one authenticated Unix socket.
# only the dedicated internal MCP control network. It has no `awf-net`, enclave
# executor network, Squid, host gateway, published port, or external egress. Its
# only caller-facing surface is authenticated streamable HTTP through mcpg.
#
# BUILD CONTEXT: `containers/` (not `containers/bounded-query/`). The server
# drives two audited executors that live in two directories:
Expand Down
29 changes: 8 additions & 21 deletions containers/bounded-query/enclave-mcp/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ const { MAX_TASK_BYTES } = require('../agent-broker/framing');
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 CAPABILITY_DIR = '/run/awf-enclave-mcp';
const CAPABILITY_PATH = path.join(CAPABILITY_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');
const MCP_PORT = 8080;

/**
* Fixed agent-enclave mount points and identity. Never caller-supplied.
Expand Down Expand Up @@ -59,16 +60,6 @@ function positiveInt(name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
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)) {
Expand Down Expand Up @@ -105,8 +96,8 @@ function loadConfig(files = fs) {
workDir: WORK_DIR,
seedMapPath: SEED_MAP_PATH,
hostWorkDir: requireEnv('AWF_ENCLAVE_HOST_WORK_DIR'),
socketDir: SOCKET_DIR,
socketPath: path.join(SOCKET_DIR, 'server.sock'),
listenHost: process.env.AWF_ENCLAVE_LISTEN_HOST || '0.0.0.0',
listenPort: MCP_PORT,
controlDir: CONTROL_DIR,
readyPath: READY_PATH,
auditDir: AUDIT_DIR,
Expand All @@ -126,8 +117,6 @@ function loadConfig(files = fs) {
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,
Expand Down Expand Up @@ -162,14 +151,12 @@ function loadServerConfig(files = fs) {
}
return {
seedMapPath: SEED_MAP_PATH,
socketDir: SOCKET_DIR,
socketPath: path.join(SOCKET_DIR, 'server.sock'),
listenHost: process.env.AWF_ENCLAVE_LISTEN_HOST || '0.0.0.0',
listenPort: MCP_PORT,
controlDir: CONTROL_DIR,
readyPath: READY_PATH,
auditDir: AUDIT_DIR,
primaryBackend,
socketUid: nonnegativeInt('AWF_ENCLAVE_SOCKET_UID', 0),
socketGid: nonnegativeInt('AWF_ENCLAVE_SOCKET_GID', 0),
capability,
};
}
Expand Down Expand Up @@ -268,7 +255,7 @@ module.exports = {
READY_PATH,
SEED_MAP_PATH,
SEEDS_DIR,
SOCKET_DIR,
CAPABILITY_DIR,
WORK_DIR,
isAgentExecutorEnabled,
isScriptExecutorEnabled,
Expand Down
25 changes: 20 additions & 5 deletions containers/bounded-query/enclave-mcp/mcp-protocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const TOOL_NAME = 'enclave_run_script';
const AGENT_TOOL_NAME = 'enclave_run_agent';
const JSONRPC_ERROR = Object.freeze({ status: 'error' });

function canonicalToolError() {
return {
content: [{ type: 'text', text: '{"status":"error"}' }],
structuredContent: JSONRPC_ERROR,
};
}

const FINITE_SCHEMA_INPUT = Object.freeze({
type: 'object',
description: 'An AWF finite-disclosure schema (const, boolean, enum, integer, object, tuple, array, or union).',
Expand Down Expand Up @@ -145,10 +152,7 @@ function brokerCall(broker, request) {
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,
});
resolve(canonicalToolError());
return;
}
resolve({
Expand Down Expand Up @@ -216,7 +220,18 @@ async function dispatchJsonRpc(message, deps) {
&& Buffer.byteLength(args[payloadKey], 'utf8') > limit
);
const request = tooLarge ? undefined : args;
return rpcResult(message.id, await brokerCall(brokers[name], request));
let release;
if (typeof deps.tryAcquireToolCall === 'function') {
release = deps.tryAcquireToolCall();
if (typeof release !== 'function') {
return rpcResult(message.id, canonicalToolError());
}
}
try {
return rpcResult(message.id, await brokerCall(brokers[name], request));
} finally {
if (release) release();
}
}

return rpcError(message.id, -32601, 'Method not found');
Expand Down
36 changes: 21 additions & 15 deletions containers/bounded-query/enclave-mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,22 @@ function readBody(req) {
});
}

function createSingleToolAdmission() {
let active = false;
return () => {
if (active) return undefined;
active = true;
return () => {
active = false;
};
};
}

function createMcpServer(deps) {
const dispatchDeps = {
...deps,
tryAcquireToolCall: createSingleToolAdmission(),
};
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',
Expand Down Expand Up @@ -105,7 +120,7 @@ function createMcpServer(deps) {

let response;
try {
response = await dispatchJsonRpc(message, deps);
response = await dispatchJsonRpc(message, dispatchDeps);
} catch {
jsonResponse(res, 200, {
jsonrpc: '2.0',
Expand All @@ -128,20 +143,10 @@ function createMcpServer(deps) {
return server;
}

function listenOnSocket(server, config) {
fs.rmSync(config.socketPath, { force: true });
fs.mkdirSync(config.socketDir, { recursive: true, mode: 0o700 });
function listenOnPrivateNetwork(server, config) {
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);
}
});
server.listen(config.listenPort, config.listenHost, resolve);
});
}

Expand Down Expand Up @@ -234,7 +239,7 @@ async function main() {
maxScriptBytes,
maxPromptBytes,
});
await listenOnSocket(server, serverConfig);
await listenOnPrivateNetwork(server, serverConfig);
fs.mkdirSync(serverConfig.controlDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(serverConfig.readyPath, '', { mode: 0o600 });
audit.lifecycle('listening', { executors });
Expand Down Expand Up @@ -283,6 +288,7 @@ if (require.main === module) {
module.exports = {
MAX_HTTP_BODY_BYTES,
createMcpServer,
listenOnSocket,
createSingleToolAdmission,
listenOnPrivateNetwork,
safeCapabilityEquals,
};
70 changes: 63 additions & 7 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2446,8 +2446,9 @@ private-repository execution. One AWF-owned, no-egress MCP service exposes the
enabled executors: the script executor launches hardened single-use script
containers with no network, and the agent executor launches hardened single-use
enclaves that run a fixed, AWF-authored model loop on a dedicated
API-proxy-only network. The service is not yet attached to the primary agent; a
later migration layer registers it exclusively through `gh-aw-mcpg`. See
API-proxy-only network. The service is reachable exclusively through a
compiler-launched, run-labelled `gh-aw-mcpg` gateway on an AWF-owned private
control network. See
[Unified Enclave Architecture and Migration](enclaves-architecture.md).

`enclaves.privateRepos` is the single trusted repository list for every
Expand Down Expand Up @@ -2493,21 +2494,76 @@ coexist because they do not activate a runtime.
The AWF-owned MCP server enforces the unified per-repository ledger for both
executors: a script call and an agent call debit the same live balance, and
switching executor kinds never resets or forks it. Both executors also share one
serialization lane inside the server. Legacy brokers retain their existing
independent behavior until runtime cutover.
serialization lane inside the server. The HTTP surface admits only one tool call
to that lane at a time; concurrent calls receive the same canonical error
without entering a queue. Legacy brokers retain their existing independent
behavior until runtime cutover.

### 16.1 Agent executor topology and disclosure

Agent enclaves join only the dedicated `internal` `awf-enclave-agent` network
(172.31.0.0/24). Its only other member is a dedicated API proxy that also joins
a separate egress bridge and is the only holder of a real provider credential.
The MCP server runs `network_mode: none` and is never on that network; neither
is the primary agent, Squid, the general API proxy, the safe-outputs collector,
the MCP gateway, or the CLI proxy. The dedicated proxy's credentials are
The MCP server is never on that network. It joins only the separate `internal`
`awf-enclave-mcp-control` network with the externally launched MCP gateway; the
primary agent, Squid, general API proxy, safe-outputs collector, CLI proxy, and
all enclave executors are excluded. The server publishes no host port. The
dedicated agent-enclave proxy's credentials are
minimized to the configured route, its external telemetry export and Actions
OIDC token-exchange state are removed, and its logs stay in the enclave-private
root.

### 16.2 Exclusive MCP gateway handoff

Enclaves require a compiler-generated handoff before staging:

| Variable | Contract |
|----------|----------|
| `AWF_ENCLAVE_MCP_CAPABILITY` | Fresh 64-character lowercase hexadecimal bearer capability passed only to mcpg and AWF |
| `AWF_ENCLAVE_MCP_GATEWAY_CONTAINER` | External gateway container name; `awmg-mcpg` by default |
| `AWF_ENCLAVE_MCP_GATEWAY_IDENTITY` | Run-unique value equal to the gateway's `com.github.gh-aw.mcpg.run` label |
| `AWF_ENCLAVE_MCP_GATEWAY_ENDPOINT` | Host-reachable gateway route ending in `/mcp/awf-enclave` |
| `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` | Optional bounded AWF end-to-end readiness window, 1000-600000 ms; default 120000 |

All five names are unconditionally excluded from primary-agent environment
passthrough. The mcpg upstream is named `awf-enclave`, uses
`http://awf-enclave-mcp:8080/mcp`, supplies
`Authorization: Bearer ${AWF_ENCLAVE_MCP_CAPABILITY}`, allowlists exactly the
enabled enclave tool names, sets each upstream attempt's `connectTimeout` to 120
seconds, and sets the per-tool timeout to 630 seconds (the maximum 600-second
fixed disclosure bucket plus a bounded 30-second gateway allowance).
`gateway.startupTimeout` is stdio-only and MUST NOT be
used as the HTTP recovery bound.

The compiler must also enable `network.isolation` and include the configured
gateway container in `network.topologyAttach`. The enclave server itself is not
a topology peer: its alias is never added to agent `NO_PROXY`, Squid ACLs, or
static hosts.

AWF starts Compose infrastructure without the primary agent, attaches only the
label-matching gateway to `awf-enclave-mcp-control`, verifies the network has
exactly those two members, then performs `initialize` and `tools/list` through
the gateway route. The complete static tool contracts must match. Failure,
timeout, authentication failure, identity mismatch, or tool mismatch aborts
before Compose starts the agent or sbx creates its sandbox.

This integration requires MCP Gateway specification 1.15.0 and the first mcpg
release after v0.4.8 containing github/gh-aw-mcpg#10784. Until the upstream is
available, mcpg returns retryable HTTP 503 `backend_unavailable`; AWF retries
`initialize` with a bounded 500 ms backoff until
`AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` expires. AWF does not log the 503 response
body, request headers, bearer capability, or other secret material. Any other
HTTP response, malformed recovery response, authentication failure, protocol
failure, or tool-contract mismatch fails immediately. Every readiness request is
capped by the remaining AWF readiness budget, so the configured deadline is a
hard upper bound for the complete handshake.

On shutdown AWF stops the primary-agent work first, sends the server a
630-second bounded graceful stop covering the maximum fixed disclosure bucket
plus the stop allowance to close admissions and drain calls, disconnects but does not stop
the externally owned gateway, then lets Compose remove the AWF-owned control
network and private service.

Each enclave is single-use: immutable seed mounted read-only, `--read-only`
root, bounded `tmpfs`, fixed non-root uid/gid, `--cap-drop ALL`,
`no-new-privileges`, seccomp, and memory/CPU/PID/file-size/timeout bounds. Every
Expand Down
2 changes: 1 addition & 1 deletion docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1105,7 +1105,7 @@
},
"enclaves": {
"type": "object",
"description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes the enabled executors through one AWF-owned, no-egress MCP server; that server is not yet attached to the primary agent.",
"description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, and every invocation debits one live per-repository information budget regardless of executor kind. AWF exposes enabled executors only through an AWF-owned MCP server and the compiler-launched trusted mcpg gateway.",
"additionalProperties": false,
"properties": {
"enabled": {
Expand Down
Loading
Loading