Skip to content

fix(gl): status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result (#123)#186

Open
beardthelion wants to merge 27 commits into
mainfrom
fix/issue-123-client-read-status-check
Open

fix(gl): status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result (#123)#186
beardthelion wants to merge 27 commits into
mainfrom
fix/issue-123-client-read-status-check

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

What

gl client commands and the MCP tools parsed HTTP responses without checking status, so a node 4xx/5xx (a gated denial, an auth failure, a server error) was deserialized and rendered as success: an empty list, a fabricated "0 stats", the error body printed as if it were data, or a silently swallowed denial. This routes every such surface through crate::http::read_json (or an explicit status check where the payload is not JSON) so a denial surfaces as an Err carrying the node's status and message.

INV-8: a gated denial must not be rendered as a result.

Surfaces

  • CLI reads and writes: repo (list/commits/labels), pr, issue (list/show/close/comment/create), cert, bounty, webhook, star, protect, visibility, changelog, sync (trigger + status), peer (list/ping/resolve), agent, register, task (create/list/view/claim/complete/fail).
  • MCP twin: every tool that returns a node response (node_info/health, repo_, pr_, webhook_, bounty_, task_, issue_, agent_register, git_refs). Before this, an agent calling these got the error body back as a successful tool result.
  • The owner-resolution GET / helpers, which reported a vague "node missing DID" on a node error instead of the node's actual status.

Left as-is on purpose: the diagnostic/fail-soft paths that intentionally degrade rather than abort (gl node status/resolve dashboards, gl doctor, the peer add public-URL lookup, the cert-show node-DID hint). Those show a clear degraded state, not fabricated data.

Verification

Each surface has a denial test that drives a 4xx/5xx through the real client path and asserts the error carries the node's status. The production conversions were run RED against the pre-fix code (the arm rendered success) and GREEN after. Denial tests carry .expect(1) mock-hit assertions so a non-matching route can't satisfy them vacuously, and those assertions were confirmed load-bearing (a wrong route drives them RED). Full gl suite green; fmt and clippy clean.

Scope

This began as the read-tools fix and grew into the whole-client sweep once it was clear the same denial-as-success bug spanned the CLI, the MCP twin, and the resolve helpers. It is one coherent theme (#123 / INV-8), but it is a lot bigger than the original diff. Happy to split it into pieces if that reviews better.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of unavailable/gated resources so denials surface as errors (not empty/“not found” fallbacks) across issue, PR, repo, certificate, bounty, webhook, label/protect, task, peer, star, and related flows.
    • Centralized status-aware JSON parsing now fails on malformed/empty success responses, and sanitizes node-provided error messages.
    • gl status now reports “unavailable ()” for non-success PRs/issues responses.
  • Tests

    • Expanded denial/malformed-response coverage and tightened mock expectations to ensure the correct endpoints are called.
    • Added a completeness gate to prevent regressions of “parse before status” patterns.

t added 9 commits July 10, 2026 23:59
gl read tools parsed node responses with `.json().await?` and never
checked the HTTP status, so a visibility-gated 404/5xx body was handed
back to the agent as the requested resource with Ok (INV-8 break).

Add a shared `read_json` helper (http.rs): surfaces a non-2xx as an Err
carrying the sanitized node message (INV-6, reusing sanitize_node_msg,
now pub(crate)), and errors on a garbage 2xx body instead of a silent
empty success. Route repo_get / repo_commits / repo_tree through it.

Denial-path tests drive a gated 404 through call_tool and assert Err
(not a fabricated repo / empty commit list); verified load-bearing by
reverting the wiring to red.
Route `gl repo commits` / `gl repo labels` (repo.rs) and `gl pr`
list/view/diff/comments (pr.rs) through read_json, so a gated 404/5xx
surfaces as an error instead of an empty render ("No commits", "No pull
requests", "No diff"). The pr.rs mutation commands already status-check
and are left as-is. Denial-path test per read arm.
MCP pr_list / pr_view / pr_diff, webhook_list (the client half of #94),
and issue_list; CLI `gl issue list`/`comments` and `gl webhook list`.
All route through read_json so a gated denial surfaces as an error, not
a fabricated or empty result. Denial-path tests for the MCP arms.
… status (#123)

cmd_info: sanitize the node-advertised message before it reaches the
terminal (INV-6), preserving the existing bare-404 wording. gl status:
a gated 404 on the PR/issue dashboard sections now renders
"unavailable (<status>)" instead of silently "no open PRs" (INV-8),
without hard-failing the multi-section command.

Classification for the rest of the U6 sweep (recorded, none converted):
whoami repo-count lists the caller's OWN repos (best-effort, not a
withheld-resource read); task/cert/bounty/peer reads are not
repo-confidentiality surfaces. gl mutation commands already status-check.
Completeness-sweep catch: /api/v1/repos/{owner}/{name}/certs is a
repo-scoped gated read that rendered a denial as an empty cert list.
Route it through read_json.
…l tests (#123)

Code review caught a missed gated read arm: `gl bounty list --repo o/n`
and MCP bounty_list hit the authorize_repo_read-gated
/api/v1/repos/{o}/{n}/bounties and rendered a denial as a silent-empty
success (INV-8). Route both through read_json.

Add denial-path tests for the converted-but-untested arms the review
flagged: bounty (repo-scoped), cert (was zero coverage), MCP pr_view /
pr_diff, CLI issue list, CLI webhook list.
…123)

The gl status PR/issue sections rendered a gated 404 via inline println!
that no test could reach (the sections only run with a gitlawb remote,
and output isn't capturable). Extract the decision into a pure
section_unavailable_line helper: non-2xx -> 'unavailable (<status>)'
(INV-8), 2xx -> None (caller renders), transport error -> None (degrade
silently, R5). Tested all three branches; the 404 case is load-bearing
(proven RED when the guard is neutered).
Add read_json edge tests (empty 2xx body -> Err; non-2xx JSON without a
message key -> 'request failed' fallback) and a denial test for the
standalone `gl pr comments` arm. Every read_json-routed denial test is
now load-bearing: mutating read_json to the pre-fix bare-parse turns all
18 arm denial tests + 7 helper error tests RED while the 271 happy-path
tests stay green.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR introduces centralized, status-aware JSON response handling through read_json, adopts it across CLI commands and MCP tools, updates sync and status rendering, and adds denial, sanitization, malformed-body, and mock-invocation tests.

Changes

HTTP denial handling

Layer / File(s) Summary
Shared response parsing and regression coverage
crates/gl/src/http.rs, crates/gl/src/sync.rs, crates/gl/tests/no_parse_before_status.rs
read_json validates 2xx JSON, surfaces non-2xx statuses with bounded sanitized messages, and tests malformed, empty, and capped bodies. A regression test detects parse-before-status patterns.
CLI and sync integration
crates/gl/src/*.rs
Agent, bounty, certificate, changelog, issue, peer, PR, protection, registration, repository, star, task, visibility, webhook, and sync paths use read_json; denial tests verify errors instead of fabricated or empty results.
MCP tool integration
crates/gl/src/mcp.rs
Node, repository, PR, webhook, bounty, task, issue, agent, and owner-resolution tools use read_json; git_refs checks status before parsing, with broad denial coverage.
Status denial rendering
crates/gl/src/status.rs
PR and issue sections render non-2xx responses as unavailable with the HTTP status.

Estimated code review effort: 4 (Complex) | ~70 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Handler as CLI or MCP handler
  participant NodeClient
  participant read_json
  Handler->>NodeClient: Request resource
  NodeClient-->>read_json: HTTP response
  alt 2xx
    read_json->>read_json: Parse JSON
    read_json-->>Handler: Parsed value
  else non-2xx
    read_json->>read_json: Read capped and sanitize message
    read_json-->>Handler: Error with status and message
  end
Loading

Possibly related issues

Possibly related PRs

  • Gitlawb/node#111: Changes /api/v1/stats visibility-gated semantics that align with the client’s fail-closed stats handling.
  • Gitlawb/node#161: Shares sync denial, bounded-body, and sanitized-message handling.

Suggested labels: sev:medium, subsystem:api

Suggested reviewers: jatmn

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but doesn't follow the required template sections like Summary, Motivation & context, Kind of change, or reviewer verification. Rewrite the description to match the template headings, add a concrete motivation and change summary, and include verification steps/commands in the code block.
Docstring Coverage ⚠️ Warning Docstring coverage is 64.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: surfacing node denials as errors instead of fabricated client results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-123-client-read-status-check

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (3)
crates/gl/src/cert.rs (1)

207-229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same missing mock-hit verification as the other new denial tests.

No .expect(1)/m.assert() on the mock, so an unmatched request (mockito's default 501 for non-matches) would also make read_json return Err, letting the test pass even if the anchored regex never actually matched the path cmd_list built.

♻️ Proposed fix
         let _m = server
             .mock(
                 "GET",
                 mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/certs$".to_string()),
             )
             .with_status(404)
             .with_header("content-type", "application/json")
             .with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
+            .expect(1)
             .create_async()
             .await;
         let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
         assert!(result.is_err(), "cert list must Err on a gated 404");
+        _m.assert_async().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gl/src/cert.rs` around lines 207 - 229, Ensure the
cmd_list_surfaces_denial_not_empty test verifies that its mock was hit exactly
once by calling the mockito mock’s assert/expect(1) method after cmd_list
completes, so unmatched requests cannot satisfy the test.
crates/gl/src/issue.rs (1)

739-756: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same missing mock-hit verification as the analogous bounty/cert denial tests.

Since mockito returns 501 for any unmatched request, and 501 is also non-2xx, read_json still errors regardless of whether the regex actually matched the path cmd_list produced — add .expect(1)/m.assert() so the test genuinely proves the gated issues-list path is exercised.

♻️ Proposed fix
         let _m = server
             .mock(
                 "GET",
                 mockito::Matcher::Regex(r"^/api/v1/repos/alice/secret/issues$".to_string()),
             )
             .with_status(404)
             .with_header("content-type", "application/json")
             .with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
+            .expect(1)
             .create_async()
             .await;
         let result = cmd_list("alice/secret".to_string(), server.url(), None).await;
         assert!(result.is_err(), "issue list must Err on a gated 404");
+        _m.assert_async().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gl/src/issue.rs` around lines 739 - 756, Add mock-hit verification to
cmd_list_surfaces_denial_not_empty by retaining the mock handle and calling its
assert method after cmd_list completes (or configuring it with expect(1)),
ensuring the intended issues-list request matched the configured route.
crates/gl/src/bounty.rs (1)

550-571: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Denial test doesn't verify the mocked endpoint was actually hit.

The mock has no .expect(1)/m.assert(). Per mockito's own docs, Any calls to the Mockito server that are not matched will return 501 Not Implemented. Since 501 is also non-2xx, read_json would still return Err even if the URL built by cmd_list didn't match the regex at all — so the test can pass without actually proving the repo-scoped bounties path is constructed/gated correctly.

♻️ Proposed fix
         let _m = server
             .mock(
                 "GET",
                 mockito::Matcher::Regex(r"/repos/alice/secret/bounties".to_string()),
             )
             .with_status(404)
             .with_header("content-type", "application/json")
             .with_body(r#"{"message":"repository 'alice/secret' not found"}"#)
+            .expect(1)
             .create_async()
             .await;
         let result = cmd_list(Some("alice/secret".to_string()), None, server.url(), None).await;
         assert!(
             result.is_err(),
             "bounty list --repo must Err on a gated 404"
         );
+        _m.assert_async().await;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gl/src/bounty.rs` around lines 550 - 571, Strengthen
cmd_list_repo_scoped_surfaces_denial_not_empty by configuring the Mockito
expectation to require exactly one request and explicitly asserting the mock was
matched after cmd_list completes. Use the existing _m mock handle’s
expectation/assertion API so the test fails if the repo-scoped
/repos/alice/secret/bounties endpoint is not requested.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gl/src/bounty.rs`:
- Around line 550-571: Strengthen cmd_list_repo_scoped_surfaces_denial_not_empty
by configuring the Mockito expectation to require exactly one request and
explicitly asserting the mock was matched after cmd_list completes. Use the
existing _m mock handle’s expectation/assertion API so the test fails if the
repo-scoped /repos/alice/secret/bounties endpoint is not requested.

In `@crates/gl/src/cert.rs`:
- Around line 207-229: Ensure the cmd_list_surfaces_denial_not_empty test
verifies that its mock was hit exactly once by calling the mockito mock’s
assert/expect(1) method after cmd_list completes, so unmatched requests cannot
satisfy the test.

In `@crates/gl/src/issue.rs`:
- Around line 739-756: Add mock-hit verification to
cmd_list_surfaces_denial_not_empty by retaining the mock handle and calling its
assert method after cmd_list completes (or configuring it with expect(1)),
ensuring the intended issues-list request matched the configured route.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f7b1bd9-cb1b-46ef-a606-2bd697fa0fea

📥 Commits

Reviewing files that changed from the base of the PR and between ad7c2b2 and 6d356e0.

📒 Files selected for processing (10)
  • crates/gl/src/bounty.rs
  • crates/gl/src/cert.rs
  • crates/gl/src/http.rs
  • crates/gl/src/issue.rs
  • crates/gl/src/mcp.rs
  • crates/gl/src/pr.rs
  • crates/gl/src/repo.rs
  • crates/gl/src/status.rs
  • crates/gl/src/sync.rs
  • crates/gl/src/webhook.rs

t added 9 commits July 11, 2026 13:07
…er-error tests so a non-matching route can't pass vacuously (#123)
…nc, star, agent, protect, changelog) so a non-matching route can't pass vacuously (#123)
…ty show, issue show/close/comment, webhook create/delete) so a non-matching route can't pass vacuously (#123)
…son so a node error surfaces its status not a vague fallback (#123)
… per-helper denial tests that go RED without the conversion (#123)
@beardthelion beardthelion changed the title fix(gl): status-check read tools so a gated denial isn't rendered as a result (#123) fix(gl): status-check the client read/write surfaces so a node denial surfaces as an error, not a fake result (#123) Jul 11, 2026

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (2)
crates/gl/src/changelog.rs (1)

57-68: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Main changelog fetch still bypasses read_json/sanitize_node_msg.

The status check here is correct (no fabricated-success risk), but the error message is taken raw from body["message"] without the sanitization read_json now applies elsewhere. Converging this call onto crate::http::read_json would close the same terminal-injection gap already fixed for sync trigger and the line-45 fetch in this same function.

♻️ Suggested consolidation
-    let resp = client
-        .get(&url)
-        .await
-        .context("failed to connect to node")?;
-
-    let status = resp.status();
-    let body: Value = resp.json().await.unwrap_or_default();
-
-    if !status.is_success() {
-        let msg = body["message"].as_str().unwrap_or("unknown error");
-        anyhow::bail!("changelog failed ({status}): {msg}");
-    }
+    let body = crate::http::read_json(
+        client.get(&url).await.context("failed to connect to node")?,
+        "changelog",
+    )
+    .await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gl/src/changelog.rs` around lines 57 - 68, Update the main changelog
fetch in the surrounding function to use crate::http::read_json, reusing its
sanitize_node_msg handling for the response error message while preserving the
existing non-success status check and changelog failure behavior. Remove the
direct resp.json parsing path so this fetch follows the same sanitized handling
as the other calls.
crates/gl/src/register.rs (1)

58-67: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

gl register's error path bypasses the new read_json/sanitize_node_msg sanitization.

This function still hand-rolls status/message extraction instead of using crate::http::read_json, so a hostile node's message field is not passed through sanitize_node_msg before being embedded in the error and printed to the terminal — unlike the now-converted agent_register MCP tool that hits the same /api/register endpoint. Worth converging for consistency and to close the control-char/ANSI-injection gap already fixed elsewhere in this PR.

♻️ Suggested consolidation
-    let status = resp.status();
-    let payload: Value = resp.json().await.context("invalid JSON response")?;
-
-    if !status.is_success() {
-        let msg = payload
-            .get("message")
-            .and_then(|v| v.as_str())
-            .unwrap_or("unknown error");
-        anyhow::bail!("registration failed ({status}): {msg}");
-    }
+    let payload = crate::http::read_json(resp, "registration").await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/gl/src/register.rs` around lines 58 - 67, Update the registration flow
in crates/gl/src/register.rs to use crate::http::read_json for the /api/register
response instead of manually parsing the status and message from resp. Preserve
the existing registration failure behavior while ensuring error messages pass
through sanitize_node_msg via the shared helper, matching agent_register.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/gl/src/changelog.rs`:
- Around line 57-68: Update the main changelog fetch in the surrounding function
to use crate::http::read_json, reusing its sanitize_node_msg handling for the
response error message while preserving the existing non-success status check
and changelog failure behavior. Remove the direct resp.json parsing path so this
fetch follows the same sanitized handling as the other calls.

In `@crates/gl/src/register.rs`:
- Around line 58-67: Update the registration flow in crates/gl/src/register.rs
to use crate::http::read_json for the /api/register response instead of manually
parsing the status and message from resp. Preserve the existing registration
failure behavior while ensuring error messages pass through sanitize_node_msg
via the shared helper, matching agent_register.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f429da4-22e0-47c0-956d-ea60ced0434a

📥 Commits

Reviewing files that changed from the base of the PR and between 6d356e0 and 975a5be.

📒 Files selected for processing (16)
  • crates/gl/src/agent.rs
  • crates/gl/src/bounty.rs
  • crates/gl/src/cert.rs
  • crates/gl/src/changelog.rs
  • crates/gl/src/issue.rs
  • crates/gl/src/mcp.rs
  • crates/gl/src/peer.rs
  • crates/gl/src/pr.rs
  • crates/gl/src/protect.rs
  • crates/gl/src/register.rs
  • crates/gl/src/repo.rs
  • crates/gl/src/star.rs
  • crates/gl/src/sync.rs
  • crates/gl/src/task.rs
  • crates/gl/src/visibility.rs
  • crates/gl/src/webhook.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/gl/src/star.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/gl/src/bounty.rs
  • crates/gl/src/cert.rs
  • crates/gl/src/repo.rs
  • crates/gl/src/pr.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Status-check the CLI bounty-stats response
    crates/gl/src/bounty.rs:387
    cmd_stats still deserializes /api/v1/bounties/stats with unwrap_or_default() and never checks the HTTP status. Consequently, a 403/404/500 (including a non-JSON body) is printed as a successful Bounty Stats result with every counter set to zero. This is the same denial-as-fake-result behavior the PR fixes elsewhere, and the MCP twin already routes this endpoint through read_json. Route this CLI request through read_json and add a non-2xx regression test.

  • [P2] Bound error bodies in the shared response helper
    crates/gl/src/http.rs:204
    The new read_json helper calls resp.json() on every non-2xx response before it extracts and truncates message. That buffers and parses the complete response, so a configured or malicious node can stream an arbitrarily large valid JSON error body and exhaust the CLI/MCP process's memory across the newly migrated calls. The 200-character display cap does not bound allocation, and sync::read_body_capped already implements the required capped-read pattern for the same hostile-node error path. Read a small capped body before parsing its message, then add a large-error-body regression test.

F1: cmd_stats deserialized /api/v1/bounties/stats with unwrap_or_default() and
never checked the HTTP status, so a 403/404/5xx (or non-JSON body) printed a
successful Bounty Stats with every counter zeroed — the denial-as-fake-result
class this PR fixes elsewhere. Route it through read_json (like the sibling
bounty commands and the MCP twin).

F2: read_json called resp.json() on every non-2xx response, buffering and parsing
the whole body before the 200-char DISPLAY cap applied, so a hostile node could
stream an arbitrarily large valid JSON error and exhaust the process's memory.
Read a capped body (read_body_capped, 8 KiB — the same bound the sync error path
uses) and best-effort extract message from it; a message-less or non-JSON body
still falls back to the status alone. read_body_capped is now pub(crate).

RED->GREEN: cmd_stats_surfaces_denial_not_fake_result (403 -> Err, was Ok+zeros).
read_json_bounds_the_error_body_read (a message placed past the cap is ABSENT
from the error; was surfaced by the full parse). All existing read_json + stats
tests stay green; gl suite 339, fmt + clippy clean.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both addressed on 85dfbd2.

Stats status check (F1). cmd_stats now routes /api/v1/bounties/stats through read_json, like the sibling bounty commands and the MCP twin, so a 403/404/5xx (including a non-JSON body) surfaces as an error instead of a Bounty Stats with every counter zeroed. Covered across the non-2xx classes, not just one: cmd_stats_surfaces_denial_across_status_classes drives 404 and 500, and reverting the routing shows both returning the fake zeroed success.

Bounded error body (F2). read_json now reads a capped 8 KiB body via read_body_capped (the same bound the sync error path uses, now pub(crate)) before parsing message, so a hostile node streaming an arbitrarily large valid JSON error can no longer exhaust the process. The prior error-text contract is unchanged: a JSON message is surfaced when present in the capped body, and a non-JSON, message-less, or empty body falls back to the status alone. The regression test places message past the cap and asserts it is absent from the surfaced error; before the fix the full parse reached it.

Each fix is RED->GREEN with the guard reverted to confirm it is load-bearing.

@beardthelion
beardthelion requested a review from jatmn July 13, 2026 23:59

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Apply the capped/sanitized error path to the remaining manual status handlers
    crates/gl/src/bounty.rs:193
    This command (and other changed manual handlers such as agent, issue, pr, repo, register, star, protect, visibility, and webhook) calls resp.json() before inspecting a non-2xx status. A configured or malicious node can therefore return an arbitrarily large valid JSON error and make gl buffer it in full, and several of these paths print its message without the new terminal-control sanitization. That leaves the exact hostile-error-body failure mode that http::read_json now documents and caps. Check the status before a bounded error read (or route these arms through the shared helper) and retain the command-specific success handling.

…fore-parse)

~40 handler call sites still parsed resp.json() before checking status, so a
hostile/misconfigured node could make gl buffer an arbitrarily large JSON error
body (unbounded) and print the node message unsanitized. Route them all through
crate::http::read_json (status-first, capped+sanitized error, JSON-or-Err on 2xx).
Two GET / node-info reads (peer cmd_add, cert cmd_show) had NO status check at
all — a denied node-info was consumed as success (INV-8); they now surface the
denial as Err. Bespoke branches preserved: agent 404 'node does not support
agents API' hint, repo cmd_info 404 'repository not found'. 341 gl tests green.
…status gate

Adversarial coverage for the #186 read_json conversions:
- peer cmd_add / cert cmd_show: a denied `GET /` node-info now surfaces as an Err
  instead of being parsed as success (INV-8). RED without the read_json conversion.
- star cmd_add: a hostile 500 carrying terminal-control + bidi bytes and a long
  message reaches the terminal neither verbatim (sanitized) nor unbounded.
- new tests/no_parse_before_status.rs: a source-level completeness gate that fails
  if any converted handler reintroduces a resp.json()-before-status bypass. Proven
  load-bearing (reverting one converted site flags it RED). Pre-existing bypasses
  in files this PR did not touch (init/mirror/profile/quickstart/whoami) are known
  debt, out of this gate's scope.
The #186 restructure of cmd_info kept the bespoke 404 'repository not found'
message ahead of read_json and routed every other status through it. Pin both:
a 404 still yields the bespoke text; a 500 yields read_json's 'repo info failed
(500)'.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Done at a180ed3.

Routed every remaining manual-status handler through read_json — the ~40 sites across bounty, changelog, protect, star, visibility, issue, pr, register, webhook, peer, cert, repo, and agent that parsed resp.json() before checking status now go status-first, capped, and sanitized. Success handling is unchanged; the existing per-handler denial tests stay green because read_json's error text matches the old "{op} failed ({status})" shape.

While scoping it I found two sites the finding didn't name that are worse than the P2: peer cmd_add and cert cmd_show fetch node-info with GET / and no status check at all, so a denied/error response was parsed as success (INV-8) — peer then announced the fallback URL, cert printed a spurious "does not match" warning. Both now surface the denial as an Err. Regression tests for both; RED-checked (reverting the conversion makes them fail).

Bespoke branches preserved: the agent 404 "does not support agents API" hint and repo cmd_info's 404 "repository not found", both with tests (cmd_info also gets a 500 test proving the rest routes through read_json).

To stop this class from creeping back I added crates/gl/tests/no_parse_before_status.rs — a source-level gate that fails if any converted handler reintroduces a resp.json()-before-status read. Proven load-bearing: reverting one converted site flags it RED. There's also a capped+sanitized end-to-end test on a converted handler (a hostile 500 with terminal-control/bidi + an oversized message reaches the terminal neither verbatim nor unbounded).

cargo test -p gl 346 + 1 gate green; fmt and clippy -D warnings clean.

One scope note: the gate surfaced the same bypass idiom in init.rs, mirror.rs, profile.rs, quickstart.rs, and whoami.rs — all files this PR never touched (pre-existing). I left them out to keep this PR to its finding and marked them as known debt in the gate's doc comment; happy to fold them in here or file a follow-up, your call.

@jatmn ready for another look.

@beardthelion
beardthelion requested a review from jatmn July 15, 2026 05:35

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/gl/src/star.rs`:
- Around line 343-369: Update the test around cmd_add to retain the created mock
handle and assert it was hit after the command completes. Ensure the assertion
verifies the /star mock received the expected PUT request before evaluating the
sanitized error contents, so a route mismatch cannot satisfy the test.

In `@crates/gl/tests/no_parse_before_status.rs`:
- Around line 23-37: Add "task" to the CONVERTED list in
no_parse_before_status.rs so the gate covers the converted task handler and
detects parse-before-status regressions in task.rs.
- Around line 50-56: Update the parse detection in the test around the
`.json().await` check to structurally identify direct Response::json calls,
including turbofish arguments and split-line method chains, while excluding
calls routed through read_json. Preserve the existing status-window validation
and any explicitly intended exceptions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5404c35-a02e-4774-a405-afc3816b310c

📥 Commits

Reviewing files that changed from the base of the PR and between 85dfbd2 and a180ed3.

📒 Files selected for processing (14)
  • crates/gl/src/agent.rs
  • crates/gl/src/bounty.rs
  • crates/gl/src/cert.rs
  • crates/gl/src/changelog.rs
  • crates/gl/src/issue.rs
  • crates/gl/src/peer.rs
  • crates/gl/src/pr.rs
  • crates/gl/src/protect.rs
  • crates/gl/src/register.rs
  • crates/gl/src/repo.rs
  • crates/gl/src/star.rs
  • crates/gl/src/visibility.rs
  • crates/gl/src/webhook.rs
  • crates/gl/tests/no_parse_before_status.rs

Comment thread crates/gl/src/star.rs
Comment thread crates/gl/tests/no_parse_before_status.rs Outdated
Comment thread crates/gl/tests/no_parse_before_status.rs Outdated
…ile mock hit (#186)

Address the three CodeRabbit threads on the #186 client-read-status work,
all INV-21 (load-bearing / completeness) findings.

no_parse_before_status.rs: the gate hand-listed the converted handlers, and
the list had already drifted (task, mcp, and sync all call read_json but were
absent, so a parse-before-status regression in them would not fail the gate).
Derive the scanned set from the source tree instead: every src/*.rs that
references read_json (minus http.rs, the definition site), so a converted
handler auto-enrolls. The known out-of-scope debt (init/mirror/profile/
quickstart/whoami) uses raw resp.json().await and no read_json, so the
derivation excludes it without a skip list. Detection now anchors on .json(
and .json::<, catching turbofish resp.json::<Value>().await and split-line
chains the old bare .json().await substring missed, still keyed on an
is_success() that lands after the parse so status-first probes stay green.

star.rs: the cmd_add hostile-response test never asserted the mock was hit, so
a route mismatch would serve mockito's fallback and satisfy every assertion
without exercising the sanitization. Add .expect(1) and _m.assert_async().
@beardthelion

Copy link
Copy Markdown
Collaborator Author

@jatmn the manual-handler status finding was already addressed on the current head (e3e21e0); your review was against 85dfbd2, before that conversion pass landed. On the current head every handler you named routes its error read through crate::http::read_json (status-first, capped, sanitized): bounty, agent, issue, pr, register, star, protect, visibility, webhook all have zero raw resp.json() calls.

The only raw parses left are three in repo.rs (368, 643, 658), and each is status-first and reached only on 2xx: line 640 bails before 643, and the 367/657 guards precede 368/658. So a hostile node's large non-2xx error body is never buffered on those paths — they parse only a success body.

The three CodeRabbit threads on this head are also resolved: the completeness gate now derives its scanned set from read_json usage (so task, and also mcp/sync, can't be silently omitted), the parse detection catches turbofish and split-line chains, and the star hostile-response test now asserts the mock was hit. Each verified RED/GREEN by execution.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Preserve the fail-soft peer URL fallback
    crates/gl/src/peer.rs:96
    gl peer add only needs GET / to improve the URL it advertises; the existing public_url fallback intentionally uses --node when that optional lookup has no usable value. Routing the lookup through read_json now returns before the remote /api/v1/peers/announce request whenever the local root endpoint returns a JSON 4xx/5xx, even though that remote announcement can still succeed with the fallback URL. This regresses the command's degraded-mode behavior and contradicts the PR's stated decision to leave the peer-add public-URL lookup fail-soft. Keep the remote announcement reachable on an unavailable local node-info response, while retaining status checking for the actual announce request.

  • [P2] Do not make the post-fetch certificate hint fatal
    crates/gl/src/cert.rs:140
    The certificate has already been fetched and printed when this ancillary GET / runs; it is used only to compare the certificate's node DID with the current node and print offline-verification guidance. A JSON 4xx/5xx response previously left the DID empty and emitted the mismatch/degraded hint, but read_json(...)? now aborts gl cert show after its partial certificate output and omits that guidance. The PR explicitly identifies this node-DID hint as a diagnostic/fail-soft path that should stay unchanged. Handle an unavailable node-info lookup as a degraded comparison instead of failing the completed certificate-show operation.

t added 3 commits July 16, 2026 12:29
The local GET / in cmd_add only improves the URL we advertise; a
response-level failure now falls back to --node with a stderr note
instead of aborting before the announce, which keeps its fail-closed
read_json check. Guards pin the fallback (500 and non-JSON body), the
announce denial, and the public_url success path.
The post-print GET / only feeds the node-DID comparison hint; a
response-level failure now prints an explicit could-not-compare note
with the offline-verification guidance and the command completes. The
certificate fetch keeps its fail-closed read_json check, pinned by a
denied-cert guard whose GET / mock asserts expect(0).
…ricate a DID mismatch (#186)

Review findings on the fail-soft restoration: the follow-up local
peer-list add printed an unconditional success line over an ignored
POST result (now status-gated, with a stderr note on failure, still
fail-soft); a 200 node-info body carrying no usable DID fell into the
empty-string comparison and printed a fabricated 'does not match ()'
warning (now routed to the could-not-compare hint via the extracted
did_check_report helper, whose unit tests pin all four outcomes); the
restructured mismatch branch gained an end-to-end test; the denied-
announce test's info mock expectation is now asserted.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both addressed on 06863da (5eff606 peer, 46fc330 cert, 06863da hardening from our own review pass).

Fail-soft peer URL fallback. The local GET / lookup falls back to the --node value with a stderr note on any response-level failure, and the announce proceeds; the announce POST keeps its fail-closed read_json check. Guards: cmd_add_announces_fallback_url_when_node_info_denied (500 JSON body; the announce mock body-matches the fallback URL) and cmd_add_falls_back_on_malformed_node_info. Both ran RED against e3e21e0 (the command errored before the announce) and GREEN after. The must-not case stays pinned: a 403 announce still errors (cmd_add_surfaces_denied_announce).

Non-fatal cert hint. gl cert show now completes and prints an explicit could-not-compare hint when the node-info lookup fails at response level; the certificate fetch stays fail-closed (cmd_show_surfaces_denied_certificate: a 404 errors, and the GET / mock asserts expect(0), so the lookup never runs). Same RED-then-GREEN observation for the two degrade guards.

The boundary, named: response-level failures degrade (any non-2xx, a 2xx with a malformed body, and for cert show a 2xx carrying no usable did); transport-level failures still error, as on main (the ? on the GET is unchanged in both commands). That is deliberately broader than main in two spots. Main's non-JSON-body fatality was an accident of .json().context(...)? rather than a decision, and a no-DID 200 body previously fell into the empty-string comparison and printed a fabricated "does not match ()" warning. Both now degrade visibly instead.

Hardening in the same push: the follow-up local peer-list add no longer prints "Added to local peer list." over an ignored POST result (status-gated, still best-effort), and the cert DID verdict selection moved into a small did_check_report helper whose unit tests pin all four outcomes (match, mismatch, no DID, lookup error), so the hint text is asserted by execution. Full gl suite, clippy with -D warnings, and fmt are green.

@beardthelion
beardthelion requested a review from jatmn July 16, 2026 18:15

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Keep the regression gate's converted-handler set independent of read_json usage
    crates/gl/tests/no_parse_before_status.rs:33
    The test chooses files to inspect only when their current text contains read_json. A full regression of a converted single-call handler therefore removes that marker and drops the file from the scan: for example, restoring register.rs to its previous resp.json().await followed by is_success() leaves no read_json reference in that file, while the test still has other handlers and passes. This defeats the stated guard against exactly the parse-before-status regression the PR is adding it to catch. Keep an expected set of converted handler files (and assert it is scanned), or derive that set from an independent source, so removal of the final helper call fails the test.

…ail closed both ways

The gate derived its scanned set from read_json usage, but a converted
handler with a single node call (register.rs) reverted to resp.json().await
loses the marker, drops out of the derived set, and its bypass goes unscanned.
Pin the converted surface as CONVERTED_IN_186 and assert the derived read_json
set EQUALS it, failing closed in both directions: a pinned file that drops out
was reverted off read_json (RED), and a file using read_json that is not pinned
is an unenrolled conversion whose own later revert would escape the same way
(RED until it is added). Equality extends the protection to handlers converted
after #186, not just the original sixteen.

Verified by execution: reverting register.rs's one read_json call trips the
deconverted assert; a new read_json handler left unpinned trips the unpinned
assert; a partial revert in a file that keeps read_json is still caught by the
offender scan; and the pre-change gate passed on the register.rs revert.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

derive that set from an independent source, so removal of the final helper call fails the test

Fixed on de781aa. You're right that the set keyed on the same read_json marker a full revert deletes, so register.rs (its only read_json) dropped out of the scan and the bypass went unseen. CONVERTED_IN_186 now pins the converted surface and the gate asserts the derived read_json set equals it, failing closed both ways: a pinned file that drops out was reverted off read_json (RED), and a read_json handler that isn't pinned is an unenrolled conversion whose own later revert would escape the same way (RED until it's added).

Verified by execution: reverting register.rs's one call trips the deconverted assert, an unpinned read_json handler trips the unpinned assert, a partial revert that keeps read_json is still caught by the offender scan, and the pre-change gate passed on the register.rs revert.

@beardthelion
beardthelion requested a review from jatmn July 18, 2026 03:30

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Surface failed trust lookups instead of saying the identity is unregistered
    crates/gl/src/status.rs:40
    This branch still sends every non-2xx response from GET /api/v1/agents/{short_key} to the not registered output. Thus a 403, 429, or 5xx for an existing identity is presented as a registration-state result and tells the user to run gl register, while this PR now explicitly renders the analogous PR/issue status failures as unavailable. Keep the fail-soft behavior if desired, but distinguish a genuine not-found response from other HTTP failures and report those as unavailable rather than fabricating an unregistered state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:gl gl — the contributor CLI kind:bug Defect fix — wrong or unsafe behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants