Tool Approval Gate
Three tools in orchestrata-ultimate are unconfined — they can reach beyond the workspace — and a volk may run one only if a Prolog fact grants it.
| tool | why it is unconfined |
|---|---|
repl |
spawns bash --noprofile --norc (or python/node) via subprocess.Popen on the HOST as the invoking user |
run_shell |
takes a caller-supplied cwd, and is a shell regardless, so the workspace does not bound it |
remote_trigger |
reaches outward over the network |
write_file is deliberately not in this set: _safe_path resolves the
target and rejects anything outside the workspace, so its confinement is
structural rather than granted. The flag means confinement, not
destructiveness.
Where the decision is made
ToolMetadata.requires_approval marks the tool. Two dispatch paths enforce it,
and both had to be covered:
volks/base.py:425
└── ToolExecutor.execute(name, arguments)
├── if/elif chain, 17 LEGACY handlers ── gated in execute() itself
│ read_file, write_file, run_shell, search_code, ...
└── fallthrough: _native_tools() ── gated by ToolRegistry.execute
a real per-executor ToolRegistry
repl, remote_trigger, worktree, task, lsp, ...
build_tool_registry(executor) — where _LEGACY_APPROVAL_TOOLS is read — has
zero production callers; it exists for SPEC-072 ToolSearch. So gating only
ToolRegistry.execute would have covered repl and remote_trigger while
leaving run_shell untouched on the only path production uses.
The rulebase
governance.pl declares may_execute_tool/2 — a strict allowlist, no
default clause, no catch-all. An unmatched (Volk, Tool) pair fails, and
failure is denial.
:- dynamic may_execute_tool/2.
may_execute_tool('U:Qa', tool_repl).
may_execute_tool('J:Junior', tool_repl).
may_execute_tool('U:SD', tool_repl).
may_execute_tool('U:Cd', tool_run_shell).
may_execute_tool('U:Qa', tool_run_shell).
may_execute_tool('J:Junior', tool_run_shell).
may_execute_tool('U:PM', tool_remote_trigger).
Tool atoms are tool_<name>, namespaced so a tool can never collide with an
action atom used elsewhere in the file.
These facts mirror the grants AGENT_TOOLS already makes, so no volk lost a
tool when the gate landed. Two tests pin the two sources together in both
directions:
- a volk that can see a flagged tool must be granted it — otherwise the tool is advertised in its LLM schema list and then refused at execution, and it burns turns on something it can never run;
- no volk may be granted a tool it never sees — dead policy that reads as an authorization doing something, and is how a stale grant survives a tool being taken away.
Why NOT may_execute/2
This is the important part, and it is counter-intuitive.
governance:may_execute/2 looks like the natural predicate. It is
default-ALLOW:
may_execute(Agent, Action) :-
volk_operational(Agent), \+ agent_action_blocked(Agent, Action).
may_execute(Agent, Action) :-
\+ volk_exists(Agent), \+ agent_action_blocked(Agent, Action).
volk_operationalrequiresvolk_exists ∧ volk_active ∧ ¬blacklisted ∧ volk_healthy— all dynamic facts that nothing asserts in production — so clause 1 fails.- Clause 2 then allows, precisely because the agent is unregistered.
agent_action_blocked/2has zero asserted facts anywhere in the tree;verdict.pyandpolicy_layers.pyonly query it.
Measured 2026-08-09 against the real janus engine with all 9 governance rule files consulted:
may_execute('U:Qa', 'tool_repl') = True
may_execute('U:Ux', 'tool_repl') = True
may_execute('NOBODY', 'rm_rf_slash') = True
may_execute('attacker', 'exfiltrate_secrets') = True
It returns True for every pair. Every production call site of
may_execute/2 is a no-op that always allows. That is filed as its own
defect.
Binding the approval gate to it would have produced something worse than no
gate: a check that always passes but reads as enforcement, so nobody looks
again. TestStrictPredicateIsNotThePermissiveOne asserts the two predicates
disagree on the same pair, so the gate cannot be quietly re-pointed.
Failing closed
Every uncertainty denies:
| condition | verdict |
|---|---|
no ApprovalGate installed on the registry |
refused |
| call carries no volk alias | refused without querying — an unattributed request cannot be attributed to a granted volk |
| Prolog query raises | refused |
| Prolog query returns an error (rules unconsulted, engine down) | refused |
| no matching fact | refused |
The last two are the opposite of may_execute's behaviour, which falls back to
a permissive evaluate_policy when strict mode is off. An approval gate that
cannot reach its rulebase must not become a rubber stamp.
Arguments are accepted by approve() and deliberately not consulted: the
grant is per (volk, tool), so an argument-dependent verdict would make the
same tool sometimes-approved in a way no fact in the rulebase records.
Engine lifetime
PrologBridge.__init__ takes get_prolog_worker(), so the janus engine is
process-global and consults are idempotent across bridges. tools/approval.py
therefore builds one client lazily behind a lock and shares it; a
PrologApprovalGate instance is just an actor alias plus that shared client.
A consequence for tests: "no rulebase consulted" is unreachable in-process —
a freshly constructed PrologBridge() already sees every rule any earlier
consult loaded. Deny-by-absence is covered by the unknown-actor and
unknown-tool tests instead.
Granting a tool to a volk
- Add the tool to that volk's entry in
AGENT_TOOLS(agents/tool_registry.py) so the LLM is offered it. - Add
may_execute_tool('<alias>', tool_<name>).togovernance.pl.
Do both. Either alone fails a pinning test — step 1 alone means the volk sees a tool it cannot run, step 2 alone leaves dead policy.
Files
| path | role |
|---|---|
src/orchestrata_ultimate/tools/registry.py |
ApprovalGate protocol; fail-closed enforcement in ToolRegistry.execute |
src/orchestrata_ultimate/tools/approval.py |
PrologApprovalGate — the production implementation |
src/orchestrata_ultimate/agents/tool_registry.py |
installs the gate on the native registry; gates the legacy branch |
src/orchestrata_ultimate/governance/prolog_client.py |
may_execute_tool() query seam |
src/orchestrata_ultimate/prolog/governance.pl |
the may_execute_tool/2 facts |
tests/tools/test_approval.py |
gate verdicts, the discriminating test, the two pinning tests |
tests/agents/test_tool_registry.py |
TestToolExecutorApprovalEnforcement — both dispatch paths |
Commits: 7b82c98a (the fail-closed gate), 1059cffb (the grantor).