Skip to content

Edit safety: parse-gate file edits before the write lands - #6238

Merged
Hmbown merged 4 commits into
mainfrom
feat/edit-safety-batch-c-20260915
Sep 15, 2026
Merged

Hmbown merged 4 commits into
mainfrom
feat/edit-safety-batch-c-20260915

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Three issues, one gate seam in the tools edit path.

Closes

The contract, which the issues did not specify

An edit is refused only when the file parsed before and would not parse after. Pre-existing breakage and new-file creation fail open. Repairing a broken file is the commonest reason to edit source at all, and a gate that blocked it would be worse than no gate.

The check runs on post-edit content immediately before the write, at every write site: file.rs (write, edit, and both small-contract twins), apply_patch.rs (once over all pending writes, ahead of the first, so a transactional patch cannot half-apply), and fim.rs. Because it precedes the write, a rejection leaves the file untouched and there is no rollback path — which also answers #6206's item 3.

.jsonc / .json5 / .jsonl are deliberately not treated as JSON. A .json file that is really JSONC does not parse before the edit either, so the before/after rule skips it rather than making it uneditable.

Dependencies: reuse, not addition

syn 3.0.3 was already in the lockfile as a proc-macro build dependency, so the same version is reused per #6151Cargo.lock gains exactly two lines under codewhale-tui and no new package version. proc-macro2 gains span-locations, which is what makes line:column available at all; the parse feature is inert without it. TOML and JSON go through toml_edit::DocumentMut and serde_json, both already codewhale-tui dependencies: no new dependency, no lockfile change.

#6205 uses rustfmt, not prettyplease

cargo fmt --check is this repo's real gate. A second formatter would produce files that pass the edit path and then fail CI. prettyplease also pins syn 2.x, which would duplicate the 3.0.3 just adopted.

Policy: normalize the whole file only when it was already rustfmt-clean before the edit, so hand-formatted files are never rewritten. A clean file is a fixpoint, so "whole file" and "edited region" coincide. Every failure path skips and lets the edit land. fim_edit takes the gate but not the normalization — its result reports byte offsets that reformatting would invalidate.

Evidence

Every regression test was confirmed failing without the fix (gate stubbed to Ok(()), format arms removed, normalize_edit stubbed to None), then restored.

Re-verified independently on this head:

