2026-06-15 08:06:08 +03:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
/**
|
|
|
|
|
* PostToolUse(AskUserQuestion) wrapper — schema bridge between Stream E
|
|
|
|
|
* pure parser (askuser-answer-parser.mjs::toApprovalRecord) and Stream B
|
|
|
|
|
* approval reader (shell-content-rules.mjs::loadApprovedGitOps).
|
|
|
|
|
*
|
|
|
|
|
* For each question/answer pair: if the answer matches a git pattern,
|
|
|
|
|
* append an approve_git_operation record to
|
|
|
|
|
* ~/.claude/runtime/askuser-decisions-<sess>.jsonl.
|
|
|
|
|
*
|
|
|
|
|
* Fail-open observability (never blocks AskUserQuestion).
|
|
|
|
|
*
|
|
|
|
|
* Stream H Task 6 — retires the manual approval-write workaround used by
|
|
|
|
|
* the controller throughout Stream H Tasks 1-5.
|
|
|
|
|
*/
|
|
|
|
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
|
|
import { homedir } from 'node:os';
|
|
|
|
|
import { join, dirname } from 'node:path';
|
2026-06-18 19:54:25 +03:00
|
|
|
import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord, answerMatchesOption, detectOtherSocialEng } from './askuser-answer-parser.mjs';
|
|
|
|
|
import { readTranscript, lastAssistantText } from './enforce-hook-helpers.mjs';
|
2026-06-15 08:06:08 +03:00
|
|
|
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Pure event processor for test-injection of runtimeDir + nowMs.
|
|
|
|
|
*
|
|
|
|
|
* @param {object} event - PostToolUse payload {session_id, tool_input, tool_response}
|
|
|
|
|
* @param {object} [opts]
|
|
|
|
|
* @param {string} [opts.runtimeDir] - override default ~/.claude/runtime
|
|
|
|
|
* @param {number} [opts.nowMs] - override timestamp for test determinism
|
|
|
|
|
*/
|
2026-06-18 19:54:25 +03:00
|
|
|
export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync }, controllerText } = {}) {
|
2026-06-15 08:06:08 +03:00
|
|
|
try {
|
|
|
|
|
const sessionId = event && event.session_id;
|
|
|
|
|
const toolInput = event && event.tool_input;
|
|
|
|
|
const toolResponse = event && event.tool_response;
|
|
|
|
|
if (!sessionId || !toolInput || !toolResponse) return;
|
|
|
|
|
|
|
|
|
|
const questions = toolInput.questions || [];
|
|
|
|
|
const answers = toolResponse.answers || {};
|
|
|
|
|
// M6 FIX-5: резолв ключа подписи один раз на событие (fail-safe — ошибка резолва
|
|
|
|
|
// → key=null → floor_escape пишется неподписанным, PostToolUse-наблюдаемость цела).
|
|
|
|
|
let key = null; try { key = keyImpl(); } catch { key = null; }
|
2026-06-18 19:54:25 +03:00
|
|
|
// C (анти-диктовка): текст контроллера ТЕКУЩЕГО хода → детект «впиши метку» (detectOtherSocialEng).
|
|
|
|
|
// flagged → свободный floor_escape не доверяем (ниже esc=null). Источник — транскрипт хода
|
|
|
|
|
// (lastAssistantText), controllerText инъектируем для тестов. fail-open: сбой → не флагуем.
|
|
|
|
|
let dictation = { flagged: false };
|
|
|
|
|
try {
|
|
|
|
|
const ct = typeof controllerText === 'string' ? controllerText
|
|
|
|
|
: lastAssistantText(readTranscript(event && event.transcript_path));
|
|
|
|
|
dictation = detectOtherSocialEng(ct);
|
|
|
|
|
} catch { dictation = { flagged: false }; }
|
2026-06-15 08:06:08 +03:00
|
|
|
|
|
|
|
|
const dir = runtimeDir || join(homedir(), '.claude', 'runtime');
|
|
|
|
|
const path = join(dir, `askuser-decisions-${sessionId}.jsonl`);
|
|
|
|
|
|
|
|
|
|
let wroteAny = false;
|
|
|
|
|
for (const q of questions) {
|
|
|
|
|
if (!q || !q.question) continue;
|
|
|
|
|
const ans = answers[q.question];
|
|
|
|
|
if (!ans) continue;
|
2026-06-18 17:43:58 +03:00
|
|
|
// Anti-button (спека §A, HOLE-1): ответ, дословно совпавший с ярлыком контроллерской
|
|
|
|
|
// кнопки, доверенную запись НЕ порождает — среда может вернуть кнопку без действия
|
|
|
|
|
// владельца. Согласие только из свободного ввода владельца. Покрывает ОБА парсера ниже.
|
|
|
|
|
const optionLabels = Array.isArray(q.options) ? q.options.map((o) => o && o.label) : [];
|
|
|
|
|
if (answerMatchesOption(ans, optionLabels)) continue;
|
2026-06-15 08:06:08 +03:00
|
|
|
const rec = toApprovalRecord(ans, { question: q.question, nowMs });
|
|
|
|
|
// M6 FIX-5: подписываем ТОЛЬКО floor_escape (§2.2 — approve_git_operation не трогаем).
|
|
|
|
|
let esc = toFloorEscapeRecord(ans, { nowMs });
|
2026-06-18 19:54:25 +03:00
|
|
|
// C (анти-диктовка): контроллер инструктировал вписать метку → свободному floor_escape не доверяем.
|
|
|
|
|
if (esc && dictation.flagged) esc = null;
|
2026-06-15 08:06:08 +03:00
|
|
|
if (esc && key) esc = signFloorEscapeRecord(esc, key);
|
|
|
|
|
for (const out of [rec, esc]) {
|
|
|
|
|
if (!out) continue;
|
|
|
|
|
if (!wroteAny) {
|
|
|
|
|
try { fsImpl.mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ }
|
|
|
|
|
wroteAny = true;
|
|
|
|
|
}
|
|
|
|
|
try { fsImpl.appendFileSync(path, JSON.stringify(out) + '\n'); } catch { /* fail-open */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// fail-open observability — never throw from PostToolUse handler
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
let input = '';
|
|
|
|
|
for await (const chunk of process.stdin) input += chunk;
|
|
|
|
|
let payload;
|
|
|
|
|
try { payload = JSON.parse(input); } catch { return; }
|
|
|
|
|
processEvent(payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-askuser-answer-parser.mjs')) {
|
|
|
|
|
main().catch(() => process.exit(0)); // fail-open observability
|
|
|
|
|
}
|