尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
A credential broker for agents. The agent asks for an effect by name and never holds the secret that causes it.
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗A toolkit for agents with credential and auth management built in. An agent asks for an effect by name and never holds the secret that causes it.
stk init # local CA, sealed store, a starter policy file
stk add github slack x # services, with safe defaults
stk connect github # the credential, from stdin — never a flag
stk run -- your-agent # the agent runs with a placeholder, not a secret
stk audit # what it actually did
The agent never holds, reads, or receives a credential.
Not configurable, no override. Which effects an agent may trigger is policy you set. That line is the product; everything else is detail.
The standard way to give an agent a tool is to put an API key in an MCP config file. That file is plaintext, any process can read it, and the agent reads it to start the server — so the credential ends up in the agent's own context, and from there in the transcript and wherever transcripts are kept.
supertoolkit holds the credential in a process the agent cannot read, and puts it on the wire on the way out.
A broker runs as its own OS user on a Unix socket. Clients — CLIs, MCP servers,
anything that speaks HTTP — are started with HTTPS_PROXY pointing at it and a
placeholder where their token would be:
GH_TOKEN=stk:ref:github:v1
The broker recognises that placeholder on the wire and does whatever the service needs: substitutes a token, decodes and re-encodes a Basic header, signs with SigV4, mints a JWT, or refreshes an OAuth 2.0 grant into a short-lived access token. Clients need no modification. If an agent reads the placeholder, it has read nothing — it means nothing off this host.
Three of the five credential operations never put a long-lived secret on the wire at all.
docs/limitations.md — what it does not do, and why. The one failure mode that cannot be fixed by design is a client that ignores proxy configuration entirely; it goes around the broker and fails silently.
That is detectable from outside, and detecting it is a command:
$ stk doctor --probe
✓ curl honoured the provider received the substituted credential
✓ python3 urllib honoured the provider received the substituted credential
✓ node fetch honoured the provider received the substituted credential
✗ go net/http refused-ca it would not trust the broker's certificate
Run it before trusting a new client. A client reported as bypassed must not be
brokered. A client that refuses the certificate is the safe case: the request
does not happen, rather than happening unbrokered.
Numbers and the machine they came from: docs/measurements.md.
doctor warns; STK_PASSPHRASE from your init system
avoids it.One file, committed to your repository, holding no secrets:
workspace: default
services:
github:
capabilities:
create_issue: approval # the agent proposes, you decide
x:
oauth2:
client_id: <your app's client id>
profiles:
reader: # a profile narrows; it can never widen
github: [get_user, list_repos]
Defaults come from the catalogue and fail safe: a read is agent-callable, a
write needs approval, and anything unclassified is treated as a write. Marking a
capability deny is a floor that no profile can lift.
Four ways, in order of preference:
| Command | When |
|---|---|
stk connect <svc> | you have the credential in hand |
stk connect <svc> --browser | OAuth 2.0 authorization code + PKCE, with a browser here |
stk connect <svc> --device | no browser on this machine — RFC 8628, approve from your phone |
stk handover key | seal | open | last resort: seal it on a machine that has a browser |
The credential travels provider → broker. Not through a hub, not through us. The hand-over path is the exception and says so rather than hiding it.
Eleven, chosen so every auth scheme and placement has a live connector behind it rather than only a constant:
| Service | Operation |
|---|---|
github, slack, notion, openai, cloudflare, resend | substitute a token |
stripe | substitute inside a Basic header (decode, replace, re-encode) |
aws-sts | sign with SigV4 — the credential is an HMAC, never a value |
github-app | mint a JWT, exchange it for an installation token |
x, google | refresh an OAuth 2.0 grant into a short-lived access token |
A service we do not front directly is reachable as a fronted MCP server: the child gets a placeholder in its environment and nothing else.
A self-hosted Forgejo or Gitea has a host no catalogue could name, so a forge is
declared in supertoolkit.yaml instead of compiled in:
services:
forge-main:
kind: forge
provider: forgejo # forgejo | gitea | github | bitbucket
host: forge.example.com # a bare hostname
scheme: bearer # or `token` for Authorization: token <t>
env_var: FORGEJO_TOKEN
repositories: [acme/widgets] # owner/name, required
preset: issues # read | issues | pull-requests | maintain | none
Then stk connect forge-main (token on stdin) and stk verify forge-main, which
makes one read call as that credential and prints the login it acts as, or a
reason it failed.
Presets are enforced. The broker decides every request to a forge before it
fetches the credential: a request no rule allows gets a 403 that names the
rule, and nothing is sent to the provider. The preset's rules come first, then
your own rules:; a matching deny always wins, and anything not allowed is
denied.
| preset | allows (cumulative) |
|---|---|
read | GET on /user and the configured repositories |
issues | read, plus creating and editing issues, comments and issue labels |
pull-requests | issues, plus opening and editing pull requests, reviews and review requests |
maintain | pull-requests, plus merging pull requests, releases and repository labels |
none | nothing; write every rule yourself |
No preset allows DELETE; none is empty, so a DELETE needs an explicit
allow of your own. Bitbucket has no presets yet: use preset: none.
preset: read
rules:
- allow: "PATCH /repos/{owner}/{repo}/issues/*"
when: {state: absent} # a JSON body must not set `state`
- deny: "POST /repos/{owner}/{repo}/issues/*/lock"
Paths are written relative to api_prefix. {owner} and {repo} must be used
together and match the configured repositories. Paths containing .., // or
an encoded slash are refused. GraphQL is denied by default (rule
builtin:graphql), because one endpoint carries every operation; setting
allow_graphql: true on the service stops that built-in deny. The endpoint is
then still subject to the path rules (a POST …/graphql needs an allow
rule; default deny applies), but the operation inside the request body is not
inspected, so no rule can restrict which queries or mutations run; a body
condition on a /graphql pattern is rejected when the config loads.
stk audit shows the rule that denied a request. Design:
docs/forge-broker-design.md.
auth: none declares a forge service that holds no credential. The broker never
reads the store for it and injects nothing; its rules still decide every request
and stk audit still records it. Anonymous git over HTTPS is the case it is for:
services:
git-public:
kind: forge
provider: github
host: github.com
auth: none
api_prefix: /
preset: none
repositories: [acme/widgets]
rules:
- allow: "GET /{owner}/{repo}/info/refs"
- allow: "POST /{owner}/{repo}/git-upload-pack"
- deny: "POST /*/*/git-receive-pack"
A push opens with GET …/info/refs?service=git-receive-pack; the broker decides
that request as the push it begins (under the rule for POST …/git-receive-pack),
so a denied push is refused at its first request. scheme and env_var cannot
be set with auth: none, and stk connect/stk verify refuse it. Point git at
the broker with http.proxy and http.sslCAInfo (from stk env), and turn off
ssh://, git:// and ext::, which would go around it.
A credential can also arrive sealed, from a machine that has it: docs/handover-v1.md.
stk mcp speaks MCP on stdio with exactly four tools — categories, actions,
details, execute — so a hundred services cost four tool definitions of context
instead of a thousand:
stk mcp install claude # prints the client config; no secret in it
stk mcp serve <server> serves one server from servers: directly: its own
tools, its own names. Every tools/call is decided by policy and recorded in
stk audit — allowed or denied, with the rule id — before the server sees it,
and the server's HTTP(S) goes through the broker, decided by egress:.
egress: { preset: public-only } # hosts not named in policy: public addresses only
servers:
fetch:
command: /home/agent/.local/bin/mcp-server-fetch # uv tool install mcp-server-fetch==2026.10.10
network: confined # or watched (detect + stop), or host
egress: { preset: public-only, schemes: [https, http], methods: [GET] }
capabilities: { fetch: agent }
stk mcp serve fetch --socket /run/stk/stk.sock # decisions and audit by the broker daemon
stk mcp serve fetch --standalone # in-process broker, for a laptop
egress: decides by address after the broker resolves the name, pins the
connection to that address (no DNS rebinding), and decides every redirect hop
afresh, because the broker never follows one. Classes: loopback, private,
link-local (169.254/16, cloud metadata), cgnat (100.64/10, tailnets), ula,
unspecified, multicast, reserved; any of them, or a CIDR, can be named in
allow:/deny:. The design is
docs/mcp-serve-and-egress.md.
$ stk audit
Oct 15 01:12:44 x post_tweet approval 201 Created
POST /2/tweets · credential: signature (OAuth 2.0 refresh)
Durable, append-only, owner-only. What is recorded about the credential is a
fingerprint or the name of the operation that replaced it — never the value, and
there is a test for every scheme. The web UI (stk ui) reads the same file, so
the two cannot disagree.
On a machine where an agent runs as one OS user, the broker can run as another, so the agent cannot read the sealed store, its key or the approvals. The agent still reaches the broker, over a loopback proxy it must present a key to.
stk installsudo stk install --user stk --group stk --agent-uid 1001 [--agent-uid 1002] [--unit-name stk]
Linux with systemd only; on any other OS it refuses. It must run as root and
needs at least one --agent-uid (without one the socket allowlist would be
empty). --dry-run prints the plan and the unit and changes nothing. It
creates, if absent:
nologin) and, with --group, that group;/var/lib/stk, 0700), the runtime directory
(default /run/stk, 0700, or 0750 with --group) and the config
directory (default /etc/stk, 0750 root:<group>);stk-passphrase.key in the state directory (0400, random, never printed);
the unit hands it to the service with systemd LoadCredential, so the
passphrase is not in an environment file;/etc/systemd/system/<unit-name>.service (default stk, see
Several instances on one host), running stk serve --proxy-listen 127.0.0.1:<port> --socket-mode … --allow-uid … (default port 8899).If a config file already exists its mode is set to 0640 root:<group>; install
never creates policy. A second run changes nothing. It refuses to run when the
state directory holds a store but no stk-passphrase.key, rather than add a new
passphrase over a store sealed under another key. It does not add agent users
to the group: with --group, you run usermod -aG <group> <agent-user>
yourself. Then systemctl enable --now <unit-name> (the report prints the exact
command).
To keep one station's agent from ever using another station's brokered
credential, run one stk per station. Give each its own everything: user and
group, --state-dir, --runtime-dir, --config, --proxy-port, --agent-uid
and --unit-name:
sudo stk install --user stk-st2 --group stk-st2 --state-dir /var/lib/stk-st2 \
--runtime-dir /run/stk-st2 --config /etc/stk-st2/supertoolkit.yaml \
--proxy-port 8900 --agent-uid 2001 --unit-name stk-st2
--unit-name NAME (letters, digits, _ . -, at most 64, no .service suffix,
no @; default stk) writes /etc/systemd/system/NAME.service; the default
unit's bytes are unchanged. Each instance has its own sealed store, passphrase,
proxy key and uid allowlist, and stk doctor run with that instance's
STK_HOME/STK_CONFIG/STK_SOCKET (as in its unit) reports that instance's
isolation only. The agent is given only its own proxy URL and key (stk env
as that instance), never another's.
Install refuses, before changing anything, to overwrite an existing unit at
that name unless its User= and STK_HOME (the state directory) are the plan's
own: a different user or state directory, or a file that is not an stk unit, is
another instance, and the error says to pick a different --unit-name. The same
plan re-run changes nothing; the same instance with, say, a new --proxy-port
updates its unit. Install does not check that ports, runtime directories or
state directories are unique across instances: choose them distinct.
stk serve --proxy-listen and stk envstk serve --proxy-listen <host:port> (loopback addresses only) publishes the
proxy at a fixed address. It creates proxy.key in the state directory (0600)
and the broker then answers 407 to any CONNECT that does not carry it,
before opening any tunnel. Without --proxy-listen, serve keeps its random
loopback port and no key.
stk env [--json] prints the variables a client needs: the proxy URL with the
key as URL userinfo, the CA path, a placeholder for each configured service,
NODE_USE_ENV_PROXY=1 and STK_BROKERED=1. Run it as a user that can read the
state directory.
The proxy key is not a service credential. It gates use of the broker and nothing else: holding it lets a client use the routes policy already allows, and unlocks no secret. The agent is still never given a credential.
Before printing the key, stk env verifies the listener recorded in
proxy.addr is a live stk proxy: the recorded pid must be running, and a
keyless CONNECT to a dummy host must be answered 407 with stk's own realm.
A crashed serve leaves a stale proxy.addr, and another process may since
have taken the port; the key is not handed to it. This is a check against
accident, not against a local attacker who can bind the port while stk is not
running.
stk run is unchanged: its per-session loopback proxy is not authenticated.
stk reload and SIGHUPstk reload [--json] (or kill -HUP on stk serve) re-reads the policy file
in the running service. A file that does not load keeps the old policy and
reports why; a service removed from the file stops being brokered, and its
stored credential is kept (stk revoke removes a credential).
Policy is read per request, so a tunnel opened before a reload enforces the new
rules on its next request (a request already in flight keeps the policy it
started with). Which hosts are brokered is decided when a CONNECT is
accepted: a request on an open tunnel whose service was removed, or whose host
now belongs to another service, is refused with 403 rather than forwarded.
stk verifystk verify <service> [--expect-login <login>] [--json] makes one read call as
the stored credential and prints the login it acts as, or the reason it failed,
and exits non-zero on failure. It never prints a token. For a GitHub App grant
it checks the app with a signed JWT and then reads the installation's
repositories; it fails if any configured repository is not in the installation.
services:
acme-app:
kind: forge
provider: github
host: github.example.com # GitHub Enterprise Server; api.github.com for github.com
auth: app
env_var: GH_TOKEN
repositories: [acme/widgets, acme/gadgets] # one owner only
preset: issues
auth: app is GitHub-only and sends the installation token as a Bearer header
(scheme: token is refused with it). The owner is taken from repositories,
which must all belong to one account; the installation token is requested
restricted to just those repositories. For any host other than api.github.com
the API is served under /api/v3, which is the default api_prefix. Connect it
with app_id, installation_id and private_key as field=value lines on
stdin to stk connect acme-app, then stk verify acme-app.
stk doctor: isolationstk doctor also reports an isolation level for the agent uids it is told about
(the top-level agent_uids: list in the policy file, plus any --agent-uid).
The level never changes doctor's exit status. It is a claim about files, and it
claims no more than was checked:
| level | meaning |
|---|---|
separate-user | every checked path passed every rule below, for every agent uid |
same-user | at least one reason was found; the reasons are listed |
unknown | no agent uid is configured, so nothing was checked |
The rules, per path and per parent directory up to /: none is owned by an
agent uid (an unknown owner counts against it); no secret-holding path (state
directory, *.cred, salt, machine.key, ca.key, handover.key,
proxy.key, proxy.addr, stk-passphrase.key, audit.log, approvals) has
a group or other read or write bit (for a directory, any group or other bit),
since agent groups are not known; no parent directory is
group- or other-writable; no symlink leads outside the checked tree; a path that
cannot be examined for a reason other than not existing is a reason. The socket
may be group-accessible, and the policy file group-readable, by design. An agent
uid of 0 is a reason.
ACLs are examined on Linux only, by reading the POSIX ACL attributes: any
ACL on a checked path or parent is a reason, and so is a filesystem that cannot
say (no POSIX ACL support, NFSv4, some FUSE and overlay mounts). On macOS and
other systems ACLs cannot be read, so the report always carries that reason and
can never be separate-user.
When doctor runs as root it also makes the access attempt: it re-runs itself as
each agent uid and tries to open the secret-holding paths. Anything that opens
is a reason and forces same-user; the method then reads mode-bits+access
rather than mode-bits. As non-root that step is skipped and the method says so.
stk doctor --probestk doctor --probe starts a real broker and a stand-in provider at a hostname
that cannot resolve, and drives curl, python3, node and a default Go client
through it (a client not installed here is missing). Each reports one of
honoured, bypassed, refused-ca, missing or failed.
The exit status is non-zero when any client is bypassed (it never asked the
broker) or failed, and also when no client was honoured (all missing, or
only refused-ca): the error is "no client was exercised; nothing is proven".
refused-ca (loud, safe) and missing do not fail it when at least one client
was honoured.
--json writes a JSON array to stdout and nothing else; the table goes to
stderr. The exit status is the same as without --json:
[
{
"client": "curl",
"outcome": "honoured",
"detail": "the provider received the substituted credential"
}
]
If the probe rig cannot start, stdout is empty, the error is on stderr and the
exit status is non-zero. The ephemeral proxy key is removed from every detail.
--with-proxy-key runs the probe through a broker that requires a proxy key,
sent in the proxy URL. A client that drops the URL's credentials gets a 407
and is reported failed with the detail proxy credential not honoured. The
probe still only tests clients as run from this environment; see
docs/limitations.md.
Not yet released. Build from source:
go build -o stk ./cmd/stk
Apache-2.0.