Commit Graph
11 Commits
Author SHA1 Message Date
claude eb70ca08ad fix(client): make the disaster-recovery path actually work
Review found the read path scoped to the CURRENT device's uuid, which breaks
the exact case snapshot mode exists for: when the laptop dies, the
replacement machine has a new id, so snapshots printed 'no restore points
yet' while the backups sat on the forge, and restore errored. Reproduced,
then fixed by unscoping the READ only. Writing stays device-scoped (two
machines must not overwrite each other) and pruning stays device-scoped
(machine A must not apply its clock to machine B's refs); the docstring now
says why the three differ.

Also from the same review:
- mirror mode printed a %cI timestamp that restore could not accept, so
  copying the first column looped the user back to snapshots. It now matches
  the log, and refuses an ambiguous timestamp (two commits in one second)
  with the candidate ids instead of guessing.
- the size guard advised 'add a .gitignore' while measuring with a plain
  walk that ignored one. It now measures what git would sync, through a
  throwaway git dir outside the folder so a refused add leaves no .git
  behind.
- get --all caught only SystemExit, so a RuntimeError from any git call
  abandoned the remaining repos.
- get --all mapped alice/notes and bob/notes to one path and reported the
  second as 'already present'. Clashes now clone to <owner>-<name> and say so.
- the prune clock was in-memory, so watch --once under launchd pruned every
  run. Persisted in config.

153 tests. Live-verified on the beta forge: machine A backed up uncommitted
work and was deleted; machine B, different device id, cloned the repo, listed
A's snapshot and restored both files.
2026-08-23 11:42:19 -04:00
claude ddb829d701 fix(client): make our credential helper the only one the repo consults
Live QA against the beta forge failed its first push with 'Failed to
authenticate user' while the config held a valid token. Cause: credential.helper
is a list accumulated across system/global/repo config, and this machine has
osxkeychain (Xcode gitconfig) plus store (~/.gitconfig). A stale entry for the
forge host answered before our helper.

The same list is a token leak in the other direction: git calls approve on
every helper after a successful auth, so 'store' writes the forge token into
~/.git-credentials in plaintext -- undoing the 0600 config and the
no-token-in-URL rule. Confirmed accidentally during QA when a verification
clone with a URL-embedded token re-created exactly that entry.

Fix: set an empty credential.helper first (git reads that as 'forget the
inherited list'), then add ours -- in install_credential_helper and in the
git clone inside get.

2 regression tests, one of which drives 'git credential fill' against a
poisoned outer helper. 143 tests.
2026-08-23 11:29:44 -04:00
claude 9e3201a296 feat(client): snapshot backups, restore points, scoped bulk pull
Two modes per linked folder. 'mirror' keeps today's behaviour for a plain
folder that add turned into a repo. 'snapshot' is new and is for a folder
that already had a git history: nothing is ever committed on the user's
behalf, and instead each pass builds a commit object from the working tree
via a scratch index + commit-tree and pushes it to
refs/granthi-backup/<device>/<ts>. HEAD, the index and every file stay
exactly as the user left them, so uncommitted, unmerged, half-finished work
leaves the machine with a timestamp to restore from.

Verified on the beta forge (Gitea 1.27.2) that a custom ref namespace is
accepted, readable via ls-remote, and absent from the branch list.

Also: retention (all for 24h, hourly for 7d, daily beyond; unparseable
timestamps kept), snapshots/restore commands, restore never writing over the
working tree, get --all bounded by what the forge grants, list <pattern>,
.gitignore seeding, an add size guard, and a persisted device_id.

141 tests (was 108).
2026-08-23 11:22:20 -04:00
Nirav PatelandClaude Opus 5 c742ca4798 fix(link): 3 more codex [P2] findings — per-route caps, bool rules, proxy validation
Round 2 of review on the same branch:

- The fail-closed capacity guard was itself a DoS lever. MAX_RATE_KEYS was
  global, and the limiter runs before auth, so a flood of cheap /v1/repos
  keys could exhaust the table and 429 never-seen /v1/link clients until
  live windows expired. Budgets are now per route.
- Rule values accepted booleans: bool subclasses int, so isinstance let
  [5, true] through as a 1-SECOND window (5/hour -> ~5/sec) and false in the
  limit slot disabled the endpoint. Now `type(x) is int`.
- trusted_proxies was unvalidated: a bare string would be iterated character
  by character, malformed entries only surfaced as a per-request log line,
  and 0.0.0.0/0 or ::/0 restored "trust XFF from any peer" — the exact hole
  the setting closes. Now parsed and validated once at startup, wildcards
  refused, and _ip_in_any takes pre-parsed networks so nothing can degrade
  to a silent per-request skip.

Tests 99 -> 108: cross-route flood isolation, per-route reclamation windows,
every bool-in-rule position, bare-string and wildcard proxies, and a v4/v6
mismatch case.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 23:29:07 -04:00
Nirav PatelandClaude Opus 5 c442721aff fix(link): address 4 codex [P2] findings on the rate limiter
All four were real bypass or fail-open paths on an endpoint about to be
publicly exposed:

- X-Forwarded-For was trusted from ANY peer. The origin also listens on the
  tailnet, so anyone reaching it directly could pick -- and rotate -- their
  own rate-limit key by sending a header. Now honored only when the socket
  peer is in a configured trusted_proxies list, and the last hop must parse
  as a real IP. trust_forwarded_for without trusted_proxies REFUSES startup.
- Capacity eviction was fail-open and exploitable: an attacker able to mint
  many distinct keys could evict their own live window and start fresh. Now
  reclaims only EXPIRED windows and refuses the new key when all are live.
  Fail closed -- /v1/link is invite-only, so hitting the cap is an attack.
- The clock was read outside the lock, so racing threads could append out of
  order; both retry_after (hits[0]) and reclamation (v[-1]) assume the list
  is chronological. Moved inside.
- Config types were unvalidated: `"enabled": null` or `0` silently disabled
  limiting, and the string "false" enabled XFF trust (non-empty strings are
  truthy). Booleans must now be real JSON booleans; rate_limit must be an
  object.

Codex confirmed no path-variant bypass (dispatch is exact-match) and no
keep-alive/pipelining bypass (rejects set close_connection).

Tests 87 -> 99: capacity fail-closed with the victim's window proven
untouched through a 40-key flood, 200-thread chronological-order check,
untrusted-peer spoof, junk XFF, and every config-type trap.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 23:22:44 -04:00
Nirav PatelandClaude Opus 5 e2fed5886f feat(link): rate-limit /v1/link and /v1/repos
Last unbuilt item on the promotion-window hardening checklist. /v1/link
round-trips Zitadel, can CREATE a forge account and always mints a token, so
it is the endpoint that must not be free to hammer.

Sliding window per (route, client), ON by default -- unlimited has to be a
deliberate config act, not an omission. Defaults 5/hour and 60/hour; /health
never limited. 429 + Retry-After, decided BEFORE the body is read so an
abusive caller costs nothing.

Decisions worth naming:
- State is an in-process dict behind a lock. granthi-link is ONE
  ThreadingHTTPServer, so that IS the store -- no redis. Kept behind a class
  so a future multi-process move has one thing to change.
- Denied requests are NOT recorded. Recording them lets a hammering client
  push its own window forward and lock itself out forever.
- Key store is capped; at capacity it drops least-recent windows and logs
  loudly. Fail-open under key pressure, chosen over an unbounded dict that
  is a memory DoS.
- trust_forwarded_for OFF by default. Behind cloudflared every request comes
  from the tunnel, so limiting on the socket peer starves everyone; but XFF
  is client-controlled. A caller can PREPEND, a trusted proxy APPENDS what it
  actually saw -- so we read the LAST entry, never the first.
- A malformed rule refuses startup instead of silently meaning unlimited.

Tests 69 -> 87, including a 40-thread race proving the lock holds, the
self-lockout case, XFF spoof-resistance, and a real 429 on the wire.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 23:14:54 -04:00
Nirav PatelandClaude Opus 5 be11e319f5 fix: address 3 codex [P2] findings on list/get
- parse_repo_arg(): validate <name> / <owner>/<name> against a strict segment
  pattern. Not shell injection (argv list, no shell), but '?', '#', '..', an
  encoded slash or an extra path component could redirect the clone URL and
  the remote that gets persisted. Validate rather than quote — the forge's
  own naming rules are this narrow anyway.
- list now keys local folders on full_name, not bare name: an account that
  can see alice/cloud and bob/cloud showed BOTH as local when one was. `get`
  and `add` both record full_name; older entries fall back to <login>/<name>.
- list_repos truncation was off by one page: a repo total that is an exact
  multiple of the page size ends on a full page and was reported as
  truncated. One sentinel fetch past the cap separates complete from
  truncated.

Tests 65 -> 69, including hostile repo arguments and the exact-multiple case.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 14:29:05 -04:00
Nirav PatelandClaude Opus 5 88604a90f6 feat(client): list + get — the download half of the sync flow
`add` pushed a local folder up; nothing pulled a cloud repo down, so the
"show me my repos -> download -> start working" half of onboarding had no
implementation. Both new commands read the forge directly with the scoped
user token the link already handed us, so neither needs a granthi-link
endpoint, a service restart, or a VPS config edit.

- list: GET /api/v1/user/repos, pagination followed to a short page, with a
  FORGE_MAX_PAGES guard whose trip is REPORTED — a bounded page must never
  read as "that is all of them". Shows which repos are already local.
- get: clones with --origin granthi (the remote name watch looks for) and
  -c credential.helper (the repo does not exist yet, so the helper cannot be
  installed first), then registers the folder in the same shape `add` writes
  — without that, watch silently ignores everything cloned.
- require_linked(): one failure mode for every forge-touching command.
- VERSION 1.0.0 -> 1.1.0, matching the README and the 1.1.0 hardening.

Tests 55 -> 65.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
2026-08-22 14:25:03 -04:00
Nirav PatelandClaude Fable 5 14ccbd59f0 test+docs: startup-refusal exit code, real-body 413, pre-1.1.0 migration note
- main() exits 2 on permissive config (not just the predicate)
- 413 proven with an actual over-limit wire body, not header-only
- README: machine-user accounts need a seeded state.json mapping; only
  granthi-sync-e2e required it in beta

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 09:25:13 -04:00
Nirav PatelandClaude Fable 5 c674db4746 security: harden granthi-link + client against 7 codex findings
1. CRITICAL account-takeover by login collision: persist zitadel_sub ->
   gitea_login identity map (state.json, 0600, atomic); mapping wins,
   deleted logins re-created only if service-created, existing unmapped
   logins bind only on verified email match, else 409; token never
   minted before binding passes
2. test_mode now gated behind GRANTHI_LINK_ALLOW_TEST_MODE=1 env
3. refuse startup unless config.json is 0600/0400 and owned by service
4. client config created O_CREAT 0600 (no write-then-chmod window)
5. credential-helper command paths shlex-quoted
6. POST bodies capped at 64KB (413); missing/invalid Content-Length rejected
7. Gitea 409 on user create handled idempotently (re-fetch + verify email)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 09:17:26 -04:00
Nirav PatelandClaude Fable 5 2077591755 granthi-sync v1: granthi-link provisioning service + client daemon + tests
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-19 00:09:19 -04:00