cargo fmt --all -- --check                                   clean
cargo clippy --workspace --all-targets --all-features
  --locked  (CI's exact allow list)                          clean
test result: ok. 141 passed; 0 failed   (syntax_check + rust_format + file::tests)
check-dead-code-budget.py                                    PASS, 254 at budget

One bug worth recording, because it stayed green while being broken: --config-path initially pointed at a directory, and rustfmt exits 1 with "unable to find a config file" when no rustfmt.toml is there. A skip-if-rustfmt-missing branch swallowed that, silently disabling normalization everywhere while the suite passed. Found by re-running with --nocapture. The config path is now only passed when a file is actually found by walking up, and the formatting tests assert rustfmt is present rather than skipping.

Pre-existing, not from this branch

check-blocking-calls-budget.py fails on crates/tui/src/runtime_api/workspace.rs (verified byte-identical to origin/main). That is main's current red; it is fixed in #6229.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 15, 2026 21:48
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 2m 40s —— View job


Review: Edit safety — parse-gate file edits before the write lands

Todo list

  • Gather context (PR diff, CLAUDE.md/AGENTS.md rules)
  • Read syntax_check.rs (new, Rust syn gate)
  • Read rust_format.rs (new, rustfmt normalization)
  • Read file.rs write/edit gate integration
  • Read apply_patch.rs transactional gate integration
  • Read fim.rs gate integration
  • Check tests in file/tests/tools.rs
  • Check provider/model safety implications (not applicable — no routing code touched)
  • Post review summary

Summary

Read the diff against origin/main (no build/clippy run in this session). The design is sound and carefully argued: the fail-open contract (never block repair of a pre-existing break, never gate file creation) is implemented correctly and matches the stated policy at every call site I checked. Good things worth calling out explicitly:

  • apply_pending_writes (crates/tui/src/tools/apply_patch.rs:1314-1323) runs guard_edit over every pending entry before the first write, correctly delivering the "transactional patch never half-applies" guarantee claimed in the PR body.
  • rust_format.rs uses tokio::process::Command with a FORMAT_TIMEOUT + kill_on_drop(true), matching AGENTS.md's blocking-call convention — no std::process on the async path.
  • The --config-path bug (pointing at a directory instead of a discovered file) is a real catch, and the fix (nearest_config walks up to a file, tests assert rustfmt is present rather than skipping) is the right response to a gate that silently no-ops.
  • guard_edit's "before must have parsed, after must not" logic (crates/tui/src/tools/syntax_check.rs:134-157) and normalize_edit's "clean-before ⇒ only edited region changes" argument (crates/tui/src/tools/rust_format.rs:59-87) are both correct and covered by tests that fail without the fix per the PR's stated evidence process.

No correctness bugs or provider/model-routing concerns found (this PR doesn't touch routing at all). Two lower-severity notes below.

1. Test coverage gap: two of the four write sites are ungated by any new test

crates/tui/src/tools/file.rs wires guard_edit/normalize_edit into four places: WriteFileTool::execute_contract_write (~L1449), WriteFileTool's ToolSpec::execute (~L1579), EditFileTool::execute_contract_edits (~L2032), and EditFileTool's ToolSpec::execute (~L2276) — plus FimEditTool in fim.rs:169. Only the EditFileTool paths and ApplyPatchTool got dedicated regression tests (crates/tui/src/tools/file/tests/tools.rs, apply_patch.rs tests). Neither WriteFileTool variant nor FimEditTool has a test that would catch a wiring regression (e.g. swapped before/after arguments, or a dropped guard_edit call) at those specific call sites — the underlying guard_edit/normalize_edit functions are well unit-tested in isolation, but the integration is not exercised for write/fim_edit.

Fix this →

2. Minor duplication across the four gate call sites

The guard_edit(...)? then if ... let Some(normalized) = normalize_edit(...).await { ... } pattern is repeated near-verbatim at all four file.rs call sites listed above (only the before expression and whether a "normalized" bool is threaded through differ). Per the repo's ponytail rule ("an abstraction must delete caller code"), a small shared helper — e.g. async fn gate_and_normalize(path, path_str, before: Option<&str>, after: &str) -> Result<(String, bool), ToolError> — would collapse all four sites and remove the risk of one of them drifting out of sync with the other three (which is exactly the failure mode note 1 is about). Not a blocker; flagging since it would directly reduce the coverage gap's blast radius.


Nothing else stood out. Dependency reuse (syn 3.0.3 already in the lockfile, proc-macro2/span-locations addition, no toml/serde_json version bump) matches the PR's stated Cargo.lock diff (+2 lines, no new package versions) — confirmed directly against the diff.
· branch feat/edit-safety-batch-c-20260915

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codewhale review

Complete review coverage: 1/1 passes, 12 file patches, sha256:ccc9407614addccb83dcf4b486e0fbe887d2fd41b63abcb79846e5e72ff0a9c2.

Pass 1: The PR adds a post-edit parse gate (syn/toml_edit/serde_json) plus a rustfmt-based normalization step, wired into four sites in file.rs, one shared site in apply_patch.rs, and fim.rs, with two new modules and regression tests. The design is sound and the gate placement (ahead of the first write, before any rollback would be needed) is correct; the before/after fail-open rule is implemented faithfully and the parser choices are defensible. The gaps are mainly evidential: the transactional claim is only tested with one file, the write_file sites and the apply_patch replace path have no test coverage at all, and the --config-path branch the author describes as previously breaking silently is still never exercised. One real contract violation exists: guard_edit cannot distinguish "parsed before" from "declined to check because the file is over MAX_CHECKED_BYTES".

Findings

  • [WARNING] apply_patch's transactional claim is asserted but only tested with a single file (crates/tui/src/tools/apply_patch.rs:1314)
    The guard loop in apply_pending_writes is deliberately placed ahead of the write loop so that "one unparseable result must leave every file in the patch untouched rather than half-applied", and the CHANGELOG repeats that apply_patch "cannot half-apply". The only test (patch_refuses_a_hunk_that_breaks_rust_syntax) uses a single file, so it cannot distinguish "guard before the first write" from "per-file guard just before each write" — the two behave identically for one file, and only the second violates the stated contract. A two-file patch whose second file breaks Rust syntax, asserting the first file's bytes are unchanged on disk, is what actually pins the placement; it would also fail today if anyone later moves the loop into the write loop.
  • [WARNING] Both write_file sites gained the gate and normalization with no tests; the apply_patch replace path is untested too (crates/tui/src/tools/file.rs:1449)
    WriteFileTool's small-contract path and its ToolSpec twin are the two sites most likely to be hit by an agent that rewrites a whole file, and neither has a test asserting that (a) a write that breaks a previously-parseable .rs/.toml/.json file is refused with the file untouched, or (b) a full-file write into an already-rustfmt-clean file is normalized. Likewise build_pending_writes_from_replace now feeds normalize_pending_rust and the same guard as the patch path, but every new test exercises file_patches only. Making it worse, the new-file fail-open leg (the contract's other half, "creating a new file is ungated") is only covered at the guard_edit unit level, never through a tool — so a change that started passing Some("") instead of None for created files would silently start gating and rustfmt-normalizing every brand-new Rust file with the suite still green.
  • [WARNING] The rustfmt --config-path branch — the exact failure the PR documents — is still unexercised by any test (crates/tui/src/tools/rust_format.rs:120)
    nearest_config is the branch whose previous form (a directory instead of a config file) made rustfmt exit 1 with "unable to find a config file" and silently disabled normalization everywhere while the suite stayed green. Every new test passes a path with no rustfmt.toml/.rustfmt.toml in an ancestor — rust_format::tests uses the relative src/lib.rs, and the tool tests use fresh tempdirs — so nearest_config returns None in all of them and --config-path is never passed. That leaves the known-broken path untested and also means nothing notices a rustfmt.toml whose contents make rustfmt exit non-zero (e.g. an unstable option under a stable toolchain): normalization would be silently off repo-wide, which is precisely the hazard the tests were supposed to close. A test that writes a rustfmt.toml into a tempdir and asserts the edit is still normalized (and that a bogus sibling config does not disable it) would cover both.
  • [WARNING] guard_edit treats an unchecked before (over MAX_CHECKED_BYTES) as if it had parsed (crates/tui/src/tools/syntax_check.rs:149)
    syntax_check returns None for two different situations: "this content parses" and "I declined to look (unsupported extension or source.len() > MAX_CHECKED_BYTES)". guard_edit uses syntax_check(path, before).is_some() as the verdict for "the file was broken before", so a pre-edit file larger than 2 MiB is treated as parseable. Concretely, an edit that shrinks a >2 MiB broken .rs file to under 2 MiB and leaves it broken is refused with "it would leave X unparseable" — the file never parsed before as far as we know, and the documented contract says the gate exists to catch the edit that introduces breakage, never to strand a model repairing one. The size check is the only asymmetric input between the two calls (the extension is identical), so an explicit pre-edit size comparison restores the contract.
  • [INFO] write_file normalizes silently, unlike the edit path (crates/tui/src/tools/file.rs:1455)
    EditFileTool appends NORMALIZED_NOTE so the model learns that the bytes on disk are not the bytes it sent — the entire point of #6205. The two WriteFileTool sites apply the same normalization with no note and no equivalent signal. Worth confirming that the write path's returned diff/metadata is built from the normalized written (it looks like it is, since written is reassigned before write_atomic_workspace); if it is, this is only a reporting inconsistency. If it is not, the model's view of the file diverges from disk in exactly the way this PR exists to prevent.
  • [INFO] Every Rust edit costs one or two rustfmt subprocess spawns, serially per file in a patch (crates/tui/src/tools/rust_format.rs:76)
    normalize_edit runs rustfmt on after first and only then on before (the ordering is deliberate and correctly documented), so a .rs edit whose result is already canonical still pays one process spawn, and a sloppy edit into a clean file pays two, each with a 5 s budget. normalize_pending_rust iterates pending sequentially, so a patch touching N Rust files can add N x up to 10 s of blocking latency to the interactive edit path before the first byte is written. Bounding this (skip when the diff is empty, cap the number of formatted files per patch, or run the files concurrently under an overall budget) may be worth considering; the current guards only bound size and time, not count.
  • [INFO] line_column can panic on a byte offset that is not a char boundary (crates/tui/src/tools/syntax_check.rs:212)
    &source[..offset] panics if offset splits a multi-byte character; offset.min(source.len()) only guards the upper bound. toml_edit spans should always land on token boundaries, so this is likely unreachable today, but a panic inside the edit path is a much worse failure than the refusal this function exists to produce, and the caller already has a None-means-no-objection convention it could use instead. Cheap defensiveness: fall back to (1, 1) when source.get(..offset) is None, or floor the offset to the nearest char boundary before slicing.
  • [INFO] The generated web changelog truncates the new entry mid-sentence and drops its issue references (web/lib/changelog.generated.ts:33)
    The first new item renders on the public changelog as "...the file untouched and apply_patch…", cutting "cannot half-apply (#6204, #6206)" — so the user-facing page loses both the closing clause and the issue links that the other entries carry. The second new entry is short enough to survive intact, which suggests a generator length cap rather than a bug; shortening the first bullet (or splitting it) would keep the generated page readable.

Suggestions

  • crates/tui/src/tools/syntax_check.rs:149syntax_check reports "no objection" both for content that parsed and for content it skipped because it exceeds MAX_CHECKED_BYTES. Using it as the before verdict therefore makes the gate refuse edits to files it never parsed, contradicting the documented fail-open rule (an edit that shrinks a >2 MiB broken file below the cap would be refused). Compare the pre-edit size explicitly so an unchecked file always fails open.

        if before.len() > MAX_CHECKED_BYTES || syntax_check(path, before).is_some() {
    

Assessment

Pass 1: The core design is right and well argued: gate immediately before the write, refuse only newly introduced breakage, reuse the parsers already in the tree, and prefer rustfmt over a second formatter so edit-time output matches cargo fmt --check. The implementation matches the prose at every call site I can see, and the guard-before-first-write placement in apply_patch is the correct way to make a patch transactional for this failure mode. What is missing is evidence and one edge case: the multi-file atomicity claim is untested, the write_file sites and the apply_patch replace path have no test at all (nor does new-file fail-open through a tool), the --config-path branch that already burned this PR once is still exercised by nothing, and guard_edit conflates "parsed before" with "not checked because oversized", which refuses a repair the contract promises never to block. Address the size asymmetry and add the multi-file and rustfmt.toml tests and this is a solid, well-scoped gate.


Advisory review by Codewhale (codewhale review --pr 6238 --post, head 674bb880dafcebac192ada0d025b39dfdf63f66d). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

}
}

