Files
stack/apps/gateway/src/agent/tools/path-guard.test.ts
T
marcieandorch-01 736b0affc1
ci/woodpecker/push/publish Pipeline was successful
compose: add wrapper-first dogfood workspace (#1488)
Co-authored-by: marcie <[email protected]>
2026-08-30 22:29:30 +00:00

156 lines
5.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { guardPath, guardPathUnsafe, guardWritePath, SandboxEscapeError } from './path-guard.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
describe('guardPathUnsafe', () => {
const sandbox = '/tmp/test-sandbox';
it('allows paths inside sandbox', () => {
const result = guardPathUnsafe('foo/bar.txt', sandbox);
expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt'));
});
it('allows sandbox root itself', () => {
const result = guardPathUnsafe('.', sandbox);
expect(result).toBe(path.resolve(sandbox));
});
it('rejects path traversal with ../', () => {
expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError);
});
it('rejects absolute path outside sandbox', () => {
expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError);
});
it('rejects deeply nested traversal', () => {
expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow(
SandboxEscapeError,
);
});
it('rejects path that starts with sandbox name but is sibling', () => {
expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow(
SandboxEscapeError,
);
});
it('returns the resolved absolute path for nested paths', () => {
const result = guardPathUnsafe('deep/nested/file.ts', sandbox);
expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts');
});
it('SandboxEscapeError includes the user path and sandbox in message', () => {
let caught: unknown;
try {
guardPathUnsafe('../escape.txt', sandbox);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SandboxEscapeError);
const e = caught as SandboxEscapeError;
expect(e.userPath).toBe('../escape.txt');
expect(e.sandboxDir).toBe(sandbox);
expect(e.message).toContain('Path escape attempt blocked');
});
});
describe('guardPath', () => {
let tmpDir: string;
it('allows an existing path inside a real temp sandbox', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
try {
const subdir = path.join(tmpDir, 'subdir');
fs.mkdirSync(subdir);
const result = guardPath('subdir', tmpDir);
expect(result).toBe(subdir);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('allows sandbox root itself', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
try {
const result = guardPath('.', tmpDir);
// realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var)
const realTmp = fs.realpathSync.native(tmpDir);
expect(result).toBe(realTmp);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects path traversal with ../ on existing sandbox', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
try {
expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects absolute path outside sandbox', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
try {
expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('rejects a symlink inside the sandbox that resolves outside it', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_SYMLINK_SENTINEL');
fs.symlinkSync(target, path.join(tmpDir, 'credential.token'));
expect(() => guardPath('credential.token', tmpDir)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
describe('guardWritePath', () => {
it('allows a new file under an existing real sandbox directory', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
try {
expect(guardWritePath('new.txt', sandbox)).toBe(path.join(sandbox, 'new.txt'));
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
});
it('rejects writes through a file symlink that resolves outside the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
const target = path.join(outside, 'credential.token');
fs.writeFileSync(target, 'OUTSIDE_WRITE_SENTINEL');
fs.symlinkSync(target, path.join(sandbox, 'credential.token'));
expect(() => guardWritePath('credential.token', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it('rejects new files under a directory symlink that leaves the sandbox', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-test-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-write-outside-'));
try {
fs.symlinkSync(outside, path.join(sandbox, 'outside'));
expect(() => guardWritePath('outside/new.txt', sandbox)).toThrow(SandboxEscapeError);
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});