Help us shape the future of Go! Take the 2026 Go Developer Survey

whosent

module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0

README

whosent

Test License Built with Bubble Tea

Who sent this agent? A TUI for delegation chains: JWT token exchange (RFC 8693 act), Biscuit, UCAN and macaroons. It shows the human at the root, how authority narrows hop by hop, and where the chain breaks.

whosent demo

Install

# Go 1.27+
go install github.com/kanywst/whosent/cmd/whosent@latest
# Homebrew (macOS)
brew install --cask kanywst/tap/whosent
# Scoop (Windows)
scoop bucket add kanywst https://github.com/kanywst/scoop-bucket
scoop install kanywst/whosent

Releases also carry binaries for macOS, Linux and Windows, .deb and .rpm packages, checksums, cosign signatures and an SBOM.

Quick start

whosent demo                       # example chains in every format; n and p switch
whosent demo --list
whosent token.jwt --jwks https://idp.example.com=jwks.json --audience https://api.example.com
pbpaste | whosent --jwks https://idp.example.com=jwks.json    # "Bearer " prefix is fine
whosent token.b64 --root-key root.pub --fact 'operation("read")'

Without keys the chain still opens. Rules that need them are shown as not checked, never as passing.

Formats

The format is recognised from the input; --format picks one when it is ambiguous. docs/formats.md has the details of each.

JWT token exchange

One token with nested act claims, or a bundle of exchanged tokens, oldest first. Also ID-JAG, Txn-Tokens and Entra agent tokens. Prior actors in one token are the issuer's word (RFC 8693 section 4.1), so they are drawn as asserted, not verified, unless the token carries actor proofs (draft-mcguinness-oauth-actor-proofs): then each covered actor signed its own hop, and with its keys in the trust file it is drawn as the actor's own word. The same holds for an Actor Chain token (draft-mw-oauth-actor-chain) whose path or disclosed evidence each actor signed and the authorization server accepted. J7 to J10 are whosent's own checks between exchanges, not a standard procedure.

Rule Checks
J1 a compact JWS signed with RS, PS, ES or EdDSA; alg: none, HMAC and unknown critical headers are refused
J2 the signature verifies against the keys you configured for the issuer the token names
J3 iat, nbf and exp within the skew; an earlier token must have held when the next was issued
J4 the last token is addressed to --audience
J5 the current actor is an object with a sub; a missing iss is a warning, a missing sub_profile a note
J6 an exchanged token's actor is the party the previous token's may_act names
J7 an exchanged token's act chain is the previous one plus exactly one new actor
J8 an exchanged token's scope is a subset of the previous token's
J9 the subject does not change across an exchange
J10 an exchanged token expires no later than the token it replaced
J11 the token's actor_proofs: each proof signed by its actor's own key, hash-linked by prh, aligned with the act chain, unexpired, and the token within the newest actor's consented target. A rejected chain fails the token; the draft itself only drops the evidence
J12 an Actor Chain profile (actp, acti, actc, acte): the server's signed acceptance of the latest hop, and any inline evidence: each step proof signed by its actor, each acceptance by its server, hash-linked by step_hash and prev, path openings matching their commitments, and the token within the current actor's resource and scope. Introspected evidence is not fetched, so it leaves the rule unchecked
Biscuit

One link per block. Signatures and the Datalog authorizer are whosent's own code, checked against the specification's 38 sample tokens.

Rule Checks
B1 the token and every block decode, and each next key is valid
B2 the block's Datalog version is 3.0 to 3.3, and it uses only what that version allows
B3 each block's signature verifies, from --root-key down; without it nothing is vouched for, though a tampered block still fails
B4 a third-party block's external signature verifies (v1 only, as the reference does by default)
B5 the proof: an open token's secret matches the last next key, or a sealed token's seal verifies
B6 the block's symbols and keys do not overlap earlier ones and every reference resolves
B7 the authorizer's facts and policies satisfy the token's checks; without --fact or --authorizer, only expiry checks
B8 no block's revocation id is on the --revoked-jtis list
UCAN

UCAN 1.0 delegations from the subject down to an invocation, checked against the specification's 1.0.0 fixtures.

