phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Phaedo
|
|
2
|
+
Copyright 2026 Phaedo Labs, Inc.
|
|
3
|
+
|
|
4
|
+
This product includes software developed by Phaedo Labs, Inc. (https://phaedo.so),
|
|
5
|
+
licensed under the Apache License, Version 2.0.
|
|
6
|
+
|
|
7
|
+
"Phaedo" and the Phaedo logo are trademarks of Phaedo Labs, Inc. The Apache License,
|
|
8
|
+
Version 2.0 grants no trademark rights (Apache-2.0 §6); see TRADEMARKS for the brand
|
|
9
|
+
policy and permitted nominative use.
|
|
10
|
+
|
|
11
|
+
----------------------------------------------------------------------
|
|
12
|
+
Third-party components (not redistributed in this package)
|
|
13
|
+
----------------------------------------------------------------------
|
|
14
|
+
The reference browser extension vendors @mlc-ai/web-llm (Apache-2.0). That bundle
|
|
15
|
+
is NOT included in this npm package or the public repository (it is a gitignored
|
|
16
|
+
build artifact). If/when the extension is distributed, the @mlc-ai/web-llm
|
|
17
|
+
LICENSE and NOTICE travel with it.
|
package/README.md
CHANGED
|
@@ -1,11 +1,98 @@
|
|
|
1
|
-
# Phaedo MCP server
|
|
1
|
+
# Phaedo MCP server (`phaedo-mcp`)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
without the Chrome extension. One integration, every MCP client.
|
|
3
|
+
Phaedo learns how you write, work and decide, and keeps that profile on your phone.
|
|
4
|
+
This server lets your AI apps use it: Claude Desktop, Claude Code, Cursor, and any
|
|
5
|
+
other app that supports [MCP](https://modelcontextprotocol.io). With it, they can:
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- **Answer the way you like** — your preferred length, tone and format, without
|
|
8
|
+
you repeating it in every chat.
|
|
9
|
+
- **Check with you before acting** — an AI agent asks Phaedo whether you'd want it
|
|
10
|
+
to go ahead. When it's unsure, your phone gets an approve / deny card.
|
|
11
|
+
- **Learn from your answers** — each approve or deny teaches it how you want work
|
|
12
|
+
handled when you're not watching.
|
|
13
|
+
- **Get your signature for purchases** — you sign a spending permission on your
|
|
14
|
+
phone with Face ID; the agent never holds the key.
|
|
15
|
+
|
|
16
|
+
Your profile comes from your phone encrypted. The copy kept on this computer, for
|
|
17
|
+
when your phone is offline, is encrypted too.
|
|
18
|
+
|
|
19
|
+
## What you need
|
|
20
|
+
|
|
21
|
+
1. **The Phaedo phone app.** Your profile lives there. The app isn't open to the
|
|
22
|
+
public yet — [phaedo.so](https://phaedo.so) has a link to hear when it is.
|
|
23
|
+
2. **Node.js**, free from [nodejs.org](https://nodejs.org) (the LTS button).
|
|
24
|
+
|
|
25
|
+
Without the phone app there is no profile, and the server says so plainly rather
|
|
26
|
+
than guessing. You can still try Phaedo with a made-up example profile.
|
|
27
|
+
|
|
28
|
+
## Try it with the example profile (no phone needed)
|
|
29
|
+
|
|
30
|
+
This loads a made-up person's profile, so you can see what Phaedo adds before you
|
|
31
|
+
have your own.
|
|
32
|
+
|
|
33
|
+
**Claude Code** — run this in Terminal (it applies to all your projects):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add -s user phaedo -e PHAEDO_FINGERPRINT=sample -- npx -y phaedo-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Claude Desktop** — open Settings → Developer → Edit Config, add the `phaedo`
|
|
40
|
+
entry below inside `mcpServers`, save, then quit and reopen Claude Desktop:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"phaedo": {
|
|
46
|
+
"command": "npx",
|
|
47
|
+
"args": ["-y", "phaedo-mcp"],
|
|
48
|
+
"env": { "PHAEDO_FINGERPRINT": "sample" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If Claude Desktop reports that the server failed to start, it couldn't find
|
|
55
|
+
`npx`. Replace `"npx"` with the full path that this prints:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
which npx
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then start a new chat and ask: *"Load my Phaedo profile."* To stop using the
|
|
62
|
+
example, delete the `env` line in Claude Desktop, or in Claude Code run
|
|
63
|
+
`claude mcp remove -s user phaedo` and add it again without `-e …`. The server
|
|
64
|
+
will then tell you there's no profile until you pair your phone.
|
|
65
|
+
|
|
66
|
+
## Use your own profile (with the phone app)
|
|
67
|
+
|
|
68
|
+
1. **Get this folder.** On [the GitHub page](https://github.com/phaedo-labs/phaedo-mcp),
|
|
69
|
+
click **Code → Download ZIP**, unzip it, and open the `mcp` folder inside.
|
|
70
|
+
2. **Run the installer.**
|
|
71
|
+
- macOS: double-click **`installers/macos/Install Phaedo MCP.command`**. If macOS
|
|
72
|
+
says it can't be opened, right-click it and choose **Open**.
|
|
73
|
+
- Windows: double-click **`installers/windows/Install Phaedo MCP.bat`**.
|
|
74
|
+
- Linux: run `bash installers/linux/install-phaedo-mcp.sh`.
|
|
75
|
+
|
|
76
|
+
It adds Phaedo to Claude Desktop, Cursor and Claude Code — keeping your other
|
|
77
|
+
settings, and backing each file up first — then starts pairing.
|
|
78
|
+
3. **Pair with your phone.** Open Phaedo on your phone. On the same Wi-Fi, approve
|
|
79
|
+
**"Phaedo MCP Server"** and check that the code matches the one in Terminal.
|
|
80
|
+
On a different network, tap **Pair with code** and type the code Terminal shows.
|
|
81
|
+
4. **Quit and reopen your AI apps**, start a new chat, and ask: *"Load my Phaedo
|
|
82
|
+
profile."*
|
|
83
|
+
|
|
84
|
+
You can disconnect this computer at any time from the **Agents** screen on your
|
|
85
|
+
phone.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Reference
|
|
90
|
+
|
|
91
|
+
Everything below is for developers: the full tool list, how the profile is
|
|
92
|
+
loaded, and the test suite.
|
|
93
|
+
|
|
94
|
+
The server implements the MCP binding of the Anamnesis protocol
|
|
95
|
+
(`docs/protocol/mcp-binding.md` §3.1):
|
|
9
96
|
|
|
10
97
|
| Surface | Name | Returns |
|
|
11
98
|
|---|---|---|
|
|
@@ -17,12 +104,24 @@ Implements `docs/protocol/mcp-binding.md` §3.1:
|
|
|
17
104
|
| tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
|
|
18
105
|
| tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
|
|
19
106
|
| tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |
|
|
107
|
+
| tool | `phaedo_sign_mandate` | §8.3 — ask the subject to **sign** an AP2 Intent Mandate built from their standing authorizations (a verifiable payment credential). Deposits the request and returns a `request_id` + the unsigned mandate (non-blocking). The subject signs on their phone in the Secure Enclave, or declines. |
|
|
108
|
+
| tool | `phaedo_sign_status` | poll a `phaedo_sign_mandate` request by `request_id` (+ the returned mandate); returns the mandate carrying a **verified** signature once signed, `refused` if declined, or pending/expired. The signature is checked against the subject's published signing key before it is returned. |
|
|
20
109
|
|
|
21
110
|
The projection is rendered by the **same** `context-block.js` the extension
|
|
22
111
|
injects, so MCP injection is byte-identical to web injection.
|
|
23
112
|
|
|
24
113
|
## Status
|
|
25
114
|
|
|
115
|
+
The phases below track the **fingerprint source** (how the server obtains the
|
|
116
|
+
fingerprint). The consultation, real-time escalation, act-as-me / delegation, and
|
|
117
|
+
receipts-audit surfaces (the tools table above) shipped on top of these across the
|
|
118
|
+
**0.2.x** line, and the §8.3 signing pair in **0.3.0** — see `CHANGELOG.md`.
|
|
119
|
+
|
|
120
|
+
> **Install 0.3.0 or later.** 0.2.0, 0.2.1 and 0.2.2 were published with three
|
|
121
|
+
> modules missing from the package, so they exit with `ERR_MODULE_NOT_FOUND`
|
|
122
|
+
> instead of starting. 0.3.0 is the first working release since 0.1.1, and
|
|
123
|
+
> `test-package-closure.mjs` now boots the packed tarball in CI.
|
|
124
|
+
|
|
26
125
|
- **Phase 1a (this cut):** reads the fingerprint from a local JSON file (see
|
|
27
126
|
*Fingerprint source*). Lets injection work in a real client today, no crypto.
|
|
28
127
|
- **Phase 1b-i (done):** the spec-faithful key boundary — authorize → pull
|
|
@@ -50,8 +149,10 @@ injects, so MCP injection is byte-identical to web injection.
|
|
|
50
149
|
`../docs/roadmap/mcp-revocable-approval-plan.md`. Phone-gated unlock stays a
|
|
51
150
|
deferred enterprise mode behind a config flag.
|
|
52
151
|
|
|
53
|
-
**
|
|
54
|
-
|
|
152
|
+
**No conversation capture.** MCP never returns the conversation, so your
|
|
153
|
+
fingerprint does not learn from MCP sessions (binding §5). (Consultation,
|
|
154
|
+
escalation, and `phaedo_record_outcome` shape and record *decisions*, but none
|
|
155
|
+
capture conversation text into the fingerprint.)
|
|
55
156
|
Local **stdio** only; **standard** mode only (enhanced-privacy/Stained-Glass and
|
|
56
157
|
remote HTTP/SSE are out of this cut).
|
|
57
158
|
|
|
@@ -112,9 +213,13 @@ foregrounded/reachable at the configured `endpoint`. *(You can still hand-copy
|
|
|
112
213
|
`pairing.sample.json` → `pairing.json` from the extension's `phaedo_local_pairing`
|
|
113
214
|
if you prefer.)*
|
|
114
215
|
|
|
115
|
-
**Phase 1a — local JSON
|
|
116
|
-
|
|
117
|
-
|
|
216
|
+
**Phase 1a — local JSON.** With no pairing record, reads a **vault payload**
|
|
217
|
+
from `PHAEDO_FINGERPRINT` (a path, or the word `sample` for the shipped
|
|
218
|
+
`mcp/sample-fingerprint.json`), then `mcp/fingerprint.json`. With neither, there
|
|
219
|
+
is **no profile**: every tool returns a plain-language `decryption_failed` error
|
|
220
|
+
saying so. The sample is never served unless asked for by name — until 0.3.1 it
|
|
221
|
+
was the silent fallback, so a fresh `npx phaedo-mcp` injected a made-up person's
|
|
222
|
+
style without saying so. The file looks like this:
|
|
118
223
|
```json
|
|
119
224
|
{
|
|
120
225
|
"phaedo_fingerprint": { "layers": { ... }, "fingerprint_id": "...", "persona_strength": 0.6 },
|
|
@@ -181,6 +286,60 @@ fully automatic depending on the client:
|
|
|
181
286
|
|
|
182
287
|
Every chat in that project then injects automatically.
|
|
183
288
|
|
|
289
|
+
### Capture from Claude Code / Cowork (optional)
|
|
290
|
+
|
|
291
|
+
`npm run setup:code-hook` also installs a **SessionEnd** hook that reads the just-ended
|
|
292
|
+
transcript locally, derives behavioral signals (deterministic pattern detectors), and
|
|
293
|
+
deposits an encrypted **signal delta** to the owner — so your Claude Code / Cowork usage
|
|
294
|
+
feeds the fingerprint, not just the browser. The transcript is counted and **discarded**
|
|
295
|
+
(nothing but signal names + integers leaves the machine); a paired phone/extension drains
|
|
296
|
+
and merges it.
|
|
297
|
+
|
|
298
|
+
Optionally enrich the deterministic detectors with a **local model** (opt-in) — it
|
|
299
|
+
classifies the messages the regexes miss, against the same taxonomy, and feeds the same
|
|
300
|
+
counters (so re-fires stay idempotent). Any **Ollama / OpenAI-compatible** endpoint works:
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
ollama pull llama3.1:8b # or any local model
|
|
304
|
+
npm run setup:capture-model -- llama3.1:8b # writes ~/.phaedo-mcp/capture-model.json
|
|
305
|
+
npm run setup:capture-model -- off # turn it back off
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Point it elsewhere with `--capture-url <url>` (default `http://localhost:11434`). It's
|
|
309
|
+
fail-safe: endpoint down/slow/garbage → the hook silently falls back to the deterministic
|
|
310
|
+
detectors, and a model call can never break or stall a session (30s budget, hook always
|
|
311
|
+
exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
|
|
312
|
+
Dock-launched Claude Desktop, a shell `export` does not.
|
|
313
|
+
|
|
314
|
+
### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
|
|
315
|
+
|
|
316
|
+
`npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
|
|
317
|
+
PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
|
|
318
|
+
(`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
|
|
319
|
+
`eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
|
|
320
|
+
(`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
|
|
321
|
+
§10.6 receipt, `agent_id: claude-code/ship-hook`.
|
|
322
|
+
|
|
323
|
+
- **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
|
|
324
|
+
Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
|
|
325
|
+
`ask`, so the action comes to you with one plain line saying why.
|
|
326
|
+
- **Outcomes are yours, never inferred.** The hook records `approved` only when it has
|
|
327
|
+
evidence you were asked: Claude Code fired PermissionRequest for that command, and the
|
|
328
|
+
command then ran. That holds in every mode except auto / dontAsk, where no person
|
|
329
|
+
answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
|
|
330
|
+
decline). No prompt shown, nothing recorded; the agent is told so, and records your
|
|
331
|
+
reaction only if you give one.
|
|
332
|
+
- **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
|
|
333
|
+
failing or unfinished checks, a large diff (> 1,000 code lines or > 20 files), or a sensitive
|
|
334
|
+
path (CI/deploy config, auth/crypto/secrets by name, or your repo's `.phaedo-sensitive` list,
|
|
335
|
+
read from the base branch) make it high-stakes. It can raise the stakes, never lower them.
|
|
336
|
+
- **Stale profile.** It reads the at-rest profile cache (so it never pings your phone);
|
|
337
|
+
when that copy is over 14 days old the agent tells you once a day, with the refresh:
|
|
338
|
+
`npm run pull` and approve on the phone.
|
|
339
|
+
- **See what it has done:** `npm run ship-readout` (`-- --days 90` to widen).
|
|
340
|
+
- **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
|
|
341
|
+
runs as if the hook weren't there.
|
|
342
|
+
|
|
184
343
|
## Consult at decision points (`phaedo_consult`, §10)
|
|
185
344
|
|
|
186
345
|
Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
|
|
@@ -254,17 +413,21 @@ returns a §10.3 signal. Any failure/timeout/parse error **falls back to the rul
|
|
|
254
413
|
engine**, so the model can only improve on, never break, the floor (and a missing
|
|
255
414
|
model never breaks consultation). The §10.4 boundary is enforced on parse.
|
|
256
415
|
|
|
257
|
-
The **
|
|
258
|
-
`
|
|
259
|
-
|
|
416
|
+
The judge is **off by default** — the rule engine answers. Measured 2026-10-18 against
|
|
417
|
+
one cached profile, `llama3.1:8b` answered a $12k irreversible payment `clarify 0.9` on
|
|
418
|
+
one run and `proceed_with_note 0.9` on the next, where the rules say `clarify` every
|
|
419
|
+
time. Opt in at install time once a model has earned it:
|
|
260
420
|
|
|
261
421
|
```bash
|
|
262
|
-
npm run setup # model judge
|
|
263
|
-
node install.mjs --
|
|
422
|
+
npm run setup # model judge OFF (rule engine) — the default
|
|
423
|
+
node install.mjs --model-judge # ON, auto-detect a local Ollama chat model
|
|
424
|
+
node install.mjs --consult-model qwen2.5:7b # ON, pinned
|
|
264
425
|
node install.mjs --consult-url http://localhost:11434 # custom endpoint
|
|
265
|
-
node install.mjs --no-model-judge # rule engine only
|
|
266
426
|
```
|
|
267
427
|
|
|
428
|
+
Auto-detect prefers `llama3.1:8b` when installed and never picks an extraction
|
|
429
|
+
fine-tune or an embedding model (`nomic-embed-text` cannot answer a chat prompt).
|
|
430
|
+
|
|
268
431
|
Use a **general** chat model (NOT the extraction fine-tune). To run it: `ollama
|
|
269
432
|
pull llama3.1:8b`. Raw env (if configuring a client by hand):
|
|
270
433
|
`PHAEDO_CONSULT_MODEL`, `PHAEDO_CONSULT_MODEL_URL`, `PHAEDO_CONSULT_API_KEY`
|
|
@@ -303,7 +466,7 @@ The installer writes exactly this. The minimal **paired** entry needs no `env`
|
|
|
303
466
|
"mcpServers": {
|
|
304
467
|
"phaedo": {
|
|
305
468
|
"command": "/absolute/path/to/node",
|
|
306
|
-
"args": ["/
|
|
469
|
+
"args": ["/absolute/path/to/phaedo/mcp/server.js"]
|
|
307
470
|
}
|
|
308
471
|
}
|
|
309
472
|
}
|
|
@@ -315,5 +478,5 @@ chat and prepend the returned `projection`.
|
|
|
315
478
|
|
|
316
479
|
**Claude Code** — `.mcp.json` in a project, or:
|
|
317
480
|
```bash
|
|
318
|
-
claude mcp add phaedo -- /absolute/path/to/node /
|
|
481
|
+
claude mcp add phaedo -- /absolute/path/to/node /absolute/path/to/phaedo/mcp/server.js
|
|
319
482
|
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// §10.2 `action_binding` — tying a consultation to the call it claims to describe.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. Every field the resolver decides on lives in `action_descriptor`,
|
|
4
|
+
// and every one of them is authored by the agent being gated: domain, magnitude,
|
|
5
|
+
// reversible, amount, summary. A standing authorization like
|
|
6
|
+
//
|
|
7
|
+
// { match: { domains: ["writing"], magnitude_max: "medium", reversible: true },
|
|
8
|
+
// effect: "autoproceed" }
|
|
9
|
+
//
|
|
10
|
+
// therefore fires on whatever the agent SAYS it is doing, not on what it is doing.
|
|
11
|
+
// A buggy, prompt-injected, or hostile agent auto-proceeds on anything by
|
|
12
|
+
// describing it as low-magnitude reversible writing. The escalation rail inherits
|
|
13
|
+
// the same assumption: the subject approves the agent's prose summary.
|
|
14
|
+
//
|
|
15
|
+
// This is a known 2026 vulnerability class, not a hypothetical — agents requesting
|
|
16
|
+
// approval on a shown path while operating on the resolved target (CWE-61 symlink
|
|
17
|
+
// following + CWE-451 UI misrepresentation) produced CVE-2026-12958 (Amazon) and
|
|
18
|
+
// CVE-2026-50549 (Cursor), among others.
|
|
19
|
+
//
|
|
20
|
+
// WHAT THIS DOES, AND WHAT IT DOES NOT. A binding does NOT make the descriptor
|
|
21
|
+
// truthful — nothing at this layer can, short of the resolver executing the call
|
|
22
|
+
// itself. What it does:
|
|
23
|
+
//
|
|
24
|
+
// 1. makes a mismatch PROVABLE after the fact (the receipt records the exact
|
|
25
|
+
// tool and a hash of its arguments, so the agent's own logs can be checked
|
|
26
|
+
// against what the oracle was told),
|
|
27
|
+
// 2. gives the subject the REAL call on the escalation card, next to the
|
|
28
|
+
// agent's description of it, and
|
|
29
|
+
// 3. lets an authored authorization demand a binding before it will RELAX
|
|
30
|
+
// anything (§10.5 `require_binding`), so the blast radius of a fabricated
|
|
31
|
+
// descriptor is bounded by what the subject explicitly opted into.
|
|
32
|
+
//
|
|
33
|
+
// The hash is over the RFC 8785 canonical form of the arguments, so an agent and
|
|
34
|
+
// an auditor computing it independently agree byte-for-byte. Arguments themselves
|
|
35
|
+
// are NEVER carried — only a digest, plus an optional short human preview that the
|
|
36
|
+
// subject sees on the escalation sheet.
|
|
37
|
+
//
|
|
38
|
+
// PURE: no I/O, no globals, no crypto beyond the hash the caller injects. Shared by
|
|
39
|
+
// the resolver (mcp/consult-core.js), the receipt writer (mcp/receipts.js) and the
|
|
40
|
+
// escalation payload builder (mcp/escalation.js) so the three cannot drift.
|
|
41
|
+
|
|
42
|
+
import { PhaedoError } from './errors.js';
|
|
43
|
+
|
|
44
|
+
// A preview is for a human glance on a phone sheet, not a serialization format.
|
|
45
|
+
export const PREVIEW_MAX = 200;
|
|
46
|
+
export const TOOL_NAME_MAX = 120;
|
|
47
|
+
// A hex or base64url digest. Bounded so a malformed field can't bloat the receipt
|
|
48
|
+
// store or the (encrypted) escalation payload.
|
|
49
|
+
const HASH_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
50
|
+
|
|
51
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
52
|
+
const clip = (s, n) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
53
|
+
|
|
54
|
+
// Parse + validate §10.2 `action_binding`. Returns the normalized binding, or null
|
|
55
|
+
// when absent. THROWS `request_malformed` when present but structurally wrong — a
|
|
56
|
+
// half-formed binding is worse than none, because a caller could believe it is
|
|
57
|
+
// bound when the resolver treats it as unbound.
|
|
58
|
+
export function normalizeActionBinding(raw) {
|
|
59
|
+
if (raw == null) return null;
|
|
60
|
+
if (!isObj(raw)) throw new PhaedoError('request_malformed', 'action_binding must be an object.');
|
|
61
|
+
|
|
62
|
+
const toolName = typeof raw.tool_name === 'string' ? raw.tool_name.trim() : '';
|
|
63
|
+
if (!toolName) throw new PhaedoError('request_malformed', 'action_binding.tool_name is required and must be a non-empty string.');
|
|
64
|
+
if (toolName.length > TOOL_NAME_MAX) throw new PhaedoError('request_malformed', `action_binding.tool_name exceeds ${TOOL_NAME_MAX} characters.`);
|
|
65
|
+
|
|
66
|
+
const hash = typeof raw.arguments_hash === 'string' ? raw.arguments_hash.trim() : '';
|
|
67
|
+
if (!hash) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash is required and must be a non-empty string.');
|
|
68
|
+
if (!HASH_RE.test(hash)) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash must be a hex or base64url digest (16–128 chars).');
|
|
69
|
+
|
|
70
|
+
const out = { tool_name: clip(toolName, TOOL_NAME_MAX), arguments_hash: hash };
|
|
71
|
+
if (typeof raw.arguments_preview === 'string' && raw.arguments_preview.trim()) {
|
|
72
|
+
out.arguments_preview = clip(raw.arguments_preview, PREVIEW_MAX);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Is this request bound to a concrete call? The single predicate every consumer
|
|
78
|
+
// asks, so "bound" means exactly one thing across the resolver, the policy engine,
|
|
79
|
+
// the receipt and the escalation card.
|
|
80
|
+
export function isBound(binding) {
|
|
81
|
+
return !!(binding && binding.tool_name && binding.arguments_hash);
|
|
82
|
+
}
|
package/agreement.js
CHANGED
|
@@ -77,13 +77,97 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ── Split by resolver revision (precedent track Phase 1b) ─────────────────────
|
|
81
|
+
// Which engine answered a receipt: the `resolverRevision` driver the consult stamped (the
|
|
82
|
+
// rules' RESOLVER_REVISION, or `judge:<model>`). A matched pre-flight is a standing rule
|
|
83
|
+
// answering, not the inferred path. Anything else predates the stamp (2026-10-20) or carried
|
|
84
|
+
// no inferred prediction (an abstain) — "not recorded", never guessed.
|
|
85
|
+
export const NOT_RECORDED = 'not recorded';
|
|
86
|
+
export function revisionOf(r) {
|
|
87
|
+
const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'resolverRevision') : null;
|
|
88
|
+
if (d && typeof d.value === 'string' && d.value) return d.value;
|
|
89
|
+
if (r?.via === 'phaedo_check_authorization') return 'standing rule (pre-flight)';
|
|
90
|
+
return NOT_RECORDED;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* (receipts, { windowDays, now }) -> { [revision]: overall-bucket }, so a behaviour change
|
|
95
|
+
* shows up as two rates instead of one blurred one. Same scoring as computeAgreement.
|
|
96
|
+
*/
|
|
97
|
+
export function computeAgreementByRevision(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now() } = {}) {
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const r of Array.isArray(receipts) ? receipts : []) {
|
|
100
|
+
const k = revisionOf(r);
|
|
101
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
102
|
+
groups.get(k).push(r);
|
|
103
|
+
}
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [k, rs] of groups) {
|
|
106
|
+
const rep = computeAgreement(rs, { windowDays, now });
|
|
107
|
+
if (rep.window.in_window > 0) out[k] = rep.overall;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── The transportable summary (receipts digest → phone) ───────────────────────
|
|
113
|
+
// `computeAgreement` is the full local report; this is the bounded slice that rides
|
|
114
|
+
// the `agent_to_phone` receipts digest so the subject can see how well their oracle
|
|
115
|
+
// is tracking them, per domain, without opening a terminal.
|
|
116
|
+
//
|
|
117
|
+
// It is derived ENTIRELY from the subject's own receipts and only ever travels
|
|
118
|
+
// between the subject's own devices (ciphertext over the relay), so it raises no
|
|
119
|
+
// §10.4 question — but it still carries no layer content, no rationale, and no
|
|
120
|
+
// `drivers`: counts and a rate, nothing else.
|
|
121
|
+
//
|
|
122
|
+
// Bounded on purpose (the digest is a last-write-wins payload, so it must not grow
|
|
123
|
+
// without limit): unscored domains are dropped (a domain with nothing scored says
|
|
124
|
+
// nothing), the rest are ranked by `scored` and capped. Ties break by name so the
|
|
125
|
+
// payload is DETERMINISTIC — an unstable key order would make every deposit look
|
|
126
|
+
// like a change.
|
|
127
|
+
export const DIGEST_DOMAIN_CAP = 8;
|
|
128
|
+
|
|
129
|
+
const slimBucket = (b) => ({
|
|
130
|
+
rate: b.rate,
|
|
131
|
+
scored: b.scored,
|
|
132
|
+
agreements: b.agreements,
|
|
133
|
+
disagreements: b.disagreements,
|
|
134
|
+
non_directional: b.non_directional,
|
|
135
|
+
awaiting_user_action: b.awaiting_user_action,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// (receipts, { windowDays, now, domainCap }) -> { window_days, overall, domains }
|
|
139
|
+
export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now(), domainCap = DIGEST_DOMAIN_CAP } = {}) {
|
|
140
|
+
const report = computeAgreement(receipts, { windowDays, now });
|
|
141
|
+
const domains = {};
|
|
142
|
+
Object.entries(report.domains)
|
|
143
|
+
.filter(([, b]) => b.scored > 0)
|
|
144
|
+
.sort((a, b) => (b[1].scored - a[1].scored) || a[0].localeCompare(b[0]))
|
|
145
|
+
.slice(0, Math.max(0, domainCap))
|
|
146
|
+
.forEach(([name, b]) => { domains[name] = slimBucket(b); });
|
|
147
|
+
return { window_days: windowDays, overall: slimBucket(report.overall), domains };
|
|
148
|
+
}
|
|
149
|
+
|
|
80
150
|
// ── Dev CLI: `node agreement.js` / `npm run agreement` — local readout only ───
|
|
81
151
|
// Decrypts the local receipt store with the local key; nothing leaves the device.
|
|
82
152
|
// NOT product UX and NOT fed back into deference (see header).
|
|
83
153
|
if (process.argv[1] && process.argv[1].endsWith('agreement.js')) {
|
|
84
|
-
const
|
|
154
|
+
const args = process.argv.slice(2);
|
|
155
|
+
const byRevision = args.includes('--by-revision');
|
|
156
|
+
const days = Number(args.find((a) => /^\d+$/.test(a))) || DEFAULT_WINDOW_DAYS;
|
|
85
157
|
Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
|
|
86
|
-
const
|
|
158
|
+
const receipts = await readReceipts(defaultStateDir());
|
|
159
|
+
if (byRevision) {
|
|
160
|
+
const split = computeAgreementByRevision(receipts, { windowDays: days });
|
|
161
|
+
console.log(`Agreement by rule-engine version — last ${days}d\n`);
|
|
162
|
+
const names = Object.keys(split).sort((a, b) => (a === NOT_RECORDED) - (b === NOT_RECORDED) || a.localeCompare(b));
|
|
163
|
+
if (!names.length) console.log('No receipts in the window.');
|
|
164
|
+
for (const k of names) {
|
|
165
|
+
const b = split[k];
|
|
166
|
+
console.log(`${k.padEnd(34)} rate ${b.rate === null ? ' — ' : (b.rate * 100).toFixed(1) + '%'} (${b.agreements}✓ ${b.disagreements}✗ of ${b.scored} scored · ${b.non_directional} non-directional · ${b.awaiting_user_action} awaiting)`);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const report = computeAgreement(receipts, { windowDays: days });
|
|
87
171
|
const { overall: o, window: w } = report;
|
|
88
172
|
console.log(`Agreement over the last ${w.days}d — ${w.in_window}/${w.total_receipts} receipts in window\n`);
|
|
89
173
|
const line = (label, b) =>
|