diff --git a/containers/bounded-query/bounded-execution/sensitivity-policy.js b/containers/bounded-query/bounded-execution/sensitivity-policy.js index ef8da1e43..476baa8a4 100644 --- a/containers/bounded-query/bounded-execution/sensitivity-policy.js +++ b/containers/bounded-query/bounded-execution/sensitivity-policy.js @@ -2,8 +2,9 @@ /** * Repository sensitivity categories and their fixed per-run information - * budgets — broker-side mirror of `BOUNDED_QUERY_SENSITIVITY_RUN_BITS` in - * `src/types/bounded-query-options.ts`. Kept in a tiny standalone module (not + * budgets — broker-side mirror of `ENCLAVE_SENSITIVITY_RUN_BITS` in + * `src/types/enclave-options.ts`. The bounded-query names below are compatibility + * aliases while legacy brokers remain live. Kept in a tiny standalone module (not * `protocol.js`) because it is config/ledger data, not wire protocol. * * `null` means "unmetered": `public` still runs through the same finite diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index b95725a84..bd5c74469 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -2439,6 +2439,42 @@ can answer the question. bounded queries; unlike a bounded query it does have a network interface, to the API proxy only. +## 16. Unified Enclaves (Migration Foundation) + +The optional `enclaves` object is the successor configuration model for bounded +private-repository execution. In this foundation release it is parsed, +normalized, and validated but does not create a runtime service or primary-agent +surface. See [Unified Enclave Architecture and Migration](enclaves-architecture.md) +for the target trust boundaries and rollout sequence. + +`enclaves.privateRepos` is the single trusted repository list for every +executor. Each entry has the same `public`, `internal`, `confidential`, or +`sealed` sensitivity policy used by the legacy systems. The resulting +information budget is one per-repository, per-run balance shared by script and +agent executor invocations; an executor change never resets the balance. + +`enclaves.executors.script` and `enclaves.executors.agent` are independently +enabled trusted definitions. Script defaults preserve the bounded-query limits +(`docker`, no network, `python3`, 30 seconds, 512 MiB, 32 invocations). Agent +defaults preserve the bounded-agent limits (`docker`, API-proxy-only network, +Copilot/OpenAI profile, 120 seconds, 512 MiB, 8 invocations, 8 model requests, +1024 completion tokens). Neither executor is enabled by omission. + +Images, runtimes, interpreters, engines, provider profiles, models, networks, +timeouts, resource limits, and operational limits are trusted configuration. +Future invocation protocols MUST reject those controls, including unknown +aliases for them. An enabled agent executor requires a configured model. + +When `enclaves.enabled` is `true`, at least one executor and one repository are +required. `boundedQueries.enabled` or `boundedAgents.enabled` MUST NOT also be +true. AWF rejects that mixed configuration before any legacy broker, enclave +server, repository staging, or primary agent starts. Disabled sections may +coexist because they do not activate a runtime. + +The foundation does not combine the existing live broker ledgers. Shared-budget +runtime enforcement begins only when the AWF-owned enclave MCP server replaces +both direct brokers in a later migration layer. + ## Normative References - [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) — Key words for use in diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 1cafc53cd..e2460cb35 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -1102,6 +1102,313 @@ "model" ] } + }, + "enclaves": { + "type": "object", + "description": "Unified private-repository enclave foundation. Repositories and sensitivities are shared by script and agent executors, and every invocation debits one per-repository information budget regardless of executor kind. This layer validates trusted configuration only; it does not expose an MCP server or a primary-agent surface.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable the unified enclave subsystem. Cannot be enabled with boundedQueries or boundedAgents." + }, + "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": "object", + "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": [ + "public", + "internal", + "confidential", + "sealed" + ] + } + } + } + }, + "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": { + "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 engine image." + }, + "network": { + "const": "api-proxy-only", + "default": "api-proxy-only" + }, + "engine": { + "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 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "default": 1024 + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "model" + ] + } + } + } + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "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 + } + } + } + } + } + ] + } + } + } } }, "$defs": { diff --git a/docs/enclaves-architecture.md b/docs/enclaves-architecture.md new file mode 100644 index 000000000..3d8b32e70 --- /dev/null +++ b/docs/enclaves-architecture.md @@ -0,0 +1,96 @@ +# Unified Enclave Architecture and Migration + +## Status + +Foundation accepted for staged migration. This document describes the target +architecture; the first implementation layer adds configuration and shared +contracts without changing either legacy runtime. + +## Decision + +AWF will replace `boundedQueries` and `boundedAgents` with one `enclaves` +subsystem. Trusted configuration declares a shared set of private repositories, +their sensitivities, and two executor kinds: + +- **script** runs a fixed interpreter in a no-network sandbox; +- **agent** runs a fixed native agent on an API-proxy-only network. + +Runtime, image, model, network, timeout, resource, mount, credential, and tool +settings are trusted AWF configuration. An enclave invocation may select only an +allowed repository, a finite response schema, and executor-specific bounded +input. It can never provide or override trusted controls. + +Every repository has **one information-budget ledger for the AWF run**. Script +and agent invocations debit the same balance. Selecting a different executor +does not create a second budget, and charges are never refunded after an +invocation is admitted. + +## Target trust boundaries + +1. **AWF host orchestration (trusted).** AWF validates configuration, proves + runtime capabilities, stages immutable repository seeds, creates private + state, launches the enclave MCP server, and owns cleanup. Staging credentials + exist only here. +2. **Enclave MCP server (trusted, AWF-owned).** AWF owns and launches the server. + It loads trusted executor configuration and the single repository ledger, + admits finite-schema requests, launches isolated executors, canonicalizes one + finite result, and protects audit state. It is not a user-supplied MCP server. +3. **`gh-aw-mcpg` (trusted policy gateway).** The primary agent can reach the + enclave server only through `gh-aw-mcpg`. The gateway guards the tool surface + and calls the AWF-owned server; it does not receive repository seeds, + credentials, executor configuration, or ledger state. +4. **Executor enclave (untrusted workload).** Each invocation receives only its + selected immutable seed and bounded input. Script execution has no network. + Agent execution can reach only its dedicated API proxy. Neither can reach the + primary agent, MCP gateway, server control state, another executor, or host + state. +5. **Primary agent (untrusted caller).** It sees only MCP tool schemas and one + canonical finite success/error response. It cannot access a broker socket, + direct executor command, private seed, audit record, or remaining budget. + +Repository-derived content processed by an agent executor reaches the configured +model provider through the API proxy. The information ledger bounds what the +primary agent learns; it does not bound what the provider sees. + +## Startup and readiness + +`gh-aw-mcpg` startup may precede AWF's enclave server startup. The configured MCP +server connection timeout and retry policy are the synchronization mechanism; +neither component may silently downgrade or bypass the gateway while waiting. + +The primary agent must not start until AWF has proved readiness end to end: + +1. the AWF-owned enclave MCP server is healthy; +2. `gh-aw-mcpg` has connected to that exact configured server; +3. a guarded readiness call has traversed `gh-aw-mcpg` to the server and returned + the expected proof. + +A timeout, identity mismatch, failed proof, or unavailable executor capability +fails the run before repository staging is exposed or the primary agent starts. + +## Migration sequence + +1. **Foundation (this layer).** Add strict `enclaves` config, neutral finite + disclosure/staging/budget contracts, shared-ledger semantics, and compatibility + exports. Keep both legacy systems fully functional and reject simultaneous + enablement of a unified and legacy surface. +2. **AWF-owned MCP server.** Implement the server over the shared contracts, + retaining trusted executor launchers behind adapters. Add authenticated local + transport and readiness proof; do not expose direct broker ingress. +3. **`gh-aw-mcpg` integration.** Register and guard the AWF-owned server, wire + startup retry/timeouts, require end-to-end readiness before primary-agent + startup, and route both executor tools exclusively through the gateway. +4. **Runtime cutover.** Move staging, auditing, timing, and the shared ledger to + the unified server. Remove direct `bounded-query` and `bounded-agent` agent + surfaces after parity tests demonstrate canonical response and isolation + equivalence. +5. **Legacy removal.** Remove `boundedQueries`, `boundedAgents`, their brokers, + compatibility exports, images, docs, and tests only after the unified path is + the sole supported runtime. + +## Compatibility + +This foundation layer is behavior-preserving. It does not launch an MCP server, +change primary-agent mounts or environment, combine live broker ledgers, or +alter legacy protocol bytes. Existing `boundedQueries` and `boundedAgents` +configurations continue to run as before. diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 1cafc53cd..e2460cb35 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -1102,6 +1102,313 @@ "model" ] } + }, + "enclaves": { + "type": "object", + "description": "Unified private-repository enclave foundation. Repositories and sensitivities are shared by script and agent executors, and every invocation debits one per-repository information budget regardless of executor kind. This layer validates trusted configuration only; it does not expose an MCP server or a primary-agent surface.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable the unified enclave subsystem. Cannot be enabled with boundedQueries or boundedAgents." + }, + "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": "object", + "additionalProperties": false, + "required": [ + "repo", + "sensitivity" + ], + "properties": { + "repo": { + "type": "string", + "maxLength": 140, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/(?!\\.\\.?$)(?!.*\\.\\.)[A-Za-z0-9._-]{1,100}$" + }, + "sensitivity": { + "type": "string", + "enum": [ + "public", + "internal", + "confidential", + "sealed" + ] + } + } + } + }, + "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": { + "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 engine image." + }, + "network": { + "const": "api-proxy-only", + "default": "api-proxy-only" + }, + "engine": { + "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 + }, + "maxModelRequests": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 8 + }, + "maxModelTokens": { + "type": "integer", + "minimum": 1, + "maximum": 32768, + "default": 1024 + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "required": [ + "enabled" + ] + }, + "then": { + "required": [ + "model" + ] + } + } + } + } + }, + "if": { + "properties": { + "enabled": { + "const": true + } + }, + "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 + } + } + } + } + } + ] + } + } + } } }, "$defs": { diff --git a/src/bounded-execution/finite-disclosure.ts b/src/bounded-execution/finite-disclosure.ts index 153acb0f9..be8caa7d5 100644 --- a/src/bounded-execution/finite-disclosure.ts +++ b/src/bounded-execution/finite-disclosure.ts @@ -913,3 +913,6 @@ export const informationChargeForSchema = queryBitsForSchema; export const canonicalizeFiniteSchemaValue = canonicalizeSchemaValue; export const canonicalSuccessJson = canonicalOkJson; export const CANONICAL_ERROR_RESPONSE_JSON = CANONICAL_ERROR_JSON; +export const PRIVATE_REPOSITORY_PATTERN = BOUNDED_QUERY_REPO_PATTERN; +export const MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS = MAX_QUERY_TIMEOUT_SECONDS; +export const parseAndValidateFiniteOutput = parseAndValidateQueryOutput; diff --git a/src/bounded-execution/index.ts b/src/bounded-execution/index.ts index 5b97171ca..554685fa7 100644 --- a/src/bounded-execution/index.ts +++ b/src/bounded-execution/index.ts @@ -1,2 +1,3 @@ export * from './finite-disclosure'; export * from './repository-staging'; +export * from '../enclave/information-budget'; diff --git a/src/bounded-execution/repository-staging.ts b/src/bounded-execution/repository-staging.ts index 593a01031..23b9fadf8 100644 --- a/src/bounded-execution/repository-staging.ts +++ b/src/bounded-execution/repository-staging.ts @@ -6,7 +6,7 @@ * consumes. */ -import type { BoundedQuerySensitivity } from '../types/bounded-query-options'; +import type { EnclaveSensitivity } from '../types/enclave-options'; /** * Version of the on-disk seed-map document. @@ -33,7 +33,7 @@ export interface PrivateRepositorySeedDescriptor { /** Commit the seed was materialized at, recorded for protected audit state. */ commit: string; /** Trusted confidentiality category, carried unmodified into the seed map. */ - sensitivity: BoundedQuerySensitivity; + sensitivity: EnclaveSensitivity; } /** @@ -50,7 +50,7 @@ export interface PrivateRepositorySeedDescriptor { export interface PrivateRepositorySeedMap { version: typeof PRIVATE_REPOSITORY_SEED_MAP_VERSION; runId: string; - seeds: Array<{ repo: string; seedId: string; sensitivity: BoundedQuerySensitivity }>; + seeds: Array<{ repo: string; seedId: string; sensitivity: EnclaveSensitivity }>; } /** Result of the trusted host staging phase. */ @@ -64,6 +64,11 @@ export type BoundedQuerySeed = PrivateRepositorySeedDescriptor; export type BoundedQuerySeedMap = PrivateRepositorySeedMap; export type BoundedQueryStagingResult = PrivateRepositoryStagingResult; +/** Canonical lookup key shared by staging, admission, and budget accounting. */ +export function normalizePrivateRepositoryKey(repo: string): string { + return repo.trim().toLowerCase(); +} + /** Canonically serializes the protected broker seed map. */ export function serializePrivateRepositorySeedMap(seedMap: PrivateRepositorySeedMap): string { return JSON.stringify(seedMap, null, 2) + '\n'; diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 065ffebb4..fd574b04c 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -122,6 +122,63 @@ describe('runMainWorkflow', () => { (topology.getTopologyContainerIps as jest.Mock).mockResolvedValue(new Map()); }); + it('rejects invalid enclave configuration before staging or startup', async () => { + const prepareBoundedQueries = jest.fn(); + const dependencies = createWorkflowDependencies({ prepareBoundedQueries }); + const config: WrapperConfig = { + ...baseConfig, + boundedQueries: { enabled: true } as WrapperConfig['boundedQueries'], + enclaves: { + enabled: true, + privateRepos: [ + { repo: 'octo/private', sensitivity: 'internal' }, + { repo: 'Octo/Private', sensitivity: 'internal' }, + ], + executors: { + script: { + enabled: true, + runtime: 'docker', + network: 'none', + interpreter: 'python3', + timeout: 30, + memoryLimit: '512m', + cpuLimit: '1', + pidsLimit: 128, + tmpfsLimit: '64m', + maxOutputBytes: 8192, + maxScriptBytes: 65536, + maxInvocations: 32, + }, + agent: { + enabled: false, + runtime: 'docker', + network: 'api-proxy-only', + engine: 'copilot', + profile: 'openai', + model: '', + timeout: 120, + memoryLimit: '512m', + cpuLimit: '1', + pidsLimit: 128, + tmpfsLimit: '64m', + maxOutputBytes: 8192, + maxTaskBytes: 4096, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, + }, + }, + }, + }; + + await expect(runMainWorkflow(config, dependencies, createWorkflowOptions())) + .rejects.toThrow(/Invalid enclave configuration.*duplicate entry/s); + expect(prepareBoundedQueries).not.toHaveBeenCalled(); + expect(dependencies.ensureFirewallNetwork).not.toHaveBeenCalled(); + expect(dependencies.writeConfigs).not.toHaveBeenCalled(); + expect(dependencies.startContainers).not.toHaveBeenCalled(); + }); + it('executes workflow steps in order and logs success for zero exit code', async () => { const callOrder: string[] = []; const dependencies = createOrderedWorkflowDependencies(callOrder); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 1a42bf79a..891441d73 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -5,6 +5,7 @@ import { parseDifcProxyHost } from './docker-manager'; import { CLI_PROXY_IP, DOH_PROXY_IP, SQUID_IP, API_PROXY_IP } from './host-iptables-shared'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; import { TOPOLOGY_NETWORK_NAME, getTopologyContainerIps, patchComposeWithTopologyHosts } from './topology'; +import { validateEnclavesConfig } from './enclave/preflight'; /** * Dependencies injected into the main workflow. @@ -86,6 +87,11 @@ export async function runMainWorkflow( ): Promise { const { logger, performCleanup, onHostIptablesSetup, onContainersStarted } = options; + const enclaveErrors = validateEnclavesConfig(config); + if (enclaveErrors.length > 0) { + throw new Error(`Invalid enclave configuration:\n- ${enclaveErrors.join('\n- ')}`); + } + // Step -1: Bounded-query staging (trusted, host-side, credential-bearing). // // Runs first so that: diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index cb06a0338..884c2e72c 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -616,4 +616,25 @@ describe('buildConfig', () => { expect(config.legacySecurity).toBeUndefined(); }); }); + + it('normalizes unified enclave config into the wrapper config', () => { + const config = buildConfig(makeInputs({ + options: { + ...makeInputs().options, + enclaves: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { script: { enabled: true } }, + }, + }, + })); + expect(config.enclaves).toMatchObject({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + executors: { + script: { enabled: true, network: 'none' }, + agent: { enabled: false, network: 'api-proxy-only' }, + }, + }); + }); }); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 84ed3237d..a33bb084c 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -3,6 +3,7 @@ import type { AwfFileConfig } from '../config-file'; import { resolveApiCredentials } from './resolve-credentials'; import { normalizeBoundedQueriesConfig } from '../parsers/bounded-query-parser'; import { normalizeBoundedAgentsConfig } from '../parsers/bounded-agent-parser'; +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; import { logger } from '../logger'; /** @@ -222,6 +223,9 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { boundedAgents: normalizeBoundedAgentsConfig( options.boundedAgents as AwfFileConfig['boundedAgents'] | undefined, ), + enclaves: normalizeEnclavesConfig( + options.enclaves as AwfFileConfig['enclaves'] | undefined, + ), }; } diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index b3087b542..621760957 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -603,4 +603,13 @@ describe('mapAwfFileConfigToCliOptions', () => { const result = mapAwfFileConfigToCliOptions({}); expect(result.boundedQueries).toBeUndefined(); }); + + 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 } }, + }; + expect(mapAwfFileConfigToCliOptions({ enclaves }).enclaves).toEqual(enclaves); + }); }); diff --git a/src/config-file.ts b/src/config-file.ts index d527aa546..8c24dc9e6 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { validateWithSchema } from './schema-validator'; +import type { RawEnclavesConfig } from './types/enclave-options'; /** @internal Used only by config-file helpers — not part of public API */ // ts-prune-ignore-next @@ -215,6 +216,11 @@ export interface AwfFileConfig { maxModelRequests?: number; maxModelTokens?: number; }; + /** + * Unified enclave configuration. This foundation is parsed and validated but + * does not expose a primary-agent runtime surface yet. + */ + enclaves?: RawEnclavesConfig; } /** @@ -228,7 +234,22 @@ export interface AwfFileConfig { */ // ts-prune-ignore-next export function validateAwfFileConfig(config: unknown): string[] { - return validateWithSchema(config); + const errors = validateWithSchema(config); + if (typeof config !== 'object' || config === null || Array.isArray(config)) return errors; + + const raw = config as Record; + const isEnabled = (value: unknown): boolean => + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && (value as Record).enabled === true; + + if (isEnabled(raw.enclaves) && (isEnabled(raw.boundedQueries) || isEnabled(raw.boundedAgents))) { + errors.push( + 'config.enclaves cannot be enabled with config.boundedQueries or config.boundedAgents; choose one configuration surface', + ); + } + return errors; } const readStdinSync = (): string => fs.readFileSync(process.stdin.fd, 'utf8'); diff --git a/src/config-mapper.ts b/src/config-mapper.ts index efa8105a0..1a858f20c 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -152,5 +152,9 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { + it('matches the broker-side sensitivity policy', () => { + expect(ENCLAVE_SENSITIVITIES).toEqual(brokerPolicy.SENSITIVITY_LEVELS); + expect(ENCLAVE_SENSITIVITY_RUN_BITS).toEqual(brokerPolicy.SENSITIVITY_RUN_BITS); + expect(ENCLAVE_INFORMATION_BUDGET_POLICY.runBits).toBe(ENCLAVE_SENSITIVITY_RUN_BITS); + }); + + it('shares one repository balance across script and agent invocations', () => { + const ledger = createEnclaveInformationBudgetLedger(new Map([ + ['octo/private', { sensitivity: 'confidential' as const }], + ])); + + expect(ledger.tryDebit('octo/private', 4, 'script')).toBe(true); + expect(ledger.remainingBits('octo/private')).toBe(4); + expect(ledger.tryDebit('Octo/Private', 4, 'agent')).toBe(true); + expect(ledger.remainingBits('OCTO/PRIVATE')).toBe(0); + expect(ledger.tryDebit('octo/private', 1, 'script')).toBe(false); + }); +}); diff --git a/src/enclave/information-budget.ts b/src/enclave/information-budget.ts new file mode 100644 index 000000000..07927daed --- /dev/null +++ b/src/enclave/information-budget.ts @@ -0,0 +1,51 @@ +import { + ENCLAVE_SENSITIVITY_RUN_BITS, + type EnclaveSensitivity, +} from '../types/enclave-options'; +import { normalizePrivateRepositoryKey } from '../bounded-execution/repository-staging'; + +export type EnclaveExecutorKind = 'script' | 'agent'; + +export interface EnclaveInformationBudgetPolicy { + readonly runBits: Readonly>; +} + +export const ENCLAVE_INFORMATION_BUDGET_POLICY: EnclaveInformationBudgetPolicy = { + runBits: ENCLAVE_SENSITIVITY_RUN_BITS, +}; + +export interface EnclaveInformationBudgetLedger { + tryDebit(repoKey: string, bits: number, executor: EnclaveExecutorKind): boolean; + remainingBits(repoKey: string): number | null | undefined; +} + +/** + * Creates one run-scoped ledger shared by script and agent executor calls. + * + * The executor argument is intentionally not part of the balance key: switching + * executor kinds cannot reset or fork a repository's disclosure budget. + */ +export function createEnclaveInformationBudgetLedger( + repositories: ReadonlyMap, + policy: EnclaveInformationBudgetPolicy = ENCLAVE_INFORMATION_BUDGET_POLICY, +): EnclaveInformationBudgetLedger { + const remaining = new Map(); + for (const [repoKey, repository] of repositories) { + remaining.set(normalizePrivateRepositoryKey(repoKey), policy.runBits[repository.sensitivity]); + } + + return { + tryDebit(repoKey, bits, _executor) { + const normalizedRepoKey = normalizePrivateRepositoryKey(repoKey); + if (!Number.isSafeInteger(bits) || bits < 0 || !remaining.has(normalizedRepoKey)) return false; + const current = remaining.get(normalizedRepoKey); + if (current === null) return true; + if (current === undefined || bits > current) return false; + remaining.set(normalizedRepoKey, current - bits); + return true; + }, + remainingBits(repoKey) { + return remaining.get(normalizePrivateRepositoryKey(repoKey)); + }, + }; +} diff --git a/src/enclave/preflight.test.ts b/src/enclave/preflight.test.ts new file mode 100644 index 000000000..f5d02ff76 --- /dev/null +++ b/src/enclave/preflight.test.ts @@ -0,0 +1,124 @@ +import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; +import type { WrapperConfig } from '../types'; +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 } }, + }), + ...overrides, + } as WrapperConfig; +} + +describe('validateEnclavesConfig', () => { + it('accepts a minimal normalized foundation configuration', () => { + expect(validateEnclavesConfig(config())).toEqual([]); + }); + + it('fails closed when a legacy subsystem is also enabled', () => { + const errors = validateEnclavesConfig(config({ + boundedAgents: { enabled: true } as WrapperConfig['boundedAgents'], + })); + expect(errors.join('\n')).toMatch(/cannot be enabled with boundedQueries or boundedAgents/); + }); + + 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 an empty repository list', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + executors: { script: { enabled: true } }, + }); + expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/privateRepos is empty/); + }); + + 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' } }, + }); + + expect(validateEnclavesConfig(config({ enclaves })).join('\n')).toMatch(/requires the AWF API proxy/); + expect(validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + })).join('\n')).toMatch(/COPILOT_GITHUB_TOKEN/); + expect(validateEnclavesConfig(config({ + enclaves, + enableApiProxy: true, + copilotGithubToken: 'token', + }))).toEqual([]); + }); + + it('rejects malformed executor controls that bypass schema validation', () => { + const enclaves = normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'not-a-slug', sensitivity: 'internal' }], + executors: { + script: { + enabled: true, + runtime: 'invalid' as 'docker', + network: 'bridge' as 'none', + interpreter: 'ruby' as 'python3', + timeout: 0, + memoryLimit: 'lots', + cpuLimit: '0', + pidsLimit: 0, + tmpfsLimit: '64', + maxOutputBytes: 0, + maxScriptBytes: 0, + maxInvocations: 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, + tmpfsLimit: '64', + maxOutputBytes: 0, + maxTaskBytes: 0, + maxInvocations: 0, + maxModelRequests: 0, + maxModelTokens: 0, + }, + }, + }); + + 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(/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(/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(/is not a Docker size/); + expect(errors).toMatch(/positive Docker --cpus value/); + expect(errors).toMatch(/must be a positive integer/); + }); +}); diff --git a/src/enclave/preflight.ts b/src/enclave/preflight.ts new file mode 100644 index 000000000..305ff048f --- /dev/null +++ b/src/enclave/preflight.ts @@ -0,0 +1,114 @@ +import type { WrapperConfig } from '../types'; +import type { EnclavesConfig } from '../types/enclave-options'; +import { + MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS, + PRIVATE_REPOSITORY_PATTERN, +} from '../bounded-execution'; +import { normalizePrivateRepositoryKey } from '../bounded-execution/repository-staging'; +import { resolveApiProxyRoute } from '../bounded-agent/preflight'; + +const RUNTIMES = new Set(['docker', 'gvisor', 'sbx']); +const ENGINES = new Set(['copilot', 'claude', 'codex', 'gemini']); + +function validateRepositoryList(enclaves: EnclavesConfig, errors: string[]): void { + if (enclaves.privateRepos.length === 0) { + errors.push('enclaves.enabled is true but enclaves.privateRepos is empty'); + } + const seen = new Set(); + 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`); + continue; + } + const key = normalizePrivateRepositoryKey(repository.repo); + if (seen.has(key)) errors.push(`enclaves.privateRepos contains a duplicate entry: "${repository.repo}"`); + seen.add(key); + } +} + +/** Static, fail-closed checks for the unified enclave foundation. */ +export function validateEnclavesConfig(config: WrapperConfig): string[] { + const enclaves = config.enclaves; + if (!enclaves?.enabled) return []; + + const errors: string[] = []; + if (config.boundedQueries?.enabled || config.boundedAgents?.enabled) { + errors.push( + 'enclaves cannot be enabled with boundedQueries or boundedAgents; choose the unified enclaves section or the legacy sections', + ); + } + + 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'); + } + + 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 (!Number.isInteger(script.timeout) || script.timeout < 1 || script.timeout > MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS) { + errors.push( + `enclaves.executors.script.timeout must be between 1 and ${MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS}`, + ); + } + validateResourceLimits('enclaves.executors.script', script, errors); + validatePositiveInteger('enclaves.executors.script.maxScriptBytes', script.maxScriptBytes, errors); + validatePositiveInteger('enclaves.executors.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 (!ENGINES.has(agent.engine)) errors.push(`enclaves.executors.agent.engine "${agent.engine}" is not supported`); + if (agent.network !== 'api-proxy-only') { + errors.push('enclaves.executors.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 (!config.enableApiProxy) { + errors.push('enclaves agent executor requires the AWF API proxy'); + } else { + const route = resolveApiProxyRoute(config, agent); + if (!route.routed) { + errors.push(`enclaves agent executor has no usable model route: ${route.detail}`); + } + } + if (!Number.isInteger(agent.timeout) || agent.timeout < 1 || agent.timeout > MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS) { + errors.push( + `enclaves.executors.agent.timeout must be between 1 and ${MAX_BOUNDED_EXECUTION_TIMEOUT_SECONDS}`, + ); + } + validateResourceLimits('enclaves.executors.agent', agent, errors); + validatePositiveInteger('enclaves.executors.agent.maxTaskBytes', agent.maxTaskBytes, errors); + validatePositiveInteger('enclaves.executors.agent.maxInvocations', agent.maxInvocations, errors); + validatePositiveInteger('enclaves.executors.agent.maxModelRequests', agent.maxModelRequests, errors); + validatePositiveInteger('enclaves.executors.agent.maxModelTokens', agent.maxModelTokens, errors); + } + + return errors; +} + +function validatePositiveInteger(name: string, value: number, errors: string[]): void { + if (!Number.isSafeInteger(value) || value < 1) errors.push(`${name} must be a positive integer`); +} + +function validateResourceLimits( + name: string, + executor: { + memoryLimit: string; + cpuLimit: string; + pidsLimit: number; + tmpfsLimit: string; + maxOutputBytes: number; + }, + errors: string[], +): void { + const dockerSize = /^[1-9][0-9]*[bkmgBKMG]$/; + if (!dockerSize.test(executor.memoryLimit)) errors.push(`${name}.memoryLimit is not a Docker size`); + if (!dockerSize.test(executor.tmpfsLimit)) errors.push(`${name}.tmpfsLimit is not a Docker size`); + if (!/^(?:[0-9]{1,2})(?:\.[0-9]{1,3})?$/.test(executor.cpuLimit) || Number(executor.cpuLimit) <= 0) { + errors.push(`${name}.cpuLimit must be a positive Docker --cpus value`); + } + validatePositiveInteger(`${name}.pidsLimit`, executor.pidsLimit, errors); + validatePositiveInteger(`${name}.maxOutputBytes`, executor.maxOutputBytes, errors); +} diff --git a/src/parsers/enclave-parser.test.ts b/src/parsers/enclave-parser.test.ts new file mode 100644 index 000000000..69c57d7cd --- /dev/null +++ b/src/parsers/enclave-parser.test.ts @@ -0,0 +1,125 @@ +import { validateAwfFileConfig } from '../config-file'; +import { + ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + ENCLAVES_DEFAULTS, +} from '../types/enclave-options'; +import { normalizeEnclavesConfig } from './enclave-parser'; + +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(ENCLAVES_DEFAULTS).toEqual({ + enabled: false, + privateRepos: [], + executors: { + script: ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + agent: ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + }, + }); + }); + + it('preserves trusted executor overrides and shared repositories', () => { + expect(normalizeEnclavesConfig({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'confidential' }], + executors: { + script: { enabled: true, runtime: 'gvisor', image: 'registry/script@sha256:abc' }, + agent: { enabled: true, model: 'gpt-5', maxModelRequests: 3 }, + }, + })).toMatchObject({ + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'confidential' }], + executors: { + script: { + enabled: true, + runtime: 'gvisor', + image: 'registry/script@sha256:abc', + network: 'none', + }, + agent: { + enabled: true, + model: 'gpt-5', + maxModelRequests: 3, + network: 'api-proxy-only', + }, + }, + }); + }); +}); + +describe('enclaves JSON Schema', () => { + const repository = { repo: 'octo/private', sensitivity: 'internal' as const }; + + it('accepts script, agent, and combined executor definitions', () => { + expect(validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { script: { enabled: true } }, + }, + })).toEqual([]); + expect(validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { agent: { enabled: true, model: 'gpt-5' } }, + }, + })).toEqual([]); + expect(validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { + script: { enabled: true }, + agent: { enabled: true, model: 'gpt-5' }, + }, + }, + })).toEqual([]); + }); + + it('requires repositories and at least one explicitly enabled executor', () => { + expect(validateAwfFileConfig({ enclaves: { enabled: true } }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: { enabled: true, privateRepos: [repository], executors: {} }, + }).length).toBeGreaterThan(0); + }); + + it('keeps trusted controls closed and constrained', () => { + expect(validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { script: { enabled: true, network: 'bridge' } }, + }, + }).length).toBeGreaterThan(0); + expect(validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { agent: { enabled: true, model: 'gpt-5', tools: ['shell'] } }, + }, + }).length).toBeGreaterThan(0); + }); + + it('fails clearly when a unified and legacy surface are both enabled', () => { + const errors = validateAwfFileConfig({ + enclaves: { + enabled: true, + privateRepos: [repository], + executors: { script: { enabled: true } }, + }, + boundedQueries: { + enabled: true, + privateRepos: [{ repo: 'octo/private', sensitivity: 'internal' }], + }, + }); + expect(errors).toContain( + 'config.enclaves cannot be enabled with config.boundedQueries or config.boundedAgents; choose one configuration surface', + ); + }); +}); diff --git a/src/parsers/enclave-parser.ts b/src/parsers/enclave-parser.ts new file mode 100644 index 000000000..96106a062 --- /dev/null +++ b/src/parsers/enclave-parser.ts @@ -0,0 +1,33 @@ +import type { RawEnclavesConfig } from '../types/enclave-options'; +import { + ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + type EnclavesConfig, +} from '../types/enclave-options'; + +/** Applies trusted defaults without enabling either executor implicitly. */ +export function normalizeEnclavesConfig( + raw: RawEnclavesConfig | undefined, +): EnclavesConfig | undefined { + if (!raw) return undefined; + + const script = raw.executors?.script; + const agent = raw.executors?.agent; + + return { + enabled: raw.enabled === true, + privateRepos: (raw.privateRepos ?? []).map((entry) => ({ ...entry })), + executors: { + script: { + ...ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + ...script, + enabled: script?.enabled === true, + }, + agent: { + ...ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + ...agent, + enabled: agent?.enabled === true, + }, + }, + }; +} diff --git a/src/schema.test.ts b/src/schema.test.ts index 377b7734a..23bc7f19b 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -44,6 +44,8 @@ describe('awf-config.schema.json', () => { 'rateLimiting', 'platform', 'boundedQueries', + 'boundedAgents', + 'enclaves', ]) ); }); diff --git a/src/types/bounded-query-options.ts b/src/types/bounded-query-options.ts index 4ca00559a..ad3cc9e5c 100644 --- a/src/types/bounded-query-options.ts +++ b/src/types/bounded-query-options.ts @@ -1,3 +1,10 @@ +import { + ENCLAVE_SENSITIVITIES, + ENCLAVE_SENSITIVITY_RUN_BITS, + type EnclaveRepository, + type EnclaveSensitivity, +} from './enclave-options'; + /** * Bounded-query sandbox configuration types. * @@ -27,15 +34,10 @@ export type BoundedQueryInterpreter = 'python3'; * numeric override, but no category may ever be granted more than its listed * maximum. */ -export type BoundedQuerySensitivity = 'public' | 'internal' | 'confidential' | 'sealed'; +export type BoundedQuerySensitivity = EnclaveSensitivity; /** Every supported sensitivity value, for schema/validation enumeration. */ -export const BOUNDED_QUERY_SENSITIVITIES: readonly BoundedQuerySensitivity[] = [ - 'public', - 'internal', - 'confidential', - 'sealed', -]; +export const BOUNDED_QUERY_SENSITIVITIES: readonly BoundedQuerySensitivity[] = ENCLAVE_SENSITIVITIES; /** * Immutable per-repository run-budget table. @@ -53,12 +55,7 @@ export const BOUNDED_QUERY_SENSITIVITIES: readonly BoundedQuerySensitivity[] = [ * identity or storage across runs, so this is deliberately not a * "lifetime" budget. */ -export const BOUNDED_QUERY_SENSITIVITY_RUN_BITS: Readonly> = { - public: null, - internal: 64, - confidential: 8, - sealed: 0, -}; +export const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = ENCLAVE_SENSITIVITY_RUN_BITS; /** * A trusted, per-repository descriptor. @@ -67,12 +64,7 @@ export const BOUNDED_QUERY_SENSITIVITY_RUN_BITS: Readonly> = { + public: null, + internal: 64, + confidential: 8, + sealed: 0, +}; + +export interface EnclaveRepository { + repo: string; + sensitivity: EnclaveSensitivity; +} + +export type EnclaveRuntime = 'docker' | 'gvisor' | 'sbx'; +export type EnclaveScriptInterpreter = 'python3'; +export type EnclaveAgentEngine = 'copilot' | 'claude' | 'codex' | 'gemini'; +export type EnclaveAgentProfile = 'openai' | 'anthropic'; + +export interface EnclaveScriptExecutorConfig { + enabled: boolean; + runtime: EnclaveRuntime; + /** Optional trusted image override; omission uses AWF's pinned script image. */ + image?: string; + network: 'none'; + interpreter: EnclaveScriptInterpreter; + timeout: number; + memoryLimit: string; + cpuLimit: string; + pidsLimit: number; + tmpfsLimit: string; + maxOutputBytes: number; + maxScriptBytes: number; + maxInvocations: number; +} + +export interface EnclaveAgentExecutorConfig { + enabled: boolean; + runtime: EnclaveRuntime; + /** Optional trusted image override; omission uses AWF's pinned engine image. */ + image?: string; + network: 'api-proxy-only'; + engine: EnclaveAgentEngine; + profile: EnclaveAgentProfile; + model: string; + timeout: number; + memoryLimit: string; + cpuLimit: string; + pidsLimit: number; + tmpfsLimit: string; + maxOutputBytes: number; + maxTaskBytes: number; + maxInvocations: number; + maxModelRequests: number; + maxModelTokens: number; +} + +export interface EnclavesConfig { + enabled: boolean; + privateRepos: EnclaveRepository[]; + executors: { + script: EnclaveScriptExecutorConfig; + agent: EnclaveAgentExecutorConfig; + }; +} + +export interface EnclaveOptions { + /** Present only when the config file contains an `enclaves` section. */ + enclaves?: EnclavesConfig; +} + +export type RawEnclaveScriptExecutorConfig = Partial; +export type RawEnclaveAgentExecutorConfig = Partial; + +export interface RawEnclavesConfig { + enabled?: boolean; + privateRepos?: EnclaveRepository[]; + executors?: { + script?: RawEnclaveScriptExecutorConfig; + agent?: RawEnclaveAgentExecutorConfig; + }; +} + +export const ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS: Readonly< + Omit +> = { + enabled: false, + runtime: 'docker', + network: 'none', + interpreter: 'python3', + timeout: 30, + memoryLimit: '512m', + cpuLimit: '1', + pidsLimit: 128, + tmpfsLimit: '64m', + maxOutputBytes: 8192, + maxScriptBytes: 64 * 1024, + maxInvocations: 32, +}; + +export const ENCLAVE_AGENT_EXECUTOR_DEFAULTS: Readonly< + Omit +> = { + enabled: false, + runtime: 'docker', + network: 'api-proxy-only', + engine: 'copilot', + profile: 'openai', + model: '', + timeout: 120, + memoryLimit: '512m', + cpuLimit: '1', + pidsLimit: 128, + tmpfsLimit: '64m', + maxOutputBytes: 8192, + maxTaskBytes: 4096, + maxInvocations: 8, + maxModelRequests: 8, + maxModelTokens: 1024, +}; + +export const ENCLAVES_DEFAULTS: Readonly = { + enabled: false, + privateRepos: [], + executors: { + script: ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + agent: ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + }, +}; diff --git a/src/types/index.ts b/src/types/index.ts index f7541d3c0..9e8bdf5e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,3 +67,21 @@ export { BOUNDED_AGENT_SENSITIVITIES, BOUNDED_AGENT_SENSITIVITY_RUN_BITS, } from './bounded-agent-options'; + +export { + type EnclaveSensitivity, + type EnclaveRepository, + type EnclaveRuntime, + type EnclaveScriptInterpreter, + type EnclaveAgentEngine, + type EnclaveAgentProfile, + type EnclaveScriptExecutorConfig, + type EnclaveAgentExecutorConfig, + type EnclavesConfig, + type EnclaveOptions, + ENCLAVE_SENSITIVITIES, + ENCLAVE_SENSITIVITY_RUN_BITS, + ENCLAVE_SCRIPT_EXECUTOR_DEFAULTS, + ENCLAVE_AGENT_EXECUTOR_DEFAULTS, + ENCLAVES_DEFAULTS, +} from './enclave-options'; diff --git a/src/types/wrapper-config.ts b/src/types/wrapper-config.ts index 8332e066f..b1a7a9474 100644 --- a/src/types/wrapper-config.ts +++ b/src/types/wrapper-config.ts @@ -17,6 +17,7 @@ import type { PlatformOptions } from './platform-options'; import type { RunnerOptions } from './runner-options'; import type { BoundedQueryOptions } from './bounded-query-options'; import type { BoundedAgentOptions } from './bounded-agent-options'; +import type { EnclaveOptions } from './enclave-options'; export type WrapperConfig = ContainerImageOptions @@ -30,4 +31,5 @@ export type WrapperConfig = & PlatformOptions & RunnerOptions & BoundedQueryOptions - & BoundedAgentOptions; + & BoundedAgentOptions + & EnclaveOptions;