clembot-doorman 0.1.0 → 0.2.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/README.md +81 -10
- package/WALKTHROUGH.md +3 -2
- package/doorman/.claude/hooks/session-notify.sh +67 -0
- package/doorman/.claude-plugin/plugin.json +1 -1
- package/doorman/cli/doorman.mjs +49 -1
- package/doorman/cli/install.mjs +14 -1
- package/doorman/cli/notify.mjs +145 -0
- package/doorman/commands/doorman.md +1 -2
- package/doorman/hooks/hooks.json +34 -3
- package/doorman/package.json +1 -1
- package/doorman/recipes/README.md +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,18 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
### 🏆 ETHOnline 2026 Prize Tracks & Judge Quick-Links
|
|
16
|
+
|
|
17
|
+
| Track | Prize | Artifact / Manifest | What It Demonstrates |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| **Bazantic · Agentify an API** | $500 / 300 / 200 | [`01-vet-mcp-candidate.json`](./bazantic/recipes/01-vet-mcp-candidate.json) | Agentifies our live OpenAPI 3.1 `mcp-scorecard` service into an autonomous pre-flight tool that evaluates untrusted MCP servers before adoption. |
|
|
20
|
+
| **Bazantic · Continuity** | 2×$500 | [`02-deepwiki-pinned-researcher.json`](./bazantic/recipes/02-deepwiki-pinned-researcher.json) | **87.6% Context Token Reduction**: Crushes 3,400 tokens of raw DeepWiki schema bloat down to 420 tokens using Doorman's cached pinned recipe. |
|
|
21
|
+
| **Bazantic · Best Multi-API Recipe** | $500 / 300 / 200 | [`03-governance-pipeline.json`](./bazantic/recipes/03-governance-pipeline.json) | **3-Gateway Autonomous Pipeline**: Audits tool safety → Settles $0.01 audit micropayment on Base mainnet via x402 → Pins SHA-256 hash to allowlist. |
|
|
22
|
+
| **Base · x402 Micropayments** | Sponsor Track | [`mcp-scorecard/src/routes/spend.ts`](./mcp-scorecard/src/routes/spend.ts) | Real $0.01 per-audit micropayment settlement using EIP-712 spend permits on Base mainnet. |
|
|
23
|
+
|
|
24
|
+
> **Judge Portals**: [Live Interactive Simulator](https://clembot-doorman.wanessalabs.com/#flow) · [Bazantic Showcase Page](https://clembot-doorman.wanessalabs.com/bazantic.html#recipes) · [ETHOnline Judging Criteria](https://clembot-doorman.wanessalabs.com/judges.html) · [2-Minute Verification Runbook](RUNBOOK.md)
|
|
25
|
+
|
|
26
|
+
---
|
|
16
27
|
|
|
17
28
|
**Clembot is built in many versions, with the latest utilizing Claude Code and a custom combination of agent harnesses.** While AI agents need tools to do real work, loading raw MCP servers blindly introduces **in-context steering ads**, prompt injection risks, and token-draining schema hallucination.
|
|
18
29
|
|
|
@@ -23,7 +34,7 @@
|
|
|
23
34
|
3. **Arm with Bazantic Recipes (`recipe.md`)**
|
|
24
35
|
Turns raw, unpredictable APIs into bounded, high-performing tools via structured `recipe.md` guidance. Built and verified against the Bazantic gateway (`clembot-doorman.bazgateway.com`).
|
|
25
36
|
|
|
26
|
-

|
|
27
38
|
|
|
28
39
|
*A real run against a real server. The grade, the hard fail, and the arithmetic are live values, not a mockup: `webzum.com/api/mcp` scores **89.9% on configuration** and still fails, because one tool description injects **6,290 characters of unprompted upsell and competitor steering** directly into your agent's context window. One cent through the Bazantic gateway answered a question that would have cost $54.47 to measure in an unguided LLM loop. [Run it live in the simulator](https://clembot-doorman.wanessalabs.com/#flow).*
|
|
29
40
|
|
|
@@ -106,20 +117,25 @@ components that never register:
|
|
|
106
117
|
claude plugin details clembot-doorman
|
|
107
118
|
# Skills (3) doorman, doorman-guide, vet
|
|
108
119
|
# Agents (1) doorman
|
|
109
|
-
# Hooks (
|
|
120
|
+
# Hooks (2) PreToolUse, SessionStart
|
|
110
121
|
# MCP servers (1) scorecard
|
|
111
122
|
```
|
|
112
123
|
|
|
113
|
-
### 2. The CLI: doctor, needs, report, watch, eval
|
|
124
|
+
### 2. The CLI: doctor, needs, report, watch, notify, eval
|
|
114
125
|
|
|
115
126
|
```bash
|
|
116
|
-
|
|
117
|
-
npm i -g ./clembot-doorman
|
|
127
|
+
npm i -g clembot-doorman
|
|
118
128
|
doorman --version
|
|
119
129
|
```
|
|
120
130
|
|
|
121
131
|
Node 20+. Zero runtime dependencies, deliberately: every dependency is one more thing that can fail to install on your machine.
|
|
122
132
|
|
|
133
|
+
**`notify` and the SessionStart hook need 0.2.0 or later.** The 0.1.0 on npm was
|
|
134
|
+
published before they landed, so `doorman --version` reporting `0.1.0` means the
|
|
135
|
+
push half is absent and no session will ever announce anything. Nothing errors:
|
|
136
|
+
that build simply stays quiet forever, which is indistinguishable from having no
|
|
137
|
+
news.
|
|
138
|
+
|
|
123
139
|
**New here?** [`WALKTHROUGH.md`](WALKTHROUGH.md) runs the first ten minutes
|
|
124
140
|
against three real builds: one with a long history, one brand new, and one in
|
|
125
141
|
between. The output in it is captured from real runs, not written by hand.
|
|
@@ -320,6 +336,54 @@ Doorman is specifically designed to stop "skill sprawl" and prevent duplicate to
|
|
|
320
336
|
2. **Adverse Drift Detection (`doorman watch`)**:
|
|
321
337
|
When you run `doorman watch`, any candidate server already in your inventory is tagged `already-installed`. If an installed server is downgraded or caught with prompt injection on the feed, `watch` raises an immediate security alert.
|
|
322
338
|
|
|
339
|
+
2a. **Push, without a daemon and without telemetry**:
|
|
340
|
+
Nobody can push to a laptop behind NAT that is asleep half the day, so the
|
|
341
|
+
push here is not a new transport. It is the poll going invisible. A
|
|
342
|
+
SessionStart hook prints a digest that is already on disk, then fires a
|
|
343
|
+
detached refresh so the next session is current. The hook makes no network
|
|
344
|
+
call: one that waited on a fetch would make every session start as slow as
|
|
345
|
+
the worst network it has seen, and offline would make them all fail.
|
|
346
|
+
|
|
347
|
+
```text
|
|
348
|
+
## doorman
|
|
349
|
+
|
|
350
|
+
1 newly graded server this build does not have:
|
|
351
|
+
- **A** 85.71/100, model not recorded https://mcp.deepwiki.com/mcp
|
|
352
|
+
not measured: behavioral, guidance
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Three rules, and each one is a notification product failing if broken. It
|
|
356
|
+
is **silent when nothing is new**, because a hook that reports "nothing new"
|
|
357
|
+
every morning teaches you to skip past the one morning it matters. It
|
|
358
|
+
**announces nothing on the first run**, because with no cursor the feed
|
|
359
|
+
returns everything graded so far and 26 rows is a catalogue, not news. And a
|
|
360
|
+
digest is **shown exactly once**, because the same three servers every
|
|
361
|
+
morning is how a notification becomes furniture.
|
|
362
|
+
|
|
363
|
+
2b. **Popularity and trend, as a second axis (`GET /feed`, `?sort=trending`)**:
|
|
364
|
+
Every feed row carries a `popularity` block: Smithery use counts, npm weekly
|
|
365
|
+
downloads and GitHub stars, swept daily, with a median percentile and a
|
|
366
|
+
trend.
|
|
367
|
+
|
|
368
|
+
It is **never part of the score**. The grade is what happened when an agent
|
|
369
|
+
drove the server; popularity is how many people installed it without asking
|
|
370
|
+
that. A popular F is the most useful row this feed can publish, and a
|
|
371
|
+
blended number is the one thing guaranteed to bury it.
|
|
372
|
+
|
|
373
|
+
Counts are **ranked within each source and never summed across them**:
|
|
374
|
+
87,579 Smithery uses, 4,200 npm downloads and 1,100 stars are three units
|
|
375
|
+
counting three populations, and adding them makes a meaningless number that
|
|
376
|
+
still sorts confidently. `sources_measured` says how many sources backed the
|
|
377
|
+
percentile, because a server ranked on one and a server ranked on three are
|
|
378
|
+
not equally known. A trend needs two readings at least 12 hours apart, so a
|
|
379
|
+
newly tracked server reports `null` rather than zero growth, and a source
|
|
380
|
+
that could not be read is **absent rather than zero**.
|
|
381
|
+
|
|
382
|
+
**Doorman's own install counts are refused as a fourth source.** They would
|
|
383
|
+
be the best popularity signal available to anyone, and collecting them needs
|
|
384
|
+
telemetry. That would sell the guarantee that makes `watch` and `needs`
|
|
385
|
+
worth running at all: your inventory never leaves your machine.
|
|
386
|
+
|
|
323
387
|
3. **Frontmatter Arithmetic (30 KB vs 640 KB)**:
|
|
324
388
|
Doorman reads only YAML frontmatter (`name`, `description`) from `.claude/skills/*/SKILL.md` and `.claude/agents/*.md`. In our production vault, reading full markdown bodies was **642 KB**; reading frontmatter was **30 KB**. This allows the complete roster to be reviewed by a model in a single prompt without bloating context.
|
|
325
389
|
|
|
@@ -645,13 +709,20 @@ node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
|
|
|
645
709
|
--needed-for "look up how a public repository works" \
|
|
646
710
|
--static-only --out ../evidence/deepwiki
|
|
647
711
|
|
|
648
|
-
# Full behavioural run (needs a key)
|
|
649
|
-
|
|
712
|
+
# Full behavioural run (needs a key).
|
|
713
|
+
# `=...` is NOT a value: read the secret in rather than pasting a placeholder,
|
|
714
|
+
# which also keeps it out of shell history. A pasted "..." reaches the server
|
|
715
|
+
# as a wrong token and comes back 401, which reads as a broken credential
|
|
716
|
+
# rather than as a placeholder nobody substituted.
|
|
717
|
+
read -rsp 'ANTHROPIC_API_KEY: ' ANTHROPIC_API_KEY && export ANTHROPIC_API_KEY
|
|
718
|
+
# --once PRINTS ONLY. Add --out DIR to keep the evidence bundle, or queue the
|
|
719
|
+
# audit and use --poll below to publish it to the feed.
|
|
650
720
|
node runner/run.mjs --once --server https://mcp.deepwiki.com/mcp \
|
|
651
|
-
--needed-for "look up how a public repository works"
|
|
721
|
+
--needed-for "look up how a public repository works" --out out/deepwiki
|
|
652
722
|
|
|
653
723
|
# Poll the queue
|
|
654
|
-
export RUNNER_TOKEN
|
|
724
|
+
read -rsp 'RUNNER_TOKEN: ' RUNNER_TOKEN && export RUNNER_TOKEN
|
|
725
|
+
export SCORECARD_API=http://127.0.0.1:8799
|
|
655
726
|
node runner/run.mjs --poll
|
|
656
727
|
```
|
|
657
728
|
|
package/WALKTHROUGH.md
CHANGED
|
@@ -5,6 +5,8 @@ captured from an actual run on 2026-09-11 against the live feed, not written by
|
|
|
5
5
|
hand. Where a run produced nothing useful, that is shown too, because "nothing
|
|
6
6
|
matched" is a result and hiding it would make this document a brochure.
|
|
7
7
|
|
|
8
|
+

|
|
9
|
+
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
## Install both halves
|
|
@@ -13,8 +15,7 @@ matched" is a result and hiding it would make this document a brochure.
|
|
|
13
15
|
claude plugin marketplace add clemenswan/clembot-doorman
|
|
14
16
|
claude plugin install clembot-doorman # the gate, /doorman, /vet, the subagent
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
npm i -g ./clembot-doorman # doctor, needs, report, watch, eval
|
|
18
|
+
npm i -g clembot-doorman # doctor, needs, report, watch, eval
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
Check what actually loaded. A manifest can validate and still ship components
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# SessionStart hook. The visible half of the push model.
|
|
4
|
+
#
|
|
5
|
+
# Prints a digest that is ALREADY ON DISK, then fires a detached refresh so the
|
|
6
|
+
# next session is current. It makes no network call itself, which is the whole
|
|
7
|
+
# design: a SessionStart hook that waits on a fetch makes every session start as
|
|
8
|
+
# slow as the worst network it has ever seen, and offline it makes them fail.
|
|
9
|
+
# The cost of this hook on a normal morning is one `test -f` and one `cat`.
|
|
10
|
+
#
|
|
11
|
+
# THIS IS NOT THE GATE. `mcp-gate.sh` is a security control and is offline,
|
|
12
|
+
# dependency-free, and fails CLOSED with exit 2. This is a notification and
|
|
13
|
+
# fails OPEN and silent: it always exits 0, and every failure path prints
|
|
14
|
+
# nothing. A notifier that can break a session start is worse than no notifier.
|
|
15
|
+
#
|
|
16
|
+
# ponytail: the refresh rides on session start, so the first session after
|
|
17
|
+
# install prints nothing (no digest yet) and news is at most one session stale.
|
|
18
|
+
# A background poller (`doorman watch --poll`, roadmap Phase 2) would close that
|
|
19
|
+
# gap and is not worth a daemon yet.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
# Never let this hook be the reason a session fails to start.
|
|
24
|
+
trap 'exit 0' ERR
|
|
25
|
+
|
|
26
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
|
+
DIGEST="$PROJECT_DIR/.doorman/notify.md"
|
|
28
|
+
|
|
29
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
RESOLVE="$HERE/../../scripts/resolve-cli.sh"
|
|
31
|
+
|
|
32
|
+
# 1. Say what is already known. Reading is destructive: consume-once is what
|
|
33
|
+
# stops the same three servers being announced every morning until they are
|
|
34
|
+
# furniture. `notify consume` does the delete.
|
|
35
|
+
if [ -f "$DIGEST" ]; then
|
|
36
|
+
CLI=""
|
|
37
|
+
[ -x "$RESOLVE" ] && CLI="$(bash "$RESOLVE" 2>/dev/null || true)"
|
|
38
|
+
if [ -n "$CLI" ] && [ "$CLI" != "NOT_FOUND" ]; then
|
|
39
|
+
$CLI notify consume --digest "$DIGEST" 2>/dev/null || true
|
|
40
|
+
else
|
|
41
|
+
# No CLI resolved. Print it anyway and remove it by hand rather than
|
|
42
|
+
# holding news hostage to a resolution problem.
|
|
43
|
+
cat "$DIGEST" 2>/dev/null || true
|
|
44
|
+
rm -f "$DIGEST" 2>/dev/null || true
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# 2. Refresh for next time, detached.
|
|
49
|
+
#
|
|
50
|
+
# stdout and stderr MUST be redirected away from this process. A background
|
|
51
|
+
# child that inherits the hook's stdout keeps the pipe open, and the harness
|
|
52
|
+
# waits on the pipe rather than on the process, so an unredirected `&` turns a
|
|
53
|
+
# fire-and-forget into the exact session-start stall this design avoids.
|
|
54
|
+
if [ -z "${DOORMAN_NO_REFRESH:-}" ]; then
|
|
55
|
+
CLI="${CLI:-}"
|
|
56
|
+
if [ -z "$CLI" ] && [ -x "$RESOLVE" ]; then
|
|
57
|
+
CLI="$(bash "$RESOLVE" 2>/dev/null || true)"
|
|
58
|
+
fi
|
|
59
|
+
if [ -n "$CLI" ] && [ "$CLI" != "NOT_FOUND" ]; then
|
|
60
|
+
LOG="$PROJECT_DIR/.doorman/notify.log"
|
|
61
|
+
mkdir -p "$PROJECT_DIR/.doorman" 2>/dev/null || true
|
|
62
|
+
nohup $CLI notify refresh --root "$PROJECT_DIR" >"$LOG" 2>&1 &
|
|
63
|
+
disown 2>/dev/null || true
|
|
64
|
+
fi
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
exit 0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "clembot-doorman",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
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
6
|
"author": {
|
|
7
7
|
"name": "Clemens Wan",
|
package/doorman/cli/doorman.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
doorman needs [path] read this build's own prompt history, propose servers.
|
|
17
17
|
* doorman watch [path] poll the feed for candidates new to THIS build.
|
|
18
18
|
* doorman discover sweep a public directory for candidates. Curate, never enqueue.
|
|
19
|
+
* doorman notify refresh|consume the SessionStart push. Called by the hook, not by hand.
|
|
19
20
|
*
|
|
20
21
|
* Zero runtime dependencies, per the requirement in package.json. That is why
|
|
21
22
|
* the YAML loader and the arg parser are in-tree rather than installed.
|
|
@@ -29,8 +30,13 @@ import { discover, renderDiscover, writeCandidates } from './discover.mjs';
|
|
|
29
30
|
import { needs as readNeeds, render as renderNeedsCli } from './needs.mjs';
|
|
30
31
|
import { install, renderInstall } from './install.mjs';
|
|
31
32
|
import { allow, renderAllow, SCOPES } from './allow.mjs';
|
|
33
|
+
import { refreshNotify, consumeDigest, DEFAULT_DIGEST } from './notify.mjs';
|
|
34
|
+
import { join } from 'node:path';
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
// Pinned to every other declaration by version.test.mjs. There are FOUR of
|
|
37
|
+
// them (root package.json, doorman/package.json, plugin.json, this) and this
|
|
38
|
+
// one silently reported 0.1.0 out of a 0.2.0 tarball.
|
|
39
|
+
const VERSION = '0.2.0';
|
|
34
40
|
|
|
35
41
|
const HELP = `
|
|
36
42
|
doorman ${VERSION} — measure a candidate, do not just read it
|
|
@@ -58,6 +64,12 @@ doorman ${VERSION} — measure a candidate, do not just read it
|
|
|
58
64
|
a match is only ever worth-measuring. Nothing here drove anything, so
|
|
59
65
|
nothing here claims a server will work. Only eval answers that.
|
|
60
66
|
|
|
67
|
+
doorman notify refresh [--root DIR] | doorman notify consume [--digest FILE]
|
|
68
|
+
The push half, and the SessionStart hook is what calls it. refresh polls
|
|
69
|
+
the feed and leaves a short digest on disk; consume prints that digest
|
|
70
|
+
and deletes it. Nothing is announced on the first run, nothing is written
|
|
71
|
+
when nothing is new, and a digest is shown exactly once.
|
|
72
|
+
|
|
61
73
|
doorman report <link> [--out DIR] [--needed-for TEXT]
|
|
62
74
|
L1. The static implementation report: protocol, schemas, annotations, and
|
|
63
75
|
a scan-only pass over every description an agent would read before
|
|
@@ -248,6 +260,42 @@ async function main() {
|
|
|
248
260
|
return;
|
|
249
261
|
}
|
|
250
262
|
|
|
263
|
+
// The push half. Two verbs, and neither is meant to be typed by a human:
|
|
264
|
+
// the SessionStart hook calls `consume`, then `refresh` detached.
|
|
265
|
+
if (cmd === 'notify') {
|
|
266
|
+
const sub = args._[1];
|
|
267
|
+
const root = args.root || process.cwd();
|
|
268
|
+
|
|
269
|
+
if (sub === 'consume') {
|
|
270
|
+
const file = args.digest || join(root, DEFAULT_DIGEST);
|
|
271
|
+
const text = consumeDigest(file);
|
|
272
|
+
if (text) console.log(text);
|
|
273
|
+
// Nothing to say is exit 0 and silence, not an error. See rule 1 in
|
|
274
|
+
// notify.mjs: a notifier that speaks every session gets ignored.
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (sub === 'refresh') {
|
|
279
|
+
const api = (args.api || DEFAULT_API).replace(/\/+$/, '');
|
|
280
|
+
try {
|
|
281
|
+
const r = await refreshNotify({ root, api, limit: Number(args.limit) > 0 ? Number(args.limit) : 50 });
|
|
282
|
+
if (args.json) console.log(JSON.stringify(r, null, 2));
|
|
283
|
+
else if (r.firstRun) console.log('First run: cursor established, nothing announced.');
|
|
284
|
+
else console.log(r.wrote ? `digest written to ${r.digest}` : 'nothing new, no digest written');
|
|
285
|
+
} catch (e) {
|
|
286
|
+
// Could not measure is 3, same as watch. This runs detached from a
|
|
287
|
+
// hook, so the exit code lands in .doorman/notify.log and nowhere else.
|
|
288
|
+
console.error(`notify refresh: ${e.message}`);
|
|
289
|
+
process.exitCode = e.code === 3 ? 3 : 1;
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.error('notify needs a subcommand: consume or refresh');
|
|
295
|
+
process.exitCode = 2;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
251
299
|
if (!['report', 'eval'].includes(cmd)) {
|
|
252
300
|
console.error(`unknown command "${cmd}". Try: doorman --help`);
|
|
253
301
|
process.exitCode = 2;
|
package/doorman/cli/install.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* And safely inspects/wires .claude/settings.json without destructive clobbering.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
18
18
|
import { resolve, join, dirname } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
|
|
@@ -58,6 +58,19 @@ export async function install(targetDir = process.cwd(), { dryRun = false } = {}
|
|
|
58
58
|
|
|
59
59
|
// 1. Hooks, Subagent, Command
|
|
60
60
|
copy('.claude/hooks/mcp-gate.sh', '.claude/hooks/mcp-gate.sh');
|
|
61
|
+
// copyFileSync preserves the SOURCE mode, and the source inside an installed
|
|
62
|
+
// npm package is 644: npm pack on Windows drops the executable bit from every
|
|
63
|
+
// file. Verified against the published 0.1.0 tarball, 2026-09-12. Without this
|
|
64
|
+
// the gate lands non-executable, fails to SPAWN rather than exiting 2, and the
|
|
65
|
+
// security control fails open. install.sh has carried the same chmod since it
|
|
66
|
+
// was written; this path never did.
|
|
67
|
+
if (!dryRun) {
|
|
68
|
+
try {
|
|
69
|
+
chmodSync(join(absTarget, '.claude/hooks/mcp-gate.sh'), 0o755);
|
|
70
|
+
} catch {
|
|
71
|
+
warnings.push('could not mark .claude/hooks/mcp-gate.sh executable; run: chmod +x .claude/hooks/mcp-gate.sh');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
61
74
|
copy('agents/doorman.md', '.claude/agents/doorman.md');
|
|
62
75
|
copy('commands/vet.md', '.claude/commands/vet.md');
|
|
63
76
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The push half of the subscription.
|
|
3
|
+
*
|
|
4
|
+
* WHAT "PUSH" HONESTLY MEANS HERE. Nobody can push to a laptop behind NAT that
|
|
5
|
+
* is asleep half the day, and a doorman that opened a socket to wait for us
|
|
6
|
+
* would be a worse product than one that does not. So the push is not a new
|
|
7
|
+
* transport: it is the POLL BECOMING INVISIBLE. A SessionStart hook reads a
|
|
8
|
+
* digest that is already on disk and costs nothing, then fires a detached
|
|
9
|
+
* refresh so the NEXT session is current. From where the operator sits they
|
|
10
|
+
* never ran a command and the doorman told them anyway, which is the whole
|
|
11
|
+
* claim, and it is true.
|
|
12
|
+
*
|
|
13
|
+
* THREE RULES, AND EACH ONE IS A NOTIFICATION PRODUCT FAILING IF BROKEN:
|
|
14
|
+
*
|
|
15
|
+
* 1. SILENT WHEN THERE IS NOTHING. No digest file is written when nothing is
|
|
16
|
+
* new. A hook that prints "nothing new" at every session start teaches the
|
|
17
|
+
* operator to skip past the one session where it matters.
|
|
18
|
+
*
|
|
19
|
+
* 2. THE FIRST RUN SAYS NOTHING. With no cursor the feed returns everything
|
|
20
|
+
* graded so far. Announcing 26 rows as "new" the first time a plugin loads
|
|
21
|
+
* is not news, it is a catalogue nobody asked for. The first refresh
|
|
22
|
+
* establishes the cursor and writes no digest.
|
|
23
|
+
*
|
|
24
|
+
* 3. CONSUMED ONCE. The hook prints the digest and deletes it. Re-showing the
|
|
25
|
+
* same three servers every morning is how a notification becomes furniture.
|
|
26
|
+
*
|
|
27
|
+
* The network never runs inside the hook. A SessionStart hook that waits on a
|
|
28
|
+
* fetch makes every session start as slow as the slowest network it has ever
|
|
29
|
+
* been on, and offline it makes them all fail.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
|
|
33
|
+
import { join, dirname } from 'node:path';
|
|
34
|
+
import { watch, readState, writeState, DEFAULT_API, DEFAULT_STATE } from './watch.mjs';
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_DIGEST = join('.doorman', 'notify.md');
|
|
37
|
+
|
|
38
|
+
/** How many rows the digest will name before it stops listing and counts. */
|
|
39
|
+
export const MAX_LISTED = 3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The digest, or null for "say nothing".
|
|
43
|
+
*
|
|
44
|
+
* Only two verdicts are ever worth interrupting a session for. `unreviewed` is
|
|
45
|
+
* something you could adopt and have not looked at. `blocked` is something the
|
|
46
|
+
* registry already refuses, and seeing it in a fresh grade is a reason to check
|
|
47
|
+
* whether anyone tried. `already-installed` and `skipped` are not news: the
|
|
48
|
+
* first is a server you have, and the second you decided about.
|
|
49
|
+
*/
|
|
50
|
+
export function renderNotify(result, { firstRun = false } = {}) {
|
|
51
|
+
if (firstRun) return null;
|
|
52
|
+
|
|
53
|
+
const rows = result.candidates ?? [];
|
|
54
|
+
const fresh = rows.filter((c) => c.verdict === 'unreviewed' && !c.is_fixture && !c.self_graded);
|
|
55
|
+
const blocked = rows.filter((c) => c.verdict === 'blocked');
|
|
56
|
+
if (!fresh.length && !blocked.length) return null;
|
|
57
|
+
|
|
58
|
+
const out = [];
|
|
59
|
+
out.push('## doorman');
|
|
60
|
+
out.push('');
|
|
61
|
+
|
|
62
|
+
if (fresh.length) {
|
|
63
|
+
const shown = fresh.slice(0, MAX_LISTED);
|
|
64
|
+
const noun = fresh.length === 1 ? 'server' : 'servers';
|
|
65
|
+
out.push(`${fresh.length} newly graded ${noun} this build does not have:`);
|
|
66
|
+
for (const c of shown) {
|
|
67
|
+
// The grade travels with what produced it. A letter with no model behind
|
|
68
|
+
// it invites a comparison across models that the grade cannot support.
|
|
69
|
+
const model = c.model ? ` on ${c.model}` : ', model not recorded';
|
|
70
|
+
const score = c.score === null || c.score === undefined ? 'not scored' : `${c.score}/100`;
|
|
71
|
+
out.push(`- **${c.grade ?? '?'}** ${score}${model} ${c.server_url}`);
|
|
72
|
+
const layers = c.layers ?? {};
|
|
73
|
+
const missing = ['behavioral_pct', 'guidance_pct']
|
|
74
|
+
.filter((k) => layers[k] === null || layers[k] === undefined)
|
|
75
|
+
.map((k) => k.replace('_pct', ''));
|
|
76
|
+
if (missing.length) out.push(` not measured: ${missing.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
if (fresh.length > shown.length) {
|
|
79
|
+
out.push(`- and ${fresh.length - shown.length} more`);
|
|
80
|
+
}
|
|
81
|
+
out.push('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (blocked.length) {
|
|
85
|
+
out.push(`${blocked.length} on your denylist were re-graded. Run \`doorman watch\` for the detail.`);
|
|
86
|
+
out.push('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
out.push('Run `doorman needs` to see which of these answer something this build ' +
|
|
90
|
+
'keeps asking for, or `doorman watch` for the full list. Nothing has been ' +
|
|
91
|
+
'installed and nothing was sent anywhere: the match runs on this machine.');
|
|
92
|
+
return out.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch, classify, and leave a digest for the next session to find.
|
|
97
|
+
*
|
|
98
|
+
* Advances the cursor whether or not a digest is written, because the cursor
|
|
99
|
+
* tracks what has been SEEN by the subscription rather than what was worth
|
|
100
|
+
* mentioning. Not advancing it on a quiet day would re-announce the same rows
|
|
101
|
+
* the moment something interesting finally landed.
|
|
102
|
+
*/
|
|
103
|
+
export async function refreshNotify({
|
|
104
|
+
root = process.cwd(), api = DEFAULT_API, limit = 50,
|
|
105
|
+
stateFile, digestFile, fetchImpl = fetch,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const state = stateFile ?? join(root, DEFAULT_STATE);
|
|
108
|
+
const digest = digestFile ?? join(root, DEFAULT_DIGEST);
|
|
109
|
+
|
|
110
|
+
const prior = readState(state);
|
|
111
|
+
const firstRun = !prior.since;
|
|
112
|
+
|
|
113
|
+
const result = await watch({ root, api, since: prior.since, limit, fetchImpl });
|
|
114
|
+
const text = renderNotify(result, { firstRun });
|
|
115
|
+
|
|
116
|
+
if (text) {
|
|
117
|
+
mkdirSync(dirname(digest), { recursive: true });
|
|
118
|
+
writeFileSync(digest, text + '\n', 'utf8');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (result.next_since) {
|
|
122
|
+
writeState(state, { since: result.next_since, seen: (prior.seen ?? 0) + result.candidates.length });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { wrote: Boolean(text), firstRun, digest, candidates: result.candidates.length };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What the hook prints. Reading is destructive on purpose: see rule 3.
|
|
130
|
+
*
|
|
131
|
+
* Any failure here returns nothing rather than throwing. This runs at session
|
|
132
|
+
* start, and a notification that can break a session start is worse than no
|
|
133
|
+
* notification.
|
|
134
|
+
*/
|
|
135
|
+
export function consumeDigest(file) {
|
|
136
|
+
try {
|
|
137
|
+
if (!file || typeof file !== 'string') return null;
|
|
138
|
+
if (!existsSync(file)) return null;
|
|
139
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
140
|
+
unlinkSync(file);
|
|
141
|
+
return text || null;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -29,8 +29,7 @@ If it prints `NOT_FOUND`, stop and tell the user this, then end the turn:
|
|
|
29
29
|
> Reinstall the plugin, or install the CLI directly:
|
|
30
30
|
>
|
|
31
31
|
> ```bash
|
|
32
|
-
>
|
|
33
|
-
> npm i -g ./clembot-doorman
|
|
32
|
+
> npm i -g clembot-doorman
|
|
34
33
|
> ```
|
|
35
34
|
>
|
|
36
35
|
> The gate is still running either way. It is pure bash and depends on none of
|
package/doorman/hooks/hooks.json
CHANGED
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
"",
|
|
12
12
|
"Timeout matches .claude/settings.json. The gate does no network and no",
|
|
13
13
|
"subprocess, so 5s is generous; it exists so a wedged hook fails rather",
|
|
14
|
-
"than hanging every tool call."
|
|
14
|
+
"than hanging every tool call.",
|
|
15
|
+
"",
|
|
16
|
+
"BOTH COMMANDS RUN THROUGH `bash` ON PURPOSE. npm pack on Windows drops",
|
|
17
|
+
"the executable bit from every file, so the .sh files inside an installed",
|
|
18
|
+
"package are 644 and cannot be invoked by path. Verified against the",
|
|
19
|
+
"published 0.1.0 tarball on 2026-09-12: mcp-gate.sh ships -rw-r--r--.",
|
|
20
|
+
"A gate that cannot start does not exit 2, it fails to spawn, so the",
|
|
21
|
+
"security control FAILS OPEN, which is the one thing its design forbids.",
|
|
22
|
+
"Invoking through the interpreter makes the mode irrelevant on every",
|
|
23
|
+
"platform. Do not `simplify` this back to a bare path."
|
|
15
24
|
],
|
|
16
25
|
"hooks": {
|
|
17
26
|
"PreToolUse": [
|
|
@@ -20,11 +29,33 @@
|
|
|
20
29
|
"hooks": [
|
|
21
30
|
{
|
|
22
31
|
"type": "command",
|
|
23
|
-
"command": "${CLAUDE_PLUGIN_ROOT}/.claude/hooks/mcp-gate.sh",
|
|
32
|
+
"command": "bash ${CLAUDE_PLUGIN_ROOT}/.claude/hooks/mcp-gate.sh",
|
|
24
33
|
"timeout": 5
|
|
25
34
|
}
|
|
26
35
|
]
|
|
27
36
|
}
|
|
37
|
+
],
|
|
38
|
+
"SessionStart": [
|
|
39
|
+
{
|
|
40
|
+
"hooks": [
|
|
41
|
+
{
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "bash ${CLAUDE_PLUGIN_ROOT}/.claude/hooks/session-notify.sh",
|
|
44
|
+
"timeout": 10
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
28
48
|
]
|
|
29
|
-
}
|
|
49
|
+
},
|
|
50
|
+
"$comment2": [
|
|
51
|
+
"SessionStart is the PUSH surface, and it is a different kind of hook from",
|
|
52
|
+
"the gate above. The gate is a security control: offline, dependency-free,",
|
|
53
|
+
"and it fails CLOSED with exit 2. The notifier fails OPEN and silent, always",
|
|
54
|
+
"exits 0, and prints nothing on every error path, because a notification",
|
|
55
|
+
"that can break a session start is worse than no notification.",
|
|
56
|
+
"",
|
|
57
|
+
"It makes no network call. It prints a digest already on disk and fires a",
|
|
58
|
+
"detached refresh for next time, so the cost on a normal session is one",
|
|
59
|
+
"file test. The 10s timeout is for a slow disk, not for a fetch."
|
|
60
|
+
]
|
|
30
61
|
}
|
package/doorman/package.json
CHANGED
|
@@ -32,5 +32,9 @@ Worth separating, because the project doc uses one word for both.
|
|
|
32
32
|
its own past is not a record. The account is still the blocker, not the spec.
|
|
33
33
|
Full detail in the root `README.md` under **Bazantic**.
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
The three canonical Bazantic platform prize recipes are published in
|
|
36
|
+
[`bazantic/recipes/`](../../bazantic/recipes/):
|
|
37
|
+
1. `01-vet-mcp-candidate.json` — Agentify an API ($500/300/200)
|
|
38
|
+
2. `02-deepwiki-pinned-researcher.json` — Help an Agent Use Your Project / Continuity (2×$500)
|
|
39
|
+
3. `03-governance-pipeline.json` — Best Multi-API Recipe ($500/300/200)
|
|
40
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clembot-doorman",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Package Manager & Security Doorman for Claude Code: inspect your build, recommend vetted MCPs from prompt history, and block rogue tools before context.",
|
|
5
5
|
"homepage": "https://clembot-doorman.wanessalabs.com",
|
|
6
6
|
"type": "module",
|