@ericrisco/rsc 1.0.15 → 1.0.16
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/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
|
|