mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
- Ask class-specific first-touch questions (instruction, test, prose, config, code); code questions are unchanged. - Credit a completed Glob/Grep/LS or shell search from the current human turn that covered the target; Read and exclusion globs never count. - Collapse same-class new-file creations in one directory within a turn. - Add opt-in GATEGUARD_FACT_FORCE_MAX_DENIALS session cap (from #2755). - Always gate sensitive targets, judged on lexical and real paths. - Canonicalize checked-path keys. Closes #2608 Co-authored-by: SulimanAbdulrazzaq <suliman9000a@gmail.com>
456 lines
22 KiB
JavaScript
456 lines
22 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Tests for scripts/lib/gateguard-target-class.js: target classification,
|
|
* question tables, sensitive targets and sibling-collapse eligibility.
|
|
*
|
|
* Run with: node tests/lib/gateguard-target-class.test.js
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const libPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'gateguard-target-class.js');
|
|
const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js');
|
|
const {
|
|
CLASS_QUESTIONS,
|
|
CLASS_CONDENSED_HINTS,
|
|
COLLAPSIBLE_CLASSES,
|
|
canonicalPathKey,
|
|
classifyTarget,
|
|
classifyTargetFor,
|
|
isCollapsibleTarget,
|
|
collapseGateDir,
|
|
isSensitiveTarget,
|
|
isSensitiveTargetFor
|
|
} = require(libPath);
|
|
|
|
console.log('=== Testing gateguard-target-class.js ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function test(desc, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${desc}`);
|
|
passed++;
|
|
} catch (e) {
|
|
console.log(` ✗ ${desc}: ${e.message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function withProjectDir(value, fn) {
|
|
const saved = process.env.CLAUDE_PROJECT_DIR;
|
|
try {
|
|
if (value === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
|
else process.env.CLAUDE_PROJECT_DIR = value;
|
|
fn();
|
|
} finally {
|
|
if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
|
else process.env.CLAUDE_PROJECT_DIR = saved;
|
|
}
|
|
}
|
|
|
|
// ── classifyTarget ──
|
|
console.log('classifyTarget:');
|
|
|
|
test('classifyTarget table (normalized, lowercased, first match wins)', () => {
|
|
assert.strictEqual(typeof classifyTarget, 'function', 'classifyTarget is exported');
|
|
const cases = [
|
|
['C:\\Repo\\Skills\\Foo\\Notes.MD', 'instruction'],
|
|
['/repo/AGENTS.md', 'instruction'],
|
|
['/repo/commands/plan.md', 'instruction'],
|
|
['/repo/rules/common/x.txt', 'instruction'],
|
|
['/repo/hooks/hooks.json', 'config'],
|
|
['/repo/skills/x/helper.js', 'code'],
|
|
['/repo/tests/fixtures/readme.md', 'test'],
|
|
['/repo/pkg/foo_test.go', 'test'],
|
|
['/repo/test_x.py', 'test'],
|
|
['/repo/x_test.py', 'test'],
|
|
['/repo/src/Button.spec.tsx', 'test'],
|
|
['/repo/src/__tests__/a.js', 'test'],
|
|
['/repo/README.md', 'prose'],
|
|
['/repo/docs/a.rst', 'prose'],
|
|
['/repo/.github/workflows/ci.yml', 'config'],
|
|
['/repo/pyproject.toml', 'config'],
|
|
['/repo/.env', 'config'],
|
|
['/repo/src/main.go', 'code'],
|
|
['', 'code']
|
|
];
|
|
for (const [input, expected] of cases) {
|
|
assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`);
|
|
}
|
|
});
|
|
|
|
test('classifyTarget covers the extended instruction and config rules', () => {
|
|
const cases = [
|
|
['/repo/.cursor/rules/x.mdc', 'instruction'],
|
|
['/repo/docs/x.mdc', 'instruction'],
|
|
['/repo/.cursorrules', 'instruction'],
|
|
['/repo/.windsurfrules', 'instruction'],
|
|
['/repo/AGENT.md', 'instruction'],
|
|
['/repo/.github/copilot-instructions.md', 'instruction'],
|
|
['/repo/.github/instructions/py.instructions.md', 'instruction'],
|
|
['/repo/.github/notes.md', 'prose'],
|
|
['/repo/docs/instructions.md', 'prose'],
|
|
['/repo/.env', 'config'],
|
|
['/repo/.env.local', 'config'],
|
|
['/repo/.env.example.ts', 'code'],
|
|
['/repo/.env.config.js', 'code'],
|
|
['/repo/.envrc', 'code'],
|
|
['/repo/.mcp.json', 'config']
|
|
];
|
|
for (const [input, expected] of cases) {
|
|
assert.strictEqual(classifyTarget(input), expected, `${input} -> ${expected}`);
|
|
}
|
|
});
|
|
|
|
test('non-string input classifies as code', () => {
|
|
assert.strictEqual(classifyTarget(undefined), 'code');
|
|
assert.strictEqual(classifyTarget(null), 'code');
|
|
assert.strictEqual(classifyTarget(42), 'code');
|
|
});
|
|
|
|
// ── classifyTargetFor ──
|
|
console.log('\nclassifyTargetFor:');
|
|
|
|
test('classifyTargetFor classifies project-relative paths', () => {
|
|
assert.strictEqual(typeof classifyTargetFor, 'function', 'classifyTargetFor is exported');
|
|
const saved = process.env.CLAUDE_PROJECT_DIR;
|
|
try {
|
|
process.env.CLAUDE_PROJECT_DIR = '/work/tests/proj';
|
|
const data = { cwd: '/work/tests/proj' };
|
|
assert.strictEqual(classifyTargetFor('/work/tests/proj/src/a.py', data), 'code');
|
|
assert.strictEqual(classifyTargetFor('src/a.py', data), 'code', 'relative target');
|
|
assert.strictEqual(classifyTargetFor('/work/tests/proj/tests/a.py', data), 'test');
|
|
// No .git in the synthetic worktree, so the prefix stays and the path is under .claude.
|
|
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/docs/g.md', data), 'instruction', 'unverified worktree');
|
|
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/worktrees/w1/skills/x/SKILL.md', data), 'instruction');
|
|
assert.strictEqual(classifyTargetFor('/work/tests/proj/.claude/agents/a.md', data), 'instruction', 'not a worktree');
|
|
assert.strictEqual(classifyTargetFor('/elsewhere/tests/a.py', data), 'test', 'outside the root: absolute path');
|
|
assert.strictEqual(classifyTargetFor('/work/tests/project2/src/a.py', data), 'test', 'sibling dir is outside the root');
|
|
process.env.CLAUDE_PROJECT_DIR = 'C:\\Work\\Tests\\Proj';
|
|
assert.strictEqual(classifyTargetFor('src\\a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 relative');
|
|
assert.strictEqual(classifyTargetFor('c:/work/tests/proj/src/a.py', { cwd: 'C:\\Work\\Tests\\Proj' }), 'code', 'win32 case-folded');
|
|
delete process.env.CLAUDE_PROJECT_DIR;
|
|
assert.strictEqual(classifyTargetFor('src/a.py', { cwd: '/work/tests/proj' }), 'code', 'cwd fallback root');
|
|
} finally {
|
|
if (saved === undefined) delete process.env.CLAUDE_PROJECT_DIR;
|
|
else process.env.CLAUDE_PROJECT_DIR = saved;
|
|
}
|
|
});
|
|
|
|
// ── canonicalPathKey ──
|
|
console.log('\ncanonicalPathKey:');
|
|
|
|
test('resolves relative targets against the tool cwd and folds Windows keys', () => {
|
|
withProjectDir('/proj', () => {
|
|
assert.strictEqual(canonicalPathKey('src/a.py', { cwd: '/proj/sub' }), '/proj/sub/src/a.py');
|
|
assert.strictEqual(canonicalPathKey('./a.py', { cwd: '/proj' }), '/proj/a.py');
|
|
assert.strictEqual(canonicalPathKey('/abs/A.py', { cwd: '/proj' }), '/abs/A.py', 'posix keeps case');
|
|
});
|
|
withProjectDir(undefined, () => {
|
|
assert.strictEqual(canonicalPathKey('src\\A.py', { cwd: 'C:\\Proj' }), 'c:/proj/src/a.py');
|
|
assert.strictEqual(canonicalPathKey('a.py', { cwd: 'relative/base' }), 'a.py', 'non-absolute base: raw path');
|
|
});
|
|
});
|
|
|
|
// ── isCollapsibleTarget ──
|
|
console.log('\nisCollapsibleTarget:');
|
|
|
|
test('only code/test/prose outside dotfiles and harness dirs may collapse', () => {
|
|
assert.deepStrictEqual([...COLLAPSIBLE_CLASSES].sort(), ['code', 'prose', 'test']);
|
|
withProjectDir('/proj', () => {
|
|
const data = { cwd: '/proj' };
|
|
assert.strictEqual(isCollapsibleTarget('/proj/src/a.js', data, 'code'), true);
|
|
assert.strictEqual(isCollapsibleTarget('/proj/docs/a.md', data, 'prose'), true);
|
|
assert.strictEqual(isCollapsibleTarget('/proj/app.yaml', data, 'config'), false, 'config');
|
|
assert.strictEqual(isCollapsibleTarget('/proj/skills/x/SKILL.md', data, 'instruction'), false, 'instruction');
|
|
assert.strictEqual(isCollapsibleTarget('/proj/src/.hidden.js', data, 'code'), false, 'dotfile');
|
|
assert.strictEqual(isCollapsibleTarget('/proj/.github/scripts/a.js', data, 'code'), false, 'harness dir');
|
|
assert.strictEqual(isCollapsibleTarget('/proj/.husky/pre-commit.js', data, 'code'), false, 'hook dir');
|
|
});
|
|
});
|
|
|
|
test('a new file under any dot-directory never collapses', () => {
|
|
withProjectDir('/proj', () => {
|
|
const data = { cwd: '/proj' };
|
|
for (const rel of [
|
|
'.devcontainer/post-create.sh',
|
|
'.githooks/pre-commit',
|
|
'.kiro/steering/b.md',
|
|
'.clinerules/b.md',
|
|
'.idea/run.xml',
|
|
'.continue/prompts/b.prompt',
|
|
'src/.generated/a.js',
|
|
'.vscode/tasks.js'
|
|
]) {
|
|
assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, classifyTargetFor(`/proj/${rel}`, data)), false, rel);
|
|
assert.strictEqual(isCollapsibleTarget(`/proj/${rel}`, data, 'code'), false, `${rel} (as code)`);
|
|
}
|
|
assert.strictEqual(isCollapsibleTarget('/proj/src/gen/a.js', data, 'code'), true, 'control');
|
|
});
|
|
});
|
|
|
|
test('Windows name tricks: trailing dots/spaces, streams and 8.3 short names', () => {
|
|
withProjectDir(undefined, () => {
|
|
const data = { cwd: 'C:\\proj' };
|
|
for (const p of ['C:\\proj\\CLAUDE.md.', 'C:\\proj\\CLAUDE.md ', 'C:\\proj\\CLAUDE.md::$DATA', 'C:\\proj\\CLAUDE.md:x', 'C:\\proj\\CLAUDE.md. .::$DATA']) {
|
|
assert.strictEqual(classifyTargetFor(p, data), 'instruction', JSON.stringify(p));
|
|
}
|
|
assert.strictEqual(classifyTargetFor('C:\\proj\\.claude.\\hooks\\x.md', data), 'instruction', 'trailing dot on a directory');
|
|
assert.strictEqual(classifyTargetFor('C:\\proj\\src\\a.js', data), 'code', 'control');
|
|
const shortName = 'C:\\proj\\CLAUDE~1\\hooks\\x.ps1';
|
|
assert.strictEqual(isCollapsibleTarget(shortName, data, classifyTargetFor(shortName, data)), false, '8.3 short name');
|
|
assert.strictEqual(isCollapsibleTarget('C:\\proj\\src\\x.ps1', data, 'code'), true, 'control');
|
|
});
|
|
withProjectDir('/proj', () => {
|
|
const data = { cwd: '/proj' };
|
|
assert.strictEqual(classifyTargetFor('/proj/CLAUDE.md::$DATA', data), 'instruction', 'posix segment with ::$');
|
|
assert.strictEqual(classifyTargetFor('/proj/notes.md.', data), 'code', 'posix trailing dot is a real name');
|
|
});
|
|
});
|
|
|
|
// ── collapseGateDir (real directories) ──
|
|
console.log('\ncollapseGateDir:');
|
|
|
|
function tempProject() {
|
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-collapse-')));
|
|
fs.mkdirSync(path.join(root, 'src', 'real'), { recursive: true });
|
|
fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
|
|
return root;
|
|
}
|
|
|
|
function trySymlink(target, link) {
|
|
try {
|
|
fs.symlinkSync(target, link, 'dir');
|
|
return true;
|
|
} catch (e) {
|
|
if (process.platform === 'win32' && e.code === 'EPERM') return false;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
test('the worktree prefix is stripped only for a real worktree (.git present)', () => {
|
|
const root = tempProject();
|
|
try {
|
|
withProjectDir(root, () => {
|
|
const data = { cwd: root };
|
|
const wt = rel => path.join(root, '.claude', 'worktrees', 'w1', rel);
|
|
fs.mkdirSync(wt('docs'), { recursive: true });
|
|
assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'instruction', 'no .git: stays under .claude');
|
|
assert.strictEqual(classifyTargetFor(wt('src/a.py'), data), 'code');
|
|
assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), false, 'no .git: never collapses');
|
|
fs.writeFileSync(wt('.git'), 'gitdir: ../../../.git/worktrees/w1\n');
|
|
assert.strictEqual(classifyTargetFor(wt('docs/g.md'), data), 'prose', '.git file: worktree');
|
|
assert.strictEqual(isCollapsibleTarget(wt('src/a.py'), data, 'code'), true, '.git file: collapses like the project');
|
|
assert.strictEqual(isCollapsibleTarget(wt('.claude/hooks/x.sh'), data, 'code'), false, 'harness dir inside a worktree');
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('collapse is screened on the real directory (symlinks, missing parents, fs errors)', () => {
|
|
const root = tempProject();
|
|
try {
|
|
withProjectDir(root, () => {
|
|
const data = { cwd: root };
|
|
const at = rel => path.join(root, rel);
|
|
const key = p => (process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p);
|
|
assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'code'), key(at('src/real')), 'plain directory');
|
|
assert.strictEqual(collapseGateDir(at('src/new/deeper/a.js'), data, 'code'), key(at('src/new/deeper')), 'missing parents');
|
|
assert.strictEqual(collapseGateDir(at('.claude/hooks/a.sh'), data, 'code'), null, 'dot-directory');
|
|
assert.strictEqual(collapseGateDir(at('src/real/a.js'), data, 'config'), null, 'non-collapsible class');
|
|
fs.writeFileSync(at('src/file'), 'x');
|
|
assert.strictEqual(collapseGateDir(at('src/file/a.js'), data, 'code'), null, 'parent is a file (ENOTDIR)');
|
|
if (!trySymlink(at('.claude/hooks'), at('src/tools'))) return;
|
|
assert.strictEqual(collapseGateDir(at('src/tools/evil.sh'), data, 'code'), null, 'symlink into .claude/hooks');
|
|
assert.strictEqual(collapseGateDir(at('src/tools/sub/evil.sh'), data, 'code'), null, 'missing dir under that symlink');
|
|
trySymlink(at('src/real'), at('src/alias'));
|
|
assert.strictEqual(collapseGateDir(at('src/alias/b.js'), data, 'code'), key(at('src/real')), 'in-project symlink keys by the real dir');
|
|
trySymlink(at('tests'), at('src/t-alias'));
|
|
fs.mkdirSync(at('tests'), { recursive: true });
|
|
assert.strictEqual(collapseGateDir(at('src/t-alias/b.js'), data, 'code'), null, 'real location has another class');
|
|
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-outside-')));
|
|
try {
|
|
trySymlink(outside, at('src/out'));
|
|
assert.strictEqual(collapseGateDir(at('src/out/b.js'), data, 'code'), null, 'symlink out of the project');
|
|
} finally {
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
trySymlink(at('nowhere'), at('src/dangling'));
|
|
assert.strictEqual(collapseGateDir(at('src/dangling/b.js'), data, 'code'), null, 'dangling symlink');
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('a path of the other platform style is never collapsed', () => {
|
|
const other = process.platform === 'win32' ? '/proj' : 'C:\\proj';
|
|
withProjectDir(other, () => {
|
|
const target = process.platform === 'win32' ? '/proj/src/a.js' : 'C:\\proj\\src\\a.js';
|
|
assert.strictEqual(collapseGateDir(target, { cwd: other }, 'code'), null);
|
|
});
|
|
});
|
|
|
|
// ── question tables ──
|
|
console.log('\nquestion tables:');
|
|
|
|
test('every non-code class has questions and a condensed hint; code has neither', () => {
|
|
for (const cls of ['instruction', 'test', 'prose', 'config']) {
|
|
for (const isWrite of [true, false]) {
|
|
const questions = CLASS_QUESTIONS[cls](isWrite);
|
|
assert.ok(Array.isArray(questions) && questions.length >= 2, `${cls} questions`);
|
|
assert.ok(CLASS_CONDENSED_HINTS[cls](isWrite).endsWith('then retry.'), `${cls} hint`);
|
|
}
|
|
}
|
|
assert.strictEqual(CLASS_QUESTIONS.code, undefined);
|
|
assert.strictEqual(CLASS_CONDENSED_HINTS.code, undefined);
|
|
assert.notDeepStrictEqual(CLASS_QUESTIONS.prose(true), CLASS_QUESTIONS.prose(false), 'prose Write and Edit differ');
|
|
});
|
|
|
|
// ── isSensitiveTarget ──
|
|
|
|
test('isSensitiveTarget matches every listed basename, segment and prefix rule', () => {
|
|
const sensitive = [
|
|
'.env', '.env.local', '.env.production.local', 'config/.env', 'config/.env.test',
|
|
'certs/server.pem', 'certs/server.key', 'certs/client.p12', 'certs/client.pfx',
|
|
'home/id_rsa', 'home/id_rsa.pub', 'id_ed25519', 'home/id_ed25519.pub',
|
|
'credentials', 'config/credentials.json', 'aws/credentials', 'config/secrets.yaml', 'secrets.json',
|
|
'src/auth/login.py', 'src/authn/x.ts', 'src/authz/policy.go', 'lib/security/x.js',
|
|
'src/secrets/vault.py', 'src/payment/charge.py', 'src/payments/x.py', 'app/billing/invoice.rb',
|
|
'db/migrations/0001_init.sql', 'auth/readme.md', 'src/auth',
|
|
'.github/workflows/ci.yml', '.github/workflows/sub/x.yaml', './.github/workflows/ci.yml',
|
|
// Below an ancestor (a fake worktree prefix, or an absolute path outside the root).
|
|
'.claude/worktrees/fake/.github/workflows/ci.yml', '/elsewhere/repo/.github/workflows/ci.yml',
|
|
// Case-insensitive, and Windows-style separators.
|
|
'SRC/Auth/Login.py', '.ENV', 'Certs/Server.PEM', 'src\\billing\\x.py'
|
|
];
|
|
for (const p of sensitive) assert.strictEqual(isSensitiveTarget(p), true, p);
|
|
});
|
|
|
|
test('isSensitiveTarget is segment-exact and basename-exact (no substring matches)', () => {
|
|
const ordinary = [
|
|
'src/author.py', 'docs/authoring.md', 'lib/paymentutils.py', 'src/authentication/x.py',
|
|
'src/securityutils/x.py', 'src/billingreport.py', 'src/migration.py', 'src/environment.py',
|
|
'envoy.yaml', 'src/keyboard.py', 'src/key.py', 'src/secretsmanager.py', 'src/my_credentials.py',
|
|
'docs/workflows/ci.md', 'github/workflows/ci.yml', '.github/ci.yml', 'src/app.js', 'README.md', 'src/.envrc'
|
|
];
|
|
for (const p of ordinary) assert.strictEqual(isSensitiveTarget(p), false, p);
|
|
});
|
|
|
|
test('isSensitiveTarget fails safe (any error => sensitive)', () => {
|
|
const hostile = { toString() { throw new Error('boom'); } };
|
|
assert.strictEqual(isSensitiveTarget(hostile), true);
|
|
assert.strictEqual(isSensitiveTarget(Symbol('x')), true);
|
|
});
|
|
|
|
test('isSensitiveTargetFor uses the project-relative class path', () => {
|
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-sensitive-')));
|
|
try {
|
|
// The project itself sits under an ancestor named `security`: only project-relative segments count.
|
|
const proj = path.join(root, 'security', 'proj');
|
|
fs.mkdirSync(proj, { recursive: true });
|
|
withProjectDir(proj, () => {
|
|
const data = { cwd: proj };
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/src/app.py`, data), false, 'ancestor segment ignored');
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/src/auth/app.py`, data), true);
|
|
assert.strictEqual(isSensitiveTargetFor('src/billing/x.py', data), true, 'relative to cwd');
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/.github/workflows/ci.yml`, data), true);
|
|
// A real worktree mirrors the project: its prefix is stripped before the rules apply.
|
|
fs.mkdirSync(path.join(proj, '.claude', 'worktrees', 'w1'), { recursive: true });
|
|
fs.writeFileSync(path.join(proj, '.claude', 'worktrees', 'w1', '.git'), 'gitdir: x\n');
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/.github/workflows/ci.yml`, data), true);
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/.claude/worktrees/w1/src/app.py`, data), false);
|
|
// Windows spellings that name the same file are normalized first.
|
|
assert.strictEqual(isSensitiveTargetFor(`${proj}/.env.`, data), true);
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('isSensitiveTargetFor also judges the real (symlink-resolved) path', () => {
|
|
const root = tempProject();
|
|
const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-r1-outside-')));
|
|
try {
|
|
withProjectDir(root, () => {
|
|
const data = { cwd: root };
|
|
const at = rel => path.join(root, rel);
|
|
fs.mkdirSync(at('src/auth'), { recursive: true });
|
|
fs.writeFileSync(at('src/auth/login.py'), 'x');
|
|
if (!trySymlink(at('src/auth'), at('src/tools'))) return;
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/tools/login.py'), data), true, 'existing file through a dir symlink');
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/tools/new_handler.py'), data), true, 'new file through a dir symlink');
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/tools/deeper/x.py'), data), true, 'missing dir under the symlink');
|
|
assert.strictEqual(isSensitiveTargetFor('src/tools/login.py', data), true, 'relative spelling');
|
|
trySymlink(at('src/real'), at('src/alias'));
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/alias/a.py'), data), false, 'alias of an ordinary dir');
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/real/a.py'), data), false, 'direct ordinary path');
|
|
fs.mkdirSync(path.join(outside, 'secrets'), { recursive: true });
|
|
trySymlink(path.join(outside, 'secrets'), at('src/ext'));
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/ext/a.py'), data), true, 'real location outside the project is judged too');
|
|
trySymlink(outside, at('src/ext-plain'));
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/ext-plain/a.py'), data), false, 'ordinary location outside the project');
|
|
trySymlink(at('nowhere'), at('src/dangling'));
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/dangling/a.py'), data), true, 'dangling symlink fails closed');
|
|
fs.writeFileSync(at('src/plainfile'), 'x');
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/plainfile/a.py'), data), true, 'ENOTDIR fails closed');
|
|
try {
|
|
fs.symlinkSync(at('src/auth/login.py'), at('src/real/login_link.py'), 'file');
|
|
assert.strictEqual(isSensitiveTargetFor(at('src/real/login_link.py'), data), true, 'file symlink into auth/');
|
|
} catch (e) {
|
|
if (!(process.platform === 'win32' && e.code === 'EPERM')) throw e;
|
|
}
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('collapseGateDir never keys a gate for a sensitive real location', () => {
|
|
const root = tempProject();
|
|
try {
|
|
withProjectDir(root, () => {
|
|
const data = { cwd: root };
|
|
const at = rel => path.join(root, rel);
|
|
fs.mkdirSync(at('src/payments'), { recursive: true });
|
|
if (!trySymlink(at('src/payments'), at('src/tools'))) return;
|
|
assert.strictEqual(collapseGateDir(at('src/tools/a.py'), data, 'code'), null);
|
|
});
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('isSensitiveTargetFor fails safe on bad input', () => {
|
|
assert.strictEqual(isSensitiveTargetFor(undefined, undefined), true);
|
|
assert.strictEqual(isSensitiveTargetFor({ toString() { throw new Error('x'); } }, {}), true);
|
|
});
|
|
|
|
test('the hook re-exports the lib classification functions', () => {
|
|
// The hook prunes stale state files on load: point it at a scratch dir.
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-target-class-'));
|
|
const savedStateDir = process.env.GATEGUARD_STATE_DIR;
|
|
process.env.GATEGUARD_STATE_DIR = stateDir;
|
|
let hook;
|
|
try {
|
|
hook = require(hookPath);
|
|
} finally {
|
|
if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR;
|
|
else process.env.GATEGUARD_STATE_DIR = savedStateDir;
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
assert.strictEqual(hook.classifyTarget, classifyTarget);
|
|
assert.strictEqual(hook.classifyTargetFor, classifyTargetFor);
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|