Rule Checks
U1 canonical DAG-CBOR envelope with a varsig header and a well-formed ucan/dlg@1.0.0 or ucan/inv@1.0.0 payload
U2 the signature verifies against the issuer's did:key (Ed25519, P-256, secp256k1); other DID methods are not checked
U3 nbf and exp hold at the evaluation time, within the skew
U4 the first delegation is issued by its subject, and each token by the audience of the delegation above it
U5 every token is about the same subject; a powerline (sub: null) may not be the root
U6 each command is the one above it or nested under it
U7 the invocation's arguments satisfy every delegation's policy
U8 the invocation names its proofs and every one is supplied, or the invoker is the subject
U9 no delegation's CID is on the --revoked-jtis list
Macaroons

libmacaroons tokens and Fly.io tokens (FlyV1 fm2_...), one link per caveat. Cross-checked on every run with go-macaroon or superfly/macaroon. A Fly.io token's Organization and App caveats restrict requests: without one they are not checked, and --fact org=ID --fact action=r gives one.

Rule Checks
M1 the macaroons decode; a Fly.io attestation appears only in a proof
M2 the HMAC chain matches under --root-key; a discharge's, bound to its root
M3 every third-party caveat has its discharge, used once, and no discharge is left over
M4 the caveat's sealed key opens under the chain; a Fly.io discharge is bound to its token
M5 time caveats (time < T, time-before T, Fly.io ValidityWindow) hold at the evaluation time
M6 libmacaroons predicates are among the --fact predicates; Fly.io caveats allow the --fact request. Without --fact they are not checked

Checking from a script

check prints the same diagnosis without the view. It evaluates every rule for every link, so a chain with three faults shows all three. demo --emit writes a scenario out as files with the command that checks them.

Three exchanged JWTs, where the last exchange gained a scope:

whosent demo --emit fixture jwt-bundle-widened
cd fixture
whosent check tokens.json --trust trust.json --audience https://api.acme.example --at 2026-09-12T08:01:00Z
=== tokens.json · 3 links · JWT · issuer https://acme.okta.example, access token (RFC 9068)
    at 2026-09-12T08:01:00Z, skew 30s, audience https://api.acme.example

❌ INVALID: the chain breaks at link 2, and no authority reaches below it

  #  issuer → subject                   J1 J2 J3 J4 J5 J6 J7 J8 J9 J10 J11 J12
  0  alice@acme.example → chat-ui        ✓  ✓  ✓  ·  ✓  ·  ·  ·  ·   ·   ·   ·
  1  chat-ui → planner-agent             ✓  ✓  ✓  ·  ✓  ✓  ✓  ✓  ✓   ✓   ·   ·
  2  planner-agent → research-agent      ✓  ✓  ✓  ✓  ✓  ✓  ✓  ✗  ✓   ✓   ·   ·
     ✓ pass  ✗ fail  ! could not evaluate  ? not checked  - skipped  · does not apply

Findings
  ✗ J8 link 2: adds files:write, which token 1 did not hold [whosent bundle rule, derived from RFC 8693; not a procedure any specification defines (section 4.2, scope)]

How authority narrows
  0 → 1  chat-ui → planner-agent
      + exchanged: planner-agent now acts for alice@acme.example
      = scope files:read
      - scope files:write dropped
      = scope calendar:read
      ~ expires 20m sooner
  1 → 2  planner-agent → research-agent
      + exchanged: research-agent now acts for alice@acme.example
      = scope files:read
      - scope calendar:read dropped
      ✗ scope files:write was not in the token exchanged
      ~ expires 20m sooner
      i https://acme.okta.example → https://api.acme.example
          audience
      = kept  ~ narrowed  - dropped  + added  ✗ widened (violation)

--json prints schema whosent.chain/v1, the same for every format. --json --native prints the format's own report. A flag the format does not use is reported as Ignored: --flag (why).

Verdict Exit Meaning
VALID 0 every rule was checked and holds
INVALID 1 a rule failed or could not be evaluated
INCOMPLETE 2 nothing failed, but something was not checked (1 with --strict)
3 the command line or an input file is wrong
70 whosent disagrees with a format's reference implementation; please report it
Asking about one request

a in the view, or whosent ask, asks whether the chain permits one request, traced link by link. The answer is AUTHORITATIVE (the chain verified with every input), CONDITIONAL (some rules were not checked) or HYPOTHETICAL (the chain does not verify). ask exits 0 only for a VALID chain that permits the request. Each format takes its own question:

Format Question
JWT --scope files:read, used at --audience
Biscuit --resource-id and --action become resource(...) and operation(...) facts for the authorizer; --policy replaces its policies (the answer is then at most CONDITIONAL)
UCAN --action /msg/send as the command, with --field 'args={...}' for its arguments
Macaroon Fly.io: --action rw --field org=ID [--field app=ID]. libmacaroons: --field 'predicates=a; b'

Inputs

Flag For What it supplies
--jwks ISSUER=PATH or ISSUER=https://URL JWT an issuer's key document; repeatable
--trust FILE JWT a file mapping issuers, and actors that sign actor proofs, to key documents (below)
--root-key KEY Biscuit, Macaroon Biscuit: a root public key (ed25519/..., secp256r1/..., or a file). Macaroon: the root key (hex, text:SECRET, or a file)
--audience AUD JWT the party the chain is presented to
--fact FACT, --authorizer FILE Biscuit, Macaroon authorizer facts and policies; for a macaroon, a satisfied predicate
--revoked-jtis FILE Biscuit, UCAN revoked ids, one per line: block revocation ids or delegation CIDs
--at TIME all evaluate at another time: RFC 3339, unix seconds, or +25m / -1h
--skew DURATION all clock skew tolerance, at most 5m (default 30s)
--reverse JWT the tokens were handed over newest first

The trust file:

{"issuers": {
  "https://idp.example.com": {"jwks_file": "jwks/idp.json"},
  "https://login.example.org": {"jwks_url": "https://login.example.org/.well-known/jwks.json"}
},
 "actors": [
  {"iss": "https://idp.example.com", "sub": "planner-agent", "jwks_file": "jwks/planner.json"}
]}

actors is optional. An actor is named by the pair (act.iss, act.sub), and its keys are the ones it signs actor proofs or Actor Chain step proofs with; they must come from somewhere other than the issuer, or the proofs cannot expose an issuer that invents an actor.

Keybindings

Key Action
←/h →/l select a link (an arrow on the rail)
↑/k ↓/j select a row of authority
J K pgup pgdn scroll the link panel
tab switch panel, when they do not fit side by side
r the raw link: header, claims or Datalog, digests
space replay authority flowing down the chain
t time mode: a timeline of every link, and ← → move the clock
[ ] shift+← shift+→ move the clock in big steps
0 back to the time the chain was opened at
x simulate revoking the selected link; again to undo (Biscuit and UCAN)
X clear every simulated revocation
a ask whether the chain would permit a request
n p next or previous scenario, in the demo tour
? help
q esc quit, back

Time mode shows what check --at would report. A simulated revocation is labelled as one and never reaches check or ask. NO_COLOR, --ascii (or WHOSENT_ASCII=1) and --no-motion are respected.

