@wooojin/forgen 0.4.0 → 0.4.3

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 (187) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/CHANGELOG.md +194 -15
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.ja.md +74 -9
  5. package/README.ko.md +77 -12
  6. package/README.md +127 -25
  7. package/README.zh.md +43 -9
  8. package/assets/README.md +86 -0
  9. package/assets/architecture.svg +100 -0
  10. package/assets/banner.png +0 -0
  11. package/assets/banner.svg +53 -0
  12. package/assets/demo/01-install.gif +0 -0
  13. package/assets/demo/01-install.tape +54 -0
  14. package/assets/demo/02-compound-learning.gif +0 -0
  15. package/assets/demo/02-compound-learning.tape +50 -0
  16. package/assets/demo/03-forge-personalization.gif +0 -0
  17. package/assets/demo/03-forge-personalization.tape +64 -0
  18. package/assets/demo/before-after.gif +0 -0
  19. package/assets/demo/before-after.tape +98 -0
  20. package/assets/demo-preview.svg +96 -0
  21. package/assets/icon.png +0 -0
  22. package/{hooks → assets/shared}/hook-registry.json +2 -1
  23. package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
  24. package/dist/checks/conclusion-verification-ratio.js +86 -0
  25. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  26. package/dist/checks/fact-vs-agreement.js +92 -0
  27. package/dist/checks/self-score-deflation.d.ts +38 -0
  28. package/dist/checks/self-score-deflation.js +108 -0
  29. package/dist/cli.js +98 -6
  30. package/dist/core/auto-compound-runner.js +137 -49
  31. package/dist/core/behavior-classifier.d.ts +28 -0
  32. package/dist/core/behavior-classifier.js +46 -0
  33. package/dist/core/dashboard.d.ts +7 -0
  34. package/dist/core/dashboard.js +41 -2
  35. package/dist/core/doctor.js +118 -5
  36. package/dist/core/extraction-notice.d.ts +18 -0
  37. package/dist/core/extraction-notice.js +64 -0
  38. package/dist/core/git-stats.d.ts +36 -0
  39. package/dist/core/git-stats.js +79 -0
  40. package/dist/core/harness.d.ts +1 -1
  41. package/dist/core/harness.js +27 -20
  42. package/dist/core/host-detect.d.ts +42 -0
  43. package/dist/core/host-detect.js +68 -0
  44. package/dist/core/init-cli.d.ts +26 -0
  45. package/dist/core/init-cli.js +104 -0
  46. package/dist/core/init.js +17 -0
  47. package/dist/core/inspect-cli.js +1 -2
  48. package/dist/core/installer.js +2 -2
  49. package/dist/core/migrate-cli.d.ts +11 -0
  50. package/dist/core/migrate-cli.js +53 -0
  51. package/dist/core/migrate-evidence-host.d.ts +36 -0
  52. package/dist/core/migrate-evidence-host.js +49 -0
  53. package/dist/core/paths.d.ts +8 -1
  54. package/dist/core/paths.js +11 -2
  55. package/dist/core/recall-cli.d.ts +26 -0
  56. package/dist/core/recall-cli.js +125 -0
  57. package/dist/core/recall-reference-detector.d.ts +43 -0
  58. package/dist/core/recall-reference-detector.js +65 -0
  59. package/dist/core/settings-injector.js +4 -2
  60. package/dist/core/spawn.d.ts +1 -1
  61. package/dist/core/spawn.js +4 -11
  62. package/dist/core/stats-cli.d.ts +21 -0
  63. package/dist/core/stats-cli.js +133 -10
  64. package/dist/core/trust-layer-intent.d.ts +35 -0
  65. package/dist/core/trust-layer-intent.js +30 -0
  66. package/dist/core/types.d.ts +1 -1
  67. package/dist/core/uninstall.js +2 -1
  68. package/dist/engine/compound-cli.js +1 -0
  69. package/dist/engine/compound-export.js +8 -3
  70. package/dist/engine/compound-extractor.js +7 -9
  71. package/dist/engine/learn-cli.js +5 -6
  72. package/dist/engine/lifecycle/bypass-detector.d.ts +6 -1
  73. package/dist/engine/lifecycle/bypass-detector.js +57 -5
  74. package/dist/engine/lifecycle/lifecycle-cli.js +4 -4
  75. package/dist/engine/lifecycle/meta-reclassifier.js +3 -3
  76. package/dist/engine/lifecycle/orchestrator.js +2 -2
  77. package/dist/engine/lifecycle/signals.js +6 -6
  78. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  79. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  80. package/dist/engine/skill-promoter.js +3 -6
  81. package/dist/fgx.js +2 -1
  82. package/dist/forge/evidence-processor.js +12 -0
  83. package/dist/forge/onboarding.d.ts +3 -2
  84. package/dist/forge/onboarding.js +3 -2
  85. package/dist/hooks/context-guard.js +1 -1
  86. package/dist/hooks/dangerous-patterns.json +3 -3
  87. package/dist/hooks/db-guard.js +21 -5
  88. package/dist/hooks/forge-loop-progress.d.ts +9 -0
  89. package/dist/hooks/forge-loop-progress.js +38 -0
  90. package/dist/hooks/hook-registry.js +1 -1
  91. package/dist/hooks/hooks-generator.d.ts +15 -1
  92. package/dist/hooks/hooks-generator.js +18 -16
  93. package/dist/hooks/intent-classifier.js +1 -1
  94. package/dist/hooks/keyword-detector.js +2 -2
  95. package/dist/hooks/notepad-injector.js +1 -1
  96. package/dist/hooks/permission-handler.js +1 -1
  97. package/dist/hooks/post-tool-failure.js +1 -1
  98. package/dist/hooks/post-tool-use.d.ts +7 -1
  99. package/dist/hooks/post-tool-use.js +50 -23
  100. package/dist/hooks/pre-compact.js +2 -2
  101. package/dist/hooks/pre-tool-use.d.ts +7 -0
  102. package/dist/hooks/pre-tool-use.js +28 -10
  103. package/dist/hooks/rate-limiter.js +3 -3
  104. package/dist/hooks/secret-filter.js +1 -1
  105. package/dist/hooks/session-recovery.js +12 -1
  106. package/dist/hooks/shared/blocking-allowlist.d.ts +28 -0
  107. package/dist/hooks/shared/blocking-allowlist.js +38 -0
  108. package/dist/hooks/shared/command-parser.d.ts +44 -0
  109. package/dist/hooks/shared/command-parser.js +50 -0
  110. package/dist/hooks/shared/forge-loop-state.d.ts +36 -0
  111. package/dist/hooks/shared/forge-loop-state.js +116 -0
  112. package/dist/hooks/shared/hook-response.d.ts +30 -2
  113. package/dist/hooks/shared/hook-response.js +61 -3
  114. package/dist/hooks/skill-injector.js +2 -2
  115. package/dist/hooks/slop-detector.js +2 -2
  116. package/dist/hooks/solution-injector.d.ts +9 -0
  117. package/dist/hooks/solution-injector.js +48 -5
  118. package/dist/hooks/stop-guard.js +152 -13
  119. package/dist/hooks/subagent-tracker.js +1 -1
  120. package/dist/host/capabilities-claude.d.ts +8 -0
  121. package/dist/host/capabilities-claude.js +46 -0
  122. package/dist/host/capabilities-codex.d.ts +11 -0
  123. package/dist/host/capabilities-codex.js +50 -0
  124. package/dist/host/capabilities-registry.d.ts +11 -0
  125. package/dist/host/capabilities-registry.js +30 -0
  126. package/dist/host/codex-adapter.d.ts +8 -5
  127. package/dist/host/codex-adapter.js +10 -82
  128. package/dist/host/codex-output-parser.d.ts +39 -0
  129. package/dist/host/codex-output-parser.js +75 -0
  130. package/dist/host/exec-host.d.ts +54 -0
  131. package/dist/host/exec-host.js +92 -0
  132. package/dist/host/host-runtime.d.ts +37 -0
  133. package/dist/host/host-runtime.js +51 -0
  134. package/dist/host/install-claude.d.ts +35 -0
  135. package/dist/host/install-claude.js +238 -0
  136. package/dist/host/install-codex.d.ts +44 -0
  137. package/dist/host/install-codex.js +276 -0
  138. package/dist/host/install-orchestrator.d.ts +34 -0
  139. package/dist/host/install-orchestrator.js +126 -0
  140. package/dist/host/invoke-agent.d.ts +27 -0
  141. package/dist/host/invoke-agent.js +115 -0
  142. package/dist/host/parity-harness.d.ts +62 -0
  143. package/dist/host/parity-harness.js +283 -0
  144. package/dist/host/projection.d.ts +35 -0
  145. package/dist/host/projection.js +126 -0
  146. package/dist/i18n/index.js +3 -5
  147. package/dist/mcp/server.js +11 -0
  148. package/dist/mcp/tools.js +47 -0
  149. package/dist/services/session.d.ts +6 -3
  150. package/dist/services/session.js +33 -4
  151. package/dist/store/evidence-store.d.ts +1 -0
  152. package/dist/store/evidence-store.js +45 -3
  153. package/dist/store/host-mismatch.d.ts +42 -0
  154. package/dist/store/host-mismatch.js +65 -0
  155. package/dist/store/implicit-feedback-store.d.ts +59 -0
  156. package/dist/store/implicit-feedback-store.js +153 -0
  157. package/dist/store/profile-store.d.ts +29 -0
  158. package/dist/store/profile-store.js +53 -0
  159. package/dist/store/rule-store.js +8 -0
  160. package/dist/store/types.d.ts +13 -0
  161. package/hooks/hooks.json +6 -1
  162. package/package.json +7 -5
  163. package/plugin.json +4 -4
  164. package/scripts/postinstall.js +100 -25
  165. /package/{agents → assets/claude/agents}/analyst.md +0 -0
  166. /package/{agents → assets/claude/agents}/architect.md +0 -0
  167. /package/{agents → assets/claude/agents}/code-reviewer.md +0 -0
  168. /package/{agents → assets/claude/agents}/critic.md +0 -0
  169. /package/{agents → assets/claude/agents}/debugger.md +0 -0
  170. /package/{agents → assets/claude/agents}/designer.md +0 -0
  171. /package/{agents → assets/claude/agents}/executor.md +0 -0
  172. /package/{agents → assets/claude/agents}/explore.md +0 -0
  173. /package/{agents → assets/claude/agents}/git-master.md +0 -0
  174. /package/{agents → assets/claude/agents}/planner.md +0 -0
  175. /package/{agents → assets/claude/agents}/solution-evolver.md +0 -0
  176. /package/{agents → assets/claude/agents}/test-engineer.md +0 -0
  177. /package/{agents → assets/claude/agents}/verifier.md +0 -0
  178. /package/{commands → assets/claude/commands}/architecture-decision.md +0 -0
  179. /package/{commands → assets/claude/commands}/calibrate.md +0 -0
  180. /package/{commands → assets/claude/commands}/code-review.md +0 -0
  181. /package/{commands → assets/claude/commands}/compound.md +0 -0
  182. /package/{commands → assets/claude/commands}/deep-interview.md +0 -0
  183. /package/{commands → assets/claude/commands}/docker.md +0 -0
  184. /package/{commands → assets/claude/commands}/forge-loop.md +0 -0
  185. /package/{commands → assets/claude/commands}/learn.md +0 -0
  186. /package/{commands → assets/claude/commands}/retro.md +0 -0
  187. /package/{commands → assets/claude/commands}/ship.md +0 -0
