Skip to content

Safety hardening: unsafe docs, async I/O, recursion and read budgets - #6333

Merged
Hmbown merged 11 commits into
Hmbown:mainfrom
AdityaVG13:fix/safety-hardening
Sep 18, 2026
Merged

Hmbown merged 11 commits into
Hmbown:mainfrom
AdityaVG13:fix/safety-hardening

Conversation

@AdityaVG13

@AdityaVG13 AdityaVG13 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Safety hardening across four areas, rebased on current main (531cddb56).
Every undocumented unsafe block gets a SAFETY contract, blocking file calls
in async code move to tokio::fs (or upstream's spawn_blocking convention
where it already applied), recursive value walkers get depth fuel, the
lock-poison policy is recorded as fail-stop, and unbounded file and stdin
reads get budgets. No public APIs change and no new dependencies are added.

Commit Area What it does
docs(unsafe) TUI, CLI, config SAFETY contracts on all undocumented unsafe blocks; one documented set_tui_env helper for TUI env mutation
fix(async) TUI tokio::fs for blocking calls in async code; defers to upstream spawn_blocking refactors where present
fix(resource) CLI, config, secrets, TUI Depth fuel (64/128) on TOML/JSON/canonicalizer walkers, fail closed past the cap
docs(policy) docs Lock-poison posture recorded as fail-stop in docs/ARCHITECTURE.md
fix(resource) CLI, config, TUI Read budgets via take(limit+1) plus check: 1 MiB config/state, 16 MiB sub-agent state and stdin patches, 8 KiB API-key stdin

Audit rescan on clean worktrees of base and branch, same analyzer build:

Family Base Branch Delta
Correctness 473 473 0
Lifecycle 22 22 0
Maintainability 486 486 0
Performance 18 5 -13
Resilience 44 31 -13
Safety 73 67 -6
Total 1116 1084 -32
Rule cleared Groups removed
Undocumented unsafe blocks 16
Blocking calls in async code 13
Unbudgeted reads 9
Unbounded recursion 4

Remaining deltas are documented-unsafe inventory (info severity, no action)
and same-site re-identifications at shifted line numbers. Net new actionable
findings: 0.

Notes:

  • Upstream fixed part of the async surface independently (blocking-call
    convention); this branch keeps its conversions only where upstream has none.
  • The JSON redactor bound was ported to codewhale-secrets after upstream
    moved the redactor there verbatim and unbounded.
  • The lock-poison commit is docs only; converting call sites is a follow-up.

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features --locked (warning-free under the CI allow list)
  • cargo test --workspace --all-features --locked

Gates run so far (workspace clippy and test left for CI):

Gate Command Result
Format cargo fmt --all -- --check Pass
Type check, touched crates ferrum check -p codewhale-cli -p codewhale-config -p codewhale-secrets -p codewhale-tui Pass, 1m54s
Clippy, touched crates ferrum clippy on the same four crates, --all-targets Only pre-existing arity lints in untouched files; none in this diff
Config redactor tests ferrum test -p codewhale-config --lib redact_json 2 passed, 0 failed
CLI bundle tests ferrum test -p codewhale-cli --lib config_bundles 56 passed, 0 failed
TUI canonicalizer test ferrum test -p codewhale-tui --lib pathological_nesting 1 passed, 0 failed

No performance benchmarks were run: this change makes no performance claims.
The quantitative evidence is the finding counts and gate results above.

Checklist

  • This PR adds a new layer/module/abstraction: it names or deletes the layer it replaces (N/A: no new layer; one private helper and one documented helper only)
  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes (N/A: no UI changes)
  • Harvested/co-authored credit uses a GitHub numeric noreply address (N/A: no harvested or co-authored credit)

No-Issue: proactive hardening sweep; no tracking issue was filed for this work.

Atlas UNSAFE-001: every production unsafe block now carries a terse
justification at the site. The 16 bare set_var blocks in apply_tui_env
are centralized into one documented set_tui_env helper; all other
changes are comment-only.
Atlas ASYNC-002: convert std fs calls lexically inside async fns to
their tokio::fs equivalents (read/write/rename/metadata/remove_file/
create_dir_all/OpenOptions/try_exists). Coherent same-function twins
converted too; search.rs:215 falsified (already behind spawn_blocking
via run_blocking_grep). Sync helpers shared with sync callers
(write_atomic*, streaming readers, staging) intentionally untouched.
Atlas RESOURCE-002: export walkers (TOML, cap 64), JSON redactor and
approval canonicalizer (cap 128, serde-parse-aligned) now carry depth
fuel and fail closed past it. canonicalize_json_keys proven bounded
(sole input is McpConfig-shaped, no Value fields) — no change. Adds
deep-input tests per walker.
Atlas PANIC-002 is one policy decision, not 21 patches. Production
locks already fail-stop with lock-naming messages (user registry,
session index, coordination slot); 15 of 21 findings are test-support
code. Document the rule: expect by default, into_inner only where
stale state is safe.
Atlas RESOURCE-001: take(limit+1)+check pattern mirroring the
credential store's existing limit. Config/state readers capped at
1 MiB, sub-agent state and stdin patches at 16 MiB, API-key stdin at
8 KiB; worker-log drain capped per call with a pending-line bound.
The 5 oauth findings already flow through the budgeted store reader.

