Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 22 additions & 1 deletion src/support/osSandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 0 additions & 2 deletions src/support/osSandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
14 changes: 14 additions & 0 deletions src/support/sharedPathDetection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
detectSharedPaths,
emptyDetectionWarning,
resolveSharedPaths,
secretSharedPathComponents,
secretSharedPathsWarning,
} from './sharedPathDetection.js';

describe('detectSharedPaths (AGT-4043)', () => {
Expand Down Expand Up @@ -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();
});
});
13 changes: 13 additions & 0 deletions src/support/sharedPathDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
4 changes: 2 additions & 2 deletions src/support/worktreeManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// ============================================
// OpenSwarm - Git Worktree Manager
// Per-issue independent worktree creation/cleanup and PR automation
Expand All @@ -23,7 +23,7 @@
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';
Expand Down Expand Up @@ -192,7 +192,7 @@
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);
Expand Down
Loading