@wooojin/forgen 0.3.2 → 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 +1 -1
- package/CHANGELOG.md +64 -0
- package/README.ja.md +61 -7
- package/README.ko.md +15 -1
- package/README.md +92 -6
- package/README.zh.md +61 -7
- package/dist/cli.js +137 -5
- package/dist/core/auto-compound-runner.js +10 -2
- package/dist/core/doctor.js +64 -10
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/state-gc.d.ts +19 -0
- package/dist/core/state-gc.js +48 -4
- 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 +24 -1
- package/dist/core/v1-bootstrap.js +9 -1
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -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/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +71 -0
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +57 -1
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +20 -0
- 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 -0
- package/dist/hooks/shared/hook-response.js +18 -0
- 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/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/tools.js +19 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +50 -1
- 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 +128 -8
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +10 -2
- package/plugin.json +1 -1
|
@@ -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}`));
|
|
@@ -10,5 +10,15 @@ export interface SecretPattern {
|
|
|
10
10
|
pattern: RegExp;
|
|
11
11
|
}
|
|
12
12
|
export declare const SECRET_PATTERNS: SecretPattern[];
|
|
13
|
+
/**
|
|
14
|
+
* 텍스트에서 민감 정보 패턴을 찾아 `[REDACTED:<NAME>]` 로 치환 (순수 함수).
|
|
15
|
+
*
|
|
16
|
+
* R5-G2: auto-compound-runner 가 사용자 transcript 를 Claude (Haiku) 로 송신하기 전
|
|
17
|
+
* 적용. `detectSecrets` 는 감지만, 이 함수는 실제 문자열에서 대체.
|
|
18
|
+
*/
|
|
19
|
+
export declare function redactSecrets(text: string): {
|
|
20
|
+
redacted: string;
|
|
21
|
+
hits: SecretPattern[];
|
|
22
|
+
};
|
|
13
23
|
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
14
24
|
export declare function detectSecrets(text: string): SecretPattern[];
|
|
@@ -23,6 +23,26 @@ export const SECRET_PATTERNS = [
|
|
|
23
23
|
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
24
24
|
{ name: 'Slack Token', pattern: /\bxox[abpors]-[A-Za-z0-9-]{10,}/ },
|
|
25
25
|
];
|
|
26
|
+
/**
|
|
27
|
+
* 텍스트에서 민감 정보 패턴을 찾아 `[REDACTED:<NAME>]` 로 치환 (순수 함수).
|
|
28
|
+
*
|
|
29
|
+
* R5-G2: auto-compound-runner 가 사용자 transcript 를 Claude (Haiku) 로 송신하기 전
|
|
30
|
+
* 적용. `detectSecrets` 는 감지만, 이 함수는 실제 문자열에서 대체.
|
|
31
|
+
*/
|
|
32
|
+
export function redactSecrets(text) {
|
|
33
|
+
const hits = [];
|
|
34
|
+
let out = text;
|
|
35
|
+
for (const sp of SECRET_PATTERNS) {
|
|
36
|
+
// regex 복제 (global flag 없이 repeated test 되는 경우 lastIndex 안전)
|
|
37
|
+
const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
|
|
38
|
+
if (re.test(out)) {
|
|
39
|
+
hits.push(sp);
|
|
40
|
+
const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
|
|
41
|
+
out = out.replace(re2, `[REDACTED:${sp.name}]`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { redacted: out, hits };
|
|
45
|
+
}
|
|
26
46
|
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
27
47
|
export function detectSecrets(text) {
|
|
28
48
|
const found = [];
|
|
@@ -37,5 +37,12 @@ export declare function atomicWriteText(filePath: string, content: string, optio
|
|
|
37
37
|
mode?: number;
|
|
38
38
|
dirMode?: number;
|
|
39
39
|
}): void;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환).
|
|
42
|
+
*
|
|
43
|
+
* R4-B3 (2026-04-22): UTF-8 BOM () prefix 제거 — Windows 메모장 등으로 저장된
|
|
44
|
+
* rule/settings JSON 이 BOM 으로 시작해 JSON.parse 가 silent 실패하던 문제.
|
|
45
|
+
* R4-SKIP: FORGEN_DEBUG_SIGNALS=1 일 때 파싱 실패를 stderr 로 노출 — silent
|
|
46
|
+
* 누락을 운영자가 추적 가능하도록.
|
|
47
|
+
*/
|
|
41
48
|
export declare function safeReadJSON<T>(filePath: string, fallback: T): T;
|
|
@@ -136,13 +136,27 @@ export function atomicWriteText(filePath, content, options) {
|
|
|
136
136
|
throw e;
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환).
|
|
141
|
+
*
|
|
142
|
+
* R4-B3 (2026-04-22): UTF-8 BOM () prefix 제거 — Windows 메모장 등으로 저장된
|
|
143
|
+
* rule/settings JSON 이 BOM 으로 시작해 JSON.parse 가 silent 실패하던 문제.
|
|
144
|
+
* R4-SKIP: FORGEN_DEBUG_SIGNALS=1 일 때 파싱 실패를 stderr 로 노출 — silent
|
|
145
|
+
* 누락을 운영자가 추적 가능하도록.
|
|
146
|
+
*/
|
|
140
147
|
export function safeReadJSON(filePath, fallback) {
|
|
141
148
|
try {
|
|
142
149
|
if (fs.existsSync(filePath)) {
|
|
143
|
-
|
|
150
|
+
let raw = fs.readFileSync(filePath, 'utf-8');
|
|
151
|
+
if (raw.charCodeAt(0) === 0xFEFF)
|
|
152
|
+
raw = raw.slice(1); // strip BOM
|
|
153
|
+
return JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
|
|
158
|
+
process.stderr.write(`[forgen:safeReadJSON] ${filePath} parse failed: ${e.message}\n`);
|
|
144
159
|
}
|
|
145
160
|
}
|
|
146
|
-
catch { /* JSON parse failure — return fallback */ }
|
|
147
161
|
return fallback;
|
|
148
162
|
}
|
|
@@ -29,6 +29,17 @@ export declare function approveWithWarning(warning: string): string;
|
|
|
29
29
|
export declare function deny(reason: string): string;
|
|
30
30
|
/** 사용자 확인 요청 (PreToolUse 전용) */
|
|
31
31
|
export declare function ask(reason: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Stop hook only — block the agent from stopping and feed a self-check
|
|
34
|
+
* question back to Claude so the current session resumes with new guidance.
|
|
35
|
+
*
|
|
36
|
+
* `reason` becomes the next-turn content (Claude reads this verbatim), while
|
|
37
|
+
* `systemMessage` is auxiliary context rendered alongside. Put the whole
|
|
38
|
+
* self-check question in `reason`; keep `systemMessage` to a short rule tag.
|
|
39
|
+
*
|
|
40
|
+
* Source: Stop hook spec — `decision: "block"` "prevents stopping and continues the agent's work".
|
|
41
|
+
*/
|
|
42
|
+
export declare function blockStop(reason: string, systemMessage?: string): string;
|
|
32
43
|
/**
|
|
33
44
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
34
45
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
@@ -59,6 +59,24 @@ export function ask(reason) {
|
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Stop hook only — block the agent from stopping and feed a self-check
|
|
64
|
+
* question back to Claude so the current session resumes with new guidance.
|
|
65
|
+
*
|
|
66
|
+
* `reason` becomes the next-turn content (Claude reads this verbatim), while
|
|
67
|
+
* `systemMessage` is auxiliary context rendered alongside. Put the whole
|
|
68
|
+
* self-check question in `reason`; keep `systemMessage` to a short rule tag.
|
|
69
|
+
*
|
|
70
|
+
* Source: Stop hook spec — `decision: "block"` "prevents stopping and continues the agent's work".
|
|
71
|
+
*/
|
|
72
|
+
export function blockStop(reason, systemMessage) {
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
continue: true,
|
|
75
|
+
decision: 'block',
|
|
76
|
+
reason,
|
|
77
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
62
80
|
/**
|
|
63
81
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
64
82
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe regex compiler — ReDoS 방지용 경량 가드.
|
|
3
|
+
*
|
|
4
|
+
* rule JSON 의 verifier.params.pattern 등 user-controlled regex 를 hook 런타임에
|
|
5
|
+
* 그대로 new RegExp() 하면 catastrophic backtracking 으로 hook hang 위험이 있다.
|
|
6
|
+
* re2 같은 linear-time 엔진 의존은 native binding 을 추가시키므로, 여기서는
|
|
7
|
+
* **패턴 복잡도 제한** + **입력 크기 제한** 으로 1차 방어.
|
|
8
|
+
*
|
|
9
|
+
* 정책:
|
|
10
|
+
* - 패턴 길이 ≤ 500자.
|
|
11
|
+
* - 중첩 quantifier (`(...)+)+` / `(...)*)*` / `(.+)+`) 같은 catastrophic 신호 거부.
|
|
12
|
+
* - backreference `\1..\9` 금지.
|
|
13
|
+
* - compile 실패 또는 거부 시 null 반환 → 호출자가 skip.
|
|
14
|
+
*/
|
|
15
|
+
export interface SafeRegexResult {
|
|
16
|
+
regex: RegExp | null;
|
|
17
|
+
reason: string | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 패턴을 안전하게 컴파일. 거부되거나 실패 시 { regex: null, reason } 반환.
|
|
21
|
+
* 호출자는 reason 을 log.debug 로 기록하고 skip 하는 것이 권장 사용법.
|
|
22
|
+
*/
|
|
23
|
+
export declare function compileSafeRegex(pattern: string, flags?: string): SafeRegexResult;
|
|
24
|
+
/** 입력을 MAX_INPUT_LEN 으로 자른 뒤 regex.test() 수행. 입력 DoS 방어. */
|
|
25
|
+
export declare function safeRegexTest(regex: RegExp, input: string): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe regex compiler — ReDoS 방지용 경량 가드.
|
|
3
|
+
*
|
|
4
|
+
* rule JSON 의 verifier.params.pattern 등 user-controlled regex 를 hook 런타임에
|
|
5
|
+
* 그대로 new RegExp() 하면 catastrophic backtracking 으로 hook hang 위험이 있다.
|
|
6
|
+
* re2 같은 linear-time 엔진 의존은 native binding 을 추가시키므로, 여기서는
|
|
7
|
+
* **패턴 복잡도 제한** + **입력 크기 제한** 으로 1차 방어.
|
|
8
|
+
*
|
|
9
|
+
* 정책:
|
|
10
|
+
* - 패턴 길이 ≤ 500자.
|
|
11
|
+
* - 중첩 quantifier (`(...)+)+` / `(...)*)*` / `(.+)+`) 같은 catastrophic 신호 거부.
|
|
12
|
+
* - backreference `\1..\9` 금지.
|
|
13
|
+
* - compile 실패 또는 거부 시 null 반환 → 호출자가 skip.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_PATTERN_LEN = 500;
|
|
16
|
+
const MAX_INPUT_LEN = 65536;
|
|
17
|
+
// Catastrophic backtracking 의 흔한 형태 — 중첩된 quantifier 체인.
|
|
18
|
+
const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)[+*]/;
|
|
19
|
+
// Alternation with shared prefix can also be catastrophic — heuristic only.
|
|
20
|
+
const OVERLAPPING_ALT = /\(([^|)]+)\|\1[^)]*\)[+*]/;
|
|
21
|
+
const BACKREFERENCE = /\\[1-9]/;
|
|
22
|
+
/**
|
|
23
|
+
* 패턴을 안전하게 컴파일. 거부되거나 실패 시 { regex: null, reason } 반환.
|
|
24
|
+
* 호출자는 reason 을 log.debug 로 기록하고 skip 하는 것이 권장 사용법.
|
|
25
|
+
*/
|
|
26
|
+
export function compileSafeRegex(pattern, flags = '') {
|
|
27
|
+
if (typeof pattern !== 'string')
|
|
28
|
+
return { regex: null, reason: 'non-string pattern' };
|
|
29
|
+
if (pattern.length === 0)
|
|
30
|
+
return { regex: null, reason: 'empty pattern' };
|
|
31
|
+
if (pattern.length > MAX_PATTERN_LEN)
|
|
32
|
+
return { regex: null, reason: `pattern length ${pattern.length} > ${MAX_PATTERN_LEN}` };
|
|
33
|
+
if (NESTED_QUANTIFIER.test(pattern))
|
|
34
|
+
return { regex: null, reason: 'nested quantifier (catastrophic backtracking risk)' };
|
|
35
|
+
if (OVERLAPPING_ALT.test(pattern))
|
|
36
|
+
return { regex: null, reason: 'overlapping alternation with quantifier' };
|
|
37
|
+
if (BACKREFERENCE.test(pattern))
|
|
38
|
+
return { regex: null, reason: 'backreference in user regex (perf risk)' };
|
|
39
|
+
try {
|
|
40
|
+
return { regex: new RegExp(pattern, flags), reason: null };
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
return { regex: null, reason: `compile error: ${String(e).slice(0, 80)}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** 입력을 MAX_INPUT_LEN 으로 자른 뒤 regex.test() 수행. 입력 DoS 방어. */
|
|
47
|
+
export function safeRegexTest(regex, input) {
|
|
48
|
+
const truncated = input.length > MAX_INPUT_LEN ? input.slice(0, MAX_INPUT_LEN) : input;
|
|
49
|
+
return regex.test(truncated);
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Stop hook default trigger regexes.
|
|
3
|
+
*
|
|
4
|
+
* R6-F2 (2026-04-22): stop-guard 와 enforce-classifier 에 리터럴 중복되던 정규식을
|
|
5
|
+
* 단일 소스로 통합. 한쪽만 고치면 다른 쪽이 drift 하는 sibling-bug 패턴 차단.
|
|
6
|
+
*
|
|
7
|
+
* 설계 결정:
|
|
8
|
+
* - trigger 는 명시적 완료 선언 동사/어미만 — "완료" 단독 매칭 금지 (retraction 오매칭 방지).
|
|
9
|
+
* - exclude 는 retraction/negation/meta 언급 광범위 차단.
|
|
10
|
+
* - A1 spike 결과로 검증됨 (10/10 scenarios pass, FP 0%).
|
|
11
|
+
*/
|
|
12
|
+
/** Stop hook 에서 rule trigger 가 명시되지 않을 때의 기본 완료 선언 매칭. */
|
|
13
|
+
export declare const DEFAULT_STOP_TRIGGER_RE = "(\uC644\uB8CC\uD588|\uC644\uC131\uB410|\uC644\uC131\uB418|\uC644\uC131\uD588|done\\.|ready\\.|shipped\\.|LGTM|finished\\.)";
|
|
14
|
+
/** Stop hook 기본 exclude — retraction/negation/meta 맥락 제외. */
|
|
15
|
+
export declare const DEFAULT_STOP_EXCLUDE_RE = "(\uCDE8\uC18C|\uCCA0\uD68C|\uC5C6\uC74C|\uC5C6\uC2B5\uB2C8\uB2E4|\uC54A\uC558|\uD558\uC9C0\\s*\uC54A|\uC544\uB2D9\uB2C8\uB2E4|not\\s*yet|no\\s*longer|retract|withdraw|\uC544\uC9C1\\s*(\uC548|\uC544))";
|
|
16
|
+
/** mock/stub/fake 감지 — R-B2 전용 pattern (자가검증 주장 차단). */
|
|
17
|
+
export declare const MOCK_TRIGGER_RE = "(mock|stub|fake)";
|
|
18
|
+
/** mock trigger 의 exclude — 테스트 맥락은 정상. */
|
|
19
|
+
export declare const MOCK_EXCLUDE_RE = "(\uD14C\uC2A4\uD2B8|test|vi\\.mock|jest\\.mock|spec\\.)";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Stop hook default trigger regexes.
|
|
3
|
+
*
|
|
4
|
+
* R6-F2 (2026-04-22): stop-guard 와 enforce-classifier 에 리터럴 중복되던 정규식을
|
|
5
|
+
* 단일 소스로 통합. 한쪽만 고치면 다른 쪽이 drift 하는 sibling-bug 패턴 차단.
|
|
6
|
+
*
|
|
7
|
+
* 설계 결정:
|
|
8
|
+
* - trigger 는 명시적 완료 선언 동사/어미만 — "완료" 단독 매칭 금지 (retraction 오매칭 방지).
|
|
9
|
+
* - exclude 는 retraction/negation/meta 언급 광범위 차단.
|
|
10
|
+
* - A1 spike 결과로 검증됨 (10/10 scenarios pass, FP 0%).
|
|
11
|
+
*/
|
|
12
|
+
/** Stop hook 에서 rule trigger 가 명시되지 않을 때의 기본 완료 선언 매칭. */
|
|
13
|
+
export const DEFAULT_STOP_TRIGGER_RE = '(완료했|완성됐|완성되|완성했|done\\.|ready\\.|shipped\\.|LGTM|finished\\.)';
|
|
14
|
+
/** Stop hook 기본 exclude — retraction/negation/meta 맥락 제외. */
|
|
15
|
+
export const DEFAULT_STOP_EXCLUDE_RE = '(취소|철회|없음|없습니다|않았|하지\\s*않|아닙니다|not\\s*yet|no\\s*longer|retract|withdraw|아직\\s*(안|아))';
|
|
16
|
+
/** mock/stub/fake 감지 — R-B2 전용 pattern (자가검증 주장 차단). */
|
|
17
|
+
export const MOCK_TRIGGER_RE = '(mock|stub|fake)';
|
|
18
|
+
/** mock trigger 의 exclude — 테스트 맥락은 정상. */
|
|
19
|
+
export const MOCK_EXCLUDE_RE = '(테스트|test|vi\\.mock|jest\\.mock|spec\\.)';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Stop Guard (Mech-B prototype, spike/mech-b-a1)
|
|
4
|
+
*
|
|
5
|
+
* Stop hook: 어시스턴트 직전 응답에서 "완료 선언" 패턴을 감지하고, 연결된
|
|
6
|
+
* Mech-A(artifact_check) / Mech-B(self_check_prompt) 규칙을 평가하여
|
|
7
|
+
* 위반 시 blockStop 으로 세션을 재개시킨다.
|
|
8
|
+
*
|
|
9
|
+
* Prototype scope (spike only — NOT v0.4.0 final):
|
|
10
|
+
* - 규칙은 tests/spike/mech-b-inject/scenarios.json 에서 로드
|
|
11
|
+
* (FORGEN_SPIKE_RULES env 로 override 가능)
|
|
12
|
+
* - 어시스턴트 메시지는 transcript_path 에서 마지막 assistant 턴을 뽑거나
|
|
13
|
+
* FORGEN_SPIKE_LAST_MESSAGE env 로 주입 가능 (runner/단위테스트용)
|
|
14
|
+
* - artifact_check 는 `~/.forgen/state/<relative>` 경로를 기준으로 평가
|
|
15
|
+
*
|
|
16
|
+
* 설계 제약 (ADR-001, Day-1 verification):
|
|
17
|
+
* - self_check_prompt 질문은 **reason** 에 전체를 담는다 (모델 도달).
|
|
18
|
+
* - systemMessage 는 rule tag 한 줄만 (UI 표시 보조).
|
|
19
|
+
* - 외부 LLM API 호출 없음 (β1 유지).
|
|
20
|
+
*/
|
|
21
|
+
import type { Rule } from '../store/types.js';
|
|
22
|
+
interface VerifierSpec {
|
|
23
|
+
kind: 'self_check_prompt' | 'artifact_check' | 'tool_arg_regex';
|
|
24
|
+
params: Record<string, string | number | boolean>;
|
|
25
|
+
}
|
|
26
|
+
interface SpikeRule {
|
|
27
|
+
id: string;
|
|
28
|
+
mech: 'A' | 'B' | 'C';
|
|
29
|
+
hook: 'Stop' | 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit';
|
|
30
|
+
trigger: {
|
|
31
|
+
response_keywords_regex?: string;
|
|
32
|
+
context_exclude_regex?: string;
|
|
33
|
+
};
|
|
34
|
+
verifier: VerifierSpec;
|
|
35
|
+
block_message?: string;
|
|
36
|
+
system_tag?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 프로덕션 rule-store 로더.
|
|
40
|
+
* ~/.forgen/me/rules 의 Rule 중 `enforce_via` 에 `hook: 'Stop'` 이 있는 것만
|
|
41
|
+
* SpikeRule 내부 shape 로 변환해 반환한다.
|
|
42
|
+
*
|
|
43
|
+
* 변환 규칙:
|
|
44
|
+
* - `trigger_keywords_regex` 미지정 → DEFAULT_STOP_TRIGGER_RE (shared)
|
|
45
|
+
* - `trigger_exclude_regex` 미지정 → DEFAULT_STOP_EXCLUDE_RE (shared)
|
|
46
|
+
* - verifier.kind 는 `self_check_prompt` 또는 `artifact_check` 지원
|
|
47
|
+
* - 그 외 verifier 는 skip (PreToolUse 전용 tool_arg_regex 등)
|
|
48
|
+
*/
|
|
49
|
+
export declare function rulesFromStore(rules: Rule[]): SpikeRule[];
|
|
50
|
+
/** Pure core — 단위 테스트용. stdin/IO 없음. */
|
|
51
|
+
export declare function evaluateStop(lastAssistantMessage: string, rules: SpikeRule[]): {
|
|
52
|
+
action: 'approve';
|
|
53
|
+
hit: null;
|
|
54
|
+
} | {
|
|
55
|
+
action: 'block';
|
|
56
|
+
hit: SpikeRule;
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* 같은 (session, rule) 조합의 연속 block 카운트. approve 가 일어나면 0 으로 초기화.
|
|
61
|
+
* export for tests. 부수효과: 디렉토리 생성 + 파일 쓰기.
|
|
62
|
+
*/
|
|
63
|
+
export declare function incrementBlockCount(sessionId: string, ruleId: string): number;
|
|
64
|
+
export declare function resetBlockCount(sessionId: string, ruleId: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* R9-PA2: approve 시점에 같은 session 의 pending block 을 찾아 ack 이벤트로 기록.
|
|
67
|
+
* Mech-B 의 핵심 가치(block → retract → pass)가 실제 작동했음을 관측 가능하게 한다.
|
|
68
|
+
* Best-effort: 실패해도 approve 자체는 영향받지 않는다.
|
|
69
|
+
*
|
|
70
|
+
* 기록 후 block-count 파일은 cleanup — 같은 session 의 같은 rule 이 다시 block 되면
|
|
71
|
+
* 새로운 카운트로 시작 (block-count 의미 보존).
|
|
72
|
+
*/
|
|
73
|
+
export declare function acknowledgeSessionBlocks(sessionId: string): number;
|
|
74
|
+
export declare function logDriftEvent(event: {
|
|
75
|
+
kind: string;
|
|
76
|
+
session_id: string;
|
|
77
|
+
rule_id: string;
|
|
78
|
+
count: number;
|
|
79
|
+
reason_preview?: string;
|
|
80
|
+
message_preview?: string;
|
|
81
|
+
}): void;
|
|
82
|
+
export declare function getStuckLoopThreshold(): number;
|
|
83
|
+
export declare function main(): Promise<void>;
|
|
84
|
+
export {};
|