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
package/WALKTHROUGH.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# The first ten minutes
|
|
2
|
+
|
|
3
|
+
Three real builds, three different answers. Every block of output below was
|
|
4
|
+
captured from an actual run on 2026-09-11 against the live feed, not written by
|
|
5
|
+
hand. Where a run produced nothing useful, that is shown too, because "nothing
|
|
6
|
+
matched" is a result and hiding it would make this document a brochure.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Install both halves
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
claude plugin marketplace add clemenswan/clembot-doorman
|
|
14
|
+
claude plugin install clembot-doorman # the gate, /doorman, /vet, the subagent
|
|
15
|
+
|
|
16
|
+
git clone https://github.com/clemenswan/clembot-doorman
|
|
17
|
+
npm i -g ./clembot-doorman # doctor, needs, report, watch, eval
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Check what actually loaded. A manifest can validate and still ship components
|
|
21
|
+
that never register, which is exactly what this project shipped once:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
claude plugin details clembot-doorman
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Skills (3) doorman, doorman-guide, vet
|
|
29
|
+
Agents (1) doorman
|
|
30
|
+
Hooks (1) PreToolUse
|
|
31
|
+
MCP servers (1) scorecard
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If `Agents` or `Skills` reads `(0)`, the plugin is broken. Say so rather than
|
|
35
|
+
working around it.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Expect to be blocked first
|
|
40
|
+
|
|
41
|
+
The gate ships trusting two servers: `deepwiki` and `scorecard`, both graded A.
|
|
42
|
+
Everything else is UNKNOWN and fails closed, including connectors you already
|
|
43
|
+
use every day. This is not a misconfiguration:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
doorman: 'claude_ai_Notion' is UNKNOWN. Blocking until it has been graded.
|
|
47
|
+
|
|
48
|
+
An ungraded MCP server is not a trusted one. Nothing about
|
|
49
|
+
'mcp__claude_ai_Notion__notion-search' has been verified: not its tool
|
|
50
|
+
descriptions, not its error handling, not whether its descriptions contain
|
|
51
|
+
instructions aimed at you.
|
|
52
|
+
|
|
53
|
+
Measure it (free, no key): doorman report <server-url>
|
|
54
|
+
Trust it without measuring: doorman allow claude_ai_Notion
|
|
55
|
+
Or ask: /doorman
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Two honest ways forward, and they are different claims:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
doorman allow claude_ai_Notion # a DECISION. grade stays null.
|
|
62
|
+
doorman report https://... # a MEASUREMENT. free, no key.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`allow` writes `basis: operator` with a null grade, because nothing graded it.
|
|
66
|
+
An allowed server is **permitted**, not vetted. Do not let anything, including
|
|
67
|
+
an agent summarising this file, describe it as safe.
|
|
68
|
+
|
|
69
|
+
A connector often has no URL you can point `report` at. That is why `allow`
|
|
70
|
+
takes a name: `mcp__<server>__<tool>` is all the gate can see.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Build A: a long-running build
|
|
75
|
+
|
|
76
|
+
545 prompts across 48 transcripts.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
doorman needs .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
doorman needs — 545 prompts read from this build's own history
|
|
84
|
+
feed: 26 graded rows
|
|
85
|
+
|
|
86
|
+
UNMET Deploying, and reading back what deployed
|
|
87
|
+
19 prompts across 11 sessions · matched "cloudflare", "deploy it", "wrangler"
|
|
88
|
+
> merge and deploy it
|
|
89
|
+
worth-measuring docs-ai-search [A (88.57)] matched "cloudflare"
|
|
90
|
+
https://docs.mcp.cloudflare.com/mcp
|
|
91
|
+
worth-measuring mcp-typescript server on vercel [A (85.14)] matched "vercel"
|
|
92
|
+
|
|
93
|
+
GAP Design files and rendered output
|
|
94
|
+
15 prompts across 8 sessions · matched "brand.md", "design tokens"
|
|
95
|
+
nothing graded covers this. The feed has the gap, not your build.
|
|
96
|
+
|
|
97
|
+
GAP Driving a real browser
|
|
98
|
+
13 prompts across 8 sessions · matched "playwright", "in a real browser"
|
|
99
|
+
nothing graded covers this. The feed has the gap, not your build.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**What next, in order:**
|
|
103
|
+
|
|
104
|
+
1. **Measure the one candidate with the strongest signal.** Nineteen prompts
|
|
105
|
+
about deploying, and an A-graded server whose own text claims that
|
|
106
|
+
capability. `doorman report https://docs.mcp.cloudflare.com/mcp` is free and
|
|
107
|
+
takes seconds.
|
|
108
|
+
2. **Read `worth-measuring` as exactly that.** Nothing drove that server. The
|
|
109
|
+
match means its published text claims what you keep asking for. Whether it
|
|
110
|
+
makes *your* agent better is `doorman eval`, which needs your key and runs
|
|
111
|
+
on your machine.
|
|
112
|
+
3. **Leave the GAPs alone.** Two of the three biggest needs have nothing graded
|
|
113
|
+
against them. That is a hole in the catalogue, not a problem with your build,
|
|
114
|
+
and the useful response is `doorman discover` to find candidates, not to
|
|
115
|
+
install something unmeasured because the row looked empty.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Build B: a brand new build
|
|
120
|
+
|
|
121
|
+
No history at all.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
doorman needs .
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
doorman needs — 0 prompts read from this build's own history
|
|
129
|
+
No readable prompt history for this path. Claude Code keeps it under
|
|
130
|
+
~/.claude/projects/<path-with-dashes>; other harnesses keep none that
|
|
131
|
+
doorman can read. Pass --history DIR if yours lives elsewhere.
|
|
132
|
+
|
|
133
|
+
Nothing in the taxonomy matched. That is a real answer: either this
|
|
134
|
+
build has not asked for any of the twelve capabilities doorman knows
|
|
135
|
+
how to look for, or the history it could read is too short to tell.
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This is the correct output, not a failure. A new build has told doorman nothing
|
|
139
|
+
about itself, so doorman says nothing about it. Compare that to a recommender
|
|
140
|
+
that would happily suggest ten servers to an empty directory.
|
|
141
|
+
|
|
142
|
+
**What next:**
|
|
143
|
+
|
|
144
|
+
1. **Point it at a history you already have.** The capabilities you reach for
|
|
145
|
+
travel with you, not with the directory:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
doorman needs . --history ~/.claude/projects/<a-project-you-have-used>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
2. **Or just start working**, and run it again in a week. The signal is your own
|
|
152
|
+
sentences; there have to be some.
|
|
153
|
+
3. **Meanwhile `doorman doctor .` still works**, because it reads configuration
|
|
154
|
+
rather than history. It will tell you the gate is running from the plugin and
|
|
155
|
+
which trust list is in force.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Build C: a second, different build
|
|
160
|
+
|
|
161
|
+
151 prompts across 22 transcripts, same machine, different work.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
GAP Driving a real browser
|
|
165
|
+
14 prompts across 4 sessions · matched "playwright", "in a real browser"
|
|
166
|
+
nothing graded covers this. The feed has the gap, not your build.
|
|
167
|
+
|
|
168
|
+
GAP Design files and rendered output
|
|
169
|
+
9 prompts across 6 sessions · matched "design tokens", "brand.md"
|
|
170
|
+
nothing graded covers this. The feed has the gap, not your build.
|
|
171
|
+
|
|
172
|
+
UNMET Deploying, and reading back what deployed
|
|
173
|
+
8 prompts across 6 sessions · matched "cloudflare"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**The interesting part is what this shares with Build A.** Different project,
|
|
177
|
+
different code, and the same two GAPs at the top: browser automation and design
|
|
178
|
+
assets. One build reporting a gap is a fact about that build. Two unrelated
|
|
179
|
+
builds reporting the same gap is a fact about the **catalogue**, and it is the
|
|
180
|
+
most actionable thing on this page:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
doorman discover --pages 2
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
That sweeps a public registry for candidates and writes a file. It never
|
|
187
|
+
enqueues anything and never spends. Curate it by hand, then grade what survives.
|
|
188
|
+
|
|
189
|
+
**What next:**
|
|
190
|
+
|
|
191
|
+
1. `doorman needs . --candidates candidates/smithery.json` to match the sweep
|
|
192
|
+
against these gaps.
|
|
193
|
+
2. Anything that looks plausible gets `doorman report <url>`: free, no key, and
|
|
194
|
+
it is the stage that can cap a server at F on its tool descriptions alone.
|
|
195
|
+
3. Only then is anything worth paying to grade, and only then is `doorman eval`
|
|
196
|
+
worth your key.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## The shape of the whole thing
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
doctor what this build HAS free, local
|
|
204
|
+
needs what it keeps ASKING for free, local
|
|
205
|
+
watch what has been graded lately free, one anonymous GET
|
|
206
|
+
report what a candidate implements free, no key
|
|
207
|
+
grade is it safe and competent $0.01, runs on our machine
|
|
208
|
+
eval does it make YOUR agent better your key, your machine
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Every stage can end the run, and three end it free. The cheapest way to evaluate
|
|
212
|
+
a tool is to establish that you do not need it.
|
|
213
|
+
|
|
214
|
+
Five of the six run on your machine and cost nothing, because they are about
|
|
215
|
+
your build and that information should never leave it. One does not, and that is
|
|
216
|
+
the one with a price on it.
|
|
217
|
+
|
|
218
|
+
## Two things this will never do
|
|
219
|
+
|
|
220
|
+
- **Invent a grade.** An unmeasured layer is `null`, never `0` and never a
|
|
221
|
+
plausible letter. If no key was supplied, the run stops and says so.
|
|
222
|
+
- **Call a match a fit.** `needs` and `watch` both do mechanical matching.
|
|
223
|
+
Neither drove anything, so neither may claim a server will work. The verdict
|
|
224
|
+
word is `worth-measuring`, and the stage that earns a stronger word is `eval`.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# mcp-gate.sh - PreToolUse gate for every MCP tool call.
|
|
4
|
+
#
|
|
5
|
+
# Wired to matcher "mcp__.*". Claude Code passes the tool call as JSON on
|
|
6
|
+
# stdin. This script decides whether the call proceeds.
|
|
7
|
+
#
|
|
8
|
+
# exit 0 allow
|
|
9
|
+
# exit 2 BLOCK, and stderr is shown to the model
|
|
10
|
+
#
|
|
11
|
+
# Exit 2 is the only code that blocks. Exit 1 is treated as a non-blocking
|
|
12
|
+
# script error and the tool call PROCEEDS, which for a security gate means
|
|
13
|
+
# failing open. Every refusal path in this file must exit 2.
|
|
14
|
+
#
|
|
15
|
+
# FOUR RULES, none of them negotiable:
|
|
16
|
+
#
|
|
17
|
+
# 1. NO NETWORK. Not a curl, not a DNS lookup, nothing. A gate that asks a
|
|
18
|
+
# server for permission is offline the moment the network is, and
|
|
19
|
+
# "offline" would mean "allow". Decisions come from local files only.
|
|
20
|
+
#
|
|
21
|
+
# 2. NO DEPENDENCIES. No jq, no node, no python. Only bash builtins and
|
|
22
|
+
# coreutils, because a gate that fails to start is a gate that fails open
|
|
23
|
+
# on the machine where the dependency is missing.
|
|
24
|
+
#
|
|
25
|
+
# 3. FAIL CLOSED. Unparseable input, missing registry, unreadable file,
|
|
26
|
+
# unknown server: all block. The default answer is no.
|
|
27
|
+
#
|
|
28
|
+
# 4. DETERMINISTIC. Same input, same registry, same answer, every time. No
|
|
29
|
+
# clocks, no randomness, no model in the loop.
|
|
30
|
+
#
|
|
31
|
+
# The registry is synced from the scorecard by a human running `/vet` or the
|
|
32
|
+
# poller. This script never writes to it and never fetches it.
|
|
33
|
+
|
|
34
|
+
set -uo pipefail
|
|
35
|
+
|
|
36
|
+
# Resolve the registry relative to THIS SCRIPT, never to a git root.
|
|
37
|
+
# `git rev-parse --show-toplevel` returns whatever repo the cwd happens to be
|
|
38
|
+
# in, which in a worktree or a submodule is the wrong repo entirely.
|
|
39
|
+
HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
40
|
+
REPO_ROOT="$(cd -- "$HOOK_DIR/../.." && pwd)"
|
|
41
|
+
|
|
42
|
+
# THE USER'S TRUST LIST OUTRANKS THE ONE WE SHIPPED. Three sources, in order:
|
|
43
|
+
#
|
|
44
|
+
# 1. $DOORMAN_REGISTRY_DIR explicit, and what every test passes
|
|
45
|
+
# 2. the PROJECT's registry/ the user's list, which we never write
|
|
46
|
+
# 3. beside this script the default we shipped
|
|
47
|
+
#
|
|
48
|
+
# Order 2 before 3 is what makes invariant 24 structurally true rather than
|
|
49
|
+
# merely observed. As a plugin, this script lives inside the plugin directory
|
|
50
|
+
# and a plugin UPDATE replaces that directory wholesale. If the gate read its
|
|
51
|
+
# allowlist from beside itself, an update would silently replace the user's
|
|
52
|
+
# trust list with our three entries, which is the single most destructive
|
|
53
|
+
# thing this project could do. It cannot now: an update rewrites path 3 and
|
|
54
|
+
# path 2 is not in the plugin at all.
|
|
55
|
+
#
|
|
56
|
+
# A USER-LEVEL list sits between the two, and it exists because the plugin does.
|
|
57
|
+
# A plugin installs at user scope and gates every project on the machine, while
|
|
58
|
+
# a project registry gates one. Without a user level, trusting a server you use
|
|
59
|
+
# everywhere means copying the same file into every repo you own, and the ones
|
|
60
|
+
# you forget fail closed on servers you already trusted. That is a rule nobody
|
|
61
|
+
# can follow, and an unfollowable rule gets switched off.
|
|
62
|
+
REGISTRY_DIR="${DOORMAN_REGISTRY_DIR:-}"
|
|
63
|
+
if [ -z "$REGISTRY_DIR" ]; then
|
|
64
|
+
USER_REGISTRY="${DOORMAN_HOME:-${HOME:-$USERPROFILE}}/.doorman/registry"
|
|
65
|
+
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -f "$CLAUDE_PROJECT_DIR/registry/allowlist.json" ]; then
|
|
66
|
+
REGISTRY_DIR="$CLAUDE_PROJECT_DIR/registry"
|
|
67
|
+
elif [ -f "$USER_REGISTRY/allowlist.json" ]; then
|
|
68
|
+
REGISTRY_DIR="$USER_REGISTRY"
|
|
69
|
+
else
|
|
70
|
+
REGISTRY_DIR="$REPO_ROOT/registry"
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
ALLOWLIST="$REGISTRY_DIR/allowlist.json"
|
|
74
|
+
DENYLIST="$REGISTRY_DIR/denylist.json"
|
|
75
|
+
|
|
76
|
+
block() {
|
|
77
|
+
# stderr reaches the model on exit 2. Say what to do next, not just "no".
|
|
78
|
+
printf '%s\n' "$1" >&2
|
|
79
|
+
exit 2
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
allow() {
|
|
83
|
+
exit 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# --- read the tool call ------------------------------------------------------
|
|
87
|
+
# Read stdin without hanging forever if nothing arrives.
|
|
88
|
+
input=""
|
|
89
|
+
if ! IFS= read -r -d '' -t 5 input; then
|
|
90
|
+
# read -d '' returns non-zero at EOF even when it read everything, so an
|
|
91
|
+
# empty result is the only real failure.
|
|
92
|
+
:
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
if [ -z "$input" ]; then
|
|
96
|
+
block "doorman: no tool-call payload on stdin. Blocking (the gate fails closed)."
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# --- extract the tool name ---------------------------------------------------
|
|
100
|
+
# Only ever matches mcp__<server>__<tool>, which is a constrained identifier:
|
|
101
|
+
# ASCII word characters and dashes, no JSON escapes possible. A regex is safe
|
|
102
|
+
# here and costs no dependency.
|
|
103
|
+
tool_name=""
|
|
104
|
+
if [[ "$input" =~ \"tool_name\"[[:space:]]*:[[:space:]]*\"(mcp__[A-Za-z0-9_.-]+)\" ]]; then
|
|
105
|
+
tool_name="${BASH_REMATCH[1]}"
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
if [ -z "$tool_name" ]; then
|
|
109
|
+
# The matcher should only route mcp__* here. Anything else means the wiring
|
|
110
|
+
# changed or the payload shape did, and we do not guess.
|
|
111
|
+
block "doorman: could not read an mcp__ tool name from the hook payload. Blocking."
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# mcp__<server>__<tool> -> <server>
|
|
115
|
+
rest="${tool_name#mcp__}"
|
|
116
|
+
server="${rest%%__*}"
|
|
117
|
+
|
|
118
|
+
if [ -z "$server" ] || [ "$server" = "$rest" ] && [[ "$rest" != *__* ]]; then
|
|
119
|
+
# No "__" separator: cannot identify a server, so cannot vouch for one.
|
|
120
|
+
server="$rest"
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
if [ -z "$server" ]; then
|
|
124
|
+
block "doorman: could not identify an MCP server in '$tool_name'. Blocking."
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# --- registry must exist -----------------------------------------------------
|
|
128
|
+
if [ ! -r "$ALLOWLIST" ]; then
|
|
129
|
+
block "doorman: no readable allowlist at $ALLOWLIST.
|
|
130
|
+
Blocking '$server' because an absent registry is not the same as an empty one.
|
|
131
|
+
Run: /vet <server-url> (or restore registry/allowlist.json)"
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# --- lookup ------------------------------------------------------------------
|
|
135
|
+
# Matches a top-level key in the "servers" object: "<server>": { ... "decision": "allow" ...
|
|
136
|
+
# Deliberately literal string matching. No JSON parser, no eval, no expansion
|
|
137
|
+
# of anything read from the file.
|
|
138
|
+
lookup_decision() {
|
|
139
|
+
local file="$1" key="$2"
|
|
140
|
+
[ -r "$file" ] || return 1
|
|
141
|
+
# Flatten to one line, then find the object that follows "<key>":
|
|
142
|
+
tr -d '\n\r\t' < "$file" \
|
|
143
|
+
| grep -o "\"${key}\"[[:space:]]*:[[:space:]]*{[^}]*}" \
|
|
144
|
+
| grep -o '"decision"[[:space:]]*:[[:space:]]*"[a-z]*"' \
|
|
145
|
+
| grep -o '"[a-z]*"$' \
|
|
146
|
+
| tr -d '"' \
|
|
147
|
+
| head -n 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Deny wins over allow, always. A server present in both lists is a mistake,
|
|
151
|
+
# and the safe reading of a mistake is "deny".
|
|
152
|
+
#
|
|
153
|
+
# DENIALS ACCUMULATE ACROSS REGISTRIES; ALLOWS DO NOT. Choosing a narrower
|
|
154
|
+
# registry is a deliberate scoping decision about what you trust, so a user or
|
|
155
|
+
# project allowlist replacing the shipped one is correct. Doing the same to a
|
|
156
|
+
# denylist is not: it silently discards a refusal that was earned by an audit.
|
|
157
|
+
# Writing a user allowlist did exactly that here, and a server that had been
|
|
158
|
+
# explicitly denied came back as merely UNKNOWN. It still blocked, because the
|
|
159
|
+
# gate fails closed, but "unknown" is one allow away from running and "denied"
|
|
160
|
+
# is not, so the distinction is the whole safety margin.
|
|
161
|
+
for dl in "$DENYLIST" "$REPO_ROOT/registry/denylist.json"; do
|
|
162
|
+
[ -r "$dl" ] || continue
|
|
163
|
+
deny_decision="$(lookup_decision "$dl" "$server" 2>/dev/null || true)"
|
|
164
|
+
if [ "$deny_decision" = "deny" ] || [ "$deny_decision" = "allow" ]; then
|
|
165
|
+
block "doorman: '$server' is on a DENYLIST. Blocking.
|
|
166
|
+
Reason and evidence: $dl
|
|
167
|
+
This server was graded and failed. Do not work around this by calling it another way."
|
|
168
|
+
fi
|
|
169
|
+
done
|
|
170
|
+
|
|
171
|
+
allow_decision="$(lookup_decision "$ALLOWLIST" "$server" 2>/dev/null || true)"
|
|
172
|
+
|
|
173
|
+
case "$allow_decision" in
|
|
174
|
+
allow)
|
|
175
|
+
allow
|
|
176
|
+
;;
|
|
177
|
+
deny)
|
|
178
|
+
block "doorman: '$server' is marked deny in the allowlist. Blocking."
|
|
179
|
+
;;
|
|
180
|
+
"")
|
|
181
|
+
block "doorman: '$server' is UNKNOWN. Blocking until it has been graded.
|
|
182
|
+
|
|
183
|
+
An ungraded MCP server is not a trusted one. Nothing about '$tool_name' has
|
|
184
|
+
been verified: not its tool descriptions, not its error handling, not whether
|
|
185
|
+
its descriptions contain instructions aimed at you.
|
|
186
|
+
|
|
187
|
+
Measure it (free, no key): doorman report <server-url>
|
|
188
|
+
Trust it without measuring: doorman allow $server
|
|
189
|
+
Or ask: /doorman
|
|
190
|
+
|
|
191
|
+
The second one records a DECISION, not a measurement: the entry is written
|
|
192
|
+
with a null grade because nothing graded it. That is a legitimate choice for
|
|
193
|
+
a server you already run, and it is not the same as this server being safe.
|
|
194
|
+
|
|
195
|
+
A url is not always available: a connector only ever tells this gate the name
|
|
196
|
+
'$server', which is why \`doorman allow\` takes the name."
|
|
197
|
+
;;
|
|
198
|
+
*)
|
|
199
|
+
block "doorman: '$server' has an unrecognised decision '$allow_decision'. Blocking."
|
|
200
|
+
;;
|
|
201
|
+
esac
|
|
202
|
+
|
|
203
|
+
# Unreachable. Present so that a future edit that falls through this far still
|
|
204
|
+
# fails closed rather than returning bash's last exit status.
|
|
205
|
+
block "doorman: fell through the decision table. Blocking."
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
|
+
"name": "clembot-doorman",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "A gate on what your agent is allowed to use. Blocks MCP servers that are not on your trust list, reads your prompt history to find what the build actually needs, and grades candidates before you install them.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Clemens Wan",
|
|
8
|
+
"url": "https://github.com/clemenswan"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://clembot-doorman.wanessalabs.com",
|
|
11
|
+
"repository": "https://github.com/clemenswan/clembot-doorman",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"security",
|
|
16
|
+
"prompt-injection",
|
|
17
|
+
"tool-adoption",
|
|
18
|
+
"x402"
|
|
19
|
+
],
|
|
20
|
+
"hooks": "./hooks/hooks.json",
|
|
21
|
+
"mcpServers": "./.mcp.json"
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": [
|
|
3
|
+
"The one MCP server the doorman subagent is allowed to call.",
|
|
4
|
+
"",
|
|
5
|
+
"The name `scorecard` is the load-bearing part, not the url. The gate reads",
|
|
6
|
+
"`mcp__<server>__<tool>` and looks `scorecard` up in registry/allowlist.json,",
|
|
7
|
+
"so the name is what the trust decision is keyed on.",
|
|
8
|
+
"",
|
|
9
|
+
"When the Bazantic gateway lands, only the url below changes: the agent's",
|
|
10
|
+
"tool stays `mcp__scorecard__grade`, the registry key stays `scorecard`, and",
|
|
11
|
+
"nothing else in this repo moves. That is deliberate. The doorman should not",
|
|
12
|
+
"be blocked on a third-party account existing.",
|
|
13
|
+
"",
|
|
14
|
+
"Yes, the scorecard is itself in the allowlist, and yes it had to be graded",
|
|
15
|
+
"to get there: audit d4bc490c, A 98.63. A grader that exempted itself from",
|
|
16
|
+
"its own gate would be the first thing worth distrusting."
|
|
17
|
+
],
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"scorecard": {
|
|
20
|
+
"type": "http",
|
|
21
|
+
"url": "https://scorecard.wanessalabs.com/mcp"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|