The toolpass server (toolpass serve) answers permission checks (POST /check), collects the runs
agents report (POST /runs) and serves the dashboard over them (GET /dashboard/). Clients: Toolpass.remote in the
toolpass package, toolpass-client for Node, or any
HTTP client. The in-process engine answers with the same decisions and codes, without HTTP.
Send the configured api_key as a bearer token and a JSON body of at most 64 KiB. Unknown fields
are rejected, and so is a field given twice; names match regardless of ASCII case. The body is framed
by one Content-Length or by Transfer-Encoding: chunked, never both or twice. user is an email
of at most 320 bytes, with no whitespace or control characters (C1 and Unicode separators included)
and none of the letters that case-fold to ASCII ones (İ, ı, ſ, the Kelvin sign). groups holds
at most 200 entries of at most 256 bytes each. token, optional, is the user's own ID token, for a
connection that reads the groups from it (Groups from the user's token):
printable ASCII without spaces, at most 16 KiB; it is never logged, and the caches key on a digest
of it.
{
"user": "dana@example.com",
"groups": ["platform-team"],
"connection": "k8s-prod-eu",
"action": "deployment.create",
"resource": "namespace:payments"
}{ "decision": "allow" | "deny" | "unknown", "reason": "<code>: <text>", "identity": "<account>" }identity is the account the user's email resolved to in the system (a GitHub login, a Jira
account id), present whenever one was resolved, allow or deny alike; it is absent when the email
did not resolve (user_not_found, a lookup that failed) and on requests refused before a lookup.
| Case | HTTP | decision | code |
|---|---|---|---|
| Evaluated | 200 | allow / deny | allowed / denied |
| User has no account in that system | 200 | deny | user_not_found |
| Several accounts match the email | 200 | unknown | user_ambiguous |
| Upstream timeout, error or rate limit | 200 | unknown | upstream_timeout, upstream_error, upstream_rate_limited |
| toolpass's own credential was rejected | 200 | unknown | credential_rejected |
| Resource not visible to toolpass | 200 | unknown | resource_not_visible |
| Policy construct toolpass does not understand | 200 | unknown | unsupported |
| The connection cannot ask as configured (no identity source that works): nothing was checked | 200 | unknown | not_configured |
| Bad request, unknown connection or action | 400 | unknown | invalid_request, unknown_connection, unknown_action |
| Bad or missing API key | 401 | unknown | unauthorized |
A method other than POST |
405 | unknown | invalid_request |
| A body over 64 KiB | 413 | unknown | invalid_request |
deny means the third-party system positively said no. Anything toolpass could not evaluate is
unknown. Callers should treat unknown as deny.
The request also accepts "fresh": true. A fresh check skips every cache for that one request,
the decision cache, the identity cache and the lookups an integration caches itself (role
definitions, policies), and asks the upstream system now; what it learns replaces the cached
entries, so reads keep using the cache. Use it for destructive actions (delete, merge, scale),
where a 30-second-old answer is not good enough, and not for reads: a fresh check costs every
upstream call an uncached check makes (for Argo CD, the policy config maps and the project list;
for Vault, the policies involved; for Snowflake, the grants of every role in the hierarchy).
Lookups that are not permission state are reused for as long as any check reuses them: GitHub's
organization-wide SAML identity listing (the permission read after it is live anyway), AWS
Identity Center's role inventory and permission set names, Salesforce's object and field
describes, and toolpass's own principal name in Databricks. Fresh checks share
a lookup one of them has in flight (an identity, a role's permissions, a policy) and one read
less than a second ago; a fresh answer is one from reads in flight when the caller asked or begun
no more than a second before. Maps toolpass reads from a local file
(role_map_file, user_map_file) are re-read on their own schedule, not per fresh check. A fresh
check narrows the window between the check and the action to the time between the two; it does
not close it. Closing it needs a conditional write in the upstream system (for example If-Match
with an ETag), which only some APIs support. A toolpass built before fresh existed rejects a
request that carries it (invalid_request: unknown field "fresh", which the clients report as
unknown), so upgrade the server before turning it on in agents.
Returns {"status":"ok"} with status 200, without authentication. It says the process is up, not
that every connection works; toolpass probe checks connections.
The runs of toolpass tools an agent reports, for the dashboard. Send the
api_key as a bearer token and a JSON body of at most 1 MiB: the reporting process's name and 1
to 500 runs, each an audit event (AuditEvent.to_dict(): at least a string tool and outcome).
A run larger than 64 KiB keeps its outcome and loses its actions and arguments.
{"server": "shop", "runs": [{"time": "...", "tool": "open_pr", "outcome": "refused", "user": "sam@acme.dev", "actions": [...]}]}202 {"accepted": N}; 400 with {"error": "..."} for a body that is not that; 401 without the
key; 413 over the size. The server keeps the latest 10,000 runs in memory; a toolkit sends them
itself (see the guide), so nothing calls this by hand.
The dashboard page, with watch.js and watch.css beside it, served to anyone: it holds no data.
GET /dashboard/events?since=N returns the runs after sequence N (0: the latest 500), as
{"kind": "dashboard", "events": [...], "next": M}, to holders of the api_key as a bearer token
(401 otherwise); the page asks for the key and keeps it in the browser tab.