Skip to main content

External Preflight Gating

In-process gating registers a Python GatePolicy (see The Gate). External preflight gating moves the ALLOW/DENY decision out of process to a Policy Decision Point (PDP) you run — so gating can be configured entirely from YAML, with no Python.

Two gate types are available, both selected under governance.preflight_gate:

typeTransportUse when
httpPOST JSON to a persistent HTTP PDP (e.g. an OPA REST server)Recommended. A long-lived server avoids per-call process spawn cost.
subprocessSpawn a command per call; JSON request on stdin, JSON verdict on stdoutPortable / air-gapped, or driving opa eval without a server.

Both implement the same synchronous PreflightGate contract as in-process gates, so they slot into the existing preflight chain with no framework-adapter changes.

Configuration

governance.preflight_gate is a discriminated union keyed on type. It is mutually exclusive with governance.tool_preflight_gate (the dotted-path, in-process form): setting both raises a configuration error at load time.

HTTP PDP

governance:
preflight_gate:
type: http
endpoint: http://localhost:8181/v1/data/traceforge/verdict
timeout: 2.0 # seconds
fail_open: false # false = fail-closed (DENY on any error). Keep false.
headers: # optional — merged over Content-Type: application/json
Authorization: "Bearer ${PDP_TOKEN}"
max_input_bytes: 65536 # per-string cap on tool input before it is sent
FieldTypeDefaultNotes
endpointstring— (required)Absolute URL of the decision endpoint.
timeoutfloat2.0Per-request timeout, seconds (> 0).
fail_openboolfalsefalse = DENY on error/timeout/non-2xx. See Fail-closed.
headersmap{}Extra request headers (e.g. auth tokens).
max_input_bytesint65536Per-string redaction cap (> 0).

The request is sent with Content-Type: application/json; any configured headers are merged on top. A non-2xx response, timeout, connection error, or unparseable body is an error and is resolved by fail_open.

Subprocess decider

governance:
preflight_gate:
type: subprocess
command: "opa eval -I -f raw data.traceforge.verdict"
timeout: 10.0
fail_open: false
max_input_bytes: 65536
FieldTypeDefaultNotes
commandstring— (required)Command line for the decider.
timeoutfloat10.0Per-call timeout, seconds (> 0).
fail_openboolfalsefalse = DENY on error/timeout/non-zero exit/bad output.
max_input_bytesint65536Per-string redaction cap (> 0).

The JSON request is written to the process's stdin; the JSON verdict is read from its stdout. A non-zero exit, timeout, or unparseable stdout is an error resolved by fail_open.

command is split with shlex (POSIX rules on Unix, so quoted arguments survive). On Windows, quoting is best-effort because native command quoting differs from POSIX — pass a simple, unquoted command line there where possible.

Wire contract

Request (traceforge → decider)

A JSON object describing the assessed tool call. Enums are stringified; the full EventTrace escape hatch is never serialized. Example:

{
"tool": "shell",
"input": { "command": "rm -rf /tmp/build" },
"target": "/tmp/build",
"mechanism": "process.shell",
"effect": "destructive",
"capabilities": ["subprocess", "filesystem_write"],
"scope": ["system.os"],
"role": ["executor.script_runner"],
"action": ["remove.delete"],
"risk_score": 78,
"risk_band": "danger",
"suggested_action": "deny",
"reason": "destructive shell command",
"session_id": "sess-123",
"tool_call_id": "call-456",
"context": {
"session_id": "sess-123",
"tool_call_count": 12,
"denied_count": 1,
"agent_id": null,
"user_id": null
}
}

Response (decider → traceforge)

{ "decision": "deny", "reason": "destructive command blocked" }
  • decision is matched case-insensitively. "deny" → DENY (with reason propagated to the model); anything else → ALLOW.
  • Extra fields (e.g. score, level) are ignored, so traceforge's own score-server response shape works as-is.
  • An OPA-style envelope { "result": { "decision": "deny", "reason": "..." } } is unwrapped automatically.

Fail-closed by default

fail_open defaults to false on both gate types. When the decider errors, times out, returns a non-2xx status / non-zero exit, or emits unparseable output, the call is DENIED. This is deliberate: a gate that fails open silently disables enforcement at exactly the moment something is wrong, which is a security anti-pattern.

Set fail_open: true only when availability outranks safety for your deployment and you have consciously accepted that a broken decider means unfiltered tool calls.

Input redaction cap

Each string value in input is capped to max_input_bytes UTF-8 bytes (with a truncation marker) before being sent. This bounds payload size and limits how much raw tool input leaves the process.

:::warning Trust the decider input can contain secrets (arguments, file contents, tokens). It crosses the wire to your PDP, so run the decider on a trusted endpoint/host and secure the transport (TLS, network policy, auth headers). The max_input_bytes cap reduces but does not eliminate exposure. :::

Worked example: OPA

Run OPA as an HTTP PDP with a rule whose document is the verdict:

package traceforge

import rego.v1

# Default allow; deny destructive shell calls.
verdict := {"decision": "deny", "reason": "destructive shell blocked"} if {
input.mechanism == "process.shell"
input.effect == "destructive"
} else := {"decision": "allow"}
opa run --server ./traceforge.rego
governance:
preflight_gate:
type: http
endpoint: http://localhost:8181/v1/data/traceforge/verdict
fail_open: false

OPA wraps the document as { "result": { "decision": ... } }; traceforge unwraps the result envelope, so no glue code is needed. For an air-gapped setup, swap the HTTP gate for a subprocess gate driving opa eval against the same policy.