Before you add security controls
Public Preview

Build an agent that passes review

Security controls you must implement in your own code close gaps the platform does not close for you.

Security controls you implement in your own code to close gaps the platform does not close, from token handling to result validation.

  • Load DUALE_TOKEN from a secret manager and rotate it without downtime.
  • Use error_transform to control what tool exceptions and return values send to the model.
  • Key side effects on the pair task_id and tool_call_id plus an input hash for idempotency.
  • Split approval into a proposal step and a separate process that acts once.
  • Validate results against domain rules, not just schema, and handle deadline and filter errors.

Summaries were generated by AI. Generative AI is experimental.

Write the controls the platform leaves to you. Each section below is one change in your own code, and each one closes a gap the platform does not close for you.

Before you add security controls

Have these three boundaries in place before you add the controls below:

  • A working integration. Python SDK covers the first task.
  • An agent you control: its identifier, its tokens, and its Library grants.
  • A place to store business state outside the conversation.

Establish any missing boundary before you continue; the later controls depend on all three.

Load the token from your secret manager

Set DUALE_TOKEN at process start from your secret manager, and keep the value out of source, container images, log lines, and business records.

Rotate on a schedule you own. In the Dashboard, open the agent’s Tokens tab. An editor or administrator can issue a replacement for 1 to 365 days, with 90 days as the default, then revoke the previous token. Both actions require multi-factor authentication. Revoking one token leaves the agent’s others working, so rotation needs no downtime. Agents and access owns the token lifecycle and revocation delay.

Correlate a business record with its task identifier, never with the token.

Verify. Run a new task with the replacement, then submit one with the revoked token and confirm that authentication fails. If the old token reached Libraries, wait a few seconds and confirm that a Library call is also refused.

Recover. Keep the previous token live until the replacement passes. If you revoked it too early, sign in and issue another token.

Control what your tool sends to the model

Everything your tool returns reaches the model, and from there your model provider. An uncaught exception counts: the SDK sends its text, so a connection string in an error message leaves your boundary.

Catch what you raise, or pass error_transform= on @tool to set exactly what the model sees. Authoring tools gives the text the SDK sends when you do neither.

# Illustrative — shows the shape, not a complete tool.
def safe_error(exc: Exception) -> str:
    return "The lookup failed. Retry or escalate to a person."

Hold your return values to the same rule: send the fields the model needs to continue, and nothing more.

Verify. Make the tool raise an exception that contains a fake connection string. Assert that the model receives only your safe text and that neither the exception nor the fake secret appears in the tool result.

Recover. If a real secret reached a result, revoke or rotate it first. Then fix the transform and handle the disclosure through your incident process.

Make every side effect idempotent

Tool delivery is at-least-once: the same call can arrive twice, and a timeout does not tell you whether your code ran. The SDK’s duplicate cache is process-local and disappears on restart. Authoring tools owns the full delivery and retry contract.

Key each effect on the pair (task_id, tool_call_id) from the tool context, never on tool_call_id alone. That pair is the strongest key the SDK gives you, and it carries one limit: nothing guarantees that two genuine requests differ. The provider can reuse an identifier, and every turn of one conversation shares a task, so a second legitimate charge can land on the key the first already claimed. Your store absorbs it, the effect never happens, and nothing reports it.

Store a hash of the tool input beside the key. A different input proves a different request; matching inputs prove nothing.

Before you ship an effect that can legitimately repeat, pick one:

  • Put a discriminator in the tool’s input, such as an installment number, and describe it in the parameter so the model fills it. Genuine requests then differ.
  • Or move the effect out of the tool, as Put approval outside the tool call does: the tool writes a proposal, and a separate process acts on distinct records.

Whichever you choose, record every effect durably in your business store. Whether the effect writes to that same store decides how you record it, and the two paths are not interchangeable.

If the effect writes to the same store as the record, write both in one transaction. No intermediate state exists, so no crash can replay the effect. Prefer this path, and reshape the work to reach it when the cost is small.

If the effect leaves that store, as a payment, a message, or any call to another system, use the sequence below instead, and accept that a crash between the claim and the settlement leaves the effect uncertain:

  1. Atomically claim a record keyed by (task_id, tool_call_id) and the input hash, with a unique constraint, compare-and-set operation, or transaction.
  2. If the record is complete, return its stored result.
  3. If another worker owns an in-progress claim, do not act. Wait for the final record or return a retryable state, according to your application.
  4. Pass that same key to the external API when it accepts an idempotency key.
  5. Store the result as complete before you release the claim.

If the external result is uncertain, mark the record as uncertain and reconcile it. Do not run the action again without checking the external system.

