@ericrisco/rsc 1.0.15 → 1.0.17
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/lib/eval-integrity.js +95 -25
- package/skills/verify/SKILL.md +9 -1
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -29,13 +29,89 @@ export const ROLE_MARKERS = {
|
|
|
29
29
|
loader: 'return its full text as',
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
// Tools that write. Checked against a tool call's actual target path, never against nearby text.
|
|
33
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
34
|
+
|
|
35
|
+
// Shell constructs that write TO a path. `cat 02-DOCS/x` is a read and must not match; `cat > 02-DOCS/x`
|
|
36
|
+
// must. Each pattern is anchored on the redirect/verb so the protected path has to be its TARGET.
|
|
37
|
+
const SHELL_WRITE_TO = (p) => [
|
|
38
|
+
new RegExp(`>>?\\s*\\S*${p}`), // > path, >> path
|
|
39
|
+
new RegExp(`\\btee\\s+(-\\S+\\s+)*\\S*${p}`),
|
|
40
|
+
new RegExp(`\\b(mv|cp|rsync)\\s+[^|;&]*\\s\\S*${p}`),
|
|
41
|
+
new RegExp(`\\brm\\s+(-\\S+\\s+)*\\S*${p}`),
|
|
42
|
+
new RegExp(`\\b(mkdir|touch)\\s+(-\\S+\\s+)*\\S*${p}`),
|
|
37
43
|
];
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Structurally extract the tool calls from a transcript. This is the fix for the defect the first
|
|
47
|
+
* real run exposed: the previous version looked for a write VERB within ~400 chars of a protected
|
|
48
|
+
* path in the raw text. In a treatment transcript the injected SKILL.md itself names
|
|
49
|
+
* 02-DOCS/wiki/... paths, and tool names appear in every JSON envelope, so the proximity window
|
|
50
|
+
* matched almost always. It blocked a run in which nothing had been written — verified against the
|
|
51
|
+
* filesystem, which held no new files at all. An over-blocking gate is still a broken gate: it would
|
|
52
|
+
* have made every treatment run BLOCKED and wedged skill-harden permanently.
|
|
53
|
+
*/
|
|
54
|
+
export function extractToolCalls(transcriptText) {
|
|
55
|
+
const calls = [];
|
|
56
|
+
for (const line of String(transcriptText || '').split('\n')) {
|
|
57
|
+
if (!line.trim()) continue;
|
|
58
|
+
let o;
|
|
59
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
60
|
+
const msg = o && typeof o.message === 'object' && o.message ? o.message : o;
|
|
61
|
+
const content = msg && msg.content;
|
|
62
|
+
if (!Array.isArray(content)) continue;
|
|
63
|
+
for (const b of content) {
|
|
64
|
+
if (b && b.type === 'tool_use') calls.push({ name: b.name, input: b.input || {} });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return calls;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The per-arm sandbox. A write under here is the CORRECT behaviour, however its path reads.
|
|
71
|
+
export const SANDBOX_MARKER = '.rsc/eval-sandbox/';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Did this tool call write to a path under `protected`?
|
|
75
|
+
*
|
|
76
|
+
* Two false positives were fixed here, both the same mistake — "the path appears in the string" is
|
|
77
|
+
* not "the write targets that location":
|
|
78
|
+
* 1. proximity matching in raw transcript text (the injected SKILL.md names 02-DOCS paths);
|
|
79
|
+
* 2. substring matching against a sandbox path that MIRRORS the wiki layout inside itself, e.g.
|
|
80
|
+
* .rsc/eval-sandbox/specify/treatment-1/02-DOCS/wiki/sdd/specs/x.md — a correct write that
|
|
81
|
+
* contains "02-DOCS/wiki/" as a substring.
|
|
82
|
+
* Both were caught only by checking the filesystem, which held no new files either time. The lesson
|
|
83
|
+
* is in the shape of the check, not in the patterns: anchor on the sandbox first, always.
|
|
84
|
+
*/
|
|
85
|
+
export function callWritesTo(call, protectedPath) {
|
|
86
|
+
if (!call) return false;
|
|
87
|
+
if (WRITE_TOOLS.has(call.name)) {
|
|
88
|
+
const target = String(call.input.file_path || call.input.path || call.input.notebook_path || '');
|
|
89
|
+
if (target.includes(SANDBOX_MARKER)) return false;
|
|
90
|
+
return target.includes(protectedPath);
|
|
91
|
+
}
|
|
92
|
+
if (call.name === 'Bash') {
|
|
93
|
+
const cmd = String(call.input.command || '');
|
|
94
|
+
if (!cmd.includes(protectedPath)) return false;
|
|
95
|
+
const esc = protectedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
const sandboxEsc = SANDBOX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
97
|
+
return SHELL_WRITE_TO(esc).some((re) => {
|
|
98
|
+
const m = re.exec(cmd);
|
|
99
|
+
if (!m) return false;
|
|
100
|
+
// The matched write target itself must not sit inside the sandbox. Checking the whole command
|
|
101
|
+
// would let one sandboxed write excuse a real one in the same line.
|
|
102
|
+
return !new RegExp(`${sandboxEsc}[^\\s'"]*${esc}`).test(m[0]);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Did this tool call READ the given path? A path inside a tool input, not prose mentioning it. */
|
|
109
|
+
export function callReads(call, path) {
|
|
110
|
+
if (!call) return false;
|
|
111
|
+
const input = JSON.stringify(call.input || {});
|
|
112
|
+
return input.includes(path);
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
// Paths an eval arm may never write. 02-DOCS is the project's brain and is untracked (P9), so a
|
|
40
116
|
// stray write there has no undo.
|
|
41
117
|
export const PROTECTED_PATHS = ['02-DOCS/wiki/'];
|
|
@@ -73,35 +149,29 @@ export function findViolations({ skillId, agents }) {
|
|
|
73
149
|
const rubricPath = `skills/${skillId}/evals/cases.yaml`;
|
|
74
150
|
|
|
75
151
|
for (const a of agents || []) {
|
|
76
|
-
|
|
152
|
+
// Judged on TOOL CALLS, not on text. A transcript naming a path proves nothing: the treatment's
|
|
153
|
+
// own injected skill body names 02-DOCS paths, and the grader quotes repo paths constantly.
|
|
154
|
+
const calls = a.calls || extractToolCalls(a.text);
|
|
155
|
+
|
|
77
156
|
if (a.role === 'baseline') {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
const skillHits =
|
|
157
|
+
// Only the control arm is judged on reads — the treatment is supposed to have the skill and
|
|
158
|
+
// the loader reads both files by design.
|
|
159
|
+
const skillHits = calls.filter((c) => callReads(c, skillPath)).length;
|
|
81
160
|
if (skillHits > 0) {
|
|
82
161
|
violations.push({ kind: 'baseline-read-skill', agent: a.name, pattern: skillPath, count: skillHits });
|
|
83
162
|
}
|
|
84
|
-
|
|
163
|
+
// Reading the rubric is reading the answer key: it also inflates the baseline's own score.
|
|
164
|
+
const rubricHits = calls.filter((c) => callReads(c, rubricPath)).length;
|
|
85
165
|
if (rubricHits > 0) {
|
|
86
166
|
violations.push({ kind: 'baseline-read-rubric', agent: a.name, pattern: rubricPath, count: rubricHits });
|
|
87
167
|
}
|
|
88
168
|
}
|
|
169
|
+
|
|
89
170
|
if (a.role === 'baseline' || a.role === 'treatment') {
|
|
90
171
|
for (const p of PROTECTED_PATHS) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const wrote = WRITE_SIGNALS.some((verb) => {
|
|
95
|
-
let i = text.indexOf(p);
|
|
96
|
-
while (i !== -1) {
|
|
97
|
-
const window = text.slice(Math.max(0, i - 400), i + 400);
|
|
98
|
-
if (window.includes(verb)) return true;
|
|
99
|
-
i = text.indexOf(p, i + p.length);
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
});
|
|
103
|
-
if (wrote) {
|
|
104
|
-
violations.push({ kind: 'wrote-protected-path', agent: a.name, pattern: p, count: countOf(text, p) });
|
|
172
|
+
const hits = calls.filter((c) => callWritesTo(c, p)).length;
|
|
173
|
+
if (hits > 0) {
|
|
174
|
+
violations.push({ kind: 'wrote-protected-path', agent: a.name, pattern: p, count: hits });
|
|
105
175
|
}
|
|
106
176
|
}
|
|
107
177
|
}
|
|
@@ -118,7 +188,7 @@ export function readAgents(dir) {
|
|
|
118
188
|
.filter((f) => /^agent-.*\.jsonl$/.test(f))
|
|
119
189
|
.map((f) => {
|
|
120
190
|
const text = readFileSync(join(dir, f), 'utf8');
|
|
121
|
-
return { name: f, role: classifyAgent(text), text };
|
|
191
|
+
return { name: f, role: classifyAgent(text), text, calls: extractToolCalls(text) };
|
|
122
192
|
});
|
|
123
193
|
}
|
|
124
194
|
|
package/skills/verify/SKILL.md
CHANGED
|
@@ -85,7 +85,7 @@ Rules for RUN:
|
|
|
85
85
|
- Run from the directory the stack skill documents (Go runs from the module root; others from the subproject root). Don't guess paths.
|
|
86
86
|
- If no `scripts/verify.sh` exists for a touched stack, that's a gap — say the gate is incomplete and recommend the user add the stack skill, rather than hand-rolling a one-off check that drifts from the real gate.
|
|
87
87
|
|
|
88
|
-
#### A home-grown gate must prove it can fail
|
|
88
|
+
#### A home-grown gate must prove it can fail — and that it can pass
|
|
89
89
|
|
|
90
90
|
The dangerous way a checker breaks is not a crash — it is **fail-open**: nothing errors, the layer prints pass, and it prints pass forever. That failure can only ever produce green, so no failing run will ever surface it. The rule follows: **before you trust a home-grown gate's pass, watch it fail** on a known-bad input. It is the RED principle applied to the checker.
|
|
91
91
|
|
|
@@ -94,6 +94,14 @@ The dangerous way a checker breaks is not a crash — it is **fail-open**: nothi
|
|
|
94
94
|
- **State the limit where you state the pass.** Watching a gate fail once proves that *one* known-bad case reaches its failure path. It does **not** prove the gate recognizes every violation of the rule it claims to enforce — a gate can fail closed perfectly and still guard a spelling rather than a behavior. Record the control, and record what it does not buy.
|
|
95
95
|
- **Our own known fail-open:** the stack `verify.sh` scripts SKIP a missing tool instead of failing. That is a documented gap, not a pass — so it goes in the record's *Capas no ejecutadas* under `HERRAMIENTA-AUSENTE`, never left implied.
|
|
96
96
|
|
|
97
|
+
**Then run the other control: a known-GOOD input, and watch it pass.** Both directions or neither — a gate proven only against bad input is half-tested, and the untested half is the one that fires on every run.
|
|
98
|
+
|
|
99
|
+
**Over-blocking is not the safe side.** It feels like caution and it is not: a gate that fires on correct work gets muted, worked around, or wedges the pipeline that depends on it, which is the same damage as a gate that checks nothing with the sign reversed. And it is *harder* to notice, because the failure arrives dressed as diligence.
|
|
100
|
+
|
|
101
|
+
Where this bites hardest is a check that matches text rather than structure — **"the path appears in the string" is not "the write targets that location"**. Our own integrity gate learned this twice in one day: it flagged a transcript that merely *named* a protected path (the skill body it had been handed named it), then flagged a sandbox directory whose path *contained* the protected one as a substring. Twelve mutants had proven it could fail; not one had asked whether it could pass, so the defect shipped and surfaced on first real use with the filesystem provably untouched. → `02-DOCS/wiki/harness/puertas-y-mecanismos.md`
|
|
102
|
+
|
|
103
|
+
So when you write the negative control, write the positive one beside it, and prefer matching **structure** (a parsed tool call, a resolved path, an exit code) over matching text that happens to contain the thing you care about.
|
|
104
|
+
|
|
97
105
|
### 3 — WALK the done-checks and acceptance criteria
|
|
98
106
|
|
|
99
107
|
The stack gate proves the code is *clean and tested*. It does **not** prove the feature does what the spec asked. Walk both lists explicitly:
|