Skip to content

fix(tui): re-read the network policy so /network allow lands without a restart - #8

Open
SparkofSpike wants to merge 4 commits into
mainfrom
fix/network-policy-hot-reload
Open

SparkofSpike wants to merge 4 commits into
mainfrom
fix/network-policy-hot-reload

Conversation

@SparkofSpike

@SparkofSpike SparkofSpike commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Summary

/network allow <host> writes the host into config.toml and tells the operator
Saved to ... Retry the command now. — but the retry was still refused with
requires network approval. The engine captures its NetworkPolicyDecider once,
when the session is spawned, and the slash command only edits the document, so
nothing read the file again for the rest of the session. The host stayed blocked
until CodeWhale was restarted, which is exactly what the message promises the
operator they do not have to do.

This makes the tool context (and the MCP pool) re-read the [network] table from
the document the session was launched with, so the retry sees the host the file
already allows.

Reproduction

  1. Put api.github.com in [network] allow (or run /network allow api.github.com).
  2. In the same session, run any read-only gh command, e.g. gh repo view owner/repo.

Before: Read-only GitHub CLI access to 'api.github.com' requires network approval; allow that host in the parent session or network policy before dispatching the scout.

The file on disk was correct the whole time, and a plain Invoke-WebRequest to
https://api.github.com/rate_limit returned 200 from the same machine — so the
gate, not the network, was the blocker.

Changes

  • NetworkPolicyDecider::with_policy_refreshed: rebuilds a decider against a
    freshly parsed policy while keeping the session cache, so an approval already
    granted through the approval prompt survives the refresh. The auditor keeps its
    log path and follows the new policy's audit switch.
  • config::network_policy_from_document: re-reads only the document's own
    [network] table. Deliberately narrower than Config::load — the tool-context
    build path must not re-apply environment, managed, and credential layers. A
    missing/unreadable/unparseable document, or one with no [network] table,
    returns None and the caller keeps the policy it already holds, which is the
    conservative direction for an allow/deny gate.
  • Engine::current_network_decider (new) + two call sites in
    crates/tui/src/core/engine.rs: the per-turn tool context, and the MCP pool at
    construction time.
  • Both directions land. /network deny <host> writes the same [network]
    table /network allow does, so a session that started without one adopts the
    table the moment it appears. Otherwise tightening a policy mid-session would
    need the very restart this change removes. A session that started gated keeps
    the policy it holds if the table later disappears, so removing [network]
    cannot leave a live session ungated by accident.

After review

The adversarial review found that the first version left the deny direction
cold: it short-circuited on a session whose spawn-time policy was None, so a
mid-session [network] deny was ignored exactly when a policy was being
tightened. The independent review found something more serious in the same
code — a resolved authority could be widened:

  • A managed overlay replaces the user's [network] table
    (apply_managed_overrides).
  • A Fleet network_access = false never consults that table at all;
    exec_network_policy (crates/tui/src/lib.rs) synthesizes a
    default = deny decider, and its own comment says user configuration "may
    never widen an explicit network denial".

Replacing the resolved policy with the raw document therefore turned an
administrator's denial into prompt/allow on the first tool-context build —
every turn. The second commit closed both by folding the re-read onto the
resolved policy instead of replacing it.

A third commit closes what the second one still let through. The fold
hardened default and deny but took the document's allow, proxy,
proxy_fake_ip_cidrs, and audit wholesale — and decide checks allow
before falling back to default, so under a deny-by-default authority a
document listing allow = ["api.github.com"] still produced Allow. The fold
was escapable in the one direction it exists to protect. The fold is now
asymmetric by the authority's own fallback:

  • deny — union; default — the stricter of the two.
  • Authority default = Deny: the lower layer contributes nothing that can
    allow
    . allow, proxy, and the fake-IP CIDRs stay the authority's own, so
    no host is lifted and no SSRF exception is granted.
  • Authority default = Prompt: the lower layer may name hosts, which is what
    /network allow <host> writes, and those merge with the authority's own
    rather than replacing them — a refresh cannot erase an administrator's
    allowances either.
  • audit: either layer asking for the log keeps it.

That third commit's own test had asserted the leak (folded.default stayed
Deny and the document's host was in allow — never what decide returned).
It now asserts verdicts for both fallbacks.

Follow-up this leaves open (pre-existing, not introduced here)

Under a Fleet denial, the fold correctly freezes allow, and that makes
/network allow <host> write an entry that has no effect — while the command
still prints Network host allowed: <host> and Saved to ..., and
/network list shows the entry as live. The NetworkDenied hint even tells the
operator to run exactly that command. The gate is right; the command-side UI is
misleading. Surfacing it needs /network to know whether an authority owns the
policy, and it is a CommandHandler::Pure with no engine handle — so it is a
separate fix, not part of this one.

Scope limit worth naming

