mirror of
https://github.com/Twigpine/node.git
synced 2026-11-10 05:01:48 -10:00
* fix(gl): status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result (#123) Convert the gl CLI + MCP read/write surfaces to status-check the node's response before parsing, so a denial (401/403/404) or a degraded 5xx surfaces as an error instead of being rendered as an empty list or a fake success. The shared http::read_json helper checks the status first and bounds the error-body read (a hostile node can't stream an arbitrarily large error body to exhaust the client), replacing the prior parse-before-status pattern across agent, bounty, cert, changelog, issue, mcp, peer, pr, protect, repo, star, status, sync, task, visibility, and webhook. A no_parse_before_status regression gate locks the converted set in. Rebased onto current main (squash of the prior branch). main had independently gated several of these same surfaces, so the overlapping arms are reconciled to keep BOTH fixes rather than let either regress: - repo::cmd_label_list keeps main's get_maybe_signed (read-visibility gating: public repos stay anon-listable, a private-repo owner signs) carrying read_json on top; the PR's get_authed is dropped so the visibility gate holds. - mcp webhook_list keeps main's get_signed and its signing-keypair-DID owner resolution (NOT resolve_owner's node DID) — list_webhooks is owner-gated AND auth-required, so the PR's plain get()+resolve_owner would have 401'd then 403'd — with read_json layered for the bounded error read. - webhook create/list/delete keep main's get_signed + resolve_owner_repo_pair structure and its tests, with read_json swapped in for the manual resp.json() so the error body is bounded. main's superseded resolve_owner helper and its duplicate tests are dropped. gl: 388 unit tests + the no_parse_before_status gate green; fmt + clippy clean. Rebased again onto current main (2026-08-11), which had moved 238 commits. main independently landed read_body_capped and sanitize_node_msg into http.rs, so this branch's duplicate definitions in sync.rs are dropped and read_json is layered on main's helpers instead. main's peer.rs also grew its own announce handling (remote_announce_failure, local_add_report/local_add_refusal, capped read, sanitized message) which supersedes this branch's read_json conversion there; main's side is kept whole and only peer::cmd_list, which main had not gated, still takes read_json. fix(gl): report why the node-DID comparison was skipped in cert show The rebase onto main dropped did_check_report, since main's cmd_show had been rewritten to do real Ed25519 verification and collapsed every node-info failure into one "could not fetch current node info" NOTE. Put the tri-state reporting back on top of main's structure: the signature verdict and the --verify issuer anchoring are untouched, but the node-DID comparison now names the reason it could not run and points at the offline verification path, and "carried no DID" stays distinct from "the lookup failed". The lookup goes through read_json so a denial yields a reason rather than an opaque None, and it stays fail-soft: a node-info hiccup must not turn a successfully displayed certificate into an error exit. The DID extraction is split out as did_from_node_info so its guard is testable. An empty did rejects rather than flowing into the comparison, where it would print a mismatch WARNING against a DID the node never claimed. That guard had no coverage: removing it leaves every pre-existing test green and only the new one goes red. fix(gl): close two completeness-gate escapes; surface the node's error key Review of the rebased branch found the #186 completeness gate could not fail on the two shapes that matter most. Both were reproduced by seeding the regression and watching the gate stay green. An unguarded parse escaped entirely. The rule flagged a parse followed by .is_success(), which describes one bad ordering and says nothing about a read with no status check at all -- the more dangerous revert, since it renders a denial body as a result. Inverted it to require a status check above the parse, which also covers a check spelled as_u16() >= 400. The lookback is 24 lines because sync.rs's hand-rolled Trigger arm puts its .status() 19 lines up; it should shrink if that arm is ever routed through read_json. Membership in the scanned set keyed on the text "read_json" rather than a call site, so a handler fully reverted off read_json kept its membership on a leftover comment. Still-derived meant the pinned-vs-derived equality never fired and the deconversion shipped green, dropping the cap and the sanitizer. Now counts call sites on non-comment lines. read_json read only `message`, but the task API answers with `error` alone ({"error":"task not found"} in gitlawb-node/src/api/tasks.rs), so every gl task denial rendered as the generic "request failed". Falls back to `error` through the same sanitizer and cap; `message` still wins when both are present, which is the shared AppError envelope. sync.rs already read both. Also covers cmd_show --verify, which had no test at all: the issuer anchoring is the security-bearing half of the command, since a valid signature only proves the certificate is self-consistent and a hostile node can self-sign one. The certificates carry real signatures, otherwise --verify would bail before reaching the anchoring and the assertions would be vacuous. Each fix verified by reverting it: both gate escapes go red, dropping the error fallback fails the two new read_json tests, and removing the anchoring block fails three of the five --verify tests. * test(gl): pin the stats mock hits and the unsigned MCP webhook_list denial Closes the two items left open on the last review round. The bounty-stats denial tests were already load-bearing: breaking either mock's route regex turns both red, because mockito answers an unmatched route with 501 and neither test's asserted status is 501. That proof is incidental to mockito's fallback status, though, so a reworded assertion would silently drop it. Assert the hit directly with expect(1) + assert_async. MCP webhook_list keeps get_maybe_signed rather than aligning with the CLI's get_signed. An agent driving the MCP tool may legitimately have no local identity, and issuing the request so the node's own denial reaches the caller is more useful than failing client-side before the node is asked. That is only defensible if the denial actually surfaces, so pin it: no identity plus an explicit owner must issue an UNSIGNED request and turn the node's 401 into an error carrying the status and the node's reason, not an empty webhook list. The new test points at an empty temp dir instead of passing None. None falls back to the ambient ~/.gitlawb/identity.pem, which exists on a dev box and not on CI, so the request would have been signed here and unsigned there; the first run of this test failed exactly that way before the dir was pinned. Verified by execution: swapping get_maybe_signed for get_signed reds the new test on "must name the status", and breaking either stats mock route reds its test. gl: 412 unit tests + the no_parse_before_status gate green; fmt and clippy clean. * docs(gl): describe the client-contract rules in plain English The comments carried internal shorthand identifiers that resolve to nothing for a reader outside this repo. Replace each with the property it names: a denial must surface as an error rather than render as an empty or fabricated result, and an error body must be read under a cap and sanitized before it reaches the terminal. Comment-only; no behavior change. Verified after the edit because the completeness gate reads source text to decide which handlers it scans: reverting a converted handler to a bare .json().await still turns it red. gl: 412 unit tests green, fmt and clippy clean, cargo check --workspace --locked clean. * test(gl): accept only a full non-success guard above a raw parse A 404-only equality upstream counted as status evidence, so reverting a read_json call behind agent.rs's special-404 hint to resp.json().await? kept the fence green while a 500's denial body rendered as a result. Require .is_success() or an as_u16() range comparison in the lookback, and pin the distinction with a mutation regression that performs that exact revert on the real agent.rs source. * test(gl): drop the traced result from the cert assertion diagnostics (#186) CodeQL reported seven high-severity "Cleartext logging of sensitive information" alerts at cert.rs:497, 540, 584, 667, 708, 803 and 862, and the aggregate check has been red on the branch since. All seven are #[cfg(test)] assertions that format a whole Result<()> through {result:?} or {got:?}. CodeQL traces a value associated with resolve_cert_id into the command error and treats the assertion's panic diagnostic as a logging sink. The alerts are test-only. The single mod tests opens at cert.rs:328 and runs to EOF, so all seven sit inside it, and the traced certificate id is already printed unconditionally in production at cert.rs:145 because it is a public identifier. That makes these repeated instances of one test-diagnostic pattern rather than seven distinct vulnerabilities. Each message now carries static context naming the scenario instead. The failing test name and source line still identify what broke, and every assertion predicate is unchanged, so nothing is weakened into vacuity. The production cleartext-logging query stays enabled: nothing is suppressed or reconfigured. One {got:?} at cert.rs:900 is deliberately left alone. CodeQL did not flag it, its value comes from did_from_node_info over hardcoded literal JSON with no resolve_cert_id taint, and the interpolation is load-bearing there because it names which of three loop cases failed. Verified: cargo test -p gl --bin gl cert, 20 passed. fmt clean. * test(gl): restore the nine peer denial tests dropped in the rebase (#186) The 2026-08-11 rebase squashed the branch and lost nine tests. Counting `async fn cmd_add_` in peer.rs per head: 6 at 06863da0, 3791c40c, 9ed2947d and ad0e35ad, then 0 at21ba974cand 0 atfba90038. The production fail-soft at peer.rs:173-180 survived; only the tests pinning it went. That matters because the rule this PR's own round 3 established is that a carve-out with no test that goes red when someone makes it fatal is an incomplete sweep, not a documented decision. The peer-add public-URL lookup has been in exactly that state for three weeks. Recovered from e179cd22, not 5eff6060: the earlier commit carries only four of the seven items, while e179cd22 has all of them plus three same-class denial tests for cmd_list, cmd_ping and cmd_resolve that were lost the same way. Neither commit is an ancestor of HEAD. Nothing needed adapting. cmd_add's signature and its node-info block are byte-identical between the two heads. The announce arm has since moved to remote_announce_failure, which still formats the status the denied-announce test asserts on, and the local-add arm has moved to local_add_report, which still returns Ok either way, so both local-add tests hold. The named import list gains cmd_list, cmd_ping and cmd_resolve. Proven load-bearing rather than assumed: rewriting the fail-soft match into a fatal `?` turns cmd_add_announces_fallback_url_when_node_info_denied and cmd_add_falls_back_on_malformed_node_info red, and leaves the pre-existing a_peer_failure_past_the_read_cap_still_reports_its_status green. That last point is the reason these are not redundant with what was already there: the surviving test drives a 503 whose body is past the read cap, so it pins the cap and ordering interaction, not the fallback. Verified: cargo test -p gl --bin gl peer, 25 passed. fmt clean. * fix(gl): route the status repo sections through read_json and pin the file (#186) `gl status` was hardened in this PR with section_unavailable_line and trust_line, so a denied section prints "unavailable ({status})" instead of rendering as empty. But it was hardened by hand rather than through read_json, so status.rs carried no marker, never entered the completeness gate's derived set, and was never scanned. Deleting the helper call would have restored the denial-as-empty bug with the gate still green: the one file written to fix this bug was the one converted file the gate did not cover. The two repo-section parses now route through read_json and status.rs is pinned in CONVERTED_IN_186. Exactly two source lines change. section_unavailable_line takes the response by reference and reads only its status, so read_json sits underneath it with no restructuring, and the outer if-let shape still stops one denied section from aborting the others. All 35 status tests pass unchanged, including the eight denial tests and their exact rendered strings. trust_line stays hand-rolled on purpose. It needs a three-way split (2xx, exactly 404, other non-2xx) that read_json collapses into a single Err, and it already carries a literal status check the scan can see. A second pinned const naming hand-rolled guards was considered and rejected. It would enforce only that a symbol is spelled somewhere in the file: deleting the println while keeping the call would stay green. A structural anchor already exists, which is read_json, and the repo's own guard-design note is explicit that a name-keyed filter must never be the membership predicate when one does. Proven, not assumed. Pinning status.rs before converting it turned the gate red naming the file. Reverting even one of the two read_json calls afterwards turns it red again, on the unguarded check rather than the pin, because the issues parse has no full status guard in its lookback window. The one gap worth naming: deleting both section_unavailable_line call sites while keeping read_json still scans green, and the denied section would then print nothing rather than a false "no open pull requests". Probed it, and clippy catches that case as a dead function under -D warnings, so CI would fail. That backstop holds only while the helper has no other caller. Verified: gate 2 passed, cargo test -p gl --bin gl status 35 passed. fmt clean. * fix(gl): let gl pr view finish rendering when a sub-fetch is denied (#186) Converting cmd_view's sub-fetches to read_json turned a tolerated 404 into a mid-render abort. The header prints first, then the reviews fetch is bare `?`, so a denial leaves a half-rendered PR, exit 1, and the comments section never runs. Before the conversion the error body parsed, the section was skipped and the rest of the command still rendered, so this is a regression this PR introduced rather than pre-existing behavior. Both sub-fetches now degrade: a denial prints "Reviews unavailable ({status})" or "Comments unavailable ({status})" and rendering continues. That is the shape this PR already invented one file over for gl status's denied sections, so the two commands now behave the same way. read_json stays in use, so pr.rs stays in the gate's derived set and the pinned list is untouched. The header fetch stays fatal. With no header there is nothing to render partially, and softening it would resurrect denial-as-success for the whole command. cmd_view_header_denial_stays_fatal pins that, and it passed before this change as well as after, which is the point: it is the must-not case. mcp.rs's pr_view arm has the same fetch coupling and is deliberately left alone. It emits a single JSON result, so there is no partial-output problem, a whole-call error is the correct fail-closed answer for a machine consumer, and inventing a partial-result shape is a design change nobody asked for. Test-first and observed: cmd_view_continues_when_reviews_are_denied and cmd_view_continues_when_comments_are_denied both failed before the change and pass after. Each asserts the comments mock was hit with expect(1), which is the load-bearing part: the mock can only be hit if rendering continued past the denied fetch. Verified: cargo test -p gl --bin gl, 473 passed. Gate 2 passed. fmt and clippy clean. * ci: trigger the PR checks on a new head --------- Co-authored-by: Kevin Codex <[email protected]>