diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index eb54a393d..ec623148c 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -444,8 +444,8 @@ jobs: actions: read contents: read copilot-requests: write - issues: read - pull-requests: read + issues: write + pull-requests: write env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index d44bc739e..48ab1219e 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -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 diff --git a/docs/mount-policy.md b/docs/mount-policy.md new file mode 100644 index 000000000..705043619 --- /dev/null +++ b/docs/mount-policy.md @@ -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). diff --git a/scripts/ci/smoke-gemini-workflow.test.ts b/scripts/ci/smoke-gemini-workflow.test.ts new file mode 100644 index 000000000..8572083c6 --- /dev/null +++ b/scripts/ci/smoke-gemini-workflow.test.ts @@ -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)'); + }); +}); diff --git a/src/config/mount-policy.test.ts b/src/config/mount-policy.test.ts new file mode 100644 index 000000000..2be9dedc3 --- /dev/null +++ b/src/config/mount-policy.test.ts @@ -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(); + 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'); + }); +}); diff --git a/src/config/mount-policy.ts b/src/config/mount-policy.ts new file mode 100644 index 000000000..6a4ac6e22 --- /dev/null +++ b/src/config/mount-policy.ts @@ -0,0 +1,255 @@ +import rawPolicy from './sandbox-mount-policy.json'; + +/** + * Central, declarative allow/deny mount policy shared by every sandbox runtime + * (Docker/runc compose, gVisor/runsc compose, and the sbx microVM). + * + * The data lives in {@link ./sandbox-mount-policy.json} so the security-relevant + * allow lists (system dirs, `/etc` files, `$HOME` tool subdirs) and deny lists + * (forbidden `$HOME` credential dirs, on-disk credential stores) have a single + * source of truth and cannot drift between runtimes. This module loads, + * validates, and exposes it through typed accessors. + * + * The JSON is imported statically (via `resolveJsonModule`) so it is emitted to + * `dist/` by `tsc` and inlined by the esbuild release bundle — no runtime file + * read or extra packaging step is required. + */ + +/** How a credential entry's path is masked. */ +export type CredentialType = 'file' | 'dir'; + +/** A single on-disk credential/token store to keep out of the sandbox. */ +export interface CredentialEntry { + /** `$HOME`-relative path to the credential store (file or directory). */ + readonly path: string; + /** Whether {@link path} is a single file or a directory of secrets. */ + readonly type: CredentialType; + /** + * For `dir` entries, the specific secret files inside {@link path} that the + * compose backend masks with `/dev/null` overlays (compose cannot mask a whole + * directory). Omitted when the sensitive filenames are unknown/opaque — such + * dirs are still fully protected by the sbx move-aside mechanism. + */ + readonly files?: readonly string[]; + /** Human-readable justification (documentation only). */ + readonly reason: string; +} + +/** The fully-typed, validated mount policy. */ +export interface MountPolicy { + readonly system: { + readonly directories: { + readonly default: readonly string[]; + readonly sysroot: readonly string[]; + }; + readonly etc: readonly string[]; + }; + readonly home: { + readonly toolSubdirs: readonly string[]; + readonly forbiddenSubdirs: readonly string[]; + }; + readonly credentials: readonly CredentialEntry[]; +} + +function fail(message: string): never { + throw new Error(`Invalid sandbox-mount-policy.json: ${message}`); +} + +function assertStringArray(value: unknown, label: string): readonly string[] { + if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) { + fail(`${label} must be an array of strings`); + } + return value as readonly string[]; +} + +/** + * Validates an array of simple `$HOME`-relative directory names: each entry + * must be a non-empty string with no `/` separator, no `..` component, and no + * leading `/` or `~`. No duplicate entries are allowed. + */ +function assertHomeSubdirArray(value: unknown, label: string): readonly string[] { + const arr = assertStringArray(value, label); + const seen = new Set(); + for (const v of arr) { + if (v.length === 0) fail(`${label} must not contain empty strings`); + if (v.startsWith('/') || v.startsWith('~')) + fail(`${label} entries must be relative $HOME paths, not absolute: ${v}`); + if (v.includes('/')) fail(`${label} entries must be simple directory names (no '/'): ${v}`); + if (v.includes('..')) fail(`${label} entries must not contain '..': ${v}`); + if (seen.has(v)) fail(`${label} has duplicate entry: ${v}`); + seen.add(v); + } + return arr; +} + +/** + * Validates an array of absolute host paths: each entry must start with `/`, + * must not be empty, must not contain `..`, and must be unique. + */ +function assertAbsolutePathArray(value: unknown, label: string): readonly string[] { + const arr = assertStringArray(value, label); + const seen = new Set(); + for (const v of arr) { + if (v.length === 0) fail(`${label} must not contain empty strings`); + if (!v.startsWith('/')) fail(`${label} entries must be absolute paths (start with '/'): ${v}`); + if (v.includes('..')) fail(`${label} entries must not contain '..': ${v}`); + if (seen.has(v)) fail(`${label} has duplicate entry: ${v}`); + seen.add(v); + } + return arr; +} + +function parseCredentials(value: unknown): readonly CredentialEntry[] { + if (typeof value !== 'object' || value === null) { + fail('credentials must be an object'); + } + const entries = (value as { entries?: unknown }).entries; + if (!Array.isArray(entries)) { + fail('credentials.entries must be an array'); + } + + const seen = new Set(); + return entries.map((entry, i) => { + if (typeof entry !== 'object' || entry === null) { + fail(`credentials.entries[${i}] must be an object`); + } + const e = entry as Record; + const path = e.path; + if (typeof path !== 'string' || path.length === 0) { + fail(`credentials.entries[${i}].path must be a non-empty string`); + } + if (path.startsWith('/') || path.startsWith('~') || path.includes('..')) { + fail(`credentials.entries[${i}].path must be a relative $HOME path without '..': ${path}`); + } + if (seen.has(path)) { + fail(`duplicate credential entry path: ${path}`); + } + seen.add(path); + + if (e.type !== 'file' && e.type !== 'dir') { + fail(`credentials.entries[${i}].type must be 'file' or 'dir'`); + } + let files: readonly string[] | undefined; + if (e.files !== undefined) { + if (e.type !== 'dir') { + fail(`credentials.entries[${i}].files is only valid for type 'dir'`); + } + const rawFiles = assertStringArray(e.files, `credentials.entries[${i}].files`); + const seenFiles = new Set(); + for (const f of rawFiles) { + if (f.length === 0) + fail(`credentials.entries[${i}].files must not contain empty strings`); + if (f.includes('/')) + fail(`credentials.entries[${i}].files entries must be plain filenames (no '/'): ${f}`); + if (f.includes('..')) + fail(`credentials.entries[${i}].files entries must not contain '..': ${f}`); + if (seenFiles.has(f)) + fail(`credentials.entries[${i}].files has duplicate entry: ${f}`); + seenFiles.add(f); + } + files = rawFiles; + } + if (typeof e.reason !== 'string' || e.reason.length === 0) { + fail(`credentials.entries[${i}].reason must be a non-empty string`); + } + return { path, type: e.type, files, reason: e.reason }; + }); +} + +function validate(input: unknown): MountPolicy { + if (typeof input !== 'object' || input === null) { + fail('root must be an object'); + } + const p = input as Record; + + const system = p.system as Record | undefined; + const directories = system?.directories as Record | undefined; + if (!system || !directories) { + fail('system.directories is required'); + } + const home = p.home as Record | undefined; + if (!home) { + fail('home is required'); + } + + return { + system: { + directories: { + default: assertAbsolutePathArray(directories.default, 'system.directories.default'), + sysroot: assertAbsolutePathArray(directories.sysroot, 'system.directories.sysroot'), + }, + etc: assertAbsolutePathArray(system.etc, 'system.etc'), + }, + home: { + toolSubdirs: assertHomeSubdirArray(home.toolSubdirs, 'home.toolSubdirs'), + forbiddenSubdirs: assertHomeSubdirArray(home.forbiddenSubdirs, 'home.forbiddenSubdirs'), + }, + credentials: parseCredentials(p.credentials), + }; +} + +/** The validated, frozen mount policy loaded from the JSON config. */ +export const mountPolicy: MountPolicy = validate(rawPolicy); + +/** + * Canonical allow list of `$HOME` subdirectories agents legitimately need + * (tool caches, language toolchains, agent state). Shared by both backends. + */ +export const HOME_TOOL_SUBDIRS: readonly string[] = mountPolicy.home.toolSubdirs; + +/** + * `$HOME` subdirectories whose primary purpose is storing credentials. These + * must NEVER appear in {@link HOME_TOOL_SUBDIRS}; the export exists so tests can + * assert the invariant against a single source of truth. + */ +export const HOME_FORBIDDEN_SUBDIRS: readonly string[] = mountPolicy.home.forbiddenSubdirs; + +/** All credential stores the policy hides from the sandbox. */ +export const CREDENTIAL_ENTRIES: readonly CredentialEntry[] = mountPolicy.credentials; + +/** + * The `$HOME`-relative credential FILE paths the compose backend masks with + * `/dev/null` overlays. For `dir` entries this expands the enumerated `files`; + * for `file` entries it is the path itself. `dir` entries without known files + * are omitted (compose cannot mask a whole directory — sbx covers those). + */ +export function credentialFilesToHide(): string[] { + const files: string[] = []; + for (const entry of mountPolicy.credentials) { + if (entry.type === 'file') { + files.push(entry.path); + } else if (entry.files) { + for (const f of entry.files) { + files.push(`${entry.path}/${f}`); + } + } + } + return files; +} + +/** + * Credential entries the sbx backend should move aside before `sbx create`: + * those whose top-level parent directory is one of the wholesale-mounted home + * dirs in {@link mountedTopLevelParents}. Entries under never-mounted dirs (e.g. + * `.ssh`, `.aws`) are excluded because they never enter the microVM anyway. + */ +export function credentialEntriesUnderMountedParents( + mountedTopLevelParents: ReadonlySet, +): CredentialEntry[] { + return mountPolicy.credentials.filter((entry) => { + const top = entry.path.split('/')[0]; + return mountedTopLevelParents.has(top); + }); +} + +/** Read-only host system directories mounted under /host for compose runtimes. */ +export function systemDirectories(useSysroot: boolean): readonly string[] { + return useSysroot + ? mountPolicy.system.directories.sysroot + : mountPolicy.system.directories.default; +} + +/** Always-mounted read-only host `/etc` paths for compose runtimes. */ +export function etcAllowlist(): readonly string[] { + return mountPolicy.system.etc; +} diff --git a/src/config/sandbox-mount-policy.json b/src/config/sandbox-mount-policy.json new file mode 100644 index 000000000..ff72eac3d --- /dev/null +++ b/src/config/sandbox-mount-policy.json @@ -0,0 +1,71 @@ +{ + "$comment": "Central declarative allow/deny policy for what the host filesystem exposes to an agent, shared by ALL sandbox runtimes (Docker/runc compose, gVisor/runsc compose, and the sbx microVM). This is the single source of truth so the runtimes cannot drift. Loaded and validated by src/config/mount-policy.ts. Paths under `home.*` and `credentials.entries[].path` are relative to the agent's $HOME. See docs/mount-policy.md.", + "system": { + "$comment": "Read-only host directories mounted under /host for the compose runtimes (Docker + gVisor). The sbx microVM does NOT use these — it gets system libraries from its guest image — so this section is compose-only. Modes and the special /dev/null:rw overlay are applied in code (system-mounts.ts).", + "directories": { + "default": ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/opt", "/sys", "/dev"], + "sysroot": ["/sys", "/dev"] + }, + "etc": ["/etc/ssl", "/etc/ca-certificates", "/etc/alternatives", "/etc/ld.so.cache", "/etc/nsswitch.conf"] + }, + "home": { + "$comment": "Agent $HOME exposure. `toolSubdirs` is the ALLOW list: tool caches, language toolchains and agent state the agent legitimately needs. `forbiddenSubdirs` is a DENY guard: dirs whose primary purpose is storing credentials and which must NEVER be added to the allow list. Compose mounts an empty home + binds toolSubdirs on top; sbx mounts toolSubdirs wholesale instead of the whole $HOME.", + "toolSubdirs": [ + ".cache", + ".config", + ".local", + ".anthropic", + ".claude", + ".cargo", + ".rustup", + ".npm", + ".nvm", + ".copilot", + ".gemini" + ], + "forbiddenSubdirs": [ + ".aws", + ".ssh", + ".docker", + ".kube", + ".azure", + ".gnupg", + ".netrc", + ".gitconfig", + ".git-credentials" + ] + }, + "credentials": { + "$comment": "DENY list of on-disk credential/token stores. Each entry's `path` is $HOME-relative. Compose blanks the credential FILES with /dev/null overlays: for a directory entry it masks the enumerated `files`; for a file entry it masks the path itself. sbx moves the whole `path` aside on the host before `sbx create` and restores it afterward, but ONLY when the entry's top-level parent is a mounted home dir (otherwise the path never enters the microVM anyway). Agents receive the credentials they need via the api-proxy or environment, never from these on-disk stores.", + "entries": [ + { "path": ".docker/config.json", "type": "file", "reason": "Docker registry auth" }, + { "path": ".npmrc", "type": "file", "reason": "npm auth token" }, + { "path": ".composer/auth.json", "type": "file", "reason": "Composer registry auth" }, + { "path": ".ssh/id_rsa", "type": "file", "reason": "SSH private key (RSA)" }, + { "path": ".ssh/id_ed25519", "type": "file", "reason": "SSH private key (Ed25519)" }, + { "path": ".ssh/id_ecdsa", "type": "file", "reason": "SSH private key (ECDSA)" }, + { "path": ".ssh/id_dsa", "type": "file", "reason": "SSH private key (DSA)" }, + { "path": ".aws/credentials", "type": "file", "reason": "AWS access keys" }, + { "path": ".aws/config", "type": "file", "reason": "AWS config (may embed SSO/credentials)" }, + { "path": ".kube/config", "type": "file", "reason": "Kubernetes cluster credentials" }, + { "path": ".azure/credentials", "type": "file", "reason": "Azure credentials" }, + { "path": ".cargo/credentials", "type": "file", "reason": "crates.io registry token" }, + { "path": ".cargo/credentials.toml", "type": "file", "reason": "crates.io registry token (newer cargo)" }, + { "path": ".claude/.credentials.json", "type": "file", "reason": "Claude Code OAuth tokens" }, + { "path": ".copilot/config.json", "type": "file", "reason": "Copilot CLI persisted auth token" }, + { "path": ".gemini/oauth_creds.json", "type": "file", "reason": "Gemini CLI OAuth tokens" }, + { "path": ".gemini/google_accounts.json", "type": "file", "reason": "Gemini CLI account identity" }, + { "path": ".gemini/access_tokens.json", "type": "file", "reason": "Gemini CLI cached access tokens" }, + { "path": ".config/gh", "type": "dir", "files": ["hosts.yml"], "reason": "GitHub CLI OAuth token" }, + { "path": ".config/gcloud", "type": "dir", "files": ["credentials.db", "access_tokens.db", "application_default_credentials.json"], "reason": "Google Cloud SDK credentials" }, + { "path": ".config/doctl", "type": "dir", "files": ["config.yaml"], "reason": "DigitalOcean CLI token" }, + { "path": ".config/heroku", "type": "dir", "reason": "Heroku CLI credential store" }, + { "path": ".config/hub", "type": "dir", "reason": "legacy hub CLI OAuth token" }, + { "path": ".config/rclone", "type": "dir", "files": ["rclone.conf"], "reason": "rclone remote credentials" }, + { "path": ".config/containers", "type": "dir", "files": ["auth.json"], "reason": "container registry credentials" }, + { "path": ".config/pulumi", "type": "dir", "files": ["credentials.json"], "reason": "Pulumi access tokens" }, + { "path": ".config/op", "type": "dir", "reason": "1Password CLI state" }, + { "path": ".config/helm", "type": "dir", "files": ["repositories.yaml"], "reason": "Helm repository auth" } + ] + } +} diff --git a/src/sbx-manager.ts b/src/sbx-manager.ts index c10e43bae..b7be8dd18 100644 --- a/src/sbx-manager.ts +++ b/src/sbx-manager.ts @@ -26,7 +26,8 @@ import execa from 'execa'; import * as fs from 'fs'; import * as path from 'path'; import { logger } from './logger'; -import { HOME_TOOL_SUBDIRS, CREDENTIAL_PATHS_BY_PARENT } from './services/agent-volumes/home-whitelist'; +import { HOME_TOOL_SUBDIRS } from './services/agent-volumes/home-whitelist'; +import { credentialEntriesUnderMountedParents } from './config/mount-policy'; /** Name prefix for AWF-managed sandboxes. */ const SBX_NAME_PREFIX = 'awf-agent'; @@ -108,43 +109,39 @@ let credentialBackupRoot: string | undefined; * moved (not deleted) to a backup dir at the home root — which is NOT one of the * mounted subdirs — and restored by {@link restoreHomeCredentials} after the * sandbox is torn down. This is the sbx analog of compose mode's `/dev/null` - * credential overlays. + * credential overlays; the credential list comes from the central mount policy + * so the two backends can't drift. */ function scrubHomeCredentials(homePath: string): void { scrubbedCredentials = []; credentialBackupRoot = undefined; - for (const [parent, names] of Object.entries(CREDENTIAL_PATHS_BY_PARENT)) { - const parentPath = path.join(homePath, parent); - // Parent isn't mounted (doesn't exist) → nothing nested to hide. - if (!fs.existsSync(parentPath)) continue; - - for (const name of names) { - const original = path.join(parentPath, name); - if (!fs.existsSync(original)) continue; - - if (!credentialBackupRoot) { - // A dotted dir at the home ROOT is never in the mounted subdir set, so - // the backup itself can't leak into the VM. - credentialBackupRoot = path.join(homePath, `.awf-sbx-cred-backup-${process.pid}`); - try { - fs.mkdirSync(credentialBackupRoot, { recursive: true }); - } catch (err) { - logger.warn(`[sbx] Could not create credential backup dir: ${(err as Error).message}`); - credentialBackupRoot = undefined; - return; - } - } + const mountedParents = new Set(HOME_TOOL_SUBDIRS); + for (const entry of credentialEntriesUnderMountedParents(mountedParents)) { + const original = path.join(homePath, entry.path); + if (!fs.existsSync(original)) continue; - const backup = path.join(credentialBackupRoot, `${parent}__${name}`.replace(/\//g, '_')); + if (!credentialBackupRoot) { + // A dotted dir at the home ROOT is never in the mounted subdir set, so + // the backup itself can't leak into the VM. + credentialBackupRoot = path.join(homePath, `.awf-sbx-cred-backup-${process.pid}`); try { - fs.renameSync(original, backup); - scrubbedCredentials.push({ original, backup }); - logger.info(`[sbx] Hid credential path from sandbox: ${parent}/${name}`); + fs.mkdirSync(credentialBackupRoot, { recursive: true }); } catch (err) { - logger.warn(`[sbx] Could not hide credential path ${original}: ${(err as Error).message}`); + logger.warn(`[sbx] Could not create credential backup dir: ${(err as Error).message}`); + credentialBackupRoot = undefined; + return; } } + + const backup = path.join(credentialBackupRoot, entry.path.replace(/\//g, '__')); + try { + fs.renameSync(original, backup); + scrubbedCredentials.push({ original, backup }); + logger.info(`[sbx] Hid credential path from sandbox: ${entry.path}`); + } catch (err) { + logger.warn(`[sbx] Could not hide credential path ${original}: ${(err as Error).message}`); + } } if (scrubbedCredentials.length > 0 && credentialBackupRoot) { @@ -259,9 +256,9 @@ export async function createSandbox(config: SbxConfig): Promise { // positional (host path == guest path) and cannot express the per-file // /dev/null credential overlays that compose mode uses (see // credential-hiding.ts), so the only way to keep host secrets out of the VM - // is to curate which $HOME subdirs are mounted. We share the same whitelist - // as the compose chroot home strategy (HOME_TOOL_SUBDIRS) plus the agent - // state dirs (.copilot, .gemini). Credential stores such as ~/.aws, ~/.ssh, + // is to curate which $HOME subdirs are mounted. The central mount policy + // (HOME_TOOL_SUBDIRS) lists the allowed tool-state dirs including agent-state + // dirs (.copilot, .gemini). Credential stores such as ~/.aws, ~/.ssh, // ~/.docker, ~/.kube, ~/.azure, ~/.gnupg, ~/.netrc and ~/.gitconfig are never // whitelisted, so they never enter the sandbox. Only paths that exist on the // host are mounted, because sbx requires the mount source to exist. @@ -276,8 +273,7 @@ export async function createSandbox(config: SbxConfig): Promise { // scrubHomeCredentials below) and restored after teardown, so the benign tool // state stays available while the secrets never enter the microVM. const homePath = process.env.HOME || '/home/runner'; - const homeSubdirs = ['.copilot', ...HOME_TOOL_SUBDIRS, '.gemini']; - for (const subdir of homeSubdirs) { + for (const subdir of HOME_TOOL_SUBDIRS) { const hostSubdir = `${homePath}/${subdir}`; if (seenPaths.has(hostSubdir)) continue; if (!fs.existsSync(hostSubdir)) continue; diff --git a/src/services/agent-environment/api-proxy-environment.ts b/src/services/agent-environment/api-proxy-environment.ts index 31c797b31..5c0ff2483 100644 --- a/src/services/agent-environment/api-proxy-environment.ts +++ b/src/services/agent-environment/api-proxy-environment.ts @@ -44,7 +44,7 @@ export function buildApiProxyEnvironment(params: ApiProxyEnvironmentParams): voi environment.AWF_USER_UID = getSafeHostUid(); environment.AWF_USER_GID = getSafeHostGid(); - if (config.geminiApiKey) { + if (config.geminiApiKey || config.googleApiKey) { environment.AWF_GEMINI_ENABLED = '1'; } } diff --git a/src/services/agent-volumes-toolchain.test.ts b/src/services/agent-volumes-toolchain.test.ts index 664f7deb3..c7ffdb9ec 100644 --- a/src/services/agent-volumes-toolchain.test.ts +++ b/src/services/agent-volumes-toolchain.test.ts @@ -47,6 +47,17 @@ describe('agent service', () => { expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); }); + it('should mount ~/.gemini when googleApiKey is configured (Vertex AI)', () => { + const configWithVertex = { ...getConfig(), googleApiKey: 'AIza-test-google-key' }; + const result = generateDockerCompose(configWithVertex, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + const homeDir = process.env.HOME || '/root'; + // Vertex AI uses the Gemini CLI's ~/.gemini directory; it must be mounted + // so the Gemini CLI can store config and auth state when using Vertex. + expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); + }); + it('should mount container.runnerToolCachePath when it points to a real directory', () => { const toolcacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-toolcache-')); diff --git a/src/services/agent-volumes/credential-hiding.test.ts b/src/services/agent-volumes/credential-hiding.test.ts index bbccc0316..743bdf023 100644 --- a/src/services/agent-volumes/credential-hiding.test.ts +++ b/src/services/agent-volumes/credential-hiding.test.ts @@ -1,13 +1,30 @@ import { buildCredentialHidingOverlays } from './credential-hiding'; +import { credentialFilesToHide } from '../../config/mount-policy'; describe('buildCredentialHidingOverlays', () => { - it('hides credentials at both home and /host paths', () => { + it('hides every policy credential file at both home and /host paths', () => { + const overlays = buildCredentialHidingOverlays('/home/runner'); + const expectedFiles = credentialFilesToHide(); + + // One overlay at the real $HOME path and one at the chroot /host path. + expect(overlays).toHaveLength(expectedFiles.length * 2); + + for (const rel of expectedFiles) { + expect(overlays).toContain(`/dev/null:/home/runner/${rel}:ro`); + expect(overlays).toContain(`/dev/null:/host/home/runner/${rel}:ro`); + } + }); + + it('masks representative credential files from the central policy', () => { const overlays = buildCredentialHidingOverlays('/home/runner'); expect(overlays).toContain('/dev/null:/home/runner/.docker/config.json:ro'); expect(overlays).toContain('/dev/null:/host/home/runner/.docker/config.json:ro'); expect(overlays).toContain('/dev/null:/home/runner/.config/gh/hosts.yml:ro'); expect(overlays).toContain('/dev/null:/host/home/runner/.config/gh/hosts.yml:ro'); - expect(overlays).toHaveLength(28); + // Newly centralized entries (previously only protected by sbx). + expect(overlays).toContain('/dev/null:/home/runner/.copilot/config.json:ro'); + expect(overlays).toContain('/dev/null:/home/runner/.claude/.credentials.json:ro'); + expect(overlays).toContain('/dev/null:/home/runner/.gemini/oauth_creds.json:ro'); }); }); diff --git a/src/services/agent-volumes/credential-hiding.ts b/src/services/agent-volumes/credential-hiding.ts index 8670bb58b..0b8c1460d 100644 --- a/src/services/agent-volumes/credential-hiding.ts +++ b/src/services/agent-volumes/credential-hiding.ts @@ -1,45 +1,22 @@ import { logger } from '../../logger'; +import { credentialFilesToHide } from '../../config/mount-policy'; +/** + * Builds the compose-mode `/dev/null` overlays that blank known on-disk + * credential files. The file list is derived from the central mount policy + * ({@link ../../config/mount-policy}) so it can't drift from the sbx backend. + * + * Each credential file is masked twice: once at the real `$HOME` path and once + * at the chroot `/host$HOME` path (the agent runs chrooted into `/host`). + */ export function buildCredentialHidingOverlays(effectiveHome: string): string[] { - const credentialFiles = [ - `${effectiveHome}/.docker/config.json`, - `${effectiveHome}/.npmrc`, - `${effectiveHome}/.cargo/credentials`, - `${effectiveHome}/.composer/auth.json`, - `${effectiveHome}/.config/gh/hosts.yml`, - `${effectiveHome}/.ssh/id_rsa`, - `${effectiveHome}/.ssh/id_ed25519`, - `${effectiveHome}/.ssh/id_ecdsa`, - `${effectiveHome}/.ssh/id_dsa`, - `${effectiveHome}/.aws/credentials`, - `${effectiveHome}/.aws/config`, - `${effectiveHome}/.kube/config`, - `${effectiveHome}/.azure/credentials`, - `${effectiveHome}/.config/gcloud/credentials.db`, - ]; + const credentialFiles = credentialFilesToHide().map((rel) => `${effectiveHome}/${rel}`); - const mounts = credentialFiles.map(credFile => `/dev/null:${credFile}:ro`); + const mounts = credentialFiles.map((credFile) => `/dev/null:${credFile}:ro`); logger.debug(`Hidden ${credentialFiles.length} credential file(s) via /dev/null mounts`); logger.debug('Hiding credential files at /host paths'); - - const chrootCredentialFiles = [ - `/dev/null:/host${effectiveHome}/.docker/config.json:ro`, - `/dev/null:/host${effectiveHome}/.npmrc:ro`, - `/dev/null:/host${effectiveHome}/.cargo/credentials:ro`, - `/dev/null:/host${effectiveHome}/.composer/auth.json:ro`, - `/dev/null:/host${effectiveHome}/.config/gh/hosts.yml:ro`, - `/dev/null:/host${effectiveHome}/.ssh/id_rsa:ro`, - `/dev/null:/host${effectiveHome}/.ssh/id_ed25519:ro`, - `/dev/null:/host${effectiveHome}/.ssh/id_ecdsa:ro`, - `/dev/null:/host${effectiveHome}/.ssh/id_dsa:ro`, - `/dev/null:/host${effectiveHome}/.aws/credentials:ro`, - `/dev/null:/host${effectiveHome}/.aws/config:ro`, - `/dev/null:/host${effectiveHome}/.kube/config:ro`, - `/dev/null:/host${effectiveHome}/.azure/credentials:ro`, - `/dev/null:/host${effectiveHome}/.config/gcloud/credentials.db:ro`, - ]; - + const chrootCredentialFiles = credentialFiles.map((credFile) => `/dev/null:/host${credFile}:ro`); mounts.push(...chrootCredentialFiles); logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`); diff --git a/src/services/agent-volumes/etc-mounts.ts b/src/services/agent-volumes/etc-mounts.ts index 66ec65741..d3969c242 100644 --- a/src/services/agent-volumes/etc-mounts.ts +++ b/src/services/agent-volumes/etc-mounts.ts @@ -4,6 +4,7 @@ import { WrapperConfig } from '../../types'; import { shouldUseDockerHostStaging, stageHostFile, getDockerHostStageRoot } from './docker-host-staging'; import { getSafeHostUid, getSafeHostGid } from '../../host-identity'; import { isSysrootEnabled } from '../sysroot-service'; +import { etcAllowlist } from '../../config/mount-policy'; /** * Synthesize a minimal /etc/passwd or /etc/group file in the staging directory. @@ -66,13 +67,7 @@ export function buildEtcMounts(config: WrapperConfig): string[] { return []; } - const mounts: string[] = [ - '/etc/ssl:/host/etc/ssl:ro', - '/etc/ca-certificates:/host/etc/ca-certificates:ro', - '/etc/alternatives:/host/etc/alternatives:ro', - '/etc/ld.so.cache:/host/etc/ld.so.cache:ro', - '/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro', - ]; + const mounts: string[] = etcAllowlist().map((p) => `${p}:/host${p}:ro`); if (!shouldUseDockerHostStaging(config.dockerHostPathPrefix)) { mounts.push('/etc/passwd:/host/etc/passwd:ro'); diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index f3d84bf08..100436d81 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -44,13 +44,11 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { mounts.push(`${agentLogsPath}:/host${effectiveHome}/.copilot/logs:rw`); for (const subdir of HOME_TOOL_SUBDIRS) { + if (subdir === '.copilot') continue; // handled specially above (existence check + session-state/logs sub-mounts) + if (subdir === '.gemini' && !config.geminiApiKey && !config.googleApiKey) continue; // only mount when Gemini/Vertex credentials are present mounts.push(`${effectiveHome}/${subdir}:/host${effectiveHome}/${subdir}:rw`); } - if (config.geminiApiKey || config.googleApiKey) { - mounts.push(`${effectiveHome}/.gemini:/host${effectiveHome}/.gemini:rw`); - } - const runnerToolCacheDir = resolveRunnerToolCachePath(config, effectiveHome); if (runnerToolCacheDir) { mounts.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); diff --git a/src/services/agent-volumes/home-whitelist.test.ts b/src/services/agent-volumes/home-whitelist.test.ts index cea4517f6..4bcd38cac 100644 --- a/src/services/agent-volumes/home-whitelist.test.ts +++ b/src/services/agent-volumes/home-whitelist.test.ts @@ -1,49 +1,21 @@ -import { - HOME_TOOL_SUBDIRS, - CREDENTIAL_PATHS_BY_PARENT, -} from './home-whitelist'; +import { HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from './home-whitelist'; -describe('home-whitelist', () => { - it('never whitelists a top-level credential store directory', () => { - const forbidden = [ - '.aws', - '.ssh', - '.docker', - '.kube', - '.azure', - '.gnupg', - '.netrc', - '.gitconfig', - '.git-credentials', - ]; - for (const dir of forbidden) { +describe('home-whitelist (mount-policy shim)', () => { + it('re-exports the shared home allow list', () => { + expect(HOME_TOOL_SUBDIRS).toEqual( + expect.arrayContaining(['.cache', '.config', '.local', '.cargo', '.npm', '.copilot', '.gemini']), + ); + }); + + it('never whitelists a directory that is on the forbidden deny list', () => { + for (const dir of HOME_FORBIDDEN_SUBDIRS) { expect(HOME_TOOL_SUBDIRS as readonly string[]).not.toContain(dir); } }); - it('enumerates the known nested credential paths for each tool dir', () => { - // Compose blanks these via /dev/null overlays; sbx moves them aside before - // `sbx create` and restores them after teardown. - expect(CREDENTIAL_PATHS_BY_PARENT['.config']).toEqual( - expect.arrayContaining(['gh', 'gcloud']), + it('lists the well-known top-level credential store dirs as forbidden', () => { + expect(HOME_FORBIDDEN_SUBDIRS).toEqual( + expect.arrayContaining(['.aws', '.ssh', '.docker', '.kube', '.azure', '.gnupg']), ); - expect(CREDENTIAL_PATHS_BY_PARENT['.cargo']).toContain('credentials'); - expect(CREDENTIAL_PATHS_BY_PARENT['.claude']).toContain('.credentials.json'); - expect(CREDENTIAL_PATHS_BY_PARENT['.copilot']).toContain('config.json'); - expect(CREDENTIAL_PATHS_BY_PARENT['.gemini']).toContain('oauth_creds.json'); - }); - - it('only nests credential paths under mounted home subdirs', () => { - // Every credential parent must itself be a mounted home subdir (whitelisted - // tool dir, or an agent-state dir the sbx path adds: .copilot / .gemini), - // otherwise scrubbing it would be pointless (the parent never enters the VM). - const mountedHomeSubdirs = new Set([ - '.copilot', - ...HOME_TOOL_SUBDIRS, - '.gemini', - ]); - for (const parent of Object.keys(CREDENTIAL_PATHS_BY_PARENT)) { - expect(mountedHomeSubdirs.has(parent)).toBe(true); - } }); }); diff --git a/src/services/agent-volumes/home-whitelist.ts b/src/services/agent-volumes/home-whitelist.ts index bd72bfe64..8c488890a 100644 --- a/src/services/agent-volumes/home-whitelist.ts +++ b/src/services/agent-volumes/home-whitelist.ts @@ -1,95 +1,22 @@ /** - * Canonical whitelist of `$HOME` subdirectories that agents legitimately need - * (tool caches, language toolchains, agent state). + * @deprecated Thin compatibility shim. The canonical allow/deny mount policy now + * lives in the declarative {@link ../../config/sandbox-mount-policy.json} and is + * loaded by {@link ../../config/mount-policy}. This module re-exports the home + * allow list so existing importers keep working; prefer importing from + * `config/mount-policy` directly in new code. * - * This list is the single source of truth shared by **both** sandbox backends - * so their home-directory exposure stays in sync: + * `HOME_TOOL_SUBDIRS` is the canonical whitelist of `$HOME` subdirectories that + * agents legitimately need (tool caches, language toolchains, agent state), + * shared by both sandbox backends: * * - **Compose / chroot mode** (`home-strategy.ts`) mounts an empty home volume - * and then bind-mounts these subdirs on top, and additionally blanks known - * credential files with `/dev/null` overlays (`credential-hiding.ts`). - * - **sbx microVM mode** (`sbx-manager.ts`) mounts these subdirs individually - * instead of the whole `$HOME`. sbx uses positional (host path == guest path) - * mounts and cannot express per-file `/dev/null` overlays, so directory - * curation is its only mechanism — which makes this whitelist the primary - * protection there. + * and bind-mounts these subdirs on top, then blanks known credential files + * with `/dev/null` overlays (`credential-hiding.ts`, driven by the policy). + * - **sbx microVM mode** (`sbx-manager.ts`) mounts these subdirs wholesale + * instead of the whole `$HOME`, then moves policy credential paths aside + * before `sbx create`. * * SECURITY: never add a directory whose primary purpose is storing credentials - * (for example `.aws`, `.ssh`, `.docker`, `.kube`, `.azure`, `.gnupg`). Any such - * store must stay OUT of the sandbox. Directories listed here can still contain - * stray secret files; compose mode masks the known ones via - * `buildCredentialHidingOverlays()`, but sbx cannot, so keep this list to - * genuinely non-credential tooling paths. - * - * `.gemini` is intentionally NOT included: compose mode mounts it only when a - * Gemini/Google API key is configured, so each caller handles it separately. - */ -export const HOME_TOOL_SUBDIRS = [ - '.cache', - '.config', - '.local', - '.anthropic', - '.claude', - '.cargo', - '.rustup', - '.npm', - '.nvm', -] as const; - -/** - * Credential/token stores that live *inside* an otherwise-whitelisted `$HOME` - * subdir, keyed by the parent subdir's basename. Each value lists the immediate - * child basenames (directories **or** files) that are credential stores. - * - * Whitelisted dirs such as `.config`, `.cargo`, `.claude`, `.copilot` and - * `.gemini` are needed for legitimate tool settings/state, but each also stashes - * secrets in a well-known child: - * - * - `.config/gh`, `.config/gcloud`, … — per-CLI token stores - * - `.cargo/credentials`, `.cargo/credentials.toml` — crates.io registry tokens - * - `.claude/.credentials.json` — Claude Code OAuth tokens - * - `.copilot/config.json` — Copilot CLI can persist its token here - * - `.gemini/oauth_creds.json`, `.gemini/google_accounts.json` — Gemini OAuth - * - * Compose mode blanks these individual paths with `/dev/null` overlays - * (`credential-hiding.ts`). sbx mounts are positional virtiofs passthroughs - * (host path == guest path, directory-granular) and cannot overlay or mask an - * individual nested path, nor mount a single file. So the sbx backend instead - * mounts these parents **wholesale** (so their required files still work) but - * temporarily **moves these credential paths aside on the host before - * `sbx create` and restores them after the sandbox is torn down** — keeping the - * secrets out of the VM without dropping the benign tool state the agent needs. - * - * SECURITY: entries here are credential-centric. The agent receives whatever - * credentials it legitimately needs through the API proxy or environment, not by - * reading the host's on-disk auth store, so hiding these paths is safe. + * — those belong in the policy's `home.forbiddenSubdirs` deny guard. */ -export const CREDENTIAL_PATHS_BY_PARENT: Readonly> = { - '.config': [ - 'gh', // GitHub CLI: hosts.yml (oauth_token) - 'gcloud', // Google Cloud SDK: credentials.db, access_tokens.db, application_default_credentials.json - 'doctl', // DigitalOcean CLI: config.yaml (access token) - 'heroku', // Heroku CLI: credential store - 'hub', // legacy hub CLI: oauth token - 'rclone', // rclone.conf: remote credentials - 'containers', // containers/auth.json: registry credentials - 'pulumi', // Pulumi: credentials.json (access tokens) - 'op', // 1Password CLI state - 'helm', // repository auth (repositories.yaml can embed credentials) - ], - '.cargo': [ - 'credentials', // crates.io registry token - 'credentials.toml', // crates.io registry token (newer cargo) - ], - '.claude': [ - '.credentials.json', // Claude Code OAuth tokens - ], - '.copilot': [ - 'config.json', // Copilot CLI may persist its auth token here - ], - '.gemini': [ - 'oauth_creds.json', // Gemini CLI OAuth access/refresh tokens - 'google_accounts.json', // Gemini CLI account identity - 'access_tokens.json', // Gemini CLI cached access tokens - ], -}; +export { HOME_TOOL_SUBDIRS, HOME_FORBIDDEN_SUBDIRS } from '../../config/mount-policy'; diff --git a/src/services/agent-volumes/system-mounts.ts b/src/services/agent-volumes/system-mounts.ts index 16cb7e614..36da01bd5 100644 --- a/src/services/agent-volumes/system-mounts.ts +++ b/src/services/agent-volumes/system-mounts.ts @@ -1,3 +1,5 @@ +import { systemDirectories } from '../../config/mount-policy'; + function normalizeChrootBinariesSourcePath(chrootBinariesSourcePath?: string): string | undefined { if (!chrootBinariesSourcePath) { return undefined; @@ -15,24 +17,14 @@ export function buildSystemMounts( chrootBinariesSourcePath?: string, useSysroot = false ): string[] { + // Read-only system directories come from the central mount policy. /sys and + // /dev are always ro; /dev/null is re-added rw so chrooted tools can write to + // it. The sysroot variant (arc-dind) omits /usr,/bin,/lib,… because the + // sysroot volume already provides them. + const systemDirMounts = systemDirectories(useSysroot).map((dir) => `${dir}:/host${dir}:ro`); const mounts = [ - ...(useSysroot - ? [ - '/sys:/host/sys:ro', - '/dev:/host/dev:ro', - '/dev/null:/host/dev/null:rw', - ] - : [ - '/usr:/host/usr:ro', - '/bin:/host/bin:ro', - '/sbin:/host/sbin:ro', - '/lib:/host/lib:ro', - '/lib64:/host/lib64:ro', - '/opt:/host/opt:ro', - '/sys:/host/sys:ro', - '/dev:/host/dev:ro', - '/dev/null:/host/dev/null:rw', - ]), + ...systemDirMounts, + '/dev/null:/host/dev/null:rw', `${workspaceDir}:/host${workspaceDir}:rw`, '/tmp:/host/tmp:rw', ]; diff --git a/src/services/api-proxy-service-api-targets.test.ts b/src/services/api-proxy-service-api-targets.test.ts index f12ee97d2..86634125d 100644 --- a/src/services/api-proxy-service-api-targets.test.ts +++ b/src/services/api-proxy-service-api-targets.test.ts @@ -306,13 +306,21 @@ describe('API proxy sidecar: API targets and auth forwarding', () => { expect(env.AWF_GEMINI_ENABLED).toBe('1'); }); - it('should NOT set AWF_GEMINI_ENABLED in agent when geminiApiKey is absent', () => { + it('should NOT set AWF_GEMINI_ENABLED in agent when neither geminiApiKey nor googleApiKey is set', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); const env = result.services.agent.environment as Record; expect(env.AWF_GEMINI_ENABLED).toBeUndefined(); }); + it('should set AWF_GEMINI_ENABLED in agent when googleApiKey is provided (Vertex AI)', () => { + // Vertex AI uses googleApiKey; ~/.gemini is mounted and entrypoint must fix its ownership. + const configWithProxy = { ...mockConfig, enableApiProxy: true, googleApiKey: 'AIza-test-google-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const env = result.services.agent.environment as Record; + expect(env.AWF_GEMINI_ENABLED).toBe('1'); + }); + it('should not inherit AWF_GEMINI_ENABLED from host env via envAll when geminiApiKey is absent', () => { const origVal = process.env.AWF_GEMINI_ENABLED; process.env.AWF_GEMINI_ENABLED = '1';