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
4 changes: 2 additions & 2 deletions .github/workflows/smoke-copilot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .github/workflows/smoke-copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ on:
reaction: "eyes"
permissions:
contents: read
pull-requests: read
issues: read
pull-requests: write
issues: write
actions: read
copilot-requests: write
name: Smoke Copilot
Expand Down
58 changes: 58 additions & 0 deletions docs/mount-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Sandbox Mount Policy

AWF exposes a curated slice of the host filesystem to the agent. The **allow
lists** (what gets mounted in) and **deny lists** (what must stay out) used to be
hand-maintained in several TypeScript modules, one per runtime, which let them
drift. They are now centralized in a single declarative config:

- **Config:** [`src/config/sandbox-mount-policy.json`](../src/config/sandbox-mount-policy.json)
- **Loader / typed accessors:** [`src/config/mount-policy.ts`](../src/config/mount-policy.ts)

Every runtime reads from this one source of truth, so the Docker/runc compose
agent, the gVisor/runsc compose agent, and the sbx microVM can no longer diverge.

## What the policy contains

| Section | Kind | Applies to | Consumed by |
| --- | --- | --- | --- |
| `system.directories.default` / `.sysroot` | allow (dirs) | compose (Docker + gVisor) | `system-mounts.ts` |
| `system.etc` | allow (files) | compose (Docker + gVisor) | `etc-mounts.ts` |
| `home.toolSubdirs` | allow (dirs) | all runtimes | `home-strategy.ts`, `sbx-manager.ts` |
| `home.forbiddenSubdirs` | deny guard | all runtimes | invariant tests |
| `credentials.entries` | deny (files/dirs) | all runtimes | `credential-hiding.ts`, `sbx-manager.ts` |

The `system.*` section is compose-only: the sbx microVM gets its system
libraries from its guest image, not from host mounts.

## How each runtime applies the credential deny list

The two backends hide credentials with different mechanisms, but from the **same
list**:

- **Compose (Docker / gVisor)** mounts an empty `$HOME` plus the `toolSubdirs`,
then blanks each credential **file** with a `/dev/null` bind overlay
(`credential-hiding.ts`). For a `dir` entry it masks the enumerated `files`;
for a `file` entry it masks the path itself. Directory entries with no known
filenames can't be masked this way and are covered only by sbx.
- **sbx microVM** mounts the `toolSubdirs` (plus `.copilot`/`.gemini`) wholesale,
because sbx positional mounts are directory-granular and can't overlay
`/dev/null` onto a nested path. Before `sbx create` it **moves** each credential
`path` aside on the host (to a backup dir at the home root, never itself
mounted) and **restores** it after teardown. It only touches entries whose
top-level parent is actually mounted — paths under never-mounted dirs like
`.ssh` or `.aws` are skipped because they never enter the VM.

In all cases the agent receives the credentials it legitimately needs through the
API proxy or environment, never from these on-disk stores.

## Adding an entry

1. Edit `sandbox-mount-policy.json`.
2. For a credential store, add a `credentials.entries[]` object:
- `path` — `$HOME`-relative path (no leading `/`, `~`, or `..`).
- `type` — `"file"` or `"dir"`.
- `files` — (dir only) specific secret filenames so compose can mask them.
- `reason` — short justification.
3. Run `npm run build && npm test`. The loader validates the JSON at startup and
the policy tests assert the invariants (relative paths, unique paths, no
forbidden dir in the allow list).
44 changes: 44 additions & 0 deletions scripts/ci/smoke-gemini-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as fs from 'fs';
import * as path from 'path';

const workflowsDir = path.resolve(__dirname, '../../.github/workflows');
const smokeGeminiSourcePath = path.join(workflowsDir, 'smoke-gemini.md');
const smokeGeminiLockPath = path.join(workflowsDir, 'smoke-gemini.lock.yml');

describe('smoke gemini workflow output requirements', () => {
it('requires noop fallback when no pull request context exists', () => {
const source = fs.readFileSync(smokeGeminiSourcePath, 'utf-8');

expect(source).toContain('**If triggered by a pull request**');
expect(source).toContain('**If triggered by workflow_dispatch or schedule**');
expect(source).toContain('Do NOT attempt to add');
expect(source).toContain('when there is no pull request');
});

it('uses GEMINI_API_KEY secret', () => {
const source = fs.readFileSync(smokeGeminiSourcePath, 'utf-8');

expect(source).toContain('GEMINI_API_KEY');
});

it('compiles to a lock file with ready-for-aw activation guard', () => {
const lock = fs.readFileSync(smokeGeminiLockPath, 'utf-8');

expect(lock).toContain("github.event.label.name == 'ready-for-aw'");
});

it('lock file excludes GEMINI_API_KEY from agent environment', () => {
const lock = fs.readFileSync(smokeGeminiLockPath, 'utf-8');

// The real key must be excluded from --env-all so the api-proxy sidecar can
// inject it instead, providing credential isolation.
expect(lock).toContain('--exclude-env GEMINI_API_KEY');
});

it('lock file uses local build (postprocessed)', () => {
const lock = fs.readFileSync(smokeGeminiLockPath, 'utf-8');

expect(lock).toContain('--build-local');
expect(lock).toContain('Install awf binary (local)');
});
});
158 changes: 158 additions & 0 deletions src/config/mount-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
mountPolicy,
HOME_TOOL_SUBDIRS,
HOME_FORBIDDEN_SUBDIRS,
CREDENTIAL_ENTRIES,
credentialFilesToHide,
credentialEntriesUnderMountedParents,
systemDirectories,
etcAllowlist,
} from './mount-policy';