@@ -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`);
@@ -82,15 +75,21 @@ const AGENT_QUALITY_PATTERNS = [
82
75
  { pattern: /(?:context (?:window|limit) (?:exceeded|reached)|too (?:large|long) to (?:read|process))/i, signal: 'agent_context_overflow', severity: 'warning', message: 'Agent hit context limits — output may be incomplete' },
83
76
  ];
84
77
  export function validateAgentOutput(toolResponse) {
85
- if (!toolResponse || toolResponse.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
78
+ // tool_response string / object / array 모두 가능. main() 측에서 stringify 를 한 번 더
79
+ // 하지만 직접 호출 보호 (defense in depth).
80
+ if (typeof toolResponse !== 'string') {
81
+ toolResponse = toolResponse == null ? '' : JSON.stringify(toolResponse);
82
+ }
83
+ const r = toolResponse;
84
+ if (!r || r.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
86
85
  return {
87
86
  signal: 'agent_empty_output',
88
87
  severity: 'warning',
89
- message: `Agent returned minimal output (${toolResponse?.trim().length ?? 0} chars). Verify the result is usable.`,
88
+ message: `Agent returned minimal output (${r.trim().length} chars). Verify the result is usable.`,
90
89
  };
91
90
  }
92
91
  for (const p of AGENT_QUALITY_PATTERNS) {
93
- if (p.pattern.test(toolResponse)) {
92
+ if (p.pattern.test(r)) {
94
93
  return { signal: p.signal, severity: p.severity, message: p.message };
95
94
  }
96
95
  }
@@ -121,10 +120,22 @@ async function main() {
121
120
  }
122
121
  const toolName = data.tool_name ?? data.toolName ?? '';
123
122
  const toolInput = data.tool_input ?? data.toolInput ?? {};
124
- const toolResponse = data.tool_response ?? data.toolOutput ?? '';
123
+ // tool_response string / object / array 모두 가능 (sub-agent 결과는 object 가 흔함).
124
+ // 모든 downstream 이 string 가정이라 stringify 로 normalize. 회귀 박제: tests/hooks/post-tool-use.test.ts
125
+ const rawResponse = data.tool_response ?? data.toolOutput ?? '';
126
+ const toolResponse = typeof rawResponse === 'string' ? rawResponse : JSON.stringify(rawResponse);
125
127
  const sessionId = data.session_id ?? 'default';
126
128
  const modState = loadModifiedFiles(sessionId);
127
129
  modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
130
+ // TEST-2: recent tool name window — stop-guard 의 self-score inflation 가드가
131
+ // "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
132
+ if (toolName) {
133
+ const names = modState.recentToolNames ?? [];
134
+ names.push(toolName);
135
+ if (names.length > RECENT_TOOL_NAMES_WINDOW)
136
+ names.splice(0, names.length - RECENT_TOOL_NAMES_WINDOW);
137
+ modState.recentToolNames = names;
138
+ }
128
139
  const messages = [];
129
140
  let revertDetected = false;
130
141
  // 1. Checkpoint (every 5 calls)
@@ -152,8 +163,9 @@ async function main() {
152
163
  // Implicit feedback: repeated edit detection (5+ edits on same file)
153
164
  if (count >= 5) {
154
165
  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({
166
+ appendImplicitFeedback({
156
167
  type: 'repeated_edit',
168
+ category: 'edit',
157
169
  file: filePath,
158
170
  editCount: count,
159
171
  at: new Date().toISOString(),
@@ -172,8 +184,9 @@ async function main() {
172
184
  // Skip the most recent hash (which would be the write being "reverted from")
173
185
  if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
174
186
  revertDetected = true;
175
- recordImplicitFeedback({
187
+ appendImplicitFeedback({
176
188
  type: 'revert_detected',
189
+ category: 'revert',
177
190
  file: filePath,
178
191
  at: new Date().toISOString(),
179
192
  sessionId,
@@ -198,8 +211,9 @@ async function main() {
198
211
  const driftResult = evaluateDrift(modState.drift, true, revertDetected);
199
212
  if (driftResult.message) {
200
213
  messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
201
- recordImplicitFeedback({
214
+ appendImplicitFeedback({
202
215
  type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
216
+ category: 'drift',
203
217
  score: driftResult.score,
204
218
  totalEdits: modState.drift.totalEdits,
205
219
  totalReverts: modState.drift.totalReverts,
@@ -213,8 +227,9 @@ async function main() {
213
227
  const agentResult = validateAgentOutput(toolResponse);
214
228
  if (agentResult) {
215
229
  messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
216
- recordImplicitFeedback({
230
+ appendImplicitFeedback({
217
231
  type: `agent_${agentResult.signal}`,
232
+ category: 'agent',
218
233
  severity: agentResult.severity,
219
234
  outputLength: toolResponse.trim().length,
220
235
  at: new Date().toISOString(),
@@ -254,14 +269,18 @@ async function main() {
254
269
  })() || toolResponse;
255
270
  if (target) {
256
271
  try {
257
- const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
272
+ const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
258
273
  import('../store/rule-store.js'),
259
274
  import('../engine/lifecycle/signals.js'),
260
275
  import('../engine/lifecycle/bypass-detector.js'),
261
276
  import('./shared/safe-regex.js'),
277
+ import('./shared/command-parser.js'),
262
278
  ]);
263
279
  const rules = loadActiveRules();
264
- // Mech-A pattern_match dispatcher
280
+ // Mech-A pattern_match dispatcher — match_target 은 **rule-per-rule**.
281
+ // AWS key / DROP 류 secret/dangerous SQL 은 파일 content 에 들어있어도
282
+ // 실제 leak 이라 raw 검사가 맞고, rm -rf 류 shell 명령은 quote 안 본문이면
283
+ // false-positive 이므로 masked 가 맞다. pre-tool-use 와 동일한 spec 기반 분기.
265
284
  for (const rule of rules) {
266
285
  for (const spec of rule.enforce_via ?? []) {
267
286
  if (spec.hook !== 'PostToolUse' || spec.mech !== 'A')
@@ -277,7 +296,9 @@ async function main() {
277
296
  log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
278
297
  continue;
279
298
  }
280
- if (!safeRegexTest(re.regex, target))
299
+ const matchTarget = (v.params?.match_target ?? 'raw');
300
+ const mechTarget = preprocessForMatch(target, matchTarget);
301
+ if (!safeRegexTest(re.regex, mechTarget))
281
302
  continue;
282
303
  recordViolation({
283
304
  rule_id: rule.rule_id, session_id: sessionId,
@@ -288,8 +309,14 @@ async function main() {
288
309
  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>`);