@Hmbown Hmbown left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — I read the full diff and verified the load-bearing claims. Most of it holds up well; one substantive issue on the file-read path, plus nits.

Verified

  • set_tui_env SAFETY contract checks out: the telemetry actor thread exists (crates/telemetry/src/actor.rs:81) and production telemetry code never touches the process environment (only std::env::consts::OS/ARCH, which are compile-time constants). All six call sites run pre-runtime. Consolidating 15 undocumented blocks into one documented helper is a strict improvement.
  • take(limit+1) + length-check pattern is correct everywhere, and the 128 depth caps match serde_json's own recursion limit, so parsed input never truncates. New depth tests (config_bundles, approval_cache, persistence) cover the fail-closed behavior.
  • CI triage: Version drift, Integrations, frontend Lint & Type Check, and Test (windows-latest) all fail identically on the base commit itself (main run 35298989096 @ 531cddb56, your exact base). None of those four are owned by this PR. Integrations and frontend lint touch only node suites this PR never modifies.

Required: the file-read path still blocks the runtime

In crates/tui/src/tools/file.rs, the conversion is open-only:

  • read_window_streaming still takes std::fs::File and runs its whole read-to-EOF loop inline; the PR just adds an async open plus .into_std().await to feed the same blocking loop.
  • hash_file_streaming (a second full-file blocking pass over up to large files) is untouched and still called inline from async execute.

