@wooojin/forgen 0.3.1 → 0.4.0

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 (125) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +164 -0
  3. package/README.ja.md +90 -7
  4. package/README.ko.md +44 -1
  5. package/README.md +128 -9
  6. package/README.zh.md +90 -7
  7. package/dist/cli.js +140 -8
  8. package/dist/core/auto-compound-runner.js +16 -5
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +85 -11
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/inspect-cli.js +65 -5
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +0 -34
  20. package/dist/core/paths.js +0 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +49 -0
  27. package/dist/core/state-gc.js +163 -0
  28. package/dist/core/stats-cli.d.ts +15 -0
  29. package/dist/core/stats-cli.js +143 -0
  30. package/dist/core/uninstall.d.ts +1 -0
  31. package/dist/core/uninstall.js +36 -5
  32. package/dist/core/v1-bootstrap.js +11 -3
  33. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  34. package/dist/engine/classify-enforce-cli.js +61 -0
  35. package/dist/engine/compound-cli.d.ts +27 -2
  36. package/dist/engine/compound-cli.js +69 -16
  37. package/dist/engine/compound-export.d.ts +15 -0
  38. package/dist/engine/compound-export.js +32 -5
  39. package/dist/engine/compound-loop.js +3 -2
  40. package/dist/engine/enforce-classifier.d.ts +31 -0
  41. package/dist/engine/enforce-classifier.js +123 -0
  42. package/dist/engine/learn-cli.js +52 -0
  43. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  44. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  45. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  46. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  47. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  48. package/dist/engine/lifecycle/meta-cli.js +7 -0
  49. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  50. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  51. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  52. package/dist/engine/lifecycle/orchestrator.js +131 -0
  53. package/dist/engine/lifecycle/signals.d.ts +30 -0
  54. package/dist/engine/lifecycle/signals.js +142 -0
  55. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  56. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  57. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  58. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  59. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  60. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  61. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  62. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  63. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  64. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  65. package/dist/engine/lifecycle/types.d.ts +52 -0
  66. package/dist/engine/lifecycle/types.js +7 -0
  67. package/dist/engine/match-eval-log.js +45 -0
  68. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  69. package/dist/engine/rule-toggle-cli.js +76 -0
  70. package/dist/engine/solution-format.d.ts +0 -2
  71. package/dist/engine/solution-format.js +0 -4
  72. package/dist/engine/solution-matcher.d.ts +8 -0
  73. package/dist/engine/solution-matcher.js +7 -4
  74. package/dist/engine/solution-outcomes.d.ts +4 -0
  75. package/dist/engine/solution-outcomes.js +174 -97
  76. package/dist/engine/solution-writer.d.ts +8 -5
  77. package/dist/engine/solution-writer.js +43 -19
  78. package/dist/fgx.js +9 -2
  79. package/dist/forge/cli.js +7 -7
  80. package/dist/forge/evidence-processor.js +10 -2
  81. package/dist/hooks/context-guard.js +86 -1
  82. package/dist/hooks/hook-config.d.ts +9 -1
  83. package/dist/hooks/hook-config.js +25 -3
  84. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  85. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  86. package/dist/hooks/notepad-injector.js +6 -3
  87. package/dist/hooks/permission-handler.d.ts +10 -2
  88. package/dist/hooks/permission-handler.js +31 -12
  89. package/dist/hooks/post-tool-use.js +62 -0
  90. package/dist/hooks/pre-tool-use.js +67 -5
  91. package/dist/hooks/secret-filter.d.ts +10 -0
  92. package/dist/hooks/secret-filter.js +26 -0
  93. package/dist/hooks/session-recovery.js +15 -7
  94. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  95. package/dist/hooks/shared/atomic-write.js +17 -3
  96. package/dist/hooks/shared/hook-response.d.ts +11 -2
  97. package/dist/hooks/shared/hook-response.js +20 -7
  98. package/dist/hooks/shared/hook-timing.js +10 -1
  99. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  100. package/dist/hooks/shared/safe-regex.js +50 -0
  101. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  102. package/dist/hooks/shared/stop-triggers.js +19 -0
  103. package/dist/hooks/solution-injector.d.ts +21 -0
  104. package/dist/hooks/solution-injector.js +60 -1
  105. package/dist/hooks/stop-guard.d.ts +84 -0
  106. package/dist/hooks/stop-guard.js +482 -0
  107. package/dist/mcp/solution-reader.d.ts +2 -0
  108. package/dist/mcp/solution-reader.js +28 -1
  109. package/dist/mcp/tools.js +24 -4
  110. package/dist/preset/preset-manager.js +12 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +55 -6
  113. package/dist/store/profile-store.d.ts +9 -0
  114. package/dist/store/profile-store.js +25 -4
  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 +133 -13
  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 +10 -2
  124. package/plugin.json +7 -2
  125. package/scripts/postinstall.js +52 -5
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
+ import * as os from 'node:os';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { createLogger } from '../core/logger.js';
16
17
  import { readStdinJSON } from './shared/read-stdin.js';
