128 lines
4.6 KiB
JavaScript
128 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PreToolUse hook — BYAN Strict Mode scope guard.
|
|
*
|
|
* When a strict session is engaged and the locked scope declares allowed
|
|
* paths, deny Write/Edit calls that target a file outside those paths. This
|
|
* keeps the agent inside the contract it locked : it cannot silently spread
|
|
* changes across the repo under the cover of the locked task.
|
|
*
|
|
* Exempt paths (the strict bookkeeping, build output, git) are always
|
|
* allowed. If enforce_paths is off or no allowed paths were declared, every
|
|
* write is allowed.
|
|
*
|
|
* Non-blocking on parse error.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const { loadConfig, loadState, isEngaged, projectRoot, readStdin, parseJson } =
|
|
require('./lib/strict-runtime');
|
|
|
|
function toRelative(filePath, root) {
|
|
if (!filePath) return '';
|
|
const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
const rel = path.relative(root, abs);
|
|
return rel.split(path.sep).join('/');
|
|
}
|
|
|
|
function matchesPrefix(rel, prefix) {
|
|
let p = String(prefix).trim();
|
|
// Glob-tolerant: reduce a glob to the literal part before the first wildcard,
|
|
// then prefix-match. So "_byan/**" and "src/**/*.test.js" match their subtree
|
|
// instead of being compared as a literal string (which never matched, wrongly
|
|
// denying every write under a globbed allowed path).
|
|
const star = p.indexOf('*');
|
|
|
|
// No wildcard: exact match or directory-prefix match.
|
|
if (star === -1) {
|
|
p = p.replace(/\/+$/, '');
|
|
if (p === '') return true;
|
|
return rel === p || rel.startsWith(p + '/');
|
|
}
|
|
|
|
// A wildcard whose preceding char is NOT "/" sits INSIDE a path segment
|
|
// (e.g. ".claude/skills/byan-*/**"). The literal lead before it must match as
|
|
// a raw prefix, with no "/" boundary forced after it - otherwise
|
|
// ".claude/skills/byan-native-dev-story/..." is wrongly denied because it does
|
|
// not start with ".claude/skills/byan-/".
|
|
const midSegment = star > 0 && p[star - 1] !== '/';
|
|
p = p.slice(0, star);
|
|
if (p === '') return true; // bare "*" / "**" -> matches everything
|
|
if (midSegment) return rel.startsWith(p);
|
|
|
|
// Directory-boundary wildcard (e.g. "_byan/**"): reduce to the dir and match
|
|
// exact-or-subtree so "_byan/**" matches "_byan/x" but not "_byanX".
|
|
p = p.replace(/\/+$/, '');
|
|
if (p === '') return true;
|
|
return rel === p || rel.startsWith(p + '/');
|
|
}
|
|
|
|
// Pure decision : returns { deny, reason }.
|
|
function decideScope({ state, config, toolName, filePath }) {
|
|
if (!['Write', 'Edit'].includes(toolName)) return { deny: false };
|
|
if (!isEngaged(state)) return { deny: false };
|
|
|
|
const guard = (config && config.scope_guard) || {};
|
|
if (!guard.enforce_paths) return { deny: false };
|
|
|
|
const allowed = (state.scope_lock && state.scope_lock.allowed_paths) || [];
|
|
if (!Array.isArray(allowed) || allowed.length === 0) return { deny: false };
|
|
|
|
const root = projectRoot();
|
|
const rel = toRelative(filePath, root);
|
|
if (!rel) return { deny: false };
|
|
|
|
const exempt = guard.exempt_globs || [];
|
|
if (exempt.some((g) => matchesPrefix(rel, g))) return { deny: false };
|
|
|
|
if (allowed.some((a) => matchesPrefix(rel, a))) return { deny: false };
|
|
|
|
const base =
|
|
(config && config.banners && config.banners.scope_deny) ||
|
|
'Strict mode: this write targets a path outside the locked scope.';
|
|
const reason =
|
|
`${base}\n` +
|
|
`Target: ${rel}\n` +
|
|
`Locked paths: ${allowed.join(', ')}\n` +
|
|
`Either this file belongs to the scope (re-lock with byan_strict_lock_scope ` +
|
|
`including the corrected paths) or it does not (do not write it).`;
|
|
|
|
return { deny: true, reason };
|
|
}
|
|
|
|
function allow() {
|
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' } };
|
|
}
|
|
|
|
if (require.main === module) {
|
|
(async () => {
|
|
const state = loadState();
|
|
if (!isEngaged(state)) {
|
|
process.stdout.write(JSON.stringify(allow()));
|
|
process.exit(0);
|
|
}
|
|
const config = loadConfig();
|
|
const payload = parseJson(await readStdin());
|
|
const toolName = payload.tool_name || payload.toolName || '';
|
|
const input = payload.tool_input || payload.toolInput || {};
|
|
const filePath = input.file_path || '';
|
|
|
|
const decision = decideScope({ state, config, toolName, filePath });
|
|
if (!decision.deny) {
|
|
process.stdout.write(JSON.stringify(allow()));
|
|
process.exit(0);
|
|
}
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
permissionDecision: 'deny',
|
|
permissionDecisionReason: decision.reason,
|
|
},
|
|
})
|
|
);
|
|
process.exit(0);
|
|
})();
|
|
}
|
|
|
|
module.exports = { decideScope, toRelative, matchesPrefix };
|