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
2 changes: 1 addition & 1 deletion containers/enclave/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
RUN npm_config_ignore_scripts=false npm install -g "@github/copilot@${COPILOT_CLI_VERSION}" \
&& npm_config_ignore_scripts=true npm install \
--prefix /usr/local/lib/node_modules/@github/copilot \
--omit=dev adm-zip@0.6.0 \
--omit=dev adm-zip@0.6.1 \
&& find /usr/local/lib/node_modules/@github/copilot \
-path '*/foundry-local-sdk/node_modules/adm-zip' -prune -type d \
-exec sh -c 'rm -rf "$1" && cp -a /usr/local/lib/node_modules/@github/copilot/node_modules/adm-zip "$1"' sh {} \; \
Expand Down
30 changes: 30 additions & 0 deletions docs/nvx-security-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,33 @@ Phase 1 is complete when maintainers have reviewed and accepted:

Phase 2 may then implement the deterministic filesystem builder and one-shot
execution adapter. Managed execution is not an exit criterion.

## Phase 2 implementation boundary

Phase 2 adds reusable foundations without making `nvx` a selectable AWF
runtime:

- `src/nvx/filesystem-builder.ts` creates ordered deterministic EROFS layers
from explicitly selected source roots, excludes known credential paths at
any depth, rejects escaping links and special files, records source and image
identities, and creates one fresh bounded ext4 scratch image per invocation.
Staging requires Linux `/proc/self/fd` and no-follow directory descriptors to
prevent source-tree path races;
- `src/nvx/one-shot-adapter.ts` constructs only `nvx.py sandbox run`, launches
without a shell or inherited credential environment, applies a host
wall-clock timeout or cancellation to the full process group, filters
workflow-command syntax as a byte stream, and retains bounded raw output
tails; and
- `src/nvx/outcome.ts` requires the exact one-shot outcome and teardown schema,
preserves guest status codes including `125`, and verifies that NVX reports
the requested fail-closed network policy.

The adapter intentionally requires a prebuilt absolute entrypoint and
whitespace-free NVX arguments. Complex agent commands must be placed in an
immutable layer as an entrypoint script because the pinned NVX kernel-command
line ABI rejects whitespace-bearing `--arg` values.

Phase 2 does not add `nvx` to the runtime registry or CLI. Selection remains
blocked until Phase 3 supplies the dedicated VMM identity, ACL-only device
access, filesystem jail, cgroup, host-enforced network namespace, live
confinement verification, artifact attestation, and durable stale cleanup.
2 changes: 1 addition & 1 deletion scripts/ci/self-hosted-runner-doctor-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ describe('self-hosted runner doctor workflow config', () => {
expect(portableAgent).toMatch(/^\| B33 \|.*github\/gh-aw-firewall#8624 \|$/m);
expect(portableAgent).toMatch(/^\| D14 \|.*github\/gh-aw-firewall#8622 \|$/m);
expect(portableAgent).toContain('| `Direct sbx egress reached 1.1.1.1 without proxy environment variables` that clears after a brief retry while Squid starts | D13 update');
expect(portableAgent).toContain('| `docker compose up -d` fails after AWF topology recreates Squid mid-run (new `extra_hosts`), with stale/unwritable Squid log files | B33 update');
expect(portableAgent).toContain('- `docker compose up -d` fails after AWF topology recreates Squid mid-run (new `extra_hosts`), with stale/unwritable Squid log files → B33 update');
expect(portableAgent).toContain('| `"cloud-hypervisor --version" exited with code undefined` under `--container-runtime cloud-hypervisor` | D14');
expect(portableAgent).toContain('D11 / github/gh-aw-firewall#6558 — gVisor + Node.js v22 V8 ESM startup crash root cause remains unresolved (`SIGABRT` `StringBytes::Encode` assertion and occasional exit 139).');
expect(portableAgent).toContain('D12 / github/gh-aw-firewall#6810, github/gh-aw-firewall#6811 — Copilot runs using `model: auto` under isolated runtimes (`--container-runtime gvisor` or `sbx`) could fail before agent start with `awf-reflect: request failed: fetch failed` plus `Model "auto" has no AI credits pricing and no default pricing is configured` when `apiProxy.maxAiCredits` was enabled.');
Expand Down
234 changes: 234 additions & 0 deletions src/nvx/filesystem-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
deriveNvxLayerUuid,
normalizeScratchBytes,
NvxFilesystemBuilder,
NVX_MIN_SCRATCH_BYTES,
type NvxFilesystemBuilderDependencies,
} from './filesystem-builder';

describe('NVX deterministic filesystem builder', () => {
it('builds ordered deterministic EROFS layers and a private ext4 scratch image', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-nvx-images-'));
const distro = path.join(root, 'distro-source');
const custom = path.join(root, 'custom-source');
await fs.mkdir(path.join(distro, 'bin'), { recursive: true });
await fs.writeFile(path.join(distro, 'bin', 'tool'), 'binary');
await fs.chmod(path.join(distro, 'bin', 'tool'), 0o4755);
await fs.symlink('bin/tool', path.join(distro, 'tool'));
await fs.mkdir(path.join(custom, '.config', 'gh'), { recursive: true });
await fs.writeFile(path.join(custom, '.config', 'gh', 'hosts.yml'), 'secret');
await fs.mkdir(path.join(custom, '.ssh'), { recursive: true });
await fs.writeFile(path.join(custom, '.ssh', 'deploy_key'), 'secret');
await fs.writeFile(path.join(custom, 'README'), 'safe');

const commands: Array<{ command: string; args: readonly string[] }> = [];
const dependencies: NvxFilesystemBuilderDependencies = {
runTool: jest.fn(async (command, args) => {
commands.push({ command, args: [...args] });
if (command === 'mkfs.erofs') {
await fs.writeFile(args[args.length - 2], `${command}\n${args.join('\n')}`);
}
}),
randomUuid: () => '11111111-2222-4333-8444-555555555555',
sha256: jest.fn(async (filePath) => `digest:${path.basename(filePath)}`),
};
const builder = new NvxFilesystemBuilder({
runId: 'phase-2',
workDir: root,
layers: [
{ role: 'custom', sourcePath: custom },
{ role: 'distro', sourcePath: distro },
],
sourceDateEpoch: 1_700_000_000,
}, dependencies);

try {
const bundle = await builder.prepare();

expect(bundle.layers.map(({ role }) => role)).toEqual(['distro', 'custom']);
expect(bundle.scratch).toMatchObject({
uuid: '11111111-2222-4333-8444-555555555555',
sizeBytes: NVX_MIN_SCRATCH_BYTES,
});
expect(commands[0]).toEqual({
command: 'mkfs.erofs',
args: expect.arrayContaining([
'--workers=1',
'--sort=path',
'--all-root',
'-T',
'1700000000',
'-U',
bundle.layers[0].uuid,
]),
});
expect(commands.map(({ command }) => command)).toEqual([
'mkfs.erofs',
'mkfs.erofs',
'mke2fs',
]);
expect(commands[2].args).toEqual(expect.arrayContaining([
'-O',
'^has_journal',
'-E',
'lazy_itable_init=0,lazy_journal_init=0,root_owner=65534:65534',
]));
expect((await fs.stat(bundle.layers[0].path)).mode & 0o777).toBe(0o400);
expect((await fs.stat(bundle.scratch.path)).mode & 0o777).toBe(0o600);
expect(await fs.readFile(
path.join(builder.stagingDirectory, 'distro', 'tool'),
'utf8',
)).toBe('binary');
expect((await fs.stat(
path.join(builder.stagingDirectory, 'distro', 'bin', 'tool'),
)).mode & 0o777).toBe(0o755);
await expect(fs.access(
path.join(builder.stagingDirectory, 'custom', '.config', 'gh', 'hosts.yml'),
)).rejects.toThrow();
await expect(fs.access(
path.join(builder.stagingDirectory, 'custom', '.ssh', 'deploy_key'),
)).rejects.toThrow();
expect(await fs.readFile(
path.join(builder.stagingDirectory, 'custom', 'README'),
'utf8',
)).toBe('safe');

const manifest = JSON.parse(await fs.readFile(bundle.manifestPath, 'utf8'));
expect(manifest).toMatchObject({
schemaVersion: 1,
sourceDateEpoch: 1_700_000_000,
layers: [
{ role: 'distro', file: 'distro.erofs' },
{ role: 'custom', file: 'custom.erofs' },
],
scratch: {
file: 'scratch.ext4',
uuid: '11111111-2222-4333-8444-555555555555',
uid: 65534,
gid: 65534,
},
});
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});

it('derives stable role-bound UUIDs and enforces scratch limits', () => {
const digest = 'a'.repeat(64);
expect(deriveNvxLayerUuid('distro', digest))
.toBe(deriveNvxLayerUuid('distro', digest));
expect(deriveNvxLayerUuid('distro', digest))
.not.toBe(deriveNvxLayerUuid('runtime', digest));
expect(normalizeScratchBytes(1)).toBe(NVX_MIN_SCRATCH_BYTES);
expect(normalizeScratchBytes(NVX_MIN_SCRATCH_BYTES + 1) % 4096).toBe(0);
expect(() => normalizeScratchBytes(
NVX_MIN_SCRATCH_BYTES * 2,
NVX_MIN_SCRATCH_BYTES,
)).toThrow(/exceeding cap/);
});

it('rejects escaping symlinks, duplicate roles, and unsafe run identifiers', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-nvx-images-'));
const source = path.join(root, 'source');
await fs.mkdir(source);
await fs.symlink('../outside', path.join(source, 'escape'));
const dependencies: NvxFilesystemBuilderDependencies = {
runTool: jest.fn(),
randomUuid: () => '11111111-2222-4333-8444-555555555555',
sha256: jest.fn(),
};
try {
await expect(new NvxFilesystemBuilder({
runId: 'safe',
workDir: root,
layers: [{ role: 'distro', sourcePath: source }],
}, dependencies).prepare()).rejects.toThrow(/symlink target.*escapes/);
expect(() => new NvxFilesystemBuilder({
runId: '../unsafe',
workDir: root,
layers: [{ role: 'distro', sourcePath: source }],
}, dependencies)).toThrow(/Unsafe NVX run id/);
expect(() => new NvxFilesystemBuilder({
runId: 'duplicate',
workDir: root,
layers: [
{ role: 'distro', sourcePath: source },
{ role: 'distro', sourcePath: source },
],
}, dependencies)).toThrow(/Duplicate NVX layer role/);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});

it('excludes credential paths nested below a rootfs home directory', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-nvx-images-'));
const source = path.join(root, 'source');
const nestedHome = path.join(source, 'home', 'runner');
await fs.mkdir(path.join(nestedHome, '.config', 'gh'), { recursive: true });
await fs.writeFile(path.join(nestedHome, '.config', 'gh', 'hosts.yml'), 'secret');
await fs.writeFile(path.join(nestedHome, 'safe'), 'visible');
const dependencies: NvxFilesystemBuilderDependencies = {
runTool: jest.fn(async (command, args) => {
if (command === 'mkfs.erofs') {
await fs.writeFile(args[args.length - 2], 'layer');
}
}),
randomUuid: () => '11111111-2222-4333-8444-555555555555',
sha256: jest.fn(async () => 'a'.repeat(64)),
};
const builder = new NvxFilesystemBuilder({
runId: 'nested-credentials',
workDir: root,
layers: [{ role: 'distro', sourcePath: source }],
}, dependencies);
try {
await builder.prepare();
await expect(fs.access(path.join(
builder.stagingDirectory,
'distro',
'home',
'runner',
'.config',
'gh',
))).rejects.toThrow();
await expect(fs.readFile(path.join(
builder.stagingDirectory,
'distro',
'home',
'runner',
'safe',
), 'utf8')).resolves.toBe('visible');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});

it('never removes a pre-existing run directory it does not own', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-nvx-images-'));
const source = path.join(root, 'source');
const existingRun = path.join(root, 'nvx-images', 'collision');
await fs.mkdir(source);
await fs.mkdir(existingRun, { recursive: true });
await fs.writeFile(path.join(existingRun, 'owned-by-other-run'), 'keep');
const dependencies: NvxFilesystemBuilderDependencies = {
runTool: jest.fn(),
randomUuid: () => '11111111-2222-4333-8444-555555555555',
sha256: jest.fn(),
};
try {
await expect(new NvxFilesystemBuilder({
runId: 'collision',
workDir: root,
layers: [{ role: 'distro', sourcePath: source }],
}, dependencies).prepare()).rejects.toMatchObject({ code: 'EEXIST' });
await expect(fs.readFile(path.join(existingRun, 'owned-by-other-run'), 'utf8'))
.resolves.toBe('keep');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
Loading
Loading