Safety hardening: unsafe docs, async I/O, recursion and read budgets - #2
Closed
AdityaVG13 wants to merge 24 commits into
Closed
AdityaVG13 wants to merge 24 commits into
AdityaVG13 wants to merge 24 commits into
Conversation
…red vocabulary (Hmbown#6290 step 2) The issue's sequencing step 2 — "the remaining pickers" — with `list_nav` as the single movement vocabulary (fleet_detail and provider_picker landed in step 1): - `mode_picker` and `status_picker` drop their hand-rolled up/down handling and gain Home/End and PageUp/PageDown through `list_nav::apply` (Prev/Next wrap; paging and Home/End clamp). `j`/`k` keep working: both are surfaces with no text input, so `motion()` applies. - `session_picker` replaces `move_selection`/`page_selection` with the vocabulary. Shift+PageUp/PageDown still scroll the history preview — claimed before the vocabulary sees the bare keys. - `file_picker` is a typing surface, so it takes the typing-safe set (`motion_while_typing`): arrows, paging and Home/End, never a letter that would be eaten from the query. Paging now clamps (a paging key asks to travel, not to teleport), matching every other surface. - Each surface consumes only the motions it can act on, so the horizontal motions stay unclaimed on these single-column lists. Tests: picker filter "304 passed; 0 failed"; new regression `home_end_and_letter_aliases_come_from_the_shared_vocabulary` on the session picker; clippy (CI's exact invocation, tui lib) clean.
…odel `provider_health_requires_observed_success_and_keeps_failure_reason` recorded its check against the literal "deepseek-v4-pro". Readiness is keyed by the full route identity (provider + endpoint + auth class + model), and the DeepSeek default route has since moved to the Flash line, so the recorded check no longer matched the row and the test failed on main. The test now reads `row.default_route.logical_model` and records against the row's own route, which keeps the intent (observed success flips SavedUnchecked → Ready) without pinning a model literal that the default route can move away from. Evidence: `provider_health_requires_observed_success` => "1 passed; 0 failed"; full picker filter green afterwards.
…d happened to use `/plugin marketplace add <name> <path>` refused an id that already existed, so re-adding the codewhale marketplace to refresh it was impossible once the name was taken — and the workaround is visible in this machine's own state: two snapshots of `codewhale-plugin-marketplace/marketplace.json`, the second one hand-named `cw2`, both stale, sitting side by side. This is the identity half of the grokbuild model (`xai-grok-plugin-marketplace`'s `MarketplaceSource::identity`: the URL or expanded path is the stable identity; the display name is not): - `add` now keys on the canonical source document: re-adding the same source updates that catalog in place and renames it to the requested id when the name differs. A *different* source under a taken name still refuses, so a name never silently re-points. - `load` collapses catalogs that share one source, keeping the entry named after the document itself (else the most recently added). Older states self-heal on the next read; the projection persists on the next write. What this does not do: it does not refresh a catalog when its local document changed on disk — that is the next slice (re-parse on add is a refresh today). And it does not yet split catalog *entry kinds* (plugin vs skill), which is what makes the skill-name suppression deletable. Evidence: marketplace filter "44 passed; 0 failed", including the un-staled command roundtrip (same-source re-add succeeds; different-source clash is refused) and new store tests for rename-in-place, clash refusal, and the two-name collapse; clippy (CI's invocation, tui lib) clean.
…er the plugin pool The plugin suggestion pool mixed three things under one word: plugins, the first-party skills the Codewhale marketplace lists as their own entries (each arriving as a `cw2:*` "plugin"), and imported third-party marketplace plugins. The observed symptom: typing "test" recommended `cw2:test` — the test *skill* — and the mitigation was Hmbown#6274's skill-name suppression, a snapshot-based name check that missed mid-session changes and never applied to the composer toast. This is the structural half of the grokbuild model (`xai-grok-plugin-marketplace`: a catalog entry declares what it is — there via inventory flags on an always-a-plugin entry, here as an explicit `MarketplaceEntryKind` because our catalogs list skills directly): - `MarketplaceCandidate` gains `kind` (Plugin | Skill), defaulted so stored snapshots keep their meaning. The Codewhale parser derives it from the entry's source path (`skills/…`) or an explicit `"kind"` field; the kimi/claude/codex parsers declare Plugin (their formats model plugins only). - Both suggestion pools — `idle_and_catalog_keyword_matches` (toast + `<recommended_plugins>` fragment) and `recommend_plugins_for_task` (`/plugin suggest`) — admit only Plugin-kind entries. - Hmbown#6274's skill-name suppression is deleted along with its Engine-side snapshot plumbing: with kinds split there is no twin to suppress. What this does not do: the matcher keeps its current scoring (grokbuild-style declared keywords/domains with word boundaries is a separate slice), and an already-stored stale catalog snapshot keeps its entries until it is re-added — the source-identity collapse gives it a single catalog. Evidence: marketplace "44 passed", recommend "27 passed" including the new differential test (same entry as Skill is excluded from the pool and produces no fragment; as Plugin it matches), plugin_suggestions "8 passed; 0 failed"; clippy (CI's invocation, tui lib) clean.
… verb lists Founder clarification, 2026-09-09 (docs/design/TUI_DECONSTRUCTION.md): "the harness should let the model decide when a goal, plan, delegation, further investigation, or verification is useful." Operate violated that with three conflicting authorities: `operate_goal_from_prompt` classified instructions with work-verb and chat-opener lists, `CreateGoalTool::description` said goals require an explicit request, and the host promoted a prompt before the model ever ran. The verb list produced exactly the absurdity it invites: "test respond with hello" became a persistent goal because "test" is a verb. - `operate_goal_from_prompt` and its vocabularies are deleted (~8.6 KB with tests). The engine creates a goal only from an explicit user declaration — `/goal` and the natural-language forms `explicit_goal_directive` parses — or when the model calls `create_goal`. No wording is classified. - `create_goal`'s description becomes the one model-facing contract: the model decides when a request is a durable objective, and is told what is not one (a question, a greeting, a one-shot edit, a conversational probe). - The create-refusal report is now always the explicit one: a `/goal` that `GoalState::create` refuses says so instead of being swallowed. - docs/MODES.md (Operate) updated to match; the design doc's "conflicting authorities" note is resolved by this change. Tests updated to the contract, not the old behavior: - `operate_never_promotes_wording_to_a_goal`: ordinary work prompts are ordinary turns in Operate and Work; an explicit declaration still creates the goal. - `operate_contract_is_appended_once_and_an_existing_goal_is_never_replaced` seeds its goal through the explicit declaration. - `operate_model_shell_uses_normal_approval_and_workspace_sandbox` declares its goal explicitly instead of relying on promotion. Evidence: goal filter "148 passed; 0 failed"; operate filter "55 passed; 0 failed"; clippy (CI's invocation, tui lib) clean.
…dit batch 1) From the read-only audit (codewhale-ops/ledger/HOST-DETERMINISM-AUDIT-20260917.md): host rules that classify meaning where the model — or declared data — should decide. This batch deletes the three that were pure subtraction. 1. `tools/workflow_trigger.rs` (405 lines) — seven verb/phrase classifiers (tiny-work phrases, factual-question prefixes, small-talk list, one-file-edit verbs, fan-out language, staged-work language, verification language) that decided auto-Workflow. Dormant in production: its only non-test consumer was the `debug_assert!(soft_auto_policy_is_linked())` in `with_subagent_tools` (Hmbown#4127), whose purpose was to keep the policy *linked*. The prompt carries the policy now; module, assertion, and file are gone. 2. `behavioral_tips::looks_like_planning_prompt` + its "switch to Plan mode" tip — a hand-tuned word list (`plan`, `roadmap`, `strategy`, `outline`, "how should we", "step by step") with a unit test pinning the classifier. The tip variant, the nudge call site, and the detector are deleted; the model already knows when to suggest plan mode. 3. `plugins/matcher::is_specific_term` — the generic-word stoplist (`mcp`, `agent`, `model`, `data`, `code`, …) that made a catalog author's *declared* keywords unmatchable: the same failure mode as the deleted Hmbown#6274 name suppression, one layer down. Matching is now purely declared data with mechanical admissibility (>= 3 chars, no control characters). The code-hosting homepage exclusion stays — a github homepage names where the plugin lives, not what it is — with a comment naming the real fix (catalogs declaring match domains explicitly). Deferred from batch 1, with reasons in the audit file: the subagent status sniffer (Hmbown#9) needs the progress kind threaded from the event payload, and the tool-name noise list (Hmbown#7) needs a declared verbosity field on tool specs. Evidence: matcher "11 passed", recommend "27 passed", plugin_suggestions "8 passed", behavioral_tips "3 passed", tools::workflow "142 passed", tools::registry "56 passed"; clippy (CI's invocation, tui lib) clean.
…assification (determinism audit batch 2, #1) The auto_reasoning keyword classifier (debug/error -> Max, search/lookup -> Low, plus CJK/JP lists) made cost and answer quality depend on vocabulary. auto now resolves the declared High default everywhere: turn loop, route planner, CLI/exec paths, runtime threads, and subagent assignment. The prompt plumbing through the spawn-route functions is deleted with it (-375/+106); select() is the seam a model-declared escalation hint lands in. Tests: auto_reasoning/resolve_auto_effort/cli_auto/model_routing filters 47 passed, 0 failed; tools::subagent 672 passed, 1 failed where the single failure (issue_5305 untethered fail-closed) also fails on clean HEAD and is unrelated to this change. clippy --all-targets clean, fmt clean.
…nt heuristic (determinism audit batch 2, #2) Without the flash classifier the router guessed cheap-vs-big from request wording (COMPLEX_KEYWORDS plus char-length thresholds) — host-side semantic determinism that made cost and quality depend on vocabulary. The fallback is now declared: the configured default model, with the explicit [auto] cost_saving opt-in pinning the runnable fast sibling. Request text is never inspected. The flash-classifier path is untouched. Receipts stay honest: new routes record LocalFallback(DeclaredDefault); content-derived reasons are never constructed again but retained so saved sessions deserialize (plus a serde alias on the renamed wrapper and a test pinning the pre-rework shape). TurnRoutingSource gains auto-local-fallback; the now-unused display_text planner input is deleted with its 4 call sites. Tests: model_routing 32 passed 0 failed; planner/receipt/preview neighbors 44 passed 0 failed; tools::subagent 672 passed with only the known pre-existing 5305 failure (fails on clean HEAD too). Clippy --all-targets, fmt, dead-code budget clean.
…minism audit batch 2, Hmbown#3) The host parsed ten prose phrasings plus a clause allow-list ("make it your /goal to ...") into durable goals before the provider call. Per the founder direction the model decides when a goal is useful: prose asks now reach the model, which calls create_goal; the deterministic user path is the leading /goals <objective> command, unchanged. Same philosophy as 4de9e9e, which deleted the Operate verb-list promotion. Tests: tools::goal 29 passed 0 failed; engine goal suites (rewritten: prose never activates, seeded-goal flows intact) 12 passed 0 failed. Clippy --all-targets, fmt, dead-code budget clean.
…audit batch 2, Hmbown#5) PROACTIVE_MIN_SCORE and RecommendOptions::proactive() had no production callers: the proactive toast and the <recommended_plugins> fragment are driven by the declared-keyword matcher, and the score rubric only ranks the user-invoked /plugin suggest list. The gate gated nothing. Tests now pin the real default options; the description-floor test pinned only the dead gate and goes with it. Tests: plugins::recommend + plugin_suggestions + skills::recommend 20 passed 0 failed; suggest 74 passed 0 failed. Clippy --all-targets, fmt clean.
…age text (determinism audit batch 2, Hmbown#9) The footer rewrote subagent progress by sniffing the literal "requesting model response", against the codebase contract that UI consumers must never recover state by parsing the message. The producer now marks the routine per-step heartbeat with AgentProgressEventMeta.routine_wait; retry/timeout waits share ModelWait status but stay informative, so the status alone could not carry this and the flag is set only at the heartbeat site. The event handler threads it into friendly_subagent_progress and the store decision; is_noisy_subagent_progress is deleted. agent_card keeps its text match deliberately (documented beside it): the mailbox is a stable cross-crate surface that may carry foreign payloads, and no in-crate producer sends routine waits there. Tests: footer progress 2 passed 0 failed (incl. new informative-wait pin); progress suites 30 passed 0 failed; tools::subagent 672 passed with only the known pre-existing 5305 failure. Clippy --all-targets, fmt clean.
…mbown#6283) A 638k-token transcript died because a child read a huge file blind: no size up front, no truncation flag, no line count. The canonical read tool now reports all three on every response (metadata plus size in the truncation footers), so paging is deliberate. Whole-file reads keep their exact footer-free shape; budgets are unchanged (100KB default / 500KB max are deliberate context policy, not the bug). The grep half already existed: grep_files is model-visible, read-only, and in the child surface (pinned by an_explicit_parent_tool_scope...), with File/search_content as the action spelling. Added the grep-then-read flow test to pin it. Tests: tools::file 173 passed 0 failed (incl. 4 new: >10MiB page-one, bounded paging to completion, ordinary-read metadata, grep-then-read); tools::search + compactor neighbor 23 passed 0 failed. Clippy --all-targets, fmt clean.
…Hmbown#6210) Completion path: finish_terminal_result no longer runs the git trio + fingerprints synchronously. ensure_worker_delivery_verified snapshots inputs under a read lock, computes in spawn_blocking, and stores under a follow-up write lock (idempotent via DeliveryEvidence.checked). The natural/panic epilogues ensure before the lock so fan-in completions and the terminal persist stay fresh; Stop/interrupt/close/stale commits heal on the next detail projection. Spawn path: spawn_subagent_from_input fingerprints the baseline pre-lock (assuming write; registration discards it when the resolved spec is read-only, and skips capture when the parent ceiling denies writes). Resume/Fleet/test paths keep the inline capture. Tests: deferred-semantics updates plus pending/ensure-idempotence/ no-op/heal/adopt/discard/fallback coverage.
Agent Mail is the durable submit-while-running queue: nothing ever
produces a Queued turn record (POST /turns rejects a busy thread), so
the executable slice is withdrawing a queued envelope, not turn routes.
- protocol: AgentMailStatus::Canceled + agent_mail.canceled event with
validation (no delivery fields, like queued).
- runtime_threads: cancel_agent_mail mirrors mark_agent_mail_read
(ownership gate, mail_mutation lock); Queued -> Canceled, re-cancel
idempotent, post-delivery states an explicit conflict. Deliver treats
Canceled as terminal (envelope back, no turn); the wake pump's
Queued-only match skips it structurally.
- runtime_api: POST /v1/threads/{id}/agent-mail/{message_id}/cancel;
cancel-after-delivery maps to 409. Unknown message ids now map to 404
via a typed io::NotFound chain check (also repairs deliver/read/mark
of unknown ids, which fell through to 400).
Tests: cancel route test (list/cancel/re-cancel/deliver-after-cancel/
409/403/404), 409 mapping unit test, protocol validation test.
…eet views (Hmbown#6290) Steps 2+3: model picker, slash menu, fleet list/roster/setup now resolve movement through list_nav instead of per-surface key tables. list_nav.rs itself untouched. - model_picker: two-pane apply_motion (typing-safe set for the live filter); pages keep the 5-row distance but clamp instead of wrapping. - slash_menu: selection helpers collapse onto move_slash_menu_selection; PageUp/PageDown navigate the popup (previously scrolled the transcript); Home/End deliberately stay cursor keys since the composer is still the focused input. - fleet_list: full axis incl. j/k, new paging; single-column. - fleet_roster: bare keys drive rows, Shift+PageUp/Down/Home keep detail scroll (Hmbown#6014-style split); region declined so Tab keeps opening workers. - fleet_setup: choice steps page/clamp; Review step keeps scroll on the same keys (no row list there); region declined so Tab/Left/Right keep wizard arms; filter mode gains paging. Tests: roster bare/shift paging split, slash paging clamp; updated the roster detail-scroll test to the Shift+ chord. Note: the mention menu still clamps Up/Down (slash wraps) — left for a follow-up UX call.
…ng (Hmbown#6211 R7b) Each fleet SSE viewer reopened the manager and replayed from disk every 250ms. Ledger managers open per operation, so a process-wide registry joins them by canonicalized ledger path with one Notify each; the append funnel notifies after fsync, and streams wait on wake-or-5s- fallback instead of sleeping. The cursor stays the source of truth, so a missed/spurious wake costs one extra poll, never lost events. All appends are in-process (the executor maps worker stdout to ledger records), so wakes cover every writer; the fallback heals missed wakes, future out-of-process writers, and compaction (which replaces history rather than appending). Tests: append-wakes-subscriber + per-file scoping. Note: fleet suite has one pre-existing red (resolved_config_mints_secret_free_fleet_route_ snapshot expects chat_completions, gets responses) failing identically on clean HEAD, unrelated to this slice.
…d of failing closed (Hmbown#6285) AC3: plan_pr_review no longer errors when a file fits no pass or passes exceed max_passes. Oversized files are skipped whole (never truncated) and passes beyond the budget are cut in diff order; the plan reviews what fits. Only a plan covering nothing still errors, and it names every skipped file. AC4: the manifest carries skipped_files (file, reason, budgeted chars) into prompts, receipts, and tool metadata. finish() says 'Partial review coverage' with the skip list instead of claiming completeness, pass prompts tell the model the review is partial, the budget-stop note names plan-time skips, and --check-receipt fails on partial coverage with the skip list. Gate decision: the partial review is printed and posted, but the CLI still exits non-zero naming the skips and the remedy — the workflow gates on the exit code, so exit 0 on unread files would silently turn the red gate green. The failure reads as limits, not as a verdict.
Hmbown#6213 T7) convert_anthropic_sse_data built a Value DOM per token and converted it with from_value. The per-token path now deserializes directly into the tagged StreamEvent; a borrow-only tag peek routes the two usage-bearing events (message_start/message_delta) to the exact legacy path, since their wire-to-normalized usage mapping reads fields the normalized Usage cannot represent. Decode failures keep their legacy outcomes (invalid SSE JSON vs unrecognized event vs tolerated-unknown), and the local-only tool_projection_warning still never decodes from SSE. This closes the last hot T7 item. The T7 chat.rs sub-item is declined: inspect_prompt_for_request runs only on explicit cache-warmup calls and the /debug cache command — not a hot path — and the catalog string is load-bearing layer content, not just hash input.
… executor (Hmbown#6211 R4/R7a) R4: Drop for Connection grace-waited up to 500ms on the dropping thread (manager reload, pool rebuild). Drops now hand the child to one shared reaper thread; the grace/kill/wait sequence is unchanged, only relocated, with inline reaping as the degenerate fallback if the reaper is gone. The Mutex half stays: request() rendezvouses on a shared responses channel and drops non-matching ids, so serialization is load-bearing for correctness until an in-flight dispatch map exists (the issue's own longer-term item; sequenced with the Hmbown#6142 stack reconciliation). R7a: the per-connection 50ms authority watch ran synchronous state fs on the executor. The check now runs under spawn_blocking with the 50ms revocation cadence unchanged. The watch stays per-connection by design: it covers the pre-insertion connect window, and a pool-level task would need the pool lock — held across in-flight calls — regressing the mid-call trip this exists for.
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.
AdityaVG13
force-pushed
the
fix/safety-hardening
branch
from
September 18, 2026 06:04
017b402 to
780e214
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Safety hardening: unsafe docs, async I/O, recursion and read budgets
Summary
This PR fixes the first wave of findings from an internal audit scan of
main(
c042ea2de). It documents every undocumentedunsafeblock with a SAFETYcontract, replaces blocking file calls in async code with
tokio::fs, boundsrecursion in value walkers, records the lock-poison policy as fail-stop, and
caps unbounded file and stdin reads. No public APIs change and no new
dependencies are added.
Changes by commit
47a3324docs(unsafe)unsafeblocks; adds aset_tui_envhelper so TUI env mutation has one reviewed site41f0c0efix(async)std::fscalls in async code withtokio::fsequivalents3bf7eb4fix(resource)6cb5d41docs(policy)docs/ARCHITECTURE.md017b402fix(resource)Diff: 41 files, +556/-212 across
crates/tui,crates/cli,crates/config,crates/app-server, anddocs/ARCHITECTURE.md.Audit findings before and after
Rescanned with the same analyzer build on clean worktrees of base and branch
(896 groups at base, 852 on this branch). Counts below are finding groups.
unsafeblocksThe 18 cleared
unsafegroups were reclassified to documented-unsafeinventory (info severity, no action required), which confirms the SAFETY
contracts registered. Remaining added groups are the same sites re-identified
at shifted line numbers after the edits. Net new actionable findings: 0.
Verification
ferrum check -p codewhale-app-server -p codewhale-cli -p codewhale-config -p codewhale-tuicargo check --all-targetson remote workercargo test -p codewhale-clicargo test -p codewhale-config-p codewhale-tuirunsatlas scanplusatlas compareon clean worktreesNo performance benchmarks were run: this change makes no performance claims.
The quantitative evidence is the finding counts and gate results above. The
edits are comments and docs, async-equivalent I/O calls, per-level depth
counters, and read adapters; none touch a hot path.
Notes for reviewers
lock()handling, so the audit rule keeps firing there. Converting call sites is a
separate follow-up.
.expect()calls in new depth-limit tests only. No newpanics in production code.
unmodified
main; proven pre-existing and out of scope for this PR.c042ea2de, which is behind currentmain. Itshould be rebased before going upstream.