The per-turn tool context — the path every shell, web, finance, speech, and skill
call goes through, including the reported read-only gh refusal — re-reads on
every turn. The MCP pool does not: ensure_mcp_pool returns early once a pool
exists, so its decider is the one captured when the pool was built. A /network allow issued after the pool exists therefore does not reach MCP transports until
the pool is rebuilt. Fixing that means mutating a live pool's policy, which this
change deliberately does not do.

An adopted table has no session cache to inherit, because the session had no
decider and therefore nothing was ever approved under one.

Why a re-read rather than wiring the slash command to the engine

/network is registered as CommandHandler::Pure — it has no App, no engine
handle, and no channel to a live decider by construction. The neighbouring
WorkspaceTrust list solves the same problem the same way: it is re-read from disk
on every tool-context build so /trust add lands mid-session. This follows that
precedent, and as a side benefit a hand edit of [network] in config.toml also
takes effect on the next turn instead of the next launch.

Testing

  • cargo fmt --all -- --check — clean.

  • cargo test -p codewhale-tui network_policy — 41 passed; 0 failed in the
    lib target (14,769 filtered out) and 29 passed; 0 failed in the
    integration target, including five new unit tests:

    • refreshed_policy_adopts_new_table_without_retracting_session_approvals
    • refreshed_policy_honours_a_new_deny_list
    • refreshed_policy_keeps_the_authority_marking
    • folding_a_document_never_widens_an_authority — asserts the folded policy's
      verdicts for both fallbacks: the document's host is Deny under a
      denial and Allow under a prompt, the authority's own allowance survives,
      an unlisted host keeps the authority's fallback, and no fake-IP exception or
      audit silencing crosses the fold.
    • folding_an_allow_by_default_authority_only_narrows — the
      default = "allow" branch (a managed overlay may resolve it): the document
      can only narrow the fallback, and a host it names stays allowed.
  • cargo test -p codewhale-tui tool_context — 24 passed; 0 failed.

  • New integration regression tests in
    crates/tui/src/core/engine/tests/test_cases_12.rs:

    • tool_context_network_policy_follows_the_config_document_mid_session — builds
      a real Engine whose spawn-time policy does not allow api.github.com,
      points loaded_config_path at a document that does, and asserts the tool
      context that comes back evaluates api.github.com as Allow while an
      unlisted host still prompts.
    • tool_context_adopts_a_policy_written_mid_session_and_never_ungates_one — a
      session that started with no [network] table adopts a document's
      deny = ["evil.example.com"], and a session that started gated keeps its
      policy after the table is removed.

    On the "prove it fails without the fix" rule: the old code path clones
    self.config.network_policy — the spawn-time snapshot, which in the first
    fixture carries default = "prompt" and no allow entry — so the same
    assertion would read Prompt and fail. That chain is pinned by existing tests
    (unknown_host_returns_default), but I did not re-run the suite against a
    reverted build to demonstrate it, and am not claiming that receipt.

Known limitation

[profiles.<name>.network] is not consulted. /network does not write that layer
either, so a live session and the command agree on the base table. Documented in
the new function's doc comment rather than left implicit.

Base and scope

This PR targets this fork (SparkofSpike/CodeWhale), base main — which is
fast-forwarded to upstream main (9524531de) so the diff is exactly this
branch. It is not proposed upstream here.

The second and third commits answer both reviews: the adversarial review's
deny-direction and fold-escape findings, and the independent review's
authority-widening finding. All of them, and the fixes, are in the PR thread.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes — no UI change; verification is
    the test receipt above plus the reproduction steps

Related Issues

No-Issue: reproduced from an operator session on 0.10.1; no upstream issue filed
for it yet.

Attribution

🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)

…a restart

`/network allow <host>` edits config.toml and prints "Retry the command now.",
but the engine held the NetworkPolicyDecider captured when the session was
spawned, so the retry was refused for the rest of the session and the host only
unblocked after a restart.

The per-turn tool context and the MCP pool now take a decider rebuilt from the
document's own [network] table. The session cache rides along, so an approval
already granted through the approval prompt survives the refresh.

A session launched without a [network] table stays ungated on purpose:
adopting one from disk would hand that session a default = "prompt" policy it
never had, and every unrelated host would start prompting.

This follows the WorkspaceTrust precedent, which also re-reads per
tool-context build so /trust add lands mid-session. As a side effect a hand
edit of [network] in config.toml now takes effect on the next turn too.

Known limitation: [profiles.<name>.network] is not consulted, matching what
/network writes.
@SparkofSpike

Copy link
Copy Markdown
Owner Author

Adversarial review of #8 — fix(tui): re-read the network policy so /network allow lands without a restart

Verdict: The fix genuinely repairs the primary reported path (the per-turn bash-gate re-reads [network] from disk and the shell tool sees it), and its three new tests are non-vacuous. But two claims do not survive scrutiny: the MCP path is not fixed for an already-live pool, and the re-read introduces a fail-open regression whenever a higher-precedence layer (managed config) contributed a stricter [network] than the base document. The "stays ungated" choice is also fail-open in the deny direction. It should not land as-is.

Findings, most severe first.