@@ -19,6 +20,7 @@ import { loadHookConfig, isHookEnabled } from './hook-config.js';
19
20
  import { approve, approveWithContext, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
20
21
  import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
21
22
  import { recordHookTiming } from './shared/hook-timing.js';
23
+ import { sanitizeId } from './shared/sanitize-id.js';
22
24
  const log = createLogger('context-guard');
23
25
  const CONTEXT_STATE_PATH = path.join(STATE_DIR, 'context-guard.json');
24
26
  // 경고 임계값: 프롬프트 50회 또는 총 문자 수 200K 이상
@@ -89,6 +91,17 @@ export async function main() {
89
91
  // Stop 훅: stop_hook_type이 있으면 처리
90
92
  if (input.stop_hook_type) {
91
93
  _hookEvent = 'Stop';
94
+ // 세션 종료 시 pending outcome을 unknown으로 finalize.
95
+ // 과거에는 프로덕션에서 호출되지 않아 pending이 다음 세션의 flushAccept에
96
+ // accept로 쓸려들어가는 구조적 optimistic bias가 있었다 (2026-04-20).
97
+ // finalizeSession은 idempotent (pending 없으면 0 반환, 에러는 log.debug만).
98
+ try {
99
+ const { finalizeSession } = await import('../engine/solution-outcomes.js');
100
+ finalizeSession(sessionId);
101
+ }
102
+ catch (e) {
103
+ log.debug('finalizeSession 실패 (fail-open)', e);
104
+ }
92
105
  // forge-loop 활성 시 미완료 스토리 감지 → 지속 메시지 주입 (polite-stop 방지)
93
106
  const forgeLoopBlock = checkForgeLoopActive();
94
107
  if (forgeLoopBlock) {
@@ -117,6 +130,16 @@ export async function main() {
117
130
  // 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
118
131
  if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
119
132
  const state = loadContextState(sessionId);
133
+ // ADR-002 T1 — 세션 중간에 교정이 들어와도 session-scoped rule 이 me-scope 으로
134
+ // 승급되도록 Stop 에서 직접 auto-compound-runner 를 debounced 로 트리거.
135
+ // 'forgen' CLI 를 통하지 않는 사용자 (claude 직접 실행) 에게도 교정이 유실되지 않는 보장.
136
+ // dedup: last-auto-compound.json 의 sessionId + 5분 cooldown.
137
+ try {
138
+ await maybeSpawnAutoCompound(sessionId, input.transcript_path, state.promptCount);
139
+ }
140
+ catch (e) {
141
+ log.debug('auto-compound Stop trigger 실패', e);
142
+ }
120
143
  if (state.promptCount >= 20) {
121
144
  // 20+ prompts: auto-trigger compound by writing marker
122
145
  try {
@@ -181,7 +204,9 @@ export async function main() {
181
204
  */
182
205
  function buildSessionSummary(sessionId, promptCount) {
183
206
  try {
184
- const cachePath = path.join(STATE_DIR, `solution-cache-${sessionId}.json`);
207
+ // P1-S3 fix (2026-04-20): sanitizeId로 path traversal 차단.
208
+ // 다른 세션 캐시 경로는 모두 sanitizeId 사용. 여기만 누락되어 있었다.
209
+ const cachePath = path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
185
210
  if (!fs.existsSync(cachePath))
186
211
  return '';
187
212
  const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
@@ -210,6 +235,66 @@ function buildSessionSummary(sessionId, promptCount) {
210
235
  }
211
236
  // forge-loop 상태 파일 경로
212
237
  const FORGE_LOOP_STATE_PATH = path.join(STATE_DIR, 'forge-loop.json');
238
+ /**
239
+ * Stop hook 에서 auto-compound-runner 를 debounced 로 spawn.
240
+ *
241
+ * 호출 조건:
242
+ * - promptCount ≥ 10 (의미있는 세션)
243
+ * - transcript_path 유효
244
+ * - last-auto-compound.json 의 sessionId 가 다르거나 5분 전
245
+ *
246
+ * dedup 파일은 session-recovery hook 과 공유되어 double-run 방지.
247
+ * fire-and-forget (detached) — hook timeout 과 무관.
248
+ */
249
+ const AUTO_COMPOUND_COOLDOWN_MS = 5 * 60 * 1000; // 5 min
250
+ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
251
+ if (!transcriptPath || promptCount < 10)
252
+ return;
253
+ const markerPath = path.join(STATE_DIR, 'last-auto-compound.json');
254
+ try {
255
+ const raw = fs.readFileSync(markerPath, 'utf-8');
256
+ const parsed = JSON.parse(raw);
257
+ if (parsed.sessionId === sessionId) {
258
+ const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
259
+ if (Number.isFinite(last) && Date.now() - last < AUTO_COMPOUND_COOLDOWN_MS)
260
+ return;
261
+ }
262
+ }
263
+ catch { /* first time or corrupt — proceed */ }
264
+ const { spawn: spawnProcess } = await import('node:child_process');
265
+ const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
266
+ // 기본: 번들된 auto-compound-runner. 프로덕션 빌드는 이 경로만 실행.
267
+ const defaultRunner = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js');
268
+ // 테스트 주입 경로 — FORGEN_TEST=1 게이트 + 경로 containment (~/.forgen 또는 /tmp 하위만 허용).
269
+ // FORGEN_TEST 없이 FORGEN_AUTO_COMPOUND_RUNNER_PATH 만 설정되어도 무시 → 임의 코드 실행 방지.
270
+ let runnerPath = defaultRunner;
271
+ const override = process.env.FORGEN_AUTO_COMPOUND_RUNNER_PATH;
272
+ if (override && process.env.FORGEN_TEST === '1') {
273
+ const resolved = path.resolve(override);
274
+ const homeDir = os.homedir();
275
+ const allowed = [
276
+ path.join(homeDir, '.forgen'),
277
+ os.tmpdir(), // 플랫폼별 /tmp, /var/folders/... 등
278
+ '/tmp',
279
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
280
+ ];
281
+ if (allowed.some((root) => resolved === root || resolved.startsWith(root + path.sep))) {
282
+ runnerPath = resolved;
283
+ }
284
+ else {
285
+ log.debug(`FORGEN_AUTO_COMPOUND_RUNNER_PATH 무시 — ${resolved} 가 허용 루트 밖`);
286
+ }
287
+ }
288
+ else if (override) {
289
+ log.debug('FORGEN_AUTO_COMPOUND_RUNNER_PATH 무시 — FORGEN_TEST=1 가 필요');
290
+ }
291
+ const child = spawnProcess('node', [runnerPath, cwd, transcriptPath, sessionId], {
292
+ detached: true,
293
+ stdio: 'ignore',
294
+ });
295
+ child.unref();
296
+ log.debug(`Stop-triggered auto-compound 시작: ${sessionId} (${promptCount} prompts)`);
297
+ }
213
298
  // forge-loop 차단 안전 상한 (무한 루프 방지)
214
299
  const FORGE_LOOP_MAX_BLOCKS = 30;
215
300
  const FORGE_LOOP_STALE_MS = 2 * 60 * 60 * 1000; // 2시간
@@ -25,6 +25,8 @@
25
25
  */
26
26
  /** 훅 설정 파일의 전체 구조 타입 */
27
27
  export type HookConfig = Record<string, unknown>;
28
+ /** 테스트/진단용: 보호된 훅 이름 집합 스냅샷. */
29
+ export declare function getProtectedHookNames(): string[];
28
30
  /**
29
31
  * 프로젝트의 작업 디렉토리를 결정합니다.
30
32
  * FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
@@ -46,9 +48,15 @@ export declare function loadHookConfig(hookName: string): Record<string, unknown
46
48
  /**
47
49
  * 훅이 활성화되어 있는지 확인합니다.
48
50
  *
51
+ * Invariant: compound-core 티어 및 compoundCritical=true 훅은 어떤 config
52
+ * 경로(개별 hooks / tier / 레거시)로도 비활성화되지 않는다. config 값과 무관하게
53
+ * 항상 true를 반환한다. 이는 복리화 3축(승급/rollback/피드백)을 project-level
54
+ * config 실수로 조용히 끄는 dual-path를 차단하는 단일 진입점 가드다.
55
+ *
49
56
  * 우선순위:
57
+ * 0. PROTECTED_HOOKS에 속하면 → 즉시 true (가드레일)
50
58
  * 1. hooks.hookName.enabled (개별 훅 설정)
51
- * 2. tiers.tierName.enabled (티어 설정) — compound-core는 티어 비활성화 무시
59
+ * 2. tiers.tierName.enabled (티어 설정)
52
60
  * 3. hookName.enabled (레거시 형식)
53
61
  * 4. 기본값 true (하위호환)
54
62
  */
@@ -33,6 +33,19 @@ const GLOBAL_CONFIG_PATH = path.join(FORGEN_HOME, 'hook-config.json');
33
33
  * 이중 구현 방지: HOOK_REGISTRY가 단일 소스 오브 트루스.
34
34
  */
35
35
  const HOOK_TIER_MAP = Object.fromEntries(HOOK_REGISTRY.map(h => [h.name, h.tier]));
36
+ /**
37
+ * compound-core 티어이거나 compoundCritical=true로 선언된 훅은 project/글로벌
38
+ * config의 어떤 경로로도 비활성화할 수 없다. 복리화 피드백 루프(승급·outcome
39
+ * 추적·세션 복구)를 project-level 설정 실수로 조용히 끄는 것을 차단한다.
40
+ * (feedback_core_loop_invariant — 2026-04-20)
41
+ */
42
+ const PROTECTED_HOOKS = new Set(HOOK_REGISTRY
43
+ .filter(h => h.tier === 'compound-core' || h.compoundCritical === true)
44
+ .map(h => h.name));
45
+ /** 테스트/진단용: 보호된 훅 이름 집합 스냅샷. */
46
+ export function getProtectedHookNames() {
47
+ return [...PROTECTED_HOOKS].sort();
48
+ }
36
49
  /**
37
50
  * 프로젝트의 작업 디렉토리를 결정합니다.
38
51
  * FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
@@ -120,13 +133,22 @@ export function loadHookConfig(hookName) {
120
133
  /**
121
134
  * 훅이 활성화되어 있는지 확인합니다.
122
135
  *
136
+ * Invariant: compound-core 티어 및 compoundCritical=true 훅은 어떤 config
137
+ * 경로(개별 hooks / tier / 레거시)로도 비활성화되지 않는다. config 값과 무관하게
138
+ * 항상 true를 반환한다. 이는 복리화 3축(승급/rollback/피드백)을 project-level
139
+ * config 실수로 조용히 끄는 dual-path를 차단하는 단일 진입점 가드다.
140
+ *
123
141
  * 우선순위:
142
+ * 0. PROTECTED_HOOKS에 속하면 → 즉시 true (가드레일)
124
143
  * 1. hooks.hookName.enabled (개별 훅 설정)
125
- * 2. tiers.tierName.enabled (티어 설정) — compound-core는 티어 비활성화 무시
144
+ * 2. tiers.tierName.enabled (티어 설정)
126
145
  * 3. hookName.enabled (레거시 형식)
127
146
  * 4. 기본값 true (하위호환)
128
147
  */
129
148
  export function isHookEnabled(hookName) {
149
+ // 0) compound-core 가드레일 — config 어떤 경로로도 끌 수 없음
150
+ if (PROTECTED_HOOKS.has(hookName))
151
+ return true;
130
152
  const all = loadFullConfig();
131
153
  if (!all)
132
154
  return true;
@@ -136,9 +158,9 @@ export function isHookEnabled(hookName) {
136
158
  return false;
137
159
  if (hooksSection?.[hookName]?.enabled === true)
138
160
  return true;
139
- // 2) 티어 설정 — compound-core는 절대 티어 비활성화로 끄지 않음
161
+ // 2) 티어 설정
140
162
  const tier = HOOK_TIER_MAP[hookName];
141
- if (tier && tier !== 'compound-core') {
163
+ if (tier) {
142
164
  const tiers = all.tiers;
143
165
  if (tiers?.[tier]?.enabled === false)
144
166
  return false;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — Internal runner: compound lifecycle check.
4
+ *
5
+ * Spawned by `session-recovery.ts` as a detached background process.
6
+ * Exists as a dedicated script file so the caller can pass `sessionId`
7
+ * via argv instead of interpolating it into a `-e` template literal.
8
+ *
9
+ * Audit finding #5 (2026-04-21): prior call site used
10
+ * spawn('node', ['--input-type=module', '-e',
11
+ * `import('${path}').then(m => m.runLifecycleCheck('${sessionId}'))`])
12
+ * which interpolated `sessionId` (originating from hook stdin) into
13
+ * executable JS source. An attacker-controlled session id of the shape
14
+ * `a'); malicious(); //` would have executed arbitrary JS under the
15
+ * user's Claude-Code privileges. A dedicated script + argv lookup has
16
+ * no shell or eval surface.
17
+ *
18
+ * Contract: `process.argv[2]` is the session id. Any extra args are
19
+ * ignored. stdout/stderr are ignored by the caller (`stdio: 'ignore'`).
20
+ */
21
+ import { runLifecycleCheck } from '../../engine/compound-lifecycle.js';
22
+ const sessionId = process.argv[2];
23
+ if (!sessionId || typeof sessionId !== 'string') {
24
+ process.exit(0);
25
+ }
26
+ try {
27
+ runLifecycleCheck(sessionId);
28
+ }
29
+ catch {
30
+ // Detached background — best effort. Surfacing errors would have no
31
+ // consumer and the parent hook already logged the spawn.
32
+ }
@@ -21,6 +21,7 @@ import { isHookEnabled } from './hook-config.js';
21
21
  import { truncateContent } from './shared/injection-caps.js';
22
22
  import { calculateBudget } from './shared/context-budget.js';
23
23
  import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
24
+ import { escapeAllXmlTags } from './prompt-injection-filter.js';
24
25
  // ── 메인 ──
25
26
  async function main() {
26
27
  const input = await readStdinJSON();
@@ -39,9 +40,11 @@ async function main() {
39
40
  console.log(approve());
40
41
  return;
41
42
  }
42
- // 태그 이스케이프: notepad 내용 내의 닫는 태그를 안전하게 처리
43
- const safeContent = truncateContent(notepadContent.trim(), calculateBudget(effectiveCwd).notepadMax)
44
- .replace(/<\/forgen-notepad>/g, '&lt;/forgen-notepad&gt;');
43
+ // P1-S2 fix (2026-04-20): 이전에는 `</forgen-notepad>` 리터럴 하나만 치환했지만,
44
+ // notepad 파일에 `<system>`, `<assistant>` 같은 임의 XML 태그가 있으면 그대로
45
+ // LLM에 전달되어 지시 주입 위험. escapeAllXmlTags로 모든 태그를 escape한다.
46
+ const truncated = truncateContent(notepadContent.trim(), calculateBudget(effectiveCwd).notepadMax);
47
+ const safeContent = escapeAllXmlTags(truncated);
45
48
  const injection = `<forgen-notepad>\n${safeContent}\n</forgen-notepad>`;
46
49
  console.log(approveWithContext(injection, 'UserPromptSubmit'));
47
50
  }
@@ -10,5 +10,13 @@
10
10
  export declare const SAFE_TOOLS: Set<string>;
11
11
  /** autopilot 모드에서도 수동 확인이 필요한 도구 */
12
12
  export declare const ALWAYS_CONFIRM_TOOLS: Set<string>;
13
- /** 도구 분류: 승인/확인/통과 결정 (순수 함수) */
14
- export declare function classifyTool(toolName: string, isAutopilot: boolean): 'auto-approve-safe' | 'autopilot-confirm' | 'autopilot-approve' | 'pass-through';
13
+ /**
14
+ * 도구 분류: pass-through 결정 (순수 함수).
15
+ *
16
+ * Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
17
+ * 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
18
+ * 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
19
+ * `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
20
+ * 실제 실행 신뢰도와 어긋났다.
21
+ */
22
+ export declare function classifyTool(toolName: string, isAutopilot: boolean): 'safe-pass-through' | 'autopilot-warn-pass-through' | 'autopilot-pass-through' | 'pass-through';
@@ -24,15 +24,23 @@ export const SAFE_TOOLS = new Set([
24
24
  export const ALWAYS_CONFIRM_TOOLS = new Set([
25
25
  'Bash', 'Write', 'Edit',
26
26
  ]);
27
- /** 도구 분류: 승인/확인/통과 결정 (순수 함수) */
27
+ /**
28
+ * 도구 분류: pass-through 결정 (순수 함수).
29
+ *
30
+ * Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
31
+ * 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
32
+ * 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
33
+ * `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
34
+ * 실제 실행 신뢰도와 어긋났다.
35
+ */
28
36
  export function classifyTool(toolName, isAutopilot) {
29
37
  if (SAFE_TOOLS.has(toolName))
30
- return 'auto-approve-safe';
38
+ return 'safe-pass-through';
31
39
  if (!isAutopilot)
32
40
  return 'pass-through';
33
41
  if (ALWAYS_CONFIRM_TOOLS.has(toolName))
34
- return 'autopilot-confirm';
35
- return 'autopilot-approve';
42
+ return 'autopilot-warn-pass-through';
43
+ return 'autopilot-pass-through';
36
44
  }
37
45
  /** autopilot 모드 활성 여부 확인 */
38
46
  function isAutopilotActive() {
@@ -80,9 +88,17 @@ async function main() {
80
88
  }
81
89
  const toolName = data.tool_name ?? data.toolName ?? '';
82
90
  const sessionId = data.session_id ?? 'default';
83
- // 안전 도구는 항상 승인
91
+ // Audit note #4 (2026-04-21): `approve()` / `approveWithWarning()` 둘 다
92
+ // Claude Code hook protocol에서 `permissionDecision: 'allow'`를 설정하지
93
+ // 않는다. 따라서 본 훅은 실제로 도구 실행을 "승인(force-allow)"하지 않고,
94
+ // Claude의 기본 권한 흐름으로 pass-through 시킨다 (systemMessage UI 경고는
95
+ // 선택사항). 과거 로그에서 `auto-approve-safe` / `autopilot-approve` 같은
96
+ // 결정 이름이 실제 효과와 어긋났기에 로그 라벨을 실효에 맞춰 정정했다.
97
+ //
98
+ // SAFE_TOOLS (Read/Glob/Grep 등): Claude 기본 정책상 이미 허용되는 도구이므로
99
+ // 이곳에서 별도 장치 없이 pass-through. 로그는 `safe-pass-through`로 기록.
84
100
  if (SAFE_TOOLS.has(toolName)) {
85
- logPermissionRequest(sessionId, toolName, 'auto-approve-safe');
101
+ logPermissionRequest(sessionId, toolName, 'safe-pass-through');
86
102
  console.log(approve());
87
103
  return;
88
104
  }
@@ -94,18 +110,21 @@ async function main() {
94
110
  }
95
111
  // autopilot 모드 (2차 방어선):
96
112
  // pre-tool-use 훅이 위험 패턴(rm -rf, git push --force 등)을 이미 block/warn 처리함.
97
- // 여기 도달하는 도구는 pre-tool-use를 통과한 것이므로, 승인하되 메시지로 추적 가능하게 함.
113
+ // 여기 도달하는 도구는 pre-tool-use를 통과한 것으로 pass-through + UI 경고.
114
+ // 여전히 Claude의 기본 confirmation은 사용자에게 노출된다 — 본 훅이 전체
115
+ // 승인을 가로채는 게 아니라 추적성을 위한 어노테이션이다.
98
116
  if (ALWAYS_CONFIRM_TOOLS.has(toolName)) {
99
- logPermissionRequest(sessionId, toolName, 'autopilot-confirm');
117
+ logPermissionRequest(sessionId, toolName, 'autopilot-warn-pass-through');
100
118
  // Bash는 pre-tool-use를 통과했더라도 경고 강도를 높임 (임의 셸 실행 위험)
101
119
  const warningLevel = toolName === 'Bash'
102
- ? `[Forgen] ⚠ Autopilot: Bash tool auto-approved — passed pre-tool-use validation. Beware of unexpected commands.`
103
- : `[Forgen] Autopilot: ${toolName} tool execution auto-approved.`;
120
+ ? `[Forgen] ⚠ Autopilot: Bash tool — passed pre-tool-use validation. Beware of unexpected commands.`
121
+ : `[Forgen] Autopilot: ${toolName} tool use passed through with warning.`;
104
122
  console.log(approveWithWarning(`<compound-permission>\n${warningLevel}\n</compound-permission>`));
105
123
  return;
106
124
  }
107
- // 기타 도구: autopilot 모드에서 자동 승인
108
- logPermissionRequest(sessionId, toolName, 'autopilot-approve');
125
+ // 기타 도구: autopilot 모드에서도 pass-through (force-approve 아님).
126
+ // 과거 로그 라벨은 `autopilot-approve`였으나 실제 효과는 pass-through.
127
+ logPermissionRequest(sessionId, toolName, 'autopilot-pass-through');
109
128
  console.log(approve());
110
129
  }
111
130
  main().catch((e) => {
@@ -237,6 +237,68 @@ async function main() {
237
237
  catch (e) {
238
238
  log.debug('compound negative check 실패', e);
239
239
  }
240
+ // 6a+b. ADR-001 Mech-A PostToolUse + T3 bypass — single rule load, 두 dispatcher 공유.
241
+ // R2-P perf: 이전에는 6a, 6b 각각 loadActiveRules() 재호출 → file read 2배.
242
+ if (toolName === 'Write' || toolName === 'Edit' || toolName === 'Bash') {
243
+ const target = (() => {
244
+ const c = toolInput.content;
245
+ if (typeof c === 'string')
246
+ return c;
247
+ const ns = toolInput.new_string;
248
+ if (typeof ns === 'string')
249
+ return ns;
250
+ const cmd = toolInput.command;
251
+ if (typeof cmd === 'string')
252
+ return cmd;
253
+ return '';
254
+ })() || toolResponse;
255
+ if (target) {
256
+ try {
257
+ const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
258
+ import('../store/rule-store.js'),
259
+ import('../engine/lifecycle/signals.js'),
260
+ import('../engine/lifecycle/bypass-detector.js'),
261
+ import('./shared/safe-regex.js'),
262
+ ]);
263
+ const rules = loadActiveRules();
264
+ // Mech-A pattern_match dispatcher
265
+ for (const rule of rules) {
266
+ for (const spec of rule.enforce_via ?? []) {
267
+ if (spec.hook !== 'PostToolUse' || spec.mech !== 'A')
268
+ continue;
269
+ const v = spec.verifier;
270
+ if (!v || v.kind !== 'pattern_match')
271
+ continue;
272
+ const pattern = String(v.params?.pattern ?? '');
273
+ if (!pattern)
274
+ continue;
275
+ const re = compileSafeRegex(pattern);
276
+ if (!re.regex) {
277
+ log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
278
+ continue;
279
+ }
280
+ if (!safeRegexTest(re.regex, target))
281
+ continue;
282
+ recordViolation({
283
+ rule_id: rule.rule_id, session_id: sessionId,
284
+ source: 'post-tool-guard',
285
+ kind: 'block',
286
+ message_preview: target.slice(0, 120),
287
+ });
288
+ 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
+ }
290
+ }
291
+ // T3 bypass detection (same rules, same target)
292
+ const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: target, session_id: sessionId });
293
+ for (const c of candidates) {
294
+ recordBypass({ rule_id: c.rule_id, session_id: c.session_id, tool: c.tool, pattern_preview: c.pattern_preview });
295
+ }
296
+ }
297
+ catch (e) {
298
+ log.debug('enforce_via/bypass post-tool dispatch 실패', e);
299
+ }
300
+ }
301
+ }
240
302
  // 7. Compound success hint (non-blocking)
241
303
  try {
242
304
  const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
@@ -311,7 +311,63 @@ async function main() {
311
311
  const toolName = data.tool_name ?? data.toolName ?? '';
312
312
  const toolInput = data.tool_input ?? data.toolInput ?? {};
313
313
  const sessionId = data.session_id ?? 'default';
314
- // Bash 도구: 위험 명령어 감지
314
+ // ADR-001 Mech-A PreToolUse dispatcher — 사용자가 정의한 rule 이 빌트인 위험-명령 감지보다 먼저.
315
+ // 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
316
+ // fail-open: 예외는 hook 차단 안 함.
317
+ try {
318
+ const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
319
+ import('../store/rule-store.js'),
320
+ import('../engine/lifecycle/signals.js'),
321
+ import('./shared/safe-regex.js'),
322
+ ]);
323
+ const rules = loadActiveRules();
324
+ const command = typeof toolInput.command === 'string'
325
+ ? String(toolInput.command)
326
+ : '';
327
+ for (const rule of rules) {
328
+ for (const spec of rule.enforce_via ?? []) {
329
+ if (spec.hook !== 'PreToolUse' || spec.mech !== 'A')
330
+ continue;
331
+ const v = spec.verifier;
332
+ if (!v || v.kind !== 'tool_arg_regex')
333
+ continue;
334
+ const pattern = String(v.params?.pattern ?? '');
335
+ if (!pattern)
336
+ continue;
337
+ const re = compileSafeRegex(pattern, 'i');
338
+ if (!re.regex) {
339
+ log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
340
+ continue;
341
+ }
342
+ if (!safeRegexTest(re.regex, command))
343
+ continue;
344
+ const requiresFlag = v.params?.requires_flag;
345
+ const confirmed = process.env.FORGEN_USER_CONFIRMED === '1';
346
+ if (requiresFlag && !confirmed) {
347
+ recordViolation({ rule_id: rule.rule_id, session_id: sessionId, source: 'pre-tool-guard', kind: 'deny', message_preview: command.slice(0, 120) });
348
+ const baseMsg = spec.block_message ?? `[${rule.rule_id}] policy violation: ${rule.policy.slice(0, 120)}`;
349
+ // G8: override 힌트 — FORGEN_USER_CONFIRMED=1 으로 사용자 명시 승인 가능, 감사 로그 기록됨.
350
+ const msgWithHint = `${baseMsg}\n\n(override: set FORGEN_USER_CONFIRMED=1 (bypass will be audited in violations.jsonl))`;
351
+ console.log(deny(msgWithHint));
352
+ return;
353
+ }
354
+ if (requiresFlag && confirmed) {
355
+ // H3: 우회 감사 — FORGEN_USER_CONFIRMED 으로 Mech-A 를 우회할 때마다 violation 로그에
356
+ // kind='correction' 으로 기록. T3 bypass 누적 대신 별도 채널로 운영자가 monitoring 가능.
357
+ recordViolation({
358
+ rule_id: rule.rule_id, session_id: sessionId,
359
+ source: 'pre-tool-guard',
360
+ kind: 'correction', // 'correction' = 사용자 명시 우회, rule 위반이지만 의도된 것
361
+ message_preview: `[FORGEN_USER_CONFIRMED=1 bypass] ${command.slice(0, 120)}`,
362
+ });
363
+ }
364
+ }
365
+ }
366
+ }
367
+ catch (e) {
368
+ log.debug('enforce_via[PreToolUse] dispatch 실패', e);
369
+ }
370
+ // Bash 도구: 위험 명령어 감지 (빌트인 safety net)
315
371
  const check = checkDangerousCommand(toolName, toolInput);
316
372
  if (check.action === 'block') {
317
373
  console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
@@ -334,10 +390,16 @@ async function main() {
334
390
  log.debug('compound reflection check 실패', e);
335
391
  }
336
392
  // 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
337
- const reminders = getActiveReminders();
338
- if (reminders.length > 0 && shouldShowReminderIO()) {
339
- console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
340
- return;
393
+ // P0-4 fix (2026-04-20): 과거에는 getActiveReminders()로 STATE_DIR을 먼저
394
+ // readdir + N회 readFileSync한 뒤에야 shouldShowReminderIO 카운터를 체크했다.
395
+ // 그래서 "리마인더를 보여줄 호출이 아닌" 90%에서도 디렉터리 스캔이 발생.
396
+ // 이제 shouldShowReminderIO를 먼저 체크해 표시 회차일 때만 스캔한다.
397
+ if (shouldShowReminderIO()) {
398
+ const reminders = getActiveReminders();
399
+ if (reminders.length > 0) {
400
+ console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
401
+ return;
402
+ }
341
403
  }
342
404
  console.log(approve());
343
405
  }
@@ -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[];
@@ -16,7 +16,33 @@ export const SECRET_PATTERNS = [
16
16
  { name: 'Password', pattern: /(password|passwd|pwd)\s*[=:]\s*["']?[^\s"']{8,}/i },
17
17
  { name: 'Private Key', pattern: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/ },
18
18
  { name: 'Connection String', pattern: /(mongodb|postgres|mysql|redis):\/\/\w+:[^@]+@/ },
19
+ // 2026-04-21 follow-up audit #B: vendor-specific prefixes the generic
20
+ // `(sk|pk|api-key)[_-]` pattern does NOT match. Real-world leaks
21
+ // overwhelmingly use these formats.
22
+ { name: 'GitHub Token', pattern: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/ },
23
+ { name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
24
+ { name: 'Slack Token', pattern: /\bxox[abpors]-[A-Za-z0-9-]{10,}/ },
19
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
+ }
20
46
  /** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
21
47
  export function detectSecrets(text) {
22
48
  const found = [];
@@ -378,7 +378,6 @@ async function main() {
378
378
  // v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
379
379
  // Compound v3: Run lifecycle check once per day
380
380
  try {
381
- const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
382
381
  const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
383
382
  let shouldRun = true;
384
383
  try {
@@ -390,13 +389,22 @@ async function main() {
390
389
  }
391
390
  catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
392
391
  if (shouldRun) {
393
- // B-4: detached background spawn으로 분리 — hook timeout 초과 방지
392
+ // B-4: detached background spawn — hook timeout 초과 방지.
393
+ //
394
+ // Audit fix #5 (2026-04-21): prior invocation interpolated
395
+ // `sessionId` into a `-e` template literal
396
+ // `import('${path}').then(m => m.runLifecycleCheck('${sessionId}'))`
397
+ // which created a code-injection surface (a crafted sessionId
398
+ // could break out of the single quotes and execute arbitrary JS
399
+ // under the user's Claude-Code privileges). The runner was moved
400
+ // to a dedicated script file and the id is now passed via argv —
401
+ // no shell, no eval, no interpolation.
402
+ const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'internal', 'run-lifecycle-check.js');
394
403
  const { spawn: spawnLifecycle } = await import('node:child_process');
395
- const lifecycleRunner = spawnLifecycle('node', [
396
- '--input-type=module',
397
- '-e',
398
- `import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
399
- ], { detached: true, stdio: 'ignore' });
404
+ const lifecycleRunner = spawnLifecycle('node', [runnerPath, sessionId], {
405
+ detached: true,
406
+ stdio: 'ignore',
407
+ });
400
408
  lifecycleRunner.unref();
401
409
  const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
402
410
  writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
@@ -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;