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:

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).

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

  1. Add the tool to that volk's entry in AGENT_TOOLS (agents/tool_registry.py) so the LLM is offered it.
  2. Add may_execute_tool('<alias>', tool_<name>). to governance.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).