397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* PreToolUse hook — git-safety header inject for Task tool + subagent gate inheritance.
|
||
*
|
||
* For каждый Task-инвокейшн контроллера: дописывает в начало tool_input.prompt
|
||
* заголовок с cwd / branch / parent SHA / worktree-root + правилами поведения
|
||
* (rule 1–5). Это компенсирует класс инцидентов «субагент путает ветку/worktree»
|
||
* (Sprint 6, Pravila §15.1).
|
||
*
|
||
* v4 Stream E (§3.2): дополнительно пишет subagent inheritance-файл + 256-bit
|
||
* parent sentinel в ~/.claude/runtime/restricted/ и сообщает inheritance env vars
|
||
* в инжектируемом заголовке. Всё best-effort — fail-open.
|
||
*
|
||
* FAIL-OPEN: любая ошибка / тайм-аут / git-не-в-PATH → {continue: true} без
|
||
* модификации; хук НИКОГДА не блокирует Task.
|
||
*
|
||
* Non-Task tools — pass-through без модификации.
|
||
*
|
||
* Spec: docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md §4
|
||
* docs/superpowers/specs/2026-05-29-router-gate-v4-design.md §3.2
|
||
* Rule: docs/Pravila_raboty_Claude_v1_1.md §15.1
|
||
*/
|
||
import { execFile } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { homedir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
|
||
const execFileP = promisify(execFile);
|
||
const GIT_TIMEOUT_MS = 1500;
|
||
|
||
/** Runtime dir (~/.claude/runtime). */
|
||
function runtimeDir() {
|
||
return join(homedir(), '.claude', 'runtime');
|
||
}
|
||
|
||
/** 256-bit random hex id for the parent sentinel (§3.2). */
|
||
export function generateParentRandomId() {
|
||
return randomBytes(32).toString('hex');
|
||
}
|
||
|
||
/** Path to the per-Task inheritance file. */
|
||
export function inheritanceFilePath(toolUseId) {
|
||
return join(runtimeDir(), `subagent-inheritance-${toolUseId || 'unknown'}.json`);
|
||
}
|
||
|
||
/** Path to the parent sentinel (restricted/ — Read+Edit blocked per §3.1). */
|
||
export function parentSentinelPath(parentRandomId) {
|
||
return join(runtimeDir(), 'restricted', `parent-sentinel-${parentRandomId || 'unknown'}.json`);
|
||
}
|
||
|
||
/** Path to the subagent block-file (restricted/ — S5 side-channel, §3.4). */
|
||
export function subagentBlockPath(toolUseId) {
|
||
return join(runtimeDir(), 'restricted', `subagent-block-${toolUseId || 'unknown'}.json`);
|
||
}
|
||
|
||
/** Build the subagent inheritance record (schema_version 3, §3.2). */
|
||
export function buildInheritanceRecord({ parentSessionId, parentRandomId, allowedActions, nowIso }) {
|
||
return {
|
||
schema_version: 3,
|
||
parent_session_id: parentSessionId || 'unknown',
|
||
parent_random_id: parentRandomId || '',
|
||
allowed_actions: Array.isArray(allowedActions) ? allowedActions : [],
|
||
subagent_constraints: {
|
||
can_use_askuser: false,
|
||
can_spawn_task: false,
|
||
max_parallel: 1,
|
||
},
|
||
created_at: nowIso || new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
/** Build inheritance env vars passed to the subagent (§3.2 step 2). */
|
||
export function buildInheritanceEnv({ parentSessionId, inheritanceFile }) {
|
||
return {
|
||
CLAUDE_PARENT_SESSION_ID: parentSessionId || 'unknown',
|
||
CLAUDE_GATE_INHERIT: 'true',
|
||
CLAUDE_INHERITANCE_FILE: inheritanceFile || '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Stream H Task 10 — pure helper: detect if cwd is inside a linked worktree.
|
||
*
|
||
* Logic: when git's per-worktree dir (`git rev-parse --git-dir`) differs from
|
||
* the shared common dir (`git rev-parse --git-common-dir`), we're in a
|
||
* linked worktree. The parent repo root is the parent directory of the
|
||
* common .git dir (with separators normalized to forward slashes).
|
||
*
|
||
* @param {object} args
|
||
* @param {string} [args.cwd]
|
||
* @param {string} [args.gitDir] - `git rev-parse --git-dir` output
|
||
* @param {string} [args.gitCommonDir] - `git rev-parse --git-common-dir` output
|
||
* @returns {{isWorktree: boolean, parentRepoRoot: string|null}}
|
||
*/
|
||
export function detectWorktreeMode({ cwd, gitDir, gitCommonDir } = {}) {
|
||
const norm = (s) => (typeof s === 'string' ? s.replace(/\\/g, '/') : null);
|
||
const gd = norm(gitDir);
|
||
const gcd = norm(gitCommonDir);
|
||
if (!gd || !gcd || gd === gcd) return { isWorktree: false, parentRepoRoot: null };
|
||
// Common dir looks like `<parent>/.git` — strip the trailing `/.git` segment.
|
||
const parent = gcd.replace(/\/\.git\/?$/, '');
|
||
if (!parent || parent === gcd) return { isWorktree: false, parentRepoRoot: null };
|
||
return { isWorktree: true, parentRepoRoot: parent };
|
||
}
|
||
|
||
/**
|
||
* Stream H Task 10 — pure helper: build SETUP — worktree bootstrap text for
|
||
* the injected subagent header. Empty string when not in a worktree OR
|
||
* parentRepoRoot is missing. Per memory `feedback_subagent_worktree_bootstrap.md`.
|
||
*
|
||
* @param {object} args
|
||
* @param {boolean} args.isWorktree
|
||
* @param {string|null} args.parentRepoRoot
|
||
* @param {string} [args.platform] - default process.platform; 'win32' uses mklink, others use ln -s
|
||
* @returns {string} block text (joined with \n) or '' to omit
|
||
*/
|
||
export function buildSetupBlock({ isWorktree, parentRepoRoot, platform = process.platform } = {}) {
|
||
if (!isWorktree || !parentRepoRoot) return '';
|
||
const isWin = platform === 'win32';
|
||
const symlinkCmd = isWin
|
||
? `mklink /D vendor "${parentRepoRoot}/app/vendor"`
|
||
: `ln -s "${parentRepoRoot}/app/vendor" vendor`;
|
||
return [
|
||
'',
|
||
'SETUP — worktree bootstrap (run inside app/ before composer/pest if needed):',
|
||
` 1. ${symlinkCmd}`,
|
||
' 2. mkdir -p storage/framework/{cache,sessions,views,testing}',
|
||
' 3. Re-run pest / composer commands; Eloquent facade and view cache paths now resolve.',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/** Read all stdin into a string. */
|
||
async function readStdin() {
|
||
let buf = '';
|
||
for await (const chunk of process.stdin) buf += chunk;
|
||
return buf;
|
||
}
|
||
|
||
/** Parse hook input JSON; return null on any parse error. */
|
||
function parseHookInput(raw) {
|
||
try { return JSON.parse(raw); } catch { return null; }
|
||
}
|
||
|
||
/** Run a single fixed-args git command; return trimmed stdout, or null on any error. */
|
||
async function gitCmd(args) {
|
||
try {
|
||
const { stdout } = await execFileP('git', args, { timeout: GIT_TIMEOUT_MS, encoding: 'utf8' });
|
||
return stdout.trim();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Build the safety header block. Returns null if any of the 4 git values can't be resolved. */
|
||
async function buildHeader() {
|
||
const cwd = process.cwd();
|
||
const [branch, head, top, gitDir, gitCommonDir] = await Promise.all([
|
||
gitCmd(['branch', '--show-current']),
|
||
gitCmd(['rev-parse', 'HEAD']),
|
||
gitCmd(['rev-parse', '--show-toplevel']),
|
||
gitCmd(['rev-parse', '--git-dir']),
|
||
gitCmd(['rev-parse', '--git-common-dir']),
|
||
]);
|
||
if (!branch || !head || !top) return null;
|
||
const worktreeInfo = detectWorktreeMode({ cwd, gitDir, gitCommonDir });
|
||
const setupBlock = buildSetupBlock(worktreeInfo);
|
||
return [
|
||
'=== SUBAGENT GIT-SAFETY HEADER (Pravila §15.1, auto-injected) ===',
|
||
`Working directory (cwd): ${cwd}`,
|
||
`Branch (git branch --show-current): ${branch}`,
|
||
`Parent commit (git rev-parse HEAD): ${head}`,
|
||
`Worktree root (git rev-parse --show-toplevel): ${top}`,
|
||
'',
|
||
'ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА:',
|
||
'1. Все git-операции выполняй ТОЛЬКО внутри cwd выше. Если pwd ≠ cwd — STOP, верни ошибку.',
|
||
'2. ЗАПРЕЩЕНО: `git checkout <branch>`, `git switch <branch>`, `git checkout -b`, `git branch -m`, `git worktree add/remove`, `git push --force`, `git reset --hard`.',
|
||
'3. После КАЖДОГО `git commit` — выполни `git rev-parse HEAD` и `git branch --show-current`, выпиши результат в ответ. Это обязательная часть отчёта.',
|
||
'4. Если обнаружил, что находишься не в той ветке/worktree — STOP, не правь ничего, верни ошибку с raw output `git status` и `git branch --show-current`.',
|
||
'5. Если задача не требует git-операций (только Edit/Read/Grep) — этот блок информационный, follow rule 1.',
|
||
setupBlock,
|
||
'=== END SUBAGENT GIT-SAFETY HEADER ===',
|
||
'',
|
||
].join('\n');
|
||
}
|
||
|
||
/** Write inheritance file + parent sentinel (best-effort). Returns env note string or ''. */
|
||
function writeInheritanceArtifacts(input) {
|
||
try {
|
||
const toolUseId = input.tool_use_id || input.toolUseId || `task-${Date.now()}`;
|
||
const parentSessionId = input.session_id || process.env.CLAUDE_SESSION_ID || 'unknown';
|
||
const parentRandomId = generateParentRandomId();
|
||
mkdirSync(join(runtimeDir(), 'restricted'), { recursive: true });
|
||
|
||
const infFile = inheritanceFilePath(toolUseId);
|
||
const record = buildInheritanceRecord({ parentSessionId, parentRandomId });
|
||
writeFileSync(infFile, JSON.stringify(record, null, 2));
|
||
writeFileSync(parentSentinelPath(parentRandomId), JSON.stringify({
|
||
parent_session_id: parentSessionId,
|
||
created_at: record.created_at,
|
||
}, null, 2));
|
||
|
||
const env = buildInheritanceEnv({ parentSessionId, inheritanceFile: infFile });
|
||
return [
|
||
'',
|
||
'GATE INHERITANCE (router-gate v4 §3.2):',
|
||
` CLAUDE_GATE_INHERIT=${env.CLAUDE_GATE_INHERIT}`,
|
||
` CLAUDE_PARENT_SESSION_ID=${env.CLAUDE_PARENT_SESSION_ID}`,
|
||
` CLAUDE_INHERITANCE_FILE=${env.CLAUDE_INHERITANCE_FILE}`,
|
||
'',
|
||
].join('\n');
|
||
} catch {
|
||
return ''; // fail-open: inheritance write is best-effort
|
||
}
|
||
}
|
||
|
||
/** Emit fail-open response and exit 0. */
|
||
function failOpen() {
|
||
process.stdout.write(JSON.stringify({ continue: true }));
|
||
process.exit(0);
|
||
}
|
||
|
||
async function main() {
|
||
const raw = await readStdin();
|
||
const input = parseHookInput(raw);
|
||
if (!input) return failOpen();
|
||
|
||
// Non-Task — pass-through
|
||
if (input.tool_name !== 'Task') return failOpen();
|
||
|
||
const originalPrompt = input.tool_input?.prompt;
|
||
if (typeof originalPrompt !== 'string') return failOpen();
|
||
|
||
const header = await buildHeader();
|
||
if (!header) {
|
||
// git unavailable / not in worktree — fail-open per spec §4.5 edge-case 3
|
||
process.stderr.write('[subagent-prompt-prefix] git resolution failed — passing through\n');
|
||
return failOpen();
|
||
}
|
||
|
||
const inheritanceEnvNote = writeInheritanceArtifacts(input);
|
||
const newPrompt = header + inheritanceEnvNote + originalPrompt;
|
||
process.stdout.write(JSON.stringify({
|
||
hookSpecificOutput: {
|
||
hookEventName: 'PreToolUse',
|
||
permissionDecision: 'allow',
|
||
permissionDecisionReason: 'git-safety header injected (Pravila §15.1)',
|
||
updatedInput: { prompt: newPrompt },
|
||
},
|
||
}));
|
||
}
|
||
|
||
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/subagent-prompt-prefix.mjs');
|
||
if (isCli) main().catch(() => failOpen());
|