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.
- package/.claude-plugin/marketplace.json +17 -0
- package/LICENSE +21 -0
- package/README.md +951 -0
- package/WALKTHROUGH.md +224 -0
- package/doorman/.claude/hooks/mcp-gate.sh +205 -0
- package/doorman/.claude/settings.json +16 -0
- package/doorman/.claude-plugin/plugin.json +22 -0
- package/doorman/.mcp.json +24 -0
- package/doorman/README.md +259 -0
- package/doorman/agents/doorman.md +104 -0
- package/doorman/cli/agents.mjs +128 -0
- package/doorman/cli/allow.mjs +128 -0
- package/doorman/cli/cost.mjs +119 -0
- package/doorman/cli/discover.mjs +265 -0
- package/doorman/cli/doctor.mjs +282 -0
- package/doorman/cli/doorman.mjs +345 -0
- package/doorman/cli/eval.mjs +320 -0
- package/doorman/cli/harness.mjs +179 -0
- package/doorman/cli/install.mjs +175 -0
- package/doorman/cli/needs.mjs +116 -0
- package/doorman/cli/report.mjs +89 -0
- package/doorman/cli/sandbox.mjs +177 -0
- package/doorman/cli/task.mjs +239 -0
- package/doorman/cli/verdict.mjs +199 -0
- package/doorman/cli/watch.mjs +218 -0
- package/doorman/commands/doorman.md +116 -0
- package/doorman/commands/vet.md +69 -0
- package/doorman/hooks/hooks.json +30 -0
- package/doorman/install.sh +186 -0
- package/doorman/package.json +38 -0
- package/doorman/recipes/README.md +36 -0
- package/doorman/recipes/deepwiki.md +10 -0
- package/doorman/recipes/planted-bad.md +27 -0
- package/doorman/recipes/scorecard.md +10 -0
- package/doorman/registry/allowlist.json +37 -0
- package/doorman/registry/denylist.json +23 -0
- package/doorman/registry/ledger.jsonl +1 -0
- package/doorman/scripts/poller.mjs +292 -0
- package/doorman/scripts/resolve-cli.sh +58 -0
- package/doorman/scripts/vet.mjs +190 -0
- package/doorman/skills/doorman-guide/SKILL.md +69 -0
- package/doorman/src/budget.mjs +236 -0
- package/doorman/src/candidate.mjs +132 -0
- package/doorman/src/fit-review.mjs +255 -0
- package/doorman/src/injection.mjs +189 -0
- package/doorman/src/instructions.mjs +134 -0
- package/doorman/src/inventory.mjs +411 -0
- package/doorman/src/llm.mjs +87 -0
- package/doorman/src/needs.mjs +491 -0
- package/doorman/src/note.mjs +213 -0
- package/doorman/src/reviews.mjs +120 -0
- package/doorman/src/scorecard.mjs +123 -0
- package/doorman/src/vet.mjs +174 -0
- package/package.json +54 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doorman watch` - the private half of a subscription.
|
|
3
|
+
*
|
|
4
|
+
* The feed is shared: a candidate is graded once and everyone reads that grade
|
|
5
|
+
* for nothing. THIS half is private, and stays that way. It fetches the feed,
|
|
6
|
+
* then decides which rows matter by reading YOUR inventory on YOUR machine.
|
|
7
|
+
* Your agent roster, your installed servers and your allowlist are never sent
|
|
8
|
+
* anywhere. The only request this makes is a GET for the feed, and that GET
|
|
9
|
+
* says nothing about you.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS NOT: it is not the fit review. `fitReview()` reads a candidate
|
|
12
|
+
* against your build with a model and returns one of `redundant`, `fits`,
|
|
13
|
+
* `needs-new-subagent`, `out-of-scope`. That needs a key and costs tokens.
|
|
14
|
+
* What runs here is a MECHANICAL overlap check: string matching on hosts and
|
|
15
|
+
* urls. It can tell you "you already have this one" and "this one hard-failed".
|
|
16
|
+
* It cannot tell you whether a server you do not have would help, and it must
|
|
17
|
+
* never claim to, so it never emits the word `fits`.
|
|
18
|
+
*
|
|
19
|
+
* Exit codes follow the rest of the CLI: 0 it ran, 2 usage, 3 could not
|
|
20
|
+
* measure, 1 it broke.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
24
|
+
import { join, dirname } from 'node:path';
|
|
25
|
+
import { inventoryFor } from '../src/inventory.mjs';
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_API = 'https://scorecard.wanessalabs.com';
|
|
28
|
+
export const DEFAULT_STATE = join('.doorman', 'watch.json');
|
|
29
|
+
|
|
30
|
+
/** What the mechanical check can honestly say. Deliberately not FIT_VERDICTS. */
|
|
31
|
+
export const WATCH_VERDICTS = ['already-installed', 'blocked', 'unreviewed', 'skipped'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a server url for comparison.
|
|
35
|
+
*
|
|
36
|
+
* Host plus path, lowercased, trailing slash and default ports removed. Host
|
|
37
|
+
* alone is too coarse: openzeppelin publishes four different servers under one
|
|
38
|
+
* host and treating them as one would report three of them as already
|
|
39
|
+
* installed on the strength of the fourth.
|
|
40
|
+
*/
|
|
41
|
+
export function serverKey(url) {
|
|
42
|
+
if (!url) return null;
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(String(url));
|
|
45
|
+
const path = u.pathname.replace(/\/+$/, '');
|
|
46
|
+
return (u.hostname.toLowerCase() + path.toLowerCase()) || u.hostname.toLowerCase();
|
|
47
|
+
} catch {
|
|
48
|
+
return String(url).trim().toLowerCase().replace(/\/+$/, '') || null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every server this build already knows about, from any source. */
|
|
53
|
+
export function installedKeys(inv) {
|
|
54
|
+
const keys = new Set();
|
|
55
|
+
for (const s of inv.mcpServers ?? []) {
|
|
56
|
+
const k = serverKey(s.url);
|
|
57
|
+
if (k) keys.add(k);
|
|
58
|
+
}
|
|
59
|
+
for (const a of inv.allowlisted ?? []) {
|
|
60
|
+
const k = serverKey(a.url);
|
|
61
|
+
if (k) keys.add(k);
|
|
62
|
+
}
|
|
63
|
+
return keys;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Classify one feed row against one inventory. Pure, so it is testable without
|
|
68
|
+
* a network or a filesystem.
|
|
69
|
+
*/
|
|
70
|
+
export function classify(candidate, keys) {
|
|
71
|
+
if (candidate.is_fixture || candidate.self_graded) {
|
|
72
|
+
return { verdict: 'skipped', why: candidate.is_fixture
|
|
73
|
+
? 'a deliberately hostile test fixture, not a candidate'
|
|
74
|
+
: 'graded by the operator of the feed, about themselves' };
|
|
75
|
+
}
|
|
76
|
+
const key = serverKey(candidate.server_url);
|
|
77
|
+
if (key && keys.has(key)) {
|
|
78
|
+
return { verdict: 'already-installed', why: 'this build already has it' };
|
|
79
|
+
}
|
|
80
|
+
if (candidate.hard_fail) {
|
|
81
|
+
return { verdict: 'blocked', why: candidate.hard_fail };
|
|
82
|
+
}
|
|
83
|
+
if (candidate.grade === 'F') {
|
|
84
|
+
return { verdict: 'blocked', why: 'graded F' };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
verdict: 'unreviewed',
|
|
88
|
+
// Deliberately not "fits". Nothing here read the candidate against this
|
|
89
|
+
// build; it only established that the build does not already have it.
|
|
90
|
+
why: 'new to this build, and it passed the static layer',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readState(file) {
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
97
|
+
return { since: typeof raw.since === 'string' ? raw.since : null, seen: raw.seen ?? 0 };
|
|
98
|
+
} catch {
|
|
99
|
+
return { since: null, seen: 0 };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeState(file, state) {
|
|
104
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
105
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {object} opts
|
|
110
|
+
* @param {string} opts.root project to read the inventory from
|
|
111
|
+
* @param {string} opts.api scorecard base url
|
|
112
|
+
* @param {string|null} opts.since cursor; null for the first run
|
|
113
|
+
* @param {number} opts.limit
|
|
114
|
+
* @param {typeof fetch} [opts.fetchImpl] injected in tests
|
|
115
|
+
*/
|
|
116
|
+
export async function watch({ root, api, since, limit, fetchImpl = fetch }) {
|
|
117
|
+
const inv = inventoryFor(root);
|
|
118
|
+
const keys = installedKeys(inv);
|
|
119
|
+
|
|
120
|
+
const url = new URL('/feed', api);
|
|
121
|
+
if (since) url.searchParams.set('since', since);
|
|
122
|
+
url.searchParams.set('limit', String(limit));
|
|
123
|
+
|
|
124
|
+
const res = await fetchImpl(url.toString(), { headers: { accept: 'application/json' } });
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const err = new Error(`feed returned HTTP ${res.status}`);
|
|
127
|
+
err.code = 3; // could not measure, not broken
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const feed = await res.json();
|
|
131
|
+
|
|
132
|
+
const rows = (feed.candidates ?? []).map((c) => ({ ...c, ...classify(c, keys) }));
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
api,
|
|
136
|
+
since_used: since ?? null,
|
|
137
|
+
next_since: feed.next_since ?? null,
|
|
138
|
+
inventory: {
|
|
139
|
+
root: inv.root ?? root,
|
|
140
|
+
// Counts are reported ALONGSIDE what could be read, never alone. "0
|
|
141
|
+
// agents" is a fact in a Claude Code project and an artefact of asking
|
|
142
|
+
// the wrong question in a Cursor one, and a caller has to be able to tell
|
|
143
|
+
// those apart before trusting "new to this build".
|
|
144
|
+
coverage: inv.coverage ?? { agents: 'unknown', skills: 'unknown', mcpServers: 'unknown' },
|
|
145
|
+
agents: (inv.agents ?? []).length,
|
|
146
|
+
skills: (inv.skills ?? []).length,
|
|
147
|
+
mcp_servers: (inv.mcpServers ?? []).length,
|
|
148
|
+
known_keys: keys.size,
|
|
149
|
+
notes: inv.notes ?? [],
|
|
150
|
+
},
|
|
151
|
+
counts: WATCH_VERDICTS.reduce((acc, v) => {
|
|
152
|
+
acc[v] = rows.filter((r) => r.verdict === v).length;
|
|
153
|
+
return acc;
|
|
154
|
+
}, {}),
|
|
155
|
+
candidates: rows,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function renderWatch(r) {
|
|
160
|
+
const out = [];
|
|
161
|
+
out.push(`doorman watch ${r.api}/feed`);
|
|
162
|
+
const cov = r.inventory.coverage ?? {};
|
|
163
|
+
const n = (count, seen) => (seen === 'read' ? String(count) : 'unknown');
|
|
164
|
+
out.push(` inventory: ${n(r.inventory.agents, cov.agents)} agents, ` +
|
|
165
|
+
`${n(r.inventory.skills, cov.skills)} skills, ` +
|
|
166
|
+
`${n(r.inventory.mcp_servers, cov.mcpServers)} mcp servers, ` +
|
|
167
|
+
`${r.inventory.known_keys} known urls`);
|
|
168
|
+
if (cov.mcpServers !== 'read') {
|
|
169
|
+
out.push(' !! No MCP config was readable here, so "new to this build" below');
|
|
170
|
+
out.push(' means "not found in a config I could read", which is a weaker');
|
|
171
|
+
out.push(' claim. Everything may already be installed.');
|
|
172
|
+
}
|
|
173
|
+
for (const note of r.inventory.notes ?? []) out.push(` note: ${note}`);
|
|
174
|
+
out.push(` cursor: ${r.since_used ?? '(first run: everything graded so far)'}`);
|
|
175
|
+
out.push('');
|
|
176
|
+
|
|
177
|
+
if (!r.candidates.length) {
|
|
178
|
+
out.push('Nothing new since the last run.');
|
|
179
|
+
out.push('Your cursor is unchanged, so nothing has been missed.');
|
|
180
|
+
return out.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const order = ['unreviewed', 'blocked', 'already-installed', 'skipped'];
|
|
184
|
+
const heading = {
|
|
185
|
+
'unreviewed': 'NEW TO THIS BUILD (not yet reviewed against it)',
|
|
186
|
+
'blocked': 'BLOCKED (do not adopt)',
|
|
187
|
+
'already-installed': 'ALREADY INSTALLED',
|
|
188
|
+
'skipped': 'SKIPPED',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
for (const v of order) {
|
|
192
|
+
const group = r.candidates.filter((c) => c.verdict === v);
|
|
193
|
+
if (!group.length) continue;
|
|
194
|
+
out.push(`${heading[v]} (${group.length})`);
|
|
195
|
+
for (const c of group) {
|
|
196
|
+
const score = c.score === null || c.score === undefined ? ' n/a' : String(c.score).padStart(6);
|
|
197
|
+
out.push(` ${String(c.grade ?? '?').padEnd(2)} ${score} ${c.server_url}`);
|
|
198
|
+
out.push(` ${c.why}`);
|
|
199
|
+
if (v === 'unreviewed') {
|
|
200
|
+
const unmeasured = ['behavioral_pct', 'guidance_pct']
|
|
201
|
+
.filter((k) => c.layers?.[k] === null || c.layers?.[k] === undefined);
|
|
202
|
+
if (unmeasured.length) {
|
|
203
|
+
out.push(` ${unmeasured.length} of 3 layers NOT measured on this grade`);
|
|
204
|
+
}
|
|
205
|
+
out.push(` tape: ${c.transcripts}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
out.push('');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
out.push('This was a mechanical overlap check, not a fit review. It knows what');
|
|
212
|
+
out.push('you already have. It does not know whether any of these would help,');
|
|
213
|
+
out.push('and it has not spent anything to find out.');
|
|
214
|
+
out.push('');
|
|
215
|
+
out.push(' doorman report <url> what it implements, free');
|
|
216
|
+
out.push(' doorman eval <url> --task <file> whether it makes YOUR agent better');
|
|
217
|
+
return out.join('\n');
|
|
218
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The doorman front door. With no arguments it reports what is gating this build and what to do next. Usage: /doorman [allow <server> | check <url> | needs | status]
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Doorman: $ARGUMENTS
|
|
6
|
+
|
|
7
|
+
You are the front door for clembot-doorman. `/vet` is the deep path for grading
|
|
8
|
+
a candidate; this is the one somebody reaches for when they do not yet know
|
|
9
|
+
which command they want, and most often when the gate has just blocked them.
|
|
10
|
+
|
|
11
|
+
## Preflight, before any branch below
|
|
12
|
+
|
|
13
|
+
Every branch here shells out to the `doorman` CLI, and **installing the plugin
|
|
14
|
+
does not put `doorman` on PATH**. It does not need to: the installed plugin
|
|
15
|
+
directory carries its own copy. Resolve it once, first, and use `$D` everywhere
|
|
16
|
+
below instead of a bare `doorman`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
D="$(bash "${CLAUDE_PLUGIN_ROOT:-.}/scripts/resolve-cli.sh")" && echo "$D"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That prints either a global `doorman`, or `node <plugin>/cli/doorman.mjs`,
|
|
23
|
+
whichever exists. A global install wins, because the user chose it and it may
|
|
24
|
+
be newer than the plugin cache.
|
|
25
|
+
|
|
26
|
+
If it prints `NOT_FOUND`, stop and tell the user this, then end the turn:
|
|
27
|
+
|
|
28
|
+
> The doorman CLI could not be located, from PATH or from the installed plugin.
|
|
29
|
+
> Reinstall the plugin, or install the CLI directly:
|
|
30
|
+
>
|
|
31
|
+
> ```bash
|
|
32
|
+
> git clone https://github.com/clemenswan/clembot-doorman
|
|
33
|
+
> npm i -g ./clembot-doorman
|
|
34
|
+
> ```
|
|
35
|
+
>
|
|
36
|
+
> The gate is still running either way. It is pure bash and depends on none of
|
|
37
|
+
> this, so nothing is unprotected. You just cannot inspect or change the trust
|
|
38
|
+
> list from here yet.
|
|
39
|
+
|
|
40
|
+
Do **not** try to work around a missing CLI by reading or editing registry
|
|
41
|
+
files by hand. The registry carries provenance fields (`basis`, `grade`,
|
|
42
|
+
`audit_id`) that `doorman allow` writes correctly and a hand-edit loses, and a
|
|
43
|
+
hand-written entry is indistinguishable from a measured one afterwards.
|
|
44
|
+
|
|
45
|
+
**Pick the branch from $ARGUMENTS. If it is empty, run `status`.**
|
|
46
|
+
|
|
47
|
+
## status (the default)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
"$D" doctor .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Report, in this order, and stop after it:
|
|
54
|
+
|
|
55
|
+
1. Whether the gate is running, and **from where**. A user-scope plugin gates
|
|
56
|
+
every project on the machine; a project install gates one. Those have
|
|
57
|
+
different blast radius and the user needs to know which they have.
|
|
58
|
+
2. Which trust list is actually in force. The order is `$DOORMAN_REGISTRY_DIR`,
|
|
59
|
+
then `<project>/registry`, then `~/.doorman/registry`, then the plugin
|
|
60
|
+
default. Name the one being read, not all four.
|
|
61
|
+
3. How many servers it trusts, and how many of those were **graded** versus
|
|
62
|
+
allowed by the operator. That ratio is the honest summary of the setup, and
|
|
63
|
+
`basis` on each entry is where it comes from.
|
|
64
|
+
|
|
65
|
+
Then offer the next step that fits what you found, one line, no menu.
|
|
66
|
+
|
|
67
|
+
## allow <server>
|
|
68
|
+
|
|
69
|
+
The user has been blocked and wants the server through. They have a NAME, not
|
|
70
|
+
a url, because `mcp__<server>__<tool>` is all the gate can see.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
"$D" allow <server> # ~/.doorman/registry, every project
|
|
74
|
+
"$D" allow <server> --scope project # ./registry, this project only
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Say what this is, every time, in one sentence:** it records a decision, not a
|
|
78
|
+
measurement. The entry is written with `basis: operator` and a null grade
|
|
79
|
+
because nothing graded the server. Never describe an allowed server as safe,
|
|
80
|
+
vetted, or approved. It is permitted.
|
|
81
|
+
|
|
82
|
+
Then offer the free measurement, once: `"$D" report <url>` needs no key.
|
|
83
|
+
|
|
84
|
+
If it refuses because the server is on a denylist, do NOT work around it. Read
|
|
85
|
+
the recorded reason back to the user and stop. A denial was earned by an audit.
|
|
86
|
+
|
|
87
|
+
## check <url>
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
"$D" report <url>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Free, keyless, no model. Report the score, whether anything hard-failed, and
|
|
94
|
+
the worst findings. Say plainly that this is the static layer: it describes
|
|
95
|
+
what the server implements and what its tool descriptions say to a model. It
|
|
96
|
+
does not say whether an agent can use it well, which is `doorman eval`.
|
|
97
|
+
|
|
98
|
+
## needs
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
"$D" needs .
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Read this build's own prompts and report unmet capabilities against the graded
|
|
105
|
+
feed. Two things must survive into your summary: a match is `worth-measuring`
|
|
106
|
+
and never `fits`, and a `GAP` means nothing graded covers that need, which is a
|
|
107
|
+
hole in the catalogue rather than a fact about the user's build.
|
|
108
|
+
|
|
109
|
+
## Rules that outrank anything above
|
|
110
|
+
|
|
111
|
+
- **Never invent a grade, a score, or an audit id.** If it was not measured it
|
|
112
|
+
is null, and you say so.
|
|
113
|
+
- **Never edit a registry by hand when `doorman allow` would do it.** The
|
|
114
|
+
command writes the provenance fields; hand-editing loses them.
|
|
115
|
+
- **Never suggest disabling the gate to get past a block.** Allow the specific
|
|
116
|
+
server, or leave it blocked.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Vet a candidate before an agent gets it. Fit review first (free), paid grade only if the system actually needs it. Usage: /vet <url|repo|path> [what you need it for]
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Vet: $ARGUMENTS
|
|
6
|
+
|
|
7
|
+
**Three questions, in this order, and the order is the feature.** First: does
|
|
8
|
+
this system need it at all? Second: can it afford to find out? Both are free and
|
|
9
|
+
local. Only if both pass does anything get paid for.
|
|
10
|
+
|
|
11
|
+
## Steps
|
|
12
|
+
|
|
13
|
+
1. **Run it.** The flow is a program, not instructions, so the ordering cannot
|
|
14
|
+
drift and the "no charge on a redundant candidate" guarantee is testable:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd doorman && node scripts/vet.mjs <candidate> --needed-for "<purpose>"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Add `--type mcp-server|skill|repo` if detection guesses wrong, and
|
|
21
|
+
`--dry-run` to run fit only and never construct a scorecard client.
|
|
22
|
+
`--max-usdc` and `--max-usdc-day` override the spend caps for this run.
|
|
23
|
+
|
|
24
|
+
2. **Read the FIT block back to the human first**, before any grade. On
|
|
25
|
+
`redundant` or `out-of-scope` the run stops there having spent nothing, and
|
|
26
|
+
the overlaps are the answer: name what already covers the need. Do not
|
|
27
|
+
suggest grading it anyway.
|
|
28
|
+
|
|
29
|
+
3. **Read the budget line too.** On `REFUSED the spend cap said no`, report
|
|
30
|
+
the cap and what it would take to raise it. Do not raise it yourself, and do
|
|
31
|
+
not suggest a way around it: a cap the agent edits when it is inconvenient
|
|
32
|
+
is not a cap. On `REFUSED could not read the price`, say that the price was
|
|
33
|
+
unreadable and that an unknown price is not a free one. Never guess a price.
|
|
34
|
+
|
|
35
|
+
4. **On `fits` or `needs-new-subagent`**, the paid grade runs only for an MCP
|
|
36
|
+
server. A skill or a repo gets a fit review plus an injection scan of its
|
|
37
|
+
instruction text and reports `behavioral grade: n/a - no tools to probe`.
|
|
38
|
+
Never describe that as a grade, and never send one to the scorecard.
|
|
39
|
+
|
|
40
|
+
5. **A review note is written to the vault**, status `pending`. Say where it
|
|
41
|
+
landed. If it fell back to `registry/reviews/`, say that too and say why.
|
|
42
|
+
|
|
43
|
+
6. **Then, and only then, propose the registry change as a diff:**
|
|
44
|
+
|
|
45
|
+
- Band A or B, no hard fail -> propose adding to `registry/allowlist.json`
|
|
46
|
+
- Band C -> allowlist PLUS the drafted recipe to
|
|
47
|
+
`recipes/<server>.md`, because a C means an
|
|
48
|
+
agent needs the recipe to succeed
|
|
49
|
+
- Band F, or any hard fail -> propose `registry/denylist.json`
|
|
50
|
+
|
|
51
|
+
Show the diff. Do not apply it. The human decides what the gate trusts.
|
|
52
|
+
|
|
53
|
+
## Never
|
|
54
|
+
|
|
55
|
+
- **Never let a human's approval override a hard fail.** The poller refuses it
|
|
56
|
+
and logs the refusal into the note. Do not work around that by editing the
|
|
57
|
+
registry by hand.
|
|
58
|
+
- **Never flip a note's `status` yourself.** `pending` is the resting state; a
|
|
59
|
+
person changes it. Nothing in this flow auto-approves.
|
|
60
|
+
- **Never claim a grade for a skill or a repo.** They have no tools to drive, so
|
|
61
|
+
a behavioural grade cannot exist for them.
|
|
62
|
+
- **Never advise bypassing the `mcp-gate` hook.**
|
|
63
|
+
- **Never raise a spend cap on the human's behalf**, and never suggest
|
|
64
|
+
`--max-usdc` as a way past a refusal you triggered. Report the number and let
|
|
65
|
+
them decide.
|
|
66
|
+
- **Never state a price the service did not give you.** If `GET /price` could
|
|
67
|
+
not be read, the price is unknown, and unknown is not zero.
|
|
68
|
+
- Never edit `registry/allowlist.json` without showing the diff and getting an
|
|
69
|
+
explicit yes.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": [
|
|
3
|
+
"The plugin's copy of the hook wiring. The file it points at is the SAME",
|
|
4
|
+
"mcp-gate.sh that install.sh copies, not a second copy: two gates would",
|
|
5
|
+
"drift, and the one that drifted would be the one still passing its tests.",
|
|
6
|
+
"",
|
|
7
|
+
"${CLAUDE_PLUGIN_ROOT} is the plugin's own directory. The non-plugin",
|
|
8
|
+
"install writes $CLAUDE_PROJECT_DIR/.claude/hooks/mcp-gate.sh instead, and",
|
|
9
|
+
"the gate resolves its registry the same way in both cases: the project's",
|
|
10
|
+
"trust list first, the shipped default only if the project has none.",
|
|
11
|
+
"",
|
|
12
|
+
"Timeout matches .claude/settings.json. The gate does no network and no",
|
|
13
|
+
"subprocess, so 5s is generous; it exists so a wedged hook fails rather",
|
|
14
|
+
"than hanging every tool call."
|
|
15
|
+
],
|
|
16
|
+
"hooks": {
|
|
17
|
+
"PreToolUse": [
|
|
18
|
+
{
|
|
19
|
+
"matcher": "mcp__.*",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/mcp-gate.sh",
|
|
24
|
+
"timeout": 5
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Install the doorman into a project, then PROVE it works there.
|
|
4
|
+
#
|
|
5
|
+
# The gate has its own test suite, and that suite proves the gate works in this
|
|
6
|
+
# repo. It says nothing about whether the copy sitting in your project resolves
|
|
7
|
+
# its registry, reads its lists, and blocks. A security control that was
|
|
8
|
+
# installed slightly wrong looks exactly like one that is working: quiet.
|
|
9
|
+
#
|
|
10
|
+
# So this script ends by driving the INSTALLED gate at its INSTALLED path and
|
|
11
|
+
# checking all four outcomes. If any of them is wrong it exits non-zero and says
|
|
12
|
+
# the install is not safe to rely on, rather than printing a tick.
|
|
13
|
+
#
|
|
14
|
+
# What it deliberately does NOT do:
|
|
15
|
+
#
|
|
16
|
+
# - It never edits your settings.json. Merging JSON in bash without jq is how
|
|
17
|
+
# a config gets silently clobbered, and the gate is dependency-free on
|
|
18
|
+
# purpose. It detects whether the hook is wired and prints the exact block.
|
|
19
|
+
# - It never overwrites an existing registry. That file is your trust list,
|
|
20
|
+
# built by hand over time, and replacing it with our three entries would be
|
|
21
|
+
# the most destructive thing this script could do.
|
|
22
|
+
#
|
|
23
|
+
# Usage:
|
|
24
|
+
# ./install.sh <path-to-your-project>
|
|
25
|
+
# ./install.sh <path-to-your-project> --dry-run
|
|
26
|
+
#
|
|
27
|
+
# Requires: bash. Nothing else. No node, no jq, no network.
|
|
28
|
+
|
|
29
|
+
set -uo pipefail
|
|
30
|
+
|
|
31
|
+
SRC="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
TARGET="${1:-}"
|
|
33
|
+
DRY=0
|
|
34
|
+
for a in "$@"; do [ "$a" = "--dry-run" ] && DRY=1; done
|
|
35
|
+
|
|
36
|
+
say() { printf '%s\n' "$*"; }
|
|
37
|
+
warn() { printf ' ! %s\n' "$*" >&2; }
|
|
38
|
+
die() { printf '\nerror: %s\n\n' "$*" >&2; exit 1; }
|
|
39
|
+
|
|
40
|
+
if [ -z "$TARGET" ] || [ "$TARGET" = "--dry-run" ]; then
|
|
41
|
+
die "usage: ./install.sh <path-to-your-project> [--dry-run]"
|
|
42
|
+
fi
|
|
43
|
+
[ -d "$TARGET" ] || die "no such directory: $TARGET"
|
|
44
|
+
TARGET="$(cd -- "$TARGET" && pwd)"
|
|
45
|
+
[ "$TARGET" = "$SRC" ] && die "that is this repo. Point it at the project you want to protect."
|
|
46
|
+
|
|
47
|
+
say ""
|
|
48
|
+
say " doorman -> $TARGET"
|
|
49
|
+
[ "$DRY" = 1 ] && say " (dry run: nothing will be written)"
|
|
50
|
+
say ""
|
|
51
|
+
|
|
52
|
+
# ── 1. Copy ──────────────────────────────────────────────────────────────────
|
|
53
|
+
#
|
|
54
|
+
# Four things, and only one of them is allowed to be skipped: the registry,
|
|
55
|
+
# because an existing one is yours.
|
|
56
|
+
|
|
57
|
+
copy() { # copy <relative-src> <relative-dest-dir>
|
|
58
|
+
local from="$SRC/$1" to="$TARGET/$2"
|
|
59
|
+
if [ "$DRY" = 1 ]; then
|
|
60
|
+
say " would copy $1 -> $2/"
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
mkdir -p "$to" || die "cannot create $to"
|
|
64
|
+
cp "$from" "$to/" || die "cannot copy $1"
|
|
65
|
+
say " copied $1 -> $2/"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
copy .claude/hooks/mcp-gate.sh .claude/hooks
|
|
69
|
+
copy agents/doorman.md .claude/agents
|
|
70
|
+
copy commands/vet.md .claude/commands
|
|
71
|
+
|
|
72
|
+
REGISTRY_KEPT=0
|
|
73
|
+
if [ -f "$TARGET/registry/allowlist.json" ]; then
|
|
74
|
+
REGISTRY_KEPT=1
|
|
75
|
+
say " KEPT registry/ already exists. Not touching it: that is your"
|
|
76
|
+
say " trust list, and ours has three entries in it."
|
|
77
|
+
elif [ "$DRY" = 1 ]; then
|
|
78
|
+
say " would copy registry/ -> registry/"
|
|
79
|
+
else
|
|
80
|
+
mkdir -p "$TARGET/registry" || die "cannot create $TARGET/registry"
|
|
81
|
+
cp "$SRC/registry/allowlist.json" "$SRC/registry/denylist.json" "$TARGET/registry/" \
|
|
82
|
+
|| die "cannot copy the registry"
|
|
83
|
+
: > "$TARGET/registry/ledger.jsonl"
|
|
84
|
+
say " copied registry/ -> registry/"
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
GATE="$TARGET/.claude/hooks/mcp-gate.sh"
|
|
88
|
+
[ "$DRY" = 1 ] || chmod +x "$GATE" 2>/dev/null
|
|
89
|
+
|
|
90
|
+
# ── 2. The hook wiring, which we will not do for you ─────────────────────────
|
|
91
|
+
|
|
92
|
+
SETTINGS="$TARGET/.claude/settings.json"
|
|
93
|
+
say ""
|
|
94
|
+
if [ -f "$SETTINGS" ] && grep -q 'mcp-gate.sh' "$SETTINGS" 2>/dev/null; then
|
|
95
|
+
say " hook already wired in .claude/settings.json"
|
|
96
|
+
else
|
|
97
|
+
if [ -f "$SETTINGS" ]; then
|
|
98
|
+
warn "settings.json exists but does not mention mcp-gate.sh."
|
|
99
|
+
else
|
|
100
|
+
warn "no .claude/settings.json yet."
|
|
101
|
+
fi
|
|
102
|
+
say ""
|
|
103
|
+
say " Add this. Not doing it for you: merging JSON in bash is how a config"
|
|
104
|
+
say " gets silently clobbered, and this file is yours."
|
|
105
|
+
say ""
|
|
106
|
+
cat <<'JSON'
|
|
107
|
+
{
|
|
108
|
+
"hooks": {
|
|
109
|
+
"PreToolUse": [{
|
|
110
|
+
"matcher": "mcp__.*",
|
|
111
|
+
"hooks": [{
|
|
112
|
+
"type": "command",
|
|
113
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/mcp-gate.sh",
|
|
114
|
+
"timeout": 5
|
|
115
|
+
}]
|
|
116
|
+
}]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
JSON
|
|
120
|
+
say ""
|
|
121
|
+
say " UNTIL YOU DO, THE GATE IS INSTALLED AND NOT RUNNING."
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
if [ "$DRY" = 1 ]; then
|
|
125
|
+
say ""
|
|
126
|
+
say " dry run complete. Nothing was written, and nothing was verified:"
|
|
127
|
+
say " verification drives the installed gate, and there is not one yet."
|
|
128
|
+
exit 0
|
|
129
|
+
fi
|
|
130
|
+
|
|
131
|
+
# ── 3. Prove it ──────────────────────────────────────────────────────────────
|
|
132
|
+
#
|
|
133
|
+
# Four cases. Three of them are refusals, and a gate that refuses EVERYTHING
|
|
134
|
+
# would pass all three while being useless, so the allow case is not optional.
|
|
135
|
+
#
|
|
136
|
+
# The allow case runs against this repo's own registry rather than the target's,
|
|
137
|
+
# because after a KEPT registry we do not know what is on yours. That still
|
|
138
|
+
# tests the code path that matters: read a list, find a match, exit 0.
|
|
139
|
+
|
|
140
|
+
say ""
|
|
141
|
+
say " verifying the INSTALLED gate at $GATE"
|
|
142
|
+
say ""
|
|
143
|
+
|
|
144
|
+
fails=0
|
|
145
|
+
probe() { # probe <expected-exit> <name> <tool> [env...]
|
|
146
|
+
local want="$1" name="$2" tool="$3"; shift 3
|
|
147
|
+
local out got
|
|
148
|
+
out="$(printf '%s' "{\"tool_name\":\"$tool\",\"tool_input\":{}}" \
|
|
149
|
+
| env "$@" bash "$GATE" 2>&1)"
|
|
150
|
+
got=$?
|
|
151
|
+
if [ "$got" = "$want" ]; then
|
|
152
|
+
printf ' PASS %s\n' "$name"
|
|
153
|
+
else
|
|
154
|
+
printf ' FAIL %s (wanted exit %s, got %s)\n' "$name" "$want" "$got"
|
|
155
|
+
printf ' %s\n' "$(printf '%s' "$out" | head -2)"
|
|
156
|
+
fails=$((fails + 1))
|
|
157
|
+
fi
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
probe 0 "an allowlisted server is allowed" mcp__scorecard__grade \
|
|
161
|
+
"DOORMAN_REGISTRY_DIR=$SRC/registry"
|
|
162
|
+
probe 2 "a denylisted server is blocked" mcp__planted_bad__search_notes \
|
|
163
|
+
"DOORMAN_REGISTRY_DIR=$SRC/registry"
|
|
164
|
+
probe 2 "an unknown server is blocked" mcp__zz_not_a_real_server_9f3a__search \
|
|
165
|
+
"DOORMAN_REGISTRY_DIR=$TARGET/registry"
|
|
166
|
+
probe 2 "a missing registry blocks rather than opens" mcp__scorecard__grade \
|
|
167
|
+
"DOORMAN_REGISTRY_DIR=$TARGET/registry-does-not-exist"
|
|
168
|
+
|
|
169
|
+
say ""
|
|
170
|
+
if [ "$fails" != 0 ]; then
|
|
171
|
+
say " $fails CHECK(S) FAILED. Do not rely on this install."
|
|
172
|
+
say " The files are in place but the gate is not behaving. Most likely the"
|
|
173
|
+
say " registry did not land next to .claude/, which is where the gate looks:"
|
|
174
|
+
say " it resolves \$hook/../../registry, never a git root."
|
|
175
|
+
exit 1
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
say " 4/4. The gate is installed and behaving."
|
|
179
|
+
say ""
|
|
180
|
+
if [ "$REGISTRY_KEPT" = 1 ]; then
|
|
181
|
+
say " Your registry was left alone, so what is trusted has not changed."
|
|
182
|
+
else
|
|
183
|
+
say " Three servers are trusted, and they are ours. Everything else your"
|
|
184
|
+
say " agent reaches for will be blocked until you grade it: /vet <url>"
|
|
185
|
+
fi
|
|
186
|
+
say ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clembot-doorman",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Vets MCP servers, skills and repos before an agent gets them. Fit review first, paid grade second.",
|
|
7
|
+
"homepage": "https://clembot-doorman.wanessalabs.com",
|
|
8
|
+
"bin": {
|
|
9
|
+
"doorman": "./cli/doorman.mjs"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node test/run.mjs",
|
|
13
|
+
"test:cli": "node test/cli-run.mjs",
|
|
14
|
+
"test:gate": "bash test-gate.sh",
|
|
15
|
+
"test:all": "node test/run.mjs && node test/cli-run.mjs && bash test-gate.sh && node test-poller.mjs"
|
|
16
|
+
},
|
|
17
|
+
"comment": [
|
|
18
|
+
"ZERO runtime dependencies, and that is a requirement rather than an",
|
|
19
|
+
"achievement. This repo is the giveaway: someone clones it into their own",
|
|
20
|
+
"project and expects a security gate to start working. Every dependency is",
|
|
21
|
+
"one more thing that can fail to install on their machine, and a gate that",
|
|
22
|
+
"fails to start is a gate that fails open.",
|
|
23
|
+
"",
|
|
24
|
+
"The same reasoning is why test/run.mjs is 40 lines instead of a runner,",
|
|
25
|
+
"and why cli/task.mjs hand-parses a strict YAML subset instead of pulling",
|
|
26
|
+
"in a yaml package. That parser REFUSES what it cannot read rather than",
|
|
27
|
+
"guessing: a task file that quietly means something other than it looks",
|
|
28
|
+
"like would corrupt an eval while every number still looked plausible.",
|
|
29
|
+
"",
|
|
30
|
+
"`bin` is the CLI surface: doorman report (L1) and doorman eval (L3).",
|
|
31
|
+
"L1 delegates to mcp-scorecard/runner/run.mjs rather than reimplementing",
|
|
32
|
+
"the static layer, per invariant 2."
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/clemenswan/clembot-doorman.git"
|
|
37
|
+
}
|
|
38
|
+
}
|