@wooojin/forgen 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +94 -0
- package/README.ja.md +119 -8
- package/README.ko.md +73 -2
- package/README.md +163 -9
- package/README.zh.md +87 -7
- package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
- package/dist/checks/conclusion-verification-ratio.js +86 -0
- package/dist/checks/fact-vs-agreement.d.ts +47 -0
- package/dist/checks/fact-vs-agreement.js +92 -0
- package/dist/checks/self-score-deflation.d.ts +38 -0
- package/dist/checks/self-score-deflation.js +108 -0
- package/dist/cli.js +158 -6
- package/dist/core/auto-compound-runner.js +85 -13
- package/dist/core/dashboard.js +9 -2
- package/dist/core/doctor.js +90 -15
- package/dist/core/extraction-notice.d.ts +18 -0
- package/dist/core/extraction-notice.js +64 -0
- package/dist/core/init-cli.d.ts +26 -0
- package/dist/core/init-cli.js +104 -0
- package/dist/core/init.js +17 -0
- package/dist/core/inspect-cli.js +64 -5
- package/dist/core/migrate-cli.d.ts +10 -0
- package/dist/core/migrate-cli.js +34 -0
- package/dist/core/paths.d.ts +8 -1
- package/dist/core/paths.js +11 -2
- package/dist/core/recall-cli.d.ts +26 -0
- package/dist/core/recall-cli.js +125 -0
- package/dist/core/recall-reference-detector.d.ts +43 -0
- package/dist/core/recall-reference-detector.js +65 -0
- package/dist/core/state-gc.d.ts +19 -0
- package/dist/core/state-gc.js +48 -4
- package/dist/core/stats-cli.d.ts +36 -0
- package/dist/core/stats-cli.js +254 -0
- package/dist/core/uninstall.d.ts +1 -0
- package/dist/core/uninstall.js +25 -1
- package/dist/core/v1-bootstrap.js +9 -1
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/compound-cli.js +1 -0
- package/dist/engine/compound-export.js +8 -3
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -0
- package/dist/engine/learn-cli.js +1 -4
- package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
- package/dist/engine/lifecycle/bypass-detector.js +82 -0
- package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
- package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
- package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
- package/dist/engine/lifecycle/meta-cli.js +7 -0
- package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
- package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
- package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
- package/dist/engine/lifecycle/orchestrator.js +131 -0
- package/dist/engine/lifecycle/signals.d.ts +30 -0
- package/dist/engine/lifecycle/signals.js +142 -0
- package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
- package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
- package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
- package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
- package/dist/engine/lifecycle/types.d.ts +52 -0
- package/dist/engine/lifecycle/types.js +7 -0
- package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
- package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
- package/dist/engine/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/engine/skill-promoter.js +3 -6
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +72 -1
- package/dist/hooks/dangerous-patterns.json +3 -3
- package/dist/hooks/db-guard.js +18 -2
- package/dist/hooks/intent-classifier.js +1 -1
- package/dist/hooks/keyword-detector.js +1 -1
- package/dist/hooks/notepad-injector.js +1 -1
- package/dist/hooks/permission-handler.js +1 -1
- package/dist/hooks/post-tool-failure.js +1 -1
- package/dist/hooks/post-tool-use.d.ts +6 -0
- package/dist/hooks/post-tool-use.js +94 -14
- package/dist/hooks/pre-compact.js +1 -1
- package/dist/hooks/pre-tool-use.d.ts +7 -0
- package/dist/hooks/pre-tool-use.js +79 -5
- package/dist/hooks/rate-limiter.js +1 -1
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +21 -1
- package/dist/hooks/session-recovery.js +1 -1
- package/dist/hooks/shared/atomic-write.d.ts +8 -1
- package/dist/hooks/shared/atomic-write.js +17 -3
- package/dist/hooks/shared/command-parser.d.ts +44 -0
- package/dist/hooks/shared/command-parser.js +50 -0
- package/dist/hooks/shared/hook-response.d.ts +23 -2
- package/dist/hooks/shared/hook-response.js +48 -3
- package/dist/hooks/shared/safe-regex.d.ts +25 -0
- package/dist/hooks/shared/safe-regex.js +50 -0
- package/dist/hooks/shared/stop-triggers.d.ts +19 -0
- package/dist/hooks/shared/stop-triggers.js +19 -0
- package/dist/hooks/skill-injector.js +1 -1
- package/dist/hooks/slop-detector.js +2 -2
- package/dist/hooks/solution-injector.d.ts +9 -0
- package/dist/hooks/solution-injector.js +48 -5
- package/dist/hooks/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +606 -0
- package/dist/hooks/subagent-tracker.js +1 -1
- package/dist/i18n/index.js +3 -5
- package/dist/mcp/tools.js +19 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +61 -1
- package/dist/store/implicit-feedback-store.d.ts +59 -0
- package/dist/store/implicit-feedback-store.js +153 -0
- package/dist/store/rule-lifecycle.d.ts +23 -0
- package/dist/store/rule-lifecycle.js +63 -0
- package/dist/store/rule-store.d.ts +21 -0
- package/dist/store/rule-store.js +136 -8
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +11 -3
- package/plugin.json +1 -1
|
@@ -20,6 +20,8 @@ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook
|
|
|
20
20
|
import { STATE_DIR } from '../core/paths.js';
|
|
21
21
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
22
22
|
import { createDriftState, evaluateDrift } from '../core/drift-score.js';
|
|
23
|
+
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
24
|
+
const RECENT_TOOL_NAMES_WINDOW = 20;
|
|
23
25
|
/** Lightweight hash for content comparison (not cryptographic) */
|
|
24
26
|
function simpleHash(content) {
|
|
25
27
|
let hash = 0;
|
|
@@ -30,15 +32,6 @@ function simpleHash(content) {
|
|
|
30
32
|
}
|
|
31
33
|
return hash.toString(36);
|
|
32
34
|
}
|
|
33
|
-
const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
|
|
34
|
-
/** Record implicit feedback signal to JSONL */
|
|
35
|
-
function recordImplicitFeedback(entry) {
|
|
36
|
-
try {
|
|
37
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
-
fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(entry) + '\n');
|
|
39
|
-
}
|
|
40
|
-
catch { /* fail-open: implicit feedback recording must not throw */ }
|
|
41
|
-
}
|
|
42
35
|
// ── State management ──
|
|
43
36
|
function getModifiedFilesPath(sessionId) {
|
|
44
37
|
return path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
|
|
@@ -125,6 +118,15 @@ async function main() {
|
|
|
125
118
|
const sessionId = data.session_id ?? 'default';
|
|
126
119
|
const modState = loadModifiedFiles(sessionId);
|
|
127
120
|
modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
|
|
121
|
+
// TEST-2: recent tool name window — stop-guard 의 self-score inflation 가드가
|
|
122
|
+
// "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
|
|
123
|
+
if (toolName) {
|
|
124
|
+
const names = modState.recentToolNames ?? [];
|
|
125
|
+
names.push(toolName);
|
|
126
|
+
if (names.length > RECENT_TOOL_NAMES_WINDOW)
|
|
127
|
+
names.splice(0, names.length - RECENT_TOOL_NAMES_WINDOW);
|
|
128
|
+
modState.recentToolNames = names;
|
|
129
|
+
}
|
|
128
130
|
const messages = [];
|
|
129
131
|
let revertDetected = false;
|
|
130
132
|
// 1. Checkpoint (every 5 calls)
|
|
@@ -152,8 +154,9 @@ async function main() {
|
|
|
152
154
|
// Implicit feedback: repeated edit detection (5+ edits on same file)
|
|
153
155
|
if (count >= 5) {
|
|
154
156
|
messages.push(`<compound-tool-warning>\n[Forgen] ⚠ ${path.basename(filePath)} has been modified ${count} times.\nConsider redesigning the overall structure and restarting.\n</compound-tool-warning>`);
|
|
155
|
-
|
|
157
|
+
appendImplicitFeedback({
|
|
156
158
|
type: 'repeated_edit',
|
|
159
|
+
category: 'edit',
|
|
157
160
|
file: filePath,
|
|
158
161
|
editCount: count,
|
|
159
162
|
at: new Date().toISOString(),
|
|
@@ -172,8 +175,9 @@ async function main() {
|
|
|
172
175
|
// Skip the most recent hash (which would be the write being "reverted from")
|
|
173
176
|
if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
|
|
174
177
|
revertDetected = true;
|
|
175
|
-
|
|
178
|
+
appendImplicitFeedback({
|
|
176
179
|
type: 'revert_detected',
|
|
180
|
+
category: 'revert',
|
|
177
181
|
file: filePath,
|
|
178
182
|
at: new Date().toISOString(),
|
|
179
183
|
sessionId,
|
|
@@ -198,8 +202,9 @@ async function main() {
|
|
|
198
202
|
const driftResult = evaluateDrift(modState.drift, true, revertDetected);
|
|
199
203
|
if (driftResult.message) {
|
|
200
204
|
messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
|
|
201
|
-
|
|
205
|
+
appendImplicitFeedback({
|
|
202
206
|
type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
|
|
207
|
+
category: 'drift',
|
|
203
208
|
score: driftResult.score,
|
|
204
209
|
totalEdits: modState.drift.totalEdits,
|
|
205
210
|
totalReverts: modState.drift.totalReverts,
|
|
@@ -213,8 +218,9 @@ async function main() {
|
|
|
213
218
|
const agentResult = validateAgentOutput(toolResponse);
|
|
214
219
|
if (agentResult) {
|
|
215
220
|
messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
|
|
216
|
-
|
|
221
|
+
appendImplicitFeedback({
|
|
217
222
|
type: `agent_${agentResult.signal}`,
|
|
223
|
+
category: 'agent',
|
|
218
224
|
severity: agentResult.severity,
|
|
219
225
|
outputLength: toolResponse.trim().length,
|
|
220
226
|
at: new Date().toISOString(),
|
|
@@ -237,6 +243,80 @@ async function main() {
|
|
|
237
243
|
catch (e) {
|
|
238
244
|
log.debug('compound negative check 실패', e);
|
|
239
245
|
}
|
|
246
|
+
// 6a+b. ADR-001 Mech-A PostToolUse + T3 bypass — single rule load, 두 dispatcher 공유.
|
|
247
|
+
// R2-P perf: 이전에는 6a, 6b 각각 loadActiveRules() 재호출 → file read 2배.
|
|
248
|
+
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'Bash') {
|
|
249
|
+
const target = (() => {
|
|
250
|
+
const c = toolInput.content;
|
|
251
|
+
if (typeof c === 'string')
|
|
252
|
+
return c;
|
|
253
|
+
const ns = toolInput.new_string;
|
|
254
|
+
if (typeof ns === 'string')
|
|
255
|
+
return ns;
|
|
256
|
+
const cmd = toolInput.command;
|
|
257
|
+
if (typeof cmd === 'string')
|
|
258
|
+
return cmd;
|
|
259
|
+
return '';
|
|
260
|
+
})() || toolResponse;
|
|
261
|
+
if (target) {
|
|
262
|
+
try {
|
|
263
|
+
const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
|
|
264
|
+
import('../store/rule-store.js'),
|
|
265
|
+
import('../engine/lifecycle/signals.js'),
|
|
266
|
+
import('../engine/lifecycle/bypass-detector.js'),
|
|
267
|
+
import('./shared/safe-regex.js'),
|
|
268
|
+
import('./shared/command-parser.js'),
|
|
269
|
+
]);
|
|
270
|
+
const rules = loadActiveRules();
|
|
271
|
+
// Mech-A pattern_match dispatcher — match_target 은 **rule-per-rule**.
|
|
272
|
+
// AWS key / DROP 류 secret/dangerous SQL 은 파일 content 에 들어있어도
|
|
273
|
+
// 실제 leak 이라 raw 검사가 맞고, rm -rf 류 shell 명령은 quote 안 본문이면
|
|
274
|
+
// false-positive 이므로 masked 가 맞다. pre-tool-use 와 동일한 spec 기반 분기.
|
|
275
|
+
for (const rule of rules) {
|
|
276
|
+
for (const spec of rule.enforce_via ?? []) {
|
|
277
|
+
if (spec.hook !== 'PostToolUse' || spec.mech !== 'A')
|
|
278
|
+
continue;
|
|
279
|
+
const v = spec.verifier;
|
|
280
|
+
if (!v || v.kind !== 'pattern_match')
|
|
281
|
+
continue;
|
|
282
|
+
const pattern = String(v.params?.pattern ?? '');
|
|
283
|
+
if (!pattern)
|
|
284
|
+
continue;
|
|
285
|
+
const re = compileSafeRegex(pattern);
|
|
286
|
+
if (!re.regex) {
|
|
287
|
+
log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const matchTarget = (v.params?.match_target ?? 'raw');
|
|
291
|
+
const mechTarget = preprocessForMatch(target, matchTarget);
|
|
292
|
+
if (!safeRegexTest(re.regex, mechTarget))
|
|
293
|
+
continue;
|
|
294
|
+
recordViolation({
|
|
295
|
+
rule_id: rule.rule_id, session_id: sessionId,
|
|
296
|
+
source: 'post-tool-guard',
|
|
297
|
+
kind: 'block',
|
|
298
|
+
message_preview: target.slice(0, 120),
|
|
299
|
+
});
|
|
300
|
+
messages.push(`<compound-rule-violation>\n[Forgen] Rule ${rule.rule_id.slice(0, 8)} pattern matched in ${toolName} output.\n${spec.block_message ?? rule.policy.slice(0, 120)}\n</compound-rule-violation>`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// T3 bypass detection — scanForBypass 는 rule.policy 자연어에서 패턴 추출이라
|
|
304
|
+
// match_target 개념 없음. Write/Edit 는 파일 본문이라 bypass-detector 의
|
|
305
|
+
// 자연어 휴리스틱이 false-positive 과다 (L1-no-rm-rf-unconfirmed bypass 20건
|
|
306
|
+
// 중 Write/Edit 15건이 실측). 이 경로만 masked. Bash 는 실제 실행된 명령이라
|
|
307
|
+
// raw 유지. Mech-A pattern_match 는 위에서 rule-per-rule 로 이미 처리.
|
|
308
|
+
const isFileContentTool = toolName === 'Write' || toolName === 'Edit';
|
|
309
|
+
const bypassTarget = isFileContentTool ? preprocessForMatch(target, 'masked') : target;
|
|
310
|
+
const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: bypassTarget, session_id: sessionId });
|
|
311
|
+
for (const c of candidates) {
|
|
312
|
+
recordBypass({ rule_id: c.rule_id, session_id: c.session_id, tool: c.tool, pattern_preview: c.pattern_preview });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
log.debug('enforce_via/bypass post-tool dispatch 실패', e);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
240
320
|
// 7. Compound success hint (non-blocking)
|
|
241
321
|
try {
|
|
242
322
|
const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
|
|
@@ -260,5 +340,5 @@ async function main() {
|
|
|
260
340
|
}
|
|
261
341
|
main().catch((e) => {
|
|
262
342
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
263
|
-
console.log(failOpenWithTracking('post-tool-use'));
|
|
343
|
+
console.log(failOpenWithTracking('post-tool-use', e));
|
|
264
344
|
});
|
|
@@ -10,6 +10,13 @@ interface DangerousPatternEntry {
|
|
|
10
10
|
pattern: RegExp;
|
|
11
11
|
description: string;
|
|
12
12
|
severity: 'block' | 'warn';
|
|
13
|
+
/**
|
|
14
|
+
* match_target (v0.4.1): 'masked' (default) — quote/heredoc 본문 제거 후 매칭.
|
|
15
|
+
* 실 shell 실행 토큰 검사에 적합. 'raw' — 원본 command 그대로 매칭.
|
|
16
|
+
* `python -c "..."`, `eval "..."` 처럼 **quote 안 본문이 실제 payload 로 실행**
|
|
17
|
+
* 되는 패턴에 사용.
|
|
18
|
+
*/
|
|
19
|
+
matchTarget?: 'raw' | 'masked';
|
|
13
20
|
}
|
|
14
21
|
/** 위험 Bash 명령어 패턴 (패키지 내장 + 사용자 커스텀 병합) */
|
|
15
22
|
export declare const DANGEROUS_PATTERNS: DangerousPatternEntry[];
|
|
@@ -22,6 +22,7 @@ import { isHookEnabled } from './hook-config.js';
|
|
|
22
22
|
import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
|
|
23
23
|
import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
|
|
24
24
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
25
|
+
import { maskQuotedContent } from './shared/command-parser.js';
|
|
25
26
|
const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'pre-tool-fail-counter.json');
|
|
26
27
|
const FAIL_CLOSE_THRESHOLD = 3; // 연속 3회 파싱 실패 시에만 reject
|
|
27
28
|
/** RegExp 안전성 검증 (ReDoS 방지) — 매칭/비매칭 양쪽 모두 테스트 */
|
|
@@ -59,12 +60,16 @@ function loadDangerousPatterns() {
|
|
|
59
60
|
pattern: new RegExp(entry.pattern, entry.flags ?? ''),
|
|
60
61
|
description: entry.description,
|
|
61
62
|
severity: entry.severity,
|
|
63
|
+
matchTarget: entry.match_target === 'raw' ? 'raw' : 'masked',
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
catch {
|
|
66
68
|
// JSON 로드 실패 시 하드코딩 폴백 (최소 안전장치)
|
|
67
|
-
results.push(
|
|
69
|
+
results.push(
|
|
70
|
+
// v0.4.1 false-positive fix: /tmp, /var/folders, /var/tmp 같은 임시 경로는
|
|
71
|
+
// 일반 개발에서 매일 정리 대상. 위험한 시스템 경로만 blacklist.
|
|
72
|
+
{ pattern: /rm\s+(-rf|-fr)\s+(\/(?!tmp\b|var\/folders\b|var\/tmp\b)|~)/, description: 'rm -rf on root/home path', severity: 'block' }, { pattern: /curl\s+.*\|\s*(ba)?sh/, description: 'curl pipe to shell', severity: 'block' }, { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, description: 'fork bomb', severity: 'block' });
|
|
68
73
|
}
|
|
69
74
|
// 2. 사용자 커스텀 패턴 (~/.compound/dangerous-patterns.json)
|
|
70
75
|
try {
|
|
@@ -100,8 +105,14 @@ export function checkDangerousCommand(toolName, toolInput) {
|
|
|
100
105
|
const command = typeof toolInput === 'string'
|
|
101
106
|
? toolInput
|
|
102
107
|
: (toolInput.command ?? '');
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
// v0.4.1 (2026-04-24) quote-aware built-in scan:
|
|
109
|
+
// 기본은 masked (quote/heredoc 본문 제거) — shell 실행 토큰 검사. 하지만
|
|
110
|
+
// `python -c "..."` / `eval "..."` 처럼 quote 안 본문이 실 payload 로 실행되는
|
|
111
|
+
// 패턴은 match_target:raw 로 지정해 원본 command 전체 검사.
|
|
112
|
+
const maskedCommand = maskQuotedContent(command);
|
|
113
|
+
for (const { pattern, description, severity, matchTarget } of DANGEROUS_PATTERNS) {
|
|
114
|
+
const target = matchTarget === 'raw' ? command : maskedCommand;
|
|
115
|
+
if (pattern.test(target)) {
|
|
105
116
|
return { action: severity, description, command: command.slice(0, 100) };
|
|
106
117
|
}
|
|
107
118
|
}
|
|
@@ -311,7 +322,70 @@ async function main() {
|
|
|
311
322
|
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
312
323
|
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
313
324
|
const sessionId = data.session_id ?? 'default';
|
|
314
|
-
//
|
|
325
|
+
// ADR-001 Mech-A PreToolUse dispatcher — 사용자가 정의한 rule 이 빌트인 위험-명령 감지보다 먼저.
|
|
326
|
+
// 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
|
|
327
|
+
// fail-open: 예외는 hook 차단 안 함.
|
|
328
|
+
try {
|
|
329
|
+
const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
|
|
330
|
+
import('../store/rule-store.js'),
|
|
331
|
+
import('../engine/lifecycle/signals.js'),
|
|
332
|
+
import('./shared/safe-regex.js'),
|
|
333
|
+
import('./shared/command-parser.js'),
|
|
334
|
+
]);
|
|
335
|
+
const rules = loadActiveRules();
|
|
336
|
+
const command = typeof toolInput.command === 'string'
|
|
337
|
+
? String(toolInput.command)
|
|
338
|
+
: '';
|
|
339
|
+
for (const rule of rules) {
|
|
340
|
+
for (const spec of rule.enforce_via ?? []) {
|
|
341
|
+
if (spec.hook !== 'PreToolUse' || spec.mech !== 'A')
|
|
342
|
+
continue;
|
|
343
|
+
const v = spec.verifier;
|
|
344
|
+
if (!v || v.kind !== 'tool_arg_regex')
|
|
345
|
+
continue;
|
|
346
|
+
const pattern = String(v.params?.pattern ?? '');
|
|
347
|
+
if (!pattern)
|
|
348
|
+
continue;
|
|
349
|
+
const re = compileSafeRegex(pattern, 'i');
|
|
350
|
+
if (!re.regex) {
|
|
351
|
+
log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
// TEST-6 / RC5: quote-aware preprocessing. Default 'raw' = backward compat.
|
|
355
|
+
// Rules that target real command invocations should set match_target: 'masked'
|
|
356
|
+
// so quoted argument text (e.g. body of `forgen compound --solution "..."`)
|
|
357
|
+
// doesn't trigger false positive blocks.
|
|
358
|
+
const matchTarget = (v.params?.match_target ?? 'raw');
|
|
359
|
+
const target = preprocessForMatch(command, matchTarget);
|
|
360
|
+
if (!safeRegexTest(re.regex, target))
|
|
361
|
+
continue;
|
|
362
|
+
const requiresFlag = v.params?.requires_flag;
|
|
363
|
+
const confirmed = process.env.FORGEN_USER_CONFIRMED === '1';
|
|
364
|
+
if (requiresFlag && !confirmed) {
|
|
365
|
+
recordViolation({ rule_id: rule.rule_id, session_id: sessionId, source: 'pre-tool-guard', kind: 'deny', message_preview: command.slice(0, 120) });
|
|
366
|
+
const baseMsg = spec.block_message ?? `[${rule.rule_id}] policy violation: ${rule.policy.slice(0, 120)}`;
|
|
367
|
+
// G8: override 힌트 — FORGEN_USER_CONFIRMED=1 으로 사용자 명시 승인 가능, 감사 로그 기록됨.
|
|
368
|
+
const msgWithHint = `${baseMsg}\n\n(override: set FORGEN_USER_CONFIRMED=1 (bypass will be audited in violations.jsonl))`;
|
|
369
|
+
console.log(deny(msgWithHint));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (requiresFlag && confirmed) {
|
|
373
|
+
// H3: 우회 감사 — FORGEN_USER_CONFIRMED 으로 Mech-A 를 우회할 때마다 violation 로그에
|
|
374
|
+
// kind='correction' 으로 기록. T3 bypass 누적 대신 별도 채널로 운영자가 monitoring 가능.
|
|
375
|
+
recordViolation({
|
|
376
|
+
rule_id: rule.rule_id, session_id: sessionId,
|
|
377
|
+
source: 'pre-tool-guard',
|
|
378
|
+
kind: 'correction', // 'correction' = 사용자 명시 우회, rule 위반이지만 의도된 것
|
|
379
|
+
message_preview: `[FORGEN_USER_CONFIRMED=1 bypass] ${command.slice(0, 120)}`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
log.debug('enforce_via[PreToolUse] dispatch 실패', e);
|
|
387
|
+
}
|
|
388
|
+
// Bash 도구: 위험 명령어 감지 (빌트인 safety net)
|
|
315
389
|
const check = checkDangerousCommand(toolName, toolInput);
|
|
316
390
|
if (check.action === 'block') {
|
|
317
391
|
console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
|
|
@@ -357,5 +431,5 @@ main().catch((e) => {
|
|
|
357
431
|
});
|
|
358
432
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
359
433
|
// fail-open: approve on internal error to avoid blocking all tool calls
|
|
360
|
-
console.log(failOpenWithTracking('pre-tool-use'));
|
|
434
|
+
console.log(failOpenWithTracking('pre-tool-use', e));
|
|
361
435
|
});
|
|
@@ -10,5 +10,15 @@ export interface SecretPattern {
|
|
|
10
10
|
pattern: RegExp;
|
|
11
11
|
}
|
|
12
12
|
export declare const SECRET_PATTERNS: SecretPattern[];
|
|
13
|
+
/**
|
|
14
|
+
* 텍스트에서 민감 정보 패턴을 찾아 `[REDACTED:<NAME>]` 로 치환 (순수 함수).
|
|
15
|
+
*
|
|
16
|
+
* R5-G2: auto-compound-runner 가 사용자 transcript 를 Claude (Haiku) 로 송신하기 전
|
|
17
|
+
* 적용. `detectSecrets` 는 감지만, 이 함수는 실제 문자열에서 대체.
|
|
18
|
+
*/
|
|
19
|
+
export declare function redactSecrets(text: string): {
|
|
20
|
+
redacted: string;
|
|
21
|
+
hits: SecretPattern[];
|
|
22
|
+
};
|
|
13
23
|
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
14
24
|
export declare function detectSecrets(text: string): SecretPattern[];
|
|
@@ -23,6 +23,26 @@ export const SECRET_PATTERNS = [
|
|
|
23
23
|
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
24
24
|
{ name: 'Slack Token', pattern: /\bxox[abpors]-[A-Za-z0-9-]{10,}/ },
|
|
25
25
|
];
|
|
26
|
+
/**
|
|
27
|
+
* 텍스트에서 민감 정보 패턴을 찾아 `[REDACTED:<NAME>]` 로 치환 (순수 함수).
|
|
28
|
+
*
|
|
29
|
+
* R5-G2: auto-compound-runner 가 사용자 transcript 를 Claude (Haiku) 로 송신하기 전
|
|
30
|
+
* 적용. `detectSecrets` 는 감지만, 이 함수는 실제 문자열에서 대체.
|
|
31
|
+
*/
|
|
32
|
+
export function redactSecrets(text) {
|
|
33
|
+
const hits = [];
|
|
34
|
+
let out = text;
|
|
35
|
+
for (const sp of SECRET_PATTERNS) {
|
|
36
|
+
// regex 복제 (global flag 없이 repeated test 되는 경우 lastIndex 안전)
|
|
37
|
+
const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
|
|
38
|
+
if (re.test(out)) {
|
|
39
|
+
hits.push(sp);
|
|
40
|
+
const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
|
|
41
|
+
out = out.replace(re2, `[REDACTED:${sp.name}]`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { redacted: out, hits };
|
|
45
|
+
}
|
|
26
46
|
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
27
47
|
export function detectSecrets(text) {
|
|
28
48
|
const found = [];
|
|
@@ -67,5 +87,5 @@ main().catch((e) => {
|
|
|
67
87
|
hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
|
|
68
88
|
});
|
|
69
89
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
70
|
-
console.log(failOpenWithTracking('secret-filter'));
|
|
90
|
+
console.log(failOpenWithTracking('secret-filter', e));
|
|
71
91
|
});
|
|
@@ -429,6 +429,6 @@ async function main() {
|
|
|
429
429
|
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
430
430
|
main().catch((e) => {
|
|
431
431
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
432
|
-
console.log(failOpenWithTracking('session-recovery'));
|
|
432
|
+
console.log(failOpenWithTracking('session-recovery', e));
|
|
433
433
|
});
|
|
434
434
|
}
|
|
@@ -37,5 +37,12 @@ export declare function atomicWriteText(filePath: string, content: string, optio
|
|
|
37
37
|
mode?: number;
|
|
38
38
|
dirMode?: number;
|
|
39
39
|
}): void;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환).
|
|
42
|
+
*
|
|
43
|
+
* R4-B3 (2026-04-22): UTF-8 BOM () prefix 제거 — Windows 메모장 등으로 저장된
|
|
44
|
+
* rule/settings JSON 이 BOM 으로 시작해 JSON.parse 가 silent 실패하던 문제.
|
|
45
|
+
* R4-SKIP: FORGEN_DEBUG_SIGNALS=1 일 때 파싱 실패를 stderr 로 노출 — silent
|
|
46
|
+
* 누락을 운영자가 추적 가능하도록.
|
|
47
|
+
*/
|
|
41
48
|
export declare function safeReadJSON<T>(filePath: string, fallback: T): T;
|
|
@@ -136,13 +136,27 @@ export function atomicWriteText(filePath, content, options) {
|
|
|
136
136
|
throw e;
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환).
|
|
141
|
+
*
|
|
142
|
+
* R4-B3 (2026-04-22): UTF-8 BOM () prefix 제거 — Windows 메모장 등으로 저장된
|
|
143
|
+
* rule/settings JSON 이 BOM 으로 시작해 JSON.parse 가 silent 실패하던 문제.
|
|
144
|
+
* R4-SKIP: FORGEN_DEBUG_SIGNALS=1 일 때 파싱 실패를 stderr 로 노출 — silent
|
|
145
|
+
* 누락을 운영자가 추적 가능하도록.
|
|
146
|
+
*/
|
|
140
147
|
export function safeReadJSON(filePath, fallback) {
|
|
141
148
|
try {
|
|
142
149
|
if (fs.existsSync(filePath)) {
|
|
143
|
-
|
|
150
|
+
let raw = fs.readFileSync(filePath, 'utf-8');
|
|
151
|
+
if (raw.charCodeAt(0) === 0xFEFF)
|
|
152
|
+
raw = raw.slice(1); // strip BOM
|
|
153
|
+
return JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
|
|
158
|
+
process.stderr.write(`[forgen:safeReadJSON] ${filePath} parse failed: ${e.message}\n`);
|
|
144
159
|
}
|
|
145
160
|
}
|
|
146
|
-
catch { /* JSON parse failure — return fallback */ }
|
|
147
161
|
return fallback;
|
|
148
162
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-token parser — quote-aware shell command preprocessing.
|
|
3
|
+
*
|
|
4
|
+
* 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
|
|
5
|
+
* 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
|
|
6
|
+
*
|
|
7
|
+
* 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
|
|
8
|
+
* "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
|
|
9
|
+
*
|
|
10
|
+
* 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
|
|
11
|
+
* 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Mask quoted string contents in a shell command so that text inside
|
|
15
|
+
* single/double quotes, backticks, or $(...) is not matched by patterns
|
|
16
|
+
* intended for command tokens.
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* maskQuotedContent('rm -rf /') → 'rm -rf /'
|
|
20
|
+
* maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
|
|
21
|
+
* maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
|
|
22
|
+
* maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
|
|
23
|
+
* maskQuotedContent('echo `rm -rf x`') → 'echo ``'
|
|
24
|
+
*
|
|
25
|
+
* Limitations (documented, not silently broken):
|
|
26
|
+
* - escaped quotes inside quoted strings: best-effort only
|
|
27
|
+
* - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
|
|
28
|
+
* - nested $(...) / `...`: outer level masked
|
|
29
|
+
*/
|
|
30
|
+
export declare function maskQuotedContent(cmd: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Decide if a verifier should match against the raw command, masked command,
|
|
33
|
+
* or the leading command tokens of each statement.
|
|
34
|
+
*
|
|
35
|
+
* 'raw' — backward compat. Match against the unmodified command string.
|
|
36
|
+
* 'masked' — Strip quoted contents first. Use this when the rule wants to
|
|
37
|
+
* guard a real command invocation (e.g. rm -rf) and not text
|
|
38
|
+
* inside string literals passed as arguments to other commands.
|
|
39
|
+
* 'command_tokens' — Reserved for future use (per-statement leading-token check).
|
|
40
|
+
* Currently behaves like 'masked' to avoid silently breaking
|
|
41
|
+
* when rule files use it.
|
|
42
|
+
*/
|
|
43
|
+
export type MatchTarget = 'raw' | 'masked' | 'command_tokens';
|
|
44
|
+
export declare function preprocessForMatch(cmd: string, target: MatchTarget | undefined): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-token parser — quote-aware shell command preprocessing.
|
|
3
|
+
*
|
|
4
|
+
* 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
|
|
5
|
+
* 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
|
|
6
|
+
*
|
|
7
|
+
* 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
|
|
8
|
+
* "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
|
|
9
|
+
*
|
|
10
|
+
* 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
|
|
11
|
+
* 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Mask quoted string contents in a shell command so that text inside
|
|
15
|
+
* single/double quotes, backticks, or $(...) is not matched by patterns
|
|
16
|
+
* intended for command tokens.
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* maskQuotedContent('rm -rf /') → 'rm -rf /'
|
|
20
|
+
* maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
|
|
21
|
+
* maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
|
|
22
|
+
* maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
|
|
23
|
+
* maskQuotedContent('echo `rm -rf x`') → 'echo ``'
|
|
24
|
+
*
|
|
25
|
+
* Limitations (documented, not silently broken):
|
|
26
|
+
* - escaped quotes inside quoted strings: best-effort only
|
|
27
|
+
* - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
|
|
28
|
+
* - nested $(...) / `...`: outer level masked
|
|
29
|
+
*/
|
|
30
|
+
export function maskQuotedContent(cmd) {
|
|
31
|
+
if (!cmd)
|
|
32
|
+
return cmd;
|
|
33
|
+
let out = cmd;
|
|
34
|
+
// v0.4.1 (2026-04-24) — heredoc body 마스킹 추가. 이전엔 `cat > f <<EOF\n rm -rf /tmp \nEOF`
|
|
35
|
+
// 처럼 heredoc 본문이 command string 에 포함돼 false-positive block 발생.
|
|
36
|
+
// 지원 형식: <<EOF / <<'EOF' / <<"EOF" / <<-EOF (indent 무시 변종).
|
|
37
|
+
// <<-MARK 은 indent 허용 (terminator 앞 whitespace). `\n\s*\2` 로 반영.
|
|
38
|
+
out = out.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?\n\s*\2\b/g, '<<HEREDOC>>');
|
|
39
|
+
// Order matters: command substitution before plain quotes (they may contain quotes themselves).
|
|
40
|
+
out = out.replace(/\$\([^)]*\)/g, '$()');
|
|
41
|
+
out = out.replace(/`[^`]*`/g, '``');
|
|
42
|
+
out = out.replace(/'[^']*'/g, "''");
|
|
43
|
+
out = out.replace(/"[^"]*"/g, '""');
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function preprocessForMatch(cmd, target) {
|
|
47
|
+
if (!target || target === 'raw')
|
|
48
|
+
return cmd;
|
|
49
|
+
return maskQuotedContent(cmd);
|
|
50
|
+
}
|
|
@@ -18,8 +18,13 @@ export declare function approve(): string;
|
|
|
18
18
|
/**
|
|
19
19
|
* 통과 + 모델에 컨텍스트 주입.
|
|
20
20
|
* UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
|
|
21
|
+
*
|
|
22
|
+
* H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
|
|
23
|
+
* 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
|
|
24
|
+
* 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
|
|
25
|
+
* 에게 가시화한다.
|
|
21
26
|
*/
|
|
22
|
-
export declare function approveWithContext(context: string, eventName: string): string;
|
|
27
|
+
export declare function approveWithContext(context: string, eventName: string, userNotice?: string): string;
|
|
23
28
|
/**
|
|
24
29
|
* 통과 + UI 경고 표시 (모델에는 전달되지 않음).
|
|
25
30
|
* PostToolUse, PreToolUse 경고 등 모델 도달이 불필요한 경우 사용.
|
|
@@ -29,10 +34,26 @@ export declare function approveWithWarning(warning: string): string;
|
|
|
29
34
|
export declare function deny(reason: string): string;
|
|
30
35
|
/** 사용자 확인 요청 (PreToolUse 전용) */
|
|
31
36
|
export declare function ask(reason: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Stop hook only — block the agent from stopping and feed a self-check
|
|
39
|
+
* question back to Claude so the current session resumes with new guidance.
|
|
40
|
+
*
|
|
41
|
+
* `reason` becomes the next-turn content (Claude reads this verbatim), while
|
|
42
|
+
* `systemMessage` is auxiliary context rendered alongside. Put the whole
|
|
43
|
+
* self-check question in `reason`; keep `systemMessage` to a short rule tag.
|
|
44
|
+
*
|
|
45
|
+
* Source: Stop hook spec — `decision: "block"` "prevents stopping and continues the agent's work".
|
|
46
|
+
*/
|
|
47
|
+
export declare function blockStop(reason: string, systemMessage?: string): string;
|
|
32
48
|
/**
|
|
33
49
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
34
50
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
35
51
|
*
|
|
52
|
+
* v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
|
|
53
|
+
* 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
|
|
54
|
+
* 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
|
|
55
|
+
* payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
|
|
56
|
+
*
|
|
36
57
|
* @fail-open: hook failure must never block the user's workflow
|
|
37
58
|
*/
|
|
38
|
-
export declare function failOpenWithTracking(hookName: string): string;
|
|
59
|
+
export declare function failOpenWithTracking(hookName: string, err?: unknown): string;
|
|
@@ -23,11 +23,17 @@ export function approve() {
|
|
|
23
23
|
/**
|
|
24
24
|
* 통과 + 모델에 컨텍스트 주입.
|
|
25
25
|
* UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
|
|
26
|
+
*
|
|
27
|
+
* H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
|
|
28
|
+
* 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
|
|
29
|
+
* 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
|
|
30
|
+
* 에게 가시화한다.
|
|
26
31
|
*/
|
|
27
|
-
export function approveWithContext(context, eventName) {
|
|
32
|
+
export function approveWithContext(context, eventName, userNotice) {
|
|
28
33
|
return JSON.stringify({
|
|
29
34
|
continue: true,
|
|
30
35
|
hookSpecificOutput: { hookEventName: eventName, additionalContext: context },
|
|
36
|
+
...(userNotice ? { systemMessage: userNotice } : {}),
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
@@ -59,17 +65,56 @@ export function ask(reason) {
|
|
|
59
65
|
},
|
|
60
66
|
});
|
|
61
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Stop hook only — block the agent from stopping and feed a self-check
|
|
70
|
+
* question back to Claude so the current session resumes with new guidance.
|
|
71
|
+
*
|
|
72
|
+
* `reason` becomes the next-turn content (Claude reads this verbatim), while
|
|
73
|
+
* `systemMessage` is auxiliary context rendered alongside. Put the whole
|
|
74
|
+
* self-check question in `reason`; keep `systemMessage` to a short rule tag.
|
|
75
|
+
*
|
|
76
|
+
* Source: Stop hook spec — `decision: "block"` "prevents stopping and continues the agent's work".
|
|
77
|
+
*/
|
|
78
|
+
export function blockStop(reason, systemMessage) {
|
|
79
|
+
return JSON.stringify({
|
|
80
|
+
continue: true,
|
|
81
|
+
decision: 'block',
|
|
82
|
+
reason,
|
|
83
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
62
86
|
/**
|
|
63
87
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
64
88
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
65
89
|
*
|
|
90
|
+
* v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
|
|
91
|
+
* 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
|
|
92
|
+
* 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
|
|
93
|
+
* payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
|
|
94
|
+
*
|
|
66
95
|
* @fail-open: hook failure must never block the user's workflow
|
|
67
96
|
*/
|
|
68
|
-
export function failOpenWithTracking(hookName) {
|
|
97
|
+
export function failOpenWithTracking(hookName, err) {
|
|
69
98
|
try {
|
|
70
99
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
71
100
|
const logPath = path.join(STATE_DIR, 'hook-errors.jsonl');
|
|
72
|
-
const
|
|
101
|
+
const payload = { hook: hookName, at: Date.now() };
|
|
102
|
+
if (err !== undefined && err !== null) {
|
|
103
|
+
if (err instanceof Error) {
|
|
104
|
+
payload.error = err.message.slice(0, 400);
|
|
105
|
+
if (err.stack) {
|
|
106
|
+
// 스택 첫 3줄만 — 어느 파일/라인에서 throw 됐는지만 알면 충분.
|
|
107
|
+
payload.stack = err.stack.split('\n').slice(0, 3).join(' | ').slice(0, 400);
|
|
108
|
+
}
|
|
109
|
+
const maybeCode = err.code;
|
|
110
|
+
if (typeof maybeCode === 'string')
|
|
111
|
+
payload.code = maybeCode;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
payload.error = String(err).slice(0, 400);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const entry = JSON.stringify(payload);
|
|
73
118
|
fs.appendFileSync(logPath, entry + '\n');
|
|
74
119
|
}
|
|
75
120
|
catch { /* fail-open: tracking itself must not throw */ }
|