fn apply_pending_writes(pending: &[PendingWrite]) -> Result<(), ToolError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] apply_patch's transactional claim is asserted but only tested with a single file

The guard loop in apply_pending_writes is deliberately placed ahead of the write loop so that "one unparseable result must leave every file in the patch untouched rather than half-applied", and the CHANGELOG repeats that apply_patch "cannot half-apply". The only test (patch_refuses_a_hunk_that_breaks_rust_syntax) uses a single file, so it cannot distinguish "guard before the first write" from "per-file guard just before each write" — the two behave identically for one file, and only the second violates the stated contract. A two-file patch whose second file breaks Rust syntax, asserting the first file's bytes are unchanged on disk, is what actually pins the placement; it would also fail today if anyone later moves the loop into the write loop.

// silently rewritten with LF line endings.
let written = preserve_prior_line_endings(file_content, &prior_contents);
let mut written = preserve_prior_line_endings(file_content, &prior_contents);
guard_edit(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] Both write_file sites gained the gate and normalization with no tests; the apply_patch replace path is untested too

WriteFileTool's small-contract path and its ToolSpec twin are the two sites most likely to be hit by an agent that rewrites a whole file, and neither has a test asserting that (a) a write that breaks a previously-parseable .rs/.toml/.json file is refused with the file untouched, or (b) a full-file write into an already-rustfmt-clean file is normalized. Likewise build_pending_writes_from_replace now feeds normalize_pending_rust and the same guard as the patch path, but every new test exercises file_patches only. Making it worse, the new-file fail-open leg (the contract's other half, "creating a new file is ungated") is only covered at the guard_edit unit level, never through a tool — so a change that started passing Some("") instead of None for created files would silently start gating and rustfmt-normalizing every brand-new Rust file with the suite still green.

// config file that actually exists — pointed at a directory without one,
// `rustfmt` exits 1 with "unable to find a config file", which would
// silently disable normalization everywhere.
if let Some(config) = nearest_config(path).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] The rustfmt --config-path branch — the exact failure the PR documents — is still unexercised by any test

