Skip to content

Governance

Every tool call in keelson, whether a rib tool, a workflow prompt node, or the agent’s own built-in shell and file edits, passes through one policy engine. The engine is a single ordered stack: an operator floor of builtins plus any policies the installed ribs contribute. It runs on all four surfaces (chat, a workflow prompt node, a rib agent turn, and the /api/mcp gateway), so a rule you set holds wherever a tool runs. A tool call an external agent makes over /api/mcp passes the same stack (denylist, ask_on_shell, redact), so a governance rule covers those callers too.

Out of the box the engine only enforces the operator tool denylist. Approvals, budgets, and redaction are opt-in builtins, each enabled by one environment variable. This guide turns them on; the rib contract covers contributing a policy from a rib.

A policy returns one of three decisions for an event. The stack is walked in order and the first deny wins, so any policy can veto a call a later one would allow.

DecisionEffect
allowNo opinion, the call proceeds. On a tool result or a node’s output, an allow that carries replacement text substitutes it: this is how redaction works.
denyShort-circuits. The agent receives the reason as a tool error, or the tool is dropped from the set offered to the model.
askPauses for a human. Accept becomes allow, reject or a timeout becomes deny. With no approval channel wired, an ask degrades to deny.

The engine evaluates at several points in a turn: when the tool set is projected to the model, before a specific call runs (with its arguments), once before a turn starts (carrying the running token and turn usage, which backs budgets), after a tool result returns but before the model reads it, and on a workflow node’s assembled output before it flows downstream. A builtin only adds cost at a phase some policy actually reads, so an unused phase is free.

Set KEELSON_ASK_ON_SHELL=1 to enable the ask_on_shell builtin. It pauses for human approval before any shell or file-mutating call: keelson’s own Bash, Edit, Write, MultiEdit, and NotebookEdit tools, and the canonical shell tools a rib might register.

This covers the agent’s own built-in capabilities too, not just keelson tools. Copilot routes its built-in shell and write permission requests through the engine, and Claude’s built-in Bash / Edit / Write now route through it as well, so an ask_on_shell prompt fires whether the write came from a keelson tool or the agent reaching for its own.

Terminal window
KEELSON_ASK_ON_SHELL=1 keelson start

A pending approval surfaces two ways. In the app it appears as an in-chat prompt, published over the snapshot WebSocket on the keelson:policy:approvals key. From the CLI, list and resolve them:

Terminal window
keelson approval list
keelson approval resolve <id> accept # or: reject

Accept lets the call proceed (later policies still apply); reject denies it with a reason. An approval that no one answers within five minutes auto-rejects, so a forgotten prompt fails closed rather than hanging the turn. When more than one policy asks about the same call, the engine coalesces them into a single prompt.

Two builtins cap a session’s spend, where a session is one chat conversation or one workflow run. KEELSON_TURN_BUDGET=<n> limits model-calling turns; KEELSON_COST_BUDGET=<tokens> limits accumulated input plus output tokens. Both check once before each turn. Leaving a variable unset, blank, or non-positive leaves that builtin off.

Terminal window
KEELSON_TURN_BUDGET=40 KEELSON_COST_BUDGET=2000000 keelson start

They are a downgrade gate, not a hard wall. Once the ceiling is reached, a turn is denied only while it runs on an expensive model: a premium cost tier, or a metered per-token login. A turn on a cheaper or flat-rate subscription model keeps going. The denial message tells you to switch models. So the budget pushes spend off your metered model rather than killing the session outright. A model keelson cannot price is treated as expensive, failing closed.

Set KEELSON_REDACT_PATTERN=<regex> to enable the redact builtin. Every match of the pattern is replaced with [REDACTED] in a tool’s result before the model consumes it, and in a workflow prompt node’s output before it flows to a dependent node. Use it to scrub a secret a tool might surface.

Terminal window
KEELSON_REDACT_PATTERN='sk-[A-Za-z0-9]{20,}' keelson start

Alternation covers several patterns at once, for example (sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36}). A blank or unset value leaves the builtin off. A pattern that is invalid, or one that risks catastrophic backtracking (ReDoS) against adversarial tool output, is reported and ignored, so a bad value disables redaction rather than wedging the server.

