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,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
+ }