Skip to content
Docs menu

Policies and POST /v1/decide

Guardrail policies are Rego bundles in the config repo (policies/<bundle>/*.rego, package name = folder), in the style of agent-policies. The profile lists the bundles that apply to a repo; tempa-api evaluates them with the agent-policies engine copied into api/policy/ (bashlex parser, regopy evaluator, Python guidance checks; see api/policy/NOTICE.md).

POST /v1/decide

The CLI's hook posts one Tempa event (schema/event.json) with its device token; the answer is one Decision (schema/decision.json):

{ "decision": "deny", "reason": "Use uv instead of pip", "guidance": null,
  "policy_ids": ["python_uv"], "bundle_version": "3f2c1a9" }

The profile used is the caller's effective profile for the event's repo and harness, from the profile cache, so a decision always follows the same bundles the developer's profile shows. Server-Timing carries the server-side time.

Tool kinds

Policies are written once for every harness, against Tempa's tool vocabulary instead of one harness's tool names. Each event's tool.kind says what the call does; the harness adapter maps the raw name (Bash, run_shell_command, exec_command, bash, …) onto it. The kinds and the per-harness tables are in harnesses.md:

KindClaude Code examplesWhat a policy sees
shellBashthe command line in tool.input.command, whatever key the harness used
read, searchRead, NotebookRead; Glob, Grep, LSallowed inside the session's directory or repo; elsewhere no decision, ~/.tempa denied (TEMPA-223, hooks.md)
write, editWrite; Edit, MultiEdit, NotebookEditfile-edit guidance on tool.input.file_path and the patch
fetchWebFetch, WebSearchtool.input.url
mcpmcp__<server>__<tool>tool.mcp: server, endpoint (tempa proxy) and tool
agent, otherTask, TodoWrite, BashOutputnothing (defer)

So a bundle that denies git push --force blocks it from Claude Code's Bash, Gemini CLI's run_shell_command and Codex's shell tool alike: the Rego rules only ever see the parsed command. Events from CLI 0.1.x carry no kind; the server derives it from the Claude Code name, which is the only harness those versions support. An event from another harness without a kind is other.

The profile's offline.allow_tools and permissions still use Claude Code's tool names and permission syntax.

How a decision is made

EventWhat runs
tool.pre, kind read or search, or a session-only tool (TodoWrite)allow (the CLI decides these itself and only inside the repo, hooks.md)
tool.pre, kind mcpthe MCP allowlist (below)
tool.pre, kind shellthe Rego bundles on the parsed command (every segment of &&, ;, pipes, $( )) and their bash guidances
tool.pre, kind fetchthe documentation-domain allowlist from agent-policies
tool.post, kind write or editfile-edit guidance (Rego activations + the Python checks, for example comment ratio)
anything elsedefer

All bundles of the profile are evaluated together, then each bundle alone to name the ones that produce the outcome (policy_ids). The strictest outcome wins: deny > ask > guidance > allow > defer.

  • guidance means allow, with guidance text for the agent (sent back as additional context).
  • An allow (or guidance) never skips a harness's own prompt where the hook could: for Claude Code, Codex and the Gemini CLI it is no decision, so the harness's own permission settings decide; their renderers also leave the profile's permissions.allow out (TEMPA-213 for Claude Code). Policies tighten, they never loosen.
  • A shell command no rule has an opinion on gets the profile's policies.default_decision (ask unless the config says otherwise), with policy_ids: ["tempa/default-decision"]. As in agent-policies, a command chain is only as allowed as its least-decided segment.
  • defer means no policy has an opinion: the harness's own permission system decides (the hook returns no permission decision). Edit and Write without a guardrail are deferred this way.

Policy exceptions

The profile's policies.exceptions (the policy_exceptions setting, config.md) waive named policies for one plain shell command. After the policies decided a shell tool.pre:

  • a deny or ask becomes allow when an exception's command matches (the same words, or for <prefix>:* the prefix's words and anything after) and every policy id of the decision is in its waives;
  • the decision says so: policy_ids: ["tempa/exception", <the waived ids>], and its reason names the exception;
  • a command with a shell metacharacter (a chain, pipe, redirect, substitution, quote, glob or comment) is never waived, nor a decision without policy ids;
  • the MCP allowlist, the account policy, Tempa's own files and the offline and no-profile rules never pass through here, so they are never waived.

/v1/decide applies it (api/tempa_api/policy_exceptions.py), and so does the CLI's hook to local and remote shell decisions alike (client/src/policy/exceptions.ts); both run the table in schema/examples/policy-exceptions.json. permissions.allow never changes a decision.

Asking for an exception

A developer sees their own recent denies and asks under Me › My decisions in tempa-web (MCP list_my_decisions, GET /v1/me/decisions), from the decision log in telemetry. The log keeps the tool, the verdict, the policy ids, the node, the harness and the session; no command. Ask for an exception (request_exception, POST /v1/exception-requests) takes a reason and, optionally, the command to allow (the developer's own words, checked like an exception's command; the page tells them never to put a secret in it). The request is only for the caller's own decision (another person's is a 404), and only for a decision an exception could waive (policy bundles and the default decision). Asking again while a request for the same rule (tool and policy ids) and node is open returns that one.

Whoever may approve config on the requester's node (config.approve: node admins and team leads above it, platform owners) sees the request under Exception requests, with a count in the menu (MCP list_exception_requests). Make a draft (draft_from_exception_request) starts a draft from the live version with one policy_exceptions entry on the requester's node, or another node in the approver's scope, that waives the decision's policies for the command, linked to the request; it is refused (422) when the config does not allow it there, for example below a locked policy_exceptions. The draft then goes through the normal approval (its author cannot approve it) and publish. Decline takes a reason. The requester sees the status, the reason and the draft's state, and may withdraw an open request. Each step is audited: exception.request, exception.draft, exception.decline, exception.withdraw.

Not built: time-limited exceptions (an end date on an entry). Notifications for new and decided requests come with TEMPA-279; services/exception_requests.notify is where they go.

MCP allowlist

MCP context reaches Claude Code only through the local tempa mcp proxy, which exposes the profile's endpoints as mcp__tempa__<endpoint>__<tool> (Claude Code may show a plugin's server as mcp__plugin_tempa_tempa__…; both forms are accepted). The rule reads the structured tool.mcp (server, endpoint, tool), so it is the same for every harness's MCP naming (mcp_tempa_<endpoint>__<tool> on Gemini CLI). PreToolUse denies (policy_ids: ["tempa/mcp-allowlist"]):

  • any MCP tool where the server is not tempa (for example a server a developer added by hand), or whose name Tempa cannot parse into a server and tool, unless the profile's allow_external_mcp setting is true: then Tempa has no opinion on that server and the harness's own permissions decide;
  • an endpoint that is not in the profile's mcp list for this repo;
  • a tool that is not on the endpoint's tools allowlist (when the list is set).

Writing rules

Write each rule set as a set, in Rego v1:

package universal

import rego.v1

import data.helpers

decisions contains decision if {
	input.parsed.executable == "git"
	input.parsed.subcommand == "status"
	decision := {"action": "allow"}
}
  • decisions, guidances and guidance_activations use contains. The older decisions[decision] if (an object keyed by the decision in Rego v1) still works and gives the same decisions in the same order, but all files of one package must use the same form: OPA and regopy reject a package that mixes them.
  • Import what a file uses (import data.helpers) in that file: OPA scopes imports per file.
  • The bundles should build with opa build -t wasm (config.md): OPA enforces Rego v1 compile checks, such as unused variables, that regopy lets through. A bundle that does not build is a warning, never an error: the version is still valid and goes live, but without local evaluation.
  • Prefer built-ins that compile into the module. sprintf and regex.find_n run on the host in the CLI (JavaScript), so their edge cases can differ from Go's.

Writing and testing policies in the UI (TEMPA-087)

A tenant whose config Tempa manages writes its bundles in the Policies tab of a draft in tempa-web, or through the MCP tools (mcp-server.md). A bundle is a folder with three kinds of files:

FileWhat it is
policies/<bundle>/*.regorule files: package <bundle>, evaluated by the engine and built into the WebAssembly module
policies/<bundle>/*_test.regoRego unit tests for opa test: package <bundle>_test (or <bundle>), test_ rules; never evaluated for a developer and never in the module
policies/<bundle>/samples.yamlsample events with the decision they should get: regression tests on every change (below)

Operations

POST /v1/config/drafts/{id}/policies takes up to 50 operations, applied in order as one new revision (the first that cannot be done refuses them all); the answer is the draft with applied and the new policies. They are written into the draft's files by api/tempa_config/policy_edit.py, so the validator stays the one judge: a rule that does not compile is an issue on its line, not a refusal.

OperationFieldsRefused when
create_bundlenamethe name is not a Rego package (lowercase letters, digits and underscores, starting with a letter, at most 64; not a keyword, not ending in _test) (422); in use, also by a library pack (409). Writes <name>.rego with a starter rule in the package
put_policy_filebundle, file, content, createa file that is not *.rego or samples.yaml in the folder (422); create and it exists (409). content null on a new file writes a starter (a rule, a unit test, samples); a rule file in a bundle that does not exist yet creates the bundle
rename_policy_file, delete_policy_filebundle, file (to)no such file (404); it is the bundle's last rule file (409: delete the bundle instead)
rename_bundlename, toas create_bundle for to; a node file that does not load (409). Moves the folder, renames <name>.rego and <name>_test.rego, changes package <name> (and <name>_test), data.<name> wherever the config's bundles import it (and the import's short name in those files), and every node's policies setting (bundles:, add:, remove:) in the same revision
delete_bundlename, forcea node's policies setting names it and no force (409, naming the nodes); with force it goes out of those settings too
put_sample, delete_samplebundle, sample / namesamples.yaml does not parse (409). A new sample is appended to the file as written; replacing or removing one writes the file again (its leading comments stay)

Every operation on a library pack's bundle (packs/<id>/policies/) is refused with 422 and action: "subscription". GET /v1/config/drafts/{id}/policies lists the bundles (the config's own and the packs', read_only) with their files and kinds, how many rule files, tests and samples, used_by ({node, list: add | bundles | remove}) and the issues in their folder; GET …/policies/{bundle}/files/{file} answers one file with its content and its issues.

Checks on every change

Every change to a draft runs the validator, which for policies means:

  • regopy compiles the rule files of all bundles together, as the engine does: what it refuses is an error on the file and line it names (the draft cannot be published).
  • OPA builds them to WebAssembly (opa build -t wasm, below): what only OPA refuses (an unused variable, a missing import) is a warning on its line.
  • OPA checks the unit tests with the rule files they test (opa check): a test that does not compile is a warning (tests never reach a profile). The package of a test file must be <bundle>_test or <bundle>.
  • samples.yaml must be in its format (an error on the line) and its samples run against the draft (below): a sample that fails its expectations is a warning on its line.

The editor marks each issue on its line (red for an error, amber for a warning).

The test runner

POST /v1/config/drafts/{id}/policies/test (config.read, and profile.read on the nodes the samples run against, as for a preview; read-only) takes:

{ "bundle": "py_uv", "node": "team-platform",
  "samples": [{ "name": "pip", "command": "pip install requests", "expect": "deny" }],
  "unit_tests": true, "saved_samples": true, "trace": false }
  • Unit tests: the bundle's *_test.rego (every bundle's without bundle) with every bundle's rule files, through opa test. unit_tests.status is passed, failed, error (the modules do not load: the problems are in issues by file and line), none (no test files) or skipped (no opa on the server, with the reason). Each result has its file, line, package, name and outcome.
  • Sample events: the request's samples, and with saved_samples the bundle's samples.yaml, each evaluated with the real engine (the bash parser and the Rego bundles, the MCP allowlist, file-edit guidance) against a node's profile resolved from the draft's validated tree, and against the same node's profile in the live version. Each result has the decision, reason, guidance and policy ids on both sides, status (passed or failed against its expectations with the failures, ran when it expects nothing, error when it cannot run, for example a node the version does not have) and changed (the draft decides differently from live). The samples need a draft without errors; the unit tests do not.
  • The node is the sample's node, else the request's, else the samples file's, else the first node (tree order) whose policies setting adds the bundle, else the tenant's default node, else the tenant.
  • The bundle under test is always evaluated: when the node's profile does not have it, it is added for the run (added names it), so a new bundle can be tried before a node uses it.
  • trace explains each decision: the tool's kind, which part of the engine decides it (shell_rules, mcp_allowlist, read_only, fetch_allowlist, file_edit_guidance, none), the profile's default decision, what each bundle says on its own, and for a shell line each command with every bundle's decisions. The editor's Explain panel is a traced run of one event.
  • Samples test the config, not the tenant's state: the kill list is not applied, and PyPI lookups (the age check of uv add and pip install) are off on both sides, so those rules see no package metadata.

samples.yaml

node: team-platform                 # optional: the node the samples run against
samples:
  - name: pip is refused            # unique in the file
    command: pip install requests   # a shell command (tool Bash, kind shell) ...
    expect: deny                    # allow, ask, deny, guidance or defer
    reason: uv                      # optional: words the reason contains (any case)
  - name: pyproject edits get guidance
    tool: Edit                      # ... or a tool: name, kind, type, input, response
    type: tool.post
    input: {file_path: /workspace/pyproject.toml}
    expect: guidance
    guidance: uv add                # optional: words the guidance contains
  - name: the brain is allowed
    tool: mcp__tempa__second-brain__search
    expect: allow
    policy_ids: [tempa/mcp-allowlist]   # optional: ids the decision names (each of them)
  - name: what does make get        # no expectation: it only shows what happens
    command: make release
    node: team-data                 # optional: this sample's own node

At most 200 samples per file. Without expect, reason, guidance or policy_ids a sample only runs. Every change to a draft runs the samples of its own bundles against its tree: one that fails its expectations, or cannot run, is a warning on its line (sample 'pip is refused' fails: expected deny, got ask), so a later change that breaks a guardrail shows up before it is published. Warnings do not block a publish: when a behaviour change is intended, change the sample with it. In the editor, a command tried in the test panel can be kept in samples.yaml with what the draft decided as its expectation.

Local evaluation (TEMPA-123)

The CLI decides shell calls on the laptop from WebAssembly, with /v1/decide as the fallback (spike: local-policy-evaluation.md). On the server (TEMPA-123a):

  • One module per set of policy sources. When a config version is published, tempa-api compiles all its bundles to one policy.wasm with opa build -t wasm (tempa_config/wasm.py), with one entrypoint per rule set a package defines (universal/decisions, universal/guidances, python_uv/guidance_activations, …: the same rule sets the Python engine queries). The profile's bundles select at evaluation time. The module is cached per tenant in policy_modules, keyed by the sha256 of the sources, the OPA version and the build format, so versions with the same policies share one module; a version published earlier is compiled on its first profile.
  • A build OPA rejects never blocks a config. The validator reports it as a warning (the version stays valid and goes live; on a GitHub push the commit status is success with "…, 1 warning: …"; the validate-config Action passes with a ::warning annotation). The failed build is kept with its error and not retried, the version's profiles carry policies.wasm: null, and the CLI keeps asking /v1/decide until the Rego is fixed (for example the two OPA fixes in vale.rego and file_operations.rego, see api/policy/NOTICE.md).
  • Named in the signed profile. policies.wasm carries the module's sha256, size, abi (OPA's WASM ABI version), opa (the compiler's version) and entrypoints, covered by the profile's signature (profiles.md). It is null when the server has no module (no opa, or a rejected build): the CLI then keeps asking /v1/decide.
  • Served by hash. GET /v1/policy/wasm/{sha256} with the device token returns the module (application/wasm, Cache-Control: private, max-age=31536000, immutable) when it belongs to the caller's tenant, else 404. The CLI verifies the hash before it loads the module.
  • Parity corpus. api/tests/policy_parity/corpus.json holds every shell command the policy tests send (792 cases) with the parsed tree, the decisions, the guidances and the combined outcome of the Python engine. CI checks the engine against it, and the policy-parity job holds the CLI's TypeScript parser and the WASM module to the same file (below). After changing a policy test, a bundle or the parser, regenerate it from api/: uv run pytest -q -p tests.policy_parity.recorder policy/tests tests/test_policy_engine.py, and review the diff: a changed decision there is a changed decision in production.

In the CLI (TEMPA-123b, client/src/policy/):

  • Fetched by hash, verified, cached. At SessionStart (2 s, next to the sync) and in tempa sync the CLI downloads the module the signed profile names from GET /v1/policy/wasm/{sha256}, checks its size and sha256 before an atomic write to ~/.tempa/cache/policies/<sha256>.wasm (mode 0600), checks the hash again before loading it, and prunes the modules no cached profile names. Never in a tool call: a profile whose module is not downloaded yet goes to /v1/decide until the next session start, and an old module is never evaluated against a newer profile (the hash in the profile decides).
  • The same answer as the server. The shell parser is a TypeScript port of parser.py on tree-sitter-bash (web-tree-sitter 0.27.1, tree-sitter-bash 0.25.1, embedded gzipped in the CLI bundle) with the spike's three workarounds (here-documents cut out before parsing, a redirect after && given to the command on its right, $'…' an unknown value). The evaluator ports evaluate_bash_rules (chains, pipes, cd tracking, path resolution) and the shell path of decide (strictest outcome, the first deny's reason, the profile's default_decision, policy ids per bundle) over @open-policy-agent/opa-wasm 1.10.0. A command it cannot parse gets no policy decision, as on the server. The policy-parity CI job builds api/policy/bundles with opa (api/tests/policy_parity/build_wasm.py) and runs the 792 corpus cases through it: every decision, guidance and outcome must equal the server's (client/test/policy-parity.test.ts); the parsed trees must too, apart from four listed bashlex quirks that change no decision.
  • Sandboxed like OPA on the server (TEMPA-129). The module may import only the SDK's pure host built-ins and regex.find_n (HOST_BUILTINS in client/src/policy/opa.ts); a module that needs anything else (http.send, net.*, opa.runtime) is refused and /v1/decide decides. The CLI never gives a policy the network or its environment.
  • When the server still decides: no module in the profile, not downloaded yet, an ABI major the CLI does not know, a load or evaluation error, local evaluation switched off (TEMPA_LOCAL_EVALUATION=0, or the setting local_evaluation: false in the tenant's config), and uv add or pip install, which the server checks on PyPI first; for those the local answer (the server's answer when PyPI is down) is used when /v1/decide cannot be reached. Other tool kinds go to /v1/decide as before.
  • Reported. Every decision's policy.decision event says where it was taken (source: local | remote | offline), with the module's hash for a local one and the reason for a fallback (local_fallback, telemetry.md), so the share of calls that reach /v1/decide can be measured.
  • Try it. tempa policy parse COMMAND prints the tree the policies read; tempa policy check COMMAND decides it from the cached profile as the hook would; tempa doctor checks that the grammar and the module load.

Not compiled into the module, so still decided by the server: PyPI enrichment (uv add, pip install: the package age check), PostToolUse file-edit guidance (Python checks), the WebFetch allowlist, and explain.

opa 1.22.0 is in the api image (TEMPA_OPA_PATH overrides where it is; by default opa on the PATH).

The resident evaluator (TEMPA-123c)

A hook is a fresh Node process per event, and loading the module and the bash grammar in it costs ~25–35 ms before the first decision. So the CLI keeps them warm in a background process:

  • Started on demand. The first hook that decides a shell call locally decides it in its own process, then starts tempa policy serve detached (no stdio, so the harness never waits for it); later hooks ask it. A marker file keeps parallel hooks from starting several; a second one that finds a live evaluator exits.
  • A private socket. ~/.tempa/run/evaluator-<CLI version>.sock, mode 0600 in a 0700 directory, one JSON request and answer per connection (at most 1 MB). The version in the name means a newer CLI never asks an older evaluator (its parser may differ). It reads modules only from the verified cache, checking the hash on every decision, and holds no tokens: it never talks to the network.
  • Never needed for a decision. A hook that gets no answer within 250 ms decides in its own process. Off with TEMPA_RESIDENT_EVALUATOR=0, on Windows (no Unix sockets) and when TEMPA_HOME makes the socket path longer than 100 bytes.
  • Idle exit after 15 minutes without a request (TEMPA_EVALUATOR_IDLE_MS); it removes its socket. A socket left by one that died is taken over.
  • The session's repo. Finding the repo around the working directory took two git processes (~20 ms) on every tool call; the hook now keeps it per session (~/.tempa/sessions/<id>.repos.json, set at SessionStart, refreshed after each tool in PostToolUse).
  • Each local policy.decision says which process decided it (evaluator: resident | in-process).

Performance

Each config version's bundles are compiled once into an in-memory evaluator (last four versions kept). The test suite asserts p95 under 50 ms server-side over typical commands; most hook traffic never reaches the server because the CLI allows read-only tools locally.

On the laptop (client/test/hook-latency.test.ts, the built CLI answering 40 real PreToolUse processes per path with api/policy/bundles, command cd src && ls -la | grep foo && cat README.md > out.txt; Node 22.9, Apple M2 Max, 2026-11-03):

PathHook process wall clock p50 / p95Decision (latency_ms) p50 / p95
Read-only fast path (Read)88 / 93 ms–
Shell, decided in the hook's own process129 / 134 ms40 / 43 ms
Shell, decided by the resident evaluator92 / 95 ms5 / 6 ms

So with the resident evaluator a shell decision adds ~4 ms to the hook process, against ~40 ms in-process and a round trip to /v1/decide before. The E12 target, p95 under 30 ms, is met for what Tempa adds (the decision); the process itself is Node's start-up plus the CLI bundle (~80 ms on Node 22 here, the same with or without the embedded grammar), which no evaluator removes. The policy-parity CI job runs the same measurement on Node 20 and fails when the resident decision p95 reaches 30 ms.