Security

  • Strings from a chain, a key document or a library error are escaped before they reach the terminal, so a chain cannot clear the screen, print a fake verdict or write the clipboard. --json is escaped the same way.
  • The network is used only to fetch key documents from https URLs you configure. Keys never come from the chain (a JWT's jku, x5u, x5c and jwk are ignored). Fetches stay on https through redirects and are capped at 1 MiB and 5 seconds.
  • Input is capped at 1 MiB, with per-format limits on links, blocks and caveats.

Report a vulnerability through a private advisory. See SECURITY.md.

  • biscuit-cli creates and attenuates Biscuit tokens.
  • y509 does the same job for X.509 certificate chains.

Development

make build       # Build with version info
make test        # Run tests
make lint        # Run golangci-lint
make vulncheck   # Run govulncheck
make run         # Take the demo tour

A new token format is an adapter under internal/formats/<id>; docs/adapters.md is the contract it must meet. See CONTRIBUTING.md before sending a patch.

Verifying releases

Releases include Sigstore-signed checksums, a CycloneDX SBOM and SLSA build provenance.

gh attestation verify whosent-<version>-<os>-<arch>.tar.gz -R kanywst/whosent
TAG=v<version>
gh release download "$TAG" -R kanywst/whosent -p '*-checksums.txt*'

cosign verify-blob \
  --bundle "whosent-${TAG#v}-checksums.txt.sigstore.json" \
  --certificate-identity-regexp 'https://github.com/kanywst/whosent/.github/workflows/release.yml@refs/tags/' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
  "whosent-${TAG#v}-checksums.txt"

sha256sum -c "whosent-${TAG#v}-checksums.txt" --ignore-missing

License

Apache License 2.0

Directories

Path Synopsis
cmd
whosent command
Command whosent explains a delegation chain: who sent this agent, and what is it still allowed to do.
Command whosent explains a delegation chain: who sent this agent, and what is it still allowed to do.
internal
cmd
Package cmd is the command line surface.
Package cmd is the command line surface.
formats/all
Package all is the list of formats whosent understands.
Package all is the list of formats whosent understands.
formats/biscuit
Package biscuit reads Biscuit authorization tokens (eclipse-biscuit): a root key's grant in an authority block, narrowed by later blocks that can only add checks, each block signed by a key the block before it handed on.
Package biscuit reads Biscuit authorization tokens (eclipse-biscuit): a root key's grant in an authority block, narrowed by later blocks that can only add checks, each block signed by a key the block before it handed on.
formats/biscuit/datalog
Package datalog is Biscuit's logic language: its terms, its printer, its expression machine, its fact engine and its parser.
Package datalog is Biscuit's logic language: its terms, its printer, its expression machine, its fact engine and its parser.
formats/fake
Package fake is a format adapter that exists only for tests.
Package fake is a format adapter that exists only for tests.
formats/jwt
Package jwt is the JWT format adapter: it reads the delegation a JWT carries in RFC 8693's nested `act` claim (and the profiles built on it: ID-JAG, Txn-Tokens, Microsoft Entra's agent claims) and describes it in the format-neutral model the view renders.
Package jwt is the JWT format adapter: it reads the delegation a JWT carries in RFC 8693's nested `act` claim (and the profiles built on it: ID-JAG, Txn-Tokens, Microsoft Entra's agent claims) and describes it in the format-neutral model the view renders.
formats/macaroon
Package macaroon is the macaroons format adapter: a macaroon and its discharges, in the libmacaroons formats or as a Fly.io token, described as a chain of caveats in the format-neutral model.
Package macaroon is the macaroons format adapter: a macaroon and its discharges, in the libmacaroons formats or as a Fly.io token, described as a chain of caveats in the format-neutral model.
formats/ucan
Package ucan reads UCAN 1.0 delegation chains (ucan-wg/spec): signed DAG-CBOR envelopes, each a delegation from its issuer to its audience, ending in an invocation whose prf lists them from the subject's own.
Package ucan reads UCAN 1.0 delegation chains (ucan-wg/spec): signed DAG-CBOR envelopes, each a delegation from its issuer to its audience, ending in an invocation whose prf lists them from the subject's own.
safewrite
Package safewrite writes demo fixtures without trusting what is already on disk: a directory that holds something is refused without force, and every file is written to a temporary name and renamed into place at 0600, so a symlink planted at the name is replaced rather than written through.
Package safewrite writes demo fixtures without trusting what is already on disk: a directory that holds something is refused without force, and every file is written to a temporary name and renamed into place at 0600, so a symlink planted at the name is replaced rather than written through.
termsafe
Package termsafe makes untrusted text safe to print to a terminal.
Package termsafe makes untrusted text safe to print to a terminal.
trust
Package trust loads the key documents for the issuers a chain is checked against: from a trust file, from --jwks flags, from local JWKS documents or from https URLs.
Package trust loads the key documents for the issuers a chain is checked against: from a trust file, from --jwks flags, from local JWKS documents or from https URLs.
ui
Package ui is the interactive view.
Package ui is the interactive view.
version
Package version reports what build this is.
Package version reports what build this is.
pkg
chain
Package chain is the format-neutral model of a delegation chain that whosent's interactive view and command line render.
Package chain is the format-neutral model of a delegation chain that whosent's interactive view and command line render.
chain/chaintest
Package chaintest is the conformance suite every format adapter passes.
Package chaintest is the conformance suite every format adapter passes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL