@wooojin/forgen 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.ja.md +58 -1
- package/README.ko.md +58 -1
- package/README.md +83 -15
- package/README.zh.md +26 -0
- package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
- package/dist/checks/conclusion-verification-ratio.js +86 -0
- package/dist/checks/fact-vs-agreement.d.ts +47 -0
- package/dist/checks/fact-vs-agreement.js +92 -0
- package/dist/checks/self-score-deflation.d.ts +38 -0
- package/dist/checks/self-score-deflation.js +108 -0
- package/dist/cli.js +22 -2
- package/dist/core/auto-compound-runner.js +75 -11
- package/dist/core/dashboard.js +9 -2
- package/dist/core/doctor.js +26 -5
- package/dist/core/extraction-notice.d.ts +18 -0
- package/dist/core/extraction-notice.js +64 -0
- package/dist/core/init-cli.d.ts +26 -0
- package/dist/core/init-cli.js +104 -0
- package/dist/core/init.js +17 -0
- package/dist/core/inspect-cli.js +1 -2
- package/dist/core/migrate-cli.d.ts +10 -0
- package/dist/core/migrate-cli.js +34 -0
- package/dist/core/paths.d.ts +8 -1
- package/dist/core/paths.js +11 -2
- package/dist/core/recall-cli.d.ts +26 -0
- package/dist/core/recall-cli.js +125 -0
- package/dist/core/recall-reference-detector.d.ts +43 -0
- package/dist/core/recall-reference-detector.js +65 -0
- package/dist/core/stats-cli.d.ts +21 -0
- package/dist/core/stats-cli.js +121 -10
- package/dist/core/uninstall.js +2 -1
- package/dist/engine/compound-cli.js +1 -0
- package/dist/engine/compound-export.js +8 -3
- package/dist/engine/learn-cli.js +1 -4
- package/dist/engine/lifecycle/lifecycle-cli.js +4 -4
- package/dist/engine/lifecycle/meta-reclassifier.js +3 -3
- package/dist/engine/lifecycle/orchestrator.js +2 -2
- package/dist/engine/lifecycle/signals.js +6 -6
- package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
- package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
- package/dist/engine/skill-promoter.js +3 -6
- package/dist/hooks/context-guard.js +1 -1
- package/dist/hooks/dangerous-patterns.json +3 -3
- package/dist/hooks/db-guard.js +18 -2
- package/dist/hooks/intent-classifier.js +1 -1
- package/dist/hooks/keyword-detector.js +1 -1
- package/dist/hooks/notepad-injector.js +1 -1
- package/dist/hooks/permission-handler.js +1 -1
- package/dist/hooks/post-tool-failure.js +1 -1
- package/dist/hooks/post-tool-use.d.ts +6 -0
- package/dist/hooks/post-tool-use.js +37 -19
- package/dist/hooks/pre-compact.js +1 -1
- package/dist/hooks/pre-tool-use.d.ts +7 -0
- package/dist/hooks/pre-tool-use.js +24 -6
- package/dist/hooks/rate-limiter.js +1 -1
- package/dist/hooks/secret-filter.js +1 -1
- package/dist/hooks/session-recovery.js +1 -1
- package/dist/hooks/shared/command-parser.d.ts +44 -0
- package/dist/hooks/shared/command-parser.js +50 -0
- package/dist/hooks/shared/hook-response.d.ts +12 -2
- package/dist/hooks/shared/hook-response.js +30 -3
- package/dist/hooks/skill-injector.js +1 -1
- package/dist/hooks/slop-detector.js +2 -2
- package/dist/hooks/solution-injector.d.ts +9 -0
- package/dist/hooks/solution-injector.js +48 -5
- package/dist/hooks/stop-guard.js +137 -13
- package/dist/hooks/subagent-tracker.js +1 -1
- package/dist/i18n/index.js +3 -5
- package/dist/store/evidence-store.js +11 -0
- package/dist/store/implicit-feedback-store.d.ts +59 -0
- package/dist/store/implicit-feedback-store.js +153 -0
- package/dist/store/rule-store.js +8 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
|
@@ -20,6 +20,8 @@ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook
|
|
|
20
20
|
import { STATE_DIR } from '../core/paths.js';
|
|
21
21
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
22
22
|
import { createDriftState, evaluateDrift } from '../core/drift-score.js';
|
|
23
|
+
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
24
|
+
const RECENT_TOOL_NAMES_WINDOW = 20;
|
|
23
25
|
/** Lightweight hash for content comparison (not cryptographic) */
|
|
24
26
|
function simpleHash(content) {
|
|
25
27
|
let hash = 0;
|
|
@@ -30,15 +32,6 @@ function simpleHash(content) {
|
|
|
30
32
|
}
|
|
31
33
|
return hash.toString(36);
|
|
32
34
|
}
|
|
33
|
-
const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
|
|
34
|
-
/** Record implicit feedback signal to JSONL */
|
|
35
|
-
function recordImplicitFeedback(entry) {
|
|
36
|
-
try {
|
|
37
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
38
|
-
fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(entry) + '\n');
|
|
39
|
-
}
|
|
40
|
-
catch { /* fail-open: implicit feedback recording must not throw */ }
|
|
41
|
-
}
|
|
42
35
|
// ── State management ──
|
|
43
36
|
function getModifiedFilesPath(sessionId) {
|
|
44
37
|
return path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
|
|
@@ -125,6 +118,15 @@ async function main() {
|
|
|
125
118
|
const sessionId = data.session_id ?? 'default';
|
|
126
119
|
const modState = loadModifiedFiles(sessionId);
|
|
127
120
|
modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
|
|
121
|
+
// TEST-2: recent tool name window — stop-guard 의 self-score inflation 가드가
|
|
122
|
+
// "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
|
|
123
|
+
if (toolName) {
|
|
124
|
+
const names = modState.recentToolNames ?? [];
|
|
125
|
+
names.push(toolName);
|
|
126
|
+
if (names.length > RECENT_TOOL_NAMES_WINDOW)
|
|
127
|
+
names.splice(0, names.length - RECENT_TOOL_NAMES_WINDOW);
|
|
128
|
+
modState.recentToolNames = names;
|
|
129
|
+
}
|
|
128
130
|
const messages = [];
|
|
129
131
|
let revertDetected = false;
|
|
130
132
|
// 1. Checkpoint (every 5 calls)
|
|
@@ -152,8 +154,9 @@ async function main() {
|
|
|
152
154
|
// Implicit feedback: repeated edit detection (5+ edits on same file)
|
|
153
155
|
if (count >= 5) {
|
|
154
156
|
messages.push(`<compound-tool-warning>\n[Forgen] ⚠ ${path.basename(filePath)} has been modified ${count} times.\nConsider redesigning the overall structure and restarting.\n</compound-tool-warning>`);
|
|
155
|
-
|
|
157
|
+
appendImplicitFeedback({
|
|
156
158
|
type: 'repeated_edit',
|
|
159
|
+
category: 'edit',
|
|
157
160
|
file: filePath,
|
|
158
161
|
editCount: count,
|
|
159
162
|
at: new Date().toISOString(),
|
|
@@ -172,8 +175,9 @@ async function main() {
|
|
|
172
175
|
// Skip the most recent hash (which would be the write being "reverted from")
|
|
173
176
|
if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
|
|
174
177
|
revertDetected = true;
|
|
175
|
-
|
|
178
|
+
appendImplicitFeedback({
|
|
176
179
|
type: 'revert_detected',
|
|
180
|
+
category: 'revert',
|
|
177
181
|
file: filePath,
|
|
178
182
|
at: new Date().toISOString(),
|
|
179
183
|
sessionId,
|
|
@@ -198,8 +202,9 @@ async function main() {
|
|
|
198
202
|
const driftResult = evaluateDrift(modState.drift, true, revertDetected);
|
|
199
203
|
if (driftResult.message) {
|
|
200
204
|
messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
|
|
201
|
-
|
|
205
|
+
appendImplicitFeedback({
|
|
202
206
|
type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
|
|
207
|
+
category: 'drift',
|
|
203
208
|
score: driftResult.score,
|
|
204
209
|
totalEdits: modState.drift.totalEdits,
|
|
205
210
|
totalReverts: modState.drift.totalReverts,
|
|
@@ -213,8 +218,9 @@ async function main() {
|
|
|
213
218
|
const agentResult = validateAgentOutput(toolResponse);
|
|
214
219
|
if (agentResult) {
|
|
215
220
|
messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
|
|
216
|
-
|
|
221
|
+
appendImplicitFeedback({
|
|
217
222
|
type: `agent_${agentResult.signal}`,
|
|
223
|
+
category: 'agent',
|
|
218
224
|
severity: agentResult.severity,
|
|
219
225
|
outputLength: toolResponse.trim().length,
|
|
220
226
|
at: new Date().toISOString(),
|
|
@@ -254,14 +260,18 @@ async function main() {
|
|
|
254
260
|
})() || toolResponse;
|
|
255
261
|
if (target) {
|
|
256
262
|
try {
|
|
257
|
-
const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
|
|
263
|
+
const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
|
|
258
264
|
import('../store/rule-store.js'),
|
|
259
265
|
import('../engine/lifecycle/signals.js'),
|
|
260
266
|
import('../engine/lifecycle/bypass-detector.js'),
|
|
261
267
|
import('./shared/safe-regex.js'),
|
|
268
|
+
import('./shared/command-parser.js'),
|
|
262
269
|
]);
|
|
263
270
|
const rules = loadActiveRules();
|
|
264
|
-
// Mech-A pattern_match dispatcher
|
|
271
|
+
// Mech-A pattern_match dispatcher — match_target 은 **rule-per-rule**.
|
|
272
|
+
// AWS key / DROP 류 secret/dangerous SQL 은 파일 content 에 들어있어도
|
|
273
|
+
// 실제 leak 이라 raw 검사가 맞고, rm -rf 류 shell 명령은 quote 안 본문이면
|
|
274
|
+
// false-positive 이므로 masked 가 맞다. pre-tool-use 와 동일한 spec 기반 분기.
|
|
265
275
|
for (const rule of rules) {
|
|
266
276
|
for (const spec of rule.enforce_via ?? []) {
|
|
267
277
|
if (spec.hook !== 'PostToolUse' || spec.mech !== 'A')
|
|
@@ -277,7 +287,9 @@ async function main() {
|
|
|
277
287
|
log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
|
|
278
288
|
continue;
|
|
279
289
|
}
|
|
280
|
-
|
|
290
|
+
const matchTarget = (v.params?.match_target ?? 'raw');
|
|
291
|
+
const mechTarget = preprocessForMatch(target, matchTarget);
|
|
292
|
+
if (!safeRegexTest(re.regex, mechTarget))
|
|
281
293
|
continue;
|
|
282
294
|
recordViolation({
|
|
283
295
|
rule_id: rule.rule_id, session_id: sessionId,
|
|
@@ -288,8 +300,14 @@ async function main() {
|
|
|
288
300
|
messages.push(`<compound-rule-violation>\n[Forgen] Rule ${rule.rule_id.slice(0, 8)} pattern matched in ${toolName} output.\n${spec.block_message ?? rule.policy.slice(0, 120)}\n</compound-rule-violation>`);
|
|
289
301
|
}
|
|
290
302
|
}
|
|
291
|
-
// T3 bypass detection
|
|
292
|
-
|
|
303
|
+
// T3 bypass detection — scanForBypass 는 rule.policy 자연어에서 패턴 추출이라
|
|
304
|
+
// match_target 개념 없음. Write/Edit 는 파일 본문이라 bypass-detector 의
|
|
305
|
+
// 자연어 휴리스틱이 false-positive 과다 (L1-no-rm-rf-unconfirmed bypass 20건
|
|
306
|
+
// 중 Write/Edit 15건이 실측). 이 경로만 masked. Bash 는 실제 실행된 명령이라
|
|
307
|
+
// raw 유지. Mech-A pattern_match 는 위에서 rule-per-rule 로 이미 처리.
|
|
308
|
+
const isFileContentTool = toolName === 'Write' || toolName === 'Edit';
|
|
309
|
+
const bypassTarget = isFileContentTool ? preprocessForMatch(target, 'masked') : target;
|
|
310
|
+
const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: bypassTarget, session_id: sessionId });
|
|
293
311
|
for (const c of candidates) {
|
|
294
312
|
recordBypass({ rule_id: c.rule_id, session_id: c.session_id, tool: c.tool, pattern_preview: c.pattern_preview });
|
|
295
313
|
}
|
|
@@ -322,5 +340,5 @@ async function main() {
|
|
|
322
340
|
}
|
|
323
341
|
main().catch((e) => {
|
|
324
342
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
325
|
-
console.log(failOpenWithTracking('post-tool-use'));
|
|
343
|
+
console.log(failOpenWithTracking('post-tool-use', e));
|
|
326
344
|
});
|
|
@@ -10,6 +10,13 @@ interface DangerousPatternEntry {
|
|
|
10
10
|
pattern: RegExp;
|
|
11
11
|
description: string;
|
|
12
12
|
severity: 'block' | 'warn';
|
|
13
|
+
/**
|
|
14
|
+
* match_target (v0.4.1): 'masked' (default) — quote/heredoc 본문 제거 후 매칭.
|
|
15
|
+
* 실 shell 실행 토큰 검사에 적합. 'raw' — 원본 command 그대로 매칭.
|
|
16
|
+
* `python -c "..."`, `eval "..."` 처럼 **quote 안 본문이 실제 payload 로 실행**
|
|
17
|
+
* 되는 패턴에 사용.
|
|
18
|
+
*/
|
|
19
|
+
matchTarget?: 'raw' | 'masked';
|
|
13
20
|
}
|
|
14
21
|
/** 위험 Bash 명령어 패턴 (패키지 내장 + 사용자 커스텀 병합) */
|
|
15
22
|
export declare const DANGEROUS_PATTERNS: DangerousPatternEntry[];
|
|
@@ -22,6 +22,7 @@ import { isHookEnabled } from './hook-config.js';
|
|
|
22
22
|
import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
|
|
23
23
|
import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
|
|
24
24
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
25
|
+
import { maskQuotedContent } from './shared/command-parser.js';
|
|
25
26
|
const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'pre-tool-fail-counter.json');
|
|
26
27
|
const FAIL_CLOSE_THRESHOLD = 3; // 연속 3회 파싱 실패 시에만 reject
|
|
27
28
|
/** RegExp 안전성 검증 (ReDoS 방지) — 매칭/비매칭 양쪽 모두 테스트 */
|
|
@@ -59,12 +60,16 @@ function loadDangerousPatterns() {
|
|
|
59
60
|
pattern: new RegExp(entry.pattern, entry.flags ?? ''),
|
|
60
61
|
description: entry.description,
|
|
61
62
|
severity: entry.severity,
|
|
63
|
+
matchTarget: entry.match_target === 'raw' ? 'raw' : 'masked',
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
catch {
|
|
66
68
|
// JSON 로드 실패 시 하드코딩 폴백 (최소 안전장치)
|
|
67
|
-
results.push(
|
|
69
|
+
results.push(
|
|
70
|
+
// v0.4.1 false-positive fix: /tmp, /var/folders, /var/tmp 같은 임시 경로는
|
|
71
|
+
// 일반 개발에서 매일 정리 대상. 위험한 시스템 경로만 blacklist.
|
|
72
|
+
{ pattern: /rm\s+(-rf|-fr)\s+(\/(?!tmp\b|var\/folders\b|var\/tmp\b)|~)/, description: 'rm -rf on root/home path', severity: 'block' }, { pattern: /curl\s+.*\|\s*(ba)?sh/, description: 'curl pipe to shell', severity: 'block' }, { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, description: 'fork bomb', severity: 'block' });
|
|
68
73
|
}
|
|
69
74
|
// 2. 사용자 커스텀 패턴 (~/.compound/dangerous-patterns.json)
|
|
70
75
|
try {
|
|
@@ -100,8 +105,14 @@ export function checkDangerousCommand(toolName, toolInput) {
|
|
|
100
105
|
const command = typeof toolInput === 'string'
|
|
101
106
|
? toolInput
|
|
102
107
|
: (toolInput.command ?? '');
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
// v0.4.1 (2026-04-24) quote-aware built-in scan:
|
|
109
|
+
// 기본은 masked (quote/heredoc 본문 제거) — shell 실행 토큰 검사. 하지만
|
|
110
|
+
// `python -c "..."` / `eval "..."` 처럼 quote 안 본문이 실 payload 로 실행되는
|
|
111
|
+
// 패턴은 match_target:raw 로 지정해 원본 command 전체 검사.
|
|
112
|
+
const maskedCommand = maskQuotedContent(command);
|
|
113
|
+
for (const { pattern, description, severity, matchTarget } of DANGEROUS_PATTERNS) {
|
|
114
|
+
const target = matchTarget === 'raw' ? command : maskedCommand;
|
|
115
|
+
if (pattern.test(target)) {
|
|
105
116
|
return { action: severity, description, command: command.slice(0, 100) };
|
|
106
117
|
}
|
|
107
118
|
}
|
|
@@ -315,10 +326,11 @@ async function main() {
|
|
|
315
326
|
// 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
|
|
316
327
|
// fail-open: 예외는 hook 차단 안 함.
|
|
317
328
|
try {
|
|
318
|
-
const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
|
|
329
|
+
const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
|
|
319
330
|
import('../store/rule-store.js'),
|
|
320
331
|
import('../engine/lifecycle/signals.js'),
|
|
321
332
|
import('./shared/safe-regex.js'),
|
|
333
|
+
import('./shared/command-parser.js'),
|
|
322
334
|
]);
|
|
323
335
|
const rules = loadActiveRules();
|
|
324
336
|
const command = typeof toolInput.command === 'string'
|
|
@@ -339,7 +351,13 @@ async function main() {
|
|
|
339
351
|
log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
|
|
340
352
|
continue;
|
|
341
353
|
}
|
|
342
|
-
|
|
354
|
+
// TEST-6 / RC5: quote-aware preprocessing. Default 'raw' = backward compat.
|
|
355
|
+
// Rules that target real command invocations should set match_target: 'masked'
|
|
356
|
+
// so quoted argument text (e.g. body of `forgen compound --solution "..."`)
|
|
357
|
+
// doesn't trigger false positive blocks.
|
|
358
|
+
const matchTarget = (v.params?.match_target ?? 'raw');
|
|
359
|
+
const target = preprocessForMatch(command, matchTarget);
|
|
360
|
+
if (!safeRegexTest(re.regex, target))
|
|
343
361
|
continue;
|
|
344
362
|
const requiresFlag = v.params?.requires_flag;
|
|
345
363
|
const confirmed = process.env.FORGEN_USER_CONFIRMED === '1';
|
|
@@ -413,5 +431,5 @@ main().catch((e) => {
|
|
|
413
431
|
});
|
|
414
432
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
415
433
|
// fail-open: approve on internal error to avoid blocking all tool calls
|
|
416
|
-
console.log(failOpenWithTracking('pre-tool-use'));
|
|
434
|
+
console.log(failOpenWithTracking('pre-tool-use', e));
|
|
417
435
|
});
|
|
@@ -87,5 +87,5 @@ main().catch((e) => {
|
|
|
87
87
|
hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
|
|
88
88
|
});
|
|
89
89
|
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
90
|
-
console.log(failOpenWithTracking('secret-filter'));
|
|
90
|
+
console.log(failOpenWithTracking('secret-filter', e));
|
|
91
91
|
});
|
|
@@ -429,6 +429,6 @@ async function main() {
|
|
|
429
429
|
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
430
430
|
main().catch((e) => {
|
|
431
431
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
432
|
-
console.log(failOpenWithTracking('session-recovery'));
|
|
432
|
+
console.log(failOpenWithTracking('session-recovery', e));
|
|
433
433
|
});
|
|
434
434
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-token parser — quote-aware shell command preprocessing.
|
|
3
|
+
*
|
|
4
|
+
* 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
|
|
5
|
+
* 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
|
|
6
|
+
*
|
|
7
|
+
* 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
|
|
8
|
+
* "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
|
|
9
|
+
*
|
|
10
|
+
* 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
|
|
11
|
+
* 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Mask quoted string contents in a shell command so that text inside
|
|
15
|
+
* single/double quotes, backticks, or $(...) is not matched by patterns
|
|
16
|
+
* intended for command tokens.
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* maskQuotedContent('rm -rf /') → 'rm -rf /'
|
|
20
|
+
* maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
|
|
21
|
+
* maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
|
|
22
|
+
* maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
|
|
23
|
+
* maskQuotedContent('echo `rm -rf x`') → 'echo ``'
|
|
24
|
+
*
|
|
25
|
+
* Limitations (documented, not silently broken):
|
|
26
|
+
* - escaped quotes inside quoted strings: best-effort only
|
|
27
|
+
* - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
|
|
28
|
+
* - nested $(...) / `...`: outer level masked
|
|
29
|
+
*/
|
|
30
|
+
export declare function maskQuotedContent(cmd: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Decide if a verifier should match against the raw command, masked command,
|
|
33
|
+
* or the leading command tokens of each statement.
|
|
34
|
+
*
|
|
35
|
+
* 'raw' — backward compat. Match against the unmodified command string.
|
|
36
|
+
* 'masked' — Strip quoted contents first. Use this when the rule wants to
|
|
37
|
+
* guard a real command invocation (e.g. rm -rf) and not text
|
|
38
|
+
* inside string literals passed as arguments to other commands.
|
|
39
|
+
* 'command_tokens' — Reserved for future use (per-statement leading-token check).
|
|
40
|
+
* Currently behaves like 'masked' to avoid silently breaking
|
|
41
|
+
* when rule files use it.
|
|
42
|
+
*/
|
|
43
|
+
export type MatchTarget = 'raw' | 'masked' | 'command_tokens';
|
|
44
|
+
export declare function preprocessForMatch(cmd: string, target: MatchTarget | undefined): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-token parser — quote-aware shell command preprocessing.
|
|
3
|
+
*
|
|
4
|
+
* 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
|
|
5
|
+
* 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
|
|
6
|
+
*
|
|
7
|
+
* 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
|
|
8
|
+
* "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
|
|
9
|
+
*
|
|
10
|
+
* 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
|
|
11
|
+
* 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Mask quoted string contents in a shell command so that text inside
|
|
15
|
+
* single/double quotes, backticks, or $(...) is not matched by patterns
|
|
16
|
+
* intended for command tokens.
|
|
17
|
+
*
|
|
18
|
+
* Examples:
|
|
19
|
+
* maskQuotedContent('rm -rf /') → 'rm -rf /'
|
|
20
|
+
* maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
|
|
21
|
+
* maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
|
|
22
|
+
* maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
|
|
23
|
+
* maskQuotedContent('echo `rm -rf x`') → 'echo ``'
|
|
24
|
+
*
|
|
25
|
+
* Limitations (documented, not silently broken):
|
|
26
|
+
* - escaped quotes inside quoted strings: best-effort only
|
|
27
|
+
* - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
|
|
28
|
+
* - nested $(...) / `...`: outer level masked
|
|
29
|
+
*/
|
|
30
|
+
export function maskQuotedContent(cmd) {
|
|
31
|
+
if (!cmd)
|
|
32
|
+
return cmd;
|
|
33
|
+
let out = cmd;
|
|
34
|
+
// v0.4.1 (2026-04-24) — heredoc body 마스킹 추가. 이전엔 `cat > f <<EOF\n rm -rf /tmp \nEOF`
|
|
35
|
+
// 처럼 heredoc 본문이 command string 에 포함돼 false-positive block 발생.
|
|
36
|
+
// 지원 형식: <<EOF / <<'EOF' / <<"EOF" / <<-EOF (indent 무시 변종).
|
|
37
|
+
// <<-MARK 은 indent 허용 (terminator 앞 whitespace). `\n\s*\2` 로 반영.
|
|
38
|
+
out = out.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?\n\s*\2\b/g, '<<HEREDOC>>');
|
|
39
|
+
// Order matters: command substitution before plain quotes (they may contain quotes themselves).
|
|
40
|
+
out = out.replace(/\$\([^)]*\)/g, '$()');
|
|
41
|
+
out = out.replace(/`[^`]*`/g, '``');
|
|
42
|
+
out = out.replace(/'[^']*'/g, "''");
|
|
43
|
+
out = out.replace(/"[^"]*"/g, '""');
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function preprocessForMatch(cmd, target) {
|
|
47
|
+
if (!target || target === 'raw')
|
|
48
|
+
return cmd;
|
|
49
|
+
return maskQuotedContent(cmd);
|
|
50
|
+
}
|
|
@@ -18,8 +18,13 @@ export declare function approve(): string;
|
|
|
18
18
|
/**
|
|
19
19
|
* 통과 + 모델에 컨텍스트 주입.
|
|
20
20
|
* UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
|
|
21
|
+
*
|
|
22
|
+
* H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
|
|
23
|
+
* 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
|
|
24
|
+
* 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
|
|
25
|
+
* 에게 가시화한다.
|
|
21
26
|
*/
|
|
22
|
-
export declare function approveWithContext(context: string, eventName: string): string;
|
|
27
|
+
export declare function approveWithContext(context: string, eventName: string, userNotice?: string): string;
|
|
23
28
|
/**
|
|
24
29
|
* 통과 + UI 경고 표시 (모델에는 전달되지 않음).
|
|
25
30
|
* PostToolUse, PreToolUse 경고 등 모델 도달이 불필요한 경우 사용.
|
|
@@ -44,6 +49,11 @@ export declare function blockStop(reason: string, systemMessage?: string): strin
|
|
|
44
49
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
45
50
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
46
51
|
*
|
|
52
|
+
* v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
|
|
53
|
+
* 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
|
|
54
|
+
* 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
|
|
55
|
+
* payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
|
|
56
|
+
*
|
|
47
57
|
* @fail-open: hook failure must never block the user's workflow
|
|
48
58
|
*/
|
|
49
|
-
export declare function failOpenWithTracking(hookName: string): string;
|
|
59
|
+
export declare function failOpenWithTracking(hookName: string, err?: unknown): string;
|
|
@@ -23,11 +23,17 @@ export function approve() {
|
|
|
23
23
|
/**
|
|
24
24
|
* 통과 + 모델에 컨텍스트 주입.
|
|
25
25
|
* UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
|
|
26
|
+
*
|
|
27
|
+
* H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
|
|
28
|
+
* 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
|
|
29
|
+
* 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
|
|
30
|
+
* 에게 가시화한다.
|
|
26
31
|
*/
|
|
27
|
-
export function approveWithContext(context, eventName) {
|
|
32
|
+
export function approveWithContext(context, eventName, userNotice) {
|
|
28
33
|
return JSON.stringify({
|
|
29
34
|
continue: true,
|
|
30
35
|
hookSpecificOutput: { hookEventName: eventName, additionalContext: context },
|
|
36
|
+
...(userNotice ? { systemMessage: userNotice } : {}),
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
@@ -81,13 +87,34 @@ export function blockStop(reason, systemMessage) {
|
|
|
81
87
|
* fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
|
|
82
88
|
* forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
|
|
83
89
|
*
|
|
90
|
+
* v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
|
|
91
|
+
* 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
|
|
92
|
+
* 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
|
|
93
|
+
* payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
|
|
94
|
+
*
|
|
84
95
|
* @fail-open: hook failure must never block the user's workflow
|
|
85
96
|
*/
|
|
86
|
-
export function failOpenWithTracking(hookName) {
|
|
97
|
+
export function failOpenWithTracking(hookName, err) {
|
|
87
98
|
try {
|
|
88
99
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
89
100
|
const logPath = path.join(STATE_DIR, 'hook-errors.jsonl');
|
|
90
|
-
const
|
|
101
|
+
const payload = { hook: hookName, at: Date.now() };
|
|
102
|
+
if (err !== undefined && err !== null) {
|
|
103
|
+
if (err instanceof Error) {
|
|
104
|
+
payload.error = err.message.slice(0, 400);
|
|
105
|
+
if (err.stack) {
|
|
106
|
+
// 스택 첫 3줄만 — 어느 파일/라인에서 throw 됐는지만 알면 충분.
|
|
107
|
+
payload.stack = err.stack.split('\n').slice(0, 3).join(' | ').slice(0, 400);
|
|
108
|
+
}
|
|
109
|
+
const maybeCode = err.code;
|
|
110
|
+
if (typeof maybeCode === 'string')
|
|
111
|
+
payload.code = maybeCode;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
payload.error = String(err).slice(0, 400);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const entry = JSON.stringify(payload);
|
|
91
118
|
fs.appendFileSync(logPath, entry + '\n');
|
|
92
119
|
}
|
|
93
120
|
catch { /* fail-open: tracking itself must not throw */ }
|
|
@@ -312,5 +312,5 @@ async function main() {
|
|
|
312
312
|
}
|
|
313
313
|
main().catch((e) => {
|
|
314
314
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
315
|
-
console.log(failOpenWithTracking('skill-injector'));
|
|
315
|
+
console.log(failOpenWithTracking('skill-injector', e));
|
|
316
316
|
});
|
|
@@ -84,10 +84,10 @@ async function main() {
|
|
|
84
84
|
}
|
|
85
85
|
catch (e) {
|
|
86
86
|
log.debug('슬롭 감지 실패', e);
|
|
87
|
-
console.log(failOpenWithTracking('slop-detector'));
|
|
87
|
+
console.log(failOpenWithTracking('slop-detector', e));
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
main().catch((e) => {
|
|
91
91
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
92
|
-
console.log(failOpenWithTracking('slop-detector'));
|
|
92
|
+
console.log(failOpenWithTracking('slop-detector', e));
|
|
93
93
|
});
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const MIN_INJECT_RELEVANCE = 0.3;
|
|
30
30
|
export declare const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
|
|
31
|
+
/**
|
|
32
|
+
* v0.4.1 — cold-start 사용자 threshold. outcomes 이벤트가 거의 없는 신규 사용자
|
|
33
|
+
* 는 starter-pack 이 champion/active 로 승격될 기회가 없어 0.3 gate 에 막혀 주입 0.
|
|
34
|
+
* 실측 (buyer-day1 v2): starter 15개 중 recall 5건 전부 relevance 0.08~0.25, 주입 0.
|
|
35
|
+
* 이 값으로 첫날부터 매칭 가능성 제공 + 누적 후엔 표준 threshold 로 자연 전환.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIN_INJECT_RELEVANCE_COLD_START = 0.2;
|
|
38
|
+
/** cold-start 판정 임계 — fitness state 있는 솔루션이 이 수 미만이면 신규 사용자 간주. */
|
|
39
|
+
export declare const COLD_START_FITNESS_THRESHOLD = 5;
|
|
31
40
|
interface SessionCacheCommitResult {
|
|
32
41
|
/**
|
|
33
42
|
* commit 상태:
|
|
@@ -29,6 +29,7 @@ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook
|
|
|
29
29
|
import { STATE_DIR } from '../core/paths.js';
|
|
30
30
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
31
31
|
import { appendPending, flushAccept } from '../engine/solution-outcomes.js';
|
|
32
|
+
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
32
33
|
const MAX_SOLUTIONS_PER_SESSION = 10;
|
|
33
34
|
/**
|
|
34
35
|
* Minimum relevance thresholds by fitness state (2026-04-21 gate sweep).
|
|
@@ -51,6 +52,15 @@ const MAX_SOLUTIONS_PER_SESSION = 10;
|
|
|
51
52
|
*/
|
|
52
53
|
export const MIN_INJECT_RELEVANCE = 0.3;
|
|
53
54
|
export const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
|
|
55
|
+
/**
|
|
56
|
+
* v0.4.1 — cold-start 사용자 threshold. outcomes 이벤트가 거의 없는 신규 사용자
|
|
57
|
+
* 는 starter-pack 이 champion/active 로 승격될 기회가 없어 0.3 gate 에 막혀 주입 0.
|
|
58
|
+
* 실측 (buyer-day1 v2): starter 15개 중 recall 5건 전부 relevance 0.08~0.25, 주입 0.
|
|
59
|
+
* 이 값으로 첫날부터 매칭 가능성 제공 + 누적 후엔 표준 threshold 로 자연 전환.
|
|
60
|
+
*/
|
|
61
|
+
export const MIN_INJECT_RELEVANCE_COLD_START = 0.2;
|
|
62
|
+
/** cold-start 판정 임계 — fitness state 있는 솔루션이 이 수 미만이면 신규 사용자 간주. */
|
|
63
|
+
export const COLD_START_FITNESS_THRESHOLD = 5;
|
|
54
64
|
/** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
|
|
55
65
|
function getSessionCachePath(sessionId) {
|
|
56
66
|
return path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
|
|
@@ -347,11 +357,17 @@ async function main() {
|
|
|
347
357
|
catch (e) {
|
|
348
358
|
log.debug('fitness state load 실패 — default 0.3 적용', e);
|
|
349
359
|
}
|
|
360
|
+
// v0.4.1 cold-start boost: outcome 이벤트가 누적되지 않은 신규 사용자 (fitness
|
|
361
|
+
// state 있는 솔루션 < THRESHOLD) 는 champion/active 로 승격될 기회가 없으므로
|
|
362
|
+
// 보정된 낮은 threshold 적용. 누적되면 자동으로 표준 경로로 전환.
|
|
363
|
+
const isColdStart = fitnessStateMap.size < COLD_START_FITNESS_THRESHOLD;
|
|
350
364
|
function minRelevanceFor(name) {
|
|
351
365
|
const state = fitnessStateMap.get(name);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
366
|
+
if (state === 'champion' || state === 'active')
|
|
367
|
+
return MIN_INJECT_RELEVANCE_TRUSTED;
|
|
368
|
+
if (isColdStart)
|
|
369
|
+
return MIN_INJECT_RELEVANCE_COLD_START;
|
|
370
|
+
return MIN_INJECT_RELEVANCE;
|
|
355
371
|
}
|
|
356
372
|
let experimentCount = 0;
|
|
357
373
|
const toInject = [];
|
|
@@ -530,7 +546,34 @@ async function main() {
|
|
|
530
546
|
catch (e) {
|
|
531
547
|
log.debug('outcome appendPending 실패', e);
|
|
532
548
|
}
|
|
533
|
-
|
|
549
|
+
// H4: 양수 implicit-feedback — 솔루션이 실제로 사용자에게 surface 되었음을 기록.
|
|
550
|
+
// v0.4.0 의 enforcement 축은 block/violation 만 카운트했고 assist (solution 노출)
|
|
551
|
+
// 은 0건이었다. 이 emit 으로 forgen stats / session-quality-scorer 가 "오늘
|
|
552
|
+
// N개 surfaced" 를 계산할 수 있다.
|
|
553
|
+
try {
|
|
554
|
+
const now = new Date().toISOString();
|
|
555
|
+
for (const sol of effectiveToInject) {
|
|
556
|
+
appendImplicitFeedback({
|
|
557
|
+
type: 'recommendation_surfaced',
|
|
558
|
+
category: 'positive',
|
|
559
|
+
solution: sol.name,
|
|
560
|
+
match_score: sol.relevance,
|
|
561
|
+
at: now,
|
|
562
|
+
sessionId,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
log.debug('recommendation_surfaced emit 실패', e);
|
|
568
|
+
}
|
|
569
|
+
// H1: 사용자 UI 에 recall hit 1줄 노출. additionalContext 는 모델 전용이라
|
|
570
|
+
// v0.4.0 에서 8,000+ 주입이 발생했는데도 사용자는 0건을 봤다. systemMessage
|
|
571
|
+
// 로 "N개 솔루션 참조" 를 surface → 사용자가 어떤 축적 지식이 붙었는지 인식.
|
|
572
|
+
const topNames = effectiveToInject.slice(0, 3).map((s) => s.name);
|
|
573
|
+
const more = effectiveToInject.length - topNames.length;
|
|
574
|
+
const noticeNames = more > 0 ? `${topNames.join(', ')} (+${more})` : topNames.join(', ');
|
|
575
|
+
const userNotice = `[Forgen] 🔎 ${effectiveToInject.length} solution${effectiveToInject.length === 1 ? '' : 's'} recalled: ${noticeNames}`;
|
|
576
|
+
console.log(approveWithContext(fullInjection, 'UserPromptSubmit', userNotice));
|
|
534
577
|
}
|
|
535
578
|
finally {
|
|
536
579
|
recordHookTiming('solution-injector', Date.now() - _hookStart, 'UserPromptSubmit');
|
|
@@ -538,5 +581,5 @@ async function main() {
|
|
|
538
581
|
}
|
|
539
582
|
main().catch((e) => {
|
|
540
583
|
process.stderr.write(`[ch-hook] solution-injector: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
541
|
-
console.log(failOpenWithTracking('solution-injector'));
|
|
584
|
+
console.log(failOpenWithTracking('solution-injector', e));
|
|
542
585
|
});
|