Finding 1 — CONFIRMED (security, fail-open): the re-read silently replaces the live policy, dropping any stricter layer — including a managed overrides

network_policy_from_document (crates/tui/src/config.rs:3078-3086) parses only the base document (the loaded_config_path file) and returns it as the whole new policy. current_network_decider (crates/tui/src/core/engine.rs:7123-7134) then hands that to with_policy_refreshed, which rebuilds from it unconditionally when Some.

But the spawn-time snapshot it replaces is not just the base document. The TUI's effective Config applies layers in Config::load: document → apply_env_overrides → apply_managed_overrides → requirements. apply_managed_overrides runs merge_config(config, managed) (crates/tui/src/config.rs:10935), and the network field merges as network: override.network.or(base.network) (crates/tui/src/config.rs:10476). So a managed config (default /etc/deepseek/managed_config.toml on unix, crates/tui/src/config/paths.rs:161) can legitimately tighten/own [network] — e.g. default = "deny" or an added deny list — and the frame builds the spawn decider from the merged value (crates/tui/src/tui/ui/frame.rs:1006).

On the first turn after /network allow, the re-read returns only the base document's [network] and replaces the whole decider with it. Any tightening the managed layer added is silently dropped. Concrete scenario, confirmed: base loaded_config_path has [network] default="prompt"; managed /etc/deepseek/managed_config.toml carries [network] default="deny" + a deny list. Session spawns denied-by-default. /network allow host is legal only if the managed layer allows it, but the merged policy is what gates — and by design the merged policy is stricter than the base table the re-read is reading. After the re-read the session answers with the looser base default="prompt", so every host the managed layer denied moves from Deny to Prompt/Allow. Net effect: the per-turn reload makes the session more permissive than the policy it was actually running under at spawn.

The failure matrix (which I traced in network_policy_from_document / current_network_decider):

state result fail direction
file deleted .ok()? → None → keep current decider closed (unchanged) — OK
file unreadable None → keep current decider closed — OK
TOML malformed ?.ok()? → None closed
[network] removed None closed
[network] present, allow emptied parses → replaces current policy is relaxed only if the new table is looser than the spawn layer
managed layer stricter than base [network] parses base → replaces the stricter merged policy OPEN (this finding)
deny newly added to base picks it up closed (intended)

So the author's claim that "the caller keeps the policy that it already holds, which is the conservative direction" (config.rs:3068-3071) holds only for the None returns, not for the ordinary Some path. The real gate for "more permissive than before" is: the document on disk is looser than the policy you were actually running. That is precisely the [network] relaunch this PR was designed to change, so the risk of over-relaxation is structural, not hypothetical.

Consequence: In an org forbidding network/for an operator running with a managed [network] policy, a single /network allow <host> (or even a hand edit to the base [network]) silently relaxes the entire session denial surface to base-doc policy. This is the most serious finding and is not addressed or acknowledged in the PR.


Finding 2 — CONFIRMED: the MCP path is over-claimed; multi-session /network allow never reaches the pool

ensure_mcp_pool reads the policy only under a re-read inside pool construction (crates/tui/src/core/engine.rs:7450, the same current_network_decider()), but the function begins:

crates/tui/src/core/engine.rs:7405-7419
if let Some(pool) = self.mcp_pool.clone() { ... return Ok(pool); }

Once a pool exists (self.mcp_pool = Some(...) at line 7482), every later ensure_mcp_pool call returns early and never re-reads. The pool then owns its network_policy value
(crates/tui/src/mcp.rs:3493 with_network_policy, cloned out at cloned_network_policy line 3513) and every HTTP/SSE connection is gated against that frozen decider (crates/tui/src/mcp.rs:3861, 3946 pass self.network_policy). There is no per-request back-reference to the engine's live decider.

So: the MCP hosts that were blocked with requires approval stay blocked for the whole rest of the session after /network allow, exactly the bug the PR claims to fix for "the MCP pool now". The claim would only be true if a pool is built after the edit (fresh session, or /mcp reload). For the original bug surface — an already-running session — the MCP path is unfixed. The PR text "the MCP pool now take the re-read decider" overstates by design: it happens only once per pool lifecycle.


Finding 3 — CONFIRMED: "stays ungated" is fail in the tightening (deny) direction

current_network_decider short-circuits when the spawn snapshot is empty:

crates/tui/src/core/engine.rs:7124
let decider = self.config.network_policy.as_ref()?;   // None → whole fn returns None

A session that began with no [network] table (hence EngineConfig::network = None) will never adopt anything from disk — including a brand-new [network] written mid-session with default = "deny" and a deny list, which is precisely the tightening an operator would add because the session is ungated. The PR's rationale ("stays ungated") defends only the allow/prompt direction (don't surprise the user by prompting every host). It is indifferent to the case that matters most in the other direction: an operator who adds [network] deny / default="deny" mid-session to stop blowgun activity, or whose /network default deny lands, gets nothing — the session remains fully ungated. Combined with Finding 3 (layering), the reload is patchy in ways that are not on the manifest: it can widen a strict policy (Finding 1) but cannot adopt a new strict one.