describe('mount-policy', () => {
describe('policy invariants', () => {
it('never lists a forbidden home dir in the tool-subdir allow list', () => {
for (const dir of HOME_FORBIDDEN_SUBDIRS) {
expect(HOME_TOOL_SUBDIRS).not.toContain(dir);
}
});

it('exposes credential entries with valid, relative, unique paths', () => {
const seen = new Set<string>();
for (const entry of CREDENTIAL_ENTRIES) {
expect(entry.path).not.toMatch(/^[/~]/);
expect(entry.path).not.toContain('..');
expect(['file', 'dir']).toContain(entry.type);
if (entry.files) expect(entry.type).toBe('dir');
expect(seen.has(entry.path)).toBe(false);
seen.add(entry.path);
}
});

it('only attaches `files` to directory credential entries', () => {
for (const entry of CREDENTIAL_ENTRIES) {
if (entry.files !== undefined) expect(entry.type).toBe('dir');
}
});

it('home.toolSubdirs entries are simple relative names without traversal', () => {
for (const dir of HOME_TOOL_SUBDIRS) {
expect(dir).not.toBe('');
expect(dir).not.toContain('/');
expect(dir).not.toContain('..');
expect(dir).not.toMatch(/^[/~]/);
}
});

it('home.toolSubdirs has no duplicate entries', () => {
expect(new Set(HOME_TOOL_SUBDIRS).size).toBe(HOME_TOOL_SUBDIRS.length);
});

it('home.forbiddenSubdirs entries are simple relative names without traversal', () => {
for (const dir of HOME_FORBIDDEN_SUBDIRS) {
expect(dir).not.toBe('');
expect(dir).not.toContain('/');
expect(dir).not.toContain('..');
expect(dir).not.toMatch(/^[/~]/);
}
});

it('system directories are absolute paths without traversal', () => {
for (const dir of [...systemDirectories(false), ...systemDirectories(true)]) {
expect(dir).toMatch(/^\//);
expect(dir).not.toContain('..');
}
});

it('/etc paths are absolute without traversal', () => {
for (const p of etcAllowlist()) {
expect(p).toMatch(/^\//);
expect(p).not.toContain('..');
}
});

it('credential dir-entry files are plain filenames (no path separators or traversal)', () => {
for (const entry of CREDENTIAL_ENTRIES) {
if (entry.files) {
for (const f of entry.files) {
expect(f).not.toBe('');
expect(f).not.toContain('/');
expect(f).not.toContain('..');
}
expect(new Set(entry.files).size).toBe(entry.files.length);
}
}
});
});

describe('credentialFilesToHide', () => {
it('expands dir entries via their files and passes file entries through', () => {
const files = credentialFilesToHide();
// file entry
expect(files).toContain('.docker/config.json');
// dir entry expanded
expect(files).toContain('.config/gh/hosts.yml');
expect(files).toContain('.config/gcloud/credentials.db');
// dir entry with no known files is omitted (compose can't mask a dir)
expect(files).not.toContain('.config/heroku');
expect(files.some((f) => f.startsWith('.config/heroku'))).toBe(false);
});

it('matches the number of file entries plus enumerated dir files', () => {
const expected =
CREDENTIAL_ENTRIES.filter((e) => e.type === 'file').length +
CREDENTIAL_ENTRIES.filter((e) => e.type === 'dir').reduce(
(n, e) => n + (e.files?.length ?? 0),
0,
);
expect(credentialFilesToHide()).toHaveLength(expected);
});
});

describe('credentialEntriesUnderMountedParents', () => {
it('includes only entries whose top-level parent is mounted', () => {
const mounted = new Set(['.config', '.cargo', '.claude', '.copilot', '.gemini']);
const entries = credentialEntriesUnderMountedParents(mounted);
const paths = entries.map((e) => e.path);

expect(paths).toContain('.config/gh');
expect(paths).toContain('.cargo/credentials');
expect(paths).toContain('.copilot/config.json');
// Never-mounted parents are excluded.
expect(paths).not.toContain('.ssh/id_rsa');
expect(paths).not.toContain('.aws/credentials');
expect(paths).not.toContain('.docker/config.json');
expect(paths).not.toContain('.npmrc');
});

it('returns nothing when no parents are mounted', () => {
expect(credentialEntriesUnderMountedParents(new Set())).toHaveLength(0);
});
});

describe('system allow lists', () => {
it('returns the full system dir set by default and a reduced set for sysroot', () => {
const def = systemDirectories(false);
const sysroot = systemDirectories(true);
expect(def).toEqual(expect.arrayContaining(['/usr', '/bin', '/lib', '/sys', '/dev']));
expect(sysroot).toEqual(['/sys', '/dev']);
expect(sysroot.length).toBeLessThan(def.length);
});

it('exposes the always-mounted /etc allow list', () => {
expect(etcAllowlist()).toEqual(
expect.arrayContaining(['/etc/ssl', '/etc/ca-certificates', '/etc/nsswitch.conf']),
);
});
});

it('freezes-through the raw JSON into a typed policy object', () => {
expect(mountPolicy.home.toolSubdirs).toBe(HOME_TOOL_SUBDIRS);
expect(mountPolicy.credentials).toBe(CREDENTIAL_ENTRIES);
});

it('includes .copilot and .gemini in home.toolSubdirs', () => {
expect(HOME_TOOL_SUBDIRS).toContain('.copilot');
expect(HOME_TOOL_SUBDIRS).toContain('.gemini');
});
});
Loading
Loading