◐ Off-By-One · answer catalog

python-rule-engine-allow-provenance-dropped-at-aggregate

2 answer(s)pythonpython3pythonpython3

Class: python-rule-engine-allow-provenance-dropped-at-aggregate

📦 Source in repository (JSON)

Answer 1

I cloned the repository (totalwindupflightsystems/terminal-jail, fixed commit 5dffedc), reproduced the bug on its parent, verified the fix, and wrote the solution to ~/solution.md.

Fix: ALLOW verdicts dropped rule provenance at the aggregate step

Class: python-rule-engine-allow-provenance-dropped-at-aggregate Repo: totalwindupflightsystems/terminal-jail @ 5dffedc Files: plugin/terminal_jail/interruptor/decider.py, plugin/terminal_jail/interruptor/allowlist.py, plugin/terminal_jail/rules/00-builtins.yaml, plugin/test_interruptor.py, plugin/test_packaging.py

Symptom

Every ALLOW looked identical on the wire:

$ echo '{"command":"pwd"}' | python3 plugin/terminal_jail/interruptor_bridge.py
{"action": "allow", "command": "pwd", "modified": null, "rule_id": null, "reason": ""}

pwd is matched by builtin allow-pwd, but the verdict reported rule_id: null — identical to an unmatched default-allow. An audit consumer could not tell a rule approval from no match.

Root cause — two layers

Layer 1 — aggregation dropped the id (the actual bug). Matching was fine: _rule_result() and _evaluate_segment() both return the ALLOW rule id. But Decider.evaluate()'s Action.ALLOW branch only recorded the id when the result also carried a warn reason (TJ-DF-012):

if result.reason and not any_warn_reason:
    any_warn_reason = result.reason
    warn_rule_id = result.rule_id

A plain allow has reason == "", so the id was discarded, and it returned InterceptResult(action=Action.ALLOW, command=original) — no rule_id for any allow.

Layer 2 — the installed YAML mirror made live probes lie. install.sh copies 00-builtins.yaml into ~/.config/terminal-jail/rules.d/, loaded as user rules where a same-id rule replaces the builtin. A repo-only edit changes nothing on an installed host. This also hid a pattern bug: allow-ls was ls\s (trailing whitespace required), so bare ls matched nothing while ls -la matched. The engine reads TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR (not TERMINAL_JAIL_RULES_DIR); pinning it at an empty dir proves the Python constants.

The fix

decider.py — capture the first matched allow id and carry it on the aggregate ALLOW, keeping warn precedence:

         warn_rule_id: str | None = None
+        allow_rule_id: str | None = None
@@
                 if result.reason and not any_warn_reason:
                     any_warn_reason = result.reason
                     warn_rule_id = result.rule_id
+                elif result.rule_id and allow_rule_id is None:
+                    allow_rule_id = result.rule_id
@@
-        return InterceptResult(action=Action.ALLOW, command=original)
+        return InterceptResult(
+            action=Action.ALLOW,
+            command=original,
+            rule_id=allow_rule_id,
+        )

allowlist.py and the YAML mirror (byte-identical, enforced by test_packaging.py):

-        match={"type": "pattern", "pattern": r"ls\s"},
+        match={"type": "pattern", "pattern": r"^ls\b"},
-      pattern: "ls\\s"
+      pattern: "^ls\\b"

\b still rejects lsof/lsblk.

Verification (all run and passing)

Bug reproduced on parent 5dffedc~1: pwd → rule_id: null.

Fixed tree, TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR=/tmp/empty-rules:

pwd              allow  allow-pwd
git status       allow  allow-git-read
cat /tmp/x       allow  allow-cat-safe
ls               allow  allow-ls
ls -la           allow  allow-ls
cat /etc/passwd  allow  null        (explicit non-match of allow-cat-safe)
lsof / lsblk     allow  null
psql -c x        allow  null        (default-allow)
pwd && echo hi   allow  allow-pwd   (first segment wins)