Finding 4 (CONFIRMED, the actual primary path) — the fix does land for the reported bash-tool gate

To be fair and precise: statically the primary path does hold. The per-turn tool registry is built from build_tool_context_for_turn (crates/tui/src/core/engine.rs:5424 → registry builder build(tool_context) line 5584), and build_tool_context_for_turn installs the re-read decider via ctx.with_network_policy(decider) (line 7240). The turn loop's prepared_registry / batch_tool_context are both derived from live_tool_context(tool_registry) (crates/tui/src/core/engine/turn_loop.rs:1719, 2680), which clones the registry's context and does not overwrite network_policy, so the fresh per-turn decider is what reaches context_override at turn_loop.rs:2757 and hence execute_tool... → the bash tool. enforce_readonly_network_reads then reads context.network_policy and gates api.github.com (crates/tui/src/tools/shell.rs:4143-4166). So on the next turn after /network allow writes the file, the bash tool context does carry Decision::Allow for the host. This is the primary bug and it is genuinely fixed.

Suspected caveat on this same path: checkpoints/PreparedToolRegistry are rebuilt per turn, but any stale registry handed to the loop between a long autogroomed boundary (child_host/background runtime speaking the tools with a cached context) would still hold the old decider. I did not find a direct producer of such inconsistency; I flag it as unverified, lower severity.


Finding 5 — CONFIRMED: the integration test only proves build_tool_context_for_turn, not the real dispatch used by the turn loop

The new integration test (crates/tui/src/core/engine/tests/test_cases_12.rs:1584-1652) constructs a Config+EngineConfig and calls engine.build_tool_context_for_turn(&authority, &route) directly, asserts on context.network_policy.evaluate("Bash"). This proves the function it calls. It does not exercise turn_loop's live_tool_context → batch_tool_context → context_override wiring (Finding 4) or the MCP path (Finding 2). So two of the three confirmed defects are entirely outside what the test suite can see. The tests that do test persistence are non-vacuous (refreshed_policy_adopts_new_table_without_retracting_session_approvals depends on the session cache being carried; refreshed_policy_honours_a_new_deny_list depends on the new deny list being actually applied) — good. But the MCP claim in the PR body is not backed by any test at all.


Finding 6 — N/A / accepted: cost

A small file read + single-table TOML parse per build_tool_context_for_turn is bounded and consistent with the WorkspaceTrust::load_for precedent right beside it (crates/tui/src/core/engine.rs:7182). It runs once per turn and in previews / sub-agent wiring (host_profiler), not per tool call, and only when a [network] table exists at spawn (short-circuit at line 7124 avoids I/O when there is nothing to refresh). Not a finding.


Non-findings / checked and closed

  • Question 3 (dropped state): with_policy_refreshed drops trusted_fakeip_cidrs added via with_trusted_fakeip_cidrs. The only caller of with_trusted_fakeip_cidrs is a test (crates/tui/src/network_policy.rs:779, inside mod tests at line 655). No production caller reaches the loss; re-deriving from the re-read policy is consistent. Confirmed OK.
  • Auditor: with_policy_refreshed rebuilds the auditor with the same log path and follows the new policy's audit switch — the spawn uses with_default_audit, and the delta is honored. No drop.
  • Question 7 tests non-vacuity: all reasonably argued above; the two network_policy.rs tests genuinely fail if the relevant field (cache carry-over / new deny) is mis-squared.

Summary

The core UX claim is true for the bash tool and is regression-protected, which is real value. But the security posture is not: (1) a stricter managed/policy layer is structurally dropped on reload (fail-open); (2) MCP is not fixed for an existing session; (3) the "stays ungated" choice fails the tightening direction. These should be blocked/adjusted before merge — at minimum the reload must merge, not replace, and must carry an explicit fail-closed default when the document table is looser than the effective sliced spawn, and the MCP path must be re-read on a live pool (or the claim scoped out).

Attribution

🤖 Generated by SpikeBot 003(ClaudeCode-JP)

The first cut re-read `[network]` from the user's document and treated the
result as the live policy. That document is only one layer of it: a managed
overlay replaces the user table, and a Fleet `network_access = false` never
consults it at all — `exec_network_policy` synthesizes a `default = "deny"`
decider for exactly that case. Replacing the resolved policy with the raw
document therefore widened an administrator's denial to prompt/allow on the
first tool-context build, which is every turn.

`Config` now carries a runtime-only receipt that a managed overlay supplied
`[network]`, and the Fleet-synthesized decider is marked authoritative. When
either holds, a mid-session re-read is folded onto the resolved policy instead
of replacing it: the document's `allow` list is adopted — `allow <host>` still
works — but the fallback keeps the stricter of the two decisions and a denial
is never dropped.

This also lands the deny direction: a session that started with no policy at
all adopts the table the moment one exists, so `/network deny <host>` is not
ignored in the case where a policy is being tightened.

Tests: `folding_a_document_never_widens_an_authority` covers the fold
(deny kept, fallback unchanged, allow adopted), `refreshed_policy_keeps_the_
authority_marking` covers the marker surviving a refresh, and
`tool_context_adopts_a_policy_written_mid_session_and_never_ungates_one`
covers adoption plus the reverse direction (removing the table leaves a gated
session gated). Found by the review on this PR.
@SparkofSpike

Copy link
Copy Markdown
Owner Author

Adversarial review of 0379673f8 — “a resolved network authority is not the document's to widen”

Verdict: does NOT fully close the gate — the fold is escapable at allow

The structural fix is real and correct in its skeleton: exec_network_policy
synthesizes an authoritative deny decider for a Fleet denial, Config tracks
network_layer_is_managed (monotonic, ORed on every merge, never reset), and
Engine::current_network_decider now folds only when an authority owns the
policy and replaces or adopts otherwise. The deny-default and the document's
deny list are protected, and the plain-user /network default allow path is
untouched. But the fold's allow/proxy/proxy_fake_ip_cidrs/audit
layers are adopted wholesale from the lower document, which re-opens exactly
the denial guarantee the follow-up claims to enforce.
Under a
default = deny authority (the canonical managed / Fleet stance) the document
can lift specific hosts to Allow, add a fake-IP SSRF exception, and disable
audit logging. That is a widening of an explicit network denial.


Confirmed findings (most severe first)

F1 — CONFIRMED (security): the fold lets the document's allow, proxy, proxy_fake_ip_cidrs, and audit override an authority's default = deny

crates/tui/src/network_policy.rs:201-215

pub fn folded_with_lower_layer(&self, lower) -> Self {
    let mut deny = self.deny.clone();
    for entry in lower.deny { ... }
    Self {
        default: stricter_decision(self.default, lower.default), // only default+deny hardened
        allow: lower.allow,                  // lower wins wholesale
        deny,
        proxy: lower.proxy,                  // lower wins wholesale
        proxy_fake_ip_cidrs: lower.proxy_fake_ip_cidrs,  // lower wins wholesale
        audit: lower.audit,                  // lower wins wholesale
    }
}

exec_network_policy (lib.rs:14736-14749) synthesizes the Fleet authority as
default: Deny with empty allow, deny, proxy, proxy_fake_ip_cidrs and
marks it authoritative. A managed overlay reaches the same place through
network_layer_is_managed (current_network_decider, engine.rs:7143-7149).
Both authorities mean "deny by default / deny or audit everything the mark is
explicit; my allow is empty and my list-host set is a denial."

decide() (network_policy.rs:127-160) checks deny first, then allow, so for
this authority there is no deny entry to stop lower.allow. The consequences:

  1. Deny-all → Allow for any document-listed host. A user writes
    [network] allow = ["api.github.com"]; the fold sets
    allow = ["api.github.com"], and decide returns Allow — the document
    "fixed" a host the authority decided to preclude, exactly what the follow-up
    commit's title says should be impossible ("a resolved network policy
    authority is not the document's to widen"). The test
    folding_a_document_never_widens_an_authority (network_policy.rs:1007)
    asserts this behavior is intentional — it checks api.github.com is
    present in the folded allow — but it only checks folded.default stays
    Deny; it never asserts what decide("api.github.com") returns, which is
    Allow under the description.
  • audit = false lands. audit: lower.audit (line 214) means a document
    can silence the network audit log inside a security-relevant managed session.
    Authority audit intent is defeated by the lower layer.
  • A fake-IP SSRF exception is granted. guard::validate_dns_resolved_ip
    (guard.rs:169-190) bypasses the restricted/private-IP block when
    decider.is_trusted_fakeip_addr(ip) && decider.trusts_proxy_fakeip_host(host).
    Under the fold, proxy and proxy_fake_ip_cidrs are the document's; a host
    in both makes one host count through the restricted-IP check and reach the
    198.18.0.0/15 placeholders (parse_trusted_fakeip_cidr, network_policy.rs:335-338,
    caps any document value). Not private space, but a real widening the admin's
    empty proxy never authorized.

Fix direction: an authority whose default is Deny must not adopt
lower.allow (or lower.proxy/lower.proxy_fake_ip_cidrs/lower.audit);
those should only be merged when the authority's own fallback is Prompt
("may add hosts to prompt- it", as the docstring claims). Alternatively the
authority's own allow/proxy/audit should survive and the lower layer's
hosts should face the higher default. Any way you slice it, allow: lower.allow
is a widening under default = Deny.

F2 — CONFIRMED (functional, lower severity): the fold drops the authority's own allow, proxy, proxy_fake_ip_cidrs, and audit

The other side of F1. allow: lower.allow and the proxy*/audit fields are
full replaces, not merges. If a managed overlay or Fleet authority legitimately
default = Prompt with allow: [internal.example] (so the host is usable
without every-call prompt), a user doc that omits [network] (or lists its own
hosts) replaces the admin's allow entries wholesale — the admin-approved host
falls back to Prompt. Net effect: the lower layer can unknowingly erase an
administrator's intended allowances and any authored fake-IP/proxy setup, and
adopt only its own. Not a security widening (direction is stricter for the
unlisted), but it breaks the "managed layer wins" contract and will needlessly
lose admin config. The deny and proxy-union asymmetry (deny is merged, allow
is wholesale-replaced) is inconsistent with the intent statement.

F3 — Served — suspected (low): a document-side default = allow that mutes a Prompt-fallback authority hides under the tighter fallback; not exploitable here

For the default = Prompt authority, stricter_decision keeps Prompt, so a
document that wants default = allow is refused — correct. No widening. This
category did not produce a defect: the ordinary path's replace semantics
(engine.rs:7152) leave /network default allow landing for a plain session
as advertised.


Confirmed-normal outcome (category-by-category)

Q4 — nothing harmful. In current_network_decider
(None, Some(policy)) (engine.rs:7156) a session with no resolved decider
adopts the document via with_default_audit. Loaded before that the session had
no decider, so guard::validate_network_policy returned Ok for every host.
The adoption either (a) adds a deny gate (more gated — fail-safe) or (b) with
default = "allow" yields Allow everywhere, which is exactly the pre-adoption
state (equally ungated). It cannot make the session less gated than the
no-decider baseline. SSRF protection also unmoved (no proxy CIDRs adopted unless
the doc sets them).

