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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ src/quality-gates.js
src/command-proofs.js
src/repo-settings-access.js
src/claude-credentials.js
src/copy-containment.js
src/copy-worker.js
src/darwin-keychain-boundary.js
src/guidance-topics.js
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Operational rules and references for automated agents working on this repo. Inst
npm version or GitHub Release.
- Provider output-silence liveness checks are opt-in (`enableLivenessCheck: true`). Recovery tests
that exercise stale-agent termination must enable the watchdog explicitly.
- Isolation copies must reuse the shared pinned-root boundary in `src/copy-containment.ts` for
traversal, directory creation, synchronous copies, and worker copies. Revalidate the source and
destination immediately before every filesystem effect; never reconstruct unchecked effect paths.

Worker git operations are allowed only with isolation (`--worktree`, `--docker`, `--pr`, `--ship`). They are forbidden without isolation.

Expand Down
15 changes: 8 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ Message-passing primitives for multi-agent workflows. **Install:** `npm i -g @th

## 🔴 CRITICAL RULES

| Rule | Why | Forbidden | Required |
| ---------------------------------- | ---------------------------- | -------------------------------------------- | ------------------------------------------------ |
| **GENERAL PURPOSE ONLY** | Zeroshot runs on ANY repo | Hardcoded paths, scripts, languages, domains | Discover from target repo's CLAUDE.md/README |
| **Never spawn without permission** | Consumes API credits | "I'll run zeroshot on 123" | User says "run zeroshot" |
| **Never use git in validators** | Git state unreliable | `git diff`, `git status` in prompts | Validate files directly |
| **Never ask questions** | Agents run non-interactively | `AskUserQuestion`, waiting for confirmation | Make autonomous decisions |
| **Never edit CLAUDE.md** | Context file for Claude Code | Editing this file | Read-only unless explicitly asked to update docs |
| Rule | Why | Forbidden | Required |
| ---------------------------------- | --------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **GENERAL PURPOSE ONLY** | Zeroshot runs on ANY repo | Hardcoded paths, scripts, languages, domains | Discover from target repo's CLAUDE.md/README |
| **Never spawn without permission** | Consumes API credits | "I'll run zeroshot on 123" | User says "run zeroshot" |
| **Never use git in validators** | Git state unreliable | `git diff`, `git status` in prompts | Validate files directly |
| **Never ask questions** | Agents run non-interactively | `AskUserQuestion`, waiting for confirmation | Make autonomous decisions |
| **Never edit CLAUDE.md** | Context file for Claude Code | Editing this file | Read-only unless explicitly asked to update docs |
| **Preserve copy containment** | Copy paths cross trust boundaries | Independent path joins or one-time checks | Reuse the pinned-root boundary and revalidate immediately before every filesystem effect |

### 🔴 GENERAL PURPOSE REQUIREMENT (CRITICAL)

Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ export default [
'lib/stream-json-parser.js',
'src/claude-credentials.js',
'src/config-router.js',
'src/copy-containment.js',
'src/copy-worker.js',
'src/darwin-keychain-boundary.js',
'src/guidance-topics.js',
Expand Down
259 changes: 259 additions & 0 deletions src/copy-containment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import fs = require('fs');
import path = require('path');

export const CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';

export interface PinnedCopyRoot {
requestedPath: string;
canonicalPath: string;
device: string;
inode: string;
}

export interface CopyBoundary {
sourceRoot: PinnedCopyRoot;
destinationRoot: PinnedCopyRoot;
}

interface PathIdentity {
device: string;
inode: string;
directory: boolean;
}

interface CopyPathApi {
isAbsolute(targetPath: string): boolean;
normalize(targetPath: string): string;
parse(targetPath: string): { root: string };
readonly sep: string;
}

export class CopyContainmentError extends Error {
readonly code = CONTAINMENT_ERROR_CODE;
readonly relativePath: unknown;

constructor(relativePath: unknown, reason: string) {
super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
this.name = 'CopyContainmentError';
this.relativePath = relativePath;
}
}

function isContained(rootPath: string, targetPath: string): boolean {
const relative = path.relative(rootPath, targetPath);
return (
relative === '' ||
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
);
}

function containmentError(
relativePath: unknown,
reason: string,
cause?: unknown
): CopyContainmentError {
const error = new CopyContainmentError(relativePath, reason);
if (cause !== undefined) {
error.cause = cause;
}
return error;
}

export function isCopyRecord(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === 'object' && value !== null;
}

export function copyErrorCode(error: unknown): string | null {
if (isCopyRecord(error) && 'code' in error) {
return typeof error.code === 'string' ? error.code : null;
}
return null;
}

function statIdentity(targetPath: string): PathIdentity {
const stats = fs.statSync(targetPath, { bigint: true });
return {
device: stats.dev.toString(),
inode: stats.ino.toString(),
directory: stats.isDirectory(),
};
}

function pinRoot(rootPath: string, label: string, expectedRoot?: PinnedCopyRoot): PinnedCopyRoot {
const requestedPath = path.resolve(rootPath);
const canonicalPath = fs.realpathSync.native(requestedPath);
const identity = statIdentity(canonicalPath);

if (!identity.directory) {
throw containmentError('', `${label} root is not a directory`);
}

const pinnedRoot = {
requestedPath,
canonicalPath,
device: identity.device,
inode: identity.inode,
};

if (
expectedRoot &&
(expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
expectedRoot.device !== pinnedRoot.device ||
expectedRoot.inode !== pinnedRoot.inode)
) {
throw containmentError('', `${label} root changed after it was pinned`);
}

return pinnedRoot;
}

function assertPinnedRoot(root: PinnedCopyRoot, label: string, relativePath: string): void {
let identity: PathIdentity;
try {
identity = statIdentity(root.canonicalPath);
} catch (error: unknown) {
throw containmentError(relativePath, `${label} root can no longer be resolved`, error);
}

if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
throw containmentError(relativePath, `${label} root changed after it was pinned`);
}
}

export function validateRelativePath(relativePath: unknown, pathApi: CopyPathApi = path): string {
if (typeof relativePath !== 'string' || relativePath.length === 0) {
throw containmentError(relativePath, 'path must be a non-empty relative string');
}
if (relativePath.includes('\0')) {
throw containmentError(relativePath, 'path contains a null byte');
}
if (pathApi.isAbsolute(relativePath) || pathApi.parse(relativePath).root) {
throw containmentError(relativePath, 'absolute paths are not allowed');
}

const components =
pathApi.sep === '\\' ? relativePath.split(/[\\/]/) : relativePath.split(pathApi.sep);
if (components.some((component) => component === '' || component === '.' || component === '..')) {
throw containmentError(
relativePath,
'empty, current-directory, and traversal components are not allowed'
);
}

return pathApi.normalize(relativePath);
}

export function resolveSourcePath(boundary: CopyBoundary, relativePath: string): string {
const normalizedPath = validateRelativePath(relativePath);
const root = boundary.sourceRoot;
assertPinnedRoot(root, 'source', relativePath);

const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
if (!isContained(root.canonicalPath, candidatePath)) {
throw containmentError(relativePath, 'source path escapes its pinned root');
}

let canonicalPath: string;
try {
canonicalPath = fs.realpathSync.native(candidatePath);
} catch (error: unknown) {
if (copyErrorCode(error) === 'ELOOP') {
throw containmentError(relativePath, 'source path contains a symlink cycle', error);
}
throw error;
}

if (!isContained(root.canonicalPath, canonicalPath)) {
throw containmentError(relativePath, 'resolved source path escapes its pinned root');
}
return canonicalPath;
}

function resolveDestinationPath(boundary: CopyBoundary, relativePath: string): string {
const normalizedPath = validateRelativePath(relativePath);
const root = boundary.destinationRoot;
assertPinnedRoot(root, 'destination', relativePath);

const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
if (!isContained(root.canonicalPath, candidatePath)) {
throw containmentError(relativePath, 'destination path escapes its pinned root');
}

let existingPath: string;
try {
fs.lstatSync(candidatePath);
existingPath = candidatePath;
} catch (error: unknown) {
if (copyErrorCode(error) !== 'ENOENT') {
throw error;
}
// The copy pipeline creates directories parent-first in phase two, so the
// immediate parent must exist before any mkdir/copy effect is attempted.
existingPath = path.dirname(candidatePath);
}

let canonicalExistingPath: string;
try {
canonicalExistingPath = fs.realpathSync.native(existingPath);
} catch (error: unknown) {
throw containmentError(relativePath, 'destination contains an unresolved symlink', error);
}

if (!isContained(root.canonicalPath, canonicalExistingPath)) {
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
}

const unresolvedSuffix = path.relative(existingPath, candidatePath);
const resolvedPath = unresolvedSuffix
? path.join(canonicalExistingPath, unresolvedSuffix)
: canonicalExistingPath;
if (!isContained(root.canonicalPath, resolvedPath)) {
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
}
return resolvedPath;
}

export function createCopyBoundary(
sourceBase: string,
destinationBase: string,
expectedBoundary?: CopyBoundary
): CopyBoundary {
return {
sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
};
}

export function resolveCopyPath(
boundary: CopyBoundary,
relativePath: string
): { sourcePath: string; destinationPath: string } {
return {
sourcePath: resolveSourcePath(boundary, relativePath),
destinationPath: resolveDestinationPath(boundary, relativePath),
};
}

export function isCopyContainmentError(error: unknown): boolean {
return isCopyRecord(error) && error.code === CONTAINMENT_ERROR_CODE;
}

interface CopyErrorPayload {
code?: string | null;
relativePath?: unknown;
name?: string;
message: string;
}

export function copyErrorFromPayload(payload: CopyErrorPayload): Error {
const error =
payload.code === CONTAINMENT_ERROR_CODE
? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
: new Error(payload.message);
error.name = payload.name || error.name;
error.message = payload.message;
if (payload.code && !(error instanceof CopyContainmentError)) {
Object.assign(error, { code: payload.code });
}
return error;
}
Loading
Loading