289
310
  }
290
311
  }
291
- // T3 bypass detection (same rules, same target)
292
- const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: target, session_id: sessionId });
312
+ // T3 bypass detection scanForBypass rule.policy 자연어에서 패턴 추출이라
313
+ // match_target 개념 없음. Write/Edit 파일 본문이라 bypass-detector
314
+ // 자연어 휴리스틱이 false-positive 과다 (L1-no-rm-rf-unconfirmed bypass 20건
315
+ // 중 Write/Edit 15건이 실측). 이 경로만 masked. Bash 는 실제 실행된 명령이라
316
+ // raw 유지. Mech-A pattern_match 는 위에서 rule-per-rule 로 이미 처리.
317
+ const isFileContentTool = toolName === 'Write' || toolName === 'Edit';
318
+ const bypassTarget = isFileContentTool ? preprocessForMatch(target, 'masked') : target;
319
+ const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: bypassTarget, session_id: sessionId });
293
320
  for (const c of candidates) {
294
321
  recordBypass({ rule_id: c.rule_id, session_id: c.session_id, tool: c.tool, pattern_preview: c.pattern_preview });
295
322
  }
@@ -322,5 +349,5 @@ async function main() {
322
349
  }
323
350
  main().catch((e) => {
324
351
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
325
- console.log(failOpenWithTracking('post-tool-use'));
352
+ console.log(failOpenWithTracking('post-tool-use', e));
326
353
  });
