Skip to main content

Guardrails

The policy layer that gates every agent tool call before it dispatches, spend budgets, rate limits, and human approval, enforced server-side.

4 min read
View MarkdownEdit on GitHub

Guardrails

Server-side, not advisory

Guardrails run in the CodeSpar runtime, before a tool call reaches a provider, not in your agent's prompt. A blocked call never dispatches, no matter what the model decided. The same rules apply whether the call came from session.execute() or the chat-loop session.send().

Guardrails let you bound what an agent is allowed to do with money. You define policies per project; the runtime evaluates every governed tool call against them and either allows it, blocks it, or holds it for human approval. Every decision is written to the audit chain.

Policy types

PolicyWhat it bounds
BudgetA spend cap over a window, e.g. R$ 5.000/day or a monthly ceiling per project. Charges and payouts that would exceed it are blocked.
Rate limitHow often a tool can be called: calls per minute / per hour, enforced with durable counters (not best-effort in-memory).
Approval requiredRoutes a matching call to a human before it executes. The call is held; an operator approves or rejects it in /dashboard/approvals; on approval it dispatches, on rejection it never does.

Policies can scope to a tool, a meta-tool, an amount threshold, or a time window, and compose. A payout might be inside budget but still need approval above a threshold.

What this page does not cover

The internal scoring and risk logic CodeSpar uses to evaluate fraud and anomaly signals is proprietary and intentionally out of scope here. This page documents the policy surface you control, not the engine internals.

How a decision flows

agent calls a tool

guardrails evaluate the call against the project's policies
  ↓                    ↓                         ↓
allow                deny                  approval-required
  ↓                    ↓                         ↓
dispatches      returns a deny result      held → /dashboard/approvals
                                            → approve → dispatches
  ↓                    ↓                         ↓
every outcome is appended to the audit chain

Approvals

When a call matches an approval-required rule, it does not dispatch. The runtime holds it and writes a row to the approvals queue at /dashboard/approvals, where an operator with admin or owner role approves or rejects it with a reason. Every held call carries an expiry: if nobody decides in time, the approval expires and the call never runs.

The decide endpoint refuses project API keys outright — a leaked csk_ key cannot approve a fund transfer. It accepts service-key callers, and the approver's identity is meant to come from a Clerk user token (x-codespar-user-token) that the backend verifies, whose sub must hold admin or owner.

Dual control is shipped but not yet enforced

Requiring that verified token is gated on APPROVAL_DECIDE_ENFORCE_USER_TOKEN, which is off by default and off on CodeSpar's hosted production. While it is off, a service-key caller that sends no token still decides, and the approver recorded is whatever the caller wrote in the x-codespar-user header. Those decisions are not silent — they land in the audit chain as approval.decide_unverified with decided_by_source: "header_asserted" and the exact code the call would have been refused with under enforcement — but until the flag flips, "an admin approved this" means "a holder of the service key asserted that an admin approved this". Verification itself is never gated: a caller that does send a token gets the full verified path today.

On the caller's side, the blocked call returns an approval id. Your agent or SDK can poll for the outcome:

GET /v1/approvals/:id/status

The endpoint is authenticated with your normal API key and scoped to your org and project. approval_status is one of:

StatusMeaning
pendingAwaiting an operator decision. The only non-terminal status; keep polling.
approvedDecided and executed. The held call is re-executed server-side; execution_result in the response carries the upstream outcome.
deniedThe operator rejected it. Nothing executed.
expiredThe expiry elapsed before a decision. Nothing executed.
execution_failedApproved, but the replayed call errored. execution_result.error explains.

Policies themselves are created and ordered in the dashboard at /dashboard/policies.

Chat-loop coverage

The chat loop is the natural-language tool-use path: session.send("Charge R$5 via Pix") runs a tool-use loop on the backend, picks the tools, and dispatches each one through the runtime. Every tool dispatch inside that loop runs through the same guardrail hooks, in the same fixed order, as a direct session.execute():

  1. Deny-list. Non-overridable safety rails (Projects). A test cannot mock past these.
  2. Policy evaluation. Your rules evaluate against the tool name plus the input the model picked. allow, deny, or approval-required.
  3. Session mock store (test-mode sessions only). See Test mode.
  4. Upstream provider call, if not denied, held, or mocked.
  5. Audit chain append. Every completed call writes a tool_call.succeeded or tool_call.failed event, same event names and shape as the direct-execute path, so existing audit and SIEM queries pick chat-loop events up without changes.
  6. Commerce-memory capture, when the per-tool capture predicate matches.

Rules are tool-and-input scoped, not call-site scoped. A rule matching a payments tool fires whether your code named the tool or the model picked it inside session.send(); you do not need to duplicate rules per path. There is no "allow direct execute but deny chat-loop" distinction in the rule grammar. The conservative pattern for high-blast-radius tools is approval-required: the chat loop hits the approvals queue and your operators decide per call. When an approval-required rule fires inside the loop, the loop receives a structured approval_required tool result and the same queue row is written, so check /dashboard/approvals volume after your next chat-loop test run.

The cleanest way to confirm coverage is a test-mode session that mocks the relevant tool. The chat loop runs the hooks identically to direct execute, so one fixture round-trip proves both paths:

import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });

const session = await cs.create("user_test", {
  servers: ["asaas"],
  mocks: {
    "asaas/create_payment": { id: "pay_test_42", status: "PENDING" },
  },
});

const result = await session.send("Charge R$5 via Pix");

// The loop picked asaas/create_payment, the policy engine evaluated it,
// the mock store returned the fixture, and the audit chain recorded it.
const charge = result.tool_calls.find((tc) => tc.tool_name === "asaas/create_payment");
expect(charge?.output?.id).toBe("pay_test_42");

See Test mode for the full mocks contract and the type-narrowed guards (isPolicyDenied, isApprovalRequired) for parsing tool-result envelopes.

See also

  • Audit chain: every guardrail decision is recorded
  • Wallets: programmable spend limits on agent wallets
  • Test mode: policy and audit hooks fire on mocked calls too
Guardrails | CodeSpar