What an agent can and cannot reach
Toolspoke sits between your agents and your tools. This page is the detail an engineering review asks for.
What an agent key can do
- Call the tools installed in the projects that key was granted, and no others.
- Run the actions the project has left switched on, minus anything the key itself was narrowed away from.
- Use a stored credential without ever handling it.
What it cannot do
- Read a credential back. The API returns a four character preview and nothing else.
- Reach an action the project has switched off, whatever the key was granted.
- Outreach its owner. Taking them off a project takes it out of the key on the next call, and removing them from the workspace stops the key entirely.
What gets logged on every call
A gateway is only a control plane if it can say who asked, on whose behalf, under which policy, and what came back. Here is where Toolspoke answers each one.
Who is the agent?
A workspace key, stored as a SHA-256 hash plus its first 12 characters. The row records the key and the agent's name, so two agents sharing an owner are still told apart.
Which user is it acting for?
The person who created the key. Resolving it is a join against live team membership rather than a lookup, so the identity cannot outlive the account.
Which project is it in?
One of the projects the key was granted, intersected with the projects its owner can reach at the moment of the call. A key whose grants are all removed is inert, not workspace-wide.
Which tool is it reaching?
A toolkit installed in that project. Nothing installed elsewhere in the workspace is addressable, whatever name the agent passes.
Which action is requested?
A named action on that toolkit, classified read, write or destructive. An action carrying no kind is treated as write.
Which policy applies?
The project's list of switched-off actions first, then the agent key's own grant. The project's refusal wins, and it is the project's reason that comes back.
Which credentials are used?
The credential stored on that install, decrypted at call time under AES-256-GCM. The agent never handles it: the API returns a four-character preview and nothing else.
Was the request allowed?
Decided inside the tool runner on every execution, not in the interface. A hidden action is an unreachable one, and a guessed action name gets a refusal with the reason.
What happened?
Status, error, execution path, sandbox id and execution id, written whether the call succeeded or failed.
What was returned?
The full output as JSON, unless the operation declared that its response is not retained, in which case a separate flag records that it was withheld rather than empty.
When did it happen?
A timestamp, plus the total duration split into provider, queue and startup time.
How agent keys are issued and revoked
An agent authenticates with a workspace key. The key carries the identity of the person who created it, and resolving it is a join against live team membership rather than a lookup.
- Key format
- The token is rd_live_ followed by 24 random bytes, base64url encoded.
- What is stored
- A SHA-256 hash of the token, plus its first 12 characters so a key can be told apart from another in a list. The token itself is shown once, at creation, and cannot be retrieved after that. Lose it and you issue a new one.
- Revocation by departure
- Key resolution joins the key to organisation membership. Remove a teammate and every key they created stops working on the next call, even though the string they hold is still syntactically valid. There is no separate revocation step to remember.
- Project scope
- A key addresses only the projects it was granted, intersected with the projects its owner can reach at the moment of the call. A key whose grants have all been removed is inert rather than falling back to the whole workspace. A key issued for all projects picks up projects created after it, which is the one case where the grant is a standing flag rather than a set of rows.
- Capped at the owner, both ways in
- The same intersection is applied whether the call arrives on a raw agent key or through a connector its owner signed into over OAuth, and it is one piece of code so the two cannot drift apart. Move somebody off a private project and their agents come off it on the next call, with nothing to revoke by hand. An agent can be handed to a different owner, and that hand-over writes a record, because it changes what a live token reaches without the token changing.
- Credential encryption
- Credentials are encrypted with AES-256-GCM under a key derived as sha256 of CREDENTIALS_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, with a 32 character minimum. Ciphertext is stored as iv.tag.ciphertext in base64url with a fresh random 12 byte IV per value. In production a missing or short key throws at boot rather than quietly substituting a weak one, because ciphertext that looks fine and protects nothing is the worse failure.
Three levels of access policy
Access is not a role that spans the workspace. A project decides which of an installed toolkit's actions it allows, for everyone in it, and an agent key gets a slice of that and no more.
The project
Who is on it
A project is public to the whole team or private to the people named on it. Anyone can create one and is its first admin. Project admins add and remove people, install and remove toolkits, and delete the project. Organisation owners and admins reach every project by role.
The toolkit
What the project allows
Each installed toolkit's actions are switched on and off individually, for everyone in the project at once. What is stored is the list of what is off, so an action a connector gains in a later release arrives available rather than invisible.
The agent
What one key gets, capped at its owner
A key is granted projects, and can be narrowed to toolkits and to named actions inside them. Whatever the grant says, the key is intersected with what its owner can reach at the moment of the call. It cannot outlast their access to a project.
The classification selects, it does not grant
Actions are classified read, write or destructive, and the project’s action screen groups them by kind so that switching off everything destructive is one click. What it writes down is an explicit list of action names. There is no standing rule behind it: nothing means “read only, including whatever gets added later”, and an action a connector gains in a later release is available until somebody switches it off. An action carrying no kind is shown under write, so picking the read group never sweeps it in.
Enforced at execution, not in the interface
The same check runs inside the tool runner on every execution, so a hidden action is also an unreachable one. An agent that guesses an action name it was never shown gets a refusal with the reason, not a result. The project’s switch is applied before the agent’s grant, so an action the project turned off is refused with the project’s reason even when the grant lists it.
See what a policy change does
Switch an action off for the project and watch what each agent key can still run.
Step 1You choose what this project allows
Step 2Each agent key gets some or all of that
Step 1Project
Engineering · GitHub
Which of the toolkit's actions this project allows.
These switches are live. Try one.
3/ 5actions this project allows
Applies to everyone in the project, and every key in it.
Step 2Agent key
Claude · Ashiq
This key was granted everything the project allows.
3/ 5actions this key can run
All 3 this project allows.
- read repositories
- read issues
- create issues
- merge pull requestsoff at project
- delete repositoryoff at project
Step 2Agent key
Claude · Intern
This key was narrowed further, to reads only.
2/ 5actions this key can run
2 of the 3 this project allows.
- read repositories
- read issues
- create issuesoff at key
- merge pull requestsoff at project
- delete repositoryoff at project
If an agent asked for this right now
merge_pull_requestsDeniedNot allowed by this project, so no key in it can reach it.. Claude Ashiq can now run 3 of 5 actions. Claude Intern can now run 2 of 5 actions.
Where the call actually runs
Two paths. HTTP and streamable MCP tools are called straight from the gateway process. CLI and stdio MCP tools go to a sandbox, which is a separate service.
No shell, by construction
A user supplied command is rejected if it names a shell or an interpreter, or a binary that reaches one indirectly: sh, bash, zsh, node, python, perl, ruby, php, env, busybox, xargs, find, awk and sed among them, alongside destructive names like rm, dd, mkfs, mount, sudo and su. SQL is rendered as a single argument and never assembled by a shell, and a rendered statement that still contains a template marker is refused rather than substituted a second time. Credentials reach the sandbox through a named environment channel, so a secret never appears in a process argument list.
User supplied URLs are checked twice
Importing an OpenAPI document or registering a remote MCP server means fetching a URL somebody typed. Non http and https schemes are rejected, as are embedded credentials, localhost, .local, .internal, .lan and .home.arpa, and the RFC1918, loopback, link local, carrier grade NAT, multicast and IPv6 unique local ranges. The resolved DNS addresses are then checked against the same ranges, so a public hostname pointing at 169.254.169.254 does not pass. Redirects are followed at most twice, same origin only, and only on GET.
Sandboxing is delegated, and we say so
Toolspoke does not run its own sandbox runtime. Sandboxed execution goes to Harborbox, a separate service reached over HARBORBOX_URL with an API key, and in production both variables are required with no fallback. Sandboxes are per project rather than per team, they persist between calls, and they default to 512 MB of memory and one CPU. The isolation properties inside that sandbox are Harborbox's to describe. Nothing in this codebase configures dropped Linux capabilities, no-new-privileges, or a read only root filesystem, so this page does not claim them.
A tunnel exposes the gateway, not the app
Running behind a Cloudflare quick tunnel makes an origin public, not a path, so middleware narrows it back down: the MCP endpoint, the connector OAuth callback, the Stripe webhook, the two well-known metadata documents, and the handful of auth paths an OAuth handshake genuinely walks through. Everything else, the dashboard and the rest of the API included, returns 404 rather than 403, because a refusal that distinguishes forbidden from absent tells a scanner which paths to come back to. HSTS is set only when the request already arrived over TLS.
The caveat on that blocklist
Connectors that ship in the first party catalog run as trusted, and trusted lifts the executable name check. That is how a bash connector can exist at all. The check binds anything a customer typed, which is what it was written for, and it does not bind a connector we shipped. A name based blocklist is a speed bump in any case. What actually bounds an arbitrary binary is that the runtime image is built per project, and that a template only reaches another organisation after review.
How secrets stay out of the log
Every tool call writes one row, successful or failed, with the full input and the full output as JSON. What a row may not hold is decided in two places.
Recorded on every row
- organisation
- project
- user
- agent key
- agent name
- tool name
- input JSON
- output JSON
- status
- error
- duration
- sandbox id
- execution id
- execution path
- provider duration
- queue duration
- startup duration
- timestamp
A connector can withhold an argument before the row is written
An operation may name the arguments that must never be stored, and those are replaced with a marker on the way in, so the value does not reach the database at all. This is what covers content arriving under an ordinary name: a message body, a document, a calendar invitation. Gmail and Google Calendar use it today. The declaration lives with the operation rather than with the deployment, so it applies wherever the connector is installed.
Everything else is redacted by key name, at any depth, on read
A key called authorization, cookie, password, secret, token, api_key, credential or private_key has its value replaced when the record is served, however deeply nested it sits, and the same applies to the hyphen and underscore spellings. Free text gets a second pass: a Bearer prefix and a token= query parameter are stripped out of strings, because that is where a secret hides when it is not sitting under a name that gives it away. Be precise about the difference between the two. The declaration above keeps a value out of the database; this one masks what is already there whenever anyone reads it through Toolspoke. If a payload carries a secret under an ordinary name and no connector declared it, treat the row as holding it.
An operation can withhold its output entirely
A connector operation may declare that its response is not to be retained. When it does, the output is never written rather than written and later cleared, and a separate flag records that it was withheld. That distinguishes a deliberately empty output from a call that genuinely returned nothing, which matters when you are reading the log to work out what happened.
Retention is yours to set, and today it is a database column
The retention window is per organisation and defaults to null, which means rows are kept indefinitely. Set a number of days and older rows are deleted on the sweep. Be clear about the limitation: there is no screen for this yet, so setting it is an operator task against the database.
Deleting logs cannot change a bill
Credits are a separate ledger and are not derived from the log. Trimming or deleting audit rows changes what you can see, not what you were charged, so retention is a privacy decision rather than a financial one.
![An expanded audit row in Toolspoke for a sandboxed aws invoke call, with the api_key argument shown as [REDACTED] and the Harborbox sandbox execution path beside the provider, queue and startup timings.](/_next/image?url=%2Fscreenshots%2Faudit-detail-redacted.png&w=3840&q=75)
What leaves your deployment
Listing this honestly is worth more than leaving it out. Everything Toolspoke talks to, and why.
- The providers you connectEvery call
- Notion, GitHub, Stripe, your own APIs and MCP servers. This is the product working: a tool call is a request to the provider you installed, made with the credential you stored.
- HarborboxSandboxed calls
- The sandbox service that runs CLI and stdio MCP tools. Reached over HARBORBOX_URL with an API key.
- InfisicalStartup
- Where runtime secrets are read from at boot. The only bootstrap credential is the one that authenticates to Infisical itself.
- StripeIf configured
- Subscriptions, seat counts and credit top-ups. Not required for a self-hosted install.
- OpenRouterThose features only
- Model calls for the toolkit builder, and embeddings for tool search.
- GitHub Releases APITemplate extraction
- Reads the latest release of a repository while extracting a CLI template.
- SMTPInvitations
- The mail server you configure, for workspace invitations.
Runtime secrets are read from Infisical rather than an environment file on disk, which means the only credential the deployment has to hold itself is the one that authenticates to Infisical.
Run the whole thing yourself
If none of this should leave your network, it does not have to. Toolspoke ships as Docker Compose. Stripe is optional for an internal deployment, and sandboxed execution needs Harborbox deployed alongside it, since that part is a separate service rather than something the app runs itself.
- Runtime
- Node 22 or newer
- Database
- PostgreSQL 17
- Packaging
- Docker Compose
- Sandboxing
- Harborbox, deployed separately
How to report a vulnerability
Get in touch with enough detail to reproduce it, and give us the chance to fix it before it goes anywhere else. We will confirm we received it and tell you what we found. There is no bug bounty programme today, so this is us asking a favour rather than offering a payout, and we would rather say that than let you assume otherwise.
What this page does not claim
The gaps are as useful to you as the guarantees, and you would find them anyway.
No SOC 2 report
Toolspoke has not been through a SOC 2 audit, and it has no ISO 27001 or HIPAA attestation either. If your review needs one of those, we do not have it yet.
No third party penetration test
The security properties on this page come from reading the code, not from an external test report we can send you.
No retention control in the product
Audit retention is a per organisation column that defaults to keeping rows indefinitely. Today an operator sets it in the database. There is no screen for it.
No credential key rotation in place
Stored credentials cannot be re-encrypted under a new key without being re-entered. Changing the encryption key makes existing ciphertext unreadable, so plan a rotation as a re-entry.