nearest_config is the branch whose previous form (a directory instead of a config file) made rustfmt exit 1 with "unable to find a config file" and silently disabled normalization everywhere while the suite stayed green. Every new test passes a path with no rustfmt.toml/.rustfmt.toml in an ancestor — rust_format::tests uses the relative src/lib.rs, and the tool tests use fresh tempdirs — so nearest_config returns None in all of them and --config-path is never passed. That leaves the known-broken path untested and also means nothing notices a rustfmt.toml whose contents make rustfmt exit non-zero (e.g. an unstable option under a stable toolchain): normalization would be silently off repo-wide, which is precisely the hazard the tests were supposed to close. A test that writes a rustfmt.toml into a tempdir and asserts the edit is still normalized (and that a bogus sibling config does not disable it) would cover both.

let Some(before) = before else {
return Ok(());
};
if syntax_check(path, before).is_some() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] guard_edit treats an unchecked before (over MAX_CHECKED_BYTES) as if it had parsed

syntax_check returns None for two different situations: "this content parses" and "I declined to look (unsupported extension or source.len() > MAX_CHECKED_BYTES)". guard_edit uses syntax_check(path, before).is_some() as the verdict for "the file was broken before", so a pre-edit file larger than 2 MiB is treated as parseable. Concretely, an edit that shrinks a >2 MiB broken .rs file to under 2 MiB and leaves it broken is refused with "it would leave X unparseable" — the file never parsed before as far as we know, and the documented contract says the gate exists to catch the edit that introduces breakage, never to strand a model repairing one. The size check is the only asymmetric input between the two calls (the extension is identical), so an explicit pre-edit size comparison restores the contract.

existed_before.then(|| prior_contents.as_ref()),
&written,
)?;
if existed_before

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] write_file normalizes silently, unlike the edit path

EditFileTool appends NORMALIZED_NOTE so the model learns that the bytes on disk are not the bytes it sent — the entire point of #6205. The two WriteFileTool sites apply the same normalization with no note and no equivalent signal. Worth confirming that the write path's returned diff/metadata is built from the normalized written (it looks like it is, since written is reassigned before write_atomic_workspace); if it is, this is only a reporting inconsistency. If it is not, the model's view of the file diverges from disk in exactly the way this PR exists to prevent.

return None;
}

let formatted = rustfmt(path, after).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] Every Rust edit costs one or two rustfmt subprocess spawns, serially per file in a patch

normalize_edit runs rustfmt on after first and only then on before (the ordering is deliberate and correctly documented), so a .rs edit whose result is already canonical still pays one process spawn, and a sloppy edit into a clean file pays two, each with a 5 s budget. normalize_pending_rust iterates pending sequentially, so a patch touching N Rust files can add N x up to 10 s of blocking latency to the interactive edit path before the first byte is written. Bounding this (skip when the diff is empty, cap the number of formatted files per patch, or run the files concurrently under an overall budget) may be worth considering; the current guards only bound size and time, not count.

/// the reader's cursor does in a file with non-ASCII content.
fn line_column(source: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(source.len());
let head = &source[..offset];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] line_column can panic on a byte offset that is not a char boundary

&source[..offset] panics if offset splits a multi-byte character; offset.min(source.len()) only guards the upper bound. toml_edit spans should always land on token boundaries, so this is likely unreachable today, but a panic inside the edit path is a much worse failure than the refusal this function exists to produce, and the caller already has a None-means-no-objection convention it could use instead. Cheap defensiveness: fall back to (1, 1) when source.get(..offset) is None, or floor the offset to the nearest char boundary before slicing.

