Skip to content
kiffPublic

About

The safe place for your agents' keys: owner-approved on the phone, kept in the EU or self-hosted, every call permit-checked.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

planque

The safe place for your agents' keys. An agent asks to act in a vendor account: payments, a shop, email, any API. The owner approves on their own phone, seeing exactly what will be granted. Direct connections keep vendor keys in planque, in Europe or on the owner's own servers. Optional managed connections keep vendor credentials at the configured connection provider. Every call is made only with a permit for that exact call.

Planque is French slang for a safe hiding place.

For whom

People who let AI agents work in their business tools and need to know:

  • who approved what;
  • that the keys never sit in the agent;
  • that each action stays within what was approved.

How it works

planque follows the standard that modern vendors' MCP servers speak: OAuth 2.1 with discovery (RFC 9728, RFC 8414), dynamic client registration (RFC 7591) and PKCE.

An MCP authorization server can also use a Client ID Metadata Document instead of dynamic registration. When it advertises that support, planque uses its configured HTTPS /oauth/client-metadata.json URL as the client id. That public document names this installation and its exact callback; it contains no account, grant or credential data.

  1. Ask. An agent sends a grant request that says exactly what it wants: which vendor, what access, for which agent.
  2. Approve. The owner approves in their own app, for example tafs, with Face ID. Their device signs that exact request with its key, and KIFF, which keeps the devices each person approves with, says whether it did. Nothing is granted without the signature.
  3. Connect. planque discovers the vendor's sign-in server, registers itself, and sends the owner to the vendor's own sign-in page. The token lands in planque, encrypted with a managed key, bound to that vendor account, refreshed safely, and revocable.
  4. Call. For each call, planque checks a permit for that exact call (for example from KIFF, RFC 046), then calls the vendor itself. The agent never receives a token.

An optional managed connection provider adds paginated app discovery and hosted authentication without an app-specific adapter for every vendor. The same device approval, exact-call permits and once-only relay govern each selected action. Incoming chat messages for a taf's door use separately approved subscriptions and a signed door handoff; receiving a message grants no reply authority.

Connection profiles. A grant request can carry a profile: what each of the connection's operations does, in terms KIFF's Card can count (where its amount and resource are, and how KIFF can tell it happened), with KIFF's words for it. The owner's phone signs the profile and the words together with the grant. planque hands that signed evidence to KIFF, which applies the profile only if the signature covers it; if KIFF doesn't apply it, nothing is connected. Each operation keeps the profile revision it was decided under, so a replay never sends it again under another.

Two ways to run it

  • Hosted: we run it in the EU. The owner runs nothing.
  • Self-hosted: one container, on any cloud or server. Direct connections keep credentials on the owner's infrastructure; managed connections use the configured provider's credential custody.

It's the same code either way.

Done when (the first end-to-end)

  • From kiff/tafs' setup chat, an owner connects a payment provider through its MCP server:
    1. the approval on the phone;
    2. the provider's own sign-in;
    3. the token in planque;
    4. a refund inside the Card goes through;
    5. a refund outside it is never sent.
  • A call without a valid permit, with a permit for another call or account, or with an expired permit sends nothing. A test covers each case.
  • A grant request the phone didn't sign creates nothing.
  • Disconnecting revokes the token at the vendor and deletes it here.
  • The signed approvals can be exported as evidence of human oversight.

The API