KEELSON_WORKFLOW_TOOL_DENYLIST is the always-on floor: a comma-separated list of tool names no workflow prompt node may use, subtracted on top of whatever a node’s own allowed_tools permits. It is the one governance control active without opt-in, and it cannot be overridden by a node or a rib.

Terminal window
KEELSON_WORKFLOW_TOOL_DENYLIST=Bash,Write keelson start

KEELSON_APPROVAL_REVIEWER=off is its sibling for approval gates. A workflow’s approval node may declare a reviewer that answers the gate when it approves with high confidence; this floor disables every such reviewer so each gate pauses for a human. See the approval node reference.

The path_confinement builtin is always registered, alongside the operator denylist. It stays inert until a rib agent turn declares its own working roots: when a turn carries allowedDirectories, the builtin denies any tool call whose file paths or shell arguments canonicalize outside those roots. It realpath-canonicalizes both the roots and each candidate path before comparing, so a symlink inside an allowed root that points elsewhere cannot be traversed to escape confinement.

This is a rib-agent-turn capability, not a global operator toggle. Chat, workflow prompt nodes, and MCP calls declare no roots, so the builtin allows everything there; a rib that runs an agent turn against a scoped directory gets confinement for free.

A rib contributes policies through its contributePolicies hook. The harness collects them at boot and appends them to the same ordered stack, after the builtins, namespaced as rib:<id>:<policy>. They share every semantic above: the three decisions, first-deny-wins, the ASK round-trip, and the coalescing of duplicate prompts. A malformed policy is dropped with a warning rather than crashing the boot. See contributePolicies in the contract for the shape.

On the workflow surface the context also carries workflowName and nodeId, so a rib policy can scope itself to its own workflow’s node rather than firing on every workflow-surface turn. Both are absent everywhere else, and on an older harness that does not populate them, where they read as undefined: a check like ctx.workflowName === "x" does not error there, it simply stops matching, so a policy that must hold against an older harness decides for itself whether an absent name fails open or closed. The rib contract documents the full context.

A rib can reach another rib’s tool, but crossing that line is default-deny: with no grant, it is refused before it reaches the policy engine. The operator opts a caller in per caller → target → tool, in config.json:

{
"crossRibGrants": {
"swarm": {
"beads": ["beads_ready", "beads_show", "beads_update", "beads_close"]
}
}
}

"*" covers every tool the target owns; list tool names to narrow it. The equivalent KEELSON_CROSS_RIB_GRANTS env string (caller:target:tool triples, ;-separated) unions with the config rather than replacing it. Prefer the config file for a standing grant, since an env-only grant lapses whenever the server starts from a shell that never exported it.

The grant gates two paths: a rib’s explicit callTool, and the tools projected onto a rib’s agent turn. The second is easy to miss: a rib that offers an agent a capability backed by a sibling rib’s tools gets nothing without the grant, and the tools are simply absent rather than refused, so the agent cannot report the gap. If a rib’s agent says it lacks a tool you expected it to have, check the grant before the rib.

The turn’s log is where that denial surfaces. When a rib explicitly requests sibling-owned tools no grant clears, the server writes one [rib-agent-turn] warning per caller and owner pair, naming the dropped tools and the grant that would clear them. A rib can also ask before it spends a turn: getToolReachability on the rib contract returns a per-name status, and cross-rib-denied is the missing grant.

The check runs first, ahead of the policy stack, so an ungranted call never surfaces an approval prompt for a call that would be denied regardless. See Configuration for the config key, the env string, and the cross-rib call timeout.

A rib starting a catalog workflow through startWorkflow follows the same order. It is default-deny, the operator opts a rib in per workflow name under ribWorkflowGrants, and the grant is checked before the policy stack. A granted start is then evaluated as a workflow_run tool call on the rib surface, so a policy can still deny it. The rib can read and cancel the runs it started. See Configuration for the config key and the env string.

Answering those runs’ approval gates is a second grant, ribApprovalGrants, in the same order: default-deny, opted in per workflow name, checked before the policy stack, then evaluated as a workflow_respond call on the rib surface. Without it the gate waits for you. A rib answers only on runs it started, and a paused run’s status hands it the files the gate names, such as the plan, so it can judge what it approves. See Configuration.

  • Configuration: every KEELSON_* variable, including the governance ones, in one table.
  • The CLI: the keelson approval command group.
  • The rib contract: contributing a policy from a rib with contributePolicies.
  • Operating the server: the running server the approval round-trip needs.