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
70 changes: 70 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}" \
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}")"
Expand Down
16 changes: 16 additions & 0 deletions containers/bounded-query/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
25 changes: 16 additions & 9 deletions containers/bounded-query/bounded-execution/sensitivity-ledger.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -24,10 +24,10 @@ const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity-policy');
* @param seeds `Map<normalizedRepoKey, { seedId, sensitivity }>` 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 {
Expand All @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 32 additions & 12 deletions containers/bounded-query/broker/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -81,28 +86,33 @@ 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;
const repoKey = privateRepo.toLowerCase();

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;
}

Expand All @@ -111,18 +121,16 @@ 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;
}

// From here on the charge is committed (never refunded) and every
// 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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 queued = tail.then(async () => {
const startMs = clock.nowMs();
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();
}
Expand Down
24 changes: 17 additions & 7 deletions containers/bounded-query/broker/query-runner-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions containers/bounded-query/broker/workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Loading
Loading