So the two heaviest blocking operations on the hottest tool path remain on the runtime, and the analyzer-count drop here reflects moved call sites rather than moved work. Per the blocking-call convention (#6149, which this PR already cites), please wrap the open+stream+hash section in spawn_blocking at the async call site (keeping the sync open), rather than the async-open-then-into_std round trip. Same question for fim.rs/git_history.rs/tool_result_retrieval.rs only if their reads are large-file-capable — single small reads via tokio::fs are fine as-is.

Nits (non-blocking)

  • set_tui_env docs say callers must be on the main thread, but the existing tests call apply_tui_env from test threads (serialized via env_lock). Consider wording the contract as what it actually is: no concurrent environment access, with tests serializing on the lock.
  • Fleet drain (fleet/executor.rs): pending.clear() on a >1 MiB newline-free flood silently drops bytes. A debug log or counter would keep that observable.
  • Canonicalizer maxdepth marker: values differing only past depth 128 now share an approval key. Contrived to exploit and strictly better than the old stack overflow, so not a blocker — noting it for the record.

Before merge

  • Address the file.rs item above.
  • Ubuntu/macos Test, Lint, and Safety gate were still in progress at review time; those need to go green (or be shown pre-existing like the four above).
  • The "PR closes an issue" check wants a linked issue — please add Closes #… if one exists for this hardening work.

The file.rs conversion was open-only: read_window_streaming ran its
full read-to-EOF loop inline and hash_file_streaming made a second
blocking pass from async execute. Wrap open+stream+hash in
spawn_blocking at the call site with the sync open, per the
blocking-call convention (Hmbown#6149).

Also from the same review: the set_tui_env contract now states no
concurrent environment access (tests serialize on the env lock), and
the fleet drain logs dropped bytes instead of clearing silently.
@AdityaVG13

Copy link
Copy Markdown
Contributor Author

Note: the cross-reference on this PR's timeline to a pull request on my fork was accidental. It came from an internal review pointer that has since been deleted; GitHub does not retract timeline cross-references, so the stub remains visible. It points to a closed fork PR and has no bearing on this change.

For the link check: no open issue tracks this hardening work (the blocking-call audit #6149 is closed), so the PR body carries the documented No-Issue: opt-out instead of a closing keyword. The check is green.

@AdityaVG13

Copy link
Copy Markdown
Contributor Author

Review feedback addressed in 34c222c (pushed):

file.rs (required): done as prescribed. Open+stream+hash now run in one spawn_blocking section with the sync open; the async-open-then-into_std round trip is gone. Error shapes and the small-file fast path are unchanged. All 142 file-tool tests pass.

Sibling reads: checked all three, no changes needed.

  • git_history: metadata syscall only, not a content read.
  • tool_result_retrieval: reads the bounded spillover store (write-side capped, output clamped to 128 KiB) via tokio::fs.
  • fim: reads arbitrary files fully, but via tokio::fs (already pool-dispatched, nothing sync left on the worker), and anchor search inherently needs full contents. No size cap exists; adding one would be a behavior change beyond this PR.

Nits: set_tui_env contract reworded to no-concurrent-access (tests serialize on env_lock, verified at the call sites); fleet drain logs dropped bytes with a count. Canonicalizer depth-key note acknowledged, no action taken.

Ubuntu Test failures (6): shown pre-existing, not from this PR. Each of the six fails identically on clean main (531cddb56) and on this branch in isolated single-test runs, with byte-identical assertion signatures (e.g. responses vs chat_completions at worker_runtime.rs:2289 in both). Same code-independent, environment-sensitive class as the four already triaged; the fresh CI run from this push will confirm.

@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Ubuntu triage (following up on my review): the run shows 6 failures, all outside this PR's behavioral surface, and with a shared signature pointing at live-catalog drift rather than the diff:

  • pricing::...shipped_default_routes_have_reviewed_pricing_coverage: 49 vs 48 routes
  • auto_dispatch_keeps_last_and_pending_receipts_aligned: GLM-5.3 vs GLM-5.3-Flash
  • resolved_config_mints_secret_free_fleet_route_snapshot: responses vs chat_completions
  • config_panel_golden...: settings count 70 vs 71
  • issue_5305_untethered_runtime_fails_closed_before_admission: untethered launch unexpectedly succeeded with a deepseek route
  • mouse_selection_autocopies_on_release_without_ctrl_c

SAFETY comments, take() read budgets, depth caps, and open-only tokio conversions cannot add a model to a catalog, grow a settings panel, or mint a default route. The base run (02:21 UTC) predates this run (06:04 UTC) by ~3.7h, so an upstream catalog change in between fits all six better than the diff does.

I re-ran the failed jobs to test that: if Ubuntu goes green, these were flakes/drift-that-settled and unrelated to the PR. If they fail identically, the next step is re-running the same tests on base-as-of-now to confirm the drift, and base — not this PR — owns the re-baseline. Either way, my requested change (the file.rs spawn_blocking item) still stands.

Follow-up to the file.rs review item: the same audit traced up and
down the file-tool tree and found three more instances of the same
bug class, all fixed here per the blocking-call convention (Hmbown#6149).

- Write/edit paths called sync write_atomic_workspace inline: temp
  create plus fsync plus rename (and a thread::sleep retry loop on
  Windows). All four call sites now go through one spawn_blocking
  helper with identical error shapes.
- PDF detection ran a sync open plus magic sniff on every read.
  is_pdf is now async over tokio::fs; its three tests moved to
  tokio::test with the same assertions.
- OCR shelled out to tesseract synchronously from two async execute
  paths (File read and ImageOcrTool). Both call sites now wrap the
  whole synchronous call in spawn_blocking.

Audited and deliberately left alone: note_file_read (one stat
syscall, ten call sites), the canonicalize credential guard (fast
path syscalls), list_dir (already pooled), and the PDF extractor
(already on tokio::process).
@AdityaVG13

Copy link
Copy Markdown
Contributor Author

Follow-up to the file.rs item: I traced the file-tool tree up and down for the same bug class and found three more instances, fixed in 624dd098c (pushed):

  • Write/edit paths called sync write_atomic_workspace inline (temp create + fsync + rename, plus a thread::sleep retry loop on Windows). All four call sites now go through one spawn_blocking helper with identical error shapes.
  • PDF detection ran a sync open + magic sniff on every read. is_pdf is now async over tokio::fs; its three tests moved to tokio::test with unchanged assertions.
  • OCR shelled out to tesseract synchronously from both async execute paths (File read and ImageOcrTool). Both now wrap the call in spawn_blocking.

Audited and deliberately left alone: note_file_read (one stat, ten call sites), the canonicalize credential guard (fast syscalls), list_dir (already pooled), PDF extraction (already on tokio::process).

Verification: fmt clean, ferrum check green, file suite 142 passed, image_ocr 3 passed, pdf 5 passed, no new clippy lints in the touched files.

@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Maintainer pass on the red CI: the ubuntu failures I sampled are base drift, not this PR's code — shipped_default_routes_have_reviewed_pricing_coverage (49 vs 48: ModelScope/ZenMux routes landed on main after this branch) and resolved_config_mints_secret_free_fleet_route_snapshot (responses-vs-chat_completions route drift). I've merged current main into the branch so the rerun tests your changes against today's tree. The founder's requested changes still stand — those are yours to address; this comment covers only the CI redness.

@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Maintainer CI update: root-caused one of the ubuntu reds to base, not this PR — config_panel_golden_at_eighty_and_one_twenty has been failing on main since f8d00a31ff removed the provider_templates row (#6289) without re-blessing. Re-blessed in 8062487b2 (diff is exactly (17/71)→(17/70) in both sizes, matching the approved row removal) and merged current main into this branch, so the rerun tests against a green golden. The other four triaged failures (pricing coverage, auto-dispatch receipts, fleet route snapshot, mouse autocopy) already pass on current main — that drift settled. Lint & Type Check (web.yml) also fails on main itself, so it's base-owned too. The founder's requested code changes still stand — this covers only the CI redness. Thanks @AdityaVG13 for the spawn_blocking follow-ups.

@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

CI triage on the rerun (macos green, 44m): the two reds both look environmental. Windows failed in 2m before running any test — NSIS could not be provisioned from Chocolatey, the known feed flake. Ubuntu ran the full 15,982-test suite with exactly 2 failures, both timing-sensitive: codewhale-mcp ... idle_child_requests_are_answered_before_the_next_client_request (MCP-crate IPC timing; this PR touches no crates/mcp file) and the plugin PTY e2e (trust receipt ... not visible within 80s with the TUI alive — a loaded-runner timeout signature). I reproduced the PR's touched-area suites locally (file/shell/hooks/cli: all green) and reran the failed jobs. If the rerun is green, these were flakes; the remaining reds (version-drift, web lint) are just this branch predating today's main fixes — I'll merge main in after the rerun lands so the final run tests everything at once.

codewhale-maint and others added 2 commits September 18, 2026 12:47
The not(unix) read_string_no_follow variant gained a .take() budget
but std::io::Read stayed cfg(unix)-gated: E0599 on Windows, zero tests
ran. Read is now used on all platforms.
@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Rerun verdict + fixes pushed. (1) Windows: real PR bug, fixed. Both runs died at compile with E0599: std::fs::File is not an iterator at crates/config/src/lib.rs:6876 — the PR's bounded read added .take() to the not(unix) variant while use std::io::Read stayed cfg(unix)-gated (main's variant is a one-line read_to_string, which is why main stays green). One-line fix pushed to the branch (ungate the import; it is now used on all platforms), unix cargo check + fmt clean. I swept the PR for sibling instances: no other new cfg-gated code, and the cli .take() sits in shared code that already builds everywhere. (2) Ubuntu MCP test: confirmed flake — passed on rerun. (3) Ubuntu plugin PTY e2e: failed twice identically (trust receipt timeout, TUI alive). I read every PR hunk in the trust path (plugins/install, skills/install, registry, marketplace store): all mechanical sync→async conversions or read budgets that are behavior-neutral for small files — nothing that can stall a receipt. Treating as a loaded-runner timing flake pending the third run; if it fails identically again I'll dig deeper. Merged latest main (web + changelog-sync fixes) so this run tests everything at once. @AdityaVG13 no action needed from you on CI right now.

@Hmbown

Hmbown commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Merge judgment (written into the record before merging past red legs)

Founder approved at 19:53 UTC. Final run 8ddedba: Lint pass, Safety gate pass, web pass, version drift pass, macos 15991/15991 pass (the job then timed out compiling doctests — infra, zero test failures). Three red rows remain, all proven outside this PR's 35 files by full-diff review plus independent reproduction:

  • Windows run_tests_cwd_scopes_cargo_to_subdir — verbatim-prefix path assertion in test_runner.rs, a file this PR never touches, and the PR changes nothing in the resolve_existing_dir path it exercises. Main passed it twice in the same window. Filed as test(tui): run_tests_cwd_scopes_cargo_to_subdir fails on Windows verbatim path prefix #6346 (test-only fix, base-owned).
  • Ubuntu MCP idle_child_requests — timing-sensitive IPC test in crates/mcp, untouched by this PR; passed on the rerun run. Flake.
  • Ubuntu plugin PTY e2e (3x) — the one I chased hardest. Every PR hunk in the trust path is a mechanical sync-to-async conversion, a SAFETY comment, or a small-file-neutral read budget; nothing can stall a receipt. Decisive experiment: the exact test passes on this PR's code locally in 4.67s and passed on macos CI in 9.4s on this same commit, while ubuntu fails it with an 80s wall timeout and a live-but-blank TUI — the loaded-runner signature. Environmental.
  • (Earlier real PR bug, the Windows E0599 from the cfg-gated Read import, was fixed on this branch before this run.)

Per the merge gate, that judgment is now in the artifact. Merging as itself with admin (ruleset requires the Windows leg, which is red on #6346's row, not this PR's). Thanks @AdityaVG13 — 35 files of hardening, responsive review follow-through, and the spawn_blocking work the review asked for.

(Reposted: the first attempt lost its code spans to shell interpolation.)

@Hmbown
Hmbown merged commit 13d08f6 into Hmbown:main Sep 18, 2026
21 of 24 checks passed
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.

2 participants