@@ -74,7 +74,7 @@ export function buildSessionBrief(sessionId) {
74
74
  }
75
75
  catch { /* fail-open */ }
76
76
  // solutionsInjected: read injection-cache-*.json files, collect solutions[].name
77
- let solutionsInjected = [];
77
+ const solutionsInjected = [];
78
78
  try {
79
79
  if (fs.existsSync(STATE_DIR)) {
80
80
  for (const f of fs.readdirSync(STATE_DIR)) {
@@ -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[];
@@ -19,9 +19,10 @@ import { sanitizeId } from './shared/sanitize-id.js';
19
19
  import { incrementEvidence } from '../engine/solution-writer.js';
20
20
  import { isReflectionCandidate } from './compound-reflection.js';
21
21
  import { isHookEnabled } from './hook-config.js';
22
- import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
22
+ import { approve, approveWithWarning, denyOrObserve, 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
  }
@@ -294,7 +305,7 @@ async function main() {
294
305
  // for `forgen doctor` / log inspection. Mirrors `db-guard.ts:85-96`.
295
306
  const failCount = getAndIncrementFailCount();
296
307
  if (failCount >= FAIL_CLOSE_THRESHOLD) {
297
- console.log(deny(`[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
308
+ console.log(denyOrObserve('pre-tool-use', `[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
298
309
  }
299
310
  else {
300
311
  process.stderr.write(`[ch-hook] pre-tool-use stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
@@ -315,10 +326,11 @@ async function main() {
315
326
  // 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
316
327
  // fail-open: 예외는 hook 차단 안 함.
317
328
  try {
318
- const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
329
+ const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
319
330
  import('../store/rule-store.js'),
320
331
  import('../engine/lifecycle/signals.js'),
321
332
  import('./shared/safe-regex.js'),
333
+ import('./shared/command-parser.js'),
322
334
  ]);
323
335
  const rules = loadActiveRules();
324
336
  const command = typeof toolInput.command === 'string'
@@ -339,7 +351,13 @@ async function main() {
339
351
  log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
340
352
  continue;
341
353
  }
342
- if (!safeRegexTest(re.regex, command))
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))
343
361
  continue;
344
362
  const requiresFlag = v.params?.requires_flag;
345
363
  const confirmed = process.env.FORGEN_USER_CONFIRMED === '1';
@@ -348,7 +366,7 @@ async function main() {
348
366
  const baseMsg = spec.block_message ?? `[${rule.rule_id}] policy violation: ${rule.policy.slice(0, 120)}`;
349
367
  // G8: override 힌트 — FORGEN_USER_CONFIRMED=1 으로 사용자 명시 승인 가능, 감사 로그 기록됨.
350
368
  const msgWithHint = `${baseMsg}\n\n(override: set FORGEN_USER_CONFIRMED=1 (bypass will be audited in violations.jsonl))`;
351
- console.log(deny(msgWithHint));
369
+ console.log(denyOrObserve('pre-tool-use', msgWithHint));
352
370
  return;
353
371
  }
354
372
  if (requiresFlag && confirmed) {
@@ -370,7 +388,7 @@ async function main() {
370
388
  // Bash 도구: 위험 명령어 감지 (빌트인 safety net)
371
389
  const check = checkDangerousCommand(toolName, toolInput);
372
390
  if (check.action === 'block') {
373
- console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
391
+ console.log(denyOrObserve('pre-tool-use', `[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
374
392
  return;
375
393
  }
376
394
  if (check.action === 'warn') {
@@ -413,5 +431,5 @@ main().catch((e) => {
413
431
  });
414
432
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
415
433
  // fail-open: approve on internal error to avoid blocking all tool calls
416
- console.log(failOpenWithTracking('pre-tool-use'));
434
+ console.log(failOpenWithTracking('pre-tool-use', e));
417
435
  });
@@ -10,7 +10,7 @@ import * as path from 'node:path';
10
10
  import { readStdinJSON } from './shared/read-stdin.js';
11
11
  import { atomicWriteJSON } from './shared/atomic-write.js';
12
12
  import { isHookEnabled } from './hook-config.js';
13
- import { approve, deny, failOpenWithTracking } from './shared/hook-response.js';
13
+ import { approve, denyOrObserve, failOpenWithTracking } from './shared/hook-response.js';
14
14
  import { STATE_DIR } from '../core/paths.js';
15
15
  const RATE_LIMIT_PATH = path.join(STATE_DIR, 'rate-limit.json');
16
16
  const DEFAULT_LIMIT = 30; // calls per minute
@@ -75,12 +75,12 @@ async function main() {
75
75
  saveRateLimitState(updatedState);
76
76
  }
77
77
  if (exceeded) {
78
- console.log(deny(`[Forgen] Rate limit exceeded (${count}/${DEFAULT_LIMIT}/min). Wait before retrying.`));
78
+ console.log(denyOrObserve('rate-limiter', `[Forgen] Rate limit exceeded (${count}/${DEFAULT_LIMIT}/min). Wait before retrying.`));
79
79
  return;
80
80
  }
81
81
  console.log(approve());
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
  });
@@ -87,5 +87,5 @@ main().catch((e) => {
87
87
  hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
88
88
  });
89
89
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
90
- console.log(failOpenWithTracking('secret-filter'));
90
+ console.log(failOpenWithTracking('secret-filter', e));
91
91
  });
@@ -320,6 +320,17 @@ async function main() {
320
320
  }
321
321
  }
322
322
  catch { /* fail-open */ }
323
+ // US-M1 (RC6 가드): 직전 forge-loop findings 또는 진행 중 stories 자동 inject.
324
+ // 본 세션 자기증거 — head -80 truncation 으로 findings 누락 → 같은 가설 재발.
325
+ try {
326
+ const { readForgeLoopState, renderForgeLoopForSession } = await import('./shared/forge-loop-state.js');
327
+ const block = renderForgeLoopForSession(readForgeLoopState());
328
+ if (block)
329
+ recoveryMessages.push(block);
330
+ }
331
+ catch (e) {
332
+ log.debug('forge-loop findings inject 실패', e);
333
+ }
323
334
  const sessionId = sessionContext.sessionId;
324
335
  // 이전 세션 자동 compound (fire-and-forget)
325
336
  // /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
@@ -429,6 +440,6 @@ async function main() {
429
440
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
430
441
  main().catch((e) => {
431
442
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
432
- console.log(failOpenWithTracking('session-recovery'));
443
+ console.log(failOpenWithTracking('session-recovery', e));
433
444
  });
434
445
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Blocking ALLOW-LIST — P3' (2026-04-27)
3
+ *
4
+ * 사용자 작업을 차단(block)할 권한을 가진 hook 의 명시적 화이트리스트.
5
+ * 목록 외 hook 의 부정적 판정은 "관찰 신호"(log only) 로만 처리되어야 한다.
6
+ *
7
+ * RC5 (retro-v040): 분산된 detector 가 각자 block 결정을 내리면서 false-positive
8
+ * 가 메인 로직 흐름까지 차단하는 회귀 패턴 발생. ALLOW-LIST 명시화로 차단 권한
9
+ * 의 source-of-truth 를 단일화.
10
+ *
11
+ * v0.4.2 정책:
12
+ * - 본 모듈은 ALLOW-LIST 정의 + 검증 helper. 기존 deny() 직접 호출 hook 들은
13
+ * v0.4.2 에서 denyOrObserve(name, reason) 로 마이그레이션 완료.
14
+ * - 신규 hook 추가 시 차단 권한이 필요하면 본 ALLOW-LIST 에 추가 + 본 파일의
15
+ * 사유 문서화 의무. 본 commit diff 가 review 필수 항목.
16
+ *
17
+ * 멤버 사유:
18
+ * - stop-guard: Stop hook — false-completion 메타 가드 (자가 검증 강제)
19
+ * - pre-tool-use: Bash dangerous-pattern + 수동 confirm 가드
20
+ * - secret-filter: Write/Edit 결과의 .env / API key 노출 차단
21
+ * - db-guard: Bash 의 destructive DB 명령 (DROP/TRUNCATE/DELETE) 차단
22
+ * - rate-limiter: 사용자 작업 빈도 임계 초과 시 cool-down 차단 (resource abuse 방어)
23
+ */
24
+ export declare const BLOCKING_ALLOWLIST: ReadonlySet<string>;
25
+ /** hook 이 block 결정을 출력할 권한이 있는지. */
26
+ export declare function canBlock(hookName: string): boolean;
27
+ /** ALLOW-LIST 에 추가하려는 hook 이 정책 문서화를 요구하는지 (lint helper). */
28
+ export declare function requiresPolicyDoc(hookName: string): boolean;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Blocking ALLOW-LIST — P3' (2026-04-27)
3
+ *
4
+ * 사용자 작업을 차단(block)할 권한을 가진 hook 의 명시적 화이트리스트.
5
+ * 목록 외 hook 의 부정적 판정은 "관찰 신호"(log only) 로만 처리되어야 한다.
6
+ *
7
+ * RC5 (retro-v040): 분산된 detector 가 각자 block 결정을 내리면서 false-positive
8
+ * 가 메인 로직 흐름까지 차단하는 회귀 패턴 발생. ALLOW-LIST 명시화로 차단 권한
9
+ * 의 source-of-truth 를 단일화.
10
+ *
11
+ * v0.4.2 정책:
12
+ * - 본 모듈은 ALLOW-LIST 정의 + 검증 helper. 기존 deny() 직접 호출 hook 들은
13
+ * v0.4.2 에서 denyOrObserve(name, reason) 로 마이그레이션 완료.
14
+ * - 신규 hook 추가 시 차단 권한이 필요하면 본 ALLOW-LIST 에 추가 + 본 파일의
15
+ * 사유 문서화 의무. 본 commit diff 가 review 필수 항목.
16
+ *
17
+ * 멤버 사유:
18
+ * - stop-guard: Stop hook — false-completion 메타 가드 (자가 검증 강제)
19
+ * - pre-tool-use: Bash dangerous-pattern + 수동 confirm 가드
20
+ * - secret-filter: Write/Edit 결과의 .env / API key 노출 차단
21
+ * - db-guard: Bash 의 destructive DB 명령 (DROP/TRUNCATE/DELETE) 차단
22
+ * - rate-limiter: 사용자 작업 빈도 임계 초과 시 cool-down 차단 (resource abuse 방어)
23
+ */
24
+ export const BLOCKING_ALLOWLIST = new Set([
25
+ 'stop-guard',
26
+ 'pre-tool-use',
27
+ 'secret-filter',
28
+ 'db-guard',
29
+ 'rate-limiter',
30
+ ]);
31
+ /** hook 이 block 결정을 출력할 권한이 있는지. */
32
+ export function canBlock(hookName) {
33
+ return BLOCKING_ALLOWLIST.has(hookName);
34
+ }
35
+ /** ALLOW-LIST 에 추가하려는 hook 이 정책 문서화를 요구하는지 (lint helper). */
36
+ export function requiresPolicyDoc(hookName) {
37
+ return !BLOCKING_ALLOWLIST.has(hookName);
38
+ }
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Forge Loop State — RC6 가드 (US-M1)
3
+ *
4
+ * 직전 forge-loop 의 findings 또는 진행 중 stories 를 ≤1KB 요약으로 렌더한다.
5
+ * SessionStart 와 UserPromptSubmit 두 hook 이 공유하는 단일 진입점.
6
+ *
7
+ * RC6 자기증거: 본 세션 R1 에서 head -80 으로 forge-loop.json 을 읽어 findings
8
+ * 8줄(line 92~99)이 잘렸음. 결과적으로 직전 결론을 컨텍스트에 못 가져 같은
9
+ * 가설을 재발. 이 모듈은 그 회귀를 시스템 레벨에서 차단한다.
10
+ */
11
+ interface ForgeLoopStory {
12
+ id: string;
13
+ title: string;
14
+ passes?: boolean;
15
+ }
16
+ export interface ForgeLoopState {
17
+ active?: boolean;
18
+ task?: string;
19
+ startedAt?: string;
20
+ completedAt?: string;
21
+ stories?: ForgeLoopStory[];
22
+ findings?: Record<string, string>;
23
+ }
24
+ export declare function readForgeLoopState(filePath?: string): ForgeLoopState | null;
25
+ /** SessionStart 용 — 완료된 forge-loop 의 findings 또는 진행 중 stories 요약. */
26
+ export declare function renderForgeLoopForSession(state: ForgeLoopState | null, now?: number): string | null;
27
+ /** UserPromptSubmit 용 — active=true 시에만 짧은 진행 상황 1~2줄. */
28
+ export declare function renderForgeLoopForPrompt(state: ForgeLoopState | null, now?: number): string | null;
29
+ /** 테스트 노출용 상수 — 회귀 시 임계값 변경 즉시 감지. */
30
+ export declare const FORGE_LOOP_LIMITS: {
31
+ readonly SOFT_STALE_MS: number;
32
+ readonly HARD_STALE_MS: number;
33
+ readonly MAX_INJECT_BYTES: 1024;
34
+ readonly MAX_PENDING: 5;
35
+ };
36
+ export {};