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:
| Kind | Claude Code examples | What a policy sees |
|---|---|---|
shell | Bash | the command line in tool.input.command, whatever key the harness used |
read, search | Read, NotebookRead; Glob, Grep, LS | allowed inside the session's directory or repo; elsewhere no decision, ~/.tempa denied (TEMPA-223, hooks.md) |
write, edit | Write; Edit, MultiEdit, NotebookEdit | file-edit guidance on tool.input.file_path and the patch |
fetch | WebFetch, WebSearch | tool.input.url |
mcp | mcp__<server>__<tool> | tool.mcp: server, endpoint (tempa proxy) and tool |
agent, other | Task, TodoWrite, BashOutput | nothing (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
| Event | What 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 mcp | the MCP allowlist (below) |
tool.pre, kind shell | the Rego bundles on the parsed command (every segment of &&, ;, pipes, $( )) and their bash guidances |
tool.pre, kind fetch | the documentation-domain allowlist from agent-policies |
tool.post, kind write or edit | file-edit guidance (Rego activations + the Python checks, for example comment ratio) |
| anything else | defer |
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.
guidancemeans allow, withguidancetext for the agent (sent back as additional context).- An
allow(orguidance) 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'spermissions.allowout (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(askunless the config says otherwise), withpolicy_ids: ["tempa/default-decision"]. As in agent-policies, a command chain is only as allowed as its least-decided segment. defermeans 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
denyoraskbecomesallowwhen an exception'scommandmatches (the same words, or for<prefix>:*the prefix's words and anything after) and every policy id of the decision is in itswaives; - 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'sallow_external_mcpsetting istrue: then Tempa has no opinion on that server and the harness's own permissions decide; - an endpoint that is not in the profile's
mcplist for this repo; - a tool that is not on the endpoint's
toolsallowlist (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,guidancesandguidance_activationsusecontains. The olderdecisions[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.
sprintfandregex.find_nrun 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:
| File | What it is |
|---|---|
policies/<bundle>/*.rego | rule files: package <bundle>, evaluated by the engine and built into the WebAssembly module |
policies/<bundle>/*_test.rego | Rego 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.yaml | sample 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.
| Operation | Fields | Refused when |
|---|---|---|
create_bundle | name | the 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_file | bundle, file, content, create | a 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_file | bundle, file (to) | no such file (404); it is the bundle's last rule file (409: delete the bundle instead) |
rename_bundle | name, to | as 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_bundle | name, force | a 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_sample | bundle, sample / name | samples.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>_testor<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 withoutbundle) with every bundle's rule files, throughopa test.unit_tests.statusispassed,failed,error(the modules do not load: the problems are inissuesby file and line),none(no test files) orskipped(noopaon the server, with the reason). Each result has its file, line, package, name and outcome. - Sample events: the request's
samples, and withsaved_samplesthe bundle'ssamples.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(passedorfailedagainst its expectations with thefailures,ranwhen it expects nothing,errorwhen it cannot run, for example a node the version does not have) andchanged(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) whosepoliciessetting 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 (
addednames it), so a new bundle can be tried before a node uses it. traceexplains 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 addandpip 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 nodeAt 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.wasmwithopa 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 inpolicy_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
successwith "…, 1 warning: …"; the validate-config Action passes with a::warningannotation). The failed build is kept with its error and not retried, the version's profiles carrypolicies.wasm: null, and the CLI keeps asking/v1/decideuntil the Rego is fixed (for example the two OPA fixes invale.regoandfile_operations.rego, seeapi/policy/NOTICE.md). - Named in the signed profile.
policies.wasmcarries the module'ssha256,size,abi(OPA's WASM ABI version),opa(the compiler's version) andentrypoints, covered by the profile's signature (profiles.md). It isnullwhen the server has no module (noopa, 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.jsonholds 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 thepolicy-parityjob 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 fromapi/: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 syncthe CLI downloads the module the signed profile names fromGET /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/decideuntil 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.pyon tree-sitter-bash (web-tree-sitter0.27.1,tree-sitter-bash0.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 portsevaluate_bash_rules(chains, pipes,cdtracking, path resolution) and the shell path ofdecide(strictest outcome, the first deny's reason, the profile'sdefault_decision, policy ids per bundle) over@open-policy-agent/opa-wasm1.10.0. A command it cannot parse gets no policy decision, as on the server. Thepolicy-parityCI job buildsapi/policy/bundleswithopa(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_BUILTINSinclient/src/policy/opa.ts); a module that needs anything else (http.send,net.*,opa.runtime) is refused and/v1/decidedecides. 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 settinglocal_evaluation: falsein the tenant's config), anduv addorpip 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/decidecannot be reached. Other tool kinds go to/v1/decideas before. - Reported. Every decision's
policy.decisionevent 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/decidecan be measured. - Try it.
tempa policy parse COMMANDprints the tree the policies read;tempa policy check COMMANDdecides it from the cached profile as the hook would;tempa doctorchecks 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 servedetached (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 whenTEMPA_HOMEmakes 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
gitprocesses (~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.decisionsays 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):
| Path | Hook process wall clock p50 / p95 | Decision (latency_ms) p50 / p95 |
|---|---|---|
Read-only fast path (Read) | 88 / 93 ms | – |
| Shell, decided in the hook's own process | 129 / 134 ms | 40 / 43 ms |
| Shell, decided by the resident evaluator | 92 / 95 ms | 5 / 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.