@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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI handler for `forgen classify-enforce [--apply] [--force]`.
|
|
3
|
+
*
|
|
4
|
+
* 기본: dry-run — 각 rule 의 제안만 출력. 변경 없음.
|
|
5
|
+
* --apply: 제안을 rule 파일에 저장 (enforce_via 미설정 rule 만).
|
|
6
|
+
* --force: enforce_via 가 이미 있어도 덮어쓴다.
|
|
7
|
+
*/
|
|
8
|
+
import { loadAllRules, saveRule } from '../store/rule-store.js';
|
|
9
|
+
import { classifyAll, applyProposal } from './enforce-classifier.js';
|
|
10
|
+
export async function handleClassifyEnforce(args) {
|
|
11
|
+
const apply = args.includes('--apply');
|
|
12
|
+
const force = args.includes('--force');
|
|
13
|
+
const rules = loadAllRules();
|
|
14
|
+
if (rules.length === 0) {
|
|
15
|
+
console.log('\n No rules in ~/.forgen/me/rules. Nothing to classify.\n');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const proposals = classifyAll(rules);
|
|
19
|
+
let saved = 0;
|
|
20
|
+
let skipped = 0;
|
|
21
|
+
let alreadySet = 0;
|
|
22
|
+
console.log(`\n Enforce Classifier — ${rules.length} rule(s) scanned\n`);
|
|
23
|
+
for (let i = 0; i < proposals.length; i++) {
|
|
24
|
+
const p = proposals[i];
|
|
25
|
+
const rule = rules[i];
|
|
26
|
+
const marker = p.current_enforce_via ? '↻' : '+';
|
|
27
|
+
console.log(` ${marker} ${p.rule_id.slice(0, 8)} "${p.trigger_preview}"`);
|
|
28
|
+
console.log(` strength=${rule.strength} status=${rule.status}`);
|
|
29
|
+
for (const spec of p.proposed) {
|
|
30
|
+
const vparts = [spec.verifier?.kind ?? 'none'];
|
|
31
|
+
if (spec.drift_key)
|
|
32
|
+
vparts.push(`drift_key=${spec.drift_key}`);
|
|
33
|
+
console.log(` → Mech-${spec.mech} @ ${spec.hook} verifier=${vparts.join(' ')}`);
|
|
34
|
+
}
|
|
35
|
+
for (const reason of p.reasoning) {
|
|
36
|
+
console.log(` · ${reason}`);
|
|
37
|
+
}
|
|
38
|
+
if (apply) {
|
|
39
|
+
if (p.current_enforce_via && p.current_enforce_via.length > 0 && !force) {
|
|
40
|
+
alreadySet += 1;
|
|
41
|
+
console.log(' (skipped — enforce_via already set; use --force to overwrite)');
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const updated = applyProposal(rule, p, { force });
|
|
45
|
+
saveRule(updated);
|
|
46
|
+
saved += 1;
|
|
47
|
+
console.log(' (saved)');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
skipped += 1;
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
55
|
+
if (apply) {
|
|
56
|
+
console.log(` Summary: saved=${saved} already-set=${alreadySet} total=${rules.length}\n`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log(` Summary: ${skipped} proposal(s) previewed. Run with --apply to save.\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -20,5 +20,30 @@ export declare function removeSolution(name: string): void;
|
|
|
20
20
|
export declare function cleanStaleSolutions(): void;
|
|
21
21
|
/** Retag all solutions using improved extractTags */
|
|
22
22
|
export declare function retagSolutions(): void;
|
|
23
|
-
/**
|
|
24
|
-
export
|
|
23
|
+
/** Result of a rollback operation — used by tests and callers that need counts. */
|
|
24
|
+
export interface RollbackCliResult {
|
|
25
|
+
archived: string[];
|
|
26
|
+
archiveDir: string | null;
|
|
27
|
+
skipped: string[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
dryRun: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rollback auto-extracted solutions created since a given date.
|
|
33
|
+
*
|
|
34
|
+
* Invariant (2026-04-20, feedback_core_loop_invariant):
|
|
35
|
+
* rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
|
|
36
|
+
* 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
|
|
37
|
+
* rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
|
|
38
|
+
* time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
|
|
39
|
+
*
|
|
40
|
+
* 필터 기준:
|
|
41
|
+
* - category === 'solution'만 대상 (rule은 제외)
|
|
42
|
+
* - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
|
|
43
|
+
* - created >= since 인 것만 대상
|
|
44
|
+
*
|
|
45
|
+
* dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
|
|
46
|
+
*/
|
|
47
|
+
export declare function rollbackSolutions(sinceDate: string, opts?: {
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
}): RollbackCliResult;
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { extractTags, parseFrontmatterOnly, parseSolutionV3 } from './solution-format.js';
|
|
4
4
|
import { mutateSolutionFile } from './solution-writer.js';
|
|
5
|
-
import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
5
|
+
import { ARCHIVED_DIR, ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
6
6
|
/** Scan saved compound entries and return summaries */
|
|
7
7
|
function scanEntries() {
|
|
8
8
|
const summaries = [];
|
|
@@ -78,7 +78,17 @@ export function listSolutions() {
|
|
|
78
78
|
console.log(` ${entry.name} [${entry.category}] (${entry.confidence.toFixed(2)}) ${evStr} [${entry.tags.slice(0, 3).join(', ')}]`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// retired 카운트: scanEntries는 모든 상태를 포함하므로 직접 계산
|
|
82
|
+
const retiredCount = entries.filter(e => e.status === 'retired').length;
|
|
83
|
+
const activeCount = total - retiredCount;
|
|
84
|
+
const highConfidence = entries.filter(e => e.status === 'verified' || e.status === 'mature').length;
|
|
85
|
+
const denominator = total;
|
|
86
|
+
const precision = denominator > 0 ? Math.round((highConfidence / denominator) * 100) : null;
|
|
87
|
+
console.log(`\n Total: ${activeCount} active + ${retiredCount} retired`);
|
|
88
|
+
if (precision !== null) {
|
|
89
|
+
console.log(` Extraction precision: ${precision}%`);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
82
92
|
}
|
|
83
93
|
/** Inspect a single saved entry in detail */
|
|
84
94
|
export function inspectSolution(name) {
|
|
@@ -218,33 +228,76 @@ export function retagSolutions() {
|
|
|
218
228
|
}
|
|
219
229
|
console.log(`\n Retagged ${retagged}/${entries.length} solutions.\n`);
|
|
220
230
|
}
|
|
221
|
-
/**
|
|
222
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Rollback auto-extracted solutions created since a given date.
|
|
233
|
+
*
|
|
234
|
+
* Invariant (2026-04-20, feedback_core_loop_invariant):
|
|
235
|
+
* rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
|
|
236
|
+
* 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
|
|
237
|
+
* rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
|
|
238
|
+
* time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
|
|
239
|
+
*
|
|
240
|
+
* 필터 기준:
|
|
241
|
+
* - category === 'solution'만 대상 (rule은 제외)
|
|
242
|
+
* - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
|
|
243
|
+
* - created >= since 인 것만 대상
|
|
244
|
+
*
|
|
245
|
+
* dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
|
|
246
|
+
*/
|
|
247
|
+
export function rollbackSolutions(sinceDate, opts = {}) {
|
|
248
|
+
const result = {
|
|
249
|
+
archived: [],
|
|
250
|
+
archiveDir: null,
|
|
251
|
+
skipped: [],
|
|
252
|
+
errors: [],
|
|
253
|
+
dryRun: !!opts.dryRun,
|
|
254
|
+
};
|
|
223
255
|
const since = new Date(sinceDate);
|
|
224
256
|
if (Number.isNaN(since.getTime())) {
|
|
225
257
|
console.log(`\n Invalid date: ${sinceDate}\n`);
|
|
226
|
-
|
|
258
|
+
result.errors.push(`invalid-date:${sinceDate}`);
|
|
259
|
+
return result;
|
|
227
260
|
}
|
|
228
261
|
const solutions = scanEntries().filter((entry) => entry.category === 'solution');
|
|
229
|
-
const
|
|
262
|
+
const toRollback = solutions.filter((solution) => {
|
|
230
263
|
if (solution.evidence.reflected > 0 || solution.evidence.sessions > 0)
|
|
231
|
-
return false;
|
|
264
|
+
return false;
|
|
232
265
|
const created = new Date(solution.created);
|
|
233
266
|
return created >= since;
|
|
234
267
|
});
|
|
235
|
-
if (
|
|
268
|
+
if (toRollback.length === 0) {
|
|
236
269
|
console.log(`\n No solutions to rollback since ${sinceDate}.\n`);
|
|
237
|
-
return;
|
|
270
|
+
return result;
|
|
238
271
|
}
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
if (opts.dryRun) {
|
|
273
|
+
console.log(`\n [dry-run] ${toRollback.length} solutions would be archived since ${sinceDate}:\n`);
|
|
274
|
+
for (const sol of toRollback) {
|
|
275
|
+
console.log(` Would archive: ${sol.name}`);
|
|
276
|
+
result.skipped.push(sol.filePath);
|
|
277
|
+
}
|
|
278
|
+
console.log(`\n Re-run without --dry-run to archive them.\n`);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
const archiveDir = path.join(ARCHIVED_DIR, `rollback-${Date.now()}`);
|
|
282
|
+
result.archiveDir = archiveDir;
|
|
283
|
+
console.log(`\n Rolling back ${toRollback.length} solutions since ${sinceDate} → ${archiveDir}:\n`);
|
|
284
|
+
for (const sol of toRollback) {
|
|
241
285
|
try {
|
|
242
|
-
fs.
|
|
243
|
-
|
|
286
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
287
|
+
// 원본 경로 정보를 destName에 보존 — 복원 시 원위치 판별용.
|
|
288
|
+
// 예: "solutions__my-pattern.md"
|
|
289
|
+
const originDir = path.basename(path.dirname(sol.filePath));
|
|
290
|
+
const destName = `${originDir}__${path.basename(sol.filePath)}`;
|
|
291
|
+
fs.renameSync(sol.filePath, path.join(archiveDir, destName));
|
|
292
|
+
console.log(` Archived: ${sol.name}`);
|
|
293
|
+
result.archived.push(sol.filePath);
|
|
244
294
|
}
|
|
245
|
-
catch {
|
|
246
|
-
|
|
295
|
+
catch (e) {
|
|
296
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
297
|
+
console.log(` Failed: ${sol.name} — ${msg}`);
|
|
298
|
+
result.errors.push(`${sol.filePath}: ${msg}`);
|
|
247
299
|
}
|
|
248
300
|
}
|
|
249
|
-
console.log();
|
|
301
|
+
console.log(`\n ${result.archived.length}/${toRollback.length} archived. Restore from ${archiveDir} if needed.\n`);
|
|
302
|
+
return result;
|
|
250
303
|
}
|
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
* Export creates a tar.gz archive; Import extracts it while skipping existing
|
|
8
8
|
* files to prevent accidental overwrites.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Test-friendly containment check (exported for unit tests).
|
|
12
|
+
*
|
|
13
|
+
* Audit fix (2026-04-21, follow-up #A): prior guard
|
|
14
|
+
* `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
|
|
15
|
+
* `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
|
|
16
|
+
* bypassed the check — even though it's a sibling, not a child, of
|
|
17
|
+
* ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
|
|
18
|
+
* cannot prefix-match `/home/u/.forgen/me-evil/...`.
|
|
19
|
+
*
|
|
20
|
+
* @param parentWithSep absolute canonical parent path that INCLUDES a
|
|
21
|
+
* trailing `path.sep` (precomputed by caller once per archive).
|
|
22
|
+
* @param candidate any path string (will be resolved lexically).
|
|
23
|
+
*/
|
|
24
|
+
export declare function isPathInside(parentWithSep: string, candidate: string): boolean;
|
|
10
25
|
export interface ExportResult {
|
|
11
26
|
outputPath: string;
|
|
12
27
|
counts: Record<string, number>;
|
|
@@ -14,6 +14,24 @@ import { execFileSync } from 'node:child_process';
|
|
|
14
14
|
import { ME_DIR } from '../core/paths.js';
|
|
15
15
|
/** Directories within ME_DIR to include in the archive. */
|
|
16
16
|
const KNOWLEDGE_DIRS = ['solutions', 'rules', 'behavior'];
|
|
17
|
+
/**
|
|
18
|
+
* Test-friendly containment check (exported for unit tests).
|
|
19
|
+
*
|
|
20
|
+
* Audit fix (2026-04-21, follow-up #A): prior guard
|
|
21
|
+
* `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
|
|
22
|
+
* `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
|
|
23
|
+
* bypassed the check — even though it's a sibling, not a child, of
|
|
24
|
+
* ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
|
|
25
|
+
* cannot prefix-match `/home/u/.forgen/me-evil/...`.
|
|
26
|
+
*
|
|
27
|
+
* @param parentWithSep absolute canonical parent path that INCLUDES a
|
|
28
|
+
* trailing `path.sep` (precomputed by caller once per archive).
|
|
29
|
+
* @param candidate any path string (will be resolved lexically).
|
|
30
|
+
*/
|
|
31
|
+
export function isPathInside(parentWithSep, candidate) {
|
|
32
|
+
const resolved = path.resolve(candidate) + path.sep;
|
|
33
|
+
return resolved.startsWith(parentWithSep) && resolved !== parentWithSep;
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Count .md files in a directory (non-recursive).
|
|
19
37
|
* Returns 0 if the directory does not exist.
|
|
@@ -92,12 +110,21 @@ export function importKnowledge(archivePath) {
|
|
|
92
110
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
93
111
|
});
|
|
94
112
|
const result = { imported: 0, skipped: 0, details: [] };
|
|
113
|
+
// Audit fix (2026-04-21, follow-up #A): prior check
|
|
114
|
+
// `realDest.startsWith(ME_DIR)` was a broken prefix guard. An archive
|
|
115
|
+
// entry `../me-evil/payload.md` resolves to a sibling directory path
|
|
116
|
+
// that still startsWith ME_DIR (prefix collision) and bypasses the
|
|
117
|
+
// check. Fix: compare lexically resolved paths with an explicit
|
|
118
|
+
// `path.sep` suffix so `.../me-evil` can never masquerade as
|
|
119
|
+
// `.../me/...`. Both source and destination are validated — a
|
|
120
|
+
// malformed archive must neither read from outside tmpDir nor write
|
|
121
|
+
// outside ME_DIR.
|
|
122
|
+
const meDirCanon = path.resolve(ME_DIR) + path.sep;
|
|
123
|
+
const tmpDirCanon = path.resolve(tmpDir) + path.sep;
|
|
95
124
|
for (const relFile of archiveFiles) {
|
|
96
|
-
const srcPath = path.join(tmpDir, relFile);
|
|
97
|
-
const destPath = path.join(ME_DIR, relFile);
|
|
98
|
-
|
|
99
|
-
const realDest = path.resolve(destPath);
|
|
100
|
-
if (!realDest.startsWith(ME_DIR)) {
|
|
125
|
+
const srcPath = path.resolve(path.join(tmpDir, relFile));
|
|
126
|
+
const destPath = path.resolve(path.join(ME_DIR, relFile));
|
|
127
|
+
if (!isPathInside(meDirCanon, destPath) || !isPathInside(tmpDirCanon, srcPath)) {
|
|
101
128
|
result.skipped++;
|
|
102
129
|
result.details.push({ file: relFile, action: 'skipped' });
|
|
103
130
|
continue;
|
|
@@ -324,11 +324,12 @@ export async function handleCompound(args) {
|
|
|
324
324
|
const sinceIdx = args.indexOf('--since');
|
|
325
325
|
const since = sinceIdx !== -1 ? args[sinceIdx + 1] : undefined;
|
|
326
326
|
if (!since) {
|
|
327
|
-
console.log(' Usage: forgen compound rollback --since
|
|
327
|
+
console.log(' Usage: forgen compound rollback --since YYYY-MM-DD [--dry-run]\n');
|
|
328
328
|
return;
|
|
329
329
|
}
|
|
330
|
+
const dryRun = args.includes('--dry-run');
|
|
330
331
|
const { rollbackSolutions } = await import('./compound-cli.js');
|
|
331
|
-
rollbackSolutions(since);
|
|
332
|
+
rollbackSolutions(since, { dryRun });
|
|
332
333
|
return;
|
|
333
334
|
}
|
|
334
335
|
// --- explicit interactive command ---
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Enforce Classifier (ADR-001 §Migration)
|
|
3
|
+
*
|
|
4
|
+
* 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
|
|
5
|
+
* 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
|
|
6
|
+
*
|
|
7
|
+
* 휴리스틱 (ADR-001 §Migration heuristics):
|
|
8
|
+
* - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
|
|
9
|
+
* - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
|
|
10
|
+
* - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
|
|
11
|
+
* - 그 외 soft/default → Mech-C (drift 측정)
|
|
12
|
+
*
|
|
13
|
+
* 설계 원칙:
|
|
14
|
+
* - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
|
|
15
|
+
* - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
|
|
16
|
+
* - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
|
|
17
|
+
*/
|
|
18
|
+
import type { Rule, EnforceSpec } from '../store/types.js';
|
|
19
|
+
export interface EnforceProposal {
|
|
20
|
+
rule_id: string;
|
|
21
|
+
trigger_preview: string;
|
|
22
|
+
current_enforce_via: EnforceSpec[] | null;
|
|
23
|
+
proposed: EnforceSpec[];
|
|
24
|
+
reasoning: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function classify(rule: Rule): EnforceProposal;
|
|
27
|
+
export declare function classifyAll(rules: Rule[]): EnforceProposal[];
|
|
28
|
+
/** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
|
|
29
|
+
export declare function applyProposal(rule: Rule, proposal: EnforceProposal, options?: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}): Rule;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Enforce Classifier (ADR-001 §Migration)
|
|
3
|
+
*
|
|
4
|
+
* 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
|
|
5
|
+
* 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
|
|
6
|
+
*
|
|
7
|
+
* 휴리스틱 (ADR-001 §Migration heuristics):
|
|
8
|
+
* - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
|
|
9
|
+
* - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
|
|
10
|
+
* - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
|
|
11
|
+
* - 그 외 soft/default → Mech-C (drift 측정)
|
|
12
|
+
*
|
|
13
|
+
* 설계 원칙:
|
|
14
|
+
* - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
|
|
15
|
+
* - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
|
|
16
|
+
* - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
|
|
17
|
+
*/
|
|
18
|
+
const DESTRUCTIVE_PATTERN = /\b(rm\s+-rf|rm\s+-fr|force|DROP\s+TABLE|credentials|\.env|sudo|mkfs|dd\s+if=)/i;
|
|
19
|
+
const COMPLETION_PATTERN = /(완료|complete|done|ready|shipped|finished|e2e|mock|verify|검증|배포)/i;
|
|
20
|
+
const STYLE_PATTERN = /(문체|응답|설명|톤|어투|장황|간결|verbose|tone|style)/i;
|
|
21
|
+
// R6-F2: shared single source of truth — stop-guard 와 동일 regex 재사용.
|
|
22
|
+
import { DEFAULT_STOP_TRIGGER_RE as STOP_COMPLETION_TRIGGER, DEFAULT_STOP_EXCLUDE_RE as STOP_COMPLETION_EXCLUDE, MOCK_TRIGGER_RE as STOP_MOCK_TRIGGER, MOCK_EXCLUDE_RE as STOP_MOCK_EXCLUDE, } from '../hooks/shared/stop-triggers.js';
|
|
23
|
+
export function classify(rule) {
|
|
24
|
+
const reasoning = [];
|
|
25
|
+
const proposed = [];
|
|
26
|
+
const text = `${rule.trigger}\n${rule.policy}`;
|
|
27
|
+
const isDestructive = DESTRUCTIVE_PATTERN.test(text);
|
|
28
|
+
const isCompletion = COMPLETION_PATTERN.test(text);
|
|
29
|
+
const isStyle = STYLE_PATTERN.test(text);
|
|
30
|
+
const isStrong = rule.strength === 'strong' || rule.strength === 'hard';
|
|
31
|
+
// Mech-A PreToolUse — 파괴적 명령 패턴.
|
|
32
|
+
// 이전에는 DESTRUCTIVE_PATTERN.source 를 다시 .match() 하여 alternation 의 첫 리터럴
|
|
33
|
+
// ("credentials") 만 반환하는 버그가 있었음. 이제 rule 텍스트에서 실제 매칭된 구문을
|
|
34
|
+
// 뽑아 그 구문에 맞는 runtime regex 로 변환.
|
|
35
|
+
if (isDestructive) {
|
|
36
|
+
const matched = text.match(DESTRUCTIVE_PATTERN);
|
|
37
|
+
const matchedLiteral = matched?.[0] ?? '';
|
|
38
|
+
// 안전을 위해 매칭된 literal 을 공백 보존 + escape 해서 runtime regex 로 재구성.
|
|
39
|
+
// 예: "rm -rf" → "rm\s+-rf" (공백 유연); "DROP TABLE" → "DROP\s+TABLE"; ".env" → "\.env"
|
|
40
|
+
const pattern = matchedLiteral
|
|
41
|
+
? matchedLiteral
|
|
42
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metachar
|
|
43
|
+
.replace(/\s+/g, '\\s+') // 공백 하나 이상
|
|
44
|
+
: 'rm\\s+-rf'; // fallback
|
|
45
|
+
proposed.push({
|
|
46
|
+
mech: 'A',
|
|
47
|
+
hook: 'PreToolUse',
|
|
48
|
+
verifier: {
|
|
49
|
+
kind: 'tool_arg_regex',
|
|
50
|
+
params: { pattern, requires_flag: 'user_confirmed' },
|
|
51
|
+
},
|
|
52
|
+
block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 80)}`,
|
|
53
|
+
});
|
|
54
|
+
reasoning.push(`destructive literal "${matchedLiteral}" → Mech-A PreToolUse+tool_arg_regex ${pattern}`);
|
|
55
|
+
}
|
|
56
|
+
// Mech-A Stop — 완료 선언 + 증거 요구 (destructive 와 독립적으로 평가: 하나의 rule 이 둘 다 해당 가능)
|
|
57
|
+
if (isCompletion) {
|
|
58
|
+
const mockAsProof = /mock|stub|fake/i.test(text);
|
|
59
|
+
// 증거 파일 경로는 v0.4.0 최종 구현에서 rule.policy 에서 추출; 지금은 default 사용
|
|
60
|
+
proposed.push({
|
|
61
|
+
mech: 'A',
|
|
62
|
+
hook: 'Stop',
|
|
63
|
+
verifier: {
|
|
64
|
+
kind: 'artifact_check',
|
|
65
|
+
params: { path: '.forgen/state/e2e-result.json', max_age_s: 3600 },
|
|
66
|
+
},
|
|
67
|
+
block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 120)}`,
|
|
68
|
+
trigger_keywords_regex: mockAsProof ? STOP_MOCK_TRIGGER : STOP_COMPLETION_TRIGGER,
|
|
69
|
+
trigger_exclude_regex: mockAsProof ? STOP_MOCK_EXCLUDE : STOP_COMPLETION_EXCLUDE,
|
|
70
|
+
system_tag: `rule:${rule.rule_id.slice(0, 8)} — ${mockAsProof ? 'no-mock-as-proof' : 'e2e-before-done'}`,
|
|
71
|
+
});
|
|
72
|
+
reasoning.push(mockAsProof
|
|
73
|
+
? 'completion + mock keyword → Mech-A Stop+artifact_check (mock trigger)'
|
|
74
|
+
: 'completion keyword → Mech-A Stop+artifact_check (completion trigger)');
|
|
75
|
+
}
|
|
76
|
+
// Mech-B — 문체/응답 관련 또는 strong/hard 정책이지만 기계 판정 어려운 경우
|
|
77
|
+
if ((isStyle || (isStrong && !isDestructive && !isCompletion))) {
|
|
78
|
+
proposed.push({
|
|
79
|
+
mech: 'B',
|
|
80
|
+
hook: 'Stop',
|
|
81
|
+
verifier: {
|
|
82
|
+
kind: 'self_check_prompt',
|
|
83
|
+
params: {
|
|
84
|
+
question: `직전 응답이 다음 규칙을 위반했는지 자가점검하라: "${rule.policy.slice(0, 120)}". 위반 시 구체적 근거와 함께 수정해 재응답하라.`,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
trigger_keywords_regex: STOP_COMPLETION_TRIGGER,
|
|
88
|
+
trigger_exclude_regex: STOP_COMPLETION_EXCLUDE,
|
|
89
|
+
system_tag: `rule:${rule.rule_id.slice(0, 8)} — style-check`,
|
|
90
|
+
});
|
|
91
|
+
reasoning.push(isStyle ? 'style/tone keyword → Mech-B Stop+self_check_prompt' : 'strong/hard strength + non-mechanical → Mech-B Stop+self_check_prompt');
|
|
92
|
+
}
|
|
93
|
+
// 잔여 — drift measure only (Mech-C)
|
|
94
|
+
if (proposed.length === 0) {
|
|
95
|
+
proposed.push({
|
|
96
|
+
mech: 'C',
|
|
97
|
+
hook: 'PostToolUse',
|
|
98
|
+
drift_key: `rule.${rule.rule_id.slice(0, 8)}`,
|
|
99
|
+
});
|
|
100
|
+
reasoning.push('no direct enforcement pattern → Mech-C drift measurement');
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
rule_id: rule.rule_id,
|
|
104
|
+
trigger_preview: rule.trigger.slice(0, 60),
|
|
105
|
+
current_enforce_via: rule.enforce_via ?? null,
|
|
106
|
+
proposed,
|
|
107
|
+
reasoning,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function classifyAll(rules) {
|
|
111
|
+
return rules.map(classify);
|
|
112
|
+
}
|
|
113
|
+
/** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
|
|
114
|
+
export function applyProposal(rule, proposal, options = {}) {
|
|
115
|
+
if (rule.enforce_via && rule.enforce_via.length > 0 && !options.force) {
|
|
116
|
+
return rule;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...rule,
|
|
120
|
+
enforce_via: proposal.proposed,
|
|
121
|
+
updated_at: new Date().toISOString(),
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/engine/learn-cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
1
2
|
import * as path from 'node:path';
|
|
2
3
|
import * as os from 'node:os';
|
|
3
4
|
import { fixupSolutions } from './solution-fixup.js';
|
|
@@ -6,6 +7,8 @@ import { computeFitness } from './solution-fitness.js';
|
|
|
6
7
|
import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
|
|
7
8
|
import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
|
|
8
9
|
const ME_SOLUTIONS = path.join(os.homedir(), '.forgen', 'me', 'solutions');
|
|
10
|
+
const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
|
|
11
|
+
const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
|
|
9
12
|
export async function handleLearn(args) {
|
|
10
13
|
const sub = args[0];
|
|
11
14
|
if (sub === 'fix-up')
|
|
@@ -16,6 +19,8 @@ export async function handleLearn(args) {
|
|
|
16
19
|
return runFitness(args.slice(1));
|
|
17
20
|
if (sub === 'evolve')
|
|
18
21
|
return runEvolve(args.slice(1));
|
|
22
|
+
if (sub === 'reset-outcomes')
|
|
23
|
+
return runResetOutcomes(args.slice(1));
|
|
19
24
|
printUsage();
|
|
20
25
|
}
|
|
21
26
|
function printUsage() {
|
|
@@ -28,8 +33,55 @@ function printUsage() {
|
|
|
28
33
|
forgen learn fitness [--json] Show per-solution fitness (accept/correct/error ratios)
|
|
29
34
|
forgen learn evolve [--save|--rollback <ts>|--promote <name>]
|
|
30
35
|
Phase 4 evolution: weakness report + candidate lifecycle
|
|
36
|
+
forgen learn reset-outcomes [--apply] Archive pre-audit outcome history (dry-run by default).
|
|
37
|
+
Use after upgrading from <0.3.2 to start fitness fresh
|
|
38
|
+
under the new attribution gates. Old data is preserved
|
|
39
|
+
at ~/.forgen/state/outcomes.archive-<ts>/ (never deleted).
|
|
31
40
|
`);
|
|
32
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Archive the outcomes directory to a timestamped sibling so fitness
|
|
44
|
+
* computation starts fresh under v0.3.2's corrected attribution gates
|
|
45
|
+
* (match_score≥0.3, lag≤5min, top-3, single-tag rejection). Pre-0.3.2
|
|
46
|
+
* outcomes were recorded with blanket error-attribution on every tool
|
|
47
|
+
* failure in the session window, producing a 91% global error rate even
|
|
48
|
+
* for solutions that weren't actually causing the failures.
|
|
49
|
+
*
|
|
50
|
+
* Archive, never delete — users who want to audit their pre-0.3.2
|
|
51
|
+
* history can still read `outcomes.archive-<ts>/*.jsonl`.
|
|
52
|
+
*/
|
|
53
|
+
function runResetOutcomes(args) {
|
|
54
|
+
const apply = args.includes('--apply');
|
|
55
|
+
if (!fs.existsSync(OUTCOMES_DIR)) {
|
|
56
|
+
console.log(`\n No outcomes directory yet — nothing to reset.\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const files = fs.readdirSync(OUTCOMES_DIR).filter((f) => f.endsWith('.jsonl'));
|
|
60
|
+
if (files.length === 0) {
|
|
61
|
+
console.log(`\n Outcomes directory is empty — nothing to reset.\n`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let totalLines = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
try {
|
|
67
|
+
totalLines += fs.readFileSync(path.join(OUTCOMES_DIR, f), 'utf-8').split('\n').filter(Boolean).length;
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
console.log(`\n Outcomes snapshot: ${files.length} session files, ${totalLines} events.`);
|
|
72
|
+
if (!apply) {
|
|
73
|
+
console.log(`\n Dry-run. Re-run with --apply to archive:`);
|
|
74
|
+
console.log(` mv ~/.forgen/state/outcomes → ~/.forgen/state/outcomes.archive-<ts>`);
|
|
75
|
+
console.log(` new empty ~/.forgen/state/outcomes/\n`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const archivePath = `${OUTCOMES_DIR}.archive-${Date.now()}`;
|
|
79
|
+
fs.renameSync(OUTCOMES_DIR, archivePath);
|
|
80
|
+
fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
|
|
81
|
+
console.log(`\n ✓ Archived to ${archivePath}`);
|
|
82
|
+
console.log(` ✓ Fresh ${OUTCOMES_DIR} created.`);
|
|
83
|
+
console.log(`\n Next fitness snapshot reflects v0.3.2 attribution gates only.\n`);
|
|
84
|
+
}
|
|
33
85
|
function runFixUp(args) {
|
|
34
86
|
const apply = args.includes('--apply');
|
|
35
87
|
const result = fixupSolutions(ME_SOLUTIONS, { dryRun: !apply });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bypass detector — T3 signal source.
|
|
3
|
+
*
|
|
4
|
+
* Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
|
|
5
|
+
* 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
|
|
6
|
+
*
|
|
7
|
+
* Heuristic:
|
|
8
|
+
* 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
|
|
9
|
+
* 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
|
|
10
|
+
* 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
|
|
11
|
+
* 4) 그 외: 빈 배열 (탐지 불가).
|
|
12
|
+
*
|
|
13
|
+
* 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
|
|
14
|
+
*/
|
|
15
|
+
import type { Rule } from '../../store/types.js';
|
|
16
|
+
export declare function extractBypassPatterns(rule: Rule): string[];
|
|
17
|
+
export interface BypassScanInput {
|
|
18
|
+
rules: Rule[];
|
|
19
|
+
tool_name: string;
|
|
20
|
+
tool_output: string;
|
|
21
|
+
session_id: string;
|
|
22
|
+
}
|
|
23
|
+
export interface BypassCandidate {
|
|
24
|
+
rule_id: string;
|
|
25
|
+
session_id: string;
|
|
26
|
+
tool: string;
|
|
27
|
+
pattern_preview: string;
|
|
28
|
+
matched: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pure — rules + tool output 으로 bypass candidates 추출.
|
|
32
|
+
* 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
|
|
33
|
+
*/
|
|
34
|
+
export declare function scanForBypass(input: BypassScanInput): BypassCandidate[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bypass detector — T3 signal source.
|
|
3
|
+
*
|
|
4
|
+
* Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
|
|
5
|
+
* 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
|
|
6
|
+
*
|
|
7
|
+
* Heuristic:
|
|
8
|
+
* 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
|
|
9
|
+
* 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
|
|
10
|
+
* 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
|
|
11
|
+
* 4) 그 외: 빈 배열 (탐지 불가).
|
|
12
|
+
*
|
|
13
|
+
* 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
|
|
14
|
+
*/
|
|
15
|
+
function escapeRegex(s) {
|
|
16
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Trim punctuation 많이 붙은 자연어 표현을 "검색용 토큰" 으로 정규화.
|
|
20
|
+
* Leading `.` 는 유지 — `.then`, `.mock` 같은 메서드 참조가 의도된 매칭 대상.
|
|
21
|
+
* Trailing `()` 는 제거 — `.then()` 을 `.then` 으로 정규화해 `.then(x=>...)` 에 매치.
|
|
22
|
+
*/
|
|
23
|
+
function trimPunct(s) {
|
|
24
|
+
let out = s;
|
|
25
|
+
// Strip trailing "()" once (natural-language shorthand for method calls)
|
|
26
|
+
if (out.endsWith('()'))
|
|
27
|
+
out = out.slice(0, -2);
|
|
28
|
+
// Strip other leading/trailing punctuation, preserving leading `.`
|
|
29
|
+
out = out.replace(/^[,;:!?"'`(]+|[.,;:!?"'`)]+$/g, '');
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export function extractBypassPatterns(rule) {
|
|
33
|
+
const patterns = [];
|
|
34
|
+
const p = rule.policy;
|
|
35
|
+
// use X not Y / use X instead of Y / use X over Y
|
|
36
|
+
// X, Y may contain dots (e.g., ".then()", "vi.mock"). Strip trailing punctuation.
|
|
37
|
+
const useNot = p.match(/\b(?:use|prefer|choose)\s+(\S+?)\s+(?:not|instead\s+of|over|rather\s+than)\s+(\S+)/i);
|
|
38
|
+
if (useNot)
|
|
39
|
+
patterns.push(escapeRegex(trimPunct(useNot[2])));
|
|
40
|
+
// avoid X / don't use X / never use X / do not use X
|
|
41
|
+
const avoid = p.match(/\b(?:avoid|don'?t\s+use|never\s+use|do\s+not\s+use)\s+(\S+)/i);
|
|
42
|
+
if (avoid)
|
|
43
|
+
patterns.push(escapeRegex(trimPunct(avoid[1])));
|
|
44
|
+
// Korean: "X 말라" / "X 금지" / "X 하지 마"
|
|
45
|
+
const ko = p.match(/(\S+)\s*(?:말라|금지|하지\s*마|쓰지\s*마)/);
|
|
46
|
+
if (ko)
|
|
47
|
+
patterns.push(escapeRegex(trimPunct(ko[1])));
|
|
48
|
+
// Dedupe + filter trivial
|
|
49
|
+
return [...new Set(patterns)].filter((pat) => pat.length >= 2);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure — rules + tool output 으로 bypass candidates 추출.
|
|
53
|
+
* 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
|
|
54
|
+
*/
|
|
55
|
+
export function scanForBypass(input) {
|
|
56
|
+
const { rules, tool_name, tool_output, session_id } = input;
|
|
57
|
+
const candidates = [];
|
|
58
|
+
const reported = new Set(); // rule_id|pattern
|
|
59
|
+
for (const rule of rules) {
|
|
60
|
+
if (rule.status !== 'active')
|
|
61
|
+
continue;
|
|
62
|
+
const patterns = extractBypassPatterns(rule);
|
|
63
|
+
for (const pat of patterns) {
|
|
64
|
+
const re = new RegExp(pat, 'i');
|
|
65
|
+
const m = tool_output.match(re);
|
|
66
|
+
if (!m)
|
|
67
|
+
continue;
|
|
68
|
+
const key = `${rule.rule_id}|${pat}`;
|
|
69
|
+
if (reported.has(key))
|
|
70
|
+
continue;
|
|
71
|
+
reported.add(key);
|
|
72
|
+
candidates.push({
|
|
73
|
+
rule_id: rule.rule_id,
|
|
74
|
+
session_id,
|
|
75
|
+
tool: tool_name,
|
|
76
|
+
pattern_preview: pat.slice(0, 40),
|
|
77
|
+
matched: m[0].slice(0, 40),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return candidates;
|
|
82
|
+
}
|