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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`).
- `container.enableDind` → `--enable-dind`
- `container.workDir` → `--work-dir`
- `container.containerWorkDir` → `--container-workdir`
- `container.images` → *(config-only; a closed compiler-authorized manifest of complete `tag@sha256:<digest>` OCI references. Supported keys are `squid`, `agent`, `apiProxy`, `cliProxy`, `buildTools`, `enclaveScript`, `enclaveAgent`, `enclaveMcpServer`, and `dindStaging`. AWF rejects missing enabled roles and never falls back to the official registry. It cannot be combined with legacy image controls, local builds, or per-enclave image overrides. Registry credentials are intentionally not configured by AWF; use a pre-authenticated Docker daemon.)*
- `container.imageRegistry` → `--image-registry`
- `container.imageTag` → `--image-tag`
- `container.skipPull` → `--skip-pull`
Expand Down
76 changes: 75 additions & 1 deletion docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,40 @@
"type": "string",
"description": "Working directory inside the agent container."
},
"images": {
"type": "object",
"description": "Closed manifest of complete digest-pinned infrastructure image references. This compiler-authorized form cannot be combined with imageRegistry, imageTag, agentImage, sysrootImage, per-enclave image overrides, or buildLocal.",
"additionalProperties": false,
"properties": {
"squid": {
"$ref": "#/$defs/digestPinnedImage"
},
"agent": {
"$ref": "#/$defs/digestPinnedImage"
},
"apiProxy": {
"$ref": "#/$defs/digestPinnedImage"
},
"cliProxy": {
"$ref": "#/$defs/digestPinnedImage"
},
"buildTools": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveScript": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveAgent": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveMcpServer": {
"$ref": "#/$defs/digestPinnedImage"
},
"dindStaging": {
"$ref": "#/$defs/digestPinnedImage"
}
}
},
"imageRegistry": {
"type": "string",
"description": "Container image registry to pull from. Defaults to \"ghcr.io/github/gh-aw-firewall\"."
Expand Down Expand Up @@ -654,7 +688,42 @@
],
"description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). Infrastructure containers always use the default runc runtime."
}
}
},
"allOf": [
{
"if": {
"required": [
"images"
]
},
"then": {
"not": {
"anyOf": [
{
"required": [
"imageRegistry"
]
},
{
"required": [
"imageTag"
]
},
{
"required": [
"agentImage"
]
},
{
"required": [
"buildLocal"
]
}
]
}
}
}
]
},
"cloudHypervisor": {
"type": "object",
Expand Down Expand Up @@ -1111,6 +1180,11 @@
}
},
"$defs": {
"digestPinnedImage": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._:-]*(?:/[a-z0-9][a-z0-9._-]*)+:[A-Za-z0-9_][A-Za-z0-9_.-]*@sha256:[a-f0-9]{64}$",
"description": "Complete OCI image reference with a tag and lowercase sha256 digest."
},
"sha256Digest": {
"type": "string",
"pattern": "^[A-Fa-f0-9]{64}$",
Expand Down
76 changes: 75 additions & 1 deletion src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,40 @@
"type": "string",
"description": "Working directory inside the agent container."
},
"images": {
"type": "object",
"description": "Closed manifest of complete digest-pinned infrastructure image references. This compiler-authorized form cannot be combined with imageRegistry, imageTag, agentImage, sysrootImage, per-enclave image overrides, or buildLocal.",
"additionalProperties": false,
"properties": {
"squid": {
"$ref": "#/$defs/digestPinnedImage"
},
"agent": {
"$ref": "#/$defs/digestPinnedImage"
},
"apiProxy": {
"$ref": "#/$defs/digestPinnedImage"
},
"cliProxy": {
"$ref": "#/$defs/digestPinnedImage"
},
"buildTools": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveScript": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveAgent": {
"$ref": "#/$defs/digestPinnedImage"
},
"enclaveMcpServer": {
"$ref": "#/$defs/digestPinnedImage"
},
"dindStaging": {
"$ref": "#/$defs/digestPinnedImage"
}
}
},
"imageRegistry": {
"type": "string",
"description": "Container image registry to pull from. Defaults to \"ghcr.io/github/gh-aw-firewall\"."
Expand Down Expand Up @@ -654,7 +688,42 @@
],
"description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). Infrastructure containers always use the default runc runtime."
}
}
},
"allOf": [
{
"if": {
"required": [
"images"
]
},
"then": {
"not": {
"anyOf": [
{
"required": [
"imageRegistry"
]
},
{
"required": [
"imageTag"
]
},
{
"required": [
"agentImage"
]
},
{
"required": [
"buildLocal"
]
}
]
}
}
}
]
},
"cloudHypervisor": {
"type": "object",
Expand Down Expand Up @@ -1111,6 +1180,11 @@
}
},
"$defs": {
"digestPinnedImage": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._:-]*(?:/[a-z0-9][a-z0-9._-]*)+:[A-Za-z0-9_][A-Za-z0-9_.-]*@sha256:[a-f0-9]{64}$",
"description": "Complete OCI image reference with a tag and lowercase sha256 digest."
},
"sha256Digest": {
"type": "string",
"pattern": "^[A-Fa-f0-9]{64}$",
Expand Down
1 change: 1 addition & 0 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
workDir: options.workDir as string,
buildLocal: options.buildLocal as boolean,
skipPull: options.skipPull as boolean,
images: options.images as WrapperConfig['images'],
agentImage,
imageRegistry: options.imageRegistry as string,
imageTag: options.imageTag as string,
Expand Down
11 changes: 10 additions & 1 deletion src/compose-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';
import { DockerComposeConfig, WrapperConfig } from './types';
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
import { parseImageTag } from './image-tag';
import { resolveRuntimeImage, validateCustomImageManifest } from './image-resolver';
import { SslConfig } from './host-env';
import { getRealUserHome } from './host-identity';
import { resolveLogPaths } from './log-paths';
Expand Down Expand Up @@ -32,6 +33,7 @@ export function generateDockerCompose(
sslConfig?: SslConfig,
squidConfigContent?: string
): DockerComposeConfig {
validateCustomImageManifest(config);
const projectRoot = path.join(__dirname, '..');

// Guard: --build-local requires full repo checkout (not available in standalone bundle)
Expand All @@ -52,7 +54,14 @@ export function generateDockerCompose(
const parsedImageTag = parseImageTag(config.imageTag || 'latest');

// Shared image build configuration passed to all service builders
const imageConfig = { useGHCR, registry, parsedTag: parsedImageTag, projectRoot };
const imageConfig = {
useGHCR,
registry,
parsedTag: parsedImageTag,
projectRoot,
resolveImage: (name: import('./image-resolver').RuntimeImageName) =>
resolveRuntimeImage(config, name, registry, parsedImageTag),
};

// ── Log / state paths ──────────────────────────────────────────────────────

Expand Down
5 changes: 5 additions & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ export interface AwfFileConfig {
};
};
container?: {
images?: Partial<Record<
'squid' | 'agent' | 'apiProxy' | 'cliProxy' | 'buildTools' |
'enclaveScript' | 'enclaveAgent' | 'enclaveMcpServer' | 'dindStaging',
string
>>;
memoryLimit?: string;
pidsLimit?: number;
agentTimeout?: number;
Expand Down
1 change: 1 addition & 0 deletions src/config-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
enableDind: config.container?.enableDind,
workDir: config.container?.workDir,
containerWorkdir: config.container?.containerWorkDir,
images: config.container?.images,
imageRegistry: config.container?.imageRegistry,
imageTag: config.container?.imageTag,
skipPull: config.container?.skipPull,
Expand Down
13 changes: 13 additions & 0 deletions src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,19 @@ function writeAuditArtifacts(
yaml.dump(redactedCompose, { lineWidth: -1 })
);

const images = Object.fromEntries(
Object.entries(dockerCompose.services)
.filter(([, service]) => typeof service?.image === 'string')
.map(([service, definition]) => [
service,
{
image: definition.image,
source: config.images ? 'explicit' : 'default',
},
]),
);
writeAuditArtifact(auditDir, 'image-manifest.json', JSON.stringify(images, null, 2));

// Generate and save policy manifest (structured description of all firewall rules)
const policyManifest = generatePolicyManifest({
domains: config.allowedDomains,
Expand Down
11 changes: 10 additions & 1 deletion src/dind-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import execa from 'execa';
import { getLocalDockerEnv } from './docker-host';
import { logger } from './logger';
import { WrapperConfig } from './types';
import { parseImageTag } from './image-tag';
import { resolveRuntimeImage } from './image-resolver';

const DEFAULT_STAGING_IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:latest';
const DEFAULT_DIND_WORKDIR = '/tmp/gh-aw';
Expand Down Expand Up @@ -111,7 +113,14 @@ export async function runDindBootstrap(config: WrapperConfig): Promise<void> {
return;
}

const stagingImage = dindConfig.stagingImage || DEFAULT_STAGING_IMAGE;
const stagingImage = config.images
? resolveRuntimeImage(
config,
'dind-staging',
config.imageRegistry || 'ghcr.io/github/gh-aw-firewall',
parseImageTag(config.imageTag || 'latest'),
)
: dindConfig.stagingImage || DEFAULT_STAGING_IMAGE;
if (dindConfig.preStageDirs) {
const workDir = dindConfig.workDir || DEFAULT_DIND_WORKDIR;
logger.info(`Pre-staging DinD work directory tree at ${workDir}`);
Expand Down
63 changes: 63 additions & 0 deletions src/image-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { resolveRuntimeImage, validateCustomImageManifest } from './image-resolver';
import { parseImageTag } from './image-tag';
import type { WrapperConfig } from './types';
import { validateAwfFileConfig } from './config-file';

const digest = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const image = (name: string) => `registry.example.com/approved/${name}:v1@${digest}`;
const config = (images?: WrapperConfig['images']): WrapperConfig => ({
images,
workDir: '/tmp/awf-test',
allowedDomains: [],
agentCommand: 'true',
logLevel: 'error',
keepContainers: false,
});

describe('custom runtime image manifest', () => {
it('resolves only the digest-pinned configured image', () => {
const result = resolveRuntimeImage(
config({ squid: image('squid') }),
'squid',
'ghcr.io/github/gh-aw-firewall',
parseImageTag('latest'),
);
expect(result).toBe(image('squid'));
});

it('fails closed when an enabled role is absent', () => {
expect(() => resolveRuntimeImage(
config({ squid: image('squid') }),
'agent',
'ghcr.io/github/gh-aw-firewall',
parseImageTag('latest'),
)).toThrow('must include "agent"');
});

it.each([
'registry.example.com/approved/squid:v1',
'registry.example.com/approved/squid:v1@$sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'registry.example.com/approved/squid:v1@sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
'registry.example.com/approved/squid:v1@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa extra',
])('rejects unsafe or mutable references: %s', (reference) => {
expect(() => validateCustomImageManifest(config({ squid: reference }))).toThrow(
'expected a literal tag@sha256',
);
});

it('rejects local builds and enclave overrides with a manifest', () => {
expect(() => validateCustomImageManifest({
...config({ squid: image('squid') }),
buildLocal: true,
})).toThrow('buildLocal');
});

it('rejects legacy image controls in file configuration', () => {
expect(validateAwfFileConfig({
container: {
images: { squid: image('squid') },
imageRegistry: 'registry.example.com/approved',
},
}).join('\n')).toContain('must NOT be valid');
});
});
Loading