opencode-goal-mode 0.2.2 → 0.3.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/ARCHITECTURE.md +47 -7
- package/CHANGELOG.md +27 -0
- package/README.md +81 -23
- package/benchmarks/build-external-corpus.mjs +177 -0
- package/benchmarks/charts.mjs +176 -0
- package/benchmarks/comparison.mjs +48 -0
- package/benchmarks/completion-corpus.mjs +70 -0
- package/benchmarks/corpus.mjs +92 -0
- package/benchmarks/external-corpus.json +3540 -0
- package/benchmarks/external.mjs +110 -0
- package/benchmarks/legacy-analyzer.mjs +54 -0
- package/benchmarks/run.mjs +252 -0
- package/benchmarks/truthfulness.mjs +64 -0
- package/commands/goal-evidence-map.md +27 -0
- package/commands/goal.md +16 -1
- package/docs/benchmarks/detection-by-family.svg +2 -2
- package/docs/benchmarks/external-scorecard.svg +32 -0
- package/docs/benchmarks/latency.svg +3 -3
- package/docs/benchmarks/overall-scorecard.svg +2 -2
- package/docs/benchmarks/results.json +207 -67
- package/docs/benchmarks/truthfulness-score.svg +17 -0
- package/package.json +5 -1
- package/plugins/goal-guard/config.js +9 -0
- package/plugins/goal-guard/events.js +6 -3
- package/plugins/goal-guard/shell.js +4 -3
- package/plugins/goal-guard/sidebar-data.js +71 -0
- package/plugins/goal-guard/state.js +2 -1
- package/plugins/goal-guard/summary.js +139 -1
- package/plugins/goal-guard/system.js +3 -0
- package/plugins/goal-guard/tools.js +43 -3
- package/plugins/goal-guard/verdicts.js +38 -1
- package/plugins/goal-guard.js +20 -5
- package/plugins/goal-sidebar.js +141 -0
- package/research/README.md +1 -1
- package/research/benchmarks.md +72 -45
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Run the shell guard against the EXTERNAL, third-party-authored corpus built by
|
|
4
|
+
* build-external-corpus.mjs (real tldr-pages commands). This is the honest
|
|
5
|
+
* benchmark: the analyzer authors did not write or curate these commands, so the
|
|
6
|
+
* detection / false-positive numbers reflect real-world behavior, warts and all.
|
|
7
|
+
*
|
|
8
|
+
* It deliberately also reports DISAGREEMENTS between the analyzer and the
|
|
9
|
+
* independent ground-truth labeler, so misses and false positives are auditable
|
|
10
|
+
* rather than averaged away.
|
|
11
|
+
*
|
|
12
|
+
* node benchmarks/external.mjs # summary
|
|
13
|
+
* node benchmarks/external.mjs --json # full machine-readable result
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import * as current from "../plugins/goal-guard/shell.js";
|
|
20
|
+
import * as legacy from "./legacy-analyzer.mjs";
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
export function loadExternalCorpus() {
|
|
25
|
+
return JSON.parse(readFileSync(join(here, "external-corpus.json"), "utf8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function blocked(analyzer, cmd) {
|
|
29
|
+
const a = analyzer.analyzeCommand(cmd);
|
|
30
|
+
return Boolean(a.destructive || a.networkExec);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Evaluate one analyzer over labeled entries (each {cmd, page, destructive}). */
|
|
34
|
+
function score(analyzer, labeled) {
|
|
35
|
+
let destTotal = 0;
|
|
36
|
+
let destCaught = 0;
|
|
37
|
+
let safeTotal = 0;
|
|
38
|
+
let safeFalsePos = 0;
|
|
39
|
+
const misses = [];
|
|
40
|
+
const falsePositives = [];
|
|
41
|
+
for (const e of labeled) {
|
|
42
|
+
const isBlocked = blocked(analyzer, e.cmd);
|
|
43
|
+
if (e.destructive) {
|
|
44
|
+
destTotal += 1;
|
|
45
|
+
if (isBlocked) destCaught += 1;
|
|
46
|
+
else misses.push({ cmd: e.cmd, page: e.page });
|
|
47
|
+
} else {
|
|
48
|
+
safeTotal += 1;
|
|
49
|
+
if (isBlocked) {
|
|
50
|
+
safeFalsePos += 1;
|
|
51
|
+
falsePositives.push({ cmd: e.cmd, page: e.page });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
detectionRate: destTotal ? (destCaught / destTotal) * 100 : 0,
|
|
57
|
+
falsePositiveRate: safeTotal ? (safeFalsePos / safeTotal) * 100 : 0,
|
|
58
|
+
destCaught,
|
|
59
|
+
destTotal,
|
|
60
|
+
safeFalsePos,
|
|
61
|
+
safeTotal,
|
|
62
|
+
misses,
|
|
63
|
+
falsePositives,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function runExternalBenchmark() {
|
|
68
|
+
const corpus = loadExternalCorpus();
|
|
69
|
+
// The corpus is written destructive-first then safe (see build-external-corpus.mjs),
|
|
70
|
+
// so the recorded count is the label boundary — no re-running the labeler needed.
|
|
71
|
+
const labeled = corpus.entries.map((e, i) => ({ ...e, destructive: i < corpus.totals.destructiveFound }));
|
|
72
|
+
return {
|
|
73
|
+
source: corpus.source,
|
|
74
|
+
commit: corpus.commit,
|
|
75
|
+
totals: corpus.totals,
|
|
76
|
+
sampleSize: labeled.length,
|
|
77
|
+
legacy: score(legacy, labeled),
|
|
78
|
+
current: score(current, labeled),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pct(n) {
|
|
83
|
+
return `${n.toFixed(1)}%`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
87
|
+
const r = runExternalBenchmark();
|
|
88
|
+
if (process.argv.includes("--json")) {
|
|
89
|
+
console.log(JSON.stringify(r, null, 2));
|
|
90
|
+
} else {
|
|
91
|
+
console.log("External shell-guard benchmark (third-party tldr-pages commands)");
|
|
92
|
+
console.log("================================================================");
|
|
93
|
+
console.log(`Source: ${r.source} @ ${r.commit.slice(0, 12)}`);
|
|
94
|
+
console.log(
|
|
95
|
+
`Sample: ${r.sampleSize} commands ` +
|
|
96
|
+
`(${r.totals.destructiveFound} destructive [all found], ` +
|
|
97
|
+
`${r.totals.safeSampled}/${r.totals.safeFound} safe sampled)`,
|
|
98
|
+
);
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log(`Detection (destructive caught) legacy ${pct(r.legacy.detectionRate)} → current ${pct(r.current.detectionRate)}`);
|
|
101
|
+
console.log(`False positives on safe commands legacy ${pct(r.legacy.falsePositiveRate)} → current ${pct(r.current.falsePositiveRate)}`);
|
|
102
|
+
console.log("");
|
|
103
|
+
console.log(`Current analyzer misses (${r.current.misses.length}):`);
|
|
104
|
+
for (const m of r.current.misses.slice(0, 20)) console.log(` - ${m.cmd} [${m.page}]`);
|
|
105
|
+
if (r.current.misses.length > 20) console.log(` … ${r.current.misses.length - 20} more`);
|
|
106
|
+
console.log(`Current analyzer false positives (${r.current.falsePositives.length}):`);
|
|
107
|
+
for (const f of r.current.falsePositives.slice(0, 20)) console.log(` - ${f.cmd} [${f.page}]`);
|
|
108
|
+
if (r.current.falsePositives.length > 20) console.log(` … ${r.current.falsePositives.length - 20} more`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ORIGINAL regex-based shell classifier, preserved verbatim from the first
|
|
3
|
+
* published version of the plugin (commit 130956d) so the benchmark can compare
|
|
4
|
+
* it apples-to-apples against the current quote-aware analyzer.
|
|
5
|
+
*
|
|
6
|
+
* Do not "improve" this file — its job is to faithfully represent the old
|
|
7
|
+
* behavior that the new analyzer replaced.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const MUTATING_BASH_PATTERNS = [
|
|
11
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?(rm|mv|cp|mkdir|rmdir|touch|ln)\b/i,
|
|
12
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?(tee|xargs\s+(rm|mv|cp))\b/i,
|
|
13
|
+
/(^|&&|;|\|\|)\s*[^|]*\s(>|>>)\s*(?!\/dev\/null\b)\S+/i,
|
|
14
|
+
/(^|&&|;|\|\|)\s*(perl\s+-pi|sed\s+-i)\b/i,
|
|
15
|
+
/(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(install|ci|add|remove|update)\b/i,
|
|
16
|
+
/(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(run\s+)?(format|fix|lint:fix)\b/i,
|
|
17
|
+
/\b((npx|pnpm\s+exec|yarn)\s+)?(prettier|eslint)\b.*\s(--write|--fix)\b/i,
|
|
18
|
+
/\b(node|python3?)\b.*\b(writeFile|appendFile|copyFile|rename|unlink|rmSync|mkdir|rmdir|openSync)\b/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function looksLikeDestructiveBash(command) {
|
|
22
|
+
const normalized = String(command || "").trim();
|
|
23
|
+
return [
|
|
24
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+-[a-zA-Z]*[rR][a-zA-Z]*[rfRF]?\b/,
|
|
25
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+(--recursive|--force|--recursive\s+--force|-rf|-fr|-r)\b/,
|
|
26
|
+
/(^|&&|;|\|\|)\s*git\s+reset\b/,
|
|
27
|
+
/(^|&&|;|\|\|)\s*git\s+clean\b/,
|
|
28
|
+
/(^|&&|;|\|\|)\s*git\s+checkout\b/,
|
|
29
|
+
/(^|&&|;|\|\|)\s*git\s+restore\b/,
|
|
30
|
+
/(^|&&|;|\|\|)\s*git\s+switch\b/,
|
|
31
|
+
/(^|&&|;|\|\|)\s*git\s+push\b/,
|
|
32
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-delete\b/,
|
|
33
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-exec\s+rm\b/,
|
|
34
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?dd\b.*\bof=\/dev\//,
|
|
35
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?mkfs(\.|\s|$)/,
|
|
36
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?shred\b/,
|
|
37
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?truncate\b/,
|
|
38
|
+
/(^|&&|;|\|\|)\s*(sudo\s+)?chmod\s+-[a-zA-Z]*[rR][a-zA-Z]*[wW][a-zA-Z]*[xX][a-zA-Z]*\s+\/\b/,
|
|
39
|
+
].some((pattern) => pattern.test(normalized));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function looksLikeMutatingBash(command) {
|
|
43
|
+
const normalized = String(command || "").trim();
|
|
44
|
+
if (!normalized) return false;
|
|
45
|
+
if (looksLikeDestructiveBash(normalized)) return true;
|
|
46
|
+
return MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Adapter to the analyzer signal shape used by the benchmark. */
|
|
50
|
+
export function analyzeCommand(command) {
|
|
51
|
+
const destructive = looksLikeDestructiveBash(command);
|
|
52
|
+
const mutating = looksLikeMutatingBash(command);
|
|
53
|
+
return { destructive, mutating, verification: false, networkExec: false, reasons: [] };
|
|
54
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Reproducible benchmark of the shell-command guard.
|
|
4
|
+
*
|
|
5
|
+
* Compares the original regex classifier (benchmarks/legacy-analyzer.mjs,
|
|
6
|
+
* preserved verbatim from the first release) against the current quote-aware
|
|
7
|
+
* analyzer (plugins/goal-guard/shell.js) on a labeled corpus, and measures
|
|
8
|
+
* detection rate, false-positive rate, and throughput. Writes results.json and
|
|
9
|
+
* the SVG charts embedded in the README.
|
|
10
|
+
*
|
|
11
|
+
* node benchmarks/run.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { performance } from "node:perf_hooks";
|
|
18
|
+
import { CORPUS } from "./corpus.mjs";
|
|
19
|
+
import * as current from "../plugins/goal-guard/shell.js";
|
|
20
|
+
import * as legacy from "./legacy-analyzer.mjs";
|
|
21
|
+
import { groupedBarChart, horizontalBarChart } from "./charts.mjs";
|
|
22
|
+
import { runTruthfulnessBenchmark } from "./truthfulness.mjs";
|
|
23
|
+
import { runExternalBenchmark } from "./external.mjs";
|
|
24
|
+
|
|
25
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
26
|
+
const outDir = join(root, "docs", "benchmarks");
|
|
27
|
+
mkdirSync(outDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
/** A command is "blocked" when flagged destructive or as remote execution. */
|
|
30
|
+
function blocked(analyzer, cmd) {
|
|
31
|
+
const a = analyzer.analyzeCommand(cmd);
|
|
32
|
+
return Boolean(a.destructive || a.networkExec);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evaluate(analyzer) {
|
|
36
|
+
const families = {};
|
|
37
|
+
let destTotal = 0;
|
|
38
|
+
let destCaught = 0;
|
|
39
|
+
let safeTotal = 0;
|
|
40
|
+
let safeFalsePos = 0;
|
|
41
|
+
|
|
42
|
+
for (const { cmd, label, family } of CORPUS) {
|
|
43
|
+
families[family] ??= { destTotal: 0, destCaught: 0, safeTotal: 0, safeFalsePos: 0 };
|
|
44
|
+
const isBlocked = blocked(analyzer, cmd);
|
|
45
|
+
if (label === "destructive") {
|
|
46
|
+
destTotal += 1;
|
|
47
|
+
families[family].destTotal += 1;
|
|
48
|
+
if (isBlocked) {
|
|
49
|
+
destCaught += 1;
|
|
50
|
+
families[family].destCaught += 1;
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
safeTotal += 1;
|
|
54
|
+
families[family].safeTotal += 1;
|
|
55
|
+
if (isBlocked) {
|
|
56
|
+
safeFalsePos += 1;
|
|
57
|
+
families[family].safeFalsePos += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
detectionRate: destTotal ? (destCaught / destTotal) * 100 : 0,
|
|
64
|
+
falsePositiveRate: safeTotal ? (safeFalsePos / safeTotal) * 100 : 0,
|
|
65
|
+
destCaught,
|
|
66
|
+
destTotal,
|
|
67
|
+
safeFalsePos,
|
|
68
|
+
safeTotal,
|
|
69
|
+
families,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function throughput(analyzer) {
|
|
74
|
+
const cmds = CORPUS.map((c) => c.cmd);
|
|
75
|
+
// Warm up.
|
|
76
|
+
for (const c of cmds) analyzer.analyzeCommand(c);
|
|
77
|
+
const iterations = 4000;
|
|
78
|
+
const start = performance.now();
|
|
79
|
+
for (let i = 0; i < iterations; i += 1) {
|
|
80
|
+
for (const c of cmds) analyzer.analyzeCommand(c);
|
|
81
|
+
}
|
|
82
|
+
const ms = performance.now() - start;
|
|
83
|
+
const ops = (iterations * cmds.length) / (ms / 1000);
|
|
84
|
+
return Math.round(ops);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Locale-independent thousands grouping (the host locale may use '.' as separator). */
|
|
88
|
+
function fmt(n) {
|
|
89
|
+
return Math.round(n)
|
|
90
|
+
.toString()
|
|
91
|
+
.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const legacyEval = evaluate(legacy);
|
|
95
|
+
const currentEval = evaluate(current);
|
|
96
|
+
const external = runExternalBenchmark();
|
|
97
|
+
const truthfulness = runTruthfulnessBenchmark();
|
|
98
|
+
const legacyOps = throughput(legacy);
|
|
99
|
+
const currentOps = throughput(current);
|
|
100
|
+
const legacyUs = 1e6 / legacyOps;
|
|
101
|
+
const currentUs = 1e6 / currentOps;
|
|
102
|
+
|
|
103
|
+
const FAMILY_LABELS = {
|
|
104
|
+
classic: "Classic",
|
|
105
|
+
bypass: "Obfuscated",
|
|
106
|
+
"remote-exec": "Remote exec",
|
|
107
|
+
};
|
|
108
|
+
const detFamilies = ["classic", "bypass", "remote-exec"];
|
|
109
|
+
|
|
110
|
+
function familyRate(ev, fam) {
|
|
111
|
+
const f = ev.families[fam];
|
|
112
|
+
return f && f.destTotal ? (f.destCaught / f.destTotal) * 100 : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Trim the per-command miss/false-positive lists to keep results.json readable;
|
|
116
|
+
// the full lists are always available via `node benchmarks/external.mjs --json`.
|
|
117
|
+
const externalSummary = {
|
|
118
|
+
source: external.source,
|
|
119
|
+
commit: external.commit,
|
|
120
|
+
totals: external.totals,
|
|
121
|
+
sampleSize: external.sampleSize,
|
|
122
|
+
legacy: {
|
|
123
|
+
detectionRate: Number(external.legacy.detectionRate.toFixed(1)),
|
|
124
|
+
falsePositiveRate: Number(external.legacy.falsePositiveRate.toFixed(1)),
|
|
125
|
+
destCaught: external.legacy.destCaught,
|
|
126
|
+
destTotal: external.legacy.destTotal,
|
|
127
|
+
safeFalsePos: external.legacy.safeFalsePos,
|
|
128
|
+
safeTotal: external.legacy.safeTotal,
|
|
129
|
+
},
|
|
130
|
+
current: {
|
|
131
|
+
detectionRate: Number(external.current.detectionRate.toFixed(1)),
|
|
132
|
+
falsePositiveRate: Number(external.current.falsePositiveRate.toFixed(1)),
|
|
133
|
+
destCaught: external.current.destCaught,
|
|
134
|
+
destTotal: external.current.destTotal,
|
|
135
|
+
safeFalsePos: external.current.safeFalsePos,
|
|
136
|
+
safeTotal: external.current.safeTotal,
|
|
137
|
+
misses: external.current.misses.map((m) => m.cmd),
|
|
138
|
+
falsePositives: external.current.falsePositives.map((f) => f.cmd),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const results = {
|
|
143
|
+
// The honest, third-party benchmark: real commands the analyzer was never
|
|
144
|
+
// fitted to. This is the headline number.
|
|
145
|
+
external: externalSummary,
|
|
146
|
+
// Curated REGRESSION FIXTURES: a hand-authored set of known destructive
|
|
147
|
+
// patterns and their safe look-alikes. These define the patterns the analyzer
|
|
148
|
+
// is built to catch and guard against regressions — they are NOT an unbiased
|
|
149
|
+
// sample, so the 100%/0% here is "passes its own spec", not measured accuracy.
|
|
150
|
+
fixtures: {
|
|
151
|
+
corpusSize: CORPUS.length,
|
|
152
|
+
destructiveCount: CORPUS.filter((c) => c.label === "destructive").length,
|
|
153
|
+
safeCount: CORPUS.filter((c) => c.label === "safe").length,
|
|
154
|
+
legacy: { ...legacyEval, opsPerSec: legacyOps, usPerCommand: Number(legacyUs.toFixed(2)) },
|
|
155
|
+
current: { ...currentEval, opsPerSec: currentOps, usPerCommand: Number(currentUs.toFixed(2)) },
|
|
156
|
+
},
|
|
157
|
+
// Completion-enforcement fixtures (hand-authored policy cases), not a survey.
|
|
158
|
+
completionFixtures: truthfulness,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
writeFileSync(join(outDir, "results.json"), JSON.stringify(results, null, 2));
|
|
162
|
+
|
|
163
|
+
// Headline chart: detection + false positives on the EXTERNAL third-party corpus.
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(outDir, "external-scorecard.svg"),
|
|
166
|
+
groupedBarChart({
|
|
167
|
+
title: "Guard accuracy on real third-party commands",
|
|
168
|
+
subtitle: `${external.sampleSize} tldr-pages commands the analyzer was never fitted to. Detection higher = better; false positives lower = better.`,
|
|
169
|
+
groups: ["Detection rate", "False-positive rate"],
|
|
170
|
+
series: [
|
|
171
|
+
{ name: "Legacy regex guard", color: "#9aa0a6", values: [external.legacy.detectionRate, external.legacy.falsePositiveRate] },
|
|
172
|
+
{ name: "Goal Mode analyzer", color: "#2da44e", values: [external.current.detectionRate, external.current.falsePositiveRate] },
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Chart 1: detection rate by command family (CURATED regression fixtures).
|
|
178
|
+
writeFileSync(
|
|
179
|
+
join(outDir, "detection-by-family.svg"),
|
|
180
|
+
groupedBarChart({
|
|
181
|
+
title: "Detection by family — curated regression fixtures",
|
|
182
|
+
subtitle: `Curated patterns the analyzer is built to catch (not an unbiased sample). ${results.fixtures.destructiveCount} destructive fixtures.`,
|
|
183
|
+
groups: detFamilies.map((f) => FAMILY_LABELS[f]),
|
|
184
|
+
series: [
|
|
185
|
+
{ name: "Legacy regex guard", color: "#9aa0a6", values: detFamilies.map((f) => familyRate(legacyEval, f)) },
|
|
186
|
+
{ name: "Goal Mode analyzer", color: "#2da44e", values: detFamilies.map((f) => familyRate(currentEval, f)) },
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Chart 2: overall scorecard on the CURATED fixtures (passes its own spec).
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(outDir, "overall-scorecard.svg"),
|
|
194
|
+
groupedBarChart({
|
|
195
|
+
title: "Curated fixtures — passes its own spec",
|
|
196
|
+
subtitle: "Curated regression fixtures, not measured accuracy. See external-scorecard.svg for the real-world number.",
|
|
197
|
+
groups: ["Detection rate", "False-positive rate"],
|
|
198
|
+
series: [
|
|
199
|
+
{ name: "Legacy regex guard", color: "#9aa0a6", values: [legacyEval.detectionRate, legacyEval.falsePositiveRate] },
|
|
200
|
+
{ name: "Goal Mode analyzer", color: "#2da44e", values: [currentEval.detectionRate, currentEval.falsePositiveRate] },
|
|
201
|
+
],
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Chart 3: per-command latency — the deeper analysis costs a few microseconds,
|
|
206
|
+
// which is negligible for a tool-call guard. Shown for honesty, not as a "win".
|
|
207
|
+
writeFileSync(
|
|
208
|
+
join(outDir, "latency.svg"),
|
|
209
|
+
horizontalBarChart({
|
|
210
|
+
title: "Per-command analysis latency",
|
|
211
|
+
subtitle: "Microseconds to classify one command. Both are negligible for a tool-call guard.",
|
|
212
|
+
unit: " µs",
|
|
213
|
+
max: Math.max(legacyUs, currentUs) * 1.4,
|
|
214
|
+
rows: [
|
|
215
|
+
{ label: "Legacy regex guard", value: legacyUs, display: `${legacyUs.toFixed(2)} µs`, color: "#9aa0a6" },
|
|
216
|
+
{ label: "Goal Mode analyzer", value: currentUs, display: `${currentUs.toFixed(2)} µs`, color: "#2da44e" },
|
|
217
|
+
],
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
writeFileSync(
|
|
222
|
+
join(outDir, "truthfulness-score.svg"),
|
|
223
|
+
horizontalBarChart({
|
|
224
|
+
title: "Completion-enforcement fixtures",
|
|
225
|
+
subtitle: `${truthfulness.corpusSize} hand-authored policy cases (a spec, not a survey): premature claims blocked, valid ones allowed.`,
|
|
226
|
+
unit: "%",
|
|
227
|
+
max: 100,
|
|
228
|
+
rows: [
|
|
229
|
+
{ label: "Truthfulness score", value: truthfulness.score, display: `${truthfulness.score.toFixed(1)}%`, color: "#2da44e" },
|
|
230
|
+
{ label: "Decision accuracy", value: truthfulness.decisionAccuracy, display: `${truthfulness.decisionAccuracy.toFixed(1)}%`, color: "#0969da" },
|
|
231
|
+
{ label: "Reason accuracy", value: truthfulness.reasonAccuracy, display: `${truthfulness.reasonAccuracy.toFixed(1)}%`, color: "#bf8700" },
|
|
232
|
+
],
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const pct = (n) => `${n.toFixed(1)}%`;
|
|
237
|
+
console.log("Goal Mode shell-guard benchmark");
|
|
238
|
+
console.log("================================");
|
|
239
|
+
console.log("");
|
|
240
|
+
console.log(`HEADLINE — external corpus: ${external.sampleSize} real tldr-pages commands @ ${external.commit.slice(0, 12)}`);
|
|
241
|
+
console.log(` (${external.totals.destructiveFound} destructive [all found] + ${external.totals.safeSampled}/${external.totals.safeFound} safe sampled)`);
|
|
242
|
+
console.log(` Detection legacy ${pct(external.legacy.detectionRate)} → Goal Mode ${pct(external.current.detectionRate)}`);
|
|
243
|
+
console.log(` False positives legacy ${pct(external.legacy.falsePositiveRate)} → Goal Mode ${pct(external.current.falsePositiveRate)}`);
|
|
244
|
+
console.log(` Remaining Goal Mode misses: ${external.current.misses.length} (mostly un-flagged single-target rm — see external.mjs --json)`);
|
|
245
|
+
console.log("");
|
|
246
|
+
console.log(`Curated regression fixtures: ${results.fixtures.corpusSize} commands (defines patterns to catch; not an unbiased sample)`);
|
|
247
|
+
console.log(` Detection legacy ${pct(legacyEval.detectionRate)} → Goal Mode ${pct(currentEval.detectionRate)} (passes its own spec)`);
|
|
248
|
+
console.log(` False pos legacy ${pct(legacyEval.falsePositiveRate)} → Goal Mode ${pct(currentEval.falsePositiveRate)}`);
|
|
249
|
+
console.log(`Completion-enforcement fixtures: ${truthfulness.corpusSize} hand-authored policy cases, all pass (a spec, not a survey)`);
|
|
250
|
+
console.log(`Latency: Goal Mode ${currentUs.toFixed(2)} µs/cmd (${fmt(currentOps)}/s)`);
|
|
251
|
+
console.log("");
|
|
252
|
+
console.log(`Wrote results.json + 5 SVG charts to docs/benchmarks/`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BASE_GATES } from "../plugins/goal-guard/agents.js";
|
|
2
|
+
import { DEFAULT_CONFIG } from "../plugins/goal-guard/config.js";
|
|
3
|
+
import { evaluateCompletionClaim } from "../plugins/goal-guard/completion.js";
|
|
4
|
+
import { refreshStickyGates } from "../plugins/goal-guard/gates.js";
|
|
5
|
+
import { createState } from "../plugins/goal-guard/state.js";
|
|
6
|
+
import { FALSE_COMPLETION_CORPUS } from "./completion-corpus.mjs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
function buildState(def) {
|
|
10
|
+
const state = createState("2026-01-01T00:00:00.000Z");
|
|
11
|
+
Object.assign(state, def.state || {});
|
|
12
|
+
state.latestVerdict = {};
|
|
13
|
+
for (const gate of def.state?.verdicts || []) {
|
|
14
|
+
state.latestVerdict[gate.agent] = { verdict: gate.verdict, at: "2026-01-01T00:00:01.000Z", seq: gate.seq };
|
|
15
|
+
}
|
|
16
|
+
if (!state.goalText && def.state?.contextualGate) state.goalText = def.state.contextualGate;
|
|
17
|
+
refreshStickyGates(state);
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function evaluateTruthfulnessCase(def) {
|
|
22
|
+
const config = { ...DEFAULT_CONFIG, ...(def.config || {}) };
|
|
23
|
+
const decision = evaluateCompletionClaim(buildState(def), config, def.text);
|
|
24
|
+
const reason = decision.reason || "";
|
|
25
|
+
const decisionCorrect = Boolean(decision.blocked) === Boolean(def.expected.blocked);
|
|
26
|
+
const reasonCorrect = !def.expected.blocked || reason.includes(def.expected.reasonIncludes || "");
|
|
27
|
+
return {
|
|
28
|
+
id: def.id,
|
|
29
|
+
family: def.family,
|
|
30
|
+
expectedBlocked: Boolean(def.expected.blocked),
|
|
31
|
+
actualBlocked: Boolean(decision.blocked),
|
|
32
|
+
decisionCorrect,
|
|
33
|
+
reasonCorrect,
|
|
34
|
+
reason,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runTruthfulnessBenchmark(corpus = FALSE_COMPLETION_CORPUS) {
|
|
39
|
+
const cases = corpus.map(evaluateTruthfulnessCase);
|
|
40
|
+
const falseCompletionCases = cases.filter((c) => c.expectedBlocked);
|
|
41
|
+
const trueCompletionCases = cases.filter((c) => !c.expectedBlocked);
|
|
42
|
+
const decisionCorrect = cases.filter((c) => c.decisionCorrect).length;
|
|
43
|
+
const reasonCorrect = falseCompletionCases.filter((c) => c.reasonCorrect).length;
|
|
44
|
+
const falseCompletionBlocked = falseCompletionCases.filter((c) => c.actualBlocked).length;
|
|
45
|
+
const trueCompletionAllowed = trueCompletionCases.filter((c) => !c.actualBlocked).length;
|
|
46
|
+
const decisionAccuracy = cases.length ? (decisionCorrect / cases.length) * 100 : 0;
|
|
47
|
+
const reasonAccuracy = falseCompletionCases.length ? (reasonCorrect / falseCompletionCases.length) * 100 : 100;
|
|
48
|
+
return {
|
|
49
|
+
name: "False Completion Dataset",
|
|
50
|
+
corpusSize: cases.length,
|
|
51
|
+
requiredBaseGates: BASE_GATES,
|
|
52
|
+
score: Number(((decisionAccuracy * 0.65 + reasonAccuracy * 0.35)).toFixed(1)),
|
|
53
|
+
decisionAccuracy: Number(decisionAccuracy.toFixed(1)),
|
|
54
|
+
reasonAccuracy: Number(reasonAccuracy.toFixed(1)),
|
|
55
|
+
falseCompletionBlockRate: Number(((falseCompletionBlocked / falseCompletionCases.length) * 100).toFixed(1)),
|
|
56
|
+
validCompletionAllowRate: Number(((trueCompletionAllowed / trueCompletionCases.length) * 100).toFixed(1)),
|
|
57
|
+
cases,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
62
|
+
const result = runTruthfulnessBenchmark();
|
|
63
|
+
console.log(JSON.stringify(result, null, 2));
|
|
64
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Map Goal Contract acceptance criteria to recorded verification evidence and gaps.
|
|
3
|
+
agent: goal
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Produce a read-only evidence map for the current Goal Mode session. Do not edit files.
|
|
7
|
+
|
|
8
|
+
Call `goal_evidence_map` first and use its authoritative Goal Guard state,
|
|
9
|
+
including the Goal Contract, recorded evidence, dirty state, reviewer status, and
|
|
10
|
+
any user-provided context. Report unknown or missing details honestly instead of
|
|
11
|
+
inferring evidence that is not recorded.
|
|
12
|
+
|
|
13
|
+
Include:
|
|
14
|
+
|
|
15
|
+
- Acceptance criterion
|
|
16
|
+
- Recorded evidence covering it
|
|
17
|
+
- Reviewer status
|
|
18
|
+
- Verification command/result summary
|
|
19
|
+
- Status: covered, partially covered, missing, or stale
|
|
20
|
+
- Gap or risk
|
|
21
|
+
- Next required action
|
|
22
|
+
|
|
23
|
+
Additional context:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
$ARGUMENTS
|
|
27
|
+
```
|
package/commands/goal.md
CHANGED
|
@@ -9,4 +9,19 @@ Start Goal Mode for this request:
|
|
|
9
9
|
$ARGUMENTS
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Run this sequence:
|
|
13
|
+
|
|
14
|
+
1. **Seed the contract first.** Call the `goal_contract` tool with the original
|
|
15
|
+
request, explicit/inferred requirements, non-goals, and concrete acceptance
|
|
16
|
+
criteria. This activates enforcement, fixes the required specialist review
|
|
17
|
+
gates, and lights up the goal banner in the sidebar. Ask only essential
|
|
18
|
+
clarifying questions before recording it.
|
|
19
|
+
2. Delegate discovery and research to subagents; implement in the main agent.
|
|
20
|
+
3. Verify, and record each verification with the `goal_evidence` tool so it maps
|
|
21
|
+
to your acceptance criteria.
|
|
22
|
+
4. Run the required review cycles. Consult `goal_status` / `goal_evidence_map`
|
|
23
|
+
for the authoritative list of missing or stale gates rather than relying on
|
|
24
|
+
memory.
|
|
25
|
+
5. Only finish with `Goal Completed` (plus an accurate `Review cycles: N` line)
|
|
26
|
+
once every required gate has a fresh PASS — the guard will rewrite a premature
|
|
27
|
+
claim to `Goal Not Completed`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="380" viewBox="0 0 720 380" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
|
2
2
|
<rect width="720" height="380" fill="#ffffff"/>
|
|
3
|
-
<text x="48" y="28" font-size="17" font-weight="700" fill="#1f2328">
|
|
4
|
-
<text x="48" y="47" font-size="12" fill="#656d76">
|
|
3
|
+
<text x="48" y="28" font-size="17" font-weight="700" fill="#1f2328">Detection by family — curated regression fixtures</text>
|
|
4
|
+
<text x="48" y="47" font-size="12" fill="#656d76">Curated patterns the analyzer is built to catch (not an unbiased sample). 48 destructive fixtures.</text>
|
|
5
5
|
<line x1="48" y1="296.0" x2="700" y2="296.0" stroke="#eaeef2" stroke-width="1"/>
|
|
6
6
|
<text x="40" y="300.0" font-size="11" text-anchor="end" fill="#656d76">0%</text>
|
|
7
7
|
<line x1="48" y1="249.6" x2="700" y2="249.6" stroke="#eaeef2" stroke-width="1"/>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="380" viewBox="0 0 720 380" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
|
2
|
+
<rect width="720" height="380" fill="#ffffff"/>
|
|
3
|
+
<text x="48" y="28" font-size="17" font-weight="700" fill="#1f2328">Guard accuracy on real third-party commands</text>
|
|
4
|
+
<text x="48" y="47" font-size="12" fill="#656d76">704 tldr-pages commands the analyzer was never fitted to. Detection higher = better; false positives lower = better.</text>
|
|
5
|
+
<line x1="48" y1="296.0" x2="700" y2="296.0" stroke="#eaeef2" stroke-width="1"/>
|
|
6
|
+
<text x="40" y="300.0" font-size="11" text-anchor="end" fill="#656d76">0%</text>
|
|
7
|
+
<line x1="48" y1="249.6" x2="700" y2="249.6" stroke="#eaeef2" stroke-width="1"/>
|
|
8
|
+
<text x="40" y="253.6" font-size="11" text-anchor="end" fill="#656d76">20%</text>
|
|
9
|
+
<line x1="48" y1="203.2" x2="700" y2="203.2" stroke="#eaeef2" stroke-width="1"/>
|
|
10
|
+
<text x="40" y="207.2" font-size="11" text-anchor="end" fill="#656d76">40%</text>
|
|
11
|
+
<line x1="48" y1="156.8" x2="700" y2="156.8" stroke="#eaeef2" stroke-width="1"/>
|
|
12
|
+
<text x="40" y="160.8" font-size="11" text-anchor="end" fill="#656d76">60%</text>
|
|
13
|
+
<line x1="48" y1="110.4" x2="700" y2="110.4" stroke="#eaeef2" stroke-width="1"/>
|
|
14
|
+
<text x="40" y="114.4" font-size="11" text-anchor="end" fill="#656d76">80%</text>
|
|
15
|
+
<line x1="48" y1="64.0" x2="700" y2="64.0" stroke="#eaeef2" stroke-width="1"/>
|
|
16
|
+
<text x="40" y="68.0" font-size="11" text-anchor="end" fill="#656d76">100%</text>
|
|
17
|
+
<rect x="56.0" y="171.1" width="151.0" height="124.9" rx="3" fill="#9aa0a6"/>
|
|
18
|
+
<text x="131.5" y="166.1" font-size="11" font-weight="600" text-anchor="middle" fill="#1f2328">54%</text>
|
|
19
|
+
<rect x="215.0" y="79.6" width="151.0" height="216.4" rx="3" fill="#2da44e"/>
|
|
20
|
+
<text x="290.5" y="74.6" font-size="11" font-weight="600" text-anchor="middle" fill="#1f2328">93%</text>
|
|
21
|
+
<text x="211.0" y="314.0" font-size="11" text-anchor="middle" fill="#1f2328">Detection rate</text>
|
|
22
|
+
<rect x="382.0" y="295.6" width="151.0" height="0.4" rx="3" fill="#9aa0a6"/>
|
|
23
|
+
<text x="457.5" y="290.6" font-size="11" font-weight="600" text-anchor="middle" fill="#1f2328">0%</text>
|
|
24
|
+
<rect x="541.0" y="295.6" width="151.0" height="0.4" rx="3" fill="#2da44e"/>
|
|
25
|
+
<text x="616.5" y="290.6" font-size="11" font-weight="600" text-anchor="middle" fill="#1f2328">0%</text>
|
|
26
|
+
<text x="537.0" y="314.0" font-size="11" text-anchor="middle" fill="#1f2328">False-positive rate</text>
|
|
27
|
+
<line x1="48" y1="296" x2="700" y2="296" stroke="#d0d7de" stroke-width="1.5"/>
|
|
28
|
+
<rect x="48" y="344" width="12" height="12" rx="2" fill="#9aa0a6"/>
|
|
29
|
+
<text x="66" y="354" font-size="12" fill="#1f2328">Legacy regex guard</text>
|
|
30
|
+
<rect x="201.6" y="344" width="12" height="12" rx="2" fill="#2da44e"/>
|
|
31
|
+
<text x="219.6" y="354" font-size="12" fill="#1f2328">Goal Mode analyzer</text>
|
|
32
|
+
</svg>
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
<text x="20" y="47" font-size="12" fill="#656d76">Microseconds to classify one command. Both are negligible for a tool-call guard.</text>
|
|
5
5
|
<text x="218" y="87" font-size="12" text-anchor="end" fill="#1f2328">Legacy regex guard</text>
|
|
6
6
|
<rect x="230" y="70" width="420" height="22" rx="3" fill="#eaeef2"/>
|
|
7
|
-
<rect x="230" y="70" width="
|
|
8
|
-
<text x="
|
|
7
|
+
<rect x="230" y="70" width="214.5" height="22" rx="3" fill="#9aa0a6"/>
|
|
8
|
+
<text x="452.5" y="87" font-size="12" font-weight="600" fill="#1f2328">0.79 µs</text>
|
|
9
9
|
<text x="218" y="125" font-size="12" text-anchor="end" fill="#1f2328">Goal Mode analyzer</text>
|
|
10
10
|
<rect x="230" y="108" width="420" height="22" rx="3" fill="#eaeef2"/>
|
|
11
11
|
<rect x="230" y="108" width="300.0" height="22" rx="3" fill="#2da44e"/>
|
|
12
|
-
<text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">
|
|
12
|
+
<text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">1.11 µs</text>
|
|
13
13
|
</svg>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="380" viewBox="0 0 720 380" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
|
2
2
|
<rect width="720" height="380" fill="#ffffff"/>
|
|
3
|
-
<text x="48" y="28" font-size="17" font-weight="700" fill="#1f2328">
|
|
4
|
-
<text x="48" y="47" font-size="12" fill="#656d76">
|
|
3
|
+
<text x="48" y="28" font-size="17" font-weight="700" fill="#1f2328">Curated fixtures — passes its own spec</text>
|
|
4
|
+
<text x="48" y="47" font-size="12" fill="#656d76">Curated regression fixtures, not measured accuracy. See external-scorecard.svg for the real-world number.</text>
|
|
5
5
|
<line x1="48" y1="296.0" x2="700" y2="296.0" stroke="#eaeef2" stroke-width="1"/>
|
|
6
6
|
<text x="40" y="300.0" font-size="11" text-anchor="end" fill="#656d76">0%</text>
|
|
7
7
|
<line x1="48" y1="249.6" x2="700" y2="249.6" stroke="#eaeef2" stroke-width="1"/>
|