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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 literal, registry-qualified `tag@sha256:<digest>` OCI references. Supported keys are `squid`, `agent`, `apiProxy`, `cliProxy`, `buildTools`, `dohProxy`, `enclaveScript`, `enclaveAgent`, `enclaveMcpServer`, and `dindStaging`. Every image AWF runs — including consumers outside Docker Compose such as DinD staging, `awf predownload --config`, and rootless artifact repair — resolves through this manifest, and the effective per-role references are recorded in `image-manifest.json`. AWF rejects missing enabled roles and never falls back to the official registry. It cannot be combined with controls that would select a different image: `container.imageRegistry`, `container.imageTag`, `container.agentImage`, `container.buildLocal`, `security.sslBump` (requires a locally built Squid image), `runner.sysrootImage`, `dind.stagingImage`, 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
126 changes: 124 additions & 2 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,43 @@
"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, security.sslBump, per-enclave image overrides, dind.stagingImage, 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"
},
"dohProxy": {
"$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 +691,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 +1183,11 @@
}
},
"$defs": {
"digestPinnedImage": {
"type": "string",
"pattern": "^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+(?::[0-9]{1,5})?|[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?:[0-9]{1,5}|localhost)/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}@sha256:[a-f0-9]{64}$",
"description": "Literal, registry-qualified OCI image reference pinned to both a tag and a lowercase sha256 digest."
},
"sha256Digest": {
"type": "string",
"pattern": "^[A-Fa-f0-9]{64}$",
Expand Down Expand Up @@ -1199,5 +1276,50 @@
}
}
}
}
},
"allOf": [
{
"if": {
"required": [
"container"
],
"properties": {
"container": {
"type": "object",
"required": [
"images"
]
}
}
},
"then": {
"properties": {
"security": {
"type": "object",
"not": {
"required": [
"sslBump"
]
}
},
"runner": {
"type": "object",
"not": {
"required": [
"sysrootImage"
]
}
},
"dind": {
"type": "object",
"not": {
"required": [
"stagingImage"
]
}
}
}
}
}
]
}
54 changes: 50 additions & 4 deletions src/artifact-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import execa from 'execa';
import { getSafeHostGid, getSafeHostUid } from './host-identity';
import { parseImageTag } from './image-tag';
import { agentImageRole, resolveRuntimeImageFor, type ImageManifestConfig } from './image-resolver';
import { logger } from './logger';
import { applyHostPathPrefixToVolumes } from './services/host-path-prefix';
import { getLocalDockerEnv } from './docker-host';
Expand Down Expand Up @@ -40,15 +41,51 @@
);
}

