From be89c85a8c546281bac9ea23f75b143e5c7512f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:37:44 +0000 Subject: [PATCH 1/5] Initial plan From a694631e43fae0e803848a7872c8fb5e5a96bc6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:57:46 +0000 Subject: [PATCH 2/5] feat: align enclave config with gh-aw keyed-array frontmatter contract --- CLAUDE.md | 4 +- docs/awf-config-spec.md | 90 ++-- docs/awf-config.schema.json | 483 +++++++++------------ docs/enclaves-architecture.md | 6 +- docs/sbx-integration.md | 4 +- src/awf-config-schema.json | 483 +++++++++------------ src/bounded-execution/finite-disclosure.ts | 2 +- src/cli-workflow.test.ts | 8 +- src/commands/build-config.test.ts | 8 +- src/config-file-mapping.test.ts | 8 +- src/enclave/gateway.test.ts | 22 +- src/enclave/manager.test.ts | 63 ++- src/enclave/preflight.test.ts | 188 ++++---- src/enclave/preflight.ts | 66 +-- src/enclave/workflow-integration.test.ts | 8 +- src/parsers/enclave-parser.test.ts | 164 ++++--- src/parsers/enclave-parser.ts | 81 +++- src/services/enclave-agent-service.test.ts | 68 ++- src/services/enclave-mcp-service.test.ts | 28 +- src/types/enclave-options.ts | 41 +- 20 files changed, 910 insertions(+), 915 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf07a74dd..d54dd517a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,11 +29,11 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - Ports: 10000 (OpenAI), 10001 (Anthropic), 10002 (Copilot), 10003 (Gemini) — these are discrete ports, not a contiguous range **4. Unified Enclaves (optional)** — `containers/enclave/` -- Enabled via `enclaves.enabled` in the AWF config file +- Enabled by declaring `enclaves` entries in the AWF config file - One AWF-owned MCP server (`enclave-mcp-server`) exposes enabled enclave executors only through compiler-launched `gh-aw-mcpg`; the primary agent gets no direct enclave socket, wrapper binary, capability, or private transport - `enclave_run_script` launches a no-network, read-only, single-use Python executor and returns one canonical JSON result - `enclave_run_agent` launches a single-use Copilot enclave on the dedicated `internal` `awf-enclave-agent` network whose sole peer is the dedicated API proxy; Squid, the primary agent, the general API proxy, safe outputs, and the MCP gateway are excluded -- Script and agent executors share one trusted `enclaves.privateRepos` list, one per-run information ledger, and one AWF-owned admission lane +- Script and agent executors are configured as a top-level `enclaves` array of `script`/`agent` entries whose merged `repos` lists form one trusted repository catalog, sharing one per-run information ledger and one AWF-owned admission lane - Rollout depends on the compiler handoff contract in `github/gh-aw#50920` and late backend rediscovery in `github/gh-aw-mcpg#10784`, which requires MCP Gateway spec 1.15.0 and the first mcpg release after v0.4.8 containing it - While the gateway backend is still coming up, AWF retries retryable HTTP `503 backend_unavailable` responses within `AWF_ENCLAVE_MCP_READINESS_TIMEOUT_MS` - See [docs/enclaves-architecture.md](docs/enclaves-architecture.md) and [docs/awf-config-spec.md](docs/awf-config-spec.md) §14 diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 2e62ee7a7..65beb6717 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -265,36 +265,35 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `platform.type` → *(config-only; maps to `AWF_PLATFORM_TYPE`)* - `runner.topology` → *(config-only; sets runner deployment model — `standard` or `arc-dind`; when `arc-dind`, enables sysroot staging and emits RUNNER_TOOL_CACHE warnings)* - `runner.sysrootImage` → *(config-only; sysroot init-container image for `arc-dind` topology; defaults to `/build-tools:`, where `container.imageRegistry` defaults to `ghcr.io/github/gh-aw-firewall`)* -- `enclaves.enabled` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.privateRepos[]` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.enabled` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.runtime` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.image` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.network` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.interpreter` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.timeout` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.memoryLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.cpuLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.pidsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.maxScriptBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.script.maxInvocations` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.enabled` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.runtime` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.image` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.network` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.engine` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.profile` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.model` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.timeout` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.memoryLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.cpuLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.pidsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.maxTaskBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves.executors.agent.maxInvocations` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[]` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].repos[]` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].timeout` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.runtime` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.image` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.network` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.interpreter` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.memoryLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.cpuLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.pidsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.maxScriptBytes` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].script.maxInvocations` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.runtime` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.image` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.network` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.engine` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.profile` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.model` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.memoryLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.cpuLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.pidsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.maxTaskBytes` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.maxInvocations` → *(config-only; no CLI equivalent, see §14)* When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts. @@ -1676,16 +1675,33 @@ Each record follows the `blocked-request-diag/v` schema: ## 14. Unified Enclaves -The optional `enclaves` object defines AWF's sole supported private-repository execution surface. AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, maintains one shared per-repository ledger for the run, and exposes enabled executors only through compiler-launched `gh-aw-mcpg`. +The optional top-level `enclaves` array defines AWF's sole supported private-repository execution surface. It is structurally identical to the gh-aw compiler's enclave frontmatter: every entry declares exactly one `script` or `agent` executor, its own `repos` list, and an optional entry-level `timeout`. AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, maintains one shared per-repository ledger for the run, and exposes configured executors only through compiler-launched `gh-aw-mcpg`. ### 14.1 Executors and shared configuration -`enclaves.privateRepos` is the only trusted repository list. Every enabled executor shares it, and every admitted invocation debits the same live per-repository information budget. +```yaml +enclaves: + - script: {} + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 45 + - agent: + model: gpt-5 + repos: + - repo: octo-org/private-service + sensitivity: confidential + timeout: 180 +``` + +- **Script executor** — an entry keyed by `script`; launches a no-network, read-only, single-use Python sandbox. An empty `script: {}` object is valid and selects AWF's pinned defaults. +- **Agent executor** — an entry keyed by `agent`; launches a bounded single-use Copilot enclave whose only network peer is the dedicated API proxy. `agent.model` is REQUIRED. + +At most one entry MAY exist per executor kind, and each entry MUST declare exactly one executor key. Every entry's `repos` list is merged into one trusted repository catalog: a repository shared by both entries MUST declare the same `sensitivity`, because sensitivity fixes one shared per-run information budget that both executors debit. -- **Script executor** — configured under `enclaves.executors.script`; launches a no-network, read-only, single-use Python sandbox. -- **Agent executor** — configured under `enclaves.executors.agent`; launches a bounded single-use Copilot enclave whose only network peer is the dedicated API proxy. +`timeout` is a per-invocation wall-clock bound in seconds. It defaults to `30` for `script` entries and `120` for `agent` entries, and values above `540` are rejected. The canonical enclave MCP tools use a fixed `toolTimeout` of `630` seconds, covering the 600-second disclosure bucket plus a 30-second transport allowance. -At least one executor MUST be enabled when `enclaves.enabled` is `true`. `gvisor` requires an exactly registered `runsc` runtime and never falls back. `sbx` remains fail-closed for both executors until the audited capability proof lands. +`gvisor` requires an exactly registered `runsc` runtime and never falls back. `sbx` remains fail-closed for both executors until the audited capability proof lands. The agent executor additionally requires `enableApiProxy`, a configured provider route for its fixed engine/profile, a configured `model`, and the absence of `enableDind`. AWF validates those requirements before repository staging. @@ -1737,8 +1753,8 @@ The legacy private-repository surfaces are **removed, not deprecated**: | Removed surface | Replacement | | --- | --- | -| `boundedQueries` | `enclaves.privateRepos` + `enclaves.executors.script` | -| `boundedAgents` | `enclaves.privateRepos` + `enclaves.executors.agent` | +| `boundedQueries` | an `enclaves` entry keyed by `script` with its `repos` list | +| `boundedAgents` | an `enclaves` entry keyed by `agent` with its `repos` list | | `bounded-query` wrapper / generated skill | `enclave_run_script` | | `bounded-agent` wrapper / generated skill | `enclave_run_agent` | | Separate legacy ledgers | One shared ledger inside `enclave-mcp-server` | diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 8a6b3527c..431105875 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -981,299 +981,250 @@ } }, "enclaves": { - "type": "object", - "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, every invocation debits one live per-repository information budget regardless of executor kind, and AWF exposes enabled executors only through an AWF-owned MCP server plus the compiler-launched trusted mcpg gateway.", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable the unified enclave subsystem. Requires at least one private repository and at least one enabled executor." - }, - "privateRepos": { - "type": "array", - "minItems": 1, - "description": "Private repositories shared by every configured enclave executor. Sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", - "items": { + "type": "array", + "description": "Unified private-repository enclaves, expressed as the same keyed array the gh-aw compiler emits. Every entry declares exactly one \"script\" or \"agent\" executor, its shared \"repos\" list, and an optional entry-level \"timeout\". Repositories and sensitivities are shared by both executors, every invocation debits one live per-repository information budget regardless of executor kind, and AWF exposes configured executors only through an AWF-owned MCP server plus the compiler-launched trusted mcpg gateway.", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "description": "One executor entry. Images, runtimes, networks, models, timeouts, and resources are trusted AWF configuration and must never be accepted from an invocation request.", + "oneOf": [ + { + "required": [ + "script" + ] + }, + { + "required": [ + "agent" + ] + } + ], + "required": [ + "repos" + ], + "properties": { + "script": { + "type": "object", + "additionalProperties": false, + "description": "Script executor. An empty object selects AWF's pinned defaults.", + "properties": { + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned script-executor image." + }, + "network": { + "const": "none", + "default": "none" + }, + "interpreter": { + "const": "python3", + "default": "python3" + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxScriptBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 65536 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 + } + } + }, + "agent": { "type": "object", "additionalProperties": false, + "description": "Agent executor. \"model\" is required.", "required": [ - "repo", - "sensitivity" + "model" ], "properties": { - "repo": { + "runtime": { "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned engine image." + }, + "network": { + "const": "api-proxy-only", + "default": "api-proxy-only" }, - "sensitivity": { + "engine": { "type": "string", "enum": [ - "public", - "internal", - "confidential", - "sealed" - ] + "copilot", + "claude", + "codex", + "gemini" + ], + "default": "copilot" + }, + "profile": { + "type": "string", + "enum": [ + "openai", + "anthropic" + ], + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 4096 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 8 } } - } - }, - "executors": { - "type": "object", - "additionalProperties": false, - "description": "Trusted executor definitions. Images, runtimes, networks, models, timeouts, and resources are AWF configuration and must never be accepted from an invocation request.", - "properties": { - "script": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned script-executor image." - }, - "network": { - "const": "none", - "default": "none" - }, - "interpreter": { - "const": "python3", - "default": "python3" - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 540, - "default": 30 - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, - "maxScriptBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 65536 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 32 - } - } - }, - "agent": { + }, + "repos": { + "type": "array", + "minItems": 1, + "description": "Private repositories reachable from this entry. Repositories shared with the other executor entry MUST declare the same sensitivity: sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", + "items": { "type": "object", "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "runtime": { + "repo": { "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned engine image." - }, - "network": { - "const": "api-proxy-only", - "default": "api-proxy-only" + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" }, - "engine": { + "sensitivity": { "type": "string", "enum": [ - "copilot", - "claude", - "codex", - "gemini" - ], - "default": "copilot" - }, - "profile": { - "type": "string", - "enum": [ - "openai", - "anthropic" - ], - "default": "openai" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 540, - "default": 120 - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, - "maxTaskBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 4096 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 8 + "public", + "internal", + "confidential", + "sealed" + ] } - }, - "if": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - }, - "then": { - "required": [ - "model" - ] } } + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "description": "Per-invocation wall-clock timeout in seconds. Defaults to 30 for \"script\" entries and 120 for \"agent\" entries." } } }, - "if": { - "properties": { - "enabled": { - "const": true - } + "allOf": [ + { + "contains": { + "type": "object", + "required": [ + "script" + ] + }, + "minContains": 0, + "maxContains": 1 }, - "required": [ - "enabled" - ] - }, - "then": { - "required": [ - "privateRepos", - "executors" - ], - "properties": { - "executors": { - "anyOf": [ - { - "required": [ - "script" - ], - "properties": { - "script": { - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "const": true - } - } - } - } - }, - { - "required": [ - "agent" - ], - "properties": { - "agent": { - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "const": true - } - } - } - } - } + { + "contains": { + "type": "object", + "required": [ + "agent" ] - } + }, + "minContains": 0, + "maxContains": 1 } - } + ] } }, "$defs": { diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md index 27b7f0d8b..1ea7973a8 100644 --- a/docs/enclaves-architecture.md +++ b/docs/enclaves-architecture.md @@ -10,7 +10,7 @@ AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave - **Script executor** — `enclave_run_script` runs a bounded Python script in a no-network, read-only, single-use sandbox. - **Agent executor** — `enclave_run_agent` runs the pinned Copilot engine in a bounded single-use enclave whose only network peer is the dedicated API proxy. -- **Shared controls** — `enclaves.privateRepos` is the only trusted repository list; script and agent calls debit the same per-run repository ledger and share one admission lane. +- **Shared controls** — the `repos` lists of the `enclaves` entries form the only trusted repository catalog; script and agent calls debit the same per-run repository ledger and share one admission lane. The primary agent never receives a broker socket, wrapper binary, direct MCP server URL, capability, repository seed, ledger state, or alternate transport. @@ -81,8 +81,8 @@ The following legacy surfaces are **removed, not deprecated**: | Removed surface | Replacement | | --- | --- | -| `boundedQueries` config | `enclaves.privateRepos` + `enclaves.executors.script` | -| `boundedAgents` config | `enclaves.privateRepos` + `enclaves.executors.agent` | +| `boundedQueries` config | an `enclaves` entry keyed by `script` with its `repos` list | +| `boundedAgents` config | an `enclaves` entry keyed by `agent` with its `repos` list | | `bounded-query` wrapper / generated skill | `enclave_run_script` MCP tool | | `bounded-agent` wrapper / generated skill | `enclave_run_agent` MCP tool | | Separate per-subsystem ledgers | One shared per-repository ledger inside `enclave-mcp-server` | diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 47cf69c60..156a17e40 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -83,8 +83,8 @@ VMs persist until explicitly removed; stopping an agent does not delete the VM. ### Enclave runtimes are independent `container.containerRuntime: "sbx"` selects the primary agent's execution -model. `enclaves.executors.script.runtime: "sbx"` and -`enclaves.executors.agent.runtime: "sbx"` are separate enclave backends +model. `script.runtime: "sbx"` and +`agent.runtime: "sbx"` on an `enclaves` entry are separate enclave backends behind the AWF-owned MCP server and must never reuse the primary agent VM, agent-ingress capability, gateway capability, or agent credentials. diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 8a6b3527c..431105875 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -981,299 +981,250 @@ } }, "enclaves": { - "type": "object", - "description": "Unified private-repository enclaves. Repositories and sensitivities are shared by the script and agent executors, every invocation debits one live per-repository information budget regardless of executor kind, and AWF exposes enabled executors only through an AWF-owned MCP server plus the compiler-launched trusted mcpg gateway.", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable the unified enclave subsystem. Requires at least one private repository and at least one enabled executor." - }, - "privateRepos": { - "type": "array", - "minItems": 1, - "description": "Private repositories shared by every configured enclave executor. Sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", - "items": { + "type": "array", + "description": "Unified private-repository enclaves, expressed as the same keyed array the gh-aw compiler emits. Every entry declares exactly one \"script\" or \"agent\" executor, its shared \"repos\" list, and an optional entry-level \"timeout\". Repositories and sensitivities are shared by both executors, every invocation debits one live per-repository information budget regardless of executor kind, and AWF exposes configured executors only through an AWF-owned MCP server plus the compiler-launched trusted mcpg gateway.", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "description": "One executor entry. Images, runtimes, networks, models, timeouts, and resources are trusted AWF configuration and must never be accepted from an invocation request.", + "oneOf": [ + { + "required": [ + "script" + ] + }, + { + "required": [ + "agent" + ] + } + ], + "required": [ + "repos" + ], + "properties": { + "script": { + "type": "object", + "additionalProperties": false, + "description": "Script executor. An empty object selects AWF's pinned defaults.", + "properties": { + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned script-executor image." + }, + "network": { + "const": "none", + "default": "none" + }, + "interpreter": { + "const": "python3", + "default": "python3" + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxScriptBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 65536 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 + } + } + }, + "agent": { "type": "object", "additionalProperties": false, + "description": "Agent executor. \"model\" is required.", "required": [ - "repo", - "sensitivity" + "model" ], "properties": { - "repo": { + "runtime": { "type": "string", - "maxLength": 140, - "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned engine image." + }, + "network": { + "const": "api-proxy-only", + "default": "api-proxy-only" }, - "sensitivity": { + "engine": { "type": "string", "enum": [ - "public", - "internal", - "confidential", - "sealed" - ] + "copilot", + "claude", + "codex", + "gemini" + ], + "default": "copilot" + }, + "profile": { + "type": "string", + "enum": [ + "openai", + "anthropic" + ], + "default": "openai" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxTaskBytes": { + "type": "integer", + "minimum": 1, + "maximum": 65536, + "default": 4096 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 8 } } - } - }, - "executors": { - "type": "object", - "additionalProperties": false, - "description": "Trusted executor definitions. Images, runtimes, networks, models, timeouts, and resources are AWF configuration and must never be accepted from an invocation request.", - "properties": { - "script": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned script-executor image." - }, - "network": { - "const": "none", - "default": "none" - }, - "interpreter": { - "const": "python3", - "default": "python3" - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 540, - "default": 30 - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, - "maxScriptBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 65536 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 32 - } - } - }, - "agent": { + }, + "repos": { + "type": "array", + "minItems": 1, + "description": "Private repositories reachable from this entry. Repositories shared with the other executor entry MUST declare the same sensitivity: sensitivity fixes one shared per-run information budget for the repository across script and agent calls.", + "items": { "type": "object", "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], "properties": { - "enabled": { - "type": "boolean", - "default": false - }, - "runtime": { + "repo": { "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned engine image." - }, - "network": { - "const": "api-proxy-only", - "default": "api-proxy-only" + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" }, - "engine": { + "sensitivity": { "type": "string", "enum": [ - "copilot", - "claude", - "codex", - "gemini" - ], - "default": "copilot" - }, - "profile": { - "type": "string", - "enum": [ - "openai", - "anthropic" - ], - "default": "openai" - }, - "model": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" - }, - "timeout": { - "type": "integer", - "minimum": 1, - "maximum": 540, - "default": 120 - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, - "maxTaskBytes": { - "type": "integer", - "minimum": 1, - "maximum": 65536, - "default": 4096 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 8 + "public", + "internal", + "confidential", + "sealed" + ] } - }, - "if": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - }, - "then": { - "required": [ - "model" - ] } } + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 540, + "description": "Per-invocation wall-clock timeout in seconds. Defaults to 30 for \"script\" entries and 120 for \"agent\" entries." } } }, - "if": { - "properties": { - "enabled": { - "const": true - } + "allOf": [ + { + "contains": { + "type": "object", + "required": [ + "script" + ] + }, + "minContains": 0, + "maxContains": 1 }, - "required": [ - "enabled" - ] - }, - "then": { - "required": [ - "privateRepos", - "executors" - ], - "properties": { - "executors": { - "anyOf": [ - { - "required": [ - "script" - ], - "properties": { - "script": { - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "const": true - } - } - } - } - }, - { - "required": [ - "agent" - ], - "properties": { - "agent": { - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "const": true - } - } - } - } - } + { + "contains": { + "type": "object", + "required": [ + "agent" ] - } + }, + "minContains": 0, + "maxContains": 1 } - } + ] } }, "$defs": { diff --git a/src/bounded-execution/finite-disclosure.ts b/src/bounded-execution/finite-disclosure.ts index 9d5b6b06a..7c69ebb35 100644 --- a/src/bounded-execution/finite-disclosure.ts +++ b/src/bounded-execution/finite-disclosure.ts @@ -109,7 +109,7 @@ export const RESULT_STATUS_BIT_COST = 1; * traversal (`..`), no query string or fragment (`?`/`#`), no wildcard * (`*`), and no extra path segments (only one `/` is allowed). * - * Keep in sync with `enclaves.privateRepos.items` in + * Keep in sync with `enclaves.items.properties.repos.items` in * `docs/awf-config.schema.json` (JSON Schema cannot share a regex constant * with TypeScript source). */ diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 887fb7ff0..c9a066233 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -33,11 +33,9 @@ const enclaveConfig: WrapperConfig = { ...baseConfig, networkIsolation: true, topologyAttach: ['awmg-mcpg'], - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - }), + enclaves: normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]), }; const createLogger = () => ({ diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 54073b554..a21f560a4 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -576,11 +576,9 @@ describe('buildConfig', () => { const config = buildConfig(makeInputs({ options: { ...makeInputs().options, - enclaves: { - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - }, + enclaves: [ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ], }, })); expect(config.enclaves).toMatchObject({ diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 3c55e414d..bc47ee5aa 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -588,11 +588,9 @@ describe('mapAwfFileConfigToCliOptions', () => { }); it('passes unified enclaves through as trusted config-only state', () => { - const enclaves = { - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' as const }], - executors: { script: { enabled: true } }, - }; + const enclaves = [ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' as const }] }, + ]; expect(mapAwfFileConfigToCliOptions({ enclaves }).enclaves).toEqual(enclaves); }); }); diff --git a/src/enclave/gateway.test.ts b/src/enclave/gateway.test.ts index 64e3fd118..5b1bb1fa7 100644 --- a/src/enclave/gateway.test.ts +++ b/src/enclave/gateway.test.ts @@ -22,17 +22,15 @@ const enclaveProtocol = require(path.join( jest.mock('execa', () => ({ __esModule: true, default: jest.fn() })); const mockExeca = execa as unknown as jest.Mock; +const repository = { repo: 'octo/private', sensitivity: 'internal' as const }; + function config(agent = false): WrapperConfig { return { workDir: '/tmp/awf-test', - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { - script: { enabled: true }, - agent: agent ? { enabled: true, model: 'gpt-test' } : undefined, - }, - }), + enclaves: normalizeEnclavesConfig([ + { script: {}, repos: [repository] }, + ...(agent ? [{ agent: { model: 'gpt-test' }, repos: [repository] }] : []), + ]), } as WrapperConfig; } @@ -218,11 +216,9 @@ describe('enclave mcpg handoff', () => { it('builds an agent-only timeout and tool allowlist', () => { const wrapperConfig = { ...config(), - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-test', timeout: 45 } }, - }), + enclaves: normalizeEnclavesConfig([ + { agent: { model: 'gpt-test' }, repos: [repository], timeout: 45 }, + ]), }; expect(buildEnclaveMcpgUpstreamContract(wrapperConfig).server).toMatchObject({ tools: ['enclave_run_agent'], diff --git a/src/enclave/manager.test.ts b/src/enclave/manager.test.ts index cab73979b..d29a12065 100644 --- a/src/enclave/manager.test.ts +++ b/src/enclave/manager.test.ts @@ -39,27 +39,28 @@ function enclaveEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { }; } -function config(workDir: string, overrides: Parameters[0] = {}): WrapperConfig { +const repository = { repo: 'octo/private', sensitivity: 'internal' as const }; + +type EnclaveEntries = NonNullable[0]>; + +const scriptEntries: EnclaveEntries = [{ script: {}, repos: [repository] }]; + +function config(workDir: string, entries: EnclaveEntries = scriptEntries): WrapperConfig { return { workDir, networkIsolation: true, topologyAttach: ['awmg-mcpg'], - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - ...overrides, - }), + enclaves: normalizeEnclavesConfig(entries), } as WrapperConfig; } /** A configuration whose agent executor has a routed API-proxy model target. */ function agentConfig( workDir: string, - overrides: Parameters[0] = {}, + entries: EnclaveEntries = scriptEntries, ): WrapperConfig { return { - ...config(workDir, overrides), + ...config(workDir, entries), enableApiProxy: true, copilotGithubToken: 'copilot-token', } as WrapperConfig; @@ -130,12 +131,10 @@ describe('prepareEnclaves fail-closed preflight', () => { it('proves both executor runtimes before staging when both are enabled', async () => { const assertScriptRuntimeAvailable = jest.fn().mockResolvedValue(undefined); const assertAgentRuntimeAvailable = jest.fn().mockResolvedValue(undefined); - await prepareToleratingPrivateRootIo(agentConfig(workDir, { - executors: { - script: { enabled: true }, - agent: { enabled: true, model: 'gpt-test' }, - }, - }), { + await prepareToleratingPrivateRootIo(agentConfig(workDir, [ + { script: {}, repos: [repository] }, + { agent: { model: 'gpt-test' }, repos: [repository] }, + ]), { env: enclaveEnv(), gitRunner, assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), @@ -154,12 +153,10 @@ describe('prepareEnclaves fail-closed preflight', () => { .mockResolvedValueOnce(undefined); const agentProof = jest.spyOn(runtimePreflight, 'assertAgentRuntimeAvailable') .mockResolvedValueOnce(undefined); - const wrapperConfig = agentConfig(workDir, { - executors: { - script: { enabled: true }, - agent: { enabled: true, model: 'gpt-test' }, - }, - }); + const wrapperConfig = agentConfig(workDir, [ + { script: {}, repos: [repository] }, + { agent: { model: 'gpt-test' }, repos: [repository] }, + ]); try { await prepareToleratingPrivateRootIo(wrapperConfig, { env: enclaveEnv(), @@ -179,9 +176,9 @@ describe('prepareEnclaves fail-closed preflight', () => { it('never probes a disabled executor runtime', async () => { const assertScriptRuntimeAvailable = jest.fn().mockResolvedValue(undefined); const assertAgentRuntimeAvailable = jest.fn().mockResolvedValue(undefined); - await prepareToleratingPrivateRootIo(agentConfig(workDir, { - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }), { + await prepareToleratingPrivateRootIo(agentConfig(workDir, [ + { agent: { model: 'gpt-test' }, repos: [repository] }, + ]), { env: enclaveEnv(), gitRunner, assertPrimaryAvailable: jest.fn().mockResolvedValue(undefined), @@ -194,9 +191,9 @@ describe('prepareEnclaves fail-closed preflight', () => { it('rejects the unproven sbx agent runtime before staging and never downgrades', async () => { const assertAgentRuntimeAvailable = jest.fn(); - await expect(prepareEnclaves(agentConfig(workDir, { - executors: { agent: { enabled: true, model: 'gpt-test', runtime: 'sbx' } }, - }), { + await expect(prepareEnclaves(agentConfig(workDir, [ + { agent: { model: 'gpt-test', runtime: 'sbx' }, repos: [repository] }, + ]), { env: enclaveEnv(), assertPrimaryAvailable: jest.fn(), assertScriptRuntimeAvailable: jest.fn(), @@ -207,9 +204,9 @@ describe('prepareEnclaves fail-closed preflight', () => { it('rejects an agent executor without the mandatory API proxy', async () => { await expect(prepareEnclaves({ - ...agentConfig(workDir, { - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }), + ...agentConfig(workDir, [ + { agent: { model: 'gpt-test' }, repos: [repository] }, + ]), enableApiProxy: false, } as WrapperConfig, { env: enclaveEnv(), @@ -219,9 +216,9 @@ describe('prepareEnclaves fail-closed preflight', () => { }); it('rejects the unimplemented sbx script runtime before staging', async () => { - await expect(prepareEnclaves(config(workDir, { - executors: { script: { enabled: true, runtime: 'sbx' } }, - }), { + await expect(prepareEnclaves(config(workDir, [ + { script: { runtime: 'sbx' }, repos: [repository] }, + ]), { env: enclaveEnv(), assertPrimaryAvailable: jest.fn(), assertScriptRuntimeAvailable: jest.fn(), diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index 7239afedc..c0e326dc0 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -5,11 +5,9 @@ import { validateEnclavesConfig } from './preflight'; function config(overrides: Partial = {}): WrapperConfig { return { workDir: '/tmp/awf', - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - }), + enclaves: normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]), ...overrides, } as WrapperConfig; } @@ -19,51 +17,47 @@ describe('validateEnclavesConfig', () => { expect(validateEnclavesConfig(config())).toEqual([]); }); - it('rejects duplicate repositories and no enabled executor', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [ - { repo: 'octo/private', sensitivity: 'internal' }, - { repo: 'Octo/Private', sensitivity: 'internal' }, - ], - executors: {}, - }); - const errors = validateEnclavesConfig(config({ enclaves })); - expect(errors.join('\n')).toMatch(/duplicate entry/); - expect(errors.join('\n')).toMatch(/no enclave executor is enabled/); + it('rejects repositories shared with conflicting sensitivities', () => { + const enclaves = normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + { agent: { model: 'gpt-5' }, repos: [{ repo: 'Octo/Private', sensitivity: 'confidential' }] }, + ]); + expect(validateEnclavesConfig(config({ enclaves })).join('\n')) + .toMatch(/conflicting sensitivities for "Octo\/Private"/); + }); + + it('rejects a normalized configuration with no executor entry', () => { + const enclaves = normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); + enclaves!.executors.script.enabled = false; + expect(validateEnclavesConfig(config({ enclaves })).join('\n')) + .toMatch(/no enclave executor entry is configured/); }); it('rejects an empty repository list', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - executors: { script: { enabled: true } }, - }); - expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/privateRepos is empty/); + const enclaves = normalizeEnclavesConfig([ + { script: {} }, + ]); + expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/entries declare no repos/); }); 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 enclaves = normalizeEnclavesConfig([ + { + script: { maxScriptBytes: 65_537, maxOutputBytes: 8_193 }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, - }); + ]); 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, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-5' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: 'gpt-5' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/requires the AWF API proxy/); expect(validateEnclavesConfig(config({ @@ -78,16 +72,12 @@ describe('validateEnclavesConfig', () => { }); it('rejects malformed executor controls that bypass schema validation', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], - executors: { + const enclaves = normalizeEnclavesConfig([ + { script: { - enabled: true, runtime: 'invalid' as 'docker', network: 'bridge' as 'none', interpreter: 'ruby' as 'python3', - timeout: 0, memoryLimit: 'lots', cpuLimit: '0', pidsLimit: 0, @@ -96,13 +86,15 @@ describe('validateEnclavesConfig', () => { maxScriptBytes: 0, maxInvocations: 0, }, + repos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], + timeout: 0, + }, + { agent: { - enabled: true, runtime: 'invalid' as 'docker', engine: 'invalid' as 'copilot', network: 'bridge' as 'api-proxy-only', model: '', - timeout: 601, memoryLimit: 'lots', cpuLimit: 'all', pidsLimit: 0, @@ -111,8 +103,10 @@ describe('validateEnclavesConfig', () => { maxTaskBytes: 0, maxInvocations: 0, }, + repos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], + timeout: 601, }, - }); + ]); const errors = validateEnclavesConfig(config({ enclaves, enableApiProxy: true })).join('\n'); expect(errors).toMatch(/not a bare owner\/repo slug/); @@ -131,11 +125,9 @@ describe('validateEnclavesConfig', () => { }); it('accepts an agent executor with a routed API-proxy model target', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: 'gpt-test' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -144,11 +136,12 @@ describe('validateEnclavesConfig', () => { }); it('rejects an agent executor whose engine has no audited enclave image', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'claude-test', engine: 'claude' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { + agent: { model: 'claude-test', engine: 'claude' }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], + }, + ]); const errors = validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -159,21 +152,17 @@ describe('validateEnclavesConfig', () => { }); it('rejects an agent executor without a configured provider route', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: 'gpt-test' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(validateEnclavesConfig(config({ enclaves, enableApiProxy: true })).join('\n')) .toMatch(/requires a configured API target for engine "copilot"/); }); it('rejects a Copilot base URL without a credential', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: 'gpt-test' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -185,11 +174,9 @@ describe('validateEnclavesConfig', () => { expect(validateEnclavesConfig(config({ enableDind: true })).join('\n')) .toMatch(/enclaves cannot be combined with enableDind/); - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'gpt-test' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: 'gpt-test' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -213,32 +200,22 @@ describe('validateEnclavesConfig', () => { }); it('rejects an agent executor that cannot reach a model or drops its network', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true } }, - }); + const enclaves = normalizeEnclavesConfig([ + { agent: { model: '' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); const errors = validateEnclavesConfig(config({ enclaves })).join('\n'); expect(errors).toMatch(/agent.model is required/); expect(errors).toMatch(/agent executor requires the AWF API proxy/); }); it('rejects agent disclosure and resource bounds the enclave cannot enforce', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { - agent: { - enabled: true, - model: 'gpt-test', - timeout: 100_000, - memoryLimit: 'huge', - cpuLimit: '0', - pidsLimit: 0, - maxOutputBytes: 0, - }, + const enclaves = normalizeEnclavesConfig([ + { + agent: { model: 'gpt-test', memoryLimit: 'huge', cpuLimit: '0', pidsLimit: 0, maxOutputBytes: 0 }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], + timeout: 100_000, }, - }); + ]); const errors = validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -256,18 +233,12 @@ describe('validateEnclavesConfig', () => { }); it('rejects agent bounds above the server and native-loop hard ceilings', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { - agent: { - enabled: true, - model: 'gpt-test', - maxOutputBytes: 8193, - maxTaskBytes: 65_537, - }, + const enclaves = normalizeEnclavesConfig([ + { + agent: { model: 'gpt-test', maxOutputBytes: 8193, maxTaskBytes: 65_537 }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, - }); + ]); const errors = validateEnclavesConfig(config({ enclaves, enableApiProxy: true, @@ -278,17 +249,12 @@ describe('validateEnclavesConfig', () => { }); 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 enclaves = normalizeEnclavesConfig([ + { + script: { maxScriptBytes: 65_537, maxOutputBytes: 8_193 }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, - }); + ]); 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 a39d4a1ab..c6a5735ed 100644 --- a/src/enclave/preflight.ts +++ b/src/enclave/preflight.ts @@ -1,5 +1,9 @@ import type { WrapperConfig } from '../types'; -import type { EnclaveAgentExecutorConfig, EnclavesConfig } from '../types/enclave-options'; +import type { + EnclaveAgentExecutorConfig, + EnclaveSensitivity, + EnclavesConfig, +} from '../types/enclave-options'; import { MAX_RESULT_BYTES, MAX_SCRIPT_BYTES, @@ -52,17 +56,25 @@ export function resolveEnclaveAgentApiRoute( function validateRepositoryList(enclaves: EnclavesConfig, errors: string[]): void { if (enclaves.privateRepos.length === 0) { - errors.push('enclaves.enabled is true but enclaves.privateRepos is empty'); + errors.push('enclaves entries declare no repos'); } - const seen = new Set(); + const seen = new Map(); for (const repository of enclaves.privateRepos) { if (!PRIVATE_REPOSITORY_PATTERN.test(repository.repo)) { - errors.push(`enclaves.privateRepos entry "${repository.repo}" is not a bare owner/repo slug`); + errors.push(`enclaves[].repos entry "${repository.repo}" is not a bare owner/repo slug`); continue; } const key = normalizePrivateRepositoryKey(repository.repo); - if (seen.has(key)) errors.push(`enclaves.privateRepos contains a duplicate entry: "${repository.repo}"`); - seen.add(key); + const previous = seen.get(key); + if (previous !== undefined) { + errors.push( + previous === repository.sensitivity + ? `enclaves[].repos contains a duplicate entry: "${repository.repo}"` + : `enclaves[].repos declares conflicting sensitivities for "${repository.repo}": ` + + `"${previous}" and "${repository.sensitivity}" cannot share one information budget`, + ); + } + seen.set(key, repository.sensitivity); } } @@ -90,44 +102,44 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { validateRepositoryList(enclaves, errors); const { script, agent } = enclaves.executors; if (!script.enabled && !agent.enabled) { - errors.push('enclaves.enabled is true but no enclave executor is enabled'); + errors.push('enclaves is enabled but no enclave executor entry is configured'); } if (script.enabled) { - if (!RUNTIMES.has(script.runtime)) errors.push(`enclaves.executors.script.runtime "${script.runtime}" is not supported`); - if (script.network !== 'none') errors.push('enclaves.executors.script.network must be "none"'); - if (script.interpreter !== 'python3') errors.push('enclaves.executors.script.interpreter must be "python3"'); + if (!RUNTIMES.has(script.runtime)) errors.push(`enclaves[].script.runtime "${script.runtime}" is not supported`); + if (script.network !== 'none') errors.push('enclaves[].script.network must be "none"'); + if (script.interpreter !== 'python3') errors.push('enclaves[].script.interpreter must be "python3"'); if (!Number.isInteger(script.timeout) || script.timeout < 1 || script.timeout > MAX_ENCLAVE_TIMEOUT_SECONDS) { errors.push( - `enclaves.executors.script.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, + `enclaves[].script.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, ); } - validateResourceLimits('enclaves.executors.script', script, errors); - validatePositiveInteger('enclaves.executors.script.maxScriptBytes', script.maxScriptBytes, errors); + validateResourceLimits('enclaves[].script', script, errors); + validatePositiveInteger('enclaves[].script.maxScriptBytes', script.maxScriptBytes, errors); if (script.maxScriptBytes > MAX_SCRIPT_BYTES) { - errors.push(`enclaves.executors.script.maxScriptBytes must be at most ${MAX_SCRIPT_BYTES}`); + errors.push(`enclaves[].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}`); + errors.push(`enclaves[].script.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); } - validatePositiveInteger('enclaves.executors.script.maxInvocations', script.maxInvocations, errors); + validatePositiveInteger('enclaves[].script.maxInvocations', script.maxInvocations, errors); } if (agent.enabled) { - if (!RUNTIMES.has(agent.runtime)) errors.push(`enclaves.executors.agent.runtime "${agent.runtime}" is not supported`); + if (!RUNTIMES.has(agent.runtime)) errors.push(`enclaves[].agent.runtime "${agent.runtime}" is not supported`); if (!ENGINES.has(agent.engine)) { - errors.push(`enclaves.executors.agent.engine "${agent.engine}" is not supported`); + errors.push(`enclaves[].agent.engine "${agent.engine}" is not supported`); } else if (!IMPLEMENTED_AGENT_ENGINES.has(agent.engine)) { errors.push( - `enclaves.executors.agent.engine "${agent.engine}" is not implemented. Only "copilot" has a ` + + `enclaves[].agent.engine "${agent.engine}" is not implemented. Only "copilot" has a ` + 'pinned native enclave image and an AWF-authored model loop; enclaves never fall back to a ' + 'different engine.', ); } if (agent.network !== 'api-proxy-only') { - errors.push('enclaves.executors.agent.network must be "api-proxy-only"'); + errors.push('enclaves[].agent.network must be "api-proxy-only"'); } - if (!agent.model) errors.push('enclaves.executors.agent.model is required when the agent executor is enabled'); + if (!agent.model) errors.push('enclaves[].agent.model is required when the agent executor is enabled'); if (!config.enableApiProxy) { errors.push('enclaves agent executor requires the AWF API proxy'); } else { @@ -141,18 +153,18 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { } if (!Number.isInteger(agent.timeout) || agent.timeout < 1 || agent.timeout > MAX_ENCLAVE_TIMEOUT_SECONDS) { errors.push( - `enclaves.executors.agent.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, + `enclaves[].agent.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, ); } - validateResourceLimits('enclaves.executors.agent', agent, errors); - validatePositiveInteger('enclaves.executors.agent.maxTaskBytes', agent.maxTaskBytes, errors); + validateResourceLimits('enclaves[].agent', agent, errors); + validatePositiveInteger('enclaves[].agent.maxTaskBytes', agent.maxTaskBytes, errors); if (agent.maxTaskBytes > ENCLAVE_AGENT_MAX_TASK_BYTES) { - errors.push(`enclaves.executors.agent.maxTaskBytes must be at most ${ENCLAVE_AGENT_MAX_TASK_BYTES}`); + errors.push(`enclaves[].agent.maxTaskBytes must be at most ${ENCLAVE_AGENT_MAX_TASK_BYTES}`); } if (agent.maxOutputBytes > MAX_RESULT_BYTES) { - errors.push(`enclaves.executors.agent.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + errors.push(`enclaves[].agent.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); } - validatePositiveInteger('enclaves.executors.agent.maxInvocations', agent.maxInvocations, errors); + validatePositiveInteger('enclaves[].agent.maxInvocations', agent.maxInvocations, errors); } return errors; diff --git a/src/enclave/workflow-integration.test.ts b/src/enclave/workflow-integration.test.ts index db0cda5b8..5293bf712 100644 --- a/src/enclave/workflow-integration.test.ts +++ b/src/enclave/workflow-integration.test.ts @@ -11,11 +11,9 @@ 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 } }, - }), + enclaves: normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]), } as WrapperConfig; } diff --git a/src/parsers/enclave-parser.test.ts b/src/parsers/enclave-parser.test.ts index 016e1e04d..9af016921 100644 --- a/src/parsers/enclave-parser.test.ts +++ b/src/parsers/enclave-parser.test.ts @@ -6,13 +6,15 @@ import { } from '../types/enclave-options'; import { normalizeEnclavesConfig } from './enclave-parser'; +const repository = { repo: 'octo-org/private-service', sensitivity: 'confidential' as const }; + describe('normalizeEnclavesConfig', () => { it('is absent unless the section is configured', () => { expect(normalizeEnclavesConfig(undefined)).toBeUndefined(); }); it('applies conservative defaults without enabling executors', () => { - expect(normalizeEnclavesConfig({})).toEqual(ENCLAVES_DEFAULTS); + expect(normalizeEnclavesConfig([])).toEqual(ENCLAVES_DEFAULTS); expect(ENCLAVES_DEFAULTS).toEqual({ enabled: false, privateRepos: [], @@ -23,86 +25,136 @@ describe('normalizeEnclavesConfig', () => { }); }); - it('preserves trusted executor overrides and shared repositories', () => { - expect(normalizeEnclavesConfig({ + it('normalizes the keyed-array contract into trusted executor config', () => { + expect(normalizeEnclavesConfig([ + { script: {}, repos: [repository], timeout: 45 }, + { agent: { model: 'gpt-5' }, repos: [repository], timeout: 180 }, + ])).toMatchObject({ enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'confidential' }], + privateRepos: [repository], executors: { - script: { enabled: true, runtime: 'gvisor', image: 'registry/script@sha256:abc' }, - agent: { enabled: true, model: 'gpt-5' }, + script: { enabled: true, network: 'none', interpreter: 'python3', timeout: 45 }, + agent: { enabled: true, network: 'api-proxy-only', model: 'gpt-5', timeout: 180 }, }, - })).toMatchObject({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'confidential' }], + }); + }); + + it('defaults script and agent timeouts to 30 and 120 seconds', () => { + const config = normalizeEnclavesConfig([ + { script: {}, repos: [repository] }, + { agent: { model: 'gpt-5' }, repos: [repository] }, + ]); + expect(config?.executors.script.timeout).toBe(30); + expect(config?.executors.agent.timeout).toBe(120); + }); + + it('preserves trusted executor overrides', () => { + expect(normalizeEnclavesConfig([ + { script: { runtime: 'gvisor', image: 'registry/script@sha256:abc' }, repos: [repository] }, + ])).toMatchObject({ executors: { - script: { - enabled: true, - runtime: 'gvisor', - image: 'registry/script@sha256:abc', - network: 'none', - }, - agent: { - enabled: true, - model: 'gpt-5', - network: 'api-proxy-only', - }, + script: { enabled: true, runtime: 'gvisor', image: 'registry/script@sha256:abc' }, + agent: { enabled: false }, }, }); }); + + it('keeps a repository shared by both entries as one budgeted catalog entry', () => { + expect(normalizeEnclavesConfig([ + { script: {}, repos: [repository] }, + { agent: { model: 'gpt-5' }, repos: [repository] }, + ])?.privateRepos).toEqual([repository]); + }); + + it('keeps conflicting sensitivities so validation can reject them', () => { + expect(normalizeEnclavesConfig([ + { script: {}, repos: [repository] }, + { agent: { model: 'gpt-5' }, repos: [{ repo: 'octo-org/private-service', sensitivity: 'internal' }] }, + ])?.privateRepos).toHaveLength(2); + }); + + it('rejects entries that do not declare exactly one executor key', () => { + expect(() => normalizeEnclavesConfig([{ repos: [repository] } as never])).toThrow(/exactly one/); + expect(() => normalizeEnclavesConfig([ + { script: {}, agent: { model: 'gpt-5' }, repos: [repository] } as never, + ])).toThrow(/exactly one/); + }); + + it('rejects more than one entry per executor kind', () => { + expect(() => normalizeEnclavesConfig([ + { script: {}, repos: [repository] }, + { script: {}, repos: [repository] }, + ])).toThrow(/at most one "script" entry/); + expect(() => normalizeEnclavesConfig([ + { agent: { model: 'gpt-5' }, repos: [repository] }, + { agent: { model: 'gpt-5' }, repos: [repository] }, + ])).toThrow(/at most one "agent" entry/); + }); }); describe('enclaves JSON Schema', () => { - const repository = { repo: 'octo/private', sensitivity: 'internal' as const }; - - it('accepts script, agent, and combined executor definitions', () => { + it('accepts the gh-aw keyed-array contract', () => { expect(validateAwfFileConfig({ - enclaves: { - enabled: true, - privateRepos: [repository], - executors: { script: { enabled: true } }, - }, + enclaves: [ + { script: {}, repos: [repository], timeout: 45 }, + { agent: { model: 'gpt-5' }, repos: [repository], timeout: 180 }, + ], })).toEqual([]); + expect(validateAwfFileConfig({ enclaves: [{ script: {}, repos: [repository] }] })).toEqual([]); expect(validateAwfFileConfig({ - enclaves: { - enabled: true, - privateRepos: [repository], - executors: { agent: { enabled: true, model: 'gpt-5' } }, - }, + enclaves: [{ agent: { model: 'gpt-5' }, repos: [repository] }], })).toEqual([]); + }); + + it('requires repos and exactly one executor key per entry', () => { + expect(validateAwfFileConfig({ enclaves: [{ script: {} }] }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ enclaves: [{ repos: [repository] }] }).length).toBeGreaterThan(0); expect(validateAwfFileConfig({ - enclaves: { - enabled: true, - privateRepos: [repository], - executors: { - script: { enabled: true }, - agent: { enabled: true, model: 'gpt-5' }, - }, - }, - })).toEqual([]); + enclaves: [{ script: {}, agent: { model: 'gpt-5' }, repos: [repository] }], + }).length).toBeGreaterThan(0); }); - it('requires repositories and at least one explicitly enabled executor', () => { - expect(validateAwfFileConfig({ enclaves: { enabled: true } }).length).toBeGreaterThan(0); + it('allows at most one entry per executor kind', () => { expect(validateAwfFileConfig({ - enclaves: { enabled: true, privateRepos: [repository], executors: {} }, + enclaves: [ + { script: {}, repos: [repository] }, + { script: {}, repos: [repository] }, + ], + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [ + { agent: { model: 'gpt-5' }, repos: [repository] }, + { agent: { model: 'gpt-4' }, repos: [repository] }, + ], }).length).toBeGreaterThan(0); }); - it('keeps trusted controls closed and constrained', () => { + it('requires agent.model and rejects legacy shapes', () => { + expect(validateAwfFileConfig({ enclaves: [{ agent: {}, repos: [repository] }] }).length) + .toBeGreaterThan(0); expect(validateAwfFileConfig({ - enclaves: { - enabled: true, - privateRepos: [repository], - executors: { script: { enabled: true, network: 'bridge' } }, - }, + enclaves: { enabled: true, privateRepos: [repository], executors: { script: { enabled: true } } }, }).length).toBeGreaterThan(0); expect(validateAwfFileConfig({ - enclaves: { - enabled: true, - privateRepos: [repository], - executors: { agent: { enabled: true, model: 'gpt-5', tools: ['shell'] } }, - }, + enclaves: [{ script: {}, repositories: [repository] }], + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [{ script: { enabled: true }, repos: [repository] }], }).length).toBeGreaterThan(0); }); + it('keeps trusted controls closed and bounded', () => { + expect(validateAwfFileConfig({ + enclaves: [{ script: { network: 'bridge' }, repos: [repository] }], + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [{ agent: { model: 'gpt-5', tools: ['shell'] }, repos: [repository] }], + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [{ script: {}, repos: [repository], timeout: 541 }], + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [{ agent: { model: 'gpt-5' }, repos: [repository], timeout: 541 }], + }).length).toBeGreaterThan(0); + }); }); diff --git a/src/parsers/enclave-parser.ts b/src/parsers/enclave-parser.ts index 96106a062..d78f94006 100644 --- a/src/parsers/enclave-parser.ts +++ b/src/parsers/enclave-parser.ts @@ -1,32 +1,93 @@ -import type { RawEnclavesConfig } from '../types/enclave-options'; import { ENCLAVE_AGENT_EXECUTOR_DEFAULTS, ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + type EnclaveRepository, type EnclavesConfig, + type RawEnclaveAgentEntry, + type RawEnclaveEntry, + type RawEnclaveScriptEntry, + type RawEnclavesConfig, } from '../types/enclave-options'; -/** Applies trusted defaults without enabling either executor implicitly. */ +function isScriptEntry(entry: RawEnclaveEntry): entry is RawEnclaveScriptEntry { + return Object.prototype.hasOwnProperty.call(entry, 'script'); +} + +function isAgentEntry(entry: RawEnclaveEntry): entry is RawEnclaveAgentEntry { + return Object.prototype.hasOwnProperty.call(entry, 'agent'); +} + +/** + * Merges the per-entry repository lists into the single shared catalog. + * + * Repositories declared by more than one executor entry MUST agree on their + * sensitivity: both executors debit the same live per-repository budget, so a + * conflicting declaration is preserved as a duplicate and rejected by + * `validateEnclavesConfig` rather than silently resolved here. + */ +function mergeRepositories(entries: RawEnclaveEntry[]): EnclaveRepository[] { + const merged: EnclaveRepository[] = []; + const seen = new Set(); + for (const entry of entries) { + for (const repository of entry.repos ?? []) { + const key = `${repository.repo.toLowerCase()}\u0000${repository.sensitivity}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push({ ...repository }); + } + } + return merged; +} + +/** + * Normalizes the gh-aw keyed-array enclave frontmatter into AWF's trusted + * runtime configuration. + * + * Structural violations fail closed at parse time: an entry must carry exactly + * one `script` or `agent` key, and at most one entry may exist per executor + * kind. + */ export function normalizeEnclavesConfig( raw: RawEnclavesConfig | undefined, ): EnclavesConfig | undefined { if (!raw) return undefined; + if (!Array.isArray(raw)) { + throw new Error('enclaves must be an array of executor entries'); + } + + let script: RawEnclaveScriptEntry | undefined; + let agent: RawEnclaveAgentEntry | undefined; - const script = raw.executors?.script; - const agent = raw.executors?.agent; + for (const entry of raw) { + const scriptEntry = isScriptEntry(entry); + const agentEntry = isAgentEntry(entry); + if (scriptEntry === agentEntry) { + throw new Error('each enclaves entry must declare exactly one "script" or "agent" key'); + } + if (scriptEntry) { + if (script) throw new Error('enclaves may declare at most one "script" entry'); + script = entry; + } else if (agentEntry) { + if (agent) throw new Error('enclaves may declare at most one "agent" entry'); + agent = entry; + } + } return { - enabled: raw.enabled === true, - privateRepos: (raw.privateRepos ?? []).map((entry) => ({ ...entry })), + enabled: raw.length > 0, + privateRepos: mergeRepositories(raw), executors: { script: { ...ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, - ...script, - enabled: script?.enabled === true, + ...script?.script, + enabled: script !== undefined, + timeout: script?.timeout ?? ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS.timeout, }, agent: { ...ENCLAVE_AGENT_EXECUTOR_DEFAULTS, - ...agent, - enabled: agent?.enabled === true, + ...agent?.agent, + enabled: agent !== undefined, + timeout: agent?.timeout ?? ENCLAVE_AGENT_EXECUTOR_DEFAULTS.timeout, }, }, }; diff --git a/src/services/enclave-agent-service.test.ts b/src/services/enclave-agent-service.test.ts index 56346ba02..b2cadf856 100644 --- a/src/services/enclave-agent-service.test.ts +++ b/src/services/enclave-agent-service.test.ts @@ -18,11 +18,9 @@ function config(overrides: Partial = {}): WrapperConfig { agentCommand: 'echo enclave', imageRegistry: 'ghcr.io/github/gh-aw-firewall', imageTag: 'latest', - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'trusted-model' } }, - }), + enclaves: normalizeEnclavesConfig([ + { agent: { model: 'trusted-model' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]), enableApiProxy: true, copilotGithubToken: 'copilot-token', openaiApiKey: 'openai-key', @@ -119,17 +117,13 @@ describe('unified enclave agent executor compose assembly', () => { }); it('derives every agent enclave control from trusted configuration', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { + const enclaves = normalizeEnclavesConfig([ + { agent: { - enabled: true, runtime: 'gvisor', engine: 'copilot', profile: 'anthropic', model: 'trusted-model', - timeout: 77, memoryLimit: '256m', cpuLimit: '0.5', pidsLimit: 32, @@ -138,8 +132,10 @@ describe('unified enclave agent executor compose assembly', () => { maxTaskBytes: 1024, maxInvocations: 3, }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], + timeout: 77, }, - }); + ]); const environment = build({ enclaves }).service.environment as Record; expect(environment).toMatchObject({ AWF_ENCLAVE_AGENT_ENABLED: 'true', @@ -170,11 +166,12 @@ describe('unified enclave agent executor compose assembly', () => { }); it('fails closed for the not-yet-proven sbx agent runtime', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { agent: { enabled: true, model: 'trusted-model', runtime: 'sbx' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { + agent: { model: 'trusted-model', runtime: 'sbx' }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], + }, + ]); expect(() => build({ enclaves })) .toThrow(/sbx agent enclave capability is not yet available/); }); @@ -185,11 +182,7 @@ describe('unified enclave agent executor compose assembly', () => { }); it('refuses to build with no executor enabled at all', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: {}, - }); + const enclaves = normalizeEnclavesConfig([]); expect(() => build({ enclaves })) .toThrow(/at least one enclave executor must be enabled/); }); @@ -219,13 +212,12 @@ describe('dedicated enclave agent API proxy', () => { }); it('drops the copilot credential for a non-copilot engine route', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { - agent: { enabled: true, model: 'trusted-model', engine: 'codex', profile: 'openai' }, + const enclaves = normalizeEnclavesConfig([ + { + agent: { model: 'trusted-model', engine: 'codex', profile: 'openai' }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, - }); + ]); const proxy = build({ enclaves }).agentApiProxyService as Record; const environment = proxy.environment as Record; expect(environment.OPENAI_API_KEY).toBe('openai-key'); @@ -325,11 +317,9 @@ describe('unified enclave compose topology', () => { }); it('creates no enclave network when only the script executor runs', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - }); + const enclaves = normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); const compose = generateDockerCompose(composeConfig({ enclaves }), networkConfig); expect(compose.networks[ENCLAVE_AGENT_NETWORK]).toBeUndefined(); expect(compose.services['enclave-agent-image']).toBeUndefined(); @@ -338,14 +328,10 @@ describe('unified enclave compose topology', () => { }); it('runs both executors from one server, one socket, and one audit root', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { - script: { enabled: true }, - agent: { enabled: true, model: 'trusted-model' }, - }, - }); + const enclaves = normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + { agent: { model: 'trusted-model' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); const compose = generateDockerCompose(composeConfig({ enclaves }), networkConfig); const servers = Object.keys(compose.services).filter((name) => name.includes('mcp-server')); expect(servers).toEqual(['enclave-mcp-server']); diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts index 019760051..bf0a2d713 100644 --- a/src/services/enclave-mcp-service.test.ts +++ b/src/services/enclave-mcp-service.test.ts @@ -15,11 +15,9 @@ function config(overrides: Partial = {}): WrapperConfig { imageTag: 'latest', agentCommand: 'echo test', allowedDomains: [], - enclaves: normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { script: { enabled: true } }, - }), + enclaves: normalizeEnclavesConfig([ + { script: {}, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]), ...overrides, } as WrapperConfig; } @@ -62,14 +60,10 @@ describe('buildEnclaveMcpService', () => { }); it('derives all sandbox controls from trusted configuration', () => { - const enclaves = normalizeEnclavesConfig({ - enabled: true, - privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], - executors: { + const enclaves = normalizeEnclavesConfig([ + { script: { - enabled: true, runtime: 'gvisor', - timeout: 12, memoryLimit: '256m', cpuLimit: '0.5', pidsLimit: 32, @@ -78,8 +72,10 @@ describe('buildEnclaveMcpService', () => { maxScriptBytes: 4096, maxInvocations: 3, }, + repos: [{ repo: 'octo/private', sensitivity: 'internal' }], + timeout: 12, }, - }); + ]); const result = buildEnclaveMcpService({ config: config({ enclaves }), imageConfig: ghcr, @@ -98,11 +94,9 @@ describe('buildEnclaveMcpService', () => { }); 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' } }, - }); + const enclaves = normalizeEnclavesConfig([ + { script: { runtime: 'sbx' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + ]); expect(() => buildEnclaveMcpService({ config: config({ enclaves }), imageConfig: ghcr })) .toThrow(/sbx script enclave capability is not yet available/); }); diff --git a/src/types/enclave-options.ts b/src/types/enclave-options.ts index a8b4dc72f..0b53e5ce0 100644 --- a/src/types/enclave-options.ts +++ b/src/types/enclave-options.ts @@ -83,18 +83,39 @@ export interface EnclaveOptions { enclaves?: EnclavesConfig; } -export type RawEnclaveScriptExecutorConfig = Partial; -export type RawEnclaveAgentExecutorConfig = Partial; - -export interface RawEnclavesConfig { - enabled?: boolean; - privateRepos?: EnclaveRepository[]; - executors?: { - script?: RawEnclaveScriptExecutorConfig; - agent?: RawEnclaveAgentExecutorConfig; - }; +/** + * Raw configuration mirrors the gh-aw compiler frontmatter exactly: `enclaves` + * is a keyed array where every entry carries exactly one `script` or `agent` + * key, its own `repos` list, and an optional entry-level `timeout`. + */ +export type RawEnclaveScriptExecutorConfig = Omit< + Partial, + 'enabled' | 'timeout' +>; +export type RawEnclaveAgentExecutorConfig = Omit< + Partial, + 'enabled' | 'timeout' +> & { model: string }; + +interface RawEnclaveEntryBase { + repos?: EnclaveRepository[]; + timeout?: number; +} + +export interface RawEnclaveScriptEntry extends RawEnclaveEntryBase { + script: RawEnclaveScriptExecutorConfig; + agent?: never; +} + +export interface RawEnclaveAgentEntry extends RawEnclaveEntryBase { + agent: RawEnclaveAgentExecutorConfig; + script?: never; } +export type RawEnclaveEntry = RawEnclaveScriptEntry | RawEnclaveAgentEntry; + +export type RawEnclavesConfig = RawEnclaveEntry[]; + export const ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS: Readonly< Omit > = { From c932f50da84976858be095cc1696d62207b1c0d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:38:34 +0000 Subject: [PATCH 3/5] fix: align enclave entry controls Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- containers/enclave/agent-entrypoint.py | 11 ++ .../agent-executor/enclave-runner-spec.js | 6 + containers/enclave/mcp-server/config.js | 8 + docs/awf-config-spec.md | 38 ++-- docs/awf-config.schema.json | 164 +++++++----------- docs/sbx-integration.md | 3 +- src/awf-config-schema.json | 164 +++++++----------- src/enclave/manager.test.ts | 4 +- src/enclave/preflight.test.ts | 61 ++++--- src/enclave/preflight.ts | 26 +-- src/parsers/enclave-parser.test.ts | 18 +- src/parsers/enclave-parser.ts | 8 + src/services/enclave-agent-service.test.ts | 21 ++- src/services/enclave-mcp-service.test.ts | 2 +- src/services/enclave-mcp-service.ts | 6 + src/types/enclave-options.ts | 18 +- 16 files changed, 269 insertions(+), 289 deletions(-) diff --git a/containers/enclave/agent-entrypoint.py b/containers/enclave/agent-entrypoint.py index e835465ce..b3e93147e 100644 --- a/containers/enclave/agent-entrypoint.py +++ b/containers/enclave/agent-entrypoint.py @@ -152,6 +152,13 @@ def main() -> int: max_output = int(os.environ["AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES"]) timeout = int(os.environ["AWF_ENCLAVE_AGENT_DEADLINE_SECONDS"]) model = os.environ["AWF_ENCLAVE_AGENT_MODEL"] + max_model_requests = os.environ.get("AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS") + max_model_tokens = os.environ.get("AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS") + if ( + (max_model_requests is not None and int(max_model_requests) < 1) + or (max_model_tokens is not None and int(max_model_tokens) < 1) + ): + raise ValueError("invalid model limits") except (KeyError, OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError): append_event({"event": "failure", "category": "input-invalid"}) return EXIT_INPUT_INVALID @@ -185,6 +192,10 @@ def main() -> int: "--log-level", "all", "--log-dir", str(copilot_logs), ] + if max_model_requests is not None: + command.extend(["--max-model-requests", max_model_requests]) + if max_model_tokens is not None: + command.extend(["--max-model-tokens", max_model_tokens]) deadline = time.monotonic() + timeout completed = None stdout = "" diff --git a/containers/enclave/agent-executor/enclave-runner-spec.js b/containers/enclave/agent-executor/enclave-runner-spec.js index ab2cf2cdf..ccaac5c1f 100644 --- a/containers/enclave/agent-executor/enclave-runner-spec.js +++ b/containers/enclave/agent-executor/enclave-runner-spec.js @@ -125,6 +125,12 @@ function deriveEnclaveContainerSpec({ config, runId, invocationId, seedId, runti if (runtimeName !== undefined) { launchArgs.push('--runtime', runtimeName); } + if (config.maxModelRequests !== undefined) { + launchArgs.push('--env', `AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS=${config.maxModelRequests}`); + } + if (config.maxModelTokens !== undefined) { + launchArgs.push('--env', `AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS=${config.maxModelTokens}`); + } launchArgs.push('--entrypoint', '/usr/local/bin/run-enclave-agent', config.enclaveImage); return Object.freeze({ diff --git a/containers/enclave/mcp-server/config.js b/containers/enclave/mcp-server/config.js index 62e1fed3a..06ee4a2bf 100644 --- a/containers/enclave/mcp-server/config.js +++ b/containers/enclave/mcp-server/config.js @@ -60,6 +60,12 @@ function positiveInt(name, fallback, maximum = Number.MAX_SAFE_INTEGER) { return value; } +function optionalPositiveInt(name) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return undefined; + return positiveInt(name, 1); +} + function dockerSize(name, fallback) { const value = process.env[name] || fallback; if (!/^[1-9][0-9]*[bkmgBKMG]$/.test(value)) { @@ -226,6 +232,8 @@ function loadAgentConfig(server) { maxOutputBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES', MAX_RESULT_BYTES, MAX_RESULT_BYTES), maxPromptBytes: positiveInt('AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES', 4096, MAX_TASK_BYTES), maxInvocations: positiveInt('AWF_ENCLAVE_AGENT_MAX_INVOCATIONS', 8), + maxModelRequests: optionalPositiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS'), + maxModelTokens: optionalPositiveInt('AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS'), runLabelKey: ENCLAVE_RUN_LABEL, invocationLabelKey: ENCLAVE_INVOCATION_LABEL, containerPrefix: AGENT_CONTAINER_PREFIX, diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 65beb6717..81354fe56 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -268,32 +268,23 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `enclaves[]` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].repos[]` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].timeout` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].runtime` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].image` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].memoryLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].cpuLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].pidsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].maxInvocations` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].script` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.runtime` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.image` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.network` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.interpreter` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.memoryLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.cpuLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.pidsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].script.maxScriptBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].script.maxInvocations` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].agent` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.runtime` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.image` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.network` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].agent.engine` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].agent.profile` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].agent.model` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.memoryLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.cpuLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.pidsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.tmpfsLimit` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.maxOutputBytes` → *(config-only; no CLI equivalent, see §14)* - `enclaves[].agent.maxTaskBytes` → *(config-only; no CLI equivalent, see §14)* -- `enclaves[].agent.maxInvocations` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.maxModelRequests` → *(config-only; no CLI equivalent, see §14)* +- `enclaves[].agent.maxModelTokens` → *(config-only; no CLI equivalent, see §14)* When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts. @@ -1675,7 +1666,7 @@ Each record follows the `blocked-request-diag/v` schema: ## 14. Unified Enclaves -The optional top-level `enclaves` array defines AWF's sole supported private-repository execution surface. It is structurally identical to the gh-aw compiler's enclave frontmatter: every entry declares exactly one `script` or `agent` executor, its own `repos` list, and an optional entry-level `timeout`. AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, maintains one shared per-repository ledger for the run, and exposes configured executors only through compiler-launched `gh-aw-mcpg`. +The optional top-level `enclaves` array defines AWF's sole supported private-repository execution surface. It is structurally identical to the gh-aw compiler's enclave frontmatter: every entry declares exactly one `script` or `agent` executor, its own `repos` list, and entry-level shared controls including `timeout`, `runtime`, `image`, resource limits, and disclosure limits. AWF stages immutable repository seeds on the host, starts one AWF-owned `enclave-mcp-server`, maintains one shared per-repository ledger for the run, and exposes configured executors only through compiler-launched `gh-aw-mcpg`. ### 14.1 Executors and shared configuration @@ -1688,6 +1679,12 @@ enclaves: timeout: 45 - agent: model: gpt-5 + maxModelRequests: 3 + maxModelTokens: 10000 + runtime: gvisor + memoryLimit: 256m + maxOutputBytes: 2048 + maxInvocations: 3 repos: - repo: octo-org/private-service sensitivity: confidential @@ -1696,6 +1693,7 @@ enclaves: - **Script executor** — an entry keyed by `script`; launches a no-network, read-only, single-use Python sandbox. An empty `script: {}` object is valid and selects AWF's pinned defaults. - **Agent executor** — an entry keyed by `agent`; launches a bounded single-use Copilot enclave whose only network peer is the dedicated API proxy. `agent.model` is REQUIRED. +- **Entry-level controls** — `runtime`, `image`, `memoryLimit`, `cpuLimit`, `pidsLimit`, `tmpfsLimit`, `maxOutputBytes`, and `maxInvocations` apply to the entry's selected executor. `script.maxScriptBytes` and agent `maxTaskBytes`, `maxModelRequests`, and `maxModelTokens` remain executor-specific. Network and interpreter are AWF-owned invariants, not input fields. At most one entry MAY exist per executor kind, and each entry MUST declare exactly one executor key. Every entry's `repos` list is merged into one trusted repository catalog: a repository shared by both entries MUST declare the same `sensitivity`, because sensitivity fixes one shared per-run information budget that both executors debit. diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 431105875..6fd38cad8 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -988,7 +988,7 @@ "items": { "type": "object", "additionalProperties": false, - "description": "One executor entry. Images, runtimes, networks, models, timeouts, and resources are trusted AWF configuration and must never be accepted from an invocation request.", + "description": "One executor entry. Common runtime, image, resource, and disclosure controls are entry-level; executor-specific controls are nested under the discriminator. All are trusted AWF configuration and must never be accepted from an invocation request.", "oneOf": [ { "required": [ @@ -1010,67 +1010,11 @@ "additionalProperties": false, "description": "Script executor. An empty object selects AWF's pinned defaults.", "properties": { - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned script-executor image." - }, - "network": { - "const": "none", - "default": "none" - }, - "interpreter": { - "const": "python3", - "default": "python3" - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, "maxScriptBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 32 } } }, @@ -1082,25 +1026,6 @@ "model" ], "properties": { - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned engine image." - }, - "network": { - "const": "api-proxy-only", - "default": "api-proxy-only" - }, "engine": { "type": "string", "enum": [ @@ -1125,44 +1050,23 @@ "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, "maxTaskBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, - "maxInvocations": { + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Maximum model requests permitted for one agent invocation." + }, + "maxModelTokens": { "type": "integer", "minimum": 1, - "maximum": 1000, - "default": 8 + "maximum": 10000000, + "description": "Maximum model tokens permitted for one agent invocation." } } }, @@ -1200,6 +1104,54 @@ "minimum": 1, "maximum": 540, "description": "Per-invocation wall-clock timeout in seconds. Defaults to 30 for \"script\" entries and 120 for \"agent\" entries." + }, + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned executor image." + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 } } }, diff --git a/docs/sbx-integration.md b/docs/sbx-integration.md index 156a17e40..e587f790c 100644 --- a/docs/sbx-integration.md +++ b/docs/sbx-integration.md @@ -83,8 +83,7 @@ VMs persist until explicitly removed; stopping an agent does not delete the VM. ### Enclave runtimes are independent `container.containerRuntime: "sbx"` selects the primary agent's execution -model. `script.runtime: "sbx"` and -`agent.runtime: "sbx"` on an `enclaves` entry are separate enclave backends +model. `enclaves[].runtime: "sbx"` on a keyed enclave entry is a separate enclave backend behind the AWF-owned MCP server and must never reuse the primary agent VM, agent-ingress capability, gateway capability, or agent credentials. diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 431105875..6fd38cad8 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -988,7 +988,7 @@ "items": { "type": "object", "additionalProperties": false, - "description": "One executor entry. Images, runtimes, networks, models, timeouts, and resources are trusted AWF configuration and must never be accepted from an invocation request.", + "description": "One executor entry. Common runtime, image, resource, and disclosure controls are entry-level; executor-specific controls are nested under the discriminator. All are trusted AWF configuration and must never be accepted from an invocation request.", "oneOf": [ { "required": [ @@ -1010,67 +1010,11 @@ "additionalProperties": false, "description": "Script executor. An empty object selects AWF's pinned defaults.", "properties": { - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned script-executor image." - }, - "network": { - "const": "none", - "default": "none" - }, - "interpreter": { - "const": "python3", - "default": "python3" - }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, "maxScriptBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 65536 - }, - "maxInvocations": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "default": 32 } } }, @@ -1082,25 +1026,6 @@ "model" ], "properties": { - "runtime": { - "type": "string", - "enum": [ - "docker", - "gvisor", - "sbx" - ], - "default": "docker" - }, - "image": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "description": "Trusted image override. Omission uses AWF's pinned engine image." - }, - "network": { - "const": "api-proxy-only", - "default": "api-proxy-only" - }, "engine": { "type": "string", "enum": [ @@ -1125,44 +1050,23 @@ "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$" }, - "memoryLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "512m" - }, - "cpuLimit": { - "type": "string", - "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", - "default": "1" - }, - "pidsLimit": { - "type": "integer", - "minimum": 1, - "maximum": 4096, - "default": 128 - }, - "tmpfsLimit": { - "type": "string", - "pattern": "^[1-9][0-9]*[bkmgBKMG]$", - "default": "64m" - }, - "maxOutputBytes": { - "type": "integer", - "minimum": 1, - "maximum": 8192, - "default": 8192 - }, "maxTaskBytes": { "type": "integer", "minimum": 1, "maximum": 65536, "default": 4096 }, - "maxInvocations": { + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Maximum model requests permitted for one agent invocation." + }, + "maxModelTokens": { "type": "integer", "minimum": 1, - "maximum": 1000, - "default": 8 + "maximum": 10000000, + "description": "Maximum model tokens permitted for one agent invocation." } } }, @@ -1200,6 +1104,54 @@ "minimum": 1, "maximum": 540, "description": "Per-invocation wall-clock timeout in seconds. Defaults to 30 for \"script\" entries and 120 for \"agent\" entries." + }, + "runtime": { + "type": "string", + "enum": [ + "docker", + "gvisor", + "sbx" + ], + "default": "docker" + }, + "image": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "Trusted image override. Omission uses AWF's pinned executor image." + }, + "memoryLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "512m" + }, + "cpuLimit": { + "type": "string", + "pattern": "^(?:[0-9]{1,2})(?:\\.[0-9]{1,3})?$", + "default": "1" + }, + "pidsLimit": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 128 + }, + "tmpfsLimit": { + "type": "string", + "pattern": "^[1-9][0-9]*[bkmgBKMG]$", + "default": "64m" + }, + "maxOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 8192, + "default": 8192 + }, + "maxInvocations": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 32 } } }, diff --git a/src/enclave/manager.test.ts b/src/enclave/manager.test.ts index d29a12065..ea6507dcd 100644 --- a/src/enclave/manager.test.ts +++ b/src/enclave/manager.test.ts @@ -192,7 +192,7 @@ describe('prepareEnclaves fail-closed preflight', () => { it('rejects the unproven sbx agent runtime before staging and never downgrades', async () => { const assertAgentRuntimeAvailable = jest.fn(); await expect(prepareEnclaves(agentConfig(workDir, [ - { agent: { model: 'gpt-test', runtime: 'sbx' }, repos: [repository] }, + { agent: { model: 'gpt-test' }, runtime: 'sbx', repos: [repository] }, ]), { env: enclaveEnv(), assertPrimaryAvailable: jest.fn(), @@ -217,7 +217,7 @@ describe('prepareEnclaves fail-closed preflight', () => { it('rejects the unimplemented sbx script runtime before staging', async () => { await expect(prepareEnclaves(config(workDir, [ - { script: { runtime: 'sbx' }, repos: [repository] }, + { script: {}, runtime: 'sbx', repos: [repository] }, ]), { env: enclaveEnv(), assertPrimaryAvailable: jest.fn(), diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index c0e326dc0..560b0c8b9 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -45,7 +45,8 @@ describe('validateEnclavesConfig', () => { it('rejects script disclosure bounds the container cannot enforce', () => { const enclaves = normalizeEnclavesConfig([ { - script: { maxScriptBytes: 65_537, maxOutputBytes: 8_193 }, + script: { maxScriptBytes: 65_537 }, + maxOutputBytes: 8_193, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, ]); @@ -75,34 +76,34 @@ describe('validateEnclavesConfig', () => { const enclaves = normalizeEnclavesConfig([ { script: { - runtime: 'invalid' as 'docker', network: 'bridge' as 'none', interpreter: 'ruby' as 'python3', - memoryLimit: 'lots', - cpuLimit: '0', - pidsLimit: 0, - tmpfsLimit: '64', - maxOutputBytes: 0, maxScriptBytes: 0, - maxInvocations: 0, }, + runtime: 'invalid' as 'docker', + memoryLimit: 'lots', + cpuLimit: '0', + pidsLimit: 0, + tmpfsLimit: '64', + maxOutputBytes: 0, + maxInvocations: 0, repos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], timeout: 0, }, { agent: { - runtime: 'invalid' as 'docker', engine: 'invalid' as 'copilot', network: 'bridge' as 'api-proxy-only', model: '', - memoryLimit: 'lots', - cpuLimit: 'all', - pidsLimit: 0, - tmpfsLimit: '64', - maxOutputBytes: 0, maxTaskBytes: 0, - maxInvocations: 0, }, + runtime: 'invalid' as 'docker', + memoryLimit: 'lots', + cpuLimit: 'all', + pidsLimit: 0, + tmpfsLimit: '64', + maxOutputBytes: 0, + maxInvocations: 0, repos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], timeout: 601, }, @@ -110,15 +111,15 @@ describe('validateEnclavesConfig', () => { const errors = validateEnclavesConfig(config({ enclaves, enableApiProxy: true })).join('\n'); expect(errors).toMatch(/not a bare owner\/repo slug/); - expect(errors).toMatch(/script.runtime "invalid" is not supported/); + expect(errors).toMatch(/runtime "invalid" is not supported/); expect(errors).toMatch(/script.network must be "none"/); expect(errors).toMatch(/script.interpreter must be "python3"/); - expect(errors).toMatch(/script.timeout must be between/); - expect(errors).toMatch(/agent.runtime "invalid" is not supported/); + expect(errors).toMatch(/timeout must be between/); + expect(errors).toMatch(/runtime "invalid" is not supported/); expect(errors).toMatch(/agent.engine "invalid" is not supported/); expect(errors).toMatch(/agent.network must be "api-proxy-only"/); expect(errors).toMatch(/agent.model is required/); - expect(errors).toMatch(/agent.timeout must be between/); + expect(errors).toMatch(/timeout must be between/); expect(errors).toMatch(/is not a Docker size/); expect(errors).toMatch(/positive Docker --cpus value/); expect(errors).toMatch(/must be a positive integer/); @@ -211,7 +212,11 @@ describe('validateEnclavesConfig', () => { it('rejects agent disclosure and resource bounds the enclave cannot enforce', () => { const enclaves = normalizeEnclavesConfig([ { - agent: { model: 'gpt-test', memoryLimit: 'huge', cpuLimit: '0', pidsLimit: 0, maxOutputBytes: 0 }, + agent: { model: 'gpt-test' }, + memoryLimit: 'huge', + cpuLimit: '0', + pidsLimit: 0, + maxOutputBytes: 0, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], timeout: 100_000, }, @@ -222,11 +227,11 @@ describe('validateEnclavesConfig', () => { copilotGithubToken: 'token', })).join('\n'); for (const pattern of [ - /agent.timeout must be between/, - /agent.memoryLimit is not a Docker size/, - /agent.cpuLimit must be a positive/, - /agent.pidsLimit must be a positive integer/, - /agent.maxOutputBytes must be a positive integer/, + /timeout must be between/, + /memoryLimit is not a Docker size/, + /cpuLimit must be a positive/, + /pidsLimit must be a positive integer/, + /maxOutputBytes must be a positive integer/, ]) { expect(errors).toMatch(pattern); } @@ -235,7 +240,8 @@ describe('validateEnclavesConfig', () => { it('rejects agent bounds above the server and native-loop hard ceilings', () => { const enclaves = normalizeEnclavesConfig([ { - agent: { model: 'gpt-test', maxOutputBytes: 8193, maxTaskBytes: 65_537 }, + agent: { model: 'gpt-test', maxTaskBytes: 65_537 }, + maxOutputBytes: 8193, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, ]); @@ -251,7 +257,8 @@ describe('validateEnclavesConfig', () => { it('rejects script disclosure bounds the container cannot enforce', () => { const enclaves = normalizeEnclavesConfig([ { - script: { maxScriptBytes: 65_537, maxOutputBytes: 8_193 }, + script: { maxScriptBytes: 65_537 }, + maxOutputBytes: 8_193, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, ]); diff --git a/src/enclave/preflight.ts b/src/enclave/preflight.ts index c6a5735ed..b4b5a1048 100644 --- a/src/enclave/preflight.ts +++ b/src/enclave/preflight.ts @@ -106,27 +106,27 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { } if (script.enabled) { - if (!RUNTIMES.has(script.runtime)) errors.push(`enclaves[].script.runtime "${script.runtime}" is not supported`); + if (!RUNTIMES.has(script.runtime)) errors.push(`enclaves[].runtime "${script.runtime}" is not supported`); if (script.network !== 'none') errors.push('enclaves[].script.network must be "none"'); if (script.interpreter !== 'python3') errors.push('enclaves[].script.interpreter must be "python3"'); if (!Number.isInteger(script.timeout) || script.timeout < 1 || script.timeout > MAX_ENCLAVE_TIMEOUT_SECONDS) { errors.push( - `enclaves[].script.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, + `enclaves[].timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, ); } - validateResourceLimits('enclaves[].script', script, errors); + validateResourceLimits('enclaves[]', script, errors); validatePositiveInteger('enclaves[].script.maxScriptBytes', script.maxScriptBytes, errors); if (script.maxScriptBytes > MAX_SCRIPT_BYTES) { errors.push(`enclaves[].script.maxScriptBytes must be at most ${MAX_SCRIPT_BYTES}`); } if (script.maxOutputBytes > MAX_RESULT_BYTES) { - errors.push(`enclaves[].script.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + errors.push(`enclaves[].maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); } - validatePositiveInteger('enclaves[].script.maxInvocations', script.maxInvocations, errors); + validatePositiveInteger('enclaves[].maxInvocations', script.maxInvocations, errors); } if (agent.enabled) { - if (!RUNTIMES.has(agent.runtime)) errors.push(`enclaves[].agent.runtime "${agent.runtime}" is not supported`); + if (!RUNTIMES.has(agent.runtime)) errors.push(`enclaves[].runtime "${agent.runtime}" is not supported`); if (!ENGINES.has(agent.engine)) { errors.push(`enclaves[].agent.engine "${agent.engine}" is not supported`); } else if (!IMPLEMENTED_AGENT_ENGINES.has(agent.engine)) { @@ -153,18 +153,24 @@ export function validateEnclavesConfig(config: WrapperConfig): string[] { } if (!Number.isInteger(agent.timeout) || agent.timeout < 1 || agent.timeout > MAX_ENCLAVE_TIMEOUT_SECONDS) { errors.push( - `enclaves[].agent.timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, + `enclaves[].timeout must be between 1 and ${MAX_ENCLAVE_TIMEOUT_SECONDS}`, ); } - validateResourceLimits('enclaves[].agent', agent, errors); + validateResourceLimits('enclaves[]', agent, errors); validatePositiveInteger('enclaves[].agent.maxTaskBytes', agent.maxTaskBytes, errors); if (agent.maxTaskBytes > ENCLAVE_AGENT_MAX_TASK_BYTES) { errors.push(`enclaves[].agent.maxTaskBytes must be at most ${ENCLAVE_AGENT_MAX_TASK_BYTES}`); } if (agent.maxOutputBytes > MAX_RESULT_BYTES) { - errors.push(`enclaves[].agent.maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + errors.push(`enclaves[].maxOutputBytes must be at most ${MAX_RESULT_BYTES}`); + } + validatePositiveInteger('enclaves[].maxInvocations', agent.maxInvocations, errors); + if (agent.maxModelRequests !== undefined) { + validatePositiveInteger('enclaves[].agent.maxModelRequests', agent.maxModelRequests, errors); + } + if (agent.maxModelTokens !== undefined) { + validatePositiveInteger('enclaves[].agent.maxModelTokens', agent.maxModelTokens, errors); } - validatePositiveInteger('enclaves[].agent.maxInvocations', agent.maxInvocations, errors); } return errors; diff --git a/src/parsers/enclave-parser.test.ts b/src/parsers/enclave-parser.test.ts index 9af016921..51aa86697 100644 --- a/src/parsers/enclave-parser.test.ts +++ b/src/parsers/enclave-parser.test.ts @@ -50,7 +50,7 @@ describe('normalizeEnclavesConfig', () => { it('preserves trusted executor overrides', () => { expect(normalizeEnclavesConfig([ - { script: { runtime: 'gvisor', image: 'registry/script@sha256:abc' }, repos: [repository] }, + { script: {}, runtime: 'gvisor', image: 'registry/script@sha256:abc', repos: [repository] }, ])).toMatchObject({ executors: { script: { enabled: true, runtime: 'gvisor', image: 'registry/script@sha256:abc' }, @@ -145,11 +145,25 @@ describe('enclaves JSON Schema', () => { it('keeps trusted controls closed and bounded', () => { expect(validateAwfFileConfig({ - enclaves: [{ script: { network: 'bridge' }, repos: [repository] }], + enclaves: [{ script: { maxScriptBytes: 65_537 }, repos: [repository] }], }).length).toBeGreaterThan(0); expect(validateAwfFileConfig({ enclaves: [{ agent: { model: 'gpt-5', tools: ['shell'] }, repos: [repository] }], }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: [{ + agent: { model: 'gpt-5', maxModelRequests: 3, maxModelTokens: 10_000 }, + runtime: 'gvisor', + image: 'registry/agent@sha256:abc', + memoryLimit: '256m', + cpuLimit: '0.5', + pidsLimit: 32, + tmpfsLimit: '24m', + maxOutputBytes: 2048, + maxInvocations: 3, + repos: [repository], + }], + })).toEqual([]); expect(validateAwfFileConfig({ enclaves: [{ script: {}, repos: [repository], timeout: 541 }], }).length).toBeGreaterThan(0); diff --git a/src/parsers/enclave-parser.ts b/src/parsers/enclave-parser.ts index d78f94006..0d3bca5b1 100644 --- a/src/parsers/enclave-parser.ts +++ b/src/parsers/enclave-parser.ts @@ -39,6 +39,12 @@ function mergeRepositories(entries: RawEnclaveEntry[]): EnclaveRepository[] { return merged; } +function entryCommon(entry: RawEnclaveEntry | undefined): object { + if (!entry) return {}; + const { script: _script, agent: _agent, repos: _repos, timeout: _timeout, ...common } = entry; + return common; +} + /** * Normalizes the gh-aw keyed-array enclave frontmatter into AWF's trusted * runtime configuration. @@ -79,12 +85,14 @@ export function normalizeEnclavesConfig( executors: { script: { ...ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + ...entryCommon(script), ...script?.script, enabled: script !== undefined, timeout: script?.timeout ?? ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS.timeout, }, agent: { ...ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + ...entryCommon(agent), ...agent?.agent, enabled: agent !== undefined, timeout: agent?.timeout ?? ENCLAVE_AGENT_EXECUTOR_DEFAULTS.timeout, diff --git a/src/services/enclave-agent-service.test.ts b/src/services/enclave-agent-service.test.ts index b2cadf856..dfcf0aba9 100644 --- a/src/services/enclave-agent-service.test.ts +++ b/src/services/enclave-agent-service.test.ts @@ -120,18 +120,20 @@ describe('unified enclave agent executor compose assembly', () => { const enclaves = normalizeEnclavesConfig([ { agent: { - runtime: 'gvisor', engine: 'copilot', profile: 'anthropic', model: 'trusted-model', - memoryLimit: '256m', - cpuLimit: '0.5', - pidsLimit: 32, - tmpfsLimit: '24m', - maxOutputBytes: 2048, maxTaskBytes: 1024, - maxInvocations: 3, + maxModelRequests: 3, + maxModelTokens: 10_000, }, + runtime: 'gvisor', + memoryLimit: '256m', + cpuLimit: '0.5', + pidsLimit: 32, + tmpfsLimit: '24m', + maxOutputBytes: 2048, + maxInvocations: 3, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], timeout: 77, }, @@ -153,6 +155,8 @@ describe('unified enclave agent executor compose assembly', () => { AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES: '2048', AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES: '1024', AWF_ENCLAVE_AGENT_MAX_INVOCATIONS: '3', + AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS: '3', + AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS: '10000', }); // Copilot always speaks the Copilot API-proxy port, regardless of profile. expect(environment.AWF_ENCLAVE_AGENT_API_ENDPOINT) @@ -168,7 +172,8 @@ describe('unified enclave agent executor compose assembly', () => { it('fails closed for the not-yet-proven sbx agent runtime', () => { const enclaves = normalizeEnclavesConfig([ { - agent: { model: 'trusted-model', runtime: 'sbx' }, + agent: { model: 'trusted-model' }, + runtime: 'sbx', repos: [{ repo: 'octo/private', sensitivity: 'internal' }], }, ]); diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts index bf0a2d713..ed2bec2a3 100644 --- a/src/services/enclave-mcp-service.test.ts +++ b/src/services/enclave-mcp-service.test.ts @@ -95,7 +95,7 @@ describe('buildEnclaveMcpService', () => { it('fails closed for the not-yet-proven sbx script runtime', () => { const enclaves = normalizeEnclavesConfig([ - { script: { runtime: 'sbx' }, repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, + { script: {}, runtime: 'sbx', repos: [{ repo: 'octo/private', sensitivity: 'internal' }] }, ]); expect(() => buildEnclaveMcpService({ config: config({ enclaves }), imageConfig: ghcr })) .toThrow(/sbx script enclave capability is not yet available/); diff --git a/src/services/enclave-mcp-service.ts b/src/services/enclave-mcp-service.ts index ca081ef6c..792d85c47 100644 --- a/src/services/enclave-mcp-service.ts +++ b/src/services/enclave-mcp-service.ts @@ -335,6 +335,12 @@ export function buildEnclaveMcpService(params: EnclaveMcpServiceParams): Enclave AWF_ENCLAVE_AGENT_MAX_OUTPUT_BYTES: String(agent.maxOutputBytes), AWF_ENCLAVE_AGENT_MAX_PROMPT_BYTES: String(agent.maxTaskBytes), AWF_ENCLAVE_AGENT_MAX_INVOCATIONS: String(agent.maxInvocations), + ...(agent.maxModelRequests !== undefined && { + AWF_ENCLAVE_AGENT_MAX_MODEL_REQUESTS: String(agent.maxModelRequests), + }), + ...(agent.maxModelTokens !== undefined && { + AWF_ENCLAVE_AGENT_MAX_MODEL_TOKENS: String(agent.maxModelTokens), + }), // Enclave bind-mount sources are handed to the daemon, not opened by the // server, so they must be daemon-visible paths. AWF_ENCLAVE_AGENT_HOST_WORK_DIR: toDaemonVisiblePath(paths.workDir, config.dockerHostPathPrefix), diff --git a/src/types/enclave-options.ts b/src/types/enclave-options.ts index 0b53e5ce0..66e76d80c 100644 --- a/src/types/enclave-options.ts +++ b/src/types/enclave-options.ts @@ -67,6 +67,8 @@ export interface EnclaveAgentExecutorConfig { maxOutputBytes: number; maxTaskBytes: number; maxInvocations: number; + maxModelRequests?: number; + maxModelTokens?: number; } export interface EnclavesConfig { @@ -88,16 +90,22 @@ export interface EnclaveOptions { * is a keyed array where every entry carries exactly one `script` or `agent` * key, its own `repos` list, and an optional entry-level `timeout`. */ -export type RawEnclaveScriptExecutorConfig = Omit< +type RawEnclaveCommonConfig = Pick< Partial, - 'enabled' | 'timeout' + 'runtime' | 'image' | 'memoryLimit' | 'cpuLimit' | 'pidsLimit' | 'tmpfsLimit' + | 'maxOutputBytes' | 'maxInvocations' >; -export type RawEnclaveAgentExecutorConfig = Omit< + +export type RawEnclaveScriptExecutorConfig = Pick< + Partial, + 'maxScriptBytes' +>; +export type RawEnclaveAgentExecutorConfig = Pick< Partial, - 'enabled' | 'timeout' + 'engine' | 'profile' | 'maxTaskBytes' | 'maxModelRequests' | 'maxModelTokens' > & { model: string }; -interface RawEnclaveEntryBase { +interface RawEnclaveEntryBase extends RawEnclaveCommonConfig { repos?: EnclaveRepository[]; timeout?: number; } From e2b5e86a86d3b96cba101e1c56e1c1dc48afa56e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:39:07 +0000 Subject: [PATCH 4/5] fix lint in enclave parser Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- src/parsers/enclave-parser.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/parsers/enclave-parser.ts b/src/parsers/enclave-parser.ts index 0d3bca5b1..f5efdbc6d 100644 --- a/src/parsers/enclave-parser.ts +++ b/src/parsers/enclave-parser.ts @@ -41,8 +41,16 @@ function mergeRepositories(entries: RawEnclaveEntry[]): EnclaveRepository[] { function entryCommon(entry: RawEnclaveEntry | undefined): object { if (!entry) return {}; - const { script: _script, agent: _agent, repos: _repos, timeout: _timeout, ...common } = entry; - return common; + return { + ...(entry.runtime !== undefined && { runtime: entry.runtime }), + ...(entry.image !== undefined && { image: entry.image }), + ...(entry.memoryLimit !== undefined && { memoryLimit: entry.memoryLimit }), + ...(entry.cpuLimit !== undefined && { cpuLimit: entry.cpuLimit }), + ...(entry.pidsLimit !== undefined && { pidsLimit: entry.pidsLimit }), + ...(entry.tmpfsLimit !== undefined && { tmpfsLimit: entry.tmpfsLimit }), + ...(entry.maxOutputBytes !== undefined && { maxOutputBytes: entry.maxOutputBytes }), + ...(entry.maxInvocations !== undefined && { maxInvocations: entry.maxInvocations }), + }; } /** From 1679f102ddbd9c34d408aa5dfa88fec2eec58dc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:15:16 +0000 Subject: [PATCH 5/5] fix enclave test fixture types Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- src/enclave/preflight.test.ts | 4 ++-- src/services/enclave-mcp-service.test.ts | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts index 560b0c8b9..031165bde 100644 --- a/src/enclave/preflight.test.ts +++ b/src/enclave/preflight.test.ts @@ -79,7 +79,7 @@ describe('validateEnclavesConfig', () => { network: 'bridge' as 'none', interpreter: 'ruby' as 'python3', maxScriptBytes: 0, - }, + } as never, runtime: 'invalid' as 'docker', memoryLimit: 'lots', cpuLimit: '0', @@ -96,7 +96,7 @@ describe('validateEnclavesConfig', () => { network: 'bridge' as 'api-proxy-only', model: '', maxTaskBytes: 0, - }, + } as never, runtime: 'invalid' as 'docker', memoryLimit: 'lots', cpuLimit: 'all', diff --git a/src/services/enclave-mcp-service.test.ts b/src/services/enclave-mcp-service.test.ts index ed2bec2a3..afaf23b2b 100644 --- a/src/services/enclave-mcp-service.test.ts +++ b/src/services/enclave-mcp-service.test.ts @@ -63,15 +63,15 @@ describe('buildEnclaveMcpService', () => { const enclaves = normalizeEnclavesConfig([ { script: { - runtime: 'gvisor', - memoryLimit: '256m', - cpuLimit: '0.5', - pidsLimit: 32, - tmpfsLimit: '24m', - maxOutputBytes: 2048, maxScriptBytes: 4096, - maxInvocations: 3, }, + runtime: 'gvisor', + memoryLimit: '256m', + cpuLimit: '0.5', + pidsLimit: 32, + tmpfsLimit: '24m', + maxOutputBytes: 2048, + maxInvocations: 3, repos: [{ repo: 'octo/private', sensitivity: 'internal' }], timeout: 12, },