@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.
- package/.claude-plugin/plugin.json +7 -2
- package/CHANGELOG.md +164 -0
- package/README.ja.md +90 -7
- package/README.ko.md +44 -1
- package/README.md +128 -9
- package/README.zh.md +90 -7
- package/dist/cli.js +140 -8
- package/dist/core/auto-compound-runner.js +16 -5
- package/dist/core/dashboard.js +11 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +85 -11
- package/dist/core/global-config.d.ts +2 -2
- package/dist/core/global-config.js +6 -14
- package/dist/core/harness.d.ts +3 -5
- package/dist/core/harness.js +34 -338
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +0 -34
- package/dist/core/paths.js +0 -35
- package/dist/core/settings-injector.d.ts +13 -0
- package/dist/core/settings-injector.js +167 -0
- package/dist/core/settings-lock.d.ts +35 -2
- package/dist/core/settings-lock.js +65 -7
- package/dist/core/spawn.js +100 -39
- package/dist/core/state-gc.d.ts +49 -0
- package/dist/core/state-gc.js +163 -0
- package/dist/core/stats-cli.d.ts +15 -0
- package/dist/core/stats-cli.js +143 -0
- package/dist/core/uninstall.d.ts +1 -0
- package/dist/core/uninstall.js +36 -5
- package/dist/core/v1-bootstrap.js +11 -3
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/compound-cli.d.ts +27 -2
- package/dist/engine/compound-cli.js +69 -16
- package/dist/engine/compound-export.d.ts +15 -0
- package/dist/engine/compound-export.js +32 -5
- package/dist/engine/compound-loop.js +3 -2
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -0
- package/dist/engine/learn-cli.js +52 -0
- package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
- package/dist/engine/lifecycle/bypass-detector.js +82 -0
- package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
- package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
- package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
- package/dist/engine/lifecycle/meta-cli.js +7 -0
- package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
- package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
- package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
- package/dist/engine/lifecycle/orchestrator.js +131 -0
- package/dist/engine/lifecycle/signals.d.ts +30 -0
- package/dist/engine/lifecycle/signals.js +142 -0
- package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
- package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
- package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
- package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
- package/dist/engine/lifecycle/types.d.ts +52 -0
- package/dist/engine/lifecycle/types.js +7 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/engine/solution-format.d.ts +0 -2
- package/dist/engine/solution-format.js +0 -4
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +7 -4
- package/dist/engine/solution-outcomes.d.ts +4 -0
- package/dist/engine/solution-outcomes.js +174 -97
- package/dist/engine/solution-writer.d.ts +8 -5
- package/dist/engine/solution-writer.js +43 -19
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +86 -1
- package/dist/hooks/hook-config.d.ts +9 -1
- package/dist/hooks/hook-config.js +25 -3
- package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
- package/dist/hooks/internal/run-lifecycle-check.js +32 -0
- package/dist/hooks/notepad-injector.js +6 -3
- package/dist/hooks/permission-handler.d.ts +10 -2
- package/dist/hooks/permission-handler.js +31 -12
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +67 -5
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +26 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/atomic-write.d.ts +8 -1
- package/dist/hooks/shared/atomic-write.js +17 -3
- package/dist/hooks/shared/hook-response.d.ts +11 -2
- package/dist/hooks/shared/hook-response.js +20 -7
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/shared/safe-regex.d.ts +25 -0
- package/dist/hooks/shared/safe-regex.js +50 -0
- package/dist/hooks/shared/stop-triggers.d.ts +19 -0
- package/dist/hooks/shared/stop-triggers.js +19 -0
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +60 -1
- package/dist/hooks/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +24 -4
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +55 -6
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-lifecycle.d.ts +23 -0
- package/dist/store/rule-lifecycle.js +63 -0
- package/dist/store/rule-store.d.ts +21 -0
- package/dist/store/rule-store.js +133 -13
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +10 -2
- package/plugin.json +7 -2
- 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
|
-
|
|
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 (티어 설정)
|
|
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 (티어 설정)
|
|
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) 티어 설정
|
|
161
|
+
// 2) 티어 설정
|
|
140
162
|
const tier = HOOK_TIER_MAP[hookName];
|
|
141
|
-
if (tier
|
|
163
|
+
if (tier) {
|
|
142
164
|
const tiers = all.tiers;
|
|
143
165
|
if (tiers?.[tier]?.enabled === false)
|
|
144
166
|
return false;
|
|
@@ -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
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 '
|
|
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-
|
|
35
|
-
return 'autopilot-
|
|
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, '
|
|
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-
|
|
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
|
|
103
|
-
: `[Forgen] Autopilot: ${toolName} tool
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
'
|
|
398
|
-
|
|
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
|
-
/**
|
|
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;
|