clembot-doorman 0.1.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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +951 -0
  4. package/WALKTHROUGH.md +224 -0
  5. package/doorman/.claude/hooks/mcp-gate.sh +205 -0
  6. package/doorman/.claude/settings.json +16 -0
  7. package/doorman/.claude-plugin/plugin.json +22 -0
  8. package/doorman/.mcp.json +24 -0
  9. package/doorman/README.md +259 -0
  10. package/doorman/agents/doorman.md +104 -0
  11. package/doorman/cli/agents.mjs +128 -0
  12. package/doorman/cli/allow.mjs +128 -0
  13. package/doorman/cli/cost.mjs +119 -0
  14. package/doorman/cli/discover.mjs +265 -0
  15. package/doorman/cli/doctor.mjs +282 -0
  16. package/doorman/cli/doorman.mjs +345 -0
  17. package/doorman/cli/eval.mjs +320 -0
  18. package/doorman/cli/harness.mjs +179 -0
  19. package/doorman/cli/install.mjs +175 -0
  20. package/doorman/cli/needs.mjs +116 -0
  21. package/doorman/cli/report.mjs +89 -0
  22. package/doorman/cli/sandbox.mjs +177 -0
  23. package/doorman/cli/task.mjs +239 -0
  24. package/doorman/cli/verdict.mjs +199 -0
  25. package/doorman/cli/watch.mjs +218 -0
  26. package/doorman/commands/doorman.md +116 -0
  27. package/doorman/commands/vet.md +69 -0
  28. package/doorman/hooks/hooks.json +30 -0
  29. package/doorman/install.sh +186 -0
  30. package/doorman/package.json +38 -0
  31. package/doorman/recipes/README.md +36 -0
  32. package/doorman/recipes/deepwiki.md +10 -0
  33. package/doorman/recipes/planted-bad.md +27 -0
  34. package/doorman/recipes/scorecard.md +10 -0
  35. package/doorman/registry/allowlist.json +37 -0
  36. package/doorman/registry/denylist.json +23 -0
  37. package/doorman/registry/ledger.jsonl +1 -0
  38. package/doorman/scripts/poller.mjs +292 -0
  39. package/doorman/scripts/resolve-cli.sh +58 -0
  40. package/doorman/scripts/vet.mjs +190 -0
  41. package/doorman/skills/doorman-guide/SKILL.md +69 -0
  42. package/doorman/src/budget.mjs +236 -0
  43. package/doorman/src/candidate.mjs +132 -0
  44. package/doorman/src/fit-review.mjs +255 -0
  45. package/doorman/src/injection.mjs +189 -0
  46. package/doorman/src/instructions.mjs +134 -0
  47. package/doorman/src/inventory.mjs +411 -0
  48. package/doorman/src/llm.mjs +87 -0
  49. package/doorman/src/needs.mjs +491 -0
  50. package/doorman/src/note.mjs +213 -0
  51. package/doorman/src/reviews.mjs +120 -0
  52. package/doorman/src/scorecard.mjs +123 -0
  53. package/doorman/src/vet.mjs +174 -0
  54. package/package.json +54 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Fit review: does MY system need this at all?