{
"heading": "Added",
"items": [
"File edits are parse-gated before the write lands: Rust goes through syn::parse_file for a grammar-exact line:column, and .toml / .json through the parsers already vendored. An edit is refused only when the file parsed *before* and would not parse *after* — repairing an already-broken file is the commonest reason to edit source at all, so pre-existing breakage and new files fail open. The check precedes the write, so a rejection leaves the file untouched and apply_patch…",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] The generated web changelog truncates the new entry mid-sentence and drops its issue references

The first new item renders on the public changelog as "...the file untouched and apply_patch…", cutting "cannot half-apply (#6204, #6206)" — so the user-facing page loses both the closing clause and the issue links that the other entries carry. The second new entry is short enough to survive intact, which suggests a generator length cap rather than a bug; shortening the first bullet (or splitting it) would keep the generated page readable.

let Some(before) = before else {
return Ok(());
};
if syntax_check(path, before).is_some() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

syntax_check reports "no objection" both for content that parsed and for content it skipped because it exceeds MAX_CHECKED_BYTES. Using it as the before verdict therefore makes the gate refuse edits to files it never parsed, contradicting the documented fail-open rule (an edit that shrinks a >2 MiB broken file below the cap would be refused). Compare the pre-edit size explicitly so an unchecked file always fails open.

Suggested change
if syntax_check(path, before).is_some() {
if before.len() > MAX_CHECKED_BYTES || syntax_check(path, before).is_some() {

CodeWhale Bot and others added 4 commits September 15, 2026 15:32
Agent file edits land as text. When a patch produces Rust that does not
parse, the defect surfaces on the next compile — a turn later, as a wall
of cascading errors instead of one actionable message. `syn` implements
the real Rust grammar and reports a precise `line:column`, so the model
can fix it on the same turn.

`crates/tui/src/tools/syntax_check.rs` is the single gate seam for the
whole edit path. `guard_edit` runs on post-edit content immediately
before the bytes reach disk, at every write site:

- `file.rs` — `write`, `edit`, and their small-contract twins
- `apply_patch.rs` — once over all pending writes ahead of the first
  one, so a transactional patch cannot land half-applied
- `fim.rs` — model-generated infill

The gate refuses an edit only when the file parsed **before** and would
not parse **after**. Repairing an already-broken file is the commonest
reason to edit source at all, so pre-existing breakage fails open, as
does creating a new file. Because the check precedes the write, a
rejection leaves the file byte-for-byte untouched and there is no
rollback path to get wrong.

`syn` 3.0.3 was already in the lockfile as a proc-macro build
dependency; this reuses that exact version rather than vendoring a
second parser (#6151). The only lock change is two lines under
`codewhale-tui`. `proc-macro2/span-locations` is what turns a parse
failure into the `line:column` the model needs.

Evidence (all three regression tests were confirmed FAILED with
`guard_edit` stubbed to `Ok(())`, then restored):

    cargo fmt --all -- --check                             clean
    cargo clippy -p codewhale-tui --all-targets
      --all-features --locked -- -D warnings ...           clean
    cargo test -p codewhale-tui --lib --all-features --locked --
      tools::syntax_check:: tools::file::tests::edit_file
      tools::apply_patch::tests:: tools::file::tests::write_
    test result: ok. 80 passed; 0 failed; 0 ignored; 12668 filtered out

Closes #6204

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A malformed `Cargo.toml` fails the entire workspace build, not one file,
and until now the model only found out on the next `cargo` run — several
turns and one confusing error message later. Agents edit manifests
constantly (version bumps, feature flags, workspace members), so this is
the highest-value file class on the edit path.

Extends the `syntax_check` seam from #6204 with two parsers this crate
already loads its own config with: `toml_edit::DocumentMut` for `.toml`
and `serde_json` for `.json`. No new dependency, no lockfile change.
Every call site was wired in the previous commit, so the whole change is
the extension plus its tests.

`.toml` covers `Cargo.toml`, `deny.toml`, `.cargo/config.toml` and every
ordinary `*.toml` uniformly — the extension is the whole rule. `.jsonc`,
`.json5`, and `.jsonl` are deliberately *not* treated as JSON; they are
different grammars and a strict parser would reject valid files. A
`.json` file that is really JSONC (`tsconfig.json` with comments) does
not parse before the edit either, so the before/after rule skips it
instead of making it uneditable — covered by a test.

Rollback question from the issue, answered: there is nothing to roll
back. The check runs before the write at every call site, so a refused
edit leaves the file byte-for-byte untouched.

Evidence (the four new location-reporting tests were confirmed FAILED
with the `.toml`/`.json` arms removed, then restored):

    cargo fmt --all -- --check                             clean
    cargo clippy -p codewhale-tui --all-targets
      --all-features --locked -- -D warnings ...           clean
    cargo test -p codewhale-tui --lib --all-features --locked --
      tools::syntax_check:: tools::file::tests::edit_file
      tools::apply_patch::tests::
    test result: ok. 81 passed; 0 failed; 0 ignored; 12675 filtered out

Closes #6206

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Model-generated edits rarely match rustfmt output. Once an unformatted
edit lands, the next turn's `old_string` or patch context was written
against text `cargo fmt` is about to move, so the follow-up edit fails to
match and costs a re-read. Normalizing at edit time keeps anchors stable
for the rest of the session.

`crates/tui/src/tools/rust_format.rs` shells to `rustfmt`, deliberately
not `prettyplease`: `cargo fmt --check` is this repository's real gate,
and a second formatter with its own opinions would produce files that
pass the edit path and fail the gate. `prettyplease` also pins syn 2.x,
which would duplicate the syn 3.0.3 adopted in #6204 (#6151). Shelling
out additionally honors the project's own rustfmt.toml.

Policy: normalize the whole edited file, and only when that file was
already rustfmt-clean before the edit. A hand-formatted file is never
rewritten. Because a clean file is a formatting fixpoint, reformatting
after an edit can only touch the edited region — "whole file" and
"edited region" coincide without span arithmetic to get wrong. Common
case costs one rustfmt run, not two: if the post-edit content is already
canonical there is nothing to check.

Runs after the #6204 syntax gate and before the write, at the same call
sites, so the returned content and diff are the bytes on disk. `fim_edit`
takes the gate but not the normalization: its result reports byte offsets
into the written file, and reformatting would move them.

Every failure path — missing rustfmt, parse failure, timeout, non-zero
exit, CRLF file, oversized file — skips normalization and lets the edit
land. Nothing here can fail an edit.

One bug worth naming, since the tests initially hid it: `--config-path`
pointed at a directory with no rustfmt.toml makes rustfmt exit 1, which
silently disabled normalization everywhere while a skip-if-unavailable
test branch stayed green. The flag is now passed only when a config file
is actually found, and the tests assert rustfmt is present instead of
skipping.

Evidence (the three normalization tests were confirmed FAILED with
`normalize_edit` stubbed to `None`, then restored):

    cargo fmt --all -- --check                             clean
    cargo clippy -p codewhale-tui --all-targets
      --all-features --locked -- -D warnings ...           clean
    cargo test -p codewhale-tui --lib --all-features --locked --
      tools::rust_format:: tools::syntax_check::
      tools::file::tests::edit_file tools::file::tests::write_
      tools::apply_patch::tests::
    test result: ok. 97 passed; 0 failed; 0 ignored; 12668 filtered out

Closes #6205

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9
The three feat commits referenced issues without the changelog receipt the
working loop requires. Regenerated with scripts/sync-changelog.sh and
web/scripts/derive-changelog.mjs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9
@Hmbown
Hmbown force-pushed the feat/edit-safety-batch-c-20260915 branch from 674bb88 to d184a8b Compare September 15, 2026 22:41
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Hmbown's task in 1m 54s —— View job


Review: Edit safety — parse-gate file edits before the write lands

Todo

  • Gather context (PR diff, CLAUDE.md/AGENTS.md rules, prior review threads)
  • Read syntax_check.rs (Rust syn / TOML / JSON gate)
  • Read rust_format.rs (rustfmt normalization)
  • Read file.rs write/edit gate integration
  • Read apply_patch.rs transactional gate integration
  • Read fim.rs gate integration
  • Check tests in file/tests/tools.rs
  • Check whether prior review findings were addressed
  • Post review summary

Re-reviewed the current head (d184a8b) against origin/main. The only commit added since the last automated review (674bb880) is the CHANGELOG receipt (d184a8b) — no source changed, so every finding from that earlier pass is still live. I verified the code directly rather than just repeating the prior comment; summary below with what's confirmed still-present.

1. Correctness bug, still unfixed: guard_edit treats an unchecked before as "parsed" (crates/tui/src/tools/syntax_check.rs:149)

if syntax_check(path, before).is_some() {
    return Ok(());
}

syntax_check returns None both when content parses and when it's skipped for exceeding MAX_CHECKED_BYTES (2 MiB). guard_edit can't tell those apart, so a pre-edit file over 2 MiB is treated as "parsed before." Concretely: a >2 MiB broken .rs/.toml/.json file gets an edit that shrinks it under 2 MiB while it's still broken — the gate refuses this with "it would leave X unparseable," even though the file never parsed as far as the check could tell and the PR's own stated contract ("pre-existing breakage... fail open") says this exact edit must land. Since before and after go through the identical size gate, the fix is a one-line explicit pre-edit size check:

if before.len() > MAX_CHECKED_BYTES || syntax_check(path, before).is_some() {

This was already flagged with a committable suggestion on this PR; it hasn't been applied.

Fix this →

2. Test coverage gaps are unchanged

Confirmed directly against the current tree:

  • crates/tui/src/tools/file/tests/tools.rs has no test exercising guard_edit/normalize_edit through either WriteFileTool path (file.rs:1449 small-contract write, file.rs:1579 ToolSpec::execute) — grepped test names and neither a "write refuses a syntax break" nor a "write normalizes a clean file" case exists.
  • crates/tui/src/tools/fim.rs:169 wires guard_edit in, but fim.rs's own #[test] module (line 197) has no case that would catch a dropped or reordered call there.
  • apply_pending_writes's "guard runs over every pending entry before the first write" claim (apply_patch.rs:1314) is still only tested with a single-file patch, which can't distinguish "guarded before the first write" from "guarded per-file right before its own write" — only the latter would violate the no-half-apply contract this PR states as its answer to v0.9.14: Parse-gate structured config edits (TOML/JSON) on the file-edit path #6206 item 3.

None of these gaps are new; they were already itemized with concrete test shapes in the prior review pass and remain open.

3. Design is sound and correctly argued

Worth restating since it's still true: the fail-open contract (parsed-before + unparses-after is the only refusal condition; new files and pre-existing breakage always land) is implemented faithfully at every call site checked. Reusing syn 3.0.3 already in the lockfile, using rustfmt instead of prettyplease to match the real cargo fmt --check gate, gating apply_patch ahead of the first write, and the "only normalize a file that was already rustfmt-clean" fixpoint argument in rust_format.rs are all well-reasoned and match the PR body's claims.

4. Minor, still open

  • crates/tui/src/tools/rust_format.rs's nearest_config / --config-path branch — the exact bug the PR's own description says stayed green while broken — has no test where a rustfmt.toml is actually present in an ancestor directory.
  • web/lib/changelog.generated.ts:33 still truncates the new entry mid-sentence ("...the file untouched and apply_patch…"), dropping the cannot half-apply clause and the (#6204, #6206) issue links that every other entry carries. This is a generator length cap reacting to this PR's long first bullet, not a bug in the generator itself — shortening that CHANGELOG.md bullet would fix it.
  • The guard_edit/normalize_edit call-and-branch pattern is still duplicated near-verbatim across the four file.rs sites; a shared helper would directly shrink the blast radius of finding Simplify UI and improve footer display (v0.3.14) #2 above (one wiring bug instead of up to four).

No provider/model-routing code is touched by this PR, so EPIC #2608's prefix-inference invariant doesn't apply here. No new security-relevant surface (no shell/secret handling) beyond the already-reviewed rustfmt subprocess, which is invoked with fixed args and piped stdin/stdout, not shell-interpreted.

Assessment

The design is correct and well-argued. One real correctness bug remains unaddressed (item 1) — it's a narrow edge case (oversized + already-broken file) but it directly contradicts the PR's own stated contract, so it's worth fixing before merge rather than as a follow-up. The test-coverage gaps (item 2) are real but lower severity since the underlying guard_edit/normalize_edit functions are well unit-tested in isolation — the risk is a wiring regression at one of the four untested integration points going unnoticed.
· branch feat/edit-safety-batch-c-20260915

@codewhale-agent codewhale-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Codewhale review

Complete review coverage: 1/1 passes, 12 file patches, sha256:2f5e8511d519eca32dd452eaa7fcbdf60820ecff0b1062d027c206ed9639bca0.

Pass 1: The change adds a pre-write syntax gate (syn::parse_file / toml_edit / serde_json) plus rustfmt-based normalization to every file-mutating tool. The core contract — reject only when the file parsed before and does not parse after — is implemented correctly in guard_edit, the write sites are ordered gate-before-write (and all guards precede the first write in apply_patch), and the unit tests for the guard are strong. The remaining concerns are a parser-offset slicing hazard, an environment-sensitive test that can pass for the wrong reason, and several untested write paths / claims.

Findings

  • [WARNING] line_column slices a parser-supplied byte offset without a char-boundary check (crates/tui/src/tools/syntax_check.rs:212)
    toml_edit's TomlError::span() is a raw byte range; line_column immediately does &source[..offset]. If that offset is not a UTF-8 char boundary (an error position inside or adjacent to a multi-byte character), the slice panics instead of producing the recoverable ToolError the gate is designed to return — a panic on the interactive edit path, in the very path that is supposed to fail soft. Clamp to a boundary first (walk back while !source.is_char_boundary(offset), or use source.get(..offset).unwrap_or(source)) before slicing.
  • [WARNING] Normalization test in the tool suite can pass vacuously when rustfmt is absent (crates/tui/src/tools/file/tests/tools.rs:935)
    rust_format::tests::require_rustfmt asserts the formatter is present specifically so a missing rustfmt cannot make the formatting tests green for the wrong reason. The tool-level test edit_file_leaves_a_hand_formatted_file_alone has no such precondition, and its assertion ("unrelated user formatting must survive the edit") is exactly what a missing/stubbed formatter also produces, so the test proves nothing about the skip-on-dirty-file policy when rustfmt is not on PATH. Add a shared precondition (call a require_rustfmt-style helper, or assert that an explicitly sloppy edit becomes normalized) so the policy assertion cannot be satisfied by the formatter being gone.
  • [WARNING] The 'patch cannot half-apply' transactional claim is not actually tested (crates/tui/src/tools/apply_patch.rs:1706)
    The doc comment on patch_refuses_a_hunk_that_breaks_rust_syntax says a multi-file patch cannot land half-applied, but the test patches a single file. It therefore cannot distinguish a gate that runs once ahead of the first write (the claim) from a per-file gate, from a gate placed after the first write, or from no gate at all on a second entry. Add a two-file patch whose second file's result is unparseable and assert that both files are byte-identical to their pre-patch contents — that is the only shape that exercises the ordering inside apply_pending_writes.
  • [INFO] File action="write" and fim_edit gate/normalize paths have no test coverage (crates/tui/src/tools/file.rs:1449)
    The PR states the check runs at every write site, but only the edit path (file.rs main + contract), one single-file patch, and the guard unit tests are covered. The write tool's two call sites use a different pre-edit source (existed_before.then(...) + preserve_prior_line_endings) and the fim_edit guard is a distinct call with no test at all. Add at least: (a) File action="write" over an existing parseable .rs/.toml that becomes unparseable is refused and leaves the file untouched; (b) the same over a new file is allowed (creation fails open); (c) fim_edit refuses a generated block that breaks Rust syntax. These are the paths most likely to regress silently.
  • [INFO] Normalization's extension test disagrees with the gate's on case (crates/tui/src/tools/rust_format.rs:65)
    SyntaxLanguage::from_path lowercases the extension before matching, while normalize_edit compares path.extension()?.to_str()? != "rs" case-sensitively. A file named LIB.RS is therefore syntax-gated but never normalized, so the two halves of the same feature disagree about what a Rust file is. Use eq_ignore_ascii_case("rs") (or reuse SyntaxLanguage::from_path) so the gate and the formatter agree.
  • [INFO] JSON gate materializes a whole Value tree just to validate (crates/tui/src/tools/syntax_check.rs:188)
    serde_json::from_str::<serde_json::Value>(source) allocates the entire document (up to MAX_CHECKED_BYTES) on the interactive edit path, twice per rejected edit, purely to learn whether it parses; the parsed value is discarded. Deserializing into serde::de::IgnoredAny validates the same grammar without building the tree, and keeps error.line()/column() available. Low priority, but it is pure cost on the hot path this gate sits on.

Suggestions

  • crates/tui/src/tools/syntax_check.rs:212 — Guard the slice against a non-char-boundary offset before taking &source[..offset], e.g. walk the offset back while !source.is_char_boundary(offset) (or source.get(..offset).unwrap_or(source)), so a span reported inside a multi-byte character degrades to a slightly-off column instead of panicking the edit path. No replacement is offered because the exact anchor line was inferred from the diff.
  • crates/tui/src/tools/file/tests/tools.rs:935 — Give the formatting-sensitive tool tests the same precondition the rust_format unit tests use (assert rustfmt is on PATH and produces expected output) so edit_file_leaves_a_hand_formatted_file_alone cannot pass because the formatter is missing rather than because the policy correctly skipped a hand-formatted file.
  • crates/tui/src/tools/apply_patch.rs:1706 — Extend the refusal test to two files in one patch, with only the second file's post-patch content unparseable, and assert both files are unchanged. That is what actually verifies the 'gate ahead of the first write' ordering inside apply_pending_writes.
  • crates/tui/src/tools/file.rs:1449 — Add coverage for the write write site (refuse a write that breaks an existing parseable file; allow a write that creates a new file) and for fim_edit's guard, since those are the two gate call sites with no test in this PR.
  • crates/tui/src/tools/rust_format.rs:65 — Match the extension case-insensitively (or delegate to SyntaxLanguage::from_path) so a .RS file is normalized whenever it is gated.
  • crates/tui/src/tools/syntax_check.rs:188 — Validate JSON with serde::de::IgnoredAny instead of serde_json::Value to avoid allocating the whole document on the edit path while keeping error.line()/error.column().

Assessment

Pass 1: The gate itself is sound: the before/after asymmetry (fail open on pre-existing breakage and on file creation) is implemented exactly as documented, every write site checks before writing, apply_patch guards all pending writes ahead of the first write, and the guard unit tests plus the edit/patch regression tests are meaningful and were verified to fail without the fix. No correctness defect was found in the gate or the normalization logic — the ordering of normalize_edit (whole-file, only for files that were already rustfmt-clean, CRLF and oversized files skipped, every failure path returning None) is consistent with the stated policy. The remaining work is robustness and evidence: guard the toml_edit byte offset before slicing it, make the formatting-sensitive tool tests assert rustfmt is present so they cannot pass vacuously, test the transactional multi-file claim rather than a single-file patch, and cover the two untested write sites (File action="write", fim_edit). The dependency story is consistent — syn/proc-macro2 reuse the existing lockfile entries (two added lines) and TOML/JSON reuse existing crates.


Advisory review by Codewhale (codewhale review --pr 6238 --post, head d184a8bb179595f9cbc34454d8db5b4643ab2e72). Line-specific findings are also posted as inline review comments; mechanical fixes arrive as committable suggestions you can apply from the Files tab. CODEOWNERS approval still governs merge.

/// the reader's cursor does in a file with non-ASCII content.
fn line_column(source: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(source.len());
let head = &source[..offset];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] line_column slices a parser-supplied byte offset without a char-boundary check

toml_edit's TomlError::span() is a raw byte range; line_column immediately does &source[..offset]. If that offset is not a UTF-8 char boundary (an error position inside or adjacent to a multi-byte character), the slice panics instead of producing the recoverable ToolError the gate is designed to return — a panic on the interactive edit path, in the very path that is supposed to fail soft. Clamp to a boundary first (walk back while !source.is_char_boundary(offset), or use source.get(..offset).unwrap_or(source)) before slicing.


/// A file the author formats by hand is never reformatted wholesale.
#[tokio::test]
async fn edit_file_leaves_a_hand_formatted_file_alone() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] Normalization test in the tool suite can pass vacuously when rustfmt is absent

rust_format::tests::require_rustfmt asserts the formatter is present specifically so a missing rustfmt cannot make the formatting tests green for the wrong reason. The tool-level test edit_file_leaves_a_hand_formatted_file_alone has no such precondition, and its assertion ("unrelated user formatting must survive the edit") is exactly what a missing/stubbed formatter also produces, so the test proves nothing about the skip-on-dirty-file policy when rustfmt is not on PATH. Add a shared precondition (call a require_rustfmt-style helper, or assert that an explicitly sloppy edit becomes normalized) so the policy assertion cannot be satisfied by the formatter being gone.

/// #6204 — a patch whose result does not parse is refused before any file
/// is written, so a multi-file patch cannot land half-applied.
#[tokio::test]
async fn patch_refuses_a_hunk_that_breaks_rust_syntax() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING] The 'patch cannot half-apply' transactional claim is not actually tested

The doc comment on patch_refuses_a_hunk_that_breaks_rust_syntax says a multi-file patch cannot land half-applied, but the test patches a single file. It therefore cannot distinguish a gate that runs once ahead of the first write (the claim) from a per-file gate, from a gate placed after the first write, or from no gate at all on a second entry. Add a two-file patch whose second file's result is unparseable and assert that both files are byte-identical to their pre-patch contents — that is the only shape that exercises the ordering inside apply_pending_writes.

// silently rewritten with LF line endings.
let written = preserve_prior_line_endings(file_content, &prior_contents);
let mut written = preserve_prior_line_endings(file_content, &prior_contents);
guard_edit(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] File action="write" and fim_edit gate/normalize paths have no test coverage

The PR states the check runs at every write site, but only the edit path (file.rs main + contract), one single-file patch, and the guard unit tests are covered. The write tool's two call sites use a different pre-edit source (existed_before.then(...) + preserve_prior_line_endings) and the fim_edit guard is a distinct call with no test at all. Add at least: (a) File action="write" over an existing parseable .rs/.toml that becomes unparseable is refused and leaves the file untouched; (b) the same over a new file is allowed (creation fails open); (c) fim_edit refuses a generated block that breaks Rust syntax. These are the paths most likely to regress silently.

/// `after` exactly as the caller produced it. Every failure path returns
/// `None`: normalization is a convenience and may never break an edit.
pub(super) async fn normalize_edit(path: &Path, before: &str, after: &str) -> Option<String> {
if path.extension()?.to_str()? != "rs" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[INFO] Normalization's extension test disagrees with the gate's on case

SyntaxLanguage::from_path lowercases the extension before matching, while normalize_edit compares path.extension()?.to_str()? != "rs" case-sensitively. A file named LIB.RS is therefore syntax-gated but never normalized, so the two halves of the same feature disagree about what a Rust file is. Use eq_ignore_ascii_case("rs") (or reuse SyntaxLanguage::from_path) so the gate and the formatter agree.


/// A file the author formats by hand is never reformatted wholesale.
#[tokio::test]
async fn edit_file_leaves_a_hand_formatted_file_alone() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Give the formatting-sensitive tool tests the same precondition the rust_format unit tests use (assert rustfmt is on PATH and produces expected output) so edit_file_leaves_a_hand_formatted_file_alone cannot pass because the formatter is missing rather than because the policy correctly skipped a hand-formatted file.

/// #6204 — a patch whose result does not parse is refused before any file
/// is written, so a multi-file patch cannot land half-applied.
#[tokio::test]
async fn patch_refuses_a_hunk_that_breaks_rust_syntax() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Extend the refusal test to two files in one patch, with only the second file's post-patch content unparseable, and assert both files are unchanged. That is what actually verifies the 'gate ahead of the first write' ordering inside apply_pending_writes.

// silently rewritten with LF line endings.
let written = preserve_prior_line_endings(file_content, &prior_contents);
let mut written = preserve_prior_line_endings(file_content, &prior_contents);
guard_edit(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Add coverage for the write write site (refuse a write that breaks an existing parseable file; allow a write that creates a new file) and for fim_edit's guard, since those are the two gate call sites with no test in this PR.

/// `after` exactly as the caller produced it. Every failure path returns
/// `None`: normalization is a convenience and may never break an edit.
pub(super) async fn normalize_edit(path: &Path, before: &str, after: &str) -> Option<String> {
if path.extension()?.to_str()? != "rs" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Match the extension case-insensitively (or delegate to SyntaxLanguage::from_path) so a .RS file is normalized whenever it is gated.

}

fn check_json(source: &str) -> Option<SyntaxIssue> {
let error = serde_json::from_str::<serde_json::Value>(source).err()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Validate JSON with serde::de::IgnoredAny instead of serde_json::Value to avoid allocating the whole document on the edit path while keeping error.line()/error.column().

@Hmbown
Hmbown merged commit 165873f into main Sep 15, 2026
34 checks passed
@Hmbown
Hmbown deleted the feat/edit-safety-batch-c-20260915 branch September 15, 2026 23:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants