@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.
Files changed (124) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.ja.md +119 -8
  4. package/README.ko.md +73 -2
  5. package/README.md +163 -9
  6. package/README.zh.md +87 -7
  7. package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
  8. package/dist/checks/conclusion-verification-ratio.js +86 -0
  9. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  10. package/dist/checks/fact-vs-agreement.js +92 -0
  11. package/dist/checks/self-score-deflation.d.ts +38 -0
  12. package/dist/checks/self-score-deflation.js +108 -0
  13. package/dist/cli.js +158 -6
  14. package/dist/core/auto-compound-runner.js +85 -13
  15. package/dist/core/dashboard.js +9 -2
  16. package/dist/core/doctor.js +90 -15
  17. package/dist/core/extraction-notice.d.ts +18 -0
  18. package/dist/core/extraction-notice.js +64 -0
  19. package/dist/core/init-cli.d.ts +26 -0
  20. package/dist/core/init-cli.js +104 -0
  21. package/dist/core/init.js +17 -0
  22. package/dist/core/inspect-cli.js +64 -5
  23. package/dist/core/migrate-cli.d.ts +10 -0
  24. package/dist/core/migrate-cli.js +34 -0
  25. package/dist/core/paths.d.ts +8 -1
  26. package/dist/core/paths.js +11 -2
  27. package/dist/core/recall-cli.d.ts +26 -0
  28. package/dist/core/recall-cli.js +125 -0
  29. package/dist/core/recall-reference-detector.d.ts +43 -0
  30. package/dist/core/recall-reference-detector.js +65 -0
  31. package/dist/core/state-gc.d.ts +19 -0
  32. package/dist/core/state-gc.js +48 -4
  33. package/dist/core/stats-cli.d.ts +36 -0
  34. package/dist/core/stats-cli.js +254 -0
  35. package/dist/core/uninstall.d.ts +1 -0
  36. package/dist/core/uninstall.js +25 -1
  37. package/dist/core/v1-bootstrap.js +9 -1
  38. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  39. package/dist/engine/classify-enforce-cli.js +61 -0
  40. package/dist/engine/compound-cli.js +1 -0
  41. package/dist/engine/compound-export.js +8 -3
  42. package/dist/engine/enforce-classifier.d.ts +31 -0
  43. package/dist/engine/enforce-classifier.js +123 -0
  44. package/dist/engine/learn-cli.js +1 -4
  45. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  46. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  47. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  48. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  49. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  50. package/dist/engine/lifecycle/meta-cli.js +7 -0
  51. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  52. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  53. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  54. package/dist/engine/lifecycle/orchestrator.js +131 -0
  55. package/dist/engine/lifecycle/signals.d.ts +30 -0
  56. package/dist/engine/lifecycle/signals.js +142 -0
  57. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  58. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  59. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  60. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  61. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  62. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  63. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  64. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  65. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  66. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  67. package/dist/engine/lifecycle/types.d.ts +52 -0
  68. package/dist/engine/lifecycle/types.js +7 -0
  69. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  70. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  71. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  72. package/dist/engine/rule-toggle-cli.js +76 -0
  73. package/dist/engine/skill-promoter.js +3 -6
  74. package/dist/forge/evidence-processor.js +10 -2
  75. package/dist/hooks/context-guard.js +72 -1
  76. package/dist/hooks/dangerous-patterns.json +3 -3
  77. package/dist/hooks/db-guard.js +18 -2
  78. package/dist/hooks/intent-classifier.js +1 -1
  79. package/dist/hooks/keyword-detector.js +1 -1
  80. package/dist/hooks/notepad-injector.js +1 -1
  81. package/dist/hooks/permission-handler.js +1 -1
  82. package/dist/hooks/post-tool-failure.js +1 -1
  83. package/dist/hooks/post-tool-use.d.ts +6 -0
  84. package/dist/hooks/post-tool-use.js +94 -14
  85. package/dist/hooks/pre-compact.js +1 -1
  86. package/dist/hooks/pre-tool-use.d.ts +7 -0
  87. package/dist/hooks/pre-tool-use.js +79 -5
  88. package/dist/hooks/rate-limiter.js +1 -1
  89. package/dist/hooks/secret-filter.d.ts +10 -0
  90. package/dist/hooks/secret-filter.js +21 -1
  91. package/dist/hooks/session-recovery.js +1 -1
  92. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  93. package/dist/hooks/shared/atomic-write.js +17 -3
  94. package/dist/hooks/shared/command-parser.d.ts +44 -0
  95. package/dist/hooks/shared/command-parser.js +50 -0
  96. package/dist/hooks/shared/hook-response.d.ts +23 -2
  97. package/dist/hooks/shared/hook-response.js +48 -3
  98. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  99. package/dist/hooks/shared/safe-regex.js +50 -0
  100. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  101. package/dist/hooks/shared/stop-triggers.js +19 -0
  102. package/dist/hooks/skill-injector.js +1 -1
  103. package/dist/hooks/slop-detector.js +2 -2
  104. package/dist/hooks/solution-injector.d.ts +9 -0
  105. package/dist/hooks/solution-injector.js +48 -5
  106. package/dist/hooks/stop-guard.d.ts +84 -0
  107. package/dist/hooks/stop-guard.js +606 -0
  108. package/dist/hooks/subagent-tracker.js +1 -1
  109. package/dist/i18n/index.js +3 -5
  110. package/dist/mcp/tools.js +19 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +61 -1
  113. package/dist/store/implicit-feedback-store.d.ts +59 -0
  114. package/dist/store/implicit-feedback-store.js +153 -0
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +136 -8
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +11 -3
  124. 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
- recordImplicitFeedback({
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
- recordImplicitFeedback({
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
- recordImplicitFeedback({
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
- recordImplicitFeedback({
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
  });
@@ -271,5 +271,5 @@ Rules:
271
271
  }
272
272
  main().catch((e) => {
273
273
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
274
- console.log(failOpenWithTracking('pre-compact'));
274
+ console.log(failOpenWithTracking('pre-compact', e));
275
275
  });
@@ -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({ pattern: /rm\s+(-rf|-fr)\s+[/~]/, 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' });
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
- for (const { pattern, description, severity } of DANGEROUS_PATTERNS) {
104
- if (pattern.test(command)) {
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
- // Bash 도구: 위험 명령어 감지
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
  });
@@ -82,5 +82,5 @@ async function main() {
82
82
  }
83
83
  main().catch((e) => {
84
84
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
85
- console.log(failOpenWithTracking('rate-limiter'));
85
+ console.log(failOpenWithTracking('rate-limiter', e));
86
86
  });
@@ -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
- /** JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환) */
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
- /** JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환) */
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
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
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 entry = JSON.stringify({ hook: hookName, at: Date.now() });
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 */ }