← All docs

Your first rule

Anatomy of a candidate rule, how to write one that blocks a command, where to put it, and how to test it.

A policy file in package agentjail contributes candidate entries to the shared resolver. Each candidate is an object with an action, a rule_id, and a reason. resolver.rego collects all candidates from all loaded files and picks the most restrictive verdict (deny > ask > allow). The fail-safe default when nothing fires is ask.

This page walks through writing a single rule from scratch, installing it, and confirming it works.

Anatomy of a candidate rule

package agentjail

import future.keywords.if
import future.keywords.contains

candidate contains r if {
  input.tool_name == "Bash"
  contains(input.tool_input.command, "/.ssh/")
  r := {
    "action":  "deny",
    "rule_id": "custom/my_policy/no-ssh-access",
    "reason":  "Blocked: command targets sensitive path ~/.ssh/",
    "impact":  "would expose SSH private keys",
  }
}

Breaking that down:

  • package agentjail: all hook-path rules must use this package.
  • candidate contains r if { ... }: the partial rule entry pattern. Never declare decision = ... in your own rule files: resolver.rego is the only file that owns decision.
  • r is an object with mandatory action, rule_id, and reason (plus an optional impact).
  • action must be "deny", "ask", or "allow".
  • rule_id must use the custom/<filename_stem>/<rule> namespace (enforced by agentjail policy add).
  • The body is a conjunction: every line must hold for the candidate to be added.

If any condition does not hold, the candidate is not contributed. agentjail only blocks the call if the resolver picks a deny candidate as the most restrictive.

See The input schema for the full shape of input and how fields vary by tool.

Write a rule

Create a file called ~/my_policy.rego:

package agentjail

import future.keywords.if
import future.keywords.contains

candidate contains r if {
  input.tool_name == "Bash"
  contains(input.tool_input.command, ".env")
  r := {
    "action":  "deny",
    "rule_id": "custom/my_policy/no-env-access",
    "reason":  "Blocked: command reads or modifies .env file",
  }
}

The rule_id must start with custom/my_policy/ (matching the filename stem my_policy). The rule fires whenever a Bash command string contains .env.

Install and list policies

Install the rule using the CLI: this validates the authoring contract and hot-reloads the daemon:

agentjail policy add ~/my_policy.rego

Then confirm agentjail sees it:

agentjail policy list

You should see custom/my_policy/no-env-access alongside the built-in rules.

If the daemon is not running when you save the file, it will pick it up on next start. You can also drop the file directly into ~/.agentjail/rules/ and SIGHUP the daemon manually (skips validation):

cp ~/my_policy.rego ~/.agentjail/rules/
kill -HUP $(pgrep agentjail-daemon)

Test it

The fastest end-to-end check is agentjail try:

agentjail try "cat .env"     # expect: DENY
agentjail try "cat README.md"  # expect: ALLOW

Or pipe a raw PreToolUse JSON payload directly to the hook (daemon must be running):

Deny case: confirm the rule fires.

echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat .env"}}' \
  | agentjail-hook

Expected output:

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked: command reads or modifies .env file"}}

Exit code is 2 on deny.

Allow case: confirm a harmless call passes through.

echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hello"}}' \
  | agentjail-hook

Expected: exit code 0, permissionDecision is "allow" or "ask".

For more on writing test cases, see Testing policies. For the full set of fields available in input, see The input schema. For the evaluation model (verdict semantics), see The policy model.