skillgauntlet 1.1.1 → 1.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/LICENSE +201 -201
- package/README.md +44 -1
- package/bin/skillgauntlet.js +380 -131
- package/lib/automerge.js +107 -0
- package/lib/browse.js +58 -0
- package/lib/chart.js +160 -0
- package/lib/depth.js +4 -1
- package/lib/discover.js +27 -2
- package/lib/findhost.js +83 -0
- package/lib/i18n.js +405 -39
- package/lib/models.js +46 -0
- package/lib/report.js +1 -1
- package/lib/schema.js +90 -0
- package/lib/scrub.js +186 -0
- package/lib/static.js +24 -13
- package/lib/submission.js +325 -0
- package/lib/submit.js +208 -0
- package/lib/ticker.js +39 -0
- package/lib/trial.js +65 -13
- package/lib/ui.js +40 -2
- package/lib/update.js +73 -0
- package/package.json +4 -2
- package/schemas/submission-1.json +210 -0
package/lib/models.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Model names a host will accept, read from the host itself so the list is the user's own (it depends on the
|
|
2
|
+
// account and the custom models they set up) and never goes stale inside this package. Any failure gives [].
|
|
3
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
4
|
+
import { launchSpec } from './spawn.js';
|
|
5
|
+
|
|
6
|
+
const OPTS = { encoding: 'utf8', timeout: 20000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 };
|
|
7
|
+
// The arguments here are fixed words, so a shell launch (a .cmd shim) is one command string; passing an argument
|
|
8
|
+
// list together with shell: true makes Node print a deprecation warning into the user's terminal.
|
|
9
|
+
const run = (cmd, args, { shell } = {}) => (shell ? execSync(`"${cmd}" ${args.join(' ')}`, OPTS) : execFileSync(cmd, args, OPTS));
|
|
10
|
+
|
|
11
|
+
// CodeBuddy prints "Currently supported: (a, b, c)" in --help.
|
|
12
|
+
export function parseCodebuddyModels(help) {
|
|
13
|
+
const m = String(help).match(/Currently supported:\s*\(([^)]*)\)/);
|
|
14
|
+
return m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `codex debug models` renders its catalog as JSON; entries marked visibility "list" are the ones its own picker shows.
|
|
18
|
+
export function parseCodexModels(json) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(json);
|
|
21
|
+
const list = Array.isArray(parsed) ? parsed : parsed.models || [];
|
|
22
|
+
return list.filter((m) => m?.slug && m.visibility === 'list').map((m) => m.slug);
|
|
23
|
+
} catch { return []; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hostModels(host, bin, exec = run) {
|
|
27
|
+
try {
|
|
28
|
+
if (host === 'codebuddy') {
|
|
29
|
+
const l = launchSpec(bin, ['--help'], 'codebuddy');
|
|
30
|
+
return parseCodebuddyModels(exec(l.cmd, l.args, { shell: l.shell }));
|
|
31
|
+
}
|
|
32
|
+
if (host === 'codex') {
|
|
33
|
+
const l = launchSpec(bin, ['debug', 'models'], 'codex');
|
|
34
|
+
return parseCodexModels(exec(l.cmd, l.args, { shell: l.shell }));
|
|
35
|
+
}
|
|
36
|
+
} catch { /* the caller falls back to typing */ }
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A model the user typed that the host does not list would fail only after the question-drafting call started. Names the
|
|
41
|
+
// package turns into a DeepSeek model of its own (deepseek-*) are exempt: they are not in the host's list by design.
|
|
42
|
+
// Returns the host's current list when the name is not in it, else null (also null when the list could not be read).
|
|
43
|
+
export function unknownModel(model, known) {
|
|
44
|
+
if (!model || !known?.length || known.includes(model) || /^deepseek/i.test(model)) return null;
|
|
45
|
+
return known;
|
|
46
|
+
}
|
package/lib/report.js
CHANGED
|
@@ -31,7 +31,7 @@ export function staticMarkdown(result) {
|
|
|
31
31
|
L.push('');
|
|
32
32
|
for (const c of g.checks) {
|
|
33
33
|
L.push(`- ${ICON[c.status]} **${c.id} ${t(c.titleKey)}** — ${t(c.detailKey, c.detailVars)}`);
|
|
34
|
-
for (const e of c.evidence.slice(0, 3)) L.push(` -
|
|
34
|
+
for (const e of c.evidence.slice(0, 3)) L.push(` - \`${e.file ? `${e.file}:` : ''}L${e.line}\` ${e.match}`);
|
|
35
35
|
}
|
|
36
36
|
L.push('');
|
|
37
37
|
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A small JSON Schema checker: enough of the vocabulary to validate schemas/submission-1.json
|
|
2
|
+
// (type, enum, const, pattern, length and size bounds, required, properties, additionalProperties,
|
|
3
|
+
// items, local $ref) and nothing else. The package has no dependencies, and CI must not install any
|
|
4
|
+
// to look at a stranger's file. A keyword this checker does not know is an error, not silently ignored,
|
|
5
|
+
// so the schema cannot drift into promising checks nobody runs.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const KNOWN = new Set([
|
|
12
|
+
'$schema', '$id', '$defs', '$ref', 'title', 'description', 'type', 'enum', 'const', 'pattern', 'minLength',
|
|
13
|
+
'maxLength', 'minimum', 'maximum', 'required', 'properties', 'additionalProperties', 'items', 'minItems',
|
|
14
|
+
'maxItems', 'maxProperties',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function loadSchema() {
|
|
18
|
+
return JSON.parse(fs.readFileSync(path.join(here, '..', 'schemas', 'submission-1.json'), 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function typeOf(v) {
|
|
22
|
+
if (v === null) return 'null';
|
|
23
|
+
if (Array.isArray(v)) return 'array';
|
|
24
|
+
if (Number.isInteger(v)) return 'integer';
|
|
25
|
+
return typeof v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function typeMatches(want, v) {
|
|
29
|
+
const got = typeOf(v);
|
|
30
|
+
return want === got || (want === 'number' && got === 'integer');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Depth guard: a hostile file must not be able to blow the stack of the checker.
|
|
34
|
+
export function maxDepth(v, limit = 12, d = 0) {
|
|
35
|
+
if (d > limit) return d;
|
|
36
|
+
if (v && typeof v === 'object') {
|
|
37
|
+
let m = d;
|
|
38
|
+
for (const x of Object.values(v)) m = Math.max(m, maxDepth(x, limit, d + 1));
|
|
39
|
+
return m;
|
|
40
|
+
}
|
|
41
|
+
return d;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Returns a list of { path, message }. Empty means valid.
|
|
45
|
+
export function validate(root, value, defName) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const resolve = (ref) => {
|
|
48
|
+
const m = /^#\/\$defs\/(\w+)$/.exec(ref);
|
|
49
|
+
if (!m || !root.$defs?.[m[1]]) throw new Error(`unsupported $ref ${ref}`);
|
|
50
|
+
return root.$defs[m[1]];
|
|
51
|
+
};
|
|
52
|
+
const walk = (schema, v, at) => {
|
|
53
|
+
for (const k of Object.keys(schema)) if (!KNOWN.has(k)) throw new Error(`schema keyword not supported by the checker: ${k}`);
|
|
54
|
+
if (schema.$ref) { walk(resolve(schema.$ref), v, at); return; }
|
|
55
|
+
const fail = (message) => errors.push({ path: at || '(root)', message });
|
|
56
|
+
if (schema.const !== undefined && v !== schema.const) { fail(`must be ${JSON.stringify(schema.const)}`); return; }
|
|
57
|
+
if (schema.enum && !schema.enum.includes(v)) { fail(`must be one of ${schema.enum.map((x) => JSON.stringify(x)).join(', ')}`); return; }
|
|
58
|
+
if (schema.type) {
|
|
59
|
+
const want = [].concat(schema.type);
|
|
60
|
+
if (!want.some((w) => typeMatches(w, v))) { fail(`must be ${want.join(' or ')}, got ${typeOf(v)}`); return; }
|
|
61
|
+
}
|
|
62
|
+
if (typeof v === 'string') {
|
|
63
|
+
if (schema.minLength !== undefined && v.length < schema.minLength) fail(`shorter than ${schema.minLength}`);
|
|
64
|
+
if (schema.maxLength !== undefined && v.length > schema.maxLength) fail(`longer than ${schema.maxLength}`);
|
|
65
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(v)) fail(`does not match ${schema.pattern}`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof v === 'number') {
|
|
68
|
+
if (schema.minimum !== undefined && v < schema.minimum) fail(`below ${schema.minimum}`);
|
|
69
|
+
if (schema.maximum !== undefined && v > schema.maximum) fail(`above ${schema.maximum}`);
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(v)) {
|
|
72
|
+
if (schema.minItems !== undefined && v.length < schema.minItems) fail(`fewer than ${schema.minItems} items`);
|
|
73
|
+
if (schema.maxItems !== undefined && v.length > schema.maxItems) fail(`more than ${schema.maxItems} items`);
|
|
74
|
+
if (schema.items) v.forEach((x, i) => walk(schema.items, x, `${at}[${i}]`));
|
|
75
|
+
}
|
|
76
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
77
|
+
const keys = Object.keys(v);
|
|
78
|
+
if (schema.maxProperties !== undefined && keys.length > schema.maxProperties) fail(`more than ${schema.maxProperties} properties`);
|
|
79
|
+
for (const r of schema.required || []) if (!(r in v)) errors.push({ path: at ? `${at}.${r}` : r, message: 'is required' });
|
|
80
|
+
const props = schema.properties || {};
|
|
81
|
+
for (const k of keys) {
|
|
82
|
+
const sub = at ? `${at}.${k}` : k;
|
|
83
|
+
if (props[k]) walk(props[k], v[k], sub);
|
|
84
|
+
else if (schema.additionalProperties === false) errors.push({ path: sub, message: 'is not allowed here' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
walk(resolve(`#/$defs/${defName}`), value, '');
|
|
89
|
+
return errors;
|
|
90
|
+
}
|
package/lib/scrub.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Nothing leaves the machine before it has been through here.
|
|
2
|
+
//
|
|
3
|
+
// Run records are written by tools that capture error text, stderr and tool names, so they can hold the
|
|
4
|
+
// user's home path, login name, host name, an environment value or a key that was echoed in an error.
|
|
5
|
+
// A submission is public and permanent (it becomes a pull request), so the rule is: strip what is known
|
|
6
|
+
// to be personal, redact what is shaped like a secret, then scan the result again with a wider net and
|
|
7
|
+
// refuse to package anything the scan still dislikes. Stripping is best effort; the refusal is the guarantee.
|
|
8
|
+
//
|
|
9
|
+
// The question text is the one thing that must not be rewritten: the query-pack hash covers it. It is
|
|
10
|
+
// scanned but never edited, and a hit there stops the submission with a message naming the question.
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
// Patterns that are redacted when found, and reported when they survive the redaction.
|
|
14
|
+
export const SECRET_PATTERNS = [
|
|
15
|
+
['anthropic-key', /sk-ant-[A-Za-z0-9_-]{16,}/g],
|
|
16
|
+
['openai-style-key', /\bsk-[A-Za-z0-9_-]{16,}/g],
|
|
17
|
+
['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g],
|
|
18
|
+
['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
|
|
19
|
+
['google-api-key', /\bAIza[0-9A-Za-z_-]{30,}/g],
|
|
20
|
+
['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
|
|
21
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
22
|
+
['bearer-token', /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi],
|
|
23
|
+
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/g],
|
|
24
|
+
['url-credentials', /\bhttps?:\/\/[^/\s:@]+:[^/\s@]+@/gi],
|
|
25
|
+
['assignment', /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{8,}/gi],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
29
|
+
const SENSITIVE_ENV = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|SESSION)/i;
|
|
30
|
+
|
|
31
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
|
|
33
|
+
// What this machine knows about its user: replaced wherever it shows up, in any slash style.
|
|
34
|
+
export function localContext({ env = process.env, extraPaths = [], user = null } = {}) {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
const names = new Set();
|
|
37
|
+
for (const n of [user, env.USERNAME, env.USER, env.LOGNAME, safeUser()]) if (n && n.length >= 4) names.add(n);
|
|
38
|
+
const hosts = new Set([os.hostname(), env.COMPUTERNAME, env.HOSTNAME].filter((h) => h && h.length >= 3));
|
|
39
|
+
const paths = new Set([home, os.tmpdir(), ...extraPaths].filter(Boolean));
|
|
40
|
+
const secrets = new Set();
|
|
41
|
+
for (const [k, v] of Object.entries(env)) {
|
|
42
|
+
if (SENSITIVE_ENV.test(k) && v && v.length >= 6) secrets.add(v);
|
|
43
|
+
}
|
|
44
|
+
return { home, names: [...names], hosts: [...hosts], paths: [...paths], secrets: [...secrets] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For CI: it knows nothing about the submitter's machine, so only the shape-based patterns apply.
|
|
48
|
+
export function emptyContext() {
|
|
49
|
+
return { home: '', names: [], hosts: [], paths: [], secrets: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeUser() {
|
|
53
|
+
try { return os.userInfo().username; } catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Both slash styles of one path, longest first so a path inside another is replaced as a whole.
|
|
57
|
+
function pathVariants(p) {
|
|
58
|
+
const fwd = p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
59
|
+
const back = fwd.replace(/\//g, '\\');
|
|
60
|
+
return [...new Set([p.replace(/[\\/]+$/, ''), fwd, back])].filter((x) => x.length >= 4);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function compileContext(ctx) {
|
|
64
|
+
const pathRes = ctx.paths
|
|
65
|
+
.flatMap(pathVariants)
|
|
66
|
+
.sort((a, b) => b.length - a.length)
|
|
67
|
+
.map((p) => [new RegExp(escapeRe(p), 'gi'), p === ctx.home || pathVariants(ctx.home).includes(p) ? '<home>' : '<path>']);
|
|
68
|
+
const nameRe = ctx.names.length ? new RegExp(`(?<![A-Za-z0-9])(?:${ctx.names.map(escapeRe).join('|')})(?![A-Za-z0-9])`, 'gi') : null;
|
|
69
|
+
const hostRe = ctx.hosts.length ? new RegExp(`(?<![A-Za-z0-9])(?:${ctx.hosts.map(escapeRe).join('|')})(?![A-Za-z0-9])`, 'gi') : null;
|
|
70
|
+
const secretRes = ctx.secrets.map((s) => new RegExp(escapeRe(s), 'g'));
|
|
71
|
+
return { pathRes, nameRe, hostRe, secretRes };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const GENERIC_PATHS = [
|
|
75
|
+
[/[A-Za-z]:[\\/]+Users[\\/]+[^\\/\s"']+/gi, '<home>'],
|
|
76
|
+
[/(?<![A-Za-z0-9_.-])\/(?:home|Users)\/[^/\s"']+/g, '<home>'],
|
|
77
|
+
[/\b[A-Za-z]:[\\/][^\s"'<>|*?]+/g, '<path>'],
|
|
78
|
+
[/(?<![A-Za-z0-9_.:-])\/(?:tmp|var|private|opt|mnt|root|usr|etc|proc|run|snap|srv|Volumes)\/[^\s"'<>|*?]*/g, '<path>'],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Returns { text, counts }. counts says how many of each kind were replaced, for the preview.
|
|
82
|
+
export function scrubString(s, compiled, counts = {}) {
|
|
83
|
+
let out = s;
|
|
84
|
+
const bump = (k, n) => { if (n) counts[k] = (counts[k] || 0) + n; };
|
|
85
|
+
const sub = (re, repl, kind) => {
|
|
86
|
+
let n = 0;
|
|
87
|
+
out = out.replace(re, (...m) => { n += 1; return typeof repl === 'function' ? repl(...m) : repl; });
|
|
88
|
+
bump(kind, n);
|
|
89
|
+
};
|
|
90
|
+
for (const re of compiled.secretRes) sub(re, '<redacted>', 'env-value');
|
|
91
|
+
for (const [name, re] of SECRET_PATTERNS) {
|
|
92
|
+
if (name === 'assignment') sub(re, (_m, sep) => `<redacted-key>${sep}<redacted>`, 'secret');
|
|
93
|
+
else sub(re, '<redacted>', 'secret');
|
|
94
|
+
}
|
|
95
|
+
for (const [re, repl] of compiled.pathRes) sub(re, repl, 'path');
|
|
96
|
+
for (const [re, repl] of GENERIC_PATHS) sub(re, repl, 'path');
|
|
97
|
+
// host name first: a login name is often a piece of it (ALICE-PC)
|
|
98
|
+
if (compiled.hostRe) sub(compiled.hostRe, '<host>', 'hostname');
|
|
99
|
+
if (compiled.nameRe) sub(compiled.nameRe, '<user>', 'username');
|
|
100
|
+
sub(EMAIL, '<email>', 'email');
|
|
101
|
+
return { text: out, counts };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The wider net, run on already-scrubbed data. Returns a list of { where, kind, sample }.
|
|
105
|
+
// Hex strings are exempt (hashes and commits are meant to be there); everything else long, mixed
|
|
106
|
+
// letters-and-digits and random-looking is treated as a probable credential.
|
|
107
|
+
export function findSecrets(value, ctx = localContext(), where = '') {
|
|
108
|
+
const compiled = compileContext(ctx);
|
|
109
|
+
const hits = [];
|
|
110
|
+
const visit = (v, at) => {
|
|
111
|
+
if (typeof v === 'string') {
|
|
112
|
+
for (const [name, re] of SECRET_PATTERNS) {
|
|
113
|
+
re.lastIndex = 0;
|
|
114
|
+
// an already-redacted assignment still matches the assignment pattern; the placeholder is not a secret
|
|
115
|
+
const m = [...v.matchAll(re)].find((x) => !/<redacted/.test(x[0]));
|
|
116
|
+
if (m) hits.push({ where: at, kind: name, sample: mask(m[0]) });
|
|
117
|
+
}
|
|
118
|
+
for (const re of compiled.secretRes) {
|
|
119
|
+
if (re.test(v)) hits.push({ where: at, kind: 'env-value', sample: '(a value from one of your environment variables)' });
|
|
120
|
+
re.lastIndex = 0;
|
|
121
|
+
}
|
|
122
|
+
for (const tok of v.split(/[^A-Za-z0-9_\-+=]+/)) {
|
|
123
|
+
if (tok.length >= 32 && !/^[0-9a-f]+$/i.test(tok) && /[A-Za-z]/.test(tok) && /\d/.test(tok) && entropy(tok) >= 4.2) {
|
|
124
|
+
hits.push({ where: at, kind: 'high-entropy-token', sample: mask(tok) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const re of [...compiled.pathRes.map((x) => x[0]), ...GENERIC_PATHS.slice(0, 2).map((x) => x[0])]) {
|
|
128
|
+
re.lastIndex = 0;
|
|
129
|
+
const m = re.exec(v);
|
|
130
|
+
re.lastIndex = 0;
|
|
131
|
+
if (m) hits.push({ where: at, kind: 'local-path', sample: mask(m[0]) });
|
|
132
|
+
}
|
|
133
|
+
if (compiled.nameRe) {
|
|
134
|
+
compiled.nameRe.lastIndex = 0;
|
|
135
|
+
if (compiled.nameRe.test(v)) hits.push({ where: at, kind: 'username', sample: '(your login name)' });
|
|
136
|
+
compiled.nameRe.lastIndex = 0;
|
|
137
|
+
}
|
|
138
|
+
} else if (Array.isArray(v)) v.forEach((x, i) => visit(x, `${at}[${i}]`));
|
|
139
|
+
else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) visit(x, at ? `${at}.${k}` : k);
|
|
140
|
+
};
|
|
141
|
+
visit(value, where);
|
|
142
|
+
return hits;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mask(s) {
|
|
146
|
+
return s.length <= 8 ? '****' : `${s.slice(0, 4)}…(${s.length} chars)`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function entropy(s) {
|
|
150
|
+
const f = new Map();
|
|
151
|
+
for (const c of s) f.set(c, (f.get(c) || 0) + 1);
|
|
152
|
+
let h = 0;
|
|
153
|
+
for (const n of f.values()) h -= (n / s.length) * Math.log2(n / s.length);
|
|
154
|
+
return h;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Scrub a parsed JSON value. keepKeys names string fields that are copied as they are (the question
|
|
158
|
+
// text); those are only scanned. Returns { value, counts }.
|
|
159
|
+
export function scrubValue(value, ctx = localContext(), { keepKeys = [] } = {}) {
|
|
160
|
+
const compiled = compileContext(ctx);
|
|
161
|
+
const counts = {};
|
|
162
|
+
const keep = new Set(keepKeys);
|
|
163
|
+
const walk = (v, key) => {
|
|
164
|
+
if (typeof v === 'string') return keep.has(key) ? v : scrubString(v, compiled, counts).text;
|
|
165
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, key));
|
|
166
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)]));
|
|
167
|
+
return v;
|
|
168
|
+
};
|
|
169
|
+
return { value: walk(value, ''), counts };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The whole gate for one package: scrub every file, then scan. files is { name: parsed value | array of
|
|
173
|
+
// parsed lines }. Returns { files, counts, problems }; a non-empty problems list means do not package.
|
|
174
|
+
// `verbatim` names files that are scanned but never edited (the question pack: its hash covers every field).
|
|
175
|
+
export function scrubPackage(files, ctx = localContext(), { verbatim = [] } = {}) {
|
|
176
|
+
const out = {};
|
|
177
|
+
const counts = {};
|
|
178
|
+
const problems = [];
|
|
179
|
+
for (const [name, content] of Object.entries(files)) {
|
|
180
|
+
const { value, counts: c } = verbatim.includes(name) ? { value: content, counts: {} } : scrubValue(content, ctx, { keepKeys: ['query'] });
|
|
181
|
+
out[name] = value;
|
|
182
|
+
for (const [k, n] of Object.entries(c)) counts[k] = (counts[k] || 0) + n;
|
|
183
|
+
for (const h of findSecrets(value, ctx, name)) problems.push(h);
|
|
184
|
+
}
|
|
185
|
+
return { files: out, counts, problems };
|
|
186
|
+
}
|
package/lib/static.js
CHANGED
|
@@ -29,12 +29,23 @@ function lineOf(text, index) {
|
|
|
29
29
|
return text.slice(0, index).split(/\r?\n/).length;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function hits(text, re, limit = 5) {
|
|
32
|
+
function hits(text, re, limit = 5, file = null) {
|
|
33
33
|
const out = [];
|
|
34
34
|
re.lastIndex = 0;
|
|
35
35
|
let m;
|
|
36
36
|
while ((m = re.exec(text)) && out.length < limit) {
|
|
37
|
-
out.push({ line: lineOf(text, m.index), match: m[0].trim().slice(0, 60) });
|
|
37
|
+
out.push({ ...(file ? { file } : {}), line: lineOf(text, m.index), match: m[0].trim().slice(0, 60) });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The same search across several files, each hit carrying its own file and the line inside that file. (Searching
|
|
43
|
+
// one joined text gave line numbers that matched no file.)
|
|
44
|
+
function hitsIn(files, re, limit = 5) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
if (out.length >= limit) break;
|
|
48
|
+
out.push(...hits(f.text, re, limit - out.length, f.rel));
|
|
38
49
|
}
|
|
39
50
|
return out;
|
|
40
51
|
}
|
|
@@ -88,9 +99,9 @@ export function analyze(skillDir) {
|
|
|
88
99
|
const { fm, text } = readFrontmatter(dir);
|
|
89
100
|
const files = collect(dir);
|
|
90
101
|
const codeFiles = files.filter((f) => CODE_EXT.has(f.ext));
|
|
91
|
-
const
|
|
92
|
-
try { return fs.readFileSync(f.path, 'utf8'); } catch { return ''; }
|
|
93
|
-
})
|
|
102
|
+
const codeTexts = codeFiles.map((f) => {
|
|
103
|
+
try { return { rel: f.rel, text: fs.readFileSync(f.path, 'utf8') }; } catch { return { rel: f.rel, text: '' }; }
|
|
104
|
+
});
|
|
94
105
|
const body = text.replace(/^---[\s\S]*?---/, '');
|
|
95
106
|
const totalBytes = total(files);
|
|
96
107
|
const size = humanSize(totalBytes);
|
|
@@ -126,30 +137,30 @@ export function analyze(skillDir) {
|
|
|
126
137
|
const undeclared = !allowed && codeFiles.length > 0;
|
|
127
138
|
const C = [];
|
|
128
139
|
for (const [id, re] of [['C1', RE.network], ['C2', RE.exec], ['C3', RE.secrets], ['C4', RE.obfusc], ['C5', RE.install]]) {
|
|
129
|
-
const h =
|
|
140
|
+
const h = hitsIn(codeTexts, re);
|
|
130
141
|
const detailKey = h.length ? (undeclared ? 'check.C.escalated' : 'check.C.hits') : 'check.C.none';
|
|
131
142
|
C.push(check(id, h.length ? (undeclared ? 'warn' : 'info') : 'pass', detailKey, { n: h.length }, h));
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// D. instruction hijacking -- aimed at the reader, not the shell
|
|
135
146
|
const D = [];
|
|
136
|
-
const dh = hits(text, RE.hijack);
|
|
147
|
+
const dh = hits(text, RE.hijack, 5, 'SKILL.md');
|
|
137
148
|
D.push(check('D1', dh.length ? 'fail' : 'pass', dh.length ? 'check.D1.hit' : 'check.D.none', {}, dh));
|
|
138
|
-
const sec = hits(text, RE.secrets);
|
|
149
|
+
const sec = hits(text, RE.secrets, 5, 'SKILL.md');
|
|
139
150
|
D.push(check('D2', sec.length ? 'warn' : 'pass', sec.length ? 'check.D2.hit' : 'check.D.none', {}, sec));
|
|
140
|
-
const ex = hits(text, RE.exfil);
|
|
151
|
+
const ex = hits(text, RE.exfil, 5, 'SKILL.md');
|
|
141
152
|
D.push(check('D3', ex.length ? 'warn' : 'pass', ex.length ? 'check.D3.hit' : 'check.D.none', {}, ex));
|
|
142
|
-
const zw = hits(text, RE.zeroWidth);
|
|
153
|
+
const zw = hits(text, RE.zeroWidth, 5, 'SKILL.md');
|
|
143
154
|
D.push(check('D4', zw.length ? 'fail' : 'pass', zw.length ? 'check.D4.hit' : 'check.D.none', {}, zw));
|
|
144
|
-
const img = hits(text, RE.extImage);
|
|
155
|
+
const img = hits(text, RE.extImage, 5, 'SKILL.md');
|
|
145
156
|
D.push(check('D5', img.length ? 'warn' : 'pass', img.length ? 'check.D5.hit' : 'check.D.none', {}, img));
|
|
146
157
|
|
|
147
158
|
// E. description quality -- the only layer that is also the trigger layer's input
|
|
148
159
|
const E = [];
|
|
149
160
|
E.push(check('E1', desc ? 'pass' : 'fail', desc ? 'check.E1.detail' : 'check.E1.missing', { chars: desc.length }));
|
|
150
|
-
const tw = hits(desc, RE.triggerWords, 1);
|
|
161
|
+
const tw = hits(desc, RE.triggerWords, 1, 'SKILL.md (description)');
|
|
151
162
|
E.push(check('E2', tw.length ? 'pass' : 'warn', tw.length ? 'check.E2.hit' : 'check.E2.miss'));
|
|
152
|
-
const wide = hits(desc, RE.wide, 1);
|
|
163
|
+
const wide = hits(desc, RE.wide, 1, 'SKILL.md (description)');
|
|
153
164
|
E.push(check('E3', wide.length ? 'warn' : 'pass', wide.length ? 'check.E3.hit' : 'check.E3.none', {}, wide));
|
|
154
165
|
const clauses = (desc.match(/\bor\b|、|,|,|\//g) || []).length + 1;
|
|
155
166
|
E.push(check('E4', 'info', 'check.E4.detail', { n: clauses }));
|