Q3 — both directions land. The plain path (Some, Some) without authority
uses with_policy_refreshed(document) (unconditional replace) → /network default allow and /network deny <h> both land. The authority path unions
deny (network_policy.rs:202-206) so /network deny <host> persists its entry
regardless of the authority. (The same deny union is what cannot stop the F1
allow-appoint for the default = deny authority's listed host — see F1.)

Q1 — no additional escape found beyond F1. get_managed reaches the fold
both that a managed overlay (network_layer_is_managed, engine.rs:7165) and
a Fleet deny (authoritative, lib.rs:14748) — the two authority gates the
prompt‑intro lists. network_layer_is_managed is #[serde(skip)] on the
struct's default (false) and is set only in apply_managed_overrides
(config.rs:11015); the merge is override || base (config.rs:10484-10485), never
reset, and every runtime config reload goes through Config::load →
apply_managed_overrides (confirmed: the rereload paths in
commands/groups/config/config.rs all call Config::load). A nonzero path that
introduces a managed [network] after Config::load and that never re-applies
the overlay would leave the flag false, but none is in the code.

Q2 — the fold is sound, in both directions, for the two fields it actually
hardens: deny union and stricter_decision(Prompt/Deny/Allow) order.
stricter returns the lower rank correct both in (Deny,Allow)→Deny and
(Prompt,Deny)→Deny.

Q5 — false is the fail-safe value and every non-load constructor is safe.
Config::default() gives network_layer_is_managed = false; the flag is
serde-skipped so deserialize keeps it at false. Config::from_saved_document
never sets it (nor calls apply_managed_overrides). It is only consulted
by the engine per turn via the merged live Config (the one that ran
apply_managed_overrides), not via from_saved_document. The
from_saved_document callers (route_billing, provider_readiness,
commands/config+config.rs:1450/4411, tests) are editor/preview/model-lookup
surfaces that does not build a current_network_decider. So nothing that fails
true reads false. The only theoretical false-where-it-true-case would be
a hot-plug of a managed file without a subsequent Config::load — not reachable.


Suggested-resolved items

  • The engine's self.config.network_policy for the interactive TUI is built
    only in …/ui/frame.rs:1006-1008 from config.network (plain, no Fleet deny);
    the Fleet network_access = false synthetic deny is wired only in
    exec_agent.rs:468/lib.rs:14736. This is fine for Fleet (which only runs
    headless workers), but I could not find an interactive-turned-worker that
    would feed the authoritative deny into the interactive engine path — so the
    fold route for an interactive TUI under strict Fleet is present only through
    managed overrides. If any future path lets an interactive TUI inherit
    network_access=false (Fleet handler/agent/operator), it would rebuild
    its decider from config.network and miss the deny, and current_network_decider
    would then fold an empty-authority — live now, not m a bug, but I record the
    wiring here because that is precisely the escape "a Fleet denial reached
    through a different constructor than exec_network_policy" the task asked to
    rule out. It is currently unreachable (Fleet worker sessions are headless),
    so I label this suspected above.

  • Memory-level: the folded policy is rebuilt on every tool-context build (each
    turn) — the fold → with_policy_refreshed allocates a fresh policy + CIDR
    table + audit path per turn. Not a correctness issue, but worth noting the
    authenticated authoritative flag is the only thing distinguishing the
    expensive fold path; leaving it as-is is fine.

  • MCP login / discovery deciders (tui/ui/handlers.rs:550, :1158 and
    lib.rs:11626, :14242) build the policy from config.network directly with
    no authoritative/marked route. These are the MCP-side use of the decider,
    not the engine's per-turn gate, and under a managed [network] they read
    config.network (which the overlay replaced) that is the same managed table —
    so managed intent is reflected there too. Not a route.

  • The catch outside the fold says audit world; the main decider's per-call audit
    writes are unaffected besides the audit False fold issue (F2) which is the
    same as F1's audit bullet.


Conclusion

The foundation is correct: the authoritative flag, the managed-override flag, the
fold-vs-replace-vs-adopt branch, the monotonic merge for network_layer_is_managed,
the stricter-default and deny-union all behave as documented, and the plain-user
path plus the adoption path are fail-safe. But the fold still lets the document
widen the authority through allow (and proxy/proxy_fake_ip_cidrs/audit),
which is the precise property the follow-up commit exists to guarantee. That is
not closed. I would not merge until that line — and/or the whole
allow/proxy/audit adoption — is gated on the authority's own default
being anything other than deny (or the authority's allow/audit merged in and
the doc's sub-list restricted).

Attribution

🤖 Generated by SpikeBot 003(ClaudeCode-JP)

The fold added in the previous commit hardened `default` and `deny` but took
the document's `allow`, `proxy`, `proxy_fake_ip_cidrs`, and `audit` wholesale.
`decide` checks `allow` *before* falling back to `default`, so under a
deny-by-default authority — the canonical managed / Fleet stance — a document
listing `allow = ["api.github.com"]` still produced `Allow`. The fold was
escapable in the one direction it exists to protect, and the review that found
it also caught that the case's own test asserted the leak: it checked
`folded.default` stayed `Deny` and that the document's host was in `allow`,
never what `decide` returned.

The fold is now asymmetric by the authority's own fallback:

- `deny`: union. Either layer saying no is a no.
- `default`: the stricter of the two, as before.
- Authority `default = Deny`: the lower layer contributes nothing that can
  allow. `allow`, `proxy`, and the fake-IP CIDRs stay the authority's own — no
  host lifted, no SSRF exception granted.
- Authority `default = Prompt`: the lower layer may name hosts, which is what
  `/network allow <host>` writes. Those **merge** with the authority's own
  instead of replacing them, so a refresh cannot erase an administrator's
  allowances either.
- `audit`: either layer asking for the log keeps it. A document cannot silence
  audit under an authority.

`folding_a_document_never_widens_an_authority` now asserts `decide()` verdicts
for both fallbacks rather than field shape: the document's host is `Deny` under
a denial and `Allow` under a prompt, the authority's own allowance survives, an
unlisted host keeps the authority's fallback, and no fake-IP exception or audit
silencing crosses the fold.

`cargo test -p codewhale-tui network_policy` -> 40 passed (lib) + 28 passed
(integration); `cargo test -p codewhale-tui tool_context` -> 24 passed.
@SparkofSpike

Copy link
Copy Markdown
Owner Author

Review: PR #8 third commit 5a2fa012e

Verdict: F1 is closed. Under a deny-by-default authority the allow /
proxy / proxy_fake_ip_cidrs union is now replaced by the authority's own
fields, so a mid-session re-read of the user document can no longer lift the
authority's denial. The rewritten test also pins the verdicts rather than the
folded field layout, which is exactly what the fold's interaction with
decide() (allow-checked-before-default) requires. I walked all nine
authority × document fallback combinations and the only deny-by-default
authority that really exists (the Fleet denial, exec_network_policy) is
correctly frozen.

Three notes follow, most-severe first. None of them is a reopen of F1.


1. The freeze is a non-escape, verified by walking all combinations

folded_with_lower_layer (network_policy.rs:223) branches on
self.default == DecisionToml::Deny and freezes allow/proxy/
proxy_fake_ip_cidrs; otherwise it unions them. decide checks
allow before the default fallback, so the only way a document could lift a
deny is if its entries reached folded.allow. For the branch on
self.default, enumerating authority×document ∈ {Deny, Prompt, Allow}²
(witnesses in parentheses):

auth def doc def folded default doc-only-host verdict > authority-alone?
Deny Deny Deny Deny (frozen) no
Deny Prompt Deny Deny (frozen) no
Deny Allow Deny Deny (frozen) no
Prompt Deny Deny Allow (union) yes → doc chose deny-default yet named allow
Prompt Prompt Prompt Allow (union) yes → intended /network allow
Prompt Allow Prompt Allow (union) yes → intended /network allow
Allow Deny Deny Allow (union) no
Allow Prompt Prompt Allow (union) no
Allow Allow Allow Allow (union) no

The Prompt rows are the intended /network allow behavior — an authority
that is not deny-by-default tolerates new hostnames. The two rows where the
folded default becomes Deny but the document's allow survives both have the
document itself setting its own default = Deny. The user named those hosts;
they are not a widening. Starting the authority's own default — the only one
whose Deny is a boundary the user may not cross — is the right key for the
"fleet denial" authority, which always resolves to Deny via
exec_network_policy (lib.rs:14743). That case is fully sealed.

2. /network allow reports success, writes an inert entry, and tells you nothing

** — product gap, confirmed, pre-existing (not introduced here).** A Fleet
denial (outer_network_access == Some(false)) is frozen: allow stays the
authority's own. But /network allow <host> is a document-only
writer (crates/tui/src/commands/groups/utility/network.rs:162):

"Network host {action}: {host}\nSaved to {path}. Retry the command now."

Every code path — the doc now has the host under [network].allow, a re-read
folds it, and folded.allow does not include it because it is frozen. It prints
"Network host allowed: {host}" and "Saved", which reads as success even though
the allow never takes effect. /network list labels that allow entry as live.
Worse, the NetworkDenied hint (contract.rs:2850, plugins/mod.rs:927)
tells the user to "Add it to your allow list with /network allow {host}, then
retry" — precisely the inert action, sent as if it is the fix. The engine
frozen correctly; the command-side UI is what is misled. Worth a follow-up so
that /network allow/list can surface "a Fleet denial keeps the last word; this
entry is stored but has no effect", but that is a separate product fix, not a
defect in this commit.

3. The fold's audit = true interacts correctly with with_policy_refreshed; the one dead-audit case is pre-existing

** — confirmed consistent; one adjacent pre-existing gap.** The refreshed
decider (network_policy.rs:636) re-derives trusted_fakeip_cidrs from the
folded policy and rebuilds the auditor against policy.audit_enabled(), so a
fold that clears proxy_fake_ip_cidrs (deny-authority) leaves no trusted CIDRs
(with_policy_refreshed re-parses the folded list, so a cleared list clears
trust), and a fold that raises audit to true yields a new enabled
auditor (None & policy.audit_enabled() => default_path(true)). No stale-CIDR
or dead-audit-under-audit-true path is opened by this commit.

Pre-existing and separable: the Fleet-denial authority is built with
NetworkPolicyDecider::new(policy, None) (lib.rs:14741), so it has
policy.audit == true (Default) but no auditor, and audit_record is a
no-op when self.auditor is None (network_policy.rs:735) until a document
re-read triggers a refresh that materializes the auditor. A long-gated session
never writes an audit line despite audit_enabled() == true. Independent of
this commit; naming it for completeness.

4. The rewritten test pins the verdicts — and the two field asserts are genuine (the Default-value objection fails here)

folding_a_document_never_widens_an_authority (network_policy.rs:1036) now
walks decide() (the "F1-as-test-asserted-the-leak" defect is gone —
the deny-authority verdicts, 1060/1065/1070 pin DenyDenyDeny; the
prompt-authority ones, 1097/1102/1107/1112 pin Allow/Allow/Prompt/Deny).

  • proxy_fake_ip_cidrs asserts (1074–1076): these are field asserts
    because decide takes only hosts; a CIDR cannot go through it. But they do not
    test a Default — the deny-branch would have discarded the doc's CIDR,
    otherwise the doc's 198.18.0.0/15 would be present and the assert would
    fail. It genuinely pins the "a doc cannot hand itself an SSRF exception"
    freeze.
  • folded.audit (1078–1079): asserts a doc with audit: false cannot
    silence an authority with audit: true. That is a real pin on the
    self.audit || lower.audit wider direction it names. The complementary
    direction (authority off, doc on → union keeps it on) is unpinned but is
    more-logging-not-more-access, harmless.

Unpinned corner, worth a guard test: the authority-Allow default key
(row 7–9 above) is a valid live state (a managed [network] overlay resolving
default = "allow", via into_runtime at config.rs:3057 and
network_layer_is_managed at config.rs:11014). Its fold uses the union
branch; combined with a doc default = Deny it yields a deny-by-default
folded policy that still honors the document's allow. That is not more
permissive than the authority-alone (Allow allows everything), so it is not
F1-class, but a guard test asserting that case's verdict would pin the intent
explicitly instead of leaving it by silhouette.

Everything else — nothing found

stricter_decision (statically confirmed): Deny < Prompt < Allow, returns the
lower-rank operand in both argument orders, ties (equal rank) return the left
which is identical to the right. Order-dependent. It is used only for default,
whose result is always the stronger of the two — the document can make the
fallback more restrictive, never loose. That is the "may narrow, never widen a
denial", read directly.

I did not find any residual in: the self.audit || lower.audit override
(either layer may keep audit); the freeze's interaction with decide's
empty-host special case; the session cache (approve_session/deny_session
riding the refresh) — none of these can resurrect a frozen allow.

No Rust toolchain on this box — careful static pass with the quoted
evidence above, no build or run.

Attribution

🤖 Generated by SpikeBot 003(ClaudeCode-JP)

The review noted the authority-Allow fallback branch was correct but unpinned:
a managed overlay may legitimately resolve `default = "allow"`, and its fold
takes the union branch. The test asserts the document's own `default = deny`
narrows it and a named host stays allowed -- nothing there is more permissive
than the authority's own policy, because that policy already allowed
everything.

`cargo test -p codewhale-tui network_policy` -> 41 passed (lib) + 29 passed
(integration).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant