mirror of
https://github.com/Twigpine/node.git
synced 2026-11-09 18:08:02 -10:00
feat/commit-status-surface
37
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cd810b3375 | chore(main): release 0.7.1 | ||
|
|
32006b26a2 | chore(main): release 0.7.0 | ||
|
|
c681af70db |
fix(gl): anchor cert --verify to a trusted issuer; pin canonical payload form
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). |
||
|
|
3fa38dce4e |
fix(gl): address review findings — non-fatal DID check, exact-host loopback, detached HEAD, multi-URL remotes, command-aware unalias
- 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. |
||
|
|
0765c42fef |
feat(gl): doctor warns when a shell alias shadows the gl binary
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. |
||
|
|
94c61f7942 |
fix(gl): doctor treats a reachable local node as configuration, not failure
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. |
||
|
|
b0a2ed8308 |
feat(gl): actually verify certificate signatures in gl cert show
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. |
||
|
|
cb42a4830d |
fix(gl): make gl init's push guidance match the repo's actual state
- 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. |
||
|
|
2d88937fba |
fix(gl): detect gitlawb remotes beyond origin in gl status
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) |
||
|
|
f9506b466e | chore(main): release 0.6.0 | ||
|
|
98d728c325 |
fix(gl): point doctor's version check at Gitlawb/node, not the frozen Gitlawb/releases repo (#198)
* 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. |
||
|
|
b07d41ea97 |
fix: strip Cf bidi/format controls in terminal-bound error sanitizers (#183) (#188)
* 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> |
||
|
|
ee8d7c03b2 |
fix(node): gate /hooks and sibling read surfaces leaking private repo metadata (#94) (#113)
* 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> |
||
|
|
ad7c2b2b83 |
test(gl): add tests for iCaptcha transparent retry flow (#167) (#168)
* 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]>
|
||
|
|
3ec8cbf1e1 |
chore(main): release 0.5.1 (#158)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
6b5e5bc7ae |
fix(node): bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth (#147) (#149)
* fix(node): bound list_ref_certificates with LIMIT and add upsert to prevent unbounded growth (#147) * test: add INV-7 v10 upgrade-path dedup test * fix INV-7 test: correct id-DESC tiebreaker ids and fix fmt * fix: insert_ref_certificate returns persisted id via RETURNING; issue_ref_certificate uses it * fix: guard cert upsert on issued_at so older certs cannot regress newer ones * fix: .max(0) in events limit parsing, re-add lost cert tests after rebase * address reviewer findings: return full persisted row, add monotonic clock comment, note test drift risk * fix: add ?prefix= server-side cert lookup for CLI short-ID resolution --------- Co-authored-by: Gravirei <[email protected]> |
||
|
|
26bc3f6987 |
fix(visibility): gate repo-scoped read surfaces on visibility (#120) (#157)
* 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]>
|
||
|
|
d00d89ae5b |
fix(node): gate POST /api/v1/sync/trigger and rate-limit the peer-sync routes (#82) (#161)
* 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> |
||
|
|
5f63f28215 |
chore(main): release 0.5.0 (#142)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
06388ec26a |
feat(gl): sanctioned iCaptcha client flow + secure git lifecycle (#138)
* 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]> |
||
|
|
20d6848846 |
fix(gl): sign the CLI's /ipfs/pins reads under the #134 auth gate (#146)
* fix(gl,ipfs): sign gl ipfs list so it works under the #134 pins auth gate /api/v1/ipfs/pins now 401s anonymous callers (#134). Load the caller's identity (--dir, default keystore) and use NodeClient::get_signed; when no identity exists, propagate load_keypair_from_dir's existing 'gl identity new' error instead of a raw 401. Tests: signed-headers present, empty, and no-identity-issues-no-request (mockito). * fix(gl,node): sign the gl node status pins panel, degrade gracefully The pins panel signs its /api/v1/ipfs/pins read (#134 gates it behind auth) via an injectable fetch_pins helper with four states: pins, empty, unavailable (signed read rejected/errored), and needs-identity (no keypair, no request issued). cmd_status loads the identity gracefully so a missing keystore never aborts status; peers/repos/p2p/events panels stay anonymous. Tests drive fetch_pins directly against a mock node. * fix(gl): address review — surface pins HTTP errors, correct empty-panel label - gl ipfs list: check the /ipfs/pins response status before parsing, so a rejected signed read (e.g. 401) surfaces as an error instead of silently printing 'No IPFS pins recorded'. Matches the sibling gl ipfs get. - gl node status: PinsPanel::Empty now renders 'Pinned CIDs: 0' (a reachable node with zero pins), reserving 'unavailable' for the failure state; the prior label reused 'IPFS not configured' and misreported the zero-pins case. - Carry the resolved count in PinsPanel::Pins, removing the duplicated count-fallback closure between fetch_pins and the render arm. * test(gl,node): cover fetch_pins error branches and pins-panel rendering Extract the pins-panel render into a testable pins_status_line helper and assert all four states' output (closes the untested cmd_status render path, including the empty->'Pinned CIDs: 0' label). Add fetch_pins tests for the two error branches that were code-traced but unexecuted: a malformed 200 body and a transport error both degrade to Unavailable without panicking. * style(gl): rustfmt the ipfs list pins-response parse * fix(gl,node): honor --dir identity selector in `gl node status` (#146) `gl node status` always loaded the default keystore via `load_keypair_from_dir(None)`, so a user who created or selected an identity with `--dir` saw the status pins panel render "sign in to view" even though `gl ipfs list --dir` authenticated fine. Add a `--dir` option to `NodeCmd::Status` and thread it into the signed pins fetch, so both CLI pins callers select the same identity directory. An explicit `--dir` that can't be loaded (missing, corrupt, or unreadable) no longer masquerades as "no identity, run `gl identity new`": the pins identity is resolved up front into Keyed / Anonymous / DirUnusable, and the unusable explicit-dir case renders a distinct "no usable identity in <dir>" panel line that names the path, while a missing default keystore (no `--dir`) still degrades quietly. A pins failure never aborts the dashboard. Tests cover both flag branches (Some/None) and the resolver both ways: a good `--dir` loads Keyed, an unusable one reports DirUnusable/IdentityError without issuing a request. The new symbols were absent before, so they compile-RED first. * test(gl,node): pin the pins-identity resolver to the --dir key Follow-up test hardening on the #146 --dir wiring after review: - resolve_pins_auth_loads_keyed_identity_from_explicit_dir now destructures Keyed and asserts loaded.did() equals the --dir key's DID. A bare Keyed(_) match passed even when the load fell through to the default keystore; the DID assertion catches that regression (confirmed red under a simulated fallthrough, green on the real code). - add resolve_pins_auth_none_is_never_dir_unusable: an absent --dir must degrade to Anonymous or Keyed, never DirUnusable. It asserts only the None to DirUnusable mapping, so it is stable whether or not the test runner has a default ~/.gitlawb identity. Test module only; no production behavior change. --------- Co-authored-by: t <t@t> |
||
|
|
0190fbbbf6 |
chore(main): release 0.4.0 (#131)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
12bfb1b3c8 |
fix(release): use simple release-type + generic Cargo.toml updaters (#130)
release-type: rust at path . is incompatible with this virtual workspace root. release-please's Rust strategy unconditionally appends a CargoToml update for the root Cargo.toml, and that updater throws "is not a package manifest (might be a cargo workspace)" whenever the manifest has no [package] section — so every run died on the final step. Switch to release-type: simple (touches only a version file + CHANGELOG, never a manifest) and bump each crate version through the generic updater via extra-files plus an inline x-release-please-version annotation. Preserves the single vX.Y.Z tag, all action outputs, and the entire downstream release.yml unchanged. |
||
|
|
788a8686c1 |
fix(release): give crates explicit versions for release-please (#129)
release-please's Rust workspace strategy versions each member crate in its own Cargo.toml and cannot rewrite an inherited `version.workspace = true`, so it failed with 'value at path package.version is not tagged'. Give each of the five crates an explicit version = "0.3.9" (kept in lockstep by release-please) and drop the now-unused [workspace.package].version. Inter-crate deps are path-only, so no version requirements need syncing. |
||
|
|
2153b0b7a6 |
fix(gl): paginate gl-clone Arweave recovery and make /encrypted-blobs parsing schema-strict (#49) (#70)
* fix(gl): make /encrypted-blobs recovery parsing schema-strict Deserialize the /encrypted-blobs 200 body into a typed EncryptedBlobsResponse instead of poking at a serde_json::Value and defaulting blobs to [] via unwrap_or_default(). A server schema regression now fails closed (a hard parse error) rather than silently yielding nothing to recover, mirroring the existing WithheldPathsResponse convention. The sole caller still does not abort the clone but now surfaces the error instead of swallowing it into an empty result. Part of #49. * fix(gl): paginate Arweave manifest discovery and rank ties by block height Discovery hard-coded a single first:100 GraphQL query with no pagination, so a repo with more than 100 encrypted-manifest anchors (one per push) silently lost recovery coverage for any blob whose most recent seal fell outside that window. Follow cursor pagination (after + pageInfo) until coverage is complete, deduping transaction ids and bounding the loop (1000 pages / 10000 ids, degenerate-cursor guard) so a hostile or buggy gateway cannot grow the downstream per-id fetch loop without limit. A full page returned without pageInfo, a cap hit, or a failure after discovery began now warns; a page-1 failure (unconfigured/unreachable gateway) stays silent as before. Pagination concatenates pages in gateway order, which made merge_manifests' equal-timestamp tie-break (previously insertion-order, i.e. accidentally newest on single-page HEIGHT_DESC) gateway-dependent. Rank candidates by the composite key (timestamp, Arweave block height, cid): timestamp primary keeps the existing distinct-timestamp latest-wins; block height breaks equal-timestamp ties by anchor recency (a not-yet-mined anchor counts as newest), preserving the pre-pagination behavior; cid is a final deterministic tiebreak for same-block anchors. Height is parsed defensively so a present-but-unreadable height (e.g. a gateway stringifying large ints) cannot masquerade as a pending/newest anchor. Part of #49. * fix(review): bound edges per Arweave page and cover degenerate-cursor/dedup Code-review follow-ups on the #49 recovery change: - Cap edges parsed per GraphQL page at ARWEAVE_PAGE_SIZE so a hostile gateway returning one oversized page cannot defeat the MAX_TX_IDS budget or flood the downstream per-id manifest fetch loop (adversarial review). - Collapse the two identical gateway-failure arms in the discovery loop into a single warn site, and hoist PAGE_SIZE to a named const tied to the query's first:100 (maintainability). - Add tests: per-page edge bound, non-advancing-cursor termination (infinite-loop guard), and cross-page tx-id dedup fetched exactly once (testing). Part of #49. * fix(review): make the degenerate-cursor test assert, and pin the page-size invariant Round-2 review follow-ups: - The non-advancing-cursor test bound its mock to a _-prefixed handle with expect_at_most(3) and never asserted it; mockito defaults assert_on_drop=false, so the test passed even if the guard were removed. Bind it, tighten to expect(2) (the guard breaks on the second page, so discovery makes exactly two POSTs), and assert_async. - Correct the parse_tx_page docstring: the per-page take() bounds the TxRef vec and fetch loop, not the response-body deserialization (an unbounded-body concern shared by every gateway read here). - Hoist the discovery query to a const and add query_first_matches_page_size so the query first:N literal and ARWEAVE_PAGE_SIZE are kept equal by a test rather than a comment. Part of #49. |
||
|
|
7533b74f79 |
ci: harden PR pipeline (audit, MSRV, beta matrix, release + docker smoke) (#57)
* ci: harden PR pipeline (audit, MSRV, release build, beta matrix, docker smoke) Restructure the single stable-only fmt+clippy+test job into separate jobs: fmt+clippy (stable), a test matrix on stable+beta (beta non-blocking), build --release, cargo audit, an MSRV check pinned to Rust 1.91, and a Docker build smoke test mirroring the release workflow. Add concurrency cancellation for superseded PR runs and per-job timeouts. Declare rust-version = "1.91" in [workspace.package] and inherit it in every member crate so the stated MSRV is a real, CI-enforced contract. * ci: disable credential persistence on checkout steps Set persist-credentials: false on all six actions/checkout steps. These jobs run untrusted PR code via cargo and docker build, and checkout's default leaves the GITHUB_TOKEN in the local git config where that code could read it. None of the jobs push or otherwise need git credentials. * fix(deps): bump rustls-webpki to 0.103.13 Clears three advisories flagged by the new cargo audit job: a reachable panic in CRL parsing and two name-constraint bypasses (wildcard and URI). Semver-compatible patch, lockfile only, no manifest or code change. cargo also corrects a stale lockfile reference while rewriting Cargo.lock: data-encoding-macro-internal 0.1.17 (a build-time macro crate) requires syn 1.x, and its dependency entry is re-pinned from syn 2.0.117 to the 1.0.109 it actually resolves to. Unrelated to the bump, unavoidable when cargo touches the lock, and the more-correct state. * ci(audit): suppress no-fix advisories with justification and a re-check The cargo audit job stays hard-fail. The remaining three advisories have no available upstream fix, so they are suppressed in .cargo/audit.toml (the location cargo-audit actually reads; a repo-root audit.toml is not read), each with an inline rationale and removal condition: - RUSTSEC-2026-0118/0119 (hickory-proto 0.25.2, via libp2p-dns 0.44): reachable, since the node wraps QUIC in the system DNS resolver. No fix until libp2p-dns ships hickory 0.26. Accepted, reachable risk. - RUSTSEC-2023-0071 (rsa Marvin): in the lockfile via sqlx's unconditional sqlx-mysql, but not linked (sqlx is postgres-only), so no RSA oracle exists; no upstream fix regardless. Two controls keep the suppression honest. The PR audit job echoes the active ignore list to the job summary, so a green check is not read as advisory-clean. A weekly scheduled job re-runs cargo audit without the ignores for visibility and hard-fails if hickory-proto moves off 0.25.x while the ignores remain, so the suppression cannot silently outlive the upstream fix. * ci(audit): reference tracking issue #76 in the hickory ignores * ci(audit): treat hickory-proto removal as drift too The scheduled drift guard only failed when hickory-proto was present and off 0.25.x, so removing it from Cargo.lock entirely left the RUSTSEC-2026-0118 /0119 ignores in place with the guard still passing. Treat an absent version as drift as well, so dead ignore entries are caught whether the dependency is bumped past 0.25.x or removed. |
||
|
|
5ff7af84fa |
feat: encrypted replication for private subtrees (B1/B2/B3) for #18 (#36)
* feat(core): ed25519 to x25519 key conversion for envelope crypto * feat(core): seal/open per-blob encryption envelopes * feat(node): resolve per-blob encryption recipients (least-privilege) * feat(node): encrypted_blobs table and recipient-scoped queries * feat(node): encrypt-then-pin withheld blobs at push (IPFS) At the push chokepoint, after pinning withheld objects, resolve each withheld blob's recipient DIDs and seal it to their Ed25519 keys with seal_blob, pinning the ciphertext to IPFS and recording it in encrypted_blobs. Best-effort per blob: failures are logged and skipped, never pinned in plaintext. Pinata replication is unchanged; B1 encrypts to IPFS only. Adds ed25519-dalek as a direct dependency of gitlawb-node (it was only declared in the workspace Cargo.toml). * feat(node): authenticated discovery and fetch for encrypted blobs * feat(gl): transparent recovery of authorized encrypted blobs on clone * style: cargo fmt * fix(node): re-seal encrypted blob when recipient set changes Skip re-pinning only when an existing envelope already covers exactly the current recipients. A reader added to a rule after the first pin now gets a re-seal on the next push instead of being permanently locked out. Reader removal stays non-retroactive (the old envelope is already public). * feat(node): unscoped encrypted-blob listing for replication * feat(node): encrypted-blobs/replicate endpoint for peer mirrors * feat(node): route encrypted-blobs/replicate under git_read_routes * feat(node): peer mirrors replicate encrypted withheld blobs (Option B2) * feat(node): anchor_encrypted_manifest for Option B3 Arweave manifests * feat(node): encrypt_and_pin returns the per-push sealed delta * feat(node): anchor encrypted-blob manifest on push (Option B3) * feat(gl): Arweave/IPFS gateway recovery for encrypted blobs (Option B3) * feat(gl): transparent Arweave fallback recovery on clone (Option B3) * style: cargo fmt * fix(gl): bound Arweave recovery gateway requests with a 30s timeout * test(gl): read-path recovery tests for Arweave fallback; quiet promisor present-check Add two hermetic integration tests for recover_from_arweave that drive the full read path over mocked Arweave GraphQL + IPFS gateways: discover the manifest, fetch it, fetch the envelope, decrypt, and install the withheld blob. One covers an authorized recipient (blob installed), the other a non-recipient (nothing recovered). Both simulate origin death by removing the promisor remote and enable uploadpack.allowFilter so the blob is truly withheld over file://. Also harden the local presence check in recover_from_arweave with GIT_NO_LAZY_FETCH=1 and .output() so the expected 'missing object' case does not trigger a wasted promisor fetch or leak git stderr to the user. * fix: surface corrupt recipients JSON and silent recovery I/O failures db: parse_recipients now propagates a descriptive error instead of defaulting corrupt recipients JSON to an empty list, which would have denied authorized readers and handed peers incomplete metadata. gl: clone recovery now warns when the sparse-checkout file cannot be read or written, or when the post-recovery checkout fails, instead of silently discarding those errors and claiming files were recovered. * feat(core): blind recipient identities in withheld-blob envelopes Envelope v2 drops the cleartext recipient public key (kid) from each wrapped-key header entry, so a party holding the public IPFS/Arweave copy can no longer enumerate who is authorized to decrypt a withheld blob. The version is bumped to 2 and v1 envelopes are rejected. open_blob now selects the reader's entry by trial decryption (the AEAD tag authenticates exactly one entry) instead of matching on the public key. Recipient count is still visible; identity is not. Scope is envelope-only, the node DB recipients column and peer replication metadata are unchanged. Co-authored-by: CommandCodeBot <[email protected]> * fix(core): reject malformed envelope nonces instead of panicking open_blob fed attacker-controlled envelopes to GenericArray::from_slice and XNonce::from_slice, which panic on a wrong-length input. Validate both the per-recipient box nonce and the body nonce to 24 bytes before use: skip a recipient entry whose nonce is malformed, and return an error for a malformed body nonce, so the public recovery path surfaces an error rather than panicking. * feat(node): blind recipient identities on the B2 replication surface The encrypted-blobs/replicate endpoint shipped every blob's full recipient DID list to mirrors, which persisted it, so any mirroring peer learned the reader set. The v2 envelope blinding already removed recipient public keys from the pinned envelopes, so the comment justifying this (DIDs are already public) was no longer true. /replicate now returns {oid, cid} only. Mirrors detect a re-seal by the CID changing (the OID is stable across re-seals) instead of comparing recipient sets, and store no recipient identities. Origin-side authz and the at-rest recipients column are unchanged; this blinds only the peer-facing surface. * feat(node): stop anchoring recipient identities to Arweave The B3 encrypted-blob manifest anchored {oid, cid, recipients} to Arweave, a permanent public record of every blob's reader set. The v2 envelope blinding already removed recipient keys from the pinned envelopes, so the comment justifying this (recipient DIDs are already public) was no longer true. The manifest now anchors {oid, cid} only. The gl reader already ignores the recipients field (it recovers by trial decryption), so no reader changes. --------- Co-authored-by: CommandCodeBot <[email protected]> |
||
|
|
4a59056a90 |
gl clone: clean partial clone of repos with private subtrees (#33)
* feat(node): withheld-paths endpoint reporting a caller's denied globs * feat(gl): gl clone with promisor + sparse-exclude for private subtrees * test(gl): gl clone repo-argument parsing * fix(visibility): gate withheld-paths and handle nested/exact globs Three fixes from the PR #33 review: - withheld_paths now applies the whole-repo "/" read gate (returns repo-not-found when the caller cannot read the root), matching the git read endpoints. Without it the endpoint disclosed a private repo's existence and path layout to unauthorized callers. The withheld_globs doc already assumed this gate existed; now it does. - A nested allow under a denied parent (e.g. "/secret/public/**" allowed, "/secret/**" denied) was over-withheld: the client sparse-excluded the whole parent and hid paths the caller may read. The endpoint now also returns a "reinclude" list (allowed globs strictly under a denied one) and gl clone re-includes them in the sparse spec after the excludes. - Wildcard-free globs like "/docs/private" match both the exact path and a subtree (per glob_matches), but the client only emitted the subtree exclude. sparse_patterns now emits both "/docs/private" and "/docs/private/". Verified the exclude-then-reinclude sparse ordering checks out cleanly with real git, plus unit tests for reincluded_globs, the nested re-include, the exact-path exclude, and sparse_patterns. * fix(gl): reject multi-slash repo input and stop failing open on withheld-path errors split_once('/') accepted owner/name/extra, smuggling a path segment into the repo name that then flowed into the API path and clone URL; reject it. fetch_withheld swallowed every network/auth/5xx/JSON error into an empty result, dropping to a stock clone that the node refuses once blobs are withheld. Now only 404/501 (endpoint unsupported) fall back to empty; the rest propagate so the real cause surfaces. * test(gl): guard three-level nested visibility in sparse clone Add a regression test for deny /secret, allow /secret/public, deny /secret/public/admin and clarify the sparse-checkout comment. git does not re-traverse an explicitly excluded directory, so emitting all excludes before re-includes keeps the deepest deny in force; the broader parent re-include does not resurrect it. * fix(gl): robust default-branch detection and strict withheld-paths schema Read the default branch from the local origin/HEAD symref clone already set, instead of parsing the localized, network-dependent output of git remote show origin. Deserialize the withheld-paths body into a typed struct so a missing or mistyped withheld/reinclude field is a hard error rather than silently becoming an empty list, which would mask a server regression behind a later clone failure. |
||
|
|
6abaf1d7ed |
feat: path-scoped repository visibility (Phase 1) for #18 (#25)
* feat: path-scoped repository visibility (Phase 1) Implements per-path read visibility for repositories, addressing #18. A repo's private status becomes a property of a path subtree rather than a whole-repo boolean, enforced on the git read path via the node's existing DID identity and RFC 9421 HTTP signatures. Phase 1 scope (whole-repo enforcement, data model for the rest): - visibility_rules data model (path_glob, mode, reader_dids) + DB access - visibility_check pure decision function (owner-always-allow, most- specific rule wins, reader-DID allow-list, is_public fallback) - enforcement on git_info_refs and git_upload_pack: unauthorized reads return 404 byte-identical to a missing repo (no existence leak) - owner-only management API: PUT/DELETE/GET /api/v1/repos/{o}/{r}/visibility - gl visibility set/remove/list CLI mode A (hide) is whole-repo ("/") only by construction: hiding a subtree's existence would rewrite ancestor tree hashes and diverge history. Subtrees use mode B (content withheld, SHAs intact); B-subtree clone filtering is deferred to a later phase and the CLI/API say so. 260 tests (69 node + 191 gl); fmt + clippy clean. * fix: address review on path-scoped visibility - migrations: move visibility_rules DDL out of the v1 migration into a new v3 (CodeRabbit, critical). Appending to v1 meant nodes that had already recorded v1 would skip the statements forever, so the table would be missing after upgrade. v3 matches the convention #23 set with v2 (agent_profiles) and the documented 'never append to v1' rule. - api: validate path_glob on set (must start with '/', no trailing '/', only a trailing '/**' wildcard, '/' for whole-repo not '/**'). Empty or slash-less globs would otherwise silently misconfigure access. Added unit tests for the accepted/rejected forms. 281 -> 283 tests pass (88 node + 195 gl); fmt + clippy clean. |
||
|
|
09a339745e |
feat: agent profiles (display name, bio, avatar, social links) (#23)
* feat: agent profiles — display name, bio, avatar, social links
Adds a profile system so agents and users can set metadata that the
frontend can render on profile pages.
CLI:
gl profile set --name "Axiom" --bio "AI builder" --twitter AxiomBot
gl profile show
gl profile get <did>
gl profile import profile.json
gl profile export
API:
PUT /api/v1/profile (authenticated — upsert own profile)
GET /api/v1/agents/{did}/profile (public — read any profile)
Database:
Migration v2 adds `agent_profiles` table with display_name, bio,
avatar_url, website, socials (JSON), profile_cid, timestamps.
Profile updates merge fields — setting --bio doesn't clear --name.
Bio capped at 280 chars, display_name at 50. Social links stored as
JSON (twitter, github, farcaster, telegram). IPFS pinning scaffolded
but deferred to a follow-up PR when the node gains a shared Pinata
client on AppState.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* fix: apply cargo fmt to pass CI formatting check
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
* fix: suppress dead_code warnings for pin_to_ipfs field and set_profile_cid method
Both are placeholder implementations for planned IPFS pinning — not yet wired
into the handler but intentionally part of the public API surface.
Co-Authored-By: Axiom Bot <[email protected]>
* feat: wire up IPFS pinning for agent profiles via Pinata
When pin_to_ipfs: true is sent in the profile update request and the
node has GITLAWB_PINATA_JWT configured, the profile JSON is pinned to
IPFS via Pinata and the CID is stored in the database.
- Removed #[allow(dead_code)] from pin_to_ipfs field and set_profile_cid
- Profiles serialize to JSON and pin via existing pinata::pin_object
- Graceful fallback: if Pinata JWT is empty or pin fails, profile is
saved normally without CID
- CID returned in response when available
---------
Co-authored-by: Axiom Bot <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
|
||
|
|
d366fc6b97 |
[codex] chore: add live-safe OSS readiness pass (#14)
* chore: add live-safe OSS readiness pass * fix(node): verify optional peer signatures during rollout * docs: rewrite README for node roadmap |
||
|
|
14a1cdd656 |
chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint (#12)
* chore: bump v0.3.9, fix all clippy warnings, enforce strict CI lint - Bump workspace version 0.3.8 → 0.3.9 - Fix 22 clippy warnings across 17 files: - Replace .last() with .next_back() on DoubleEndedIterators (8 instances) - Remove useless .into() conversions on anyhow::Error (3) - Remove needless borrows in gl init (3) - Replace map_or with is_some_and/is_none_or (3) - Collapse consecutive str::replace into char array pattern (2) - Change &PathBuf to &Path in function signatures (2) - Remove dead code: unused init_bare_repo function - Collapse if-let into outer match arm - Replace vec![...] with array literal - Introduce RecordAnchorInput struct (10 params → 1 struct) - Fix always-true boolean condition in gossip task guard - Promote clippy from advisory to strict (-D warnings) in CI Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]> |
||
|
|
b09714af0b |
fix(doctor): detect git-remote-gitlawb.exe on Windows (#10)
which_in_path() now also checks for `{name}.exe` on Windows targets,
so `gl doctor` correctly finds git-remote-gitlawb on that platform.
Co-authored-by: kelvinzer0 <[email protected]>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <[email protected]>
|
||
|
|
f3df4a4dd4 |
Resolve bare DID aliases for trust and cert lookups (#9)
* Resolve bare DID aliases for trust and cert lookups Public profile and CLI surfaces accept bare z6Mk keys, but trust and certificate data are stored against full did:key identities. Normalize agent profile lookups and make cert commands resolve owner-less repos against the caller DID so valid pushes are not hidden behind node-owned paths. Constraint: Public URLs and CLI examples commonly use short z6Mk identifiers Constraint: Ref certificates are issued after push and stored under the owner repo id Rejected: Register duplicate bare-key identities | would split trust and certificate history across aliases Rejected: Require full certificate UUIDs in show output | list already displays short IDs as the human-facing handle Confidence: high Scope-risk: narrow Directive: Keep bare key inputs as aliases for did:key, not separate identities Tested: cargo check -p gl Tested: cargo run -q -p gl -- cert list agentbot-opensource Tested: cargo run -q -p gl -- cert show agentbot-opensource 3686b1fe Tested: cargo check -p gitlawb-node Tested: cargo test -p gitlawb-node normalize_agent_did * Preserve agent rank on short profile URLs The public profile route can use compact handles like z6MkpUq1, while trust history is stored under the full did:key identity. Resolve exact aliases first, then fall back to matching registered agent key prefixes so older high-level identities are not displayed as fresh newcomers. Constraint: Public GitLawb profile URLs expose short z6Mk handles. Constraint: Trust and push history remain keyed by canonical did:key identities. Rejected: Mint duplicate short-handle agent rows | would split rank and push history across aliases. Confidence: high Scope-risk: narrow Directive: Keep short profile handles as aliases for canonical did:key identities, never as separate trust principals. Tested: cargo fmt --check -p gitlawb-node Tested: cargo test -p gitlawb-node agent Tested: cargo check -p gitlawb-node Tested: cargo check -p gl |
||
|
|
f312955c35 |
feat: replica registration protocol (Phase 2 v1) (#3)
Phase 2 of the network-resilience plan: any node can register itself as a
replica of someone else's repo, and the origin exposes the replica list
publicly as a "your repo is mirrored by N nodes" trust signal.
What ships
- New repo_replicas table keyed by (repo_id, replica_did) with replica URL
and registration timestamp. UNIQUE on (repo_id, replica_did); duplicate
registrations update the URL.
- API on origin:
PUT /api/v1/repos/:owner/:repo/replicas (auth) register
DELETE /api/v1/repos/:owner/:repo/replicas (auth) unregister
GET /api/v1/repos/:owner/:repo/replicas (public) list
Origin rejects self-registration and validates the replica URL (http(s),
no whitespace/control chars, length-bounded).
- CLI:
gl repo replica-register <owner>/<repo> --url <my-public-url>
gl repo replica-unregister <owner>/<repo>
gl repo replicas <owner>/<repo>
- gl repo info now shows "Replicas: N" when the origin supports it.
- Tests: 5 new unit tests for the URL validator. Full node suite at 51 pass,
full gl suite at 188 pass.
Deferred to Phase 2.5
- Replica auto-pulls the repo on registration (today: operator runs
git clone gitlawb://... manually after registering).
- Periodic freshness sync worker driven by the replica list.
- Gossipsub subscription to push ref-update events to replicas live.
Co-authored-by: OpenClaude <[email protected]>
|
||
|
|
ec4ef81770 |
feat: embed bootstrap peers seed list for automatic network discovery (#1)
* feat: embed bootstrap peers seed list for automatic network discovery A fresh \`docker compose up\` now joins the Gitlawb network with zero manual peer configuration. The node parses an embedded \`bootstrap-peers.json\` on startup and merges the entries into both the HTTP gossip task and the libp2p Kademlia bootstrap. - Add \`bootstrap-peers.json\` at repo root (versioned schema, PR-friendly) - New \`bootstrap\` module in the node crate (parse + merge_seeds) - Wire into \`main\` after \`Config::parse\` - Operators can opt out via \`GITLAWB_BOOTSTRAP_DISABLE_SEEDS\` for isolated dev networks Also in this commit: - \`cargo fmt --all\` over the entire workspace (no logic changes) - Downgrade CI clippy step to advisory (\`continue-on-error: true\`) until the existing lint backlog is cleared. fmt + tests stay strict. Co-Authored-By: OpenClaude <[email protected]> * test: cover bootstrap merge logic with unit tests Refactor bootstrap.rs into pure functions (parse_seed_list, merge_into_vecs) so the parse + merge logic can be tested without constructing a Config or mutating process-global env vars. Adds 11 tests covering: - valid v1 list parses - unknown version is rejected - malformed JSON is rejected - empty / missing peers array - merge appends new http + p2p entries - merge dedupes against existing entries - invalid p2p_multiaddr is skipped (http still added) - empty strings are skipped - null optional fields are tolerated - the canonical bootstrap-peers.json shipped in the repo always parses (regression guard against future schema changes) Co-Authored-By: OpenClaude <[email protected]> * fix(security): reject path traversal in repo_store local_path Closes CodeQL alert #4 (Uncontrolled data used in path expression). Both `owner_did` and `repo_name` come from URL parameters and were used unsanitized to build a filesystem path, so a request like `/did:key:foo/../../../etc/passwd.git/info/refs` could escape the repos directory. Fix: - New `validate_owner_did` and `validate_repo_name` enforce a strict allowlist before path construction (alphanumeric + `: . _ -` for DIDs; alphanumeric + `. _ -` for repo names; rejects empty, `..`, leading `.` or `-`, slashes, backslashes, null bytes, overlong inputs). - `RepoStore::local_path` now returns `Result<…>` and the 5 callers propagate the error. - Defence in depth: even after the allowlist passes, the joined path is checked to still be rooted at `repos_dir`. - 17 unit tests cover normal DIDs/repo names plus every malicious shape (`..`, `/`, `\`, leading dot/dash, null byte, overlong, did:web with dots). Co-Authored-By: OpenClaude <[email protected]> * fix(security): add Path::components barrier for CodeQL rust/path-injection CodeQL's rust/path-injection query did not recognise the existing allowlist validator as a sanitiser, so it kept flagging local_path even though every malicious input was already rejected. Adds an explicit Path::components() walk after path construction that rejects any ParentDir or CurDir segment. This is the barrier pattern CodeQL recognises — and is also genuine defence-in-depth in case a future change ever weakens the allowlist. Allowlist + starts_with + components walk = three independent layers between attacker-controlled HTTP input and filesystem access. All 17 existing tests still pass. Co-Authored-By: OpenClaude <[email protected]> --------- Co-authored-by: OpenClaude <[email protected]> |
||
|
|
6b9c5278d4 |
chore: initial public release
Open-source release of the gitlawb node, CLI, and git remote helper. - gitlawb-node: Axum HTTP server with git smart-HTTP and DID auth - gl: command-line client (identity, repos, PRs, MCP server) - git-remote-gitlawb: git remote helper for gitlawb:// URLs - gitlawb-core: shared crypto primitives (DID, CID, HTTP sigs, UCAN) Dual licensed under MIT or Apache-2.0. Co-Authored-By: OpenClaude <[email protected]> |