loopctl-mcp-server 2.97.0 → 2.98.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 +1 -0
- package/index.js +86 -0
- package/lib/dispatch-revoke.js +125 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -519,6 +519,7 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
|
|
|
519
519
|
|---|---|
|
|
520
520
|
| `signup` | **US-26.7.1.** Create a NEW **agent-rooted (KB-tier)** tenant and mint its one-time root API key — entirely through this call, no human operator, no hardware authenticator, no existing API key required. The tenant gets the FULL knowledge-wiki surface but **cannot** perform work-breakdown / chain-of-custody operations (those require a separate human-anchored tenant via the WebAuthn ceremony at `https://loopctl.com/signup`). Rate-limited per client IP (<= 5/hour). The `raw_key` is shown ONCE — save it immediately (e.g. as `LOOPCTL_USER_KEY`). Required: `name`, `slug`, `email`. |
|
|
521
521
|
| `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`, and `parent_dispatch_id` for every caller a dispatch minted — a dispatch may only be minted INSIDE the caller's own lineage. Omitting `parent_dispatch_id` requests a ROOT dispatch (a new independent lineage tree), which only the tenant's `user`-role operator key may create; anyone else gets `403 root_dispatch_forbidden` with their own dispatch id in `remediation.your_dispatch_id`. A parent outside the caller's lineage is `403 parent_outside_caller_lineage`. Optional: `story_id`. **LCP-1 §9.2 signed profile:** optionally enroll an agent key via `agent_pubkey` (hex) + `alg` + an `attestation` (from `custody_sign_attestation`) + `attestation_conditions`. |
|
|
522
|
+
| `revoke_dispatch` | **Revoke a dispatch AND ITS WHOLE SUBTREE** (`POST /api/v1/dispatches/:id/revoke`), plus the ephemeral api_key each one minted — the revoke matches the dispatch OR any row carrying it in `lineage_path`, so name a LEAF unless you mean the subtree. Reach for it when a dead session's key is blocking new work: an un-revoked key OCCUPIES its agent's slot in the partial unique index `api_keys_one_role_per_agent_idx` (`(tenant_id, agent_id, role)` WHERE `revoked_at IS NULL`), which cannot also test `expires_at` — a partial-index predicate must be IMMUTABLE and `now()` is STABLE — so even an EXPIRED key holds it and the next mint answers `422 agent already has an active key with this role`. Waiting works too, slowly: the expired-dispatch sweep runs every minute and the expired-api-key sweep every five, so the slot clears at the TTL (four hours for a placement session dispatch). For a PARKED STORY prefer `force_unclaim_story`, which revokes that story's own session dispatch and frees the stage as well. It does NOT clear `implementer_dispatch_id`: that is custody provenance, and a revoked dispatch still resolves, so verify/report/review-complete compare the same lineage afterwards. `role: :orchestrator` WITH the hierarchy (orchestrator, user or superadmin all pass; an agent key is `403 insufficient_role`), so no env var is pinned — `LOOPCTL_USER_KEY` is merely PREFERRED, for the ceiling reason next. `403 dispatch_outside_caller_lineage` when the target is not in your lineage — a dispatch may only be revoked by a caller it descends from, because the revoke cascades; the body carries `remediation.your_dispatch_id`, and the tenant's `user`-role operator key may revoke anywhere in its tenant. `403 unlineaged_revoke_forbidden` when your key was minted by no dispatch and is below `user` role — a legacy `LOOPCTL_ORCH_KEY` is exactly that, and with no lineage there is no subtree that would bound a cascading revoke, so it could otherwise revoke the tenant's root; use the operator key, or `force_unclaim_story` for a parked story, or mint a dispatch under an active parent and revoke from inside that lineage. `403 custody_tier_required` on an agent-rooted tenant, `503 tenant_halted` under a custody halt, 404 for an unknown dispatch or another tenant's. Idempotent: an already-revoked dispatch answers 200 with `revoked_count: 0` and its ORIGINAL `revoked_at`. Required: `dispatch_id` (refused locally if it is not a UUID). |
|
|
522
523
|
| `register_custody_owner_key` | **LCP-1 §9.2.** Register/rotate the tenant custody OWNER key — the root of trust the attestation chain hangs from. Private half stays with you; requires `LOOPCTL_USER_KEY` and a human-anchored tenant. Required: `owner_pubkey` (hex). ROTATION additionally requires `rotation_proof` (from `custody_sign_owner_rotation`); first registration needs none. |
|
|
523
524
|
| `list_enrolled_agent_keys` | **LCP-1 §9.1.1 transparency.** List the agent public keys enrolled under your tenant, reconstructed from the tamper-evident audit chain (not a mutable listing). Compare against the keys you generated; any excess is operator-minted. Keyset-paged (`limit`, `cursor`). |
|
|
524
525
|
| `custody_generate_keypair` | **LCP-1 §9.** Generate an Ed25519 keypair LOCALLY (private key never leaves the process). Returns `public_key_hex` + `private_key_hex`. |
|
package/index.js
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
resolveEscalation as resolveEscalationRequest,
|
|
55
55
|
storyStage as storyStageRequest,
|
|
56
56
|
} from "./lib/delivery-loop.js";
|
|
57
|
+
import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
|
|
57
58
|
|
|
58
59
|
// Single source of truth for the server version: the package.json this file
|
|
59
60
|
// ships with (npm always includes package.json in the published tarball).
|
|
@@ -2918,6 +2919,30 @@ async function createDispatch({
|
|
|
2918
2919
|
return toContent(result);
|
|
2919
2920
|
}
|
|
2920
2921
|
|
|
2922
|
+
// #862: the reachable half of `Dispatches.revoke/2`.
|
|
2923
|
+
//
|
|
2924
|
+
// LOOPCTL_USER_KEY is PREFERRED but NOT pinned, and the distinction is the whole point.
|
|
2925
|
+
// The role gate is `role: :orchestrator` WITH the hierarchy, not `exact_role`, so pinning
|
|
2926
|
+
// one variable would refuse configurations the server accepts — the break #861 round 2 had
|
|
2927
|
+
// to revert. But the endpoint also applies the LINEAGE CEILING, and there an UNLINEAGED
|
|
2928
|
+
// caller passes only at `role: :user` (`403 unlineaged_revoke_forbidden` otherwise, review
|
|
2929
|
+
// #862 finding 1). A bare legacy LOOPCTL_ORCH_KEY is exactly that shape, so sending it by
|
|
2930
|
+
// default made the commonest configuration the refused one.
|
|
2931
|
+
//
|
|
2932
|
+
// So the key is passed as an ORDINARY override, not with `exactKey`: `resolveKey`'s order is
|
|
2933
|
+
// LOOPCTL_API_KEY > this > LOOPCTL_ORCH_KEY. A global key still wins (it may be the operator
|
|
2934
|
+
// key, or a dispatch-minted one revoking inside its own subtree — both legitimate), the
|
|
2935
|
+
// operator key is next, and a legacy orchestrator key is still SENT rather than refused
|
|
2936
|
+
// locally, so the caller gets the server's own message naming what to do instead.
|
|
2937
|
+
async function revokeDispatch(args) {
|
|
2938
|
+
return toContent(
|
|
2939
|
+
await revokeDispatchRequest(args, {
|
|
2940
|
+
apiCall: (method, path, body) =>
|
|
2941
|
+
apiCall(method, path, body, process.env.LOOPCTL_USER_KEY),
|
|
2942
|
+
}),
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2921
2946
|
// LCP-1 §9.2: register/rotate the tenant custody owner key (root of trust).
|
|
2922
2947
|
// ROTATION (an owner key already exists) requires `rotation_proof`: a hex Ed25519
|
|
2923
2948
|
// signature by the OUTGOING owner key over owner_rotation_preimage(tenant_id,
|
|
@@ -7638,6 +7663,64 @@ const TOOLS = [
|
|
|
7638
7663
|
},
|
|
7639
7664
|
},
|
|
7640
7665
|
|
|
7666
|
+
{
|
|
7667
|
+
name: "revoke_dispatch",
|
|
7668
|
+
description:
|
|
7669
|
+
"REVOKE A DISPATCH AND ITS WHOLE SUBTREE (POST /api/v1/dispatches/:id/revoke), plus the " +
|
|
7670
|
+
"ephemeral api_key each one minted. `Dispatches.revoke/2` matches the dispatch OR any row " +
|
|
7671
|
+
"carrying it in `lineage_path`, so revoking a tree's root revokes the tree — name a LEAF " +
|
|
7672
|
+
"unless you mean the subtree.\n\n" +
|
|
7673
|
+
"WHAT IT IS FOR. A session dispatch whose session is gone still has `revoked_at IS NULL`, " +
|
|
7674
|
+
"so it OCCUPIES its agent's slot in the partial unique index " +
|
|
7675
|
+
"`api_keys_one_role_per_agent_idx` — `(tenant_id, agent_id, role)` WHERE " +
|
|
7676
|
+
"`revoked_at IS NULL`. That index cannot also test `expires_at` (Postgres requires a " +
|
|
7677
|
+
"partial-index predicate to be IMMUTABLE and `now()` is STABLE), so an EXPIRED key still " +
|
|
7678
|
+
"holds the slot and the next mint for that agent answers 422 'agent already has an active " +
|
|
7679
|
+
"key with this role'. Measured on story d9975b31: place_dispatch returned exactly that and " +
|
|
7680
|
+
"wrote nothing.\n\n" +
|
|
7681
|
+
"WAITING ALSO WORKS, SLOWLY. RevokeExpiredDispatchesWorker sweeps expired dispatches every " +
|
|
7682
|
+
"minute and RevokeExpiredApiKeysWorker sweeps expired keys every five, so a stranded " +
|
|
7683
|
+
"credential clears at its TTL — four hours for a placement session dispatch. Use this to " +
|
|
7684
|
+
"not wait. For a PARKED STORY prefer force_unclaim_story: it revokes that story's own " +
|
|
7685
|
+
"session dispatch on its way past, and it frees the stage as well.\n\n" +
|
|
7686
|
+
"IT DOES NOT CLEAR `implementer_dispatch_id`. That is custody provenance, and loopctl " +
|
|
7687
|
+
"resolves it with a read that returns a REVOKED row exactly as it returns a live one, so " +
|
|
7688
|
+
"verify / report / review-complete compare the same lineage afterwards. Revoking kills the " +
|
|
7689
|
+
"credential and changes no custody verdict.\n\n" +
|
|
7690
|
+
"REFUSALS. `role: :orchestrator` WITH the hierarchy, so an orchestrator, user or superadmin " +
|
|
7691
|
+
"key passes and an agent key is 403 insufficient_role — this is not exact_role, so no " +
|
|
7692
|
+
"particular env var is pinned; LOOPCTL_USER_KEY is merely PREFERRED, because of the " +
|
|
7693
|
+
"ceiling below. 403 custody_tier_required on an agent-rooted tenant. " +
|
|
7694
|
+
"403 dispatch_outside_caller_lineage " +
|
|
7695
|
+
"when the target is not in your lineage: a dispatch may only be revoked by a caller it " +
|
|
7696
|
+
"descends from, because the revoke cascades — the body carries your own dispatch id as " +
|
|
7697
|
+
"remediation.your_dispatch_id, and the tenant's user-role operator key may revoke anywhere " +
|
|
7698
|
+
"in its tenant. 403 unlineaged_revoke_forbidden when your key was minted by NO dispatch " +
|
|
7699
|
+
"and is below user role — a legacy LOOPCTL_ORCH_KEY is exactly that: it carries no " +
|
|
7700
|
+
"lineage, so there is no subtree that would bound a cascading revoke, and it would " +
|
|
7701
|
+
"otherwise be able to revoke the tenant's root. Use the user-role operator key, or " +
|
|
7702
|
+
"force_unclaim_story for a parked story, or mint a dispatch under an active parent and " +
|
|
7703
|
+
"revoke from inside that lineage. " +
|
|
7704
|
+
"503 tenant_halted under a custody halt. 404 for an unknown dispatch, and " +
|
|
7705
|
+
"for another tenant's. A `dispatch_id` that is not a UUID is refused here, before any " +
|
|
7706
|
+
"call.\n\n" +
|
|
7707
|
+
"IDEMPOTENT: an already-revoked dispatch answers 200 with revoked_count 0 and its ORIGINAL " +
|
|
7708
|
+
"revoked_at. No request body.",
|
|
7709
|
+
inputSchema: {
|
|
7710
|
+
type: "object",
|
|
7711
|
+
properties: {
|
|
7712
|
+
dispatch_id: {
|
|
7713
|
+
type: "string",
|
|
7714
|
+
description:
|
|
7715
|
+
"UUID of the dispatch to revoke, together with every descendant. From the " +
|
|
7716
|
+
"`dispatch` tool's response, from `implementer_dispatch_id` on a story, or from " +
|
|
7717
|
+
"GET /api/v1/dispatches.",
|
|
7718
|
+
},
|
|
7719
|
+
},
|
|
7720
|
+
required: ["dispatch_id"],
|
|
7721
|
+
},
|
|
7722
|
+
},
|
|
7723
|
+
|
|
7641
7724
|
// Issue #809: runner enrollment and the Presence pool (user key)
|
|
7642
7725
|
{
|
|
7643
7726
|
name: "runner_enroll",
|
|
@@ -8877,6 +8960,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
8877
8960
|
case "dispatch":
|
|
8878
8961
|
return await createDispatch(args);
|
|
8879
8962
|
|
|
8963
|
+
case "revoke_dispatch":
|
|
8964
|
+
return await revokeDispatch(args);
|
|
8965
|
+
|
|
8880
8966
|
case "runner_enroll":
|
|
8881
8967
|
return await runnerEnroll(args);
|
|
8882
8968
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `revoke_dispatch` — `POST /api/v1/dispatches/:id/revoke`.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect it exists for
|
|
5
|
+
*
|
|
6
|
+
* `Loopctl.Dispatches.revoke/3` had no route and no tool, so nothing outside the app
|
|
7
|
+
* could call it. That made a stranded ephemeral key unfixable rather than merely
|
|
8
|
+
* inconvenient, because of an invariant two layers down:
|
|
9
|
+
*
|
|
10
|
+
* `api_keys_one_role_per_agent_idx` is a PARTIAL UNIQUE index over
|
|
11
|
+
* `(tenant_id, agent_id, role)` with the predicate
|
|
12
|
+
* `revoked_at IS NULL AND role NOT IN ('user','superadmin')`. It CANNOT also test
|
|
13
|
+
* `expires_at` — Postgres requires a partial-index predicate to be IMMUTABLE and
|
|
14
|
+
* `now()` is STABLE — so the index's idea of "active" is `revoked_at IS NULL` while the
|
|
15
|
+
* auth pipeline's is `revoked_at IS NULL AND expires_at > now()`. A key whose session
|
|
16
|
+
* died is unusable for authentication and STILL OCCUPIES its agent's slot, so the next
|
|
17
|
+
* mint for that agent at that role is refused
|
|
18
|
+
* `422 agent already has an active key with this role`.
|
|
19
|
+
*
|
|
20
|
+
* Measured on story `d9975b31` (2026-09-15): `place_dispatch` answered exactly that 422
|
|
21
|
+
* and wrote nothing, because a session dispatch from an earlier attempt was never
|
|
22
|
+
* revoked. Nothing an operator could reach would clear it before its TTL.
|
|
23
|
+
*
|
|
24
|
+
* ## WHAT IT REVOKES — say this out loud before calling it
|
|
25
|
+
*
|
|
26
|
+
* The dispatch AND EVERY DESCENDANT, plus the ephemeral api_key each one minted. That
|
|
27
|
+
* is `Dispatches.revoke/3`'s own semantics (its query is
|
|
28
|
+
* `d.id == ^dispatch_id or ^dispatch_id in d.lineage_path`), not something this tool
|
|
29
|
+
* adds. Revoking a tree's root revokes the tree.
|
|
30
|
+
*
|
|
31
|
+
* ## WHAT IT DOES NOT DO
|
|
32
|
+
*
|
|
33
|
+
* It does not clear `stories.implementer_dispatch_id`. That field is custody
|
|
34
|
+
* provenance: loopctl resolves it with `Dispatches.get_dispatch/2`, which reads a
|
|
35
|
+
* REVOKED row exactly as it reads a live one, so `verify` / `report` /
|
|
36
|
+
* `review-complete` compare the same lineage afterwards. Revoking kills the credential
|
|
37
|
+
* and changes no custody verdict.
|
|
38
|
+
*
|
|
39
|
+
* ## REFUSALS
|
|
40
|
+
*
|
|
41
|
+
* - `403 insufficient_role` — the endpoint is `role: :orchestrator` WITH the hierarchy,
|
|
42
|
+
* so an orchestrator, user or superadmin key passes and an agent key does not. It is
|
|
43
|
+
* NOT `exact_role`, so this tool pins no particular env var; it merely PREFERS
|
|
44
|
+
* LOOPCTL_USER_KEY, for the ceiling reason two bullets down.
|
|
45
|
+
* - `403 custody_tier_required` — an agent-rooted tenant. Such a tenant cannot mint a
|
|
46
|
+
* dispatch either, so it has none to revoke.
|
|
47
|
+
* - `403 dispatch_outside_caller_lineage` — THE LINEAGE CEILING. A dispatch may only be
|
|
48
|
+
* revoked by a caller it descends from, because the revoke cascades: without it one
|
|
49
|
+
* principal could take down another's whole tree, and an implementer could prune the
|
|
50
|
+
* pool `select_verifier/3` draws from (it admits only dispatches that are
|
|
51
|
+
* `revoked_at IS NULL AND expires_at > now`) until the verifier it prefers is the one
|
|
52
|
+
* left. The tenant's `user`-role operator key — one no dispatch minted — may revoke
|
|
53
|
+
* anywhere in its tenant. The refusal carries `remediation.your_dispatch_id` when the
|
|
54
|
+
* caller has one.
|
|
55
|
+
* - `403 unlineaged_revoke_forbidden` — THE OTHER HALF OF THAT CEILING, and the one a
|
|
56
|
+
* default configuration meets. A key that no dispatch minted carries no lineage, so
|
|
57
|
+
* there is no subtree a cascading revoke could be bounded by; it passes only at
|
|
58
|
+
* `role: :user`, the tenant's operator key. A legacy LOOPCTL_ORCH_KEY is exactly that
|
|
59
|
+
* shape and is refused. Remedies the refusal names: use the user-role operator key,
|
|
60
|
+
* `force_unclaim_story` for a parked story (it revokes that story's own session
|
|
61
|
+
* dispatch through a path this ceiling does not sit on), or mint a dispatch under an
|
|
62
|
+
* active parent and revoke from inside that lineage.
|
|
63
|
+
* - `503 tenant_halted` — a custody halt suspends this route, like dispatch minting.
|
|
64
|
+
* - `404` — no such dispatch in your tenant. Another tenant's id answers 404 too.
|
|
65
|
+
*
|
|
66
|
+
* ## IDEMPOTENT
|
|
67
|
+
*
|
|
68
|
+
* An already-revoked dispatch answers 200 with `revoked_count: 0` and its ORIGINAL
|
|
69
|
+
* `revoked_at`; the revoke statement only touches rows that are `revoked_at IS NULL`.
|
|
70
|
+
* Retrying after a timeout is safe and never rewrites a revocation timestamp.
|
|
71
|
+
*
|
|
72
|
+
* ## EXPIRY IS SWEPT, SO REACH FOR THIS ONLY WHEN WAITING IS TOO SLOW
|
|
73
|
+
*
|
|
74
|
+
* `Loopctl.Workers.RevokeExpiredDispatchesWorker` (cron, every minute) revokes a
|
|
75
|
+
* dispatch and its key once `expires_at` has passed, and
|
|
76
|
+
* `RevokeExpiredApiKeysWorker` (every five minutes) catches a key minted with no
|
|
77
|
+
* dispatch behind it. So a stranded credential DOES clear itself — at its TTL, which for
|
|
78
|
+
* a placement session dispatch is four hours. This tool is for not waiting.
|
|
79
|
+
* `force_unclaim_story` also revokes the session dispatch of the story it frees, so on a
|
|
80
|
+
* parked story that is the one call to make.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
const MISSING_ID =
|
|
84
|
+
"`dispatch_id` is required: the dispatch to revoke, from `mcp__loopctl__dispatch`'s " +
|
|
85
|
+
"response or from the `implementer_dispatch_id` on a story.";
|
|
86
|
+
|
|
87
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
88
|
+
|
|
89
|
+
function refuse(body) {
|
|
90
|
+
return { error: true, status: 0, body };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function revokeDispatchPath(dispatchId) {
|
|
94
|
+
return `/api/v1/dispatches/${encodeURIComponent(dispatchId)}/revoke`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A malformed id is refused HERE, before any call.
|
|
99
|
+
*
|
|
100
|
+
* Not for tidiness: a path id that is not a UUID reaches
|
|
101
|
+
* `LoopctlWeb.CastErrorHandler`'s `Ecto.Query.CastError` clause and answers 404 — the
|
|
102
|
+
* SAME 404 a well-formed id naming no dispatch gets. Two problems with opposite remedies
|
|
103
|
+
* ("fix the argument" versus "that dispatch is gone") would otherwise have one
|
|
104
|
+
* indistinguishable answer.
|
|
105
|
+
*
|
|
106
|
+
* The refusal names the SHAPE and never echoes the value: a malformed id is often a
|
|
107
|
+
* token or a pasted line that landed in the wrong argument, and a tool result goes into
|
|
108
|
+
* the transcript.
|
|
109
|
+
*/
|
|
110
|
+
export async function revokeDispatch({ dispatch_id } = {}, { apiCall } = {}) {
|
|
111
|
+
if (typeof dispatch_id !== "string" || dispatch_id.trim() === "") {
|
|
112
|
+
return refuse(MISSING_ID);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!UUID_RE.test(dispatch_id)) {
|
|
116
|
+
return refuse(
|
|
117
|
+
"`dispatch_id` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). " +
|
|
118
|
+
`Got a ${dispatch_id.length}-character string that is not one. The value is not ` +
|
|
119
|
+
"repeated here: a malformed id is often something pasted into the wrong argument, " +
|
|
120
|
+
"and a tool result lands in the transcript.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return apiCall("POST", revokeDispatchPath(dispatch_id), null);
|
|
125
|
+
}
|