7 min read

Approval card redaction must preserve the decision

Approval card redaction should hide credentials and private data while keeping the destination, target, effect, and risky parameters clear enough to approve.

Approval card redaction must preserve the decision

An approval card has one job: help a person decide whether a specific action may leave their machine. If the card hides enough detail to protect a credential but also hides what the request will do, it has failed at that job.

The usual mistake is treating redaction as string replacement. Engineers mask Authorization, blank a JSON body, and call the result safe. The operator then sees POST https://api.example.com/... and an Approve button. That is not informed consent. It is an empty ritual that trains people to click through the one moment when human control was supposed to matter.

A good card preserves the action's meaning while removing material that would let the person reading, screenshotting, logging, or shoulder-surfing the card reuse a secret. That requires field-aware rendering. Headers, query strings, bodies, and target identifiers each need different treatment.

An approval card must explain the action

An operator should be able to answer four questions in a few seconds: who is asking, where the request is going, what it will do, and what object or scope it will affect. If any answer is absent, the card is incomplete even if every secret is perfectly hidden.

Start with an action line that uses the protocol method and a human verb together:

POST  api.billing.example  /v1/invoices/inv_7KD2/refund
Action: issue a refund

The method matters because GET, POST, PATCH, and DELETE carry different expectations. The human verb matters because a method alone does not tell the operator whether POST /v1/invoices/inv_7KD2/refund creates a draft, submits a payment, or triggers a refund. Do not ask the person to infer application semantics from a route name when the caller already knows the intended operation.

Then show the destination as a real authority, not an account nickname. "Production billing" may be useful supporting context, but it cannot replace api.billing.example. A misdirected request can use a familiar label. The host is the boundary that tells you which service will receive the data and the credential.

RFC 3986 separates a URI into components including authority, path, query, and fragment. That split is useful for approval rendering because each component carries a different kind of decision signal. Do not reduce it to one pretty URL string and hope that masking later will preserve the right information.

The card should also say whether the action creates, changes, deletes, publishes, transfers, or merely reads. "PATCH customer record" is weaker than "change the customer's payout destination." If your request builder cannot provide that sentence, fix the request builder. A card renderer cannot reliably reverse-engineer business intent from arbitrary JSON.

Render a request manifest, not a prettified request

The safe unit of work is a typed request manifest. It records what the agent intends to do before credentials are injected and before any approval view exists.

A minimal manifest might look like this:

{
  "channel": "http",
  "method": "POST",
  "destination": {
    "scheme": "https",
    "host": "api.billing.example",
    "port": 443,
    "path_template": "/v1/invoices/{invoice}/refund"
  },
  "action": "issue refund",
  "targets": [
    {"role": "invoice", "display": "inv_7KD2", "sensitivity": "internal"}
  ],
  "query": [],
  "headers": [],
  "body": {
    "media_type": "application/json",
    "fields": []
  },
  "effect": "financial"
}

This is not an HTTP wire representation. It is the object the approval renderer should consume. The distinction matters. A wire request includes injected credentials, encoded values, and transport details. A manifest carries semantic labels such as action, target role, and effect that a raw request does not have.

Classify every displayable value by what the operator needs to decide, not by where it happened to appear. A bearer token in a header is secret. A signed webhook URL in a query string is also secret. An email address inside a JSON body may be personal data. A repository name can be a target identifier whose visibility is necessary for a safe decision.

Use a small vocabulary and make it consistent:

  • public: safe to show as written.
  • internal: show when it identifies the affected object, but avoid copying it into broad logs.
  • personal: show only the minimum useful form, usually a label plus partial value.
  • secret: never show the value in the card, logs, clipboard, or error text.
  • opaque: show an approved alias or a stable non-secret reference only when it helps distinguish the target.

Do not give callers an unrestricted safe_to_display: true escape hatch. Someone will use it to make a debugging session easier, then leave it in a path that handles production credentials. Require a concrete classification at the boundary where the agent constructs the action.

Headers need names, purpose, and almost never values

Header names often tell the operator far more than header values. Values often carry exactly what you must not place on a human-facing surface.

Show the name of every security-relevant header and a short purpose label. For example:

Headers
Authorization: bearer credential from vault
Idempotency-Key: generated request identifier
X-Request-Reason: "refund requested by finance"
Content-Type: application/json

The first line tells the operator that the request will authenticate, and the credential source tells them whether the process is using an expected stored secret. Showing Bearer eyJ... adds no approval value. It creates a secret exposure path and invites people to compare meaningless token fragments.

OAuth's bearer-token specification defines the Authorization request header as the preferred transmission method, while its security guidance treats bearer tokens as credentials that need protection in transit and storage. That is the right mental model for the approval UI too: the user needs to know a bearer credential is being applied, not inspect the credential itself.