Verify. Call your claim path twice at once with one (task_id, tool_call_id) pair, then again from a second process sharing the same durable store. Assert that the external effect occurs once and every completed call returns the same result. Drive that second process yourself: the SDK persists neither the task identifier nor a stream cursor, so you cannot stage a redelivery on demand, and a fresh process reproduces the empty in-memory cache a redelivery would meet.

Recover. When a timeout leaves the effect uncertain, read the external system before you retry. Record the result under the same identifier or run the compensating action; do not guess that the first attempt failed.

Put approval outside the tool call

A tool that blocks while a person decides will time out, and the call can be redelivered while they are still deciding. Split the decision into two steps:

  1. The tool proposes

    It writes a proposal keyed by (task_id, tool_call_id) and returns. It takes no external action, so a redelivery writes the same proposal instead of acting twice.

  2. A separate process acts

    It shows the proposal to a person, then executes an accepted one once, against your own state machine.

A tool can also read an approval instead of waiting for one: when a durable approval record already covers this exact action, proceed; otherwise return that approval is required.

Apply one atomic state transition before any external action, so a retry, a restart, or a second approval cannot apply the result twice. Threat model names the three properties that make this gate necessary.

Verify. Deliver the same proposal twice and accept it twice. Assert that one proposal exists, one state transition succeeds, and one external action occurs.

Recover. If the process stops after the state transition, mark the proposal as uncertain and reconcile the external system before another worker acts on it.

Give each agent only what it needs

Two rules, and the second is the one teams miss.

One agent per project. Prefix the identifier by team, so one access policy covers that team and no other.

One agent identity per end customer, when your application serves several. Grant that identity only that customer’s Libraries. An agent’s document tools reach every Library its identity can read, not only the documents a task attached.

Per-agent grants separate agents. They do not separate people: the built-in editor and viewer roles read every Library in the tenant. When document content must stay away from a person, use a separate tenant.

Access and isolation owns the grant rules, refusal precedence, and propagation delay.

Verify. First inspect the agent’s exact Library grant list in the Dashboard. In non-production Libraries, put one unique canary in an allowed Library and a different canary in an excluded Library. Ask the agent for both.

It must return the allowed canary and must not return the excluded one. The positive result proves that the test path works; the negative result alone does not prove isolation. Review people’s tenant roles separately because agent grants do not narrow a person’s built-in role.

Recover. Revoke the excess grant or remove the broad role, wait for the documented propagation delay, and repeat the test. Move the workload to a separate tenant when a person must not reach the content.

Validate the result before you act on it

A result that matches your schema is well-formed, not correct. Pass a strict response model, apply your own domain rules to the validated object, and treat any confidence the model reports as prose rather than an approval signal.

Define what your application does when no acceptable result arrives before the deadline. await response.model() raises DualeError; branch on problem_details.error_code. TASK_DEADLINE_EXCEEDED means the task crossed its deadline. CONTENT_FILTERED is a separate, non-retryable boundary rejection. Errors and reliability owns the full error contract.

Store each correlation identifier a failure carries, including request_id, trace_id, and span_id, with the business record and include it in a support request.

For a public text answer, verify its content mark at the receiving edge, before any text-sanitizing pipeline. A valid verdict proves that the text is unchanged since signing; it does not identify the signer. Content marking gives the code and the four verdicts.

Verify. Test a well-formed result that violates a domain rule, a task that crosses its deadline, and a non-retryable filtered task. Block all three before they reach the business action. If you rely on a content mark, change one character and confirm that the verdict stops being valid.

Recover. Keep the business record pending or rejected. Retry only when problem_details.retryable permits it; otherwise change the request or send the case to a person with the correlation identifiers.

Review checklist

  • The token comes from a secret manager and appears in no source file, image, log line, or business record.
  • The replacement token works before the previous one is revoked, and the revoked token is refused.
  • Every tool either catches its exceptions or sets error_transform=.
  • Every side-effecting tool is idempotent on (task_id, tool_call_id) plus an input digest, proved from a second process against the same durable store.
  • Every effect that can legitimately repeat inside one conversation carries a discriminator in its own input, or runs outside the tool. The key alone would swallow the second one.
  • No tool blocks while a person decides.
  • One atomic state transition precedes every external action.
  • The exact Library grant list was reviewed, then positive and negative canary checks passed.
  • One agent identity does not serve two end customers.
  • Every result passes domain rules, not only schema validation.
  • A deadline is set, and the no-acceptable-result path is implemented.
  • An agent that reads content you do not control and can act externally has a gate between the two.
  • A public text answer’s content mark is checked before any text-sanitizing pipeline, when your process relies on it.

Return to Security to place these controls in the lifecycle of one task.