Proc-macro helper for curve25519-dalek (already allowlisted). Newer cargo
versions list it under `cargo tree --edges normal` while older ones do not;
the allowlist is a superset so it passes on both. Caught by core-deps-purity
on the first CI run of this branch.
gitlawb-core is embedded by gl, git-remote-gitlawb, and the node daemon,
so it must stay lean. Add a CI gate that fails if its normal (non-dev,
non-build) transitive dependency tree gains any crate not on an explicit
allowlist.
The allowlist is exhaustive-by-construction: a new heavy dependency reds
CI whether or not anyone thought to ban it, which a denylist cannot do.
The checker resolves like the build/test jobs (no --locked, since the
committed lockfile can lag the manifests) and snapshots/restores
Cargo.lock so a local run leaves no working-tree side effects.
- ci/gitlawb-core-allowed-deps.txt: the 83 current normal deps, plus the
regeneration command and rationale in the header
- scripts/check-gitlawb-core-deps.sh: recompute and diff; hard-fail on a
non-allowlisted crate, note-only on a stale (removed) allowlist entry
- pr-checks.yml: new hard-fail core-deps-purity job, matching the pinned
action SHAs and style of the sibling jobs
Settles the review's design question: a valid signature alone proves
internal consistency — the payload was signed by whatever key the
certificate itself names — so a hostile source could self-sign and
pass. --verify now also requires the issuing node_did to match a
trusted anchor: --expect-node when given, else the DID of the node
being queried; unanchorable (node info unreachable, no --expect-node)
fails closed. The displayed verdict wording says what it proves.
Also from review:
- A frozen-literal test pins gl's payload serialization to the node's
canonical byte form, so serialization drift (new field, a
preserve_order feature unifying across the workspace) fails a test
instead of silently rendering every real certificate INVALID.
- The printed unalias remediation uses the idempotent-safe
'unalias gl 2>/dev/null' form everywhere (doctor, install.sh, brew
caveats), matching what doctor already suggested.
- Acknowledged, deliberately left: bash-side alias detection and
127/8-range loopback matching (noted as out of scope for a
warning-level heuristic).
- cert show: the node-DID comparison is contextual; a node-info fetch
error now prints a note instead of failing the command after the
certificate and verdict were already shown.
- doctor: loopback detection parses the URL host exactly — substring
matching misread localhost.example and localhost-in-path URLs.
- init: a detached HEAD gets its own hint (switch to a branch first)
instead of a guessed 'git push gitlawb main'.
- status: every fetch AND push URL of each remote is inspected — a
gitlawb:// push URL behind an https fetch URL now counts.
- doctor/install.sh: unalias detection is command-aware — comments,
'unalias global', and gl-only-in-a-trailing-comment no longer
suppress the warning (BSD-grep-compatible pattern; ordering vs
oh-my-zsh load documented as accepted slack). Eight new test cases.
We ship `gl` and `git-remote-gitlawb` to Windows users via release.yml but run
no Windows tests: pr-checks.yml is ubuntu-only, and the single windows-latest in
the repo is the release build step, which never runs `cargo test`. So changes to
the shipped Windows CLIs (e.g. the multi-round fetch fix in #192) reach Windows
users with zero test runs on Windows.
Add a `test-windows` job that runs `cargo test -p gl -p git-remote-gitlawb` on
windows-latest with `continue-on-error: true`, mirroring the informational beta
lane: a Windows-only failure is visible on the PR but does not block merge. The
two crates are the client CLIs and depend on no database, so the job needs no
Postgres service (service containers do not run on windows-latest regardless);
git is preinstalled on the runner for the real_git_fetch harness.
This makes the #[cfg(windows)] regressions from #192 load-bearing instead of
never-run. Un-gating the currently #[cfg(unix)] harness tests is the deferred
follow-on in #228.
install.sh detects the common shadowing setup (omz git plugin, no
unalias in ~/.zshrc) and prints the fix right after installing; the
generated Homebrew formula gains a caveats block saying the same, so
brew install/upgrade surfaces it too. Catching this at install time
beats letting the first gl invocation print git's error.
oh-my-zsh's default git plugin ships gl='git pull', silently shadowing
gl in every interactive zsh — the symptom is the baffling 'fatal: not a
git repository'. Aliases beat PATH, so no installer can fix it; doctor
now detects the setup (explicit alias gl= in ~/.zshrc, or the omz git
plugin without an unalias) and prescribes the one-line fix. Best-effort
heuristic, warning-level, covered by four unit tests.
A localhost GITLAWB_NODE red-flagged every self-hosted and dev-harness
setup even when the node was healthy; the connectivity check already
fails loudly when the configured node is unreachable. The env check now
passes with an explanatory note.
show printed the signing payload and told the user to verify offline
themselves. It now rebuilds the node's canonical payload (field order
mirrors gitlawb-node cert issuance), derives the Ed25519 key from the
certificate's node DID, verifies, and prints a VALID/INVALID verdict;
--verify makes the exit code reflect it for scripting. Round-trip +
tamper + malformed-signature unit tests included.
- git init -b main (plain-init fallback for git <2.28) so a fresh repo
can't land on master while every hint says main.
- The closing hint checks HEAD: with no commits it prints the
add/commit step first — a bare 'git push gitlawb main' at that point
fails with 'src refspec main does not match any', the exact trap the
acceptance test walked into. The current branch name is used in the
hint either way.
gl init names its remote 'gitlawb', but detect_gitlawb_remote() only
ever read origin — so gl status reported 'not in a gitlawb repo' in the
very repo gl init had just set up. Scan all remotes, preferring origin
(clones) then gitlawb, then any other gitlawb:// URL. (#231 acceptance
test finding)
Address review: the alerter reflected CI events rather than main's
current state, which could both cry wolf and stay silent.
- Freshness gate: a run only opens or closes anything when its commit is
still main's tip — re-running an old red run after a fix has landed no
longer opens a false stop-ship issue (and a stale green can't close a
current one).
- Trigger covers timed_out and startup_failure — both are red mains that
previously alerted nothing. cancelled stays excluded as a human abort.
- Recovery: a green run on the tip comments and closes open ci-main-red
issues, turning the alert into a current-state signal.
- queue: max — the single-slot pending queue let a later completion
displace a pending red alert under burst merges.
- Issue lookup filters out pull requests carrying the label.
- github-script converged on v8.0.0 (SHA verified against the tag) in
pr-triage too.
Two near-simultaneous red-main events could both pass the open-issue
lookup and each create a ci-main-red issue (or 422 racing createLabel).
A workflow concurrency group with cancel-in-progress: false serializes
them; the default single-slot pending queue is enough since a superseded
alert's state is re-read by the newest run anyway.
Two individually-green PRs can break main in combination when the second
merges on checks that ran against a stale base (#113 + #145 compiled fine
apart, but #145 added a field to ReceivedRefUpdate that #113's new test
initializer never set — E0063 on merged main, fixed by #229).
- pr-checks: add merge_group trigger so the suite can gate a GitHub merge
queue, which re-validates every PR against the true merged result.
- main-red-alert: new workflow that opens/updates a labeled issue whenever
a push-to-main PR Checks run fails, so a red main is loud instead of
silent (release-please PRs inherit main breakage, as #182 showed).
buildx's --output name= does no lowercasing (unlike metadata-action),
so ghcr.io/Gitlawb/node is an invalid reference and the per-arch digest
push would fail outright. Compute the lowercase image once in the
resolve step and reuse it, matching the manifest job.
The v0.6.0 release run's multi-arch Docker build wedged for 3+ hours
emulating the arm64 Rust release build under QEMU, and — because the
workflow concurrency group is cancel-in-progress:false — every queued
release run sat blocked behind it.
- docker is now a matrix: amd64 on ubuntu-latest, arm64 on
ubuntu-24.04-arm — both native, mirroring release-binaries. Each leg
smoke-tests the image (--version must report the released version)
and pushes by digest only.
- docker-manifest stitches the digests into the :X.Y.Z, :X.Y and
:latest tags with buildx imagetools, so a half-finished matrix can
never publish a partially-tagged image.
- workflow_dispatch grows docker_backfill_tag (and npm_backfill_tag
becomes optional): each channel backfills independently for an
already-cut release — needed to publish the missing v0.6.0 image.
A backfill run must be a narrow npm-only operation. Without an event
guard, workflow_dispatch also ran release-please with contents+PR write
permissions and could create or advance a release PR as a side effect.
npm-publish is unaffected: its !cancelled() condition tolerates the
skipped need, and all other jobs already gate on release_created.
The npm-publish job silently skipped every release since 0.4.x because
NPM_TOKEN was never configured — the guard made the job green while
@gitlawb/gl sat at 0.3.7 on the registry. Replace token auth entirely:
- npm Trusted Publishing: the job's GitHub OIDC identity (id-token: write)
is exchanged with the registry; no long-lived secret to configure, leak,
or rotate. Provenance is attested automatically. All five @gitlawb
packages exist on the registry now, so OIDC no longer has the
first-publish bootstrap problem that forced token auth in #127.
- Fail loud: the secret guard is gone. If a package's trusted publisher
isn't configured on npmjs.com, the publish errors visibly instead of
green-skipping.
- setup-node 24 + npm >=11.5.1 (OIDC exchange support; older npm falls
back to absent token auth with a confusing error).
- workflow_dispatch backfill: publish an already-cut GitHub release
(e.g. v0.6.0) to npm by tag — binaries come from the existing release
assets, so only the npm-publish job runs.
PRs #113 and #145 raced: #145 added owner_did to ReceivedRefUpdate and
updated its own call sites, but the seed initializer in
list_repo_events_fails_closed_when_repo_lookup_errors (added by #113)
was never updated, so main fails to compile test targets (E0063).
This breaks clippy --all-targets, both test jobs, and MSRV on CI,
including the release PR #182.
owner_did stays None here on purpose: the test exercises the legacy
None-branch slug fallback.
Co-authored-by: Kevin Codex <[email protected]>
* fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo
check_version queried https://api.github.com/repos/gitlawb/releases/releases/latest.
Gitlawb/releases is a separate repo that stopped receiving tags after v0.3.8
(2026-03-30). Releases have shipped directly on Gitlawb/node since v0.4.0, via
release-please, and install.sh already treats Gitlawb/node as the canonical
release source. The old query target meant gl doctor's version check could
never again detect a real update.
Points the check at Gitlawb/node instead, matching install.sh's own default,
and makes the GitHub API base injectable so the fix is covered by a test
against a mocked server rather than the real GitHub API.
Closes#197
* fix(gl): check HTTP status before parsing the release response; strengthen up-to-date test
Addresses CodeRabbit review on #198:
- check_version now checks resp.status().is_success() before parsing the body,
so a GitHub rate-limit or other API error surfaces as its actual HTTP status
instead of a generic "could not parse latest tag".
- test_check_version_up_to_date now also asserts the detail message, so the
test can't silently pass on an unmatched mock request (mockito's fallback for
an unhandled request is a 501, which check_version also maps to Ok).
* test(gl): add unit test for non-2xx check_version response
* fix(gl): remove trailing blank line at EOF in doctor.rs
cargo fmt --all -- --check fails on the extra terminal blank line, and the
fmt + clippy workflow runs that check as a required gate.
* fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by
Ucan::can did strict equality on both resource and action, so a validly
delegated wildcard capability (with: "*", or can: "*" / "repo/admin") could
never match a concrete runtime request even though is_attenuated_by treats
those same values as wildcards during delegation-chain verification.
Not a security issue: the mismatch only produces false negatives (denying a
capability the delegation chain actually grants), never false positives.
Dormant today since no production code path calls can() yet — auth's
caller_authorized_to_push is pure did_matches(), with the UCAN-capability
check noted as future (Phase 2) work.
Makes can() mirror is_attenuated_by's semantics: the stored capability plays
the "parent" role, so with == "*" matches any resource and can == "*" or
"repo/admin" matches any action.
Closes#199
* test: add can_honors_action_wildcard unit test for Ucan::can
* feat(core): add shared is_bidi_format predicate for terminal sanitizers (#183)
Terminal-bound error sanitizers must strip Cf bidi/format controls as well as Cc control bytes (INV-6), or a right-to-left override in attacker-influenceable bytes can visually reorder the displayed line (Trojan Source, CWE-451). gl already did this with a local is_bidi_format; two sibling sanitizers shipped with only the Cc half (#183). Centralize the reordering-subset predicate here so all consumers reuse one audited definition instead of re-deriving the set. Deliberately excludes legitimate Cf chars (ZWJ, soft hyphen).
* refactor(gl): consume shared is_bidi_format from gitlawb-core (#183)
Replace gl's local is_bidi_format copy with the centralized gitlawb-core predicate so exactly one definition exists in the workspace and the three terminal sanitizers cannot drift apart. Behavior-preserving; the existing strip test is unchanged and a must-not-over-strip guard (ASCII + Arabic U+0627 + ZWJ U+200D survive) pins the reordering-only scope.
* fix(git-remote): strip Cf bidi controls from terminal-bound error bodies (#183)
safe_error_body_excerpt filtered only Cc control bytes, so a Cf bidi override in a hostile or MITM'd node's HTTP error body reached the operator's terminal and could visually reorder the line (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core::sanitize::is_bidi_format, without setting pending_space so stripped chars leave no phantom gap. Covered per bidi class plus a transport-path proof through handle_connect, RED observed before the fix.
* fix(icaptcha): strip Cf bidi controls from terminal-bound error excerpts (#183)
sanitize_excerpt filtered only Cc, so a Cf bidi override in a node-advertised iCaptcha service's reason/body reached the terminal on the bail! path (Trojan Source, CWE-451). Drop the reordering Cf subset via the shared gitlawb-core predicate, without pending_space. The existing sanitize_strips_control_chars_and_escapes test asserted only is_control() and so was vacuous for this class (why the gap shipped); a dedicated per-class bidi test now covers it, RED observed before the fix.
* style: apply rustfmt to #183 sanitizer additions
* test(#183): pin a leading-position bidi char in the strip tests
The per-class strip tests placed bidi chars only mid-string. A bidi char at index 0 exercises the empty-excerpt/out path (where the pending_space guard behaves differently), confirming a leading override still yields no phantom leading space. Traced correct; now asserted.
* test(review): pin that bidi chars don't consume the length cap (#183)
Correctness, adversarial, and testing reviewers all flagged that no test locks in the drop-before-count ordering: a dropped bidi char must not consume MAX_ERR_CHARS budget. Behavior verified correct by execution during review; this test guards it against regression (interleaved bidi across the cap still yields exactly MAX_ERR_CHARS visible chars, no phantom spaces).
---------
Co-authored-by: t <t@t>
* test(icaptcha): assert obtain_proof consumes the mocked service
Add an integration test that runs obtain_proof against a mockito server and
asserts both /v1/challenge and /v1/answer were consumed, so the flow is proven
to call the service rather than short-circuit or make a live call. Adds mockito
as a dev-dependency.
* test(icaptcha): block non-mock egress so the no-live-call guard is real (#184)
The mock-consumed assertions only prove the two mock endpoints were hit; they
cannot see a request that also leaks the DID, answer, or API key to DEFAULT_URL
or any other origin, so the test's stated "no live call" guarantee was untested.
Blackhole every non-loopback destination for the flow: NO_PROXY lets the
loopback mock through while ALL_PROXY routes any other host to a closed port, so
the client contacting anything but the injected mock fails the request. Verified
load-bearing: pointing cfg.url at DEFAULT_URL makes obtain_proof fail with a
proxy tunnel error (RED), and the loopback-mock path still passes (GREEN).
* test(icaptcha): blackhole scheme-specific proxy vars so the no-live-call guard covers https
The mock-consumed guard blackholed non-loopback egress with ALL_PROXY only, but
reqwest honors HTTPS_PROXY/https_proxy (and the http equivalents) OVER ALL_PROXY
for https URLs. On a runner whose environment has a working HTTPS_PROXY, a
regression could still leak the DID/answer/API key to the https DEFAULT_URL while
both loopback mock calls are consumed and the test stayed green. Override the
scheme-specific proxy vars (both cases) to the closed port too; NO_PROXY keeps the
loopback mock direct. Single #[test] per process, so the override needs no restore.
Verified: a throwaway repro showed reqwest routing an https request to HTTPS_PROXY
past the ALL_PROXY blackhole (the gap); with the fix the override sends that egress
to the closed port even when a working HTTPS_PROXY is pre-set; the guard still goes
RED when cfg.url points off-loopback.
---------
Co-authored-by: t <t@t>
* fix(node): carry full owner DID on ref-update wire event (#144)
* test(node): add ref-update owner_did round-trip, DB, and API tests (#144)
* fix(node): move owner_did migration to v10, add upgrade-path test
* fix(node): add owner_did to GraphQL, broadcast, and REST feed surfaces (#144)
* fix(node): address review feedback — drop ipfs regression, fix doc, improve migration test, use local owner_did
* fix(node): fix migration v10 test to actually start from v9 schema; complete owner_did API assertion
* fix(node): show local owner on global feed, assert nullable in migration test, add owner_did echo assertions
* fix(node): prefer stored wire owner_did over lossy slug-local resolution; mirror projection in GraphQL
* fix: address review findings — restore iCaptcha PoW/origin matching, fix feed owner_did projections
* fix: match icaptcha-client Cargo.toml ordering with upstream/main
sha2 was at line 16 in our branch but at line 18 in upstream/main.
The 3-way merge in CI's pull/145/merge checkout kept both lines,
causing a 'duplicate key' error. Moving sha2 after tracing matches
upstream/main exactly, so the merge commit will have a single entry.
* fix(node): bind wire owner_did to local repo before surfacing as trusted
The peer-supplied owner_did (HTTP notify + gossipsub receive) and the
repo slug arrive over untrusted paths, yet REST and GraphQL projected the
wire value as trusted feed ownership.
- P1: add Db::resolve_ref_update_owner_did so a wire owner_did is echoed
only when it matches the canonical owner of the actual local repo at that
slug; otherwise it is dropped (returns null). The canonical DID is
returned, never the raw wire bytes.
- P3: legacy owner_did = None rows are now attributed via an exact,
normalized, unique local match (get_repo), not the prefix-tolerant drop
gate, so a cross-method slug collision cannot synthesize the wrong owner.
- Unify REST (global + per-repo gossip) and GraphQL on the shared resolver
so the two surfaces cannot drift. Add tests for forged-wire drop and
exact-legacy attribution.
* style(node): apply cargo fmt to owner_did resolver
* fix: batch owner_did resolution, propagate errors, preserve full DID for mirrors
P2 batch: replace per-row join_all with resolve_ref_update_owner_dids
that issues at most one get_repo query per distinct local repo in the
page rather than one per event row. Applies to both REST and GraphQL
surfaces.
P2 error: REST handler now propagates DB errors (rather than silently
coercing them to null) by calling the batch method with ?.
P2 mirror: when the validated wire DID carries a full method prefix
(e.g. did:key:z...) and the matching repo is a mirror storing only
the bare key, return the full wire DID instead of the bare key to
preserve the DID-method contract.
P1/P3 already addressed in fb553e6.
* fix: rebase onto current main and use single batch SQL for owner DID resolution
Rebased from stale 3ec8cbf base onto current upstream/main (ad7c2b2),
dropping the already-merged iCaptcha work from the diff. The PR diff
is now only the 11 owner-DID files.
The batch owner-DID resolver now issues a single set-based SQL query
with one OR condition per distinct slug, replacing the loop of per-key
get_repo calls. A 200-row page of 200 distinct remote slugs now costs
1 SQL round-trip instead of 200.
P1/P3 already addressed in c64c082. P2 mirror, error propagation,
and the earlier batch refactor preserved in the rebased commits.
---------
Co-authored-by: Gravirei <[email protected]>
* fix(node): owner-gate list_webhooks to stop leaking webhook URLs (#94)
list_webhooks sat on the optional_signature read group with no auth or
ownership check, so any unauthenticated caller who knew a repo's owner/name
could enumerate its webhooks (target URL, created_by_did, events; only the
secret was redacted).
Gate it read-visibility-then-owner, mirroring create_webhook/delete_webhook:
headerless -> 401, non-reader of a private repo -> 404 (existence hidden,
uniform with the sibling read surfaces), non-owner of a public repo -> 403.
Add a list_webhooks row to the authz_guard drift table so the gate can't
silently regress.
* fix(node): read-visibility-gate list_replicas for private repos (#94)
list_replicas sat on optional_signature with no visibility check, so a
private repo's replica URLs were enumerable by anyone who knew owner/name.
Replica lists are a documented public mirror-discovery surface, so gate on
read visibility (authorize_repo_read) rather than owner-only: a public repo
stays anonymously listable, a private repo returns 404 to anyone who cannot
read it.
register_replica registers non-owner DIDs, and a replica operator is not a
visibility reader, so a non-owner replica operator of a private repo gets
404 — the intended contract, pinned by the new test. Add the authz_guard
row.
* fix(node): read-visibility-gate list_protected_branches (#94)
list_protected_branches sat on optional_signature with no visibility check,
leaking a private repo's branch names to any unauthenticated caller. Gate on
read visibility (authorize_repo_read at the root, INV-2 root-listing
exception): public repos stay anonymously listable, private repos 404 to a
non-reader. Add the authz_guard row.
* fix(node): gate list_repo_events for local private repos, keep gossip-only (#94)
list_repo_events served a private repo's ref certificates and gossip ref
metadata (ref names, SHAs) to any unauthenticated caller. The handler also
tolerates a repo it knows only via gossip (no local row) and legitimately
serves its events, so a blanket get_repo->authorize_repo_read swap would have
404'd that federation path.
Gate only the locally-hosted branch: when get_repo returns a record, run
visibility_check on it at the root and 404 on deny before fetching either the
cert or gossip events. A gossip-only repo (no local row) keeps its existing
public federation-feed behavior; the global /api/v1/events/ref-updates feed
remains a separately tracked deferral. Add the authz_guard row (visibility_check
marker, since the gate is direct to avoid a second get_repo).
* fix(gl): sign the webhook list request now that GET /hooks is owner-gated (#94)
`gl webhook list` built its NodeClient with no keypair, so the GET went out
anonymously. With the node now owner-gating GET /hooks, that request returns
401. Thread a --dir through the List subcommand and load the keypair, matching
`gl webhook create`/`delete`, so the listing is signed as the owner.
(Replica and protected-branch listings stay anonymous for public repos under
read-visibility gating, so their gl clients need no change.)
* fix(review): sign the webhook list GET (get_signed), not plain get (#94)
Code review caught that NodeClient::get() never attaches the RFC 9421
signature even when a keypair is loaded — only get_signed/post/put/delete
sign. The earlier gl webhook-list fix loaded the keypair but still called
get(), so `gl webhook list` (and the MCP webhook_list tool) 401'd against
the now-owner-gated endpoint. Switch both to get_signed.
Harden the two cmd_list tests to require a Signature/Signature-Input header on
the mock (verified: they fail against plain get(), pass with get_signed) — the
missing assertion that let the bug through. Add the untested owner-of-private-
repo webhooks path (200 + redaction). Fix the stale module doc and the
authz_guard bucket comment for list_webhooks.
* fix(gl): sign read-visibility GETs so private-repo owners can read their own repos (#94)
The #94 server change read-visibility-gates GET /replicas and
/branches/protected: public repos stay anonymous, private repos require the
owner/reader to authenticate. Four gl read commands sent unsigned GETs and so
broke (404) or silently degraded (empty output) for a private repo's owner:
gl protect list, gl repo owner (silent empty protected list), gl repo info
(silent missing replica count), gl repo replicas (and it had no --dir flag).
Add NodeClient::get_maybe_signed — signs when an identity is present, stays
anonymous otherwise (mirrors the conditional signing of post/put/delete) —
and route the four call sites through it. Add --dir to `gl repo replicas`.
Harden the protect-list tests to assert the request is signed (verified: they
fail on plain get()). Public-repo anonymous reads are unchanged.
* fix(review): sign the primary repo fetch in gl repo info/owner too (#94)
Round-2 review caught that cmd_info and cmd_owner loaded a keypair and signed
their replica/protected-branch sub-fetch but left the PRIMARY GET
/api/v1/repos/{owner}/{name} on plain get(). get_repo is read-visibility-gated,
so for a private repo that primary fetch 404'd first and the command bailed
before reaching the signed sub-fetch — the half-fix never actually let an owner
inspect their own private repo. Route both primary fetches through
get_maybe_signed.
Add the missing signing guards: harden test_cmd_owner_is_owner to require a
signature on both mocks, and add tests for cmd_replicas and cmd_info (both were
untested) asserting the request is signed when an identity is present.
Out of scope (pre-existing, gated before #94, untouched here): gl repo commits,
gl pr list/view/diff, and the MCP repo_get/repo_commits/pr_* tools call other
already-gated read endpoints unsigned and are broken for private repos
independently of this change.
* test(review): execute the previously-reasoned-only paths (#94)
Close the vetting gaps from the review:
- node: a listed reader (non-owner) passes the read gate but is still refused
the webhook list (403), and headerless on an existing-private vs an absent
repo both 401 — proving the 401 fires before any lookup, so it is not an
existence oracle.
- node: the read-visibility surfaces (replicas, protected branches) admit a
listed reader who is not the owner (the allow-list branch of visibility_check),
while a non-reader stranger still 404s.
- gl: get_maybe_signed's anonymous fallback (no identity -> unsigned GET) is now
exercised, RED-proven by flipping it to get_signed (which signs and fails the
no-signature-header assertion).
* test(review): execute the MCP dispatch and gl->node signature seams (#94)
Close the last two reasoned-only paths by execution:
- MCP webhook_list: a call_tool("webhook_list", ...) dispatch test against a
mock node asserts the /hooks request carries a signature (get_signed).
RED-proven: flipping the arm back to get() fails the header assertion.
- gl->node end-to-end: a real RFC-9421 signature produced exactly as the gl
client's get_signed does (gitlawb_core::http_sig::sign_request over GET +
empty body) is run through the node's actual optional_signature middleware,
which verifies it and authorizes the owner (200 + webhook body). RED-proven:
signing over POST while sending GET makes the node reject it. This stitches
the gl signing side and the node verifying side in one test rather than
mocking one end.
* fix(review): fail closed when the repo lookup errors in list_repo_events (#94)
`get_repo(...).await.ok().flatten()` collapsed a lookup Err into None, so a
transient DB failure skipped the read-visibility gate and the handler served a
private repo's gossip ref-update events. Propagate with `?` instead: an error
now maps to AppError::Internal (500) and fails closed, while a genuine Ok(None)
(repo not hosted locally) stays the intentional ungated pass-through.
Add a regression test that forces a get_repo error (drop the column its SELECT
reads) and asserts 500 with no secret in the body. The injection is
self-verifying: it asserts the lookup succeeds pre-drop and errors post-drop, so
the test can't pass vacuously if get_repo's projection changes later.
* fix(node,gl): close residual gaps in repo-read gating (#113)
Address jatmn's review feedback on the read-surface gate:
- gl webhook: resolve owner from the signing identity, not the node DID.
cmd_create/list/delete all routed through the local resolve_owner helper,
which only ever returned the node root DID, so for a repo owned by the
local identity the owner-gated /hooks route returned 404/403. Drop that
helper and reuse repo::resolve_owner_repo_pair (accepts owner/repo, else
derives from --dir/keypair). Fixed across all three subcommands so
create-writes and list/delete-reads stay on the same owner.
- gl webhook list: check the HTTP status before reading `webhooks`. A
401/403/404 body has no webhooks field and previously printed
"No webhooks" and exited 0, hiding the rejection; now bails with the
server message like cmd_create.
- visibility::is_owner: delegate to api::did_matches so the read gate and
require_repo_owner share one normalization. Fixes the under-match (bare
owner stored, full did:key caller, as mirror rows) that denied the owner
with the opaque 404, and the cross-method over-match.
- authz_guard: pin both halves of list_webhooks (authorize_repo_read and
require_repo_owner) so removing the existence-hiding gate fails the drift
test.
cargo fmt/clippy clean; 644 workspace tests pass.
* test(node)+fix(gl): close events reader-coverage gap, align label-list signing
Self-review follow-ups on the read-gate work:
- list_repo_events: add list_repo_events_admits_listed_reader, pinning the
allow-list branch of the events gate (a listed non-owner reader gets 200
with the cert; a non-reader stranger 404s with no leak). Mirrors the
read_visibility_admits_listed_reader coverage the sibling replica and
protected-branch surfaces already had; the events handler was the only
gated read surface missing it.
- gl repo: cmd_label_list now loads an identity and uses get_maybe_signed
instead of a bare anonymous get(), matching cmd_replicas/cmd_info. Public
repos stay anonymously listable; a private-repo owner can read their own
labels once /labels is read-visibility-gated, with no silent client drift.
fmt/clippy clean; 645 workspace tests pass.
* fix(review): pin the label-list signing and the events 404 sha-no-leak
Final-review hardening of the prior two follow-ups:
- gl repo label-list tests: require the signature/signature-input headers on
the signed-branch mocks, and add an anonymous (no-identity) test asserting
the header is absent. Without the header assertion the tests passed even
with a bare get(), so the signing change was not actually pinned
(RED-verified: reverting to get() now fails both signed-branch tests).
- list_repo_events_admits_listed_reader: also assert the cert sha (not just
the ref name) is absent from the stranger's 404 body, matching the
list_repo_events_is_read_visibility_gated leak-check convention.
fmt/clippy clean; 646 workspace tests pass.
* fix(gl): error on 2xx webhook-list body missing the webhooks array (#113)
A 2xx /hooks response without a `webhooks` array could hide a node/API
contract regression. cmd_list now errors with context instead of mapping
it to "No webhooks". An empty-but-present array stays a valid no-webhooks
result. Adds a test for the missing-array case.
* fix(gl,node): scope MCP webhook_list owner to keypair, gate list_labels on read visibility (#113)
- MCP webhook_list: default owner to the signing keypair's short DID (explicit
owner arg still overrides) instead of the node root DID, so the signed,
owner-gated /hooks request resolves to the caller's own repo. Mirrors the
gl webhook list fix. resolve_owner and webhook_create/delete left untouched.
- list_labels: gate on authorize_repo_read so a private repo's label names 404
for non-readers (public repos stay anonymously listable), matching the sibling
read surfaces and making the existing client comment true. Added the
authz_guard marker and a read-visibility integration test.
- gl label list: check HTTP status before reading the body so the now-gated
404 surfaces as an error instead of printing "No labels" and exiting 0,
matching the sibling list clients (replicas, protected branches).
* fix(gl): surface denied repo info reads instead of fabricating a repo (#113)
cmd_info parsed the GET /api/v1/repos/{owner}/{name} body without checking
the HTTP status, so a private-repo JSON 404 was rendered as a fake repo
summary (owner / ? / public / main) and exited 0. Capture the status and
bail on non-2xx, matching the sibling list commands.
Also assert the anonymous 404 label-list body does not leak the label name,
closing the last unread-body branch in the read-visibility gate test.
* test(node): drop #113-gated handlers from KNOWN_UNGATED; sync lockfile to 0.4.0
The repo-scoped egress guard's allowlist (added in #122, merged after this
branch was opened) still listed the read surfaces this branch gates. Now that
list_labels, list_repo_events, list_webhooks, list_replicas, and
list_protected_branches are read-visibility gated, the staleness assert
requires removing them from the allowlist. The #120 surfaces (certs, issues,
bounties, stars) stay listed; git_info_refs stays until #119 lands. Lockfile
versions follow the 0.4.0 release the rebase landed on.
* fix(gl): fail the MCP webhook_list tool on non-2xx node responses
The MCP webhook_list path deserialized the response body and returned it as a
successful tool result without checking the HTTP status, so a 401/403/404 JSON
error (missing identity, wrong owner, private or deleted repo) was reported as
success with the error body as the result. Check status().is_success() before
deserializing and surface the node's message as a tool error, matching the
`gl webhook list` path. Covered by a non-2xx test (RED without the check).
* fix(gl): read the protected_branches key in repo owner
gl repo owner parsed the protected-branch response as a bare array or a
"branches" key, but the node returns {"protected_branches":[...],"count":N},
so it always printed "Protected: (none)" even when branches were protected.
Extract the parse into parse_protected_branches matching the gl protect list
shape, and update the owner tests to mock the real schema instead of bare
arrays (which is why the bug went uncaught).
* test(gl): cover the deny/negative arms reconciled by the merge
The merge changed three gl branches that no existing test executed (they
were only reasoned): resolve_owner_did with no local identity, cmd_info on
an authenticated-but-denied 403, and get_maybe_signed with no keypair. Add
a test per branch asserting the must-not case (error/unsigned, never a
fabricated repo card). Verified load-bearing by mutation: disabling the
cmd_info status gate turns the 403 test red.
---------
Co-authored-by: t <t@t>
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)
The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
* perf(ipfs): check object existence before allowed-blob walk
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
* refactor(ipfs): improve formatting and readability in get_by_cid function
✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.
✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.
• P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.
* refactor(ipfs): streamline object retrieval by separating type and content reading
* docs(ipfs): update get_by_cid comment to reflect split object retrieval
* Run cargo fmt on store.rs
* test(gl): add tests for iCaptcha transparent retry flow (#167)
* fix(clippy): remove redundant borrow in format! arg
* fix(review): use runtime GITLAWB_ICAPTCHA_INSECURE flag instead of cfg(test), add mock assertions
* fix(review): set mock expectations at creation time, tighten insecure scheme check
* fix(test): serialize icaptcha integration tests with global Mutex to prevent env-var race
* fix(icaptcha): compare origins (scheme+host+port) in resolve_solver_url; tighten proof assertion in test
* fix(test): save/restore inherited iCaptcha env vars instead of unconditional remove; share lock in icaptcha-client tests
* fix(test): constrain negative send_once mocks with Matcher::Missing so they actually protect the contract
---------
Co-authored-by: Gravirei <[email protected]>
iCaptcha now requires a proof-of-work with each answer (enforce mode) so that
minting a proof costs CPU — the lever that stops a distributed, multi-IP
proof-farming flood that per-IP rate limits cannot. Teach the sanctioned
client to solve it: parse the `pow` field from the challenge, grind a nonce
whose sha256(challenge:nonce) has >= difficulty leading zero bits, and send it
as `powNonce` on /v1/answer. Bounded search so a hostile difficulty can't
hang the client. Mirrors icaptcha/src/pow.ts. Without this, agents on the
updated client cannot obtain a proof once enforce is on.
New pow module + unit tests; verified end-to-end against the live enforce-mode
service (obtained a level-3 proof incl. PoW).
* fix(node): add per-IP flood brake to repo/agent creation endpoints
The recurring spam-repo floods are not bypassing the iCaptcha gate — they
pass it. The enforced level (3) draws only machine-solvable deterministic
challenges (arithmetic/algebra/sequence), and the /v1/challenge API lets the
caller pin the easy type, so a bot mints a fresh throwaway did:key, solves a
real proof bound to that DID, and creates a repo. The per-DID creation
limiter never trips because each DID makes a single create call.
Add a per-client-IP rate limiter to the creation route group (repos,
register, fork, issues, pulls), reusing the same IpRateLimiter the push path
already uses against the identical DID-farm bypass. It wraps the auth layer
(outermost) so flood traffic is rejected before signature verification.
Default 120/hour/IP, GITLAWB_CREATE_RATE_LIMIT overrides, 0 disables.
* docs(env): document GITLAWB_CREATE_RATE_LIMIT in .env.example
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <[email protected]>
* test(node): unit-test the per-IP creation brake runs before auth
The route-level coverage (repo_creation_is_rate_limited_by_ip) is
#[sqlx::test] and needs Postgres, so it is skipped in the plain unit-test
pass. Add a DB-free middleware test that layers the per-IP creation limiter
over an always-401 auth stand-in and asserts an exhausted IP is rejected
with 429 before auth runs — the ordering the DID-farm flood defense relies
on.
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
* Keep node alive during database outages
* Fix startup-resilience gaps: reachable 503s, bounded retries, seamless handoff
- Route sqlx errors buried in anyhow chains back to AppError::Db and widen
the unavailable class (Io/Tls/pool), so DB outages return 503 instead of
500 — the previous 503 arm was unreachable dead code
- Bound each connect+migrate attempt (GITLAWB_DB_CONNECT_TIMEOUT_SECS) so a
held migration advisory lock cannot wedge the startup retry loop forever
- Share one bound socket between the degraded and full servers and stop
awaiting the degraded drain: no closed-port window during the handoff and
no slow client can block the full server from starting
- Probe the database from /ready (2s bound) and gate Fly routing/deploys on
it, so mid-life outages drain traffic and a bad DATABASE_URL fails the
deploy visibly instead of going green; classify auth/catalog errors as
likely-permanent (error-level log, straight to max backoff)
- Start the metrics listener before the DB connects so the degraded window
is observable; degraded /health now returns 503 so peer pings and uptime
monitors stop counting a DB-less node as alive
- Move the five GITLAWB_DB_* knobs onto clap Config (validated, in --help),
drop the redundant outer connect timeout, default the pool to 20 conns,
fix the backoff exponent cap that defeated GITLAWB_DB_RETRY_MAX_SECS, and
strip at most one .git suffix in smart-HTTP repo names
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <[email protected]>
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <[email protected]>
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)
The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
* perf(ipfs): check object existence before allowed-blob walk
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
* refactor(ipfs): improve formatting and readability in get_by_cid function
✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.
✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.
• P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.
* refactor(ipfs): streamline object retrieval by separating type and content reading
* docs(ipfs): update get_by_cid comment to reflect split object retrieval
* Run cargo fmt on store.rs
* refactor(visibility): route is_owner through the shared did_matches matcher (#153)
* style(profiles): remove redundant borrow in format arg
---------
Co-authored-by: Gravirei <[email protected]>
* feat(node,git): bound a hung served git with a total-duration timeout (#62)
Part 1 of #62. A served git upload-pack/receive-pack that neither finishes
nor disconnects parks forever, pinning a git PID (and, for receive-pack,
holding the write lock); on a public repo an anonymous caller can trigger
it. No tower TimeoutLayer exists and axum imposes no default.
Wrap the child interaction in tokio::time::timeout, bounded by a new
GITLAWB_GIT_SERVICE_TIMEOUT_SECS knob (default 600, must be positive).
Drain stdout/stderr concurrently with the stdin write so a large body
can't deadlock. On expiry run_git_service returns a typed GitServiceTimeout
and reaps the WHOLE process group before returning (SIGTERM so git clears
its .git/*.lock, wait for the leader and its grandchildren like index-pack
to exit, escalate to SIGKILL past a grace, hard-capped), so a caller
releasing the receive-pack write lock can't race a still-live git on the
same repo.
The handlers map GitServiceTimeout to a distinct 504 (not the generic 500
git error, and clear of the read-gate 404 / auth 401 the client keys
retries on) via a pure, unit-testable classifier rather than matching the
anyhow string. Surface git's own non-zero exit (its stderr) before a
stdin-write EPIPE so a malformed body is classified 400, not 500.
Out of scope, deferred in #62: the info/refs advertisement and the
withheld-blob path (spawn_blocking, which tokio timeout cannot cancel);
both remain unbounded (noted in the config knob's docs).
Tests: the timeout fires and tears the group down; it reaps the whole
group before returning (RED if the reap is leader-only); the wrap is
load-bearing (RED via an outer bound, not a hang); the error must be the
typed timeout; the 504 mapping and classifier are unit-tested; config
rejects 0. Full suite green.
* fix(review): harden #62 timeout tests and drop a dead reap flag
- test(node): cover the `protocol error` arm of git_service_app_error
independently (the input has no "bad line length" substring, so it
isolates the second classifier arm and goes RED if that arm is removed)
- test(node): make the reap-before-return test's grandchild IGNORE SIGTERM
(`trap "" TERM`) and outlive the ~4s reap cap, so the SIGKILL escalation
is load-bearing. Previously the grandchild self-exited under the cap, so
neutering the SIGKILL still passed; the escalation was exercised but not
required. Verified: RED with SIGKILL disabled, GREEN with it.
- test(node): add a receive-pack timeout test. Every prior timeout test used
an upload-pack fake; this proves the push path (which also holds the repo
write lock) is bounded too. RED-verified with the internal timeout removed.
- test(node): poll for the pidfile in the reap-before-return timeout test
so a loaded runner can't false-panic before the fake git writes it
- remove the redundant `sigkilled` guard in reap_group_on_timeout; step
== 200 fires exactly once in the 0..400 loop, so no re-entry guard is
needed and the SIGKILL still fires exactly once
* fix(node): log malformed receive-pack as warn, not error (#165)
git_receive_pack's error classifier routed every non-Timeout error,
including client-caused BadRequest (400), through tracing::error!. Mirror
the git_upload_pack arm so a malformed push logs at warn level instead of
as a server error.
* test(node,git): make smart_http timeout tests pass under the parallel runner (#165)
Addresses jatmn's review of 41eca0b.
[P2] The #62 timeout tests failed under `cargo test --workspace`:
- run_git_service_tears_down_group_when_future_dropped re-polled a completed
future ("async fn resumed after completion"): the advance-until-pids loop
ignored the timeout's Ok(_) (future-finished) case and polled the resolved
future again. It now breaks on early completion.
- Under fork-storm load a freshly-written fake `git` transiently fails to exec
(ETXTBSY) or is timed out before recording its pids. Added a
fake_git_run_with_pids retry helper (used by the four pid-reading tests) plus
a matching inline retry on the drop test, with growing backoff for the
correlated bursts and a 12-attempt cap that still fails loudly on a genuine
never-spawns regression. Reverted two tests from a speculative 2000ms bound
back to 300ms/1000ms now that the retry covers the timeout-vs-pidfile race.
[P3] Documented GITLAWB_GIT_SERVICE_TIMEOUT_SECS in .env.example and the README
config table (default 600; bounds upload-pack/receive-pack, not info/refs or the
withheld-blob path).
The tests stay load-bearing: breaking process_group(0), the post-reap disarm,
the timeout-arm reap, or the internal timeout each still turns the corresponding
test red (mutation-verified).
---------
Co-authored-by: t <t@t>
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)
The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
* perf(ipfs): check object existence before allowed-blob walk
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
* refactor(ipfs): improve formatting and readability in get_by_cid function
✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.
✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.
• P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.
* refactor(ipfs): streamline object retrieval by separating type and content reading
* docs(ipfs): update get_by_cid comment to reflect split object retrieval
* Run cargo fmt on store.rs
* fix(bounties): add tests for claim_bounty repo-read gate (#160)
* fix(profiles): remove redundant borrow in format arg
---------
Co-authored-by: Gravirei <[email protected]>
* fix(node): gate GET /ipfs/{cid} on reachable allowed-set, not deny-set (#126)
The IPFS visibility gate used withheld_blob_oids (a deny-set enumerating
only reachable blobs), so a dangling/unreachable blob was absent from the
set and served in cleartext to anonymous callers. Flip to an allowed-set
(allowed_blob_set_for_caller) that enumerates reachable blobs the caller
may read: a dangling blob has no path, is never in the set, and 404s.
* perf(ipfs): check object existence before allowed-blob walk
Move store::read_object before the allowed_blob_set_for_caller
spawn_blocking call so random-CID spray against repos with
path-scoped rules cannot trigger full-history git walks on
repos that don't carry the object.
* refactor(ipfs): improve formatting and readability in get_by_cid function
✓ P3 blocker fixed: cargo fmt applied — the format gate will pass.
✓ P3 cleanup resolved: withheld_blob_oids is still used by replication code in repos.rs, so it stays.
• P2 follow-up: Tree/commit disclosure tracked in #135 — out of scope here.
* refactor(ipfs): streamline object retrieval by separating type and content reading
* docs(ipfs): update get_by_cid comment to reflect split object retrieval
* Run cargo fmt on store.rs
* fix(visibility): gate repo-scoped read surfaces on visibility (#120)
* fix(visibility): enhance repo read authorization checks and improve test coverage
* Fix review feedback: clamp limit, memoize authz, identical 404 body, resolved issue id in comments
* cargo update -p crossbeam-epoch: fix RUSTSEC-2026-0204 (invalid pointer dereference)
* Fix review findings: correct authz memoization, page past hidden bounties, gate star/unstar
* Fix findings: sign CLI/MCP reads, keyset pagination, gate claim_bounty
---------
Co-authored-by: Gravirei <[email protected]>
* feat(node): add per-client-IP limiters + knobs for the peer-sync routes (#82)
Plumb two dedicated per-IP RateLimiters into config/state so the peer-sync
enqueue routes can be braked independently: sync_trigger_rate_limiter (tight,
GITLAWB_SYNC_TRIGGER_RATE_LIMIT, default 60/hr) and peer_write_rate_limiter
(generous, GITLAWB_PEER_WRITE_RATE_LIMIT, default 600/hr). Both are bounded and
swept by the periodic cleanup loop, keyed via the node-wide push_limiter_trust.
No route is wired to them yet; that lands next.
* fix(node): require a signature on /sync/trigger and rate-limit the peer-sync routes (#82)
/api/v1/sync/trigger was reachable unauthenticated in the default config and
drove an O(peers) outbound fan-out + per-repo sync_queue enqueue — an anonymous
amplification/DoS. The same enqueue_sink is reachable via /sync/notify, which
also accepts unsigned known-peer requests and had no rate limit.
- Move /sync/trigger into its own always-signed route group (add_auth_layers),
independent of require_signed_peer_writes, and give it a tight per-IP brake.
- Add a separate, generous per-IP brake to the peer-write group
(/peers/announce, /sync/notify); notify's signature behavior is unchanged
(rolling-upgrade compat), it only gains the brake. Separate buckets so an
unsigned notify flood can't drain the signed trigger caller's quota.
- Per-IP (not per-DID): a did:key farm self-registers, so a signature/per-DID
gate does not cap cost (INV-10); the brake keys on the client IP before auth.
Adds an adversarial test matrix (unsigned->401 both modes, IP flood->429,
DID-agnostic brake, forged X-Forwarded-For can't bypass, notify flood->429,
distinct-IP not throttled, cross-route non-exhaustion, 0-disables-but-sig-holds,
announce still accepts unsigned).
* fix(gl): surface node denial on sync trigger and sanitize the error body (#82)
Now that the node gates /api/v1/sync/trigger behind a signature and rate limit,
'gl sync trigger' must not render a 401/429 as a fabricated '✓ sync triggered /
0 peers' success. Check resp.status() before parsing; on a non-2xx, extract the
node's message and bail. Strip C0/C1 control bytes from the node-supplied string
before printing (INV-6) — a hostile node could embed ANSI/OSC escapes in the
body. Adds mockito tests: 401/429 surface as errors (RED without the status
check), control bytes are stripped, and the 200 path is unchanged.
* fix(review): bound gl error-body read, strip bidi controls, genericize 429 message (#82)
Code-review follow-ups on the #82 change:
- gl sync trigger: read the node error body with a capped reader
(read_body_capped) instead of resp.text(), so a hostile/broken node can't
force an unbounded allocation to surface a denial (INV-6, read half).
- sanitize_node_msg: also strip Unicode bidi/format controls (U+202A-202E,
U+2066-2069, LRM/RLM/ALM) — is_control() only covers the Cc category, but a
right-to-left override can reorder the displayed error line.
- too_many_requests(): the shared per-IP 429 middleware now serves the peer-sync
routes too, so drop the push-specific wording ('rate limit exceeded'); the
push-specific 429 in the receive-pack handler is unchanged.
Adds tests for the bidi/length sanitize and the oversized-body path.
* test(#82): close reasoned-not-run gaps with load-bearing tests
Executes the branches that were previously only reasoned:
- read_body_capped byte-cap test (RED against resp.text() — 2MB body -> ≤cap):
vets the bounded-read fix, not just the display cap.
- sync-route 429 body asserts 'rate limit exceeded' and NOT 'push' (RED before
the message was genericized); also finishes genericizing the warn log, which
the response-body change had missed.
- real RFC 9421 signature through the full router reaches the handler (200) in
both config modes — the positive path through the actual gate, not a
direct-mount with an injected DID.
- /peers/announce flood -> 429 (the peer_write brake covers announce, not just
notify).
- trigger_counts extraction unit test (happy + malformed -> 0,0), closing the
count-formatting gap in the 200 path.
No production behavior change beyond the warn-log wording.
* fix(#82): require an identity for sync trigger and fix the rollout docs
Address review feedback on the always-signed /sync/trigger route.
- gl sync trigger now requires a local identity via load_keypair_from_dir and
fails locally with "identity not found" before issuing a request, instead of
.ok() silently sending an unsigned request that can only 401 remotely. Add a
test proving it fails before dialing the node.
- Split /sync/trigger out of the staged unsigned peer-write rollout in the
README and the OSS-readiness audit; only /peers/announce and /sync/notify keep
the rolling-upgrade unsigned behavior.
- Reorder the two peer-sync rate-limit vars in .env.example for dotenv-linter.
- Bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204), matching main, so cargo
audit passes.
---------
Co-authored-by: t <t@t>
RUSTSEC-2026-0204 (invalid pointer dereference in crossbeam-epoch's
fmt::Pointer impl for Atomic/Shared) hard-fails cargo audit on every branch,
since the advisory's addition to the RustSec db. crossbeam-epoch is a fully
transitive dependency (moka -> hickory-resolver -> libp2p-dns), and the
advisory's prescribed fix is >= 0.9.20; moka 0.12.15 accepts >=0.9.18,<0.10.0
so the bump resolves with no other graph changes.
Lockfile-only, single crate. Verified: cargo audit exits 0 and the workspace
builds clean with 0.9.20.
Closes#162.
Co-authored-by: t <t@t>
The aarch64-unknown-linux-musl leg cross-compiled a musl target with the
glibc toolchain (CC=aarch64-linux-gnu-gcc), which aws-lc-sys cannot build
with — the Build step failed on every release and no aarch64-linux asset
has ever shipped. Build it natively on ubuntu-24.04-arm instead (free for
public repos), using the same stock musl-tools recipe as the x86_64 leg,
and drop the smoke-test skip since the binaries now run on their build
runner.
The x86_64-apple-darwin leg targeted macos-13, which GitHub has retired;
the job queues forever and holds the release concurrency group open. Move
it to macos-15-intel.
Both failures also skipped the downstream npm-publish, homebrew-bump, and
web-sync jobs, so v0.4.0 and v0.5.0 were never published there.
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(gl): sanctioned iCaptcha client flow + secure git lifecycle
Builds the client side of the iCaptcha gate so a legitimate agent passes the
challenge transparently, with no manual headers and no GITLAWB_ICAPTCHA_PROOF
env var. Server enforcement is unchanged.
New crate `icaptcha-client` (blocking, shared by gl + the git helper):
- deterministic solvers for the computational challenge types (arithmetic,
algebra, sequence) matching the iCaptcha generators; anagram/logic/LLM fall
back to a solver hook / interactive prompt.
- obtain_proof(): POST /v1/challenge (requesterId = caller DID), solve, POST
/v1/answer, handle escalation, return the signed proof. Optional bearer auth
via GITLAWB_ICAPTCHA_API_KEY.
gl (crates/gl):
- http.rs: unify post/put/delete through one signed sender that, on a 403
iCaptcha challenge (detected via x-icaptcha-url / x-icaptcha-level headers),
solves it and retries the same signed request with the x-icaptcha-proof
header (bounded retry absorbs the ~5 min proof TTL). Removes the env hack.
Emits an actionable hint on 401 not_an_agent (old-CLI / unregistered).
- repo.rs: `repo info` checks status and returns a real 404 instead of a stub
card with `?` fields; `repo info`/`clone` resolve the owner from the local
identity or an explicit owner/name — never the node's own DID.
- doctor.rs: warn on gl-vs-node version drift; add an iCaptcha reachability
check.
git-remote-gitlawb: same shared client — push stays signed (RFC 9421); on a 403
iCaptcha (safety net; push is signed-only, not gated) it solves and retries.
Clone/fetch unsigned; missing repo keeps its clear 404.
gitlawb-node: the 403 icaptcha_proof_required response now advertises the
service url + required level as JSON fields and x-icaptcha-url / x-icaptcha-level
headers (mirroring the human_detected pattern) so clients discover them instead
of scraping the message. The sub == authenticated-DID and expiry/level checks
are unchanged — enforcement is not weakened.
README: documents the full lifecycle (identity -> register -> repo create with
auto-iCaptcha -> push -> clone), the requesterId == DID rule, and proof TTL.
Tested: solver unit tests; full workspace green against Postgres (node still
rejects missing/expired/wrong-subject proofs).
* fix(icaptcha-client,gl): address PR #138 review feedback
Rebased onto main (resolving the git-remote-gitlawb/main.rs conflict from #119)
and addressed the review findings:
- [beardthelion P2 / jatmn P2 / CodeRabbit] Removed the dead iCaptcha retry loop
from the push path. git_receive_pack never calls verify_request (only
create/fork/register are iCaptcha-gated), so a push 403 never carries
x-icaptcha-* headers and the loop could never solve. Taking main's push path
(via #119) deletes it, which also resolves the pack-clone (retries no longer
copy the whole pack) and the interactive-prompt-over-git-stream threads, and
restores the sanitized info/refs error path. git-remote-gitlawb no longer
depends on icaptcha-client.
- [beardthelion P1] Sanitize and length-bound iCaptcha error bodies before they
reach the terminal. The origin is only as trusted as the node that advertised
it, so its non-2xx bodies (and Failed{reason}) are attacker-influenceable; they
went raw into bail! (CWE-150, the #137 class). Now bounded-read + C0/C1-stripped
+ capped, same shape as #137.
- [beardthelion P2] Do not trust or hand credentials to a node-chosen iCaptcha
URL. x-icaptcha-url was used as the base with no scheme/host check and the API
key was sent to it on the first hop (SSRF + key exfiltration). resolve_solver_url
now honors an advertised URL only when https + host-allowlisted (public default
or the operator's GITLAWB_ICAPTCHA_URL), else falls back to the trusted origin;
the API key is attached only to the operator's own configured origin.
- [jatmn P2 / CodeRabbit] gl repo clone with a bare name now derives the short
owner key via resolve_owner_did, matching the other commands' gitlawb://z.../name
URL shape instead of the colon-bearing full DID.
Skipped (with reason): jatmn P3 (doctor GITLAWB_ICAPTCHA_URL) is resolved by the
P2 change — that env var is now read by the write path, so the doctor's probe is
accurate. CodeRabbit User-Agent-version nit lived in the push code reverted to
main. README push wording kept ("signed-only, no per-push challenge") — now
accurate since the retry path is gone; the API-key bullet was updated for the new
trust model.
Tests: added sanitize + resolve_solver_url unit tests; full workspace green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>