Call Who What
POST /v1/accounts/{tenant}/enrolment a device of the owner's that KIFF knows, signed (d=) hands planque the account's KIFF connector key; any of the person's devices may. The enrolled key is kept while KIFF still takes it for the account (200 {"kept": true}): the tools it connected are that key's in KIFF. A new key replaces it (201) only once KIFF no longer takes the enrolled one. A key that can do more than connect tools is refused. A signature without d= (a phone that enrolled its own key before KIFF kept devices) is no longer taken; a device_key in the body is accepted and ignored
POST /v1/accounts/{tenant}/grants an agent, with its KIFF key asks for a vendor's MCP server, some of its tools, for a taf; returns the plain words and the digest the owner signs; grants nothing. With tools: [] it connects first: once signed in, planque only lists the vendor's tools
GET /v1/accounts/{tenant}/grants/{id} an agent of the account the grant's status, status_words: that status in the owner's words, one line every app shows as it is ("Connected", "Signed in · the tools are being chosen", "Connected · new tools waiting for your approval"…), and vendor: the vendor's name as its MCP server gives it (serverInfo title, else name), else its host. Connected tools reach KIFF with the vendor's title for each (its MCP title, else annotations.title), for people to read; names a vendor gives are one line, at most 100 characters
GET /v1/accounts/{tenant}/grants/{id}/catalog an agent of the account for a grant asked for with no tools, once signed in (listed): each tool the vendor lists, with its description and arguments, and the account the sign-in acts in
POST /v1/accounts/{tenant}/grants/{id}/read answers 410. Setup's reads of a grant's setup_reads tools go through KIFF's gateway like any call (kiff/tafs#138): when the grant connects, planque asks KIFF for Setup's own read Card (POST /v1/me/setup-reads, with the owner's signed approval, which KIFF checks itself), at most 50 reads a day. Each read is decided on that Card, has KIFF's permit, and is kept in KIFF's record, and the relay sends it only with that permit. setup_reads is only for a tool asked for as read_only, which the vendor must mark read-only, and the owner's text names the read on its own line. (…/describe, which read a sign-in before its tools were approved, answers 410 too: #26.)
POST /v1/accounts/{tenant}/grants/{id}/tools the agent that asked, body {"tools": […]} sets a listed grant's tools, from its catalog; new text and digest, pending again. A tool name another of the account's connections holds (at any step but failed or disconnected) is refused (409), so the agent names it anew before the owner approves; a tool left unnamed gets planque's per-grant name
POST /v1/accounts/{tenant}/grants/{id}/tools on a connected grant the agent that asked, body {"tools": […]}: only the tools to add adds them to what it has (kiff/tafs#138): pending with the sign-in held, and the owner's text says what's added and that its tools keep working meanwhile. They do: the relay serves the grant's connected tools until the owner approves, which needs no second sign-in and registers only the added tools in KIFF, with a transport credential of their own. Not approved within a day, or refused by KIFF, the addition goes and the grant stays connected with what it had. A tool it already has is refused (409)
GET /v1/accounts/{tenant}/agents/{agent}/grants the agent that asked the grants it asked for that agent, not failed or disconnected: id, vendor, server, status, account and tools, so Setup, improving a taf, adds to the connection the taf has instead of asking for another
GET /v1/accounts/{tenant}/catalog?q=… a bearer KIFF key for the account searches configured entries and optional provider catalogs, with pagination and this account’s connection state; see catalog configuration
POST /v1/accounts/{tenant}/catalog/{entry}/grants a bearer KIFF key, body {"agent": …} requests reviewed entry tools or a managed app sign-in for the taf, with names allocated before approval; returns {grant, needs_key}, with no sign-in link before device approval
GET /v1/accounts/{tenant}/asked-grants the agent that asked every grant it asked for, for any taf's agent, each with its agent and when it was asked: so Setup that lost a taf's agent id finds its connection again instead of asking for another
POST /v1/accounts/{tenant}/grants/{id}/approve the phone, signed, body {"digest": …} the owner's approval of exactly that request; returns the vendor's own sign-in link. For tools set on a grant whose sign-in is held, it connects at once, with no link. On a needs_reconnect grant, the same digest signed again returns a new sign-in link: the reconnect (below)
POST /v1/accounts/{tenant}/grants with "kind": "api_key" an agent, with its KIFF key a vendor with a REST API and no MCP server (#64). api: {"url": its OpenAPI description, "auth": {"in": "header"|"query", "name", "prefix"?}, "base"?}. planque reads the description once (JSON, OpenAPI 3.0 or 3.1, at most 16 MB and 500 operations per snapshot; api.operations may select a subset from a larger catalog) and keeps it as the grant's snapshot: the owner signs its SHA-256 with the server and where the key goes, and every call uses the snapshot, whatever the URL serves later. Refused, with the reason: YAML, external $refs, servers set per path or operation, an operation secured otherwise than the key, callbacks and links, and any way of sending the key but a header or the query. Each operation is a tool (its operationId, else its method and path), with its path, query and JSON body as arguments; GET/HEAD are reads. A vendor's refusal is never taken as evidence that nothing happened: the call stays counted in KIFF
POST /v1/accounts/{tenant}/grants/{id}/key the phone, signed, body {"digest", "key_sha256"}, the key in Planque-Vendor-Key the owner's approval of an API-key grant, with the key: the signed body holds only the key's SHA-256, so the approval kept as evidence never holds the key. The key is sealed like a sign-in's token and never shown again. With no tools the grant is listed (its catalog is the snapshot's operations, each with its method and path); with tools it connects at once. Tools set later are approved with …/approve, with no second key. A needs_reconnect grant takes a new key the same way. Disconnecting deletes the key and the snapshot
POST /v1/accounts/{tenant}/grants with "reuse": "<grant id>" an agent, with its KIFF key a second grant to the same vendor and account uses a connected grant's sign-in or key (#36): the owner's text says so, and approving needs no second sign-in or key. The token isn't copied: both grants use one, refreshed once. A grant that isn't connected, of another account or to another server can't be reused (409). When the first is disconnected, the second is needs_reconnect and signs in, or takes a key, on its own
GET /oauth/callback the vendor state used once, iss checked (required when the server says it sends it), code exchanged with PKCE and the exact redirect; the token is sealed, unused, and the browser goes on to the owner's app with a confirmation code
POST /v1/accounts/{tenant}/grants/{id}/confirm the phone that approved, signed, body {"confirm": …} the tools are registered in KIFF's gateway in relay mode; for a grant with no tools, the vendor's tools are listed instead (listed); for a reconnect, the grant is connected again with the new token, its tools in KIFF as they were
POST /mcp/{tenant}/{id} KIFF's gateway, with the relay's transport credential MCP; each tools/call needs KIFF's permit (KIFF-Permit) for that exact call
(a vendor signs planque out) when the vendor refuses the refresh (invalid_grant), or a token expires with no refresh token, the grant is needs_reconnect ("Signed out by the vendor · sign in again"). Its tools stay in KIFF; each call through it is answered "not sent", saying the owner signs in again, and reaches no vendor. The owner's phone approves the same digest again, signs in (a sign-in to another account at the vendor is refused), and confirms: the grant is connected again. A new sign-in nobody confirms goes, and the grant waits again. planque tells KIFF of each sign-out once (POST /v1/me/connections/signed-out, with the connector key: the grant, the taf's agent, the vendor's name, why and when), so the people who see the taf hear of it; one KIFF didn't take is sent again by the sweep
DELETE /v1/accounts/{tenant}/grants/{id} the phone, signed removes the tools from KIFF, revokes the token at the vendor, deletes it here
GET /v1/accounts/{tenant}/grants, …/export the phone, signed (the export, by someone KIFF says may export the account: account.export, admins) the grants in use with what the owner read and their signature, checkable without planque. A disconnected grant's record stays until the account ends (its vendor tokens are deleted at disconnect); the list leaves it out unless ?include=disconnected, and the export has every grant
DELETE /v1/accounts/{tenant} the phone, signed by someone KIFF says may delete the account (account.delete, admins), as KIFF's own deletion requires; tafs cloud forwards it, before KIFF's own deletion deletes the account (kiff/tafs#185): each grant's tools leave KIFF's gateway, its vendor tokens are revoked where the vendor allows and deleted, and every record goes, the enrolment with the connector key last. Answers what KIFF or a vendor didn't confirm in problems. Asked again once nothing is kept, 204
POST /v1/account-deletions {intent, signature}: the owner's phone's one signed intent to delete the account (tafs.delete-account.v2: tenant, device, issued, expires at most 10 minutes later, nonce), which tafs cloud carries accepts it, deleting nothing, once KIFF has: KIFF accepts first, from the phone's session, checking its approval key, and planque asks KIFF (POST /v1/account-deletions/status) that it accepted exactly this intent. Its nonce is spent once. A later intent takes an earlier one's place. 202
POST /v1/account-deletions/finish {intent, signature}, only the one accepted, which KIFF still vouches for erases the account as DELETE /v1/accounts/{tenant} does, with no deadline; the same when asked again. 403 not_accepted for any other
POST /v1/account-deletions/status {intent, signature} whether that intent was accepted (accepted), or the account is erased, deleting nothing. 403 not_accepted otherwise

A signed call carries Planque-Device-Signature: t=<signed_at>,v1=<sig>,d=<device id>: the device's P-256 key (tafs' ApprovalSigner) signs, as base64 DER ECDSA over SHA-256, the lines planque.request.v1, planque's base URL, tenant, method, path, the body's SHA-256 and signed_at. A signed request is accepted once, within 5 minutes, and only by the planque it names.

KIFF keeps the devices (kiff-cloud #1267). planque keeps none: it asks KIFF's POST /v1/devices/verify, with the account's connector key, whether device d signed those bytes and whether its person may connect tools (tools.connect). KIFF answers for its own account's devices only, and a removed device, a person who left or one who can't connect tools is refused. So any member who may connect tools on the account (an admin or an editor in KIFF) approves from any of their devices, not only the first phone set up. Each grant's record keeps who approved and on which device (KIFF's person and device ids), and its events say it in the owner's words, with the device's name from KIFF ("approved on tafs on iPad"), never an id. KIFF says nothing about when, so planque keeps the 5-minute window and the replay check. A phone enrolled with its own key before this signs without d= and is checked against that key, until it signs as a KIFF device.

The sign-in is bound to the device that approved. The vendor sends the browser back to planque, which keeps the token unused and sends that browser on to the owner's app (PLANQUE_APP_RETURN, set by the operator) with a one-time code. The device that approved signs POST …/grants/{id}/confirm with that code within 15 minutes; only then are the tools registered. A link someone else opens hands its code to their browser, never to the approver. Vendors are reached only at public addresses, checked when each connection is made.

Permits follow kiff-cloud RFC 046: typ kiff-permit+jwt, alg Ed25519, a key from KIFF's JWKS (refetched every 5 minutes, an unknown kid at most once a minute, trusted at most an hour without a fresh fetch), the relay's audience, the account, the tool, the RFC 8785 hash of the arguments, and a lifetime of at most 60 seconds. Each operation runs at most once: a replay gets the first answer, and a call whose answer was lost is never sent again.

Narrowing a vendor tool. Some vendors offer one broad tool that writes to any endpoint of their API. A grant narrows it with fixed_arguments (sent on every call, never set by the agent) and arguments (the only ones the agent sets, each placed at a path in the vendor's arguments, at the top level where KIFF's Card can govern them). A title says what the tool does in the owner's words: the approval text, KIFF's Card and the record name the tool by it. The exact terms, the narrowing included, are in the grant's terms, which the app shows under Details. The phone signs the whole request either way:

{"tool": "api_write", "name": "refund", "title": "Refund a payment", "amount_argument": "amount",
 "fixed_arguments": {"operation_id": "PostRefunds", "account": "acct_…", "livemode": false},
 "arguments": {"payment_intent": {"path": "parameters.payment_intent", "type": "string", "required": true},
               "amount": {"path": "parameters.amount", "type": "integer", "required": true}}}

The fields a narrowed tool may reach are the operation's own: planque reads them with the vendor's describing tool and refuses a path into parameters the operation doesn't take, with the list it does, before the owner is asked. It reads nothing through a vendor's tools before the owner approves them, so that tool is connected first (with setup_reads), and a broad write is added once it's approved; a narrowed broad write asked for any other way is refused. The read is on the grant's record.

Run it

go test ./...                    # every step against a fake vendor and a fake KIFF
deploy/aws.sh up                 # the hosted deployment
PLANQUE_BASE_URL=http://localhost:8080 PLANQUE_LOCAL_KEY=$(openssl rand -base64 32) \
  go run ./cmd/planque           # self-hosted, records in memory

Configuration is in cmd/planque/main.go.

The live check (live/) runs against a payment provider's real MCP server in test mode and the hosted planque. Until KIFF ships the connector key role, KIFF is the stand-in in internal/fakes, deployed with FAKEKIFF=1 deploy/aws.sh up. It enrols a phone, asks for the provider, approves, waits for the sign-in at the provider, then: a refund inside the Card goes through, one outside it is never sent, permits for other arguments, another account, another audience, expired or missing send nothing, a replayed permit gets the first answer, parallel calls on several instances refresh the token once (the rotated refresh token is kept), disconnecting revokes the token at the provider (which then answers it with 401), and the export's signature verifies.

Licence

FSL-1.1-ALv2, like kiff/agents. You can read, run and self-host it; you can't offer it as a competing hosted service for two years, after which each version becomes Apache 2.0.

Contributing and project policies

This public repository is source-available under FSL-1.1-ALv2. The license contains its future Apache-2.0 grant.

About

The safe place for your agents' keys: owner-approved on the phone, kept in the EU or self-hosted, every call permit-checked.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages