Secret-free agent tools: action contracts that hold up
Secret-free agent tools keep credentials outside AI processes by using action contracts, constrained parameters, human approval, and auditable execution.

Agents should ask for actions, not receive the means to impersonate a person or service account. That sounds obvious until you inspect a typical agent tool: an http_request function accepts a URL, headers, method, and body, while the agent gets a bearer token from an environment variable. The tool call looks tidy. The authority is scattered across prompt text, process memory, logs, shell history, and whichever subprocess the agent starts next.
A secret-free design puts a hard boundary between intent and execution. The agent says, "create a deployment for this service in this environment". A credential-owning layer decides whether that action may run, selects the right identity, makes the authenticated call, and returns the result. This changes what you can audit, approve, and revoke. It also forces you to write interfaces that deserve autonomy.
A contract must describe intent, not transport
An action contract names the operation a user recognizes and limits its inputs to facts needed for that operation. Transport details belong behind the boundary. The difference is easy to miss because HTTP makes every action look like a method, URL, headers, and a JSON body.
Consider two tool interfaces for opening a change request. The first is common and unsafe for an autonomous process:
{
"name": "http_request",
"input": {
"method": "POST",
"url": "https://code.example/api/projects/alpha/changes",
"headers": {
"Authorization": "Bearer ${TOKEN}",
"Content-Type": "application/json"
},
"body": {
"title": "Fix timeout",
"branch": "agent/fix-timeout"
}
}
}
This interface gives the agent authority over destination, authentication method, and request shape. Removing the literal token does not fix it if the agent can choose a header alias, a credential identifier, a proxy URL, or a shell command that reads a token elsewhere. You have moved the secret, but you have not reduced the authority.
A contract-oriented interface looks more like this:
{
"name": "create_change_request",
"input": {
"project": "alpha",
"source_branch": "agent/fix-timeout",
"title": "Fix timeout in retry path",
"description": "Adds a bounded retry and a regression test."
}
}
The execution layer maps project to a known endpoint and an approved account. It supplies the authentication header itself. It may reject the branch name, verify the target repository, ask for approval, or return an error from the remote service. The agent has no parameter that means "use whatever credential grants the most access."
This is a distinction people routinely blur: secret-free is not the same as token-redacted. Redaction tries to control what the agent sees after it receives authority. An action contract prevents the agent from holding that authority in the first place. If a model prompt leaks, a tool transcript gets copied, or a subprocess reads its environment, the first design has already lost a credential. The second design may expose operational data, which needs its own controls, but it does not hand over the signing material.
A contract also should not pretend that every endpoint deserves a custom tool. Custom actions make sense where a person can state the intended outcome in a sentence. "Restart this staging workload" is an outcome. "Send a PATCH to any URL" is a transport primitive. If you need the primitive for a maintenance job, give it to a separate, tightly bounded integration, not to a general-purpose coding agent.
The credential owner must execute the request
A contract does not protect anything if the agent still performs the final network call with a secret mounted in its process. The component that stores the credential must make the HTTP request or SSH connection itself.
That means the execution boundary owns five jobs:
- It resolves the action name to a fixed destination and protocol behavior.
- It chooses a stored identity from a small approved set.
- It injects the credential only into the outbound request or SSH authentication exchange.
- It records the request, decision, and result without writing secret material into the record.
- It returns a response shaped for the action rather than a dump of its internal state.
The model process should receive neither a token nor a private key, including a temporary one. Avoid shell conventions such as TOKEN=$(vault read ...), credential files in a working directory, Authorization values in generated curl commands, and SSH agents shared with an agent-run shell. Each looks convenient because it preserves existing scripts. Each makes the agent process a credential holder.
The OAuth 2.0 Security Best Current Practice from the IETF makes the same practical point in a different setting: bearer tokens must be protected in storage and transit because anyone who possesses one can use it. A bearer token is not made safe by telling a model to avoid printing it. Possession is the authorization check. For an agent tool, the better design is to avoid giving the process possession.
For SSH, the boundary has to own more than the private key. A raw ssh host command interface gives the agent broad reach even when the key never leaves a helper. The helper should select a stored host definition and identity, then enforce a command form suited to that host. A deployment host might allow status, restart-service, and tail-release-log with a service name. It should not quietly accept bash -c because someone wanted a shortcut.
Do not confuse this with a man-in-the-middle proxy. A proxy relays arbitrary client traffic and commonly sees credentials in transit. A credential-owning action layer receives a request for a named operation, constructs the outbound call, and keeps the credential in its own vault. The distinction determines whether the agent can turn an approved action into a different one.
Parameter design decides how much authority leaks through
Every field in a contract creates a degree of freedom. Good fields identify the object of work or provide content that the action genuinely needs. Bad fields alter where authority goes, which identity gets used, or what lower-level operation runs.
Use this test on each proposed input: if the agent changes this value, can it redirect a privileged request to a different system, widen the set of affected resources, or alter authentication? If yes, either remove the field, turn it into an enum mapped by the executor, or split the operation into separate contracts.
A deployment interface illustrates the point:
{
"name": "deploy_release",
"input_schema": {
"type": "object",
"additionalProperties": false,
"required": ["service", "environment", "version", "reason"],
"properties": {
"service": {"type": "string", "enum": ["api", "worker"]},
"environment": {"type": "string", "enum": ["test", "production"]},
"version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"},
"reason": {"type": "string", "maxLength": 500}
}
}
}
The schema prevents surprise fields such as url, headers, credential_name, or command. The executor can map service and environment to a known deployment target. additionalProperties: false matters more than it appears. Without it, a permissive validator may preserve an unrecognized field, and someone later may wire that field into an HTTP client "for flexibility." That is how a harmless extension point becomes a credential escape hatch.
Enums are not always the answer. A repository name, branch, ticket number, or file path may need to vary. Validate those values according to their domain and apply a boundary check after resolution. For example, resolve a repository identifier through a local allowlist, then use the mapped remote location. Do not accept a repository URL and attempt to decide whether it looks friendly.
Free text deserves a separate judgment. An agent may need to write an issue description, a pull request summary, or a support reply. That text is content, not authority, but it can still cause harm through mentions, markup, templates, or embedded commands consumed by the downstream service. Bound its length, make its rendering behavior explicit, and do not interpolate it into a shell command. When an action must execute a command, construct the argument vector directly and keep untrusted text in a data argument, never in a command string.
Generic request tools create hidden policy engines
A generic HTTP tool is popular because teams can connect an agent to any service in an afternoon. It is wrong for most privileged agent work because every prompt, tool description, and code branch becomes an unofficial authorization policy.
A team often starts with a wrapper such as:
request(method, url, headers, body)
Then it adds guardrails. Block a few domains. Strip Authorization. Permit certain methods. Parse a URL prefix. Reject localhost. Require an approval dialog for risky calls. Months later, someone needs a new endpoint with a custom header, adds an exception, and the wrapper now has a policy language without tests or a clear owner.
The problem is not that generic tools are always evil. They fit a human-operated debugging console, where the operator already holds the authority and can inspect every byte. They also fit an integration service that receives calls from code you control and has a narrow network identity. An autonomous agent is different because it can make many calls, discover unexpected paths, and act on untrusted text. It needs fewer degrees of freedom.
Write named actions around stable work units. For a source control service, prefer read_merge_request, comment_on_merge_request, and create_branch over a universal REST client. For operations, prefer get_service_status, fetch_release_logs, and request_deployment. You may need more contracts, but each has an owner, a test set, a clear approval label, and a reviewable blast radius.
Do not hide a generic request inside a named action either. A tool called update_ticket that accepts arbitrary path, method, and body has only changed the label. The contract must bind those details. It can expose a controlled patch object when the downstream API needs it, but the executor should decide the endpoint, HTTP method, content type, and account.
The Model Context Protocol specification helps with tool discovery because it lets a server publish tool names, descriptions, and JSON input schemas to a client. That schema is useful, but it cannot make an overly broad action safe. A JSON Schema can tell you that a URL is a string. It cannot tell you that the URL is the only billing endpoint your production credential should ever reach. Authorization remains an execution-layer duty.
Approval should name the action a person can judge
Human approval works when the person sees a recognizable request and can reject it quickly. It fails when the prompt asks someone to approve an opaque packet of transport details after the agent has already made the important choices.
Compare these approval cards:
Allow POST https://api.example/v1/resources/882?
Headers: Authorization, X-Region, X-Client
Deploy version 2.14.3 of api to production
Reason: Fixes failed payment retries
Requested by: signed agent process build-worker
The second card lets an operator judge intent. It also gives the audit record a useful sentence. The first asks the operator to reconstruct meaning from a URL and header list, which invites approval fatigue. People click through unreadable prompts, especially when a normal agent run creates several of them.
Use approval at the point where a decision changes authority. An execution layer can authorize a new agent process once for a session, then require a fresh decision for selected sensitive credentials or destructive actions. That separation keeps routine work usable without treating all credentials as equivalent. A read-only project token and a production deployment identity should not share an approval rule merely because both travel in HTTP headers.
Approval language must say who requested the operation. Process identity is useful because a terminal agent, a background helper, and an unknown executable do not deserve the same trust. On macOS, code-signing authority can give the person approving a run a concrete origin signal. It does not prove that every prompt instruction is safe, but it answers the first question: which process is asking to act with this account?
Never make approval the only control. A person can misread a prompt, approve under time pressure, or leave a session open. The contract still needs constrained inputs and a fixed credential route. Conversely, do not add a policy language when a clear action contract plus an approval choice answers the need. Rules that compare arbitrary fields, time windows, regular expressions, and user claims quickly become another program that nobody can confidently review during an incident.
A failed deployment shows where loose contracts break
A familiar failure starts with an agent that can deploy to test through a shell tool. The team stores a cloud token in the agent's environment because the deployment CLI expects it. The tool schema accepts environment and extra_args, which seemed harmless when only test existed.
A ticket asks the agent to "verify the urgent fix in test and share the result." The agent runs the expected command. It then sees a stale deployment message in the repository and tries an extra argument copied from an old script. That argument selects production, or changes a target account, or injects a shell expansion. The token had production scope because maintaining separate credentials felt like overhead. At that point, the prompt wording does not save you. The process already holds broad authority and the interface lets it select the target.
A contract boundary changes the sequence:
- The agent calls
deploy_releasewith an enumerated service, environment, version, and reason. - The executor resolves the environment to a fixed target and selects the identity assigned to that target.
- The executor asks for a decision if that identity requires one, then records the result under the agent run that requested it.
- The executor returns a deployment identifier and status, or a structured denial that tells the agent why it cannot proceed.
The agent cannot add --account, set a cloud endpoint, or read a token. A mistaken production value remains possible, because humans and models can ask for the wrong thing. But the approval text now says production in plain language, the selected identity can have only the authority intended for production deployments, and the action record ties the decision to the process and request.
This distinction matters during diagnosis. In the loose design, investigators often find fragments: a shell transcript, cloud audit events, a CI log, and perhaps a token value that now needs rotation. In the contract design, they can inspect the requested action, resolved target, identity label, approval result, response status, and the session that initiated it. An audit trail does not erase an error, but it shortens the time spent guessing which path ran.
Error responses must guide recovery without exposing internals
A secure action layer should return errors an agent can act on without returning the secret, request signing data, or internal vault structure. Vague failures drive agents toward retries and workarounds. Overly detailed failures turn error logs into an information channel.
Use stable error codes and a small public shape:
{
"ok": false,
"error": {
"code": "APPROVAL_REQUIRED",
"message": "Deployment to production needs user approval.",
"retryable": true,
"request_id": "act_01H..."
}
}
A locked vault should report VAULT_LOCKED; a denied user decision should report APPROVAL_DENIED; a contract violation should report INVALID_ARGUMENT; a downstream 429 can report REMOTE_RATE_LIMITED. The agent can report the state, wait, retry after the indicated condition changes, or take a non-destructive alternative. It should not receive an error that includes a raw authorization header, token subject dump, private host configuration, or a full signed request.
Separate authorization failure from remote failure. "Permission denied" can mean the local executor refused the action, the selected remote account lacks permission, or the downstream service rejected malformed authentication. Those require different repairs. The public message can remain concise while the executor's protected audit record carries a precise reason code and remote status.
Retries need contract semantics. Read operations often tolerate retries. Creating a ticket, sending a message, or starting a deployment may not. Include an idempotency identifier where the remote API supports it, generated by the executor or supplied as a constrained request identifier. Record the association before sending the request, then reuse it on retry. Do not let an agent invent fresh identifiers each time it sees a timeout, or it may create duplicate work while trying to be helpful.
For actions without remote idempotency support, use a prepare-and-confirm arrangement. The prepare action returns a short-lived plan describing the target and diff. The confirm action refers to that plan and requires a current approval. This costs an extra round trip, which is cheaper than repeating a payment, deletion, or production change after an ambiguous network failure.
Audit records need two views and one source of truth
A useful audit system answers two different questions: what did this agent run attempt, and what did each privileged call do? If you merge those into one undifferentiated event stream, people struggle either to reconstruct a session or to find a particular request.
Keep a session journal for the run. It should show process identity, start and end, authorization decision, revocation state, and the actions requested during that run. Keep an activity journal for calls. It should show the action name, normalized parameters, selected credential label without its secret, approval result, timing, destination class, and outcome.
Both views should derive from the same append-only record. Otherwise the session screen and call log can disagree when one writer crashes or filters events differently. A write-blind encrypted log also has a practical advantage: the component that appends an event does not need to decrypt past records merely to write a new one.
Tamper evidence needs an offline check. A hash chain lets a verifier detect deletion, substitution, or reordered records when it has the log sequence. The check should run over ciphertext so an auditor can verify continuity without receiving the vault key. This does not prove that a compromised machine never failed to log an event. It does prove that a retained chain has not been edited silently after the fact. Treat those as separate claims.
A command-line verifier should make failures legible. Its output can be as plain as:
$ sp audit verify audit.log
verified: 184 records
first sequence: 9012
last sequence: 9195
chain: valid
If record 9137 was changed, the command should identify the first broken sequence and exit nonzero. Do not report only "verification failed." Incident responders need the location where the evidence stops being trustworthy.
Sallyport uses this split view for agent sessions and individual activity, projecting both from one encrypted hash-chained audit log that sp audit verify can check without the vault key. That is the right shape for a local agent gateway because revoking a running session and investigating an individual call are different jobs.
Contracts need tests that try to escape them
Happy-path tests prove that an action works. Security tests prove that its declared inputs are the only controls the caller has. Write those tests before adding a convenience parameter, because convenience parameters are where authority tends to creep back in.
For each action, test at least these cases:
- Reject an unexpected field, including
headers,url,command, and credential references. - Reject values that resolve outside the action's allowed resource set.
- Confirm that outbound HTTP requests receive credentials only after the executor constructs the destination.
- Confirm that audit entries omit token values, private key material, and signed authorization fields.
- Confirm that a denied or revoked session cannot reuse a previous approval.
Use a fake outbound server in tests and inspect the received request. The test should assert the actual URL, method, headers that the executor constructs, and absence of caller-controlled authentication. Mocking only the executor's internal client misses the question that matters: what would leave the machine if the agent supplied hostile parameters?
Also test the awkward inputs that a model will eventually produce: a repository identifier with a scheme prefix, a branch name containing shell punctuation, a Unicode lookalike in an environment label, repeated JSON fields, an enormous description, and a timeout after the remote service accepted the action. Contract validation should fail closed. If the executor cannot resolve a requested target confidently, it should refuse the call and return a useful error.
Review contracts like code with authority. Ask whether a newly added field gives the caller a route to a different host, a broader account, a different command, or a different object class. If it does, make the authority explicit in the action name and approval behavior. A plain delete_file action with a free-form absolute path is much harder to reason about than remove_preview_asset with an asset identifier resolved inside a known project.
The first contract worth fixing is usually the one that accepts an arbitrary URL or a shell string. Replace it with the smallest named action that covers the work people actually need. The interface gets less clever, and the agent gets less powerful in ways you cannot explain later. That is progress.
FAQ
Can an AI agent use an API without receiving an API key?
No. A tool can authenticate a request without exposing the credential if a separate execution layer owns the credential and performs the request. The agent supplies an action name and constrained parameters, then receives the response or an error.
What is an action contract for an AI agent?
An action contract describes the intent, allowed parameters, expected result, and failure behavior for one operation. It is narrower than an API specification because it should describe only the actions an agent may ask an execution layer to perform.
Which fields should I remove from an agent tool interface?
An agent tool should accept business inputs such as repository, environment, issue text, or resource identifier. It should not accept authorization headers, cookie strings, private key paths, or arbitrary request objects that let the caller choose credentials indirectly.
Is a generic HTTP request tool safe for autonomous agents?
Only when the operation is deliberately a transport primitive and you accept the resulting authority. Most autonomous-agent tools should expose named operations instead, because a generic HTTP method and URL pair often creates a hidden policy engine in every prompt.
How should an agent handle a denied action?
The gateway should return a stable machine-readable error that says the action needs approval or the vault is locked, without revealing secret material. The agent can report that state and wait; it should never try to work around the denial with a different credential path.
How do I support multiple accounts without passing credentials?
Put the account selector in the contract when several identities are legitimate for the same action. Map that selector to a stored credential inside the execution layer, and reject unknown selectors rather than accepting an arbitrary credential reference from the agent.
Does redacting tool output solve credential exposure?
No. Output filtering can reduce accidental disclosure, but it cannot revoke authority that an agent already received in a token or private key. Keep credentials out of the process first, then treat response handling as a separate concern.
Can secret-free tools work with SSH commands?
SSH needs a named host, an allowed command shape, and a stored identity selected by the execution layer. Passing a private key through an environment variable, temporary file, or agent parameter only changes the leak location.
How do action contracts prevent duplicate destructive requests?
Use a replay-safe identifier, a narrow operation, and a request id that the execution layer records. For non-idempotent actions, require a human approval at the point of execution or design a prepare-and-confirm flow with an explicit expiry.
Does MCP provide authorization for agent tools?
Use the Model Context Protocol tool schema for discoverability and input validation, but do not mistake schema validation for authorization. The contract must still bind the requested action to a credential held outside the agent process and to a human-controlled execution decision.