3
+ *
4
+ * The scorecard answers "is this trustworthy", which is external, paid, and
5
+ * about the candidate. This answers "do I already have it", which is internal,
6
+ * free, and about you. It runs FIRST, so a redundant candidate never costs a
7
+ * cent.
8
+ *
9
+ * One model call, temperature 0, pinned model, strict JSON out.
10
+ *
11
+ * The validation below matters more than the prompt. A fit review that names a
12
+ * subagent you do not have, or an overlap that does not exist, is worse than no
13
+ * review at all: it reads as placement advice and it is fiction. So every name
14
+ * the model returns is checked against the inventory it was given, and a name
15
+ * that is not there is a rejected response, not a warning.
16
+ */
17
+
18
+ export const FIT_VERDICTS = ['redundant', 'fits', 'needs-new-subagent', 'out-of-scope'];
19
+ export const OVERLAP_KINDS = ['agent', 'skill', 'mcp-server', 'command'];
20
+
21
+ /** Two sentences. A rationale that runs long stops being read. */
22
+ export const MAX_RATIONALE_CHARS = 400;
23
+
24
+ export class FitReviewError extends Error {
25
+ constructor(message, { attempts, lastResponse } = {}) {
26
+ super(message);
27
+ this.name = 'FitReviewError';
28
+ this.attempts = attempts;
29
+ this.lastResponse = lastResponse;
30
+ }
31
+ }
32
+
33
+ const SYSTEM = `You decide whether an agent system NEEDS a candidate capability.
34
+ You are not judging whether the candidate is good, safe, or well built. A
35
+ separate paid audit does that, and it only runs if you say the capability is
36
+ needed. Your job is the cheap question that comes first: does this system
37
+ already have this?
38
+
39
+ Answer with a single JSON object and nothing else. No prose, no code fence.
40
+
41
+ {
42
+ "verdict": "redundant" | "fits" | "needs-new-subagent" | "out-of-scope",
43
+ "owner": "<subagent name>" | null,
44
+ "rationale": "<at most two sentences>",
45
+ "overlaps": [ { "kind": "agent"|"skill"|"mcp-server"|"command",
46
+ "name": "<exact name from the inventory>",
47
+ "why": "<one clause: what it already covers>" } ]
48
+ }
49
+
50
+ Verdicts:
51
+ - "redundant": an existing subagent, skill or configured server already covers
52
+ this need. Populate overlaps.
53
+ - "fits": genuinely new capability, and one EXISTING subagent should own it.
54
+ Set owner to that subagent's exact name.
55
+ - "needs-new-subagent": genuinely new capability, but no existing subagent is
56
+ the right home. owner must be null.
57
+ - "out-of-scope": the system has no business doing this at all. owner null.
58
+
59
+ Rules you must not break:
60
+ - Every "name" in overlaps MUST appear verbatim in the inventory below. Never
61
+ invent one, and never guess at a plausible-sounding name.
62
+ - "owner" MUST be an exact subagent name from the inventory, and MUST be null
63
+ unless the verdict is "fits".
64
+ - Prefer "redundant" when coverage is genuine. Declining to spend money is the
65
+ point of this step, not a failure of it.
66
+ - If the inventory says a section is UNKNOWN rather than empty, say so in the
67
+ rationale instead of assuming the system has nothing.`;
68
+
69
+ function buildUserPrompt(candidate, inventoryBlock) {
70
+ return [
71
+ '# CANDIDATE',
72
+ `url or path: ${candidate.id}`,
73
+ `type: ${candidate.type}`,
74
+ `wanted for: ${candidate.needed_for || '(not stated)'}`,
75
+ candidate.description ? `self-described as: ${candidate.description}` : '',
76
+ '',
77
+ '# THE SYSTEM AS IT EXISTS TODAY',
78
+ inventoryBlock,
79
+ ]
80
+ .filter(Boolean)
81
+ .join('\n');
82
+ }
83
+
84
+ /**
85
+ * Pull the JSON object out of a response.
86
+ *
87
+ * Strict, with exactly one concession: a ```json fence, because models emit
88
+ * them constantly and rejecting that would burn a retry on a formatting habit
89
+ * rather than on a wrong answer. Anything else is malformed.
90
+ */
91
+ export function extractJson(text) {
92
+ const trimmed = (text ?? '').trim();
93
+ if (!trimmed) return { ok: false, error: 'empty response' };
94
+
95
+ const fenced = /^```(?:json)?\s*\r?\n([\s\S]*?)\r?\n?```$/.exec(trimmed);
96
+ const body = fenced ? fenced[1].trim() : trimmed;
97
+
98
+ try {
99
+ const parsed = JSON.parse(body);
100
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
101
+ return { ok: false, error: 'response parsed but is not a JSON object' };
102
+ }
103
+ return { ok: true, value: parsed };
104
+ } catch (e) {
105
+ return { ok: false, error: 'not valid JSON: ' + e.message };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Check a parsed response against the inventory it was given.
111
+ *
112
+ * Returns a list of problems. Empty means usable. Every message is written to
113
+ * be handed straight back to the model on the retry, so they say what was
114
+ * wrong and what to do instead.
115
+ */
116
+ export function validateVerdict(v, inventory) {
117
+ const problems = [];
118
+ const agentNames = new Set(inventory.agents.map((a) => a.name));
119
+ const known = new Map();
120
+ for (const a of inventory.agents) known.set(`agent:${a.name}`, true);
121
+ for (const s of inventory.skills) known.set(`skill:${s.name}`, true);
122
+ for (const m of inventory.mcpServers) known.set(`mcp-server:${m.name}`, true);
123
+ for (const a of inventory.allowlisted) known.set(`mcp-server:${a.key}`, true);
124
+
125
+ if (!FIT_VERDICTS.includes(v.verdict)) {
126
+ problems.push(
127
+ `"verdict" was ${JSON.stringify(v.verdict)}; it must be exactly one of ` +
128
+ FIT_VERDICTS.map((x) => `"${x}"`).join(', ') + '.',
129
+ );
130
+ }
131
+
132
+ const ownerGiven = v.owner !== null && v.owner !== undefined && v.owner !== '';
133
+ if (v.verdict === 'fits') {
134
+ if (!ownerGiven) {
135
+ problems.push('"verdict" is "fits" but "owner" is null. A fit with no owner is not a placement.');
136
+ } else if (!agentNames.has(v.owner)) {
137
+ // The single most important check here. An invented subagent reads as
138
+ // advice and is fiction.
139
+ problems.push(
140
+ `"owner" was ${JSON.stringify(v.owner)}, which is not a subagent in the ` +
141
+ 'inventory. Use an exact name from the SUBAGENTS section, or change the ' +
142
+ 'verdict to "needs-new-subagent".',
143
+ );
144
+ }
145
+ } else if (ownerGiven) {
146
+ problems.push(
147
+ `"owner" must be null when the verdict is ${JSON.stringify(v.verdict)}; got ${JSON.stringify(v.owner)}.`,
148
+ );
149
+ }
150
+
151
+ if (typeof v.rationale !== 'string' || v.rationale.trim() === '') {
152
+ problems.push('"rationale" must be a non-empty string.');
153
+ } else if (v.rationale.length > MAX_RATIONALE_CHARS) {
154
+ problems.push(`"rationale" is ${v.rationale.length} chars; keep it under ${MAX_RATIONALE_CHARS}.`);
155
+ }
156
+
157
+ if (!Array.isArray(v.overlaps)) {
158
+ problems.push('"overlaps" must be an array, empty if there are none.');
159
+ } else {
160
+ v.overlaps.forEach((o, i) => {
161
+ if (!o || typeof o !== 'object') {
162
+ problems.push(`overlaps[${i}] must be an object with kind, name and why.`);
163
+ return;
164
+ }
165
+ if (!OVERLAP_KINDS.includes(o.kind)) {
166
+ problems.push(
167
+ `overlaps[${i}].kind was ${JSON.stringify(o.kind)}; must be one of ` +
168
+ OVERLAP_KINDS.map((x) => `"${x}"`).join(', ') + '.',
169
+ );
170
+ return;
171
+ }
172
+ if (typeof o.name !== 'string' || !known.has(`${o.kind}:${o.name}`)) {
173
+ problems.push(
174
+ `overlaps[${i}] names ${JSON.stringify(o.name)} as a ${o.kind}, which is not ` +
175
+ 'in the inventory. Only cite things that are actually listed.',
176
+ );
177
+ }
178
+ if (typeof o.why !== 'string' || o.why.trim() === '') {
179
+ problems.push(`overlaps[${i}].why must say what that thing already covers.`);
180
+ }
181
+ });
182
+ }
183
+
184
+ if (v.verdict === 'redundant' && Array.isArray(v.overlaps) && v.overlaps.length === 0) {
185
+ problems.push('"redundant" with no overlaps is not an answer. Name what already covers this.');
186
+ }
187
+
188
+ return problems;
189
+ }
190
+
191
+ /**
192
+ * Run the review.
193
+ *
194
+ * ONE call, then at most one retry. The retry is fed the exact problems from
195
+ * the first attempt, because "try again" without saying what was wrong mostly
196
+ * produces the same answer at a different temperature, and temperature is 0.
197
+ *
198
+ * Failing loudly after that is deliberate. A fit review that degrades to a
199
+ * default verdict would either spend money it should not have, or refuse a
200
+ * capability the system genuinely needs, and both would look like a decision.
201
+ */
202
+ export async function fitReview(candidate, inventory, { llm, renderInventory, maxAttempts = 2 } = {}) {
203
+ if (!llm) throw new FitReviewError('fitReview needs an llm client');
204
+ if (!renderInventory) throw new FitReviewError('fitReview needs a renderInventory function');
205
+
206
+ const block = renderInventory(inventory);
207
+ let user = buildUserPrompt(candidate, block);
208
+ let lastResponse = null;
209
+ const failures = [];
210
+
211
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
212
+ const res = await llm.complete({ system: SYSTEM, user, max_tokens: 1024 });
213
+ lastResponse = res?.text ?? '';
214
+
215
+ const parsed = extractJson(lastResponse);
216
+ if (!parsed.ok) {
217
+ failures.push(`attempt ${attempt}: ${parsed.error}`);
218
+ user = retryPrompt(candidate, block, [parsed.error]);
219
+ continue;
220
+ }
221
+
222
+ const problems = validateVerdict(parsed.value, inventory);
223
+ if (problems.length === 0) {
224
+ const v = parsed.value;
225
+ return {
226
+ verdict: v.verdict,
227
+ owner: v.verdict === 'fits' ? v.owner : null,
228
+ rationale: v.rationale.trim(),
229
+ overlaps: v.overlaps.map((o) => ({ kind: o.kind, name: o.name, why: o.why.trim() })),
230
+ model: llm.model,
231
+ attempts: attempt,
232
+ };
233
+ }
234
+
235
+ failures.push(`attempt ${attempt}: ${problems.join(' ')}`);
236
+ user = retryPrompt(candidate, block, problems);
237
+ }
238
+
239
+ throw new FitReviewError(
240
+ `fit review failed after ${maxAttempts} attempts:\n ` + failures.join('\n '),
241
+ { attempts: maxAttempts, lastResponse },
242
+ );
243
+ }
244
+
245
+ function retryPrompt(candidate, block, problems) {
246
+ return [
247
+ 'Your previous answer was rejected. Fix exactly these problems and return',
248
+ 'the corrected JSON object, nothing else:',
249
+ ...problems.map((p) => `- ${p}`),
250
+ '',
251
+ buildUserPrompt(candidate, block),
252
+ ].join('\n');
253
+ }
254
+
255
+ export const __testing = { SYSTEM, buildUserPrompt, retryPrompt };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * VENDORED from mcp-scorecard/src/probes/injection_sniff.ts.
3
+ *
4
+ * ── Why a copy and not an import ─────────────────────────────────────────────
5
+ *
6
+ * This repo is the giveaway. When it is extracted to its own public repo the
7
+ * scorecard's source is not there, and the scorecard's build artifact
8
+ * (`runner/lib.mjs`) is gitignored, so neither an import nor a bundled copy
9
+ * survives extraction. A vendored copy does.
10
+ *
11
+ * The cost of a copy is drift, and drift here is silent: a pattern tightened in
12
+ * the scorecard would keep passing here while quietly grading skills by an
13
+ * older rule than MCP servers. So `test/injection.test.mjs` parses the TypeScript
14
+ * source and fails when the two diverge. That test skips itself once the source
15
+ * is gone, which is the only moment a copy is legitimately on its own.
16
+ *
17
+ * If you change a pattern, change it THERE first. This file follows.
18
+ *
19
+ * ── What this covers, and what it does not ───────────────────────────────────
20
+ *
21
+ * The scorecard scans a tool SURFACE: descriptions, titles, schema field
22
+ * descriptions. That code stays there, because only the scorecard has an
23
+ * inventory to scan.
24
+ *
25
+ * This scans PROSE: a SKILL.md, a README, a gist. Same patterns, different
26
+ * input. A skill has no tools to probe, so this is the entire security signal
27
+ * available for a skill or repo candidate, and the report must say so rather
28
+ * than implying a behavioural grade happened.
29
+ */
30
+
31
+ /** Verbatim from the source. Order is preserved so hit order matches. */
32
+ export const INJECTION_PATTERNS = [
33
+ { name: 'ignore-previous-instructions',
34
+ re: /\b(ignore|disregard|forget|override)\b[^.]{0,40}\b(previous|prior|above|earlier|all)\b[^.]{0,20}\b(instruction|prompt|rule|direction|context)/i, severity: 'hard' },
35
+ { name: 'addresses-the-model-directly',
36
+ re: /\b(you are|you must|you should)\b[^.]{0,60}\b(assistant|ai|model|claude|gpt|agent)\b/i, severity: 'hard' },
37
+ { name: 'system-prompt-manipulation',
38
+ // Mentioning a system prompt is not manipulating one. The old form fired on
39
+ // three LLM tools whose entire job is to accept one as a parameter. The
40
+ // signal is a verb that CHANGES or EXTRACTS it, or a literal delimiter,
41
+ // which has no innocent reading inside a tool description.
42
+ re: /\b(ignore|override|replace|reveal|disclose|print|dump|leak|rewrite)\b[^.]{0,25}\b(system prompt|system message)\b|<\s*system\s*>|\[\s*system\s*\]|\b(new|updated) instructions?\b/i, severity: 'hard' },
43
+ { name: 'exfiltration-request',
44
+ // Window narrowed from 40 chars to 12, and bare "conversation" dropped.
45
+ // At 40 this matched "To send for real, sign up for an API key" and "post
46
+ // the reply to the main conversation": in a messaging API those words
47
+ // co-occur constantly with nothing leaving. Exfiltration puts the verb next
48
+ // to its object, "send your api key", "forward the chat history". A whole
49
+ // clause in between is a sentence, not an instruction.
50
+ //
51
+ // Bare "conversation" is gone, but a DEICTIC one stays: "the current
52
+ // conversation", "your conversation", "this conversation" all point at the
53
+ // agent's own context, while Slack's "the main conversation" and a thread
54
+ // reader's "in conversation order" point at something in the product's own
55
+ // domain. That distinction is the whole difference between the planted
56
+ // hostile fixture and two innocent messaging APIs.
57
+ re: /\b(send|post|forward|upload|transmit|leak|exfiltrate)\b[^.]{0,20}\b(api[_ -]?key|token|secret|credential|password|chat history|system prompt|(the current|your|this) conversation)\b/i, severity: 'hard' },
58
+ { name: 'secrecy-instruction',
59
+ re: /\b(do not|don't|never)\b[^.]{0,30}\b(tell|inform|mention|reveal|disclose|show)\b[^.]{0,30}\b(user|human|owner|operator)\b/i, severity: 'hard' },
60
+ { name: 'coerced-tool-preference',
61
+ // "only" dropped. "only use this tool AFTER generating the PDF" is a
62
+ // sequencing constraint and "only use this tool for X" is scoping, both of
63
+ // which are ordinary documentation. Coercion is "always" or "must": it
64
+ // removes the model's judgement rather than describing when the tool
65
+ // applies. Those two still trip, which is the point.
66
+ re: /\b(always|must)\b[^.]{0,30}\b(use|call|prefer|choose)\b[^.]{0,20}\bthis tool\b/i, severity: 'hard' },
67
+ // A role marker at the start of a line used to be enough. It is not: a Python
68
+ // docstring "Args:" block puts "system:" and "user:" on their own indented
69
+ // lines as PARAMETER NAMES, and that shape is everywhere in this ecosystem.
70
+ // Split by how ambiguous the word is.
71
+ { name: 'embedded-role-marker',
72
+ // "assistant:" and "human:" are not plausible parameter names.
73
+ re: /(^|\n)\s*(assistant|human)\s*:\s*\S/i, severity: 'hard' },
74
+ { name: 'embedded-chat-transcript',
75
+ // "system:" and "user:" are, so they need corroboration. Two things supply
76
+ // it: a SECOND turn, which makes it a transcript, or text addressed to the
77
+ // model, which makes it an instruction.
78
+ //
79
+ // Requiring only the second turn was too strict and dropped
80
+ // "system: you are now in developer mode", a single marker that is plainly
81
+ // hostile. The scorecard's own fixture test caught that, which is what a
82
+ // planted hostile server is for.
83
+ re: /(^|\n)\s*(system|user)\s*:[\s\S]{0,400}\n\s*(assistant|human|user|system)\s*:\s*\S|(^|\n)\s*(system|user)\s*:\s*(you\b|ignore\b|disregard\b|forget\b|now\b|from now\b|act as\b|pretend\b|assume\b)/i, severity: 'hard' },
84
+ { name: 'hidden-instruction-delimiter',
85
+ re: /(<\|[a-z_]+\|>|\[INST\]|###\s*(instruction|system))/i, severity: 'hard' },
86
+
87
+ // ── Commercial steering ────────────────────────────────────────────────
88
+ // A tool description is documentation. These constructions are a sales
89
+ // script addressed to the model, billed to the user's context window on
90
+ // every call. Reported, scored, never a hard fail.
91
+ { name: 'imperative-to-model',
92
+ re: /\byou MUST\b/, severity: 'steering' },
93
+ { name: 'always-offer',
94
+ re: /\b(proactively offer|always offer|every single time)\b/i, severity: 'steering' },
95
+ // The bare "use this instead of" caught five vendors routing between two
96
+ // tools in their OWN server: "instead of polling get_workflow_status",
97
+ // "instead of competitive_deep_dive". That is good documentation, not
98
+ // steering. Steering points the model away from someone ELSE, and shows up
99
+ // two ways, which are two patterns because they need different flags:
100
+ { name: 'steer-from-competitor',
101
+ // It redirects what the USER gets told. Case-insensitive.
102
+ re: /\binstead of (telling|pointing|sending|directing|referring) the user\b/i, severity: 'steering' },
103
+ { name: 'names-competing-products',
104
+ // Or it names products. A product reads as Capitalised and usually arrives
105
+ // in a slash list ("Vercel/Netlify/AWS"). This one CANNOT take /i: the
106
+ // capitalisation is the entire signal, and lowercasing it would match
107
+ // "instead of polling/fetching" again.
108
+ re: /\binstead of\b[^.]{0,25}\b[A-Z][a-zA-Z0-9]{2,}\s*\/\s*[A-Z][a-zA-Z0-9]{2,}/, severity: 'steering' },
109
+ { name: 'prescribed-user-line',
110
+ re: /\bthe (right|correct) closing line\b/i, severity: 'steering' },
111
+ { name: 'internalize-directive',
112
+ re: /\binternali[sz]e (it|this)\b/i, severity: 'steering' },
113
+ { name: 'preempt-the-user',
114
+ re: /\bdo(?: not|n't) wait for the user\b|\beven if the user did not ask\b/i, severity: 'steering' },
115
+ ];
116
+
117
+ /**
118
+ * A window around the match, so a reviewer can check the accusation.
119
+ *
120
+ * Verbatim from the source. This is a SUMMARY field. The untouched original
121
+ * text always remains available to the caller.
122
+ */
123
+ export function excerptAround(text, index, len) {
124
+ const start = Math.max(0, index - 30);
125
+ const end = Math.min(text.length, index + len + 30);
126
+ return (start > 0 ? '...' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() +
127
+ (end < text.length ? '...' : '');
128
+ }
129
+
130
+ /**
131
+ * Scan one block of prose.
132
+ *
133
+ * Returns every pattern that fired, each with the text that fired it, verbatim.
134
+ * An accusation that cannot be checked is not evidence.
135
+ *
136
+ * Deliberately conservative, for the same reason the source is: a false hit is
137
+ * a public claim about somebody else's work.
138
+ */
139
+ export function scanText(text, location = 'instructions') {
140
+ const hits = [];
141
+ if (typeof text !== 'string' || !text) return hits;
142
+ for (const p of INJECTION_PATTERNS) {
143
+ const m = p.re.exec(text);
144
+ if (m) {
145
+ hits.push({
146
+ location,
147
+ pattern: p.name,
148
+ severity: p.severity,
149
+ excerpt: excerptAround(text, m.index, m[0].length),
150
+ });
151
+ }
152
+ }
153
+ return hits;
154
+ }
155
+
156
+ /**
157
+ * The same shape the scorecard's probe returns, so a note renderer does not
158
+ * need to care which path produced it.
159
+ *
160
+ * `hard_fail` is set for exactly the same reason it is there: injection-shaped
161
+ * content is disqualifying on its own, whatever else is true.
162
+ */
163
+ export function sniffInstructions(text, location = 'instructions') {
164
+ const hits = scanText(text, location);
165
+ const hard = hits.filter((h) => h.severity === 'hard');
166
+ const steering = hits.filter((h) => h.severity === 'steering');
167
+ return {
168
+ scanned_chars: typeof text === 'string' ? text.length : 0,
169
+ hits,
170
+ hard: hard.length,
171
+ steering: steering.length,
172
+ // Same severity rule as the source: only an ATTACK caps a grade. A
173
+ // description that advertises through the agent is reported and scored,
174
+ // never treated as a jailbreak attempt.
175
+ score: hard.length ? 0 : (steering.length ? Math.max(25, 100 - steering.length * 15) : 100),
176
+ failure_modes: [
177
+ ...hard.map(
178
+ (h) => 'injection-shaped content in ' + h.location + ' (' + h.pattern + '): "' + h.excerpt + '"',
179
+ ),
180
+ ...steering.map(
181
+ (h) => 'commercial steering in ' + h.location + ' (' + h.pattern + '): "' + h.excerpt + '"',
182
+ ),
183
+ ],
184
+ hard_fail: hard.length
185
+ ? 'injection-shaped content in ' + hard.length + ' location(s): ' +
186
+ [...new Set(hard.map((h) => h.location))].join(', ')
187
+ : undefined,
188
+ };
189
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Fetch the instruction text of a skill or a repo.
3
+ *
4
+ * This is the only other file allowed to touch the network, and it is
5
+ * deliberately dull: one GET, a size cap, no redirect chasing beyond what fetch
6
+ * does on its own, no execution of anything.
7
+ *
8
+ * ── The text is DATA ─────────────────────────────────────────────────────────
9
+ *
10
+ * What comes back is somebody else's instruction text, which is precisely the
11
+ * kind of content that might be trying to talk to a reading agent. Nothing here
12
+ * interprets it, summarises it, or passes it to a model. It goes to the pattern
13
+ * scanner and into the note as a quoted excerpt. Handing it to an LLM for a
14
+ * "what does this skill do" summary would be reading the letter, which is the
15
+ * thing this project exists to refuse.
16
+ *
17
+ * ── Why the fit review still sees the candidate's own words ──────────────────
18
+ *
19
+ * The fit review gets the candidate's SELF-DESCRIPTION, which is short and
20
+ * clearly labelled as a claim. That is different from feeding a model an entire
21
+ * untrusted README.
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+
26
+ /** A README past this is not being read by a human either. */
27
+ export const MAX_INSTRUCTION_BYTES = 200_000;
28
+
29
+ export class InstructionFetchError extends Error {}
30
+
31
+ /**
32
+ * Turn a candidate into the url that actually serves its text.
33
+ *
34
+ * github.com/owner/repo -> the raw README on the default branch
35
+ * github.com/owner/repo/blob/.. -> the raw file
36
+ * gist.github.com/u/id -> the gist's raw endpoint
37
+ * anything ending .md -> itself
38
+ */
39
+ export function instructionUrl(candidate) {
40
+ const raw = candidate.id;
41
+ if (!/^https?:\/\//i.test(raw)) return { kind: 'file', target: raw };
42
+
43
+ const u = new URL(raw);
44
+ const host = u.hostname.toLowerCase();
45
+ const segments = u.pathname.split('/').filter(Boolean);
46
+
47
+ if (host === 'github.com' || host === 'www.github.com') {
48
+ if (segments.length >= 4 && (segments[2] === 'blob' || segments[2] === 'raw')) {
49
+ const [owner, repo, , ...rest] = segments;
50
+ return {
51
+ kind: 'http',
52
+ target: `https://raw.githubusercontent.com/${owner}/${repo}/${rest.join('/')}`,
53
+ };
54
+ }
55
+ const [owner, repo] = segments;
56
+ // Branch name is not knowable without an API call, and an API call needs a
57
+ // token. Try the two names that cover essentially everything, in order.
58
+ return {
59
+ kind: 'http',
60
+ target: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`,
61
+ alternates: [
62
+ `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
63
+ `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
64
+ ],
65
+ };
66
+ }
67
+
68
+ if (host === 'gist.github.com') {
69
+ return { kind: 'http', target: raw.replace(/\/$/, '') + '/raw' };
70
+ }
71
+
72
+ return { kind: 'http', target: raw };
73
+ }
74
+
75
+ /**
76
+ * @param {object} candidate from detectCandidate
77
+ * @param {object} [deps] `{ fetch, readFile }` injectable for tests
78
+ * @returns {Promise<{ text, source, bytes, truncated }>}
79
+ */
80
+ export async function fetchInstructions(candidate, { fetch: f = fetch, readFile = readFileSync, timeoutMs = 20_000 } = {}) {
81
+ const plan = instructionUrl(candidate);
82
+
83
+ if (plan.kind === 'file') {
84
+ let text;
85
+ try {
86
+ text = readFile(plan.target, 'utf8');
87
+ } catch (e) {
88
+ throw new InstructionFetchError(`could not read ${plan.target}: ${e.message}`);
89
+ }
90
+ return capped(text, plan.target);
91
+ }
92
+
93
+ const targets = [plan.target, ...(plan.alternates ?? [])];
94
+ const failures = [];
95
+
96
+ for (const target of targets) {
97
+ const ac = new AbortController();
98
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
99
+ let res;
100
+ try {
101
+ res = await f(target, { signal: ac.signal, redirect: 'follow' });
102
+ } catch (e) {
103
+ failures.push(`${target}: ${e.message}`);
104
+ continue;
105
+ } finally {
106
+ clearTimeout(timer);
107
+ }
108
+
109
+ if (!res.ok) {
110
+ failures.push(`${target}: HTTP ${res.status}`);
111
+ continue;
112
+ }
113
+ const text = await res.text();
114
+ return capped(text, target);
115
+ }
116
+
117
+ throw new InstructionFetchError(
118
+ 'could not fetch instruction text.\n ' + failures.join('\n '),
119
+ );
120
+ }
121
+
122
+ function capped(text, source) {
123
+ const bytes = Buffer.byteLength(text, 'utf8');
124
+ const truncated = bytes > MAX_INSTRUCTION_BYTES;
125
+ return {
126
+ // Truncation is reported rather than hidden. A scanner that silently read
127
+ // the first 200 KB of a 900 KB file would report "clean" about 22% of a
128
+ // document.
129
+ text: truncated ? text.slice(0, MAX_INSTRUCTION_BYTES) : text,
130
+ source,
131
+ bytes,
132
+ truncated,
133
+ };
134
+ }