Use these header rules:

  1. Show safe protocol values such as Content-Type, Accept, and If-Match when they affect behavior.
  2. Show names but not values for Authorization, Proxy-Authorization, Cookie, Set-Cookie, signature headers, API-key headers, and custom headers classified as secret.
  3. Show a bounded, escaped value for declared non-secret business context, such as X-Request-Reason, only if it is short and cannot contain personal or secret material.
  4. Show that a header is absent when absence changes the decision. A missing If-Match can matter on an overwrite operation.
  5. Never render all headers by default. Request libraries add noise, and noise hides the one header that changes the action.

A common bad design masks a secret with the first and last four characters: sk_live_...9a31. That pattern feels cautious but is unsafe for short values, structured values, test keys, and values that have already leaked elsewhere. It also makes people think they should recognize secret fragments. Replace the value with a type statement such as stored API credential or request signature.

Headers can also be target identifiers in disguise. A tenant-routing header, an impersonation header, or X-Account-ID can alter who receives the effect. Do not hide it merely because it is a header. Show its role and a safe target label: X-Account-ID: account "Northwind production". If you cannot map an opaque identifier to a safe label, state that an opaque account identifier will be used and require a more deliberate approval for sensitive operations.

Query strings deserve more suspicion than they get

Query strings are visible in URLs, copied into terminals, embedded in error reports, and frequently logged by infrastructure that never sees the request body. Their convenience is exactly why approval cards must handle them carefully.

RFC 9110 warns that information in a URI can be disclosed through references, logs, and other channels, and it advises senders to avoid sensitive information in HTTP target URIs. That is not an abstract standards concern. A card that renders a complete query string can become one more disclosure channel for a value that should not have been in the URI in the first place.

Do not decide that all query values are safe because the request is a GET. Use names, declared types, and operation context.

GET  api.crm.example  /v2/contacts
Query
status = "active"
owner = "sales-west"
include = "notes"
access_token = [secret, hidden]
search = [private text, hidden]

status and include are often useful decision material. search can contain names, email addresses, medical terms, or anything an agent extracted from local files. access_token is obviously secret, but the design cannot depend on obvious names. Some APIs use sig, token, key, code, state, assertion, or a vendor-specific parameter with no warning in its name.

Treat query values as secret by default unless the manifest has explicitly classified them. This is deliberately stricter than many API explorers. Approval UI is not a debugging console. Its reader needs enough information to authorize the request, not a byte-for-byte reconstruction of it.

Preserve duplicate parameters and their order when they affect semantics. A renderer that converts a query string into a dictionary can silently lose tag=urgent&tag=finance, transform repeated values, or make a signing bug invisible. Display a list of entries rather than a map:

Query
label = "finance"
label = "urgent"
expand = "line_items"

If a redacted query field changes request routing or authorization, say so. signature = [signed request value, hidden] gives the operator a better signal than a blank row. If the query includes an opaque share link, do not expose the token. Show the resource label if known, such as shared report: Q2 forecast, and otherwise display shared-resource token present.

Bodies should keep their shape after redaction

Keep SSH keys with the gateway
Use the bundled sp-ssh helper so SSH keys never enter the agent process.

A body that turns into [redacted] tells the operator almost nothing. A body that shows every field verbatim will eventually leak something that should never have reached the approval surface. The right answer is structural redaction.

Render the body as a typed tree. Keep object keys, array counts, data types, safe enum values, and selected target labels. Replace unsafe leaves with an explanatory marker.

{
  "invoice": "inv_7KD2",
  "amount": {"currency": "USD", "minor_units": 12500},
  "reason": "duplicate charge",
  "customer_note": "[private text, 84 characters]",
  "payment_method": {
    "id": "[opaque payment method]",
    "token": "[secret, hidden]"
  }
}

This representation lets the operator see that the action refunds 125.00 USD for a stated reason and carries a private note that will leave the machine. That is enough to decide whether the request resembles the intended task. The card does not disclose the note or token.

Preserve numbers when numbers are the effect. Hiding payment amounts, seat counts, retention periods, rate limits, permission levels, and deletion counts makes approval meaningless. Treat these values as action parameters, not incidental data. A DELETE body containing {"purge": true} must show purge: true; otherwise the card conceals the irreversible part.

Text needs a separate rule. Free-form text can contain source code, customer data, pasted secrets, or instructions that change the action. Displaying an arbitrary preview is tempting because it helps operators spot nonsense. It also turns the approval window into a data exfiltration surface. For unclassified free text, show its field name, character count, and destination role. Only show a bounded excerpt when the caller marks the field public or internal and the renderer escapes control characters.

Arrays need counts and summaries. This is bad:

recipients: [redacted]

This is better:

recipients: 37 email addresses [personal values hidden]

For a destructive operation, the number changes the decision. For an access change, show the role and the count: add 4 members to role: billing-admin. If the members are internal identifiers that the approver needs to distinguish, show approved display names or aliases, not raw IDs.

Never infer sensitivity from a field name alone. password, token, and secret deserve a hard deny list, but content, message, value, data, and metadata can hold the same material. A schema, an action builder, or an explicit field annotation must supply the classification. A name-based filter is a last line of defense, not the primary design.

Target identifiers should be legible, not fully exposed

The target is the object that gives the request its consequence. It may live in a path segment, a header, a query parameter, a JSON field, or an SSH command argument. A card needs to make the target visible even when it cannot safely show the raw identifier.

Separate a target's machine reference from its human display form:

{
  "role": "repository",
  "raw_reference": "repo_01HZX8M9...",
  "display": "payments-service",
  "scope": "production",
  "sensitivity": "internal"
}

The raw reference may be necessary for execution, but the display form is what belongs in the card. If the action changes permissions, use a sentence that names the relationship: Grant deploy permission on payments-service production to the release automation account. The card should not force the approver to memorize opaque IDs.

Sometimes the raw value is the only identifier available. Do not solve that by showing all of it. Choose a stable, non-reversible reference such as a local alias or a short approval reference generated from the protected value. Do not call a truncated identifier a hash unless it is a real cryptographic digest and you understand the collision and correlation consequences. In many cases, customer record [opaque reference 4F8C] is more honest than pretending the operator can verify cus_Qa8J7kW2m9 at a glance.

Do not over-redact identifiers that determine blast radius. A request to DELETE /projects/{project}/members with the project hidden is a dangerous card even if every personal member identifier is masked. Show the project display name, environment, and count of affected members. Keep the sensitive individual values out of sight.

There is a hard distinction here: hiding a secret protects confidentiality; hiding a target weakens authorization. Teams often collapse both into "redaction." They are different jobs, and a card needs different rules for each.

Approval scope must match the information on the card

Keep tokens off approval screens
Sallyport executes credentialed HTTP calls while API keys stay encrypted in its vault.

A complete card does not authorize more than it describes. If the person approved a request to read one repository, that approval cannot silently cover a later request to change repository settings because both came from the same agent process.

Per-session approval and per-call approval answer different questions. Per-session approval answers whether this signed process may act through the gateway for the duration of this run. Per-call approval answers whether this specific outbound action, with this target and effect, may occur. Collapsing them into one giant permission makes the first card carry impossible weight.

Use an escalation rule based on consequences. A read from a known service may fit under a session grant. A call that uses a specially protected credential, changes access, sends a message, creates a financial commitment, or deletes data needs a card tied to the concrete request manifest.

The approval result should bind to a canonical action digest, not the visible card text. The digest must include method, normalized destination, target references, classified non-secret parameters, and a representation of protected fields. It should also include enough metadata to detect a request changed after rendering. Do not bind the decision to a screenshot-friendly summary alone.

For example, these two calls require different approvals even though a careless renderer might make them look alike:

POST /v1/roles/grant
body: role = "viewer", subject = "build-bot"

POST /v1/roles/grant
body: role = "owner", subject = "build-bot"

The role is not a detail to hide in a collapsed JSON view. It is the action. If an engineer says the card has become too busy, remove decorative protocol data first. Do not remove the field that determines whether the agent can take over an account.

Sallyport keeps session authorization separate from per-call keys for this reason. A session decision can identify and admit a new agent process, while a credential marked for every-use approval still asks before the individual action occurs.

A redaction failure usually starts before rendering

Approve sensitive keys per call
Mark a key for every-use approval when each outbound action needs a human decision.

Consider an agent asked to send a contract for signature. It constructs this request:

POST /v1/envelopes?template=msa&signature=QmFzZTY0U2lnbmVkVmFsdWU HTTP/1.1
Host: api.signing.example
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{
  "recipients": [
    {"name": "Maya Chen", "email": "[email protected]"}
  ],
  "subject": "MSA for Northwind",
  "message": "Please sign the attached agreement.",
  "document": "JVBERi0xLjQK..."
}

The shallow implementation formats the raw request, replaces the Authorization value, and truncates long lines. The card now exposes the signature in the query string, the recipient's email, and perhaps the beginning of a document encoded as text. Truncation is not redaction. It merely makes the leak less predictable.

A correct manifest separates the pieces first:

POST api.signing.example /v1/envelopes
Action: send contract for signature
Target: template "msa"
Recipients: 1 email address [personal value hidden]
Subject: "MSA for Northwind"
Message: public text, 39 characters
Document: 1 PDF attachment [content hidden]
Credential: bearer credential from vault
Request signature: present, hidden

That card lets the operator catch a wrong host, the wrong template, an unexpected recipient count, or an accidental send. It does not expose the credential, signature, email address, or document bytes.

The dangerous version did not fail because its masking pattern missed signature. It failed because the system treated an HTTP request as display-ready text. The renderer received a secret-bearing blob with no field types, no target roles, and no idea which values carried the operation's meaning.

Build a renderer that fails closed

The renderer should accept only structured input, apply allowlisted display rules, and refuse to render an action that has unclassified outbound fields. That sounds strict because it is strict. An unclassified field is a choice someone deferred, and approval time is too late to guess.

A practical rendering contract has three stages:

  1. Normalize the planned action into a manifest before credential injection and transport encoding.
  2. Validate that every field has a type, sensitivity label, and display rule. Reject unknown headers, query values, and body leaves unless the caller explicitly routes them to a safe hidden representation.
  3. Render a fixed card layout that reserves prominent space for destination, action, targets, effect, and protected-field notices.

Do not permit HTML, terminal control characters, markdown, or arbitrary Unicode direction controls in visible values. Escape them before layout. A malicious value should not be able to turn recipient: [email protected] into a misleading line, create fake buttons, or visually reorder a target identifier.

Set display budgets too. A public string can still be 50,000 characters long and make the card unusable. Cap visible text by field type, state that it was shortened, and retain a controlled detail view only for content that the manifest has declared safe. Never make "show full request" a universal escape hatch.

Test the renderer with hostile examples, not only normal API calls. Include a bearer token in every possible location, duplicated query names, JSON with nested arrays, a path segment containing percent-encoded delimiters, an empty secret, a short secret, a very long text field, and a value containing newlines. Also test requests whose harmful meaning is a boolean, a count, a role, or a destination host.

Finally, record what the approval covered without copying plaintext secrets into the evidence trail. Sallyport's activity and session records derive from one encrypted, hash-chained audit log, and its offline verification command can check the chain without a vault key. That separation is the standard to aim for: auditability should establish what happened without becoming a second vault full of reusable credentials.

The card should make a wrong action look wrong. If a person can approve a credential-bearing request without seeing its destination, effect, and target, the system has hidden the very facts they needed to protect.

FAQ

What information should an API approval prompt show?

Show the method, the destination host, the meaningful path shape, and the names of fields being sent. Hide credential material, session tokens, signed values, private content, and identifiers that reveal more than the operator needs to approve the action.

Should an approval card show the full URL?

Usually no. A full URL can put secrets in a query string and can expose private account, document, or tenant identifiers. Show the host and normalized path, then show selected query parameter names and safe values separately.

Should an approval prompt reveal API token values?

Hide the value completely unless a short prefix or suffix changes the decision. For bearer credentials, signed headers, cookies, and API keys, the field name is usually sufficient because the operator needs to know that the credential will be used, not what it is.

How do you redact sensitive JSON fields in an approval prompt?

Use the field name, type, and safe structural facts: whether it is present, whether it is empty, its length when useful, and a non-reversible classification such as secret or opaque identifier. Do not use a reversible mask that exposes enough of a short value to reconstruct it.

Are query string parameters safe to display?

Treat query parameters as untrusted until you classify them. Their names can be helpful, but values often contain credentials, signed requests, search terms, email addresses, referral codes, or application state.

What is a target identifier in an approval card?

A target identifier tells the operator what object will be affected, such as an organization, repository, environment, invoice, or account. It should stay visible when it changes the authorization decision, but it should be generalized or masked when it exposes a personal or secret identifier.

Does redaction make a dangerous request safe to approve?

No. Redaction protects the person reading the card from seeing a secret; it does not reduce the power of the request. The card still needs to state the action, the destination, the scope, and the irreversible effect clearly enough for approval to mean something.

Can one approval cover every request an agent makes?

Do not approve the entire session by default if individual calls can vary materially. Session approval can establish who is running, while calls that spend money, delete data, change access, or use a specially marked credential should still require a separate decision.

How should developers implement approval-card redaction?

Keep an internal canonical request representation with typed fields and sensitivity labels, then render a separate approval view from it. Never build the card by taking a raw request string and applying a few regular expressions at the end.

What should be recorded in an audit log after approval?

Log the action shape and protected references, not plaintext secrets. A reviewer should be able to establish which agent process made which request, where it went, what operation it attempted, and whether a person approved it without turning the audit trail into another secret store.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov