Creating Policies
A Tool Guard policy is a YAML file with three required sections: identity (policy_id, name, version, status, mode), a scope that says which tool calls the policy applies to, and a list of rules that say what to do.
Anatomy
policy_id: pol-refund-cap # unique within the loaded set
name: refund-cap # human label
description: >
Allow refunds under $500; deny above.
version: 1
status: approved # draft | review | approved | archived
mode: enforcement # enforcement | shadow
scope:
tool_names: [issue_refund] # match by tool_name AND/OR
tool_groups: [monetary_outflow] # by tool_group
rules:
- rule_id: rule-amount-cap
name: Single refund amount limit
rule_type: threshold
conditions:
field: amount
operator: gt
value: 500
effect: deny
effect_config:
severity: high
suggested_response: "Refunds over $500 require supervisor approval."
citation:
document_id: doc-refund-sop
section: "2.3"
excerpt: "Individual refund transactions exceeding $500 require supervisor approval prior to processing."
tg lint -policy validates the shape and runs eight checks - warnings and errors (see tg lint heuristics). A policy that lints with error-severity findings will be REFUSED by tg-proxy at load.
Scope
scope.tool_names and scope.tool_groups match independently: an envelope matches if its tool_name is in tool_names OR its tool_group is in tool_groups. A policy with an empty scope matches every envelope, and tg lint warns about this as policy-scope-leak.
tool_names matching for whether a policy's own rules apply to a call is case-insensitive (bash in a policy matches an incoming Bash, BASH, etc.) — different agent frameworks capitalize their own tool names inconsistently (Claude Code sends Bash; other integrations send bash), and tool_name is untrusted, externally-sourced data, so a policy author shouldn't have to anticipate every casing variant to avoid a silent never-matches gap. tool_groups matching stays case-sensitive exact-match, since group names are operator-assigned constants, not raw agent-supplied input.
-unknown-tools-deny's check (is this tool name one any loaded enforcement policy explicitly declared, or is it unrecognized and therefore denied by default) deliberately stays case-sensitive — the opposite of the above. Its job is proving a name was exactly, deliberately authorized, and a case-varied spoof of a declared name (DROP_TABLE vs. a policy declaring drop_table) is exactly the kind of thing that fail-closed default exists to catch, not something that should quietly count as "known." It also only counts an enforcement-mode policy's tool_names if that policy's own status is approved — a draft/review/archived policy never actually governs anything (normal policy matching excludes it too), so letting its scope alone satisfy -unknown-tools-deny would make a name register as "known" while zero real policies enforce on it.
The recommended pattern is BOTH a tool_names allowlist AND a tool_groups allowlist. The lint heuristic scope-no-tool-group catches tool-substitution bypasses (an attacker pivots to a sibling tool in the same group).
Common group names use lower-case capability nouns with underscores:
| Tool group | Capability | |---|---| | monetary_outflow | Refunds, purchases, credits, and transfers | | database_ops | Database reads and mutations | | llm_usage | Generative-model inference | | filesystem_writes | File creation and mutation | | network_egress | Outbound HTTP/fetch operations |
An integration owns the tool-name-to-group mapping. The built-in coding-agent hook maps its standard write and HTTP tool names to filesystem_writes and network_egress respectively.
Modes
- `enforcement` - the decision returned to the agent is applied.
- `shadow` - the decision is computed and audited but the agent is
always told allowed. Useful for rolling out a new rule and watching how it would have behaved against real traffic.
A near-miss flag on the trace records the rule's operating mode and the "would-be" decision.
Effects
| Effect | Behaviour | |---|---| | allow | Pass-through; trace is still audited | | flag | Pass-through; severity recorded; useful for observation | | escalate | Returns 202 Accepted; agent gets a poll_url; operator approves / denies via /escalations//approve|deny | | deny | Returns 200 with denied; agent is expected not to call the tool |
The severity hierarchy is deny > escalate > flag > allow. The strongest effect wins when multiple rules fire.
Conditions - the leaf shapes
Field comparison
conditions:
field: parameters.amount
operator: gt
value: 500
Operators:
| Operator | Notes | |---|---| | eq, neq | Equality; nil values are rejected at load time. | | gt, gte, lt, lte | Numeric comparison. String values that parse as numbers are coerced. | | gt_field, lt_field | Compare against another field of the envelope. | | in, not_in | Value is a list; in fires when the field is a member, not_in when it is not. | | contains, not_contains | Substring match / its negation. | | starts_with, ends_with | String prefix / suffix match. | | exists | Field presence. value: true fires when the field is present, value: false (or not:) when it is absent — e.g. "deny if no justification was supplied". | | regex | Go RE2; pattern compiled at policy load. No backreferences / lookaround. |
> Missing-field contract: not_in and not_contains fail closed — an > absent field is trivially "not in the allowlist" / "does not contain X", so a > deny rule using them fires rather than silently allowing. The positive > operators (eq, in, contains, starts_with, ends_with, gt …) do > not fire on a missing field. Pick the operator whose failure mode matches > your intent: for a deny-if-not-allowlisted rule, use not_in.
SQL classifier
conditions:
sql_classify:
field: parameters.sql
dialect: postgres # postgres | mysql | sqlite | mssql
require:
top_level_kinds: [SELECT] # allow only single-stmt SELECT
no_dynamic_sql: true # forbid DO / EXECUTE / CALL
no_program_exec: true # forbid COPY ... FROM/TO PROGRAM
allowed_functions: [count, sum, avg, min, max, now, lower, upper, length, coalesce]
denied_tables: [audit_log, secrets] # prefix match
allowed_tables: [users, orders]
This closes the structured-DDL-smuggle, DML-via-SELECT, and function-exec bypass families. See examples/postgres-attack/policies/db-protect.yaml for a full bundle.
Path classifier
conditions:
path_classify:
field: parameters.path
require:
clean_first: true # normalise `..`, `.`, `//` first
absolute_only: true # reject relative paths
allowed_canonical_prefixes: [/var/log/] # canonical path must start here
deny_shell_metas: true # ; | & $() ` deny
include_backslash: true # Linux-only mode treats \ as meta
resolve_symlinks: true # EvalSymlinks before matching
deny_on_resolve_failure: true # unresolvable path → deny (write tools)
max_path_length: 4096
Closes traversal, symlink, and shell-meta bypass families on filesystem-path tools.
Shell classifier
conditions:
shell_classify:
field: parameters.argv # MUST be a parsed argv list
require:
argv0_allowlist: [uptime, date, whoami, df, pwd]
# Path-shaped argv elements are filepath.Clean'd and matched
# against these prefixes; ** is a deep glob.
denied_argv_paths: [/etc/, /root/, /home/*/.ssh/]
resolve_symlinks: true
argv_env_pattern_deny: '^(BASH_FUNC_|LD_PRELOAD|PATH=)'
argv_deny_patterns: ['(?i)\\b(rm|dd|mkfs|wget|curl)\\b']
The tool MUST exec argv directly, never sh -c. The classifier treats argv[0] as the binary and argv[1:] as its arguments. Closes shell-meta / env-wrap / interpreter-substitution bypasses (env, sudo, nice, chroot re-launching arbitrary binaries).
Write classifier
Governs a file-writing tool (write / edit / notebookedit / apply_patch / multiedit): which paths it may touch, how much it may write, and content it may not write. Deny-only — it never rewrites or redacts.
conditions:
write_classify:
path_field: parameters.file_path # optional; file_path/path + arrays/nested edits also collected
content_field: parameters.content # optional; default parameters.content
require:
allowed_path_prefixes: [/home/me/project/] # every canonical form of every target must be under one of these
denied_path_prefixes: [/home/me/project/.git/, /etc/]
max_bytes: 1048576 # runaway-write ceiling (0 = no cap)
denied_content_regex: ['(?i)BEGIN (RSA|OPENSSH) PRIVATE KEY']
Path matching reuses the path_classify canonicalization (absolute + clean + component-boundary / wildcards) and the array/nested-edit extraction. Symlink resolution is always on for write_classify (a write through a symlinked directory is the exact evasion this primitive exists to catch, so it isn't configurable — the resolve_symlinks field exists only for schema parity with path_classify/shell_classify and has no effect here). Fail-closed:* a write whose path cannot be confirmed inside allowed_path_prefixes fires the rule — every canonical candidate (lexical and symlink-resolved) must independently match an allowed prefix.
HTTP classifier (egress)
Governs where an http/fetch tool may connect.
conditions:
http_classify:
url_field: parameters.url # optional; default parameters.url
method_field: parameters.method # optional; default parameters.method
require:
allowed_hosts: [api.internal, .githubusercontent.com] # exact or .suffix
denied_hosts: [169.254.169.254] # e.g. cloud metadata endpoint
allowed_schemes: [https]
allowed_methods: [GET, POST]
denied_ports: [22, 25]
Host entries beginning with . are suffix matches (.example.com matches api.example.com and example.com); otherwise exact, case-insensitive. Fail-closed: a missing or unparseable URL fires the rule when any allow-list is set.
LLM classifier (multimodal)
conditions:
llm_classify:
prompt_field: parameters.prompt
image_url_field: parameters.source_image_url # optional; multimodal
model: gemma4:e4b
ollama_url: http://localhost:11434 # optional; default localhost
timeout_seconds: 30
forbidden:
- csam
- real_person_likeness
- weapons
- self_harm
- copyrighted_style
- extremist_content
The classifier asks Ollama-served Gemma 4 to assign the prompt to one of the forbidden labels or safe. Any non-safe verdict fires the rule. Empty model responses are treated as model_refused (fail-closed). Confidence below 0.6 is ambiguous (also fail-closed). Image URLs are fetched through an SSRF-hardened client.
Refer to content-gen-bundle.md for a full walk-through.
Condition trees
Compose with and, or, not:
conditions:
and:
- field: tool_name
operator: eq
value: issue_refund
- or:
- field: amount
operator: gt
value: 500
- field: parameters.reason
operator: regex
value: '\$[0-9,]{4,}'
- not:
field: context.verified.justification.verified
operator: eq
value: true
not: may wrap a leaf condition (as above) but not a classifier (sql_classify / path_classify / shell_classify / llm_classify). Classifiers are fail-closed - they fire on malformed or adversarial input so a deny rule trips - and negating one inverts that into fail-OPEN. ValidatePolicy rejects any classifier under a not: node; express the allowed set positively (e.g. require.top_level_kinds: [SELECT]) instead.
ValidatePolicy rejects condition trees deeper than 64 nodes to prevent stack exhaustion.
tg lint heuristics
| Rule | Severity | Catches | |---|---|---| | policy-scope-leak | warn | Empty scope - policy matches every call | | scope-no-tool-group | warn | Tool-substitution bypass surface | | amount-without-semantic-check | warn | Free-text amount fragmentation; suppressed when a compiling regex / contains rule on a non-amount free-text field is present | | rule-missing-citation | warn | Auditor traceability gap | | writable-scope-no-self-protection | warn | Policy admits write-capable tools (write, edit, notebookedit, bash, shell, run_command) but no deny/escalate rule uses path_classify to guard sensitive paths | | rule-id-collision | error | Two rules share an ID | | invalid-regex-syntax | error | Regex that doesn't compile in RE2 | | unknown-operator | error | Operator the engine cannot evaluate | | structural-validation | error | Empty / multi-form condition, bad operator/value type, oversize glob, bad LLM-classify shape |
Run tg lint -policy after every edit. The CI workflow includes a lint gate.
> Self-protection note: the writable-scope-no-self-protection warning > can be suppressed by adding a path_classify deny/escalate rule, but the > robust mitigation is operator-side: run tg-proxy or tg hook with > -protect-paths / -protect-self. That check runs before and independent > of policy evaluation — the agent cannot disable it by editing the policy.
Effect config
effect_config:
severity: critical # low | medium | high | critical
escalate_to: approver # role label, used by the approver UI
timeout_minutes: 30 # escalation expiry
suggested_response: "Refunds over $500 require supervisor approval."
timeout_minutes on an escalate effect overrides the proxy's default escalation timeout (-escalation-default-timeout-min).
Citations
Every rule should reference the policy document it implements:
citation:
document_id: doc-finance-sop
section: "3.1"
excerpt: >
Per the FY26 Finance SOP, individual purchase orders exceeding
$10,000 require written CFO approval before submission.
This is what the auditor sees on the trace - the WHY behind the decision. tg lint warns on rules with missing citations.
Example policy bundles
| Bundle | Surface | |---|---| | examples/postgres-attack/policies/ | SQL + shell + path for DB-tool agents | | examples/finance-cfo/policies/ | Procurement, wires, expenses | | examples/business-ops/policies/ | Data export, mass communication, support comp | | examples/content-gen/policies/ | Image / audio / text generation with Gemma 4 | | examples/sample-app/policies/ | The Quick Start refund policy |
Each bundle ships a test-policies.sh curl-driven suite that asserts the policy's correctness against a live proxy. Use them as templates for your own policies.
Reloading at runtime
POST /reload (or kill -HUP $(pidof tg-proxy)) re-reads -policy-dir. Validation runs on every load; if any file fails, the OLD policy set stays live and the proxy logs the error. There is no half-load state.
Validation surface
ValidatePolicy runs on every load and rejects:
- empty / multi-form condition nodes
- nil values on `eq` / `neq`
- numeric operator on a non-numeric value
- regex that doesn't compile
- glob with > 2 `**` segments
- LLM-classify with empty `forbidden` list, `safe` as a forbidden
label, duplicates, comma/newline/quote in a label, > 64 labels, bad ollama_url scheme, timeout_seconds outside [0, 120]
The system rejects a policy if it fails any of these gates during load, and the previously loaded policy set remains live.