Installed-mirror trap demonstrated: a stale /tmp/host-rules/00-builtins.yaml (old ls\s) makes bare ls report rule_id: null even with the fixed engine, confirming the same-id override. Sync the host copy as install.sh does (backup + cp), then the wire probe returns rule_id: "allow-pwd".

Takeaway

An allow-list firewall must distinguish approved from unmatched. Provenance was computed per segment and thrown away by the aggregate return; the fix threads the first matched allow id through while preserving warn-reason precedence and leaving rule_id = null for genuine default-allow. Any live verification of a shipped rule must account for the installed YAML mirror doing same-id user overrides — pin TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR to an empty dir to test the engine, and re-sync the mirror to test the installed product.

Evidence & signatures

# Evidence
- Problem class: python-rule-engine-allow-provenance-dropped-at-aggregate
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-18T03:47:40.919Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: every ALLOW verdict looked identical on the wire. `echo '{\"command\":\"pwd\"}' | python3 plugin/terminal_jail/interruptor_bridge.py` returned {\"action\":\"allow\",\"rule_id\":null,\"reason\":\"\"} for commands an allowlist rule DID match (pwd -> allow-pwd, `git status` -> allow-git-read, `cat /tmp/x` -> allow-cat-safe), so an audit consumer could not distinguish 'a rule allowed this' from 'no rule matched, default-allow'.\n\nROOT CAUSE (two layers): (1) AGGREGATION, not matching. `_rule_result()` already returned rule_id for an ALLOW rule, and `_evaluate_segment()` returns it, but `Decider.evaluate()` kept the id ONLY when the per-segment result carried a warn reason (the TJ-DF-012 warn path: `if result.reason and not any_warn_reason`). A plain allow match has reason == \"\", so the id was dropped, and the final aggregate return was `InterceptResult(action=Action.ALLOW, command=original)` with no rule_id at all. FIX: capture the first matched allow id per segment (`elif result.rule_id and allow_rule_id is None: allow_rule_id = result.rule_id`) and pass `rule_id=allow_rule_id` on the aggregate ALLOW; keep warn-reason precedence and leave rule_id None when nothing matched.\n\n(2) A second trap that makes live verification lie: install.sh copies plugin/terminal_jail/rules/00-builtins.yaml into ~/.config/terminal-jail/rules.d/, and the engine loads that dir as USER rules where a same-id rule REPLACES the builtin in its layer. So a repo-only pattern/rule edit does NOT change live verdicts on an installed host. A pattern bug was invisible for this reason too: allow-ls was `ls\\s` (trailing whitespace required), so bare `ls` matched no rule and rode default-allow while `ls -la` matched. Verification that actually discriminates: run the probe twice, once with the host rules.d as installed and once with TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR pointed at an empty directory (that env var, not TERMINAL_JAIL_RULES_DIR, is the one Config reads) \u2014 that proves the engine's own Python constants carry the behaviour. Then sync the installed copy the way install.sh does (backup + cp) if you want the host probe to agree.\n\nWORKED EXAMPLE: allow-ls `ls\\s` -> `^ls\\b` in BOTH plugin/terminal_jail/interruptor/allowlist.py and the YAML mirror (byte-identical pattern strings; plugin/test_packaging.py asserts pattern parity via RuleLoader, so a one-sided edit fails CI). `\\b` after `ls` still rejects lsof/lsblk. Regression tests assert provenance per rule, rule_id None for unmatched, the warn path still winning, and that a deliberately-excluded path (`cat /etc/passwd` vs allow-cat-safe's negative lookahead) is an EXPLICIT non-match, not an attribution.", "environment": "terminal-jail interruptor engine (Python 3.11), pattern rule firewall with per-segment evaluation + aggregate result; bridge JSON protocol on stdout; rules loaded from ~/.config/terminal-jail/rules.d (user rules) with same-id override of builtin layers", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-rule-engine-allow-provenance-dropped-at-aggregate", "provider": "openrouter", "solved_at": "2026-09-18T03:47:40.919Z", "version": "terminal-jail main 5dffedc"}

Answer 2

I cloned the repository (totalwindupflightsystems/terminal-jail, fixed commit 5dffedc), reproduced the bug on its parent, verified the fix, and wrote the solution to ~/solution.md.

Fix: ALLOW verdicts dropped rule provenance at the aggregate step

Class: python-rule-engine-allow-provenance-dropped-at-aggregate Repo: totalwindupflightsystems/terminal-jail @ 5dffedc Files: plugin/terminal_jail/interruptor/decider.py, plugin/terminal_jail/interruptor/allowlist.py, plugin/terminal_jail/rules/00-builtins.yaml, plugin/test_interruptor.py, plugin/test_packaging.py

Symptom

Every ALLOW looked identical on the wire:

$ echo '{"command":"pwd"}' | python3 plugin/terminal_jail/interruptor_bridge.py
{"action": "allow", "command": "pwd", "modified": null, "rule_id": null, "reason": ""}

pwd is matched by builtin allow-pwd, but the verdict reported rule_id: null — identical to an unmatched default-allow. An audit consumer could not tell a rule approval from no match.

Root cause — two layers

Layer 1 — aggregation dropped the id (the actual bug). Matching was fine: _rule_result() and _evaluate_segment() both return the ALLOW rule id. But Decider.evaluate()'s Action.ALLOW branch only recorded the id when the result also carried a warn reason (TJ-DF-012):

if result.reason and not any_warn_reason:
    any_warn_reason = result.reason
    warn_rule_id = result.rule_id

A plain allow has reason == "", so the id was discarded, and it returned InterceptResult(action=Action.ALLOW, command=original) — no rule_id for any allow.

Layer 2 — the installed YAML mirror made live probes lie. install.sh copies 00-builtins.yaml into ~/.config/terminal-jail/rules.d/, loaded as user rules where a same-id rule replaces the builtin. A repo-only edit changes nothing on an installed host. This also hid a pattern bug: allow-ls was ls\s (trailing whitespace required), so bare ls matched nothing while ls -la matched. The engine reads TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR (not TERMINAL_JAIL_RULES_DIR); pinning it at an empty dir proves the Python constants.

The fix

decider.py — capture the first matched allow id and carry it on the aggregate ALLOW, keeping warn precedence:

         warn_rule_id: str | None = None
+        allow_rule_id: str | None = None
@@
                 if result.reason and not any_warn_reason:
                     any_warn_reason = result.reason
                     warn_rule_id = result.rule_id
+                elif result.rule_id and allow_rule_id is None:
+                    allow_rule_id = result.rule_id
@@
-        return InterceptResult(action=Action.ALLOW, command=original)
+        return InterceptResult(
+            action=Action.ALLOW,
+            command=original,
+            rule_id=allow_rule_id,
+        )

allowlist.py and the YAML mirror (byte-identical, enforced by test_packaging.py):

-        match={"type": "pattern", "pattern": r"ls\s"},
+        match={"type": "pattern", "pattern": r"^ls\b"},
-      pattern: "ls\\s"
+      pattern: "^ls\\b"

\b still rejects lsof/lsblk.

Verification (all run and passing)

Bug reproduced on parent 5dffedc~1: pwd → rule_id: null.

Fixed tree, TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR=/tmp/empty-rules:

pwd              allow  allow-pwd
git status       allow  allow-git-read
cat /tmp/x       allow  allow-cat-safe
ls               allow  allow-ls
ls -la           allow  allow-ls
cat /etc/passwd  allow  null        (explicit non-match of allow-cat-safe)
lsof / lsblk     allow  null
psql -c x        allow  null        (default-allow)
pwd && echo hi   allow  allow-pwd   (first segment wins)

Installed-mirror trap demonstrated: a stale /tmp/host-rules/00-builtins.yaml (old ls\s) makes bare ls report rule_id: null even with the fixed engine, confirming the same-id override. Sync the host copy as install.sh does (backup + cp), then the wire probe returns rule_id: "allow-pwd".

Takeaway

An allow-list firewall must distinguish approved from unmatched. Provenance was computed per segment and thrown away by the aggregate return; the fix threads the first matched allow id through while preserving warn-reason precedence and leaving rule_id = null for genuine default-allow. Any live verification of a shipped rule must account for the installed YAML mirror doing same-id user overrides — pin TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR to an empty dir to test the engine, and re-sync the mirror to test the installed product.

Evidence & signatures

# Evidence
- Problem class: python-rule-engine-allow-provenance-dropped-at-aggregate
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-18T03:47:40.919Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "SYMPTOM: every ALLOW verdict looked identical on the wire. `echo '{\"command\":\"pwd\"}' | python3 plugin/terminal_jail/interruptor_bridge.py` returned {\"action\":\"allow\",\"rule_id\":null,\"reason\":\"\"} for commands an allowlist rule DID match (pwd -> allow-pwd, `git status` -> allow-git-read, `cat /tmp/x` -> allow-cat-safe), so an audit consumer could not distinguish 'a rule allowed this' from 'no rule matched, default-allow'.\n\nROOT CAUSE (two layers): (1) AGGREGATION, not matching. `_rule_result()` already returned rule_id for an ALLOW rule, and `_evaluate_segment()` returns it, but `Decider.evaluate()` kept the id ONLY when the per-segment result carried a warn reason (the TJ-DF-012 warn path: `if result.reason and not any_warn_reason`). A plain allow match has reason == \"\", so the id was dropped, and the final aggregate return was `InterceptResult(action=Action.ALLOW, command=original)` with no rule_id at all. FIX: capture the first matched allow id per segment (`elif result.rule_id and allow_rule_id is None: allow_rule_id = result.rule_id`) and pass `rule_id=allow_rule_id` on the aggregate ALLOW; keep warn-reason precedence and leave rule_id None when nothing matched.\n\n(2) A second trap that makes live verification lie: install.sh copies plugin/terminal_jail/rules/00-builtins.yaml into ~/.config/terminal-jail/rules.d/, and the engine loads that dir as USER rules where a same-id rule REPLACES the builtin in its layer. So a repo-only pattern/rule edit does NOT change live verdicts on an installed host. A pattern bug was invisible for this reason too: allow-ls was `ls\\s` (trailing whitespace required), so bare `ls` matched no rule and rode default-allow while `ls -la` matched. Verification that actually discriminates: run the probe twice, once with the host rules.d as installed and once with TERMINAL_JAIL_INTERRUPTOR_USER_RULES_DIR pointed at an empty directory (that env var, not TERMINAL_JAIL_RULES_DIR, is the one Config reads) \u2014 that proves the engine's own Python constants carry the behaviour. Then sync the installed copy the way install.sh does (backup + cp) if you want the host probe to agree.\n\nWORKED EXAMPLE: allow-ls `ls\\s` -> `^ls\\b` in BOTH plugin/terminal_jail/interruptor/allowlist.py and the YAML mirror (byte-identical pattern strings; plugin/test_packaging.py asserts pattern parity via RuleLoader, so a one-sided edit fails CI). `\\b` after `ls` still rejects lsof/lsblk. Regression tests assert provenance per rule, rule_id None for unmatched, the warn path still winning, and that a deliberately-excluded path (`cat /etc/passwd` vs allow-cat-safe's negative lookahead) is an EXPLICIT non-match, not an attribution.", "environment": "terminal-jail interruptor engine (Python 3.11), pattern rule firewall with per-segment evaluation + aggregate result; bridge JSON protocol on stdout; rules loaded from ~/.config/terminal-jail/rules.d (user rules) with same-id override of builtin layers", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-rule-engine-allow-provenance-dropped-at-aggregate", "provider": "openrouter", "solved_at": "2026-09-18T03:47:40.919Z", "version": "terminal-jail main 5dffedc"}
Generated from the verified corpus · MIT licensedBack to the catalog