From 405d3f4de7dde8aeef373a890835462f1a90ef11 Mon Sep 17 00:00:00 2001 From: Heewon Oh Date: Fri, 23 Oct 2026 14:08:51 +0900 Subject: [PATCH] fix(worker): restrict wrangler writes and warn on shared secrets --- README.md | 6 ++++++ src/support/osSandbox.test.ts | 23 ++++++++++++++++++++++- src/support/osSandbox.ts | 2 -- src/support/sharedPathDetection.test.ts | 14 ++++++++++++++ src/support/sharedPathDetection.ts | 13 +++++++++++++ src/support/worktreeManager.ts | 4 ++-- 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d14fc05e..529f616c 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,12 @@ when it cannot** — running a worker's code unsandboxed to decide whether to trust it defeats the point. On macOS it uses the platform sandbox and needs no setup. +Worktrees can share repository paths through `openswarm.json`'s +`sandbox.sharedPaths`. Do not list `.env*`, `*.pem`, `*.key`, or +`credentials*` paths there. OpenSwarm warns when a shared path matches one of +these names. To keep a path out of worker worktrees, remove it from +`sandbox.sharedPaths` and make sure it is not an auto-detected dependency path. + Installing the package is not always enough, and CI is where that bites: | Environment | What it takes | diff --git a/src/support/osSandbox.test.ts b/src/support/osSandbox.test.ts index d113b74e..be67d17e 100644 --- a/src/support/osSandbox.test.ts +++ b/src/support/osSandbox.test.ts @@ -9,7 +9,7 @@ // ============================================ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, realpathSync, existsSync } from 'node:fs'; +import { mkdtempSync, realpathSync, existsSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -91,6 +91,27 @@ describe('defaultWorkerWritableRoots', () => { // No duplicates even when TMPDIR and /tmp resolve to the same place. expect(new Set(roots).size).toBe(roots.length); }); + + it('excludes Wrangler directories from writable roots and refuses a Linux sandbox write', () => { + const wt = mkdtempSync(join(tmpdir(), 'osw-wrangler-wt-')); + const home = mkdtempSync(join(process.env.HOME ?? '/home', 'osw-wrangler-home-')); + try { + mkdirSync(join(home, '.wrangler')); + mkdirSync(join(home, 'Library', 'Preferences', '.wrangler'), { recursive: true }); + const roots = defaultWorkerWritableRoots(wt, { TMPDIR: '/tmp' }, home); + expect(roots).not.toContain(realpathSync(join(home, '.wrangler'))); + expect(roots).not.toContain(realpathSync(join(home, 'Library', 'Preferences', '.wrangler'))); + if (process.platform === 'linux' && existsSync('/usr/bin/bwrap')) { + const wrapped = wrapForSandbox(['bash', '-c', `touch "${join(home, '.wrangler', 'x')}"`], + { writableRoots: roots, allowNetwork: false }, 'bwrap')!; + expect(() => execFileSync(wrapped.file, wrapped.args, { stdio: 'pipe' })).toThrow(); + expect(existsSync(join(home, '.wrangler', 'x'))).toBe(false); + } + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(wt, { recursive: true, force: true }); + } + }); }); describe('looksLikeSandboxDenial', () => { diff --git a/src/support/osSandbox.ts b/src/support/osSandbox.ts index e2510e9e..4f3313d8 100644 --- a/src/support/osSandbox.ts +++ b/src/support/osSandbox.ts @@ -170,8 +170,6 @@ export function defaultWorkerWritableRoots( join(home, '.cache'), join(home, '.npm'), join(home, 'Library', 'Caches'), - join(home, 'Library', 'Preferences', '.wrangler'), - join(home, '.wrangler'), join(home, '.cargo', 'registry'), ]; const roots: string[] = []; diff --git a/src/support/sharedPathDetection.test.ts b/src/support/sharedPathDetection.test.ts index 3784cb35..c6cf1699 100644 --- a/src/support/sharedPathDetection.test.ts +++ b/src/support/sharedPathDetection.test.ts @@ -7,6 +7,8 @@ import { detectSharedPaths, emptyDetectionWarning, resolveSharedPaths, + secretSharedPathComponents, + secretSharedPathsWarning, } from './sharedPathDetection.js'; describe('detectSharedPaths (AGT-4043)', () => { @@ -136,3 +138,15 @@ describe('resolveSharedPaths configured list (INT-2415)', () => { expect(resolveSharedPaths(repo, { sandbox: { sharedPaths: ['/etc', '../secrets', 'a/../../x', ''] } })).toEqual([]); }); }); + +describe('secretSharedPathComponents (AGT-4813)', () => { + it('flags paths with secret-like components and leaves ordinary dependencies alone', () => { + expect(secretSharedPathComponents(['.env', '.env.local', 'certs/client.pem', 'keys/app.key', 'credentials.json', 'node_modules'])) + .toEqual(['.env', '.env.local', 'certs/client.pem', 'keys/app.key', 'credentials.json']); + }); + + it('returns an actionable warning when a secret-like shared path is configured', () => { + expect(secretSharedPathsWarning(['.env'])).toContain('Remove these entries from openswarm.json sandbox.sharedPaths'); + expect(secretSharedPathsWarning(['node_modules'])).toBeNull(); + }); +}); diff --git a/src/support/sharedPathDetection.ts b/src/support/sharedPathDetection.ts index b0719da8..09622edd 100644 --- a/src/support/sharedPathDetection.ts +++ b/src/support/sharedPathDetection.ts @@ -43,6 +43,19 @@ export interface SandboxConfig { sandbox?: { sharedPaths?: string[] } | null; } +/** Secret-like path components must not be silently shared into worker worktrees. */ +export function secretSharedPathComponents(paths: string[]): string[] { + const patterns = [/^\.env.*$/i, /^.+\.pem$/i, /^.+\.key$/i, /^credentials.*$/i]; + return paths.filter((path) => path.split(/[\\/]/).some((part) => patterns.some((pattern) => pattern.test(part)))); +} + +export function secretSharedPathsWarning(paths: string[]): string | null { + const matches = secretSharedPathComponents(paths); + if (matches.length === 0) return null; + return `[Worktree] sharedPaths may expose secret files to the worker: ${matches.join(', ')}. ` + + 'Remove these entries from openswarm.json sandbox.sharedPaths to keep them out of worktrees.'; +} + export interface SharedPathDetection { /** Repo-relative dependency dirs that qualify for sharing, root first, then by walk order. */ shared: string[]; diff --git a/src/support/worktreeManager.ts b/src/support/worktreeManager.ts index d1747f9e..021bff10 100644 --- a/src/support/worktreeManager.ts +++ b/src/support/worktreeManager.ts @@ -23,7 +23,7 @@ import { changeShapeSection } from './publicationChangeShape.js'; import { baseFreshnessSection, probeBaseFreshness } from './publicationBaseFreshness.js'; import { publicationClaimEvidence } from './publicationClaimEvidence.js'; import { loadRepoMetadata } from './repoMetadata.js'; -import { detectSharedPaths, emptyDetectionWarning, resolveSharedPaths, type SandboxConfig } from './sharedPathDetection.js'; +import { detectSharedPaths, emptyDetectionWarning, resolveSharedPaths, secretSharedPathsWarning, type SandboxConfig } from './sharedPathDetection.js'; import { copyIsolatedPath } from './isolatedPath.js'; import { hasEditableInstallInto, rebasePythonEnvironment } from '../verify/pythonEnvironment.js'; import { assertBranchWithinWriteScope } from './publicationScopeFence.js'; @@ -192,7 +192,7 @@ async function linkSharedPaths(repoPath: string, worktreePath: string): Promise< console.warn(`[Worktree] openswarm.json unreadable; skipping sharedPaths config:`, err); } - const shared = resolveSharedPaths(repoPath, meta); + const shared = resolveSharedPaths(repoPath, meta), secretWarning = secretSharedPathsWarning(shared); if (secretWarning) console.warn(secretWarning); if (shared.length === 0 && !(meta?.sandbox?.sharedPaths?.length)) { const warning = emptyDetectionWarning(repoPath, detectSharedPaths(repoPath)); if (warning) console.warn(warning);