function resolvePermFixerImageRef(imageRegistry?: string, imageTag?: string, agentImage?: string): string {
/**
* Resolves a digest-pinned reference to the immutable local image ID.
*
* The repair container runs with `--pull never`; passing a digest reference
* makes Docker contact the registry even then. Looking the digest up locally
* keeps the run pinned to the compiler-authorized image without a pull.
* Returns undefined when the authorized image is not present locally, so the
* caller fails closed instead of running an unauthorized cached image.
*/
function resolveLocalImageId(imageRef: string): string | undefined {
try {
const result = execa.sync('docker', ['image', 'inspect', '--format', '{{.Id}}', imageRef], {
env: getLocalDockerEnv(),
reject: false,
});
if (result.exitCode !== 0) return undefined;
const imageId = result.stdout?.trim();
return imageId && /^sha256:[a-f0-9]{64}$/.test(imageId) ? imageId : undefined;
} catch {
return undefined;
}
}

function resolvePermFixerImageRef(
imageRegistry?: string,
imageTag?: string,
agentImage?: string,
images?: ImageManifestConfig['images'],
): string | undefined {
if (images) {
// Compiler-authorized manifest: the repair container must run the same
// pinned agent image as the run itself.
const manifestRef = resolveRuntimeImageFor(
{ images, imageRegistry, imageTag, agentImage },
agentImageRole(agentImage),
);
return resolveLocalImageId(manifestRef);
}
try {
const registry = imageRegistry || 'ghcr.io/github/gh-aw-firewall';
const parsedImageTag = parseImageTag(imageTag || 'latest');
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
// Use tag-only ref (no digest) because this runs with --pull never.
// Including the digest causes Docker to attempt registry verification
// even with --pull never, which times out if credentials are unavailable.
return `${registry}/${imageName}:${parsedImageTag.tag}`;
return `${registry}/${agentImageRole(agentImage)}:${parsedImageTag.tag}`;
} catch {
return 'ghcr.io/github/gh-aw-firewall/agent:latest';
}
Expand All @@ -61,6 +98,7 @@
imageTag: string | undefined,
agentImage: string | undefined,
imageRefOverride?: string,
images?: ImageManifestConfig['images'],
): boolean {
const currentUid = process.getuid?.();
if (currentUid === undefined || currentUid === 0) {
Expand All @@ -68,7 +106,7 @@
}

const existingDirs = dirs.filter(
(dir): dir is string => typeof dir === 'string' && dir.length > 0 && fs.existsSync(dir),

Check warning on line 109 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 109 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 109 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
);
if (existingDirs.length === 0) {
return true;
Expand All @@ -76,7 +114,15 @@

const uid = getSafeHostUid();
const gid = getSafeHostGid();
const imageRef = imageRefOverride || resolvePermFixerImageRef(imageRegistry, imageTag, agentImage);
const imageRef =
imageRefOverride || resolvePermFixerImageRef(imageRegistry, imageTag, agentImage, images);
if (!imageRef) {
logger.debug(
'Rootless artifact permission repair skipped: the compiler-authorized agent image is ' +
'not available locally, and no unpinned fallback image may be used.',
);
return false;
}
let repairedAll = true;

for (const dir of existingDirs) {
Expand All @@ -102,9 +148,9 @@
'--entrypoint',
'sh',
'-e',
`TUID=${uid}`,

Check warning on line 151 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / ESLint

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 151 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 151 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements
'-e',
`TGID=${gid}`,

Check warning on line 153 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / ESLint

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 153 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 153 in src/artifact-permissions.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements
'-v',
mount,
imageRef,
Expand Down
2 changes: 2 additions & 0 deletions src/artifact-preservation-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ describe('artifact-preservation – error paths', () => {
'ghcr.io/github/gh-aw-firewall',
'latest',
'act',
undefined,
undefined,
);
expect(chrootHomeRemovalAttempts).toBe(2);
expect(realFs.existsSync(chrootHomeDir)).toBe(false);
Expand Down
10 changes: 8 additions & 2 deletions src/artifact-preservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
fixArtifactPermissionsForRootless,
isBenignArtifactPermissionError,
} from './artifact-permissions';
import type { ImageManifestConfig } from './image-resolver';
import { getLocalDockerEnv } from './host-env';
import { resolveEnclavePaths } from './enclave/paths';
import { ENCLAVE_MCP_SERVER_CONTAINER_NAME } from './constants';
Expand All @@ -26,19 +27,19 @@
const iptablesAuditSrc = path.join(workDir, 'init-signal', 'iptables-audit.txt');
const enclaveRoot = resolveEnclavePaths(workDir).root;
const targetAuditDir = auditDir || path.join(workDir, 'audit');
if (!fs.existsSync(targetAuditDir)) return;

Check warning on line 30 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 30 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 30 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0

if (fs.existsSync(iptablesAuditSrc)) {

Check warning on line 32 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 32 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 32 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
fs.copyFileSync(iptablesAuditSrc, path.join(targetAuditDir, 'iptables-audit.txt'));
fs.chmodSync(path.join(targetAuditDir, 'iptables-audit.txt'), 0o644);

Check warning on line 35 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found chmodSync from package "fs" with non literal argument at index 0

Check warning on line 35 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found chmodSync from package "fs" with non literal argument at index 0

Check warning on line 35 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found chmodSync from package "fs" with non literal argument at index 0
logger.debug('Copied iptables audit state to audit directory');
} catch (error) {
logger.debug('Could not copy iptables audit file:', error);
}
}

if (fs.existsSync(enclaveRoot)) {

Check warning on line 42 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 42 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 42 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
for (const auditFile of ENCLAVE_AUDIT_FILES) {
try {
const source = `${ENCLAVE_MCP_SERVER_CONTAINER_NAME}:/var/log/awf-enclave/${auditFile.source}`;
Expand All @@ -63,7 +64,7 @@
'docker',
[
'cp',
`${ENCLAVE_MCP_SERVER_CONTAINER_NAME}:/var/log/awf-enclave/${ENCLAVE_SESSION_DIR}`,

Check warning on line 67 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 67 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements

Check warning on line 67 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Avoid template literals with expressions in execa arguments. Pass arguments as separate array elements
destination,
],
{ env: getLocalDockerEnv(), reject: false },
Expand Down Expand Up @@ -108,7 +109,7 @@
}: PreserveDirectoryOptions): void {
if (runtimeDir) {
const targetDir = runtimeSubdir ? path.join(runtimeDir, runtimeSubdir) : runtimeDir;
if (fs.existsSync(targetDir)) {

Check warning on line 112 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
execa.sync('chmod', ['-R', 'a+rX', targetDir]);
logger.info(`${availableLabel} available at: ${targetDir}`);
Expand All @@ -128,7 +129,7 @@

const sourceDir = path.join(workDir, workSubdir);
const destinationDir = path.join(os.tmpdir(), `${destinationBaseName}-${timestamp}`);
if (fs.existsSync(sourceDir) && fs.readdirSync(sourceDir).length > 0) {

Check warning on line 132 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 132 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 132 in src/artifact-preservation.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
try {
fs.renameSync(sourceDir, destinationDir);
if (chmodPreservedDir) {
Expand All @@ -149,11 +150,12 @@
imageRegistry?: string;
imageTag?: string;
agentImage?: string;
images?: ImageManifestConfig['images'];
};

export function preserveCleanupArtifacts(
workDir: string,
{ proxyLogsDir, auditDir, sessionStateDir, dockerHostPathPrefix, imageRegistry, imageTag, agentImage }: PreserveCleanupArtifactsOptions = {},
{ proxyLogsDir, auditDir, sessionStateDir, dockerHostPathPrefix, imageRegistry, imageTag, agentImage, images }: PreserveCleanupArtifactsOptions = {},
): void {
const timestamp = path.basename(workDir).replace('awf-', '');
const agentLogsDestination = path.join(os.tmpdir(), `awf-agent-logs-${timestamp}`);
Expand Down Expand Up @@ -283,12 +285,14 @@
imageRegistry,
imageTag,
agentImage,
undefined,
images,
);
}

type RemoveWorkDirectoriesOptions = Pick<
PreserveCleanupArtifactsOptions,
'dockerHostPathPrefix' | 'imageRegistry' | 'imageTag' | 'agentImage'
'dockerHostPathPrefix' | 'imageRegistry' | 'imageTag' | 'agentImage' | 'images'
>;

export function removeWorkDirectories(workDir: string, options: RemoveWorkDirectoriesOptions = {}): void {
Expand All @@ -310,6 +314,8 @@
options.imageRegistry,
options.imageTag,
options.agentImage,
undefined,
options.images,
);
try {
fs.rmSync(chrootHomeDir, { recursive: true, force: true });
Expand Down
Loading
Loading