@vibe-commit/capture 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -9
- package/dist/account_not_here.d.ts +15 -0
- package/dist/account_not_here.js +25 -0
- package/dist/account_not_here.js.map +1 -0
- package/dist/agent_hooks.d.ts +8 -0
- package/dist/agent_hooks.js +31 -1
- package/dist/agent_hooks.js.map +1 -1
- package/dist/agents/claude_code.d.ts +34 -0
- package/dist/agents/claude_code.js +141 -8
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.d.ts +13 -0
- package/dist/agents/codex_cli.js +241 -2
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.d.ts +34 -0
- package/dist/agents/cursor.js +115 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/session_listing.d.ts +29 -0
- package/dist/agents/session_listing.js +49 -0
- package/dist/agents/session_listing.js.map +1 -0
- package/dist/agents/types.d.ts +118 -8
- package/dist/agents/types.js +17 -3
- package/dist/agents/types.js.map +1 -1
- package/dist/commands/approval_pending.d.ts +9 -0
- package/dist/commands/approval_pending.js +23 -0
- package/dist/commands/approval_pending.js.map +1 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +52 -11
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/backfill.d.ts +160 -0
- package/dist/commands/backfill.js +748 -0
- package/dist/commands/backfill.js.map +1 -0
- package/dist/commands/browser_credential.d.ts +6 -2
- package/dist/commands/browser_credential.js +34 -14
- package/dist/commands/browser_credential.js.map +1 -1
- package/dist/commands/connect.d.ts +146 -0
- package/dist/commands/connect.js +216 -117
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_account.d.ts +135 -0
- package/dist/commands/connect_account.js +321 -0
- package/dist/commands/connect_account.js.map +1 -0
- package/dist/commands/connect_picker.d.ts +27 -0
- package/dist/commands/connect_picker.js +549 -0
- package/dist/commands/connect_picker.js.map +1 -0
- package/dist/commands/context.d.ts +16 -0
- package/dist/commands/context.js +27 -0
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/credential_problem.d.ts +15 -4
- package/dist/commands/credential_problem.js +29 -22
- package/dist/commands/credential_problem.js.map +1 -1
- package/dist/commands/device_credential.d.ts +5 -1
- package/dist/commands/device_credential.js +17 -14
- package/dist/commands/device_credential.js.map +1 -1
- package/dist/commands/finalize.d.ts +95 -0
- package/dist/commands/finalize.js +249 -74
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/finish_mint.d.ts +30 -6
- package/dist/commands/finish_mint.js +26 -6
- package/dist/commands/finish_mint.js.map +1 -1
- package/dist/commands/report.js +38 -30
- package/dist/commands/report.js.map +1 -1
- package/dist/commands/scan.d.ts +162 -0
- package/dist/commands/scan.js +507 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/status.d.ts +19 -1
- package/dist/commands/status.js +232 -26
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_hooks.d.ts +14 -0
- package/dist/commands/status_hooks.js +81 -0
- package/dist/commands/status_hooks.js.map +1 -0
- package/dist/commands/status_machine.d.ts +27 -0
- package/dist/commands/status_machine.js +210 -0
- package/dist/commands/status_machine.js.map +1 -0
- package/dist/commands/sync_reason.d.ts +17 -0
- package/dist/commands/sync_reason.js +25 -0
- package/dist/commands/sync_reason.js.map +1 -0
- package/dist/commands/why.js +44 -31
- package/dist/commands/why.js.map +1 -1
- package/dist/consent.d.ts +217 -0
- package/dist/consent.js +400 -1
- package/dist/consent.js.map +1 -1
- package/dist/copy/error_codes.d.ts +222 -0
- package/dist/copy/error_codes.js +105 -0
- package/dist/copy/error_codes.js.map +1 -0
- package/dist/copy/index.d.ts +2 -2
- package/dist/copy/index.js +2 -2
- package/dist/copy/index.js.map +1 -1
- package/dist/copy/strings.d.ts +617 -30
- package/dist/copy/strings.js +741 -36
- package/dist/copy/strings.js.map +1 -1
- package/dist/copy/time.d.ts +11 -0
- package/dist/copy/time.js +18 -0
- package/dist/copy/time.js.map +1 -1
- package/dist/credential.d.ts +5 -1
- package/dist/credential.js +2 -2
- package/dist/credential.js.map +1 -1
- package/dist/git.d.ts +43 -0
- package/dist/git.js +95 -0
- package/dist/git.js.map +1 -1
- package/dist/grants_sync.d.ts +165 -0
- package/dist/grants_sync.js +289 -0
- package/dist/grants_sync.js.map +1 -0
- package/dist/grants_verb.d.ts +70 -0
- package/dist/grants_verb.js +121 -0
- package/dist/grants_verb.js.map +1 -0
- package/dist/hooks/entry.d.ts +18 -1
- package/dist/hooks/entry.js +117 -14
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/post_commit.js +3 -3
- package/dist/hooks/post_commit.js.map +1 -1
- package/dist/hooks/post_rewrite.js +6 -4
- package/dist/hooks/post_rewrite.js.map +1 -1
- package/dist/hooks/session_grants.d.ts +59 -0
- package/dist/hooks/session_grants.js +91 -0
- package/dist/hooks/session_grants.js.map +1 -0
- package/dist/hooks/session_start.d.ts +114 -0
- package/dist/hooks/session_start.js +243 -0
- package/dist/hooks/session_start.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +76 -11
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +57 -0
- package/dist/install.js +54 -20
- package/dist/install.js.map +1 -1
- package/dist/json.d.ts +32 -9
- package/dist/json.js +35 -8
- package/dist/json.js.map +1 -1
- package/dist/machine_state.d.ts +52 -0
- package/dist/machine_state.js +63 -0
- package/dist/machine_state.js.map +1 -0
- package/dist/oauth/retire.d.ts +10 -0
- package/dist/oauth/retire.js +13 -0
- package/dist/oauth/retire.js.map +1 -1
- package/dist/oauth/session.d.ts +1 -0
- package/dist/org_pending.d.ts +52 -0
- package/dist/org_pending.js +109 -0
- package/dist/org_pending.js.map +1 -0
- package/dist/paths.d.ts +70 -0
- package/dist/paths.js +90 -0
- package/dist/paths.js.map +1 -1
- package/dist/pending_revoke.d.ts +117 -0
- package/dist/pending_revoke.js +233 -0
- package/dist/pending_revoke.js.map +1 -0
- package/dist/pending_revoke_file.d.ts +55 -0
- package/dist/pending_revoke_file.js +74 -0
- package/dist/pending_revoke_file.js.map +1 -0
- package/dist/picker.d.ts +208 -0
- package/dist/picker.js +526 -0
- package/dist/picker.js.map +1 -0
- package/dist/picker_checklist.d.ts +141 -0
- package/dist/picker_checklist.js +411 -0
- package/dist/picker_checklist.js.map +1 -0
- package/dist/policy.d.ts +70 -1
- package/dist/policy.js +68 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +91 -18
- package/dist/post.js +259 -27
- package/dist/post.js.map +1 -1
- package/dist/state.d.ts +6 -0
- package/dist/state.js +38 -1
- package/dist/state.js.map +1 -1
- package/dist/system_message.d.ts +32 -2
- package/dist/system_message.js +47 -5
- package/dist/system_message.js.map +1 -1
- package/dist/term.d.ts +28 -2
- package/dist/term.js +23 -2
- package/dist/term.js.map +1 -1
- package/dist/verb_help.d.ts +13 -0
- package/dist/verb_help.js +40 -0
- package/dist/verb_help.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -9
package/dist/consent.d.ts
CHANGED
|
@@ -30,11 +30,21 @@
|
|
|
30
30
|
* `.consent`, and the parameter is named for it so a toplevel passed by habit
|
|
31
31
|
* reads wrong at the call site.
|
|
32
32
|
*
|
|
33
|
+
* ## ⛔ A SECOND KEY, AND ONE DOOR: `isCaptureAllowed` (0.7.0, D1 · B)
|
|
34
|
+
*
|
|
35
|
+
* A repo can now also be admitted by its SLUG (`owner/repo`) through
|
|
36
|
+
* `grants.json`, so any clone of it matches, not only the one that ran
|
|
37
|
+
* `connect`. Every gate asks `isCaptureAllowed`; `isProjectAllowed` is the
|
|
38
|
+
* common-dir half of it and nothing outside this file calls it
|
|
39
|
+
* (`test/consent-gates.test.ts` scans for exactly that). Two keys behind two
|
|
40
|
+
* doors is how `post_rewrite.ts` was missed once.
|
|
41
|
+
*
|
|
33
42
|
* ⚠ **No migration for allow lists written under the old key**, and that is a
|
|
34
43
|
* decision rather than an omission: pre-launch there are zero users and zero
|
|
35
44
|
* rows (`project_pre_launch_no_data`), so there is nothing to migrate. See
|
|
36
45
|
* `resolveProjectKeys`.
|
|
37
46
|
*/
|
|
47
|
+
import type { ProjectKeys } from "./project.js";
|
|
38
48
|
/** One consented project. `at` is informational; presence is the grant. */
|
|
39
49
|
export interface ProjectGrant {
|
|
40
50
|
readonly at: string;
|
|
@@ -52,6 +62,213 @@ export declare function isProjectAllowed(home: string, consentKey: string | null
|
|
|
52
62
|
export declare function grantProject(home: string, consentKey: string, now: Date): void;
|
|
53
63
|
/** Revoke consent for one project (`vibecommit off`). */
|
|
54
64
|
export declare function revokeProject(home: string, consentKey: string): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* `grants.json`. ⛔ **Slugs, not paths**: `owner/repo` for GitHub, `host/owner/repo`
|
|
67
|
+
* for any other host (`git.ts`'s `parseRemoteSlug`), so every clone matches.
|
|
68
|
+
*
|
|
69
|
+
* - `owners` — "all repos under this owner, including ones cloned later". An owner is the
|
|
70
|
+
* slug minus its last segment: `alexvibecommit`, or `gitlab.example.com/team`.
|
|
71
|
+
* - `repos` — single repos granted one by one.
|
|
72
|
+
* - `excluded` — `vibecommit off`. **Wins over every other basis**, a surviving common-dir entry included.
|
|
73
|
+
* - `refused` — 0.7.0's field for server refusals, kept so a 0.7.0 client can still read a 0.8.0 file. ⛔ Nothing writes it:
|
|
74
|
+
* slice 2's refusals are written by hooks, and hooks never write this file (see `grants-account.json` below).
|
|
75
|
+
* It is still honoured as a denial if something is in it.
|
|
76
|
+
* - `pending` — slice 2: grant writes this machine has not yet delivered to the account `{ op, kind, value, at }`. The next
|
|
77
|
+
* CLI verb that syncs sends and clears them (`grants_sync.ts`). Absent in a 0.7.0 file, which reads as none.
|
|
78
|
+
*
|
|
79
|
+
* ⛔ **THIS FILE IS CLI-ONLY.** `updateGrants` is an unlocked read-modify-write, so a hook that wrote here could race
|
|
80
|
+
* `vibecommit off` and restore the repo `off` had just turned off. What a hook has to remember lives in `grants-account.json`.
|
|
81
|
+
*
|
|
82
|
+
* `disclosed_at` is when the D65 disclosure was last answered yes for a grant; `null` until then.
|
|
83
|
+
*/
|
|
84
|
+
export interface Grants {
|
|
85
|
+
readonly disclosed_at: string | null;
|
|
86
|
+
readonly owners: readonly string[];
|
|
87
|
+
readonly repos: readonly string[];
|
|
88
|
+
readonly excluded: readonly string[];
|
|
89
|
+
readonly refused: readonly string[];
|
|
90
|
+
readonly pending: readonly PendingOp[];
|
|
91
|
+
/**
|
|
92
|
+
* ISO 8601, set ONCE, when this machine's first sync got a 200; `null` before. ⛔ **This is what makes a sync a "first sync"** (it
|
|
93
|
+
* uploads everything this machine already named), and it lives here and not in `grants-account.json` on purpose: that file is
|
|
94
|
+
* ignored when unreadable, and a deleted one must not make the next sync re-grant what was revoked elsewhere. CLI-only.
|
|
95
|
+
* Absent in a 0.7.0 file, which reads as `null`.
|
|
96
|
+
*/
|
|
97
|
+
readonly synced_at: string | null;
|
|
98
|
+
}
|
|
99
|
+
/** One queued write to the account's grants — the shape the server's sync endpoint takes, plus when it was queued. */
|
|
100
|
+
export interface PendingOp {
|
|
101
|
+
readonly op: "grant" | "revoke" | "exclude" | "include";
|
|
102
|
+
/** `exclude` and `include` are repo-only. The kind cannot be read off the value (`acme/api` and `gitlab.example.com/team` are both one slash). */
|
|
103
|
+
readonly kind: "owner" | "repo";
|
|
104
|
+
readonly value: string;
|
|
105
|
+
/** ISO 8601, when it was queued. */
|
|
106
|
+
readonly at: string;
|
|
107
|
+
}
|
|
108
|
+
export declare const EMPTY_GRANTS: Grants;
|
|
109
|
+
/**
|
|
110
|
+
* ⛔ **THE ONE NORMALIZATION.** The whole host-qualified slug, lowercased, for every host — the identity the
|
|
111
|
+
* server already stores (`resolve_repository` sets `v_slug := lower(trim(...))`). Owners, repos, exclusions,
|
|
112
|
+
* every `grants.json` write and slice 2's server comparison all go through this, so two clones whose remotes
|
|
113
|
+
* differ only in case are one repo on both sides.
|
|
114
|
+
*/
|
|
115
|
+
export declare function normalizeSlug(slug: string): string;
|
|
116
|
+
/** An owner as stored: normalized, and without the trailing `/` the picker displays (`alexvibecommit/`). */
|
|
117
|
+
export declare function normalizeOwner(owner: string): string;
|
|
118
|
+
/** `local:` slugs name a path, not a repo: no grant can match one, and their consent stays per clone. */
|
|
119
|
+
export declare function isLocalSlug(slug: string): boolean;
|
|
120
|
+
/** The owner part of a slug: everything before the last `/`. `""` when there is none. */
|
|
121
|
+
export declare function ownerOfSlug(slug: string): string;
|
|
122
|
+
/**
|
|
123
|
+
* What reading `grants.json` found. ⛔ **Three states, because two of them mean opposite things.**
|
|
124
|
+
*
|
|
125
|
+
* - `missing` — no file. Every machine upgrading from 0.6.x is here, and their common-dir consents
|
|
126
|
+
* must keep capturing.
|
|
127
|
+
* - `unreadable` — a file is there and cannot be read or does not parse. It may hold exclusions we cannot
|
|
128
|
+
* see, so it DENIES every clone that has a slug (`isCaptureAllowed`). `status` says so
|
|
129
|
+
* loudly, since a hook cannot.
|
|
130
|
+
* - `ok` — parsed and normalized.
|
|
131
|
+
*/
|
|
132
|
+
export type GrantsRead = {
|
|
133
|
+
readonly kind: "missing";
|
|
134
|
+
} | {
|
|
135
|
+
readonly kind: "unreadable";
|
|
136
|
+
} | {
|
|
137
|
+
readonly kind: "ok";
|
|
138
|
+
readonly grants: Grants;
|
|
139
|
+
};
|
|
140
|
+
export declare function readGrants(home: string): GrantsRead;
|
|
141
|
+
/** True when a `grants.json` is present and unusable — the state `status` reports and a SessionStart notice names. */
|
|
142
|
+
export declare function grantsUnreadable(home: string): boolean;
|
|
143
|
+
/**
|
|
144
|
+
* The account's grants as the last sync or GET returned them. ⛔ **It never admits anything** (R3-22): a repo the account
|
|
145
|
+
* grants is captured on this machine only once THIS machine names it, in `grants.json`. `excluded` here does DENY — a repo
|
|
146
|
+
* turned off from another machine or the dashboard stops here too.
|
|
147
|
+
*/
|
|
148
|
+
export interface AccountGrants {
|
|
149
|
+
readonly owners: readonly string[];
|
|
150
|
+
readonly repos: readonly string[];
|
|
151
|
+
readonly excluded: readonly string[];
|
|
152
|
+
/** The server's `epoch` for the user's grants when this was read. */
|
|
153
|
+
readonly epoch: number;
|
|
154
|
+
/** ISO 8601, when this machine got it. */
|
|
155
|
+
readonly synced_at: string;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `grants-account.json`.
|
|
159
|
+
*
|
|
160
|
+
* - `account` — the last synced account state, or `null` before the first sync.
|
|
161
|
+
* - `refused` — slugs the server refused with `repository_excluded` since the last sync. Denied here; a sync clears it.
|
|
162
|
+
* - `stale` — an `X-Grant-Epoch` differed from `account.epoch` (`noteGrantEpoch`); SessionStart's one GET reads it and clears it.
|
|
163
|
+
* - `last_get_at` — when SessionStart last did that GET, for its hourly limit.
|
|
164
|
+
*
|
|
165
|
+
* ⛔ **HOOKS WRITE THIS FILE AND NEVER `grants.json`.** It is written WHOLE and atomically (temp file + rename, 0600) and never
|
|
166
|
+
* through `updateGrants`. Two writers can still lose each other's update (the same unlocked read-modify-write), and that is
|
|
167
|
+
* safe by design: a lost `refused` slug means the server refuses it once more, and a lost `account` is read back by the next
|
|
168
|
+
* sync. Nothing in here can admit a repo, so nothing a race loses can capture one.
|
|
169
|
+
*
|
|
170
|
+
* ⛔ **An unreadable or corrupt file is IGNORED — it reads as empty, never as deny-all.** The opposite of `grants.json`, for the
|
|
171
|
+
* opposite reason: `grants.json` holds the user's own exclusions, which a deny-all protects; this file holds a cache and a
|
|
172
|
+
* server's refusals, and a machine that can't read it just asks the server again.
|
|
173
|
+
*/
|
|
174
|
+
export interface AccountState {
|
|
175
|
+
readonly account: AccountGrants | null;
|
|
176
|
+
readonly refused: readonly string[];
|
|
177
|
+
readonly stale: boolean;
|
|
178
|
+
readonly last_get_at: string | null;
|
|
179
|
+
}
|
|
180
|
+
export declare const EMPTY_ACCOUNT_STATE: AccountState;
|
|
181
|
+
/** Read it. **NEVER THROWS, and any failure — missing, unreadable, not JSON, any field off-shape — is the empty state.** */
|
|
182
|
+
export declare function readAccountState(home: string): AccountState;
|
|
183
|
+
/** Write it whole: normalized, atomic, 0600. Throws on an I/O failure (the destination is then untouched). */
|
|
184
|
+
export declare function writeAccountState(home: string, state: AccountState): void;
|
|
185
|
+
/**
|
|
186
|
+
* A hook saw `repository_excluded` for this slug: remember it, so this machine stops reading that repo's transcripts until
|
|
187
|
+
* the next sync says otherwise. **NEVER THROWS** — it runs on the hook's path, where a failed write only means the server
|
|
188
|
+
* refuses the next upload too. `local:` slugs are never recorded (there is no repo identity to refuse).
|
|
189
|
+
*/
|
|
190
|
+
export declare function recordRefused(home: string, slug: string): void;
|
|
191
|
+
/**
|
|
192
|
+
* A hook saw `X-Grant-Epoch` on a server answer (an ingest 2xx, or a grants refusal): if it is not the epoch this machine last
|
|
193
|
+
* recorded, mark the account state `stale`, which is what SessionStart's one bounded `GET` looks for (`session_grants.ts`).
|
|
194
|
+
* A machine with no recorded account state counts as epoch 0, as the server does when a user has no grants row.
|
|
195
|
+
*
|
|
196
|
+
* Writes `grants-account.json` and never `grants.json` (see `AccountState`), only when something changed — so a Stop hook that sees
|
|
197
|
+
* the same epoch every turn does no file write at all — and only ever sets `stale`; the GET (or a sync) clears it. **NEVER THROWS**:
|
|
198
|
+
* it runs on the hook's path, and a lost mark only means the next answer marks it again.
|
|
199
|
+
*/
|
|
200
|
+
export declare function noteGrantEpoch(home: string, epoch: number | null | undefined): void;
|
|
201
|
+
/** A slug now, or a way to get it. The thunk is for the hooks: see `isCaptureAllowed`. */
|
|
202
|
+
export type SlugSource = string | (() => string);
|
|
203
|
+
/**
|
|
204
|
+
* Does the hold of `isCaptureAllowed` (step 3) apply to ANYTHING on this machine right now? True when the machine has synced
|
|
205
|
+
* (`synced_at` is set) and a queued `grant` op names an owner or a repo the last synced account state does not already grant
|
|
206
|
+
* (the same two conditions the per-slug hold uses: a synced machine, and the account not already admitting it). "0.8.0 truth
|
|
207
|
+
* fixes" R2-2: `connect`'s untold line says sessions wait only when something actually waits. Reads `grants.json` and
|
|
208
|
+
* `grants-account.json`; an unreadable `grants.json` holds nothing here (the gate denies for its own reason).
|
|
209
|
+
*/
|
|
210
|
+
export declare function holdsAny(home: string): boolean;
|
|
211
|
+
/**
|
|
212
|
+
* ⛔ **THE ONE ADMISSION DECISION.** Every gate calls this; `entry.ts`, `post_commit.ts`, `post_rewrite.ts` (twice),
|
|
213
|
+
* `finalize.ts`, `status.ts` and `connect.ts` are the seven call sites. Absence is a NO and every error path denies.
|
|
214
|
+
*
|
|
215
|
+
* Precedence:
|
|
216
|
+
* 1. A MISSING `grants.json` is empty (0.6.x machines keep capturing on their common-dir consent). A PRESENT file
|
|
217
|
+
* that cannot be read denies every clone that has a slug, whatever `projects.json` says.
|
|
218
|
+
* 2. A denied slug is denied, whatever the basis: `grants.json`'s `excluded` (and its legacy `refused`), the account file's
|
|
219
|
+
* `refused`, or the account's own `excluded`.
|
|
220
|
+
* 3. On a machine that HAS SYNCED (`grants.synced_at` is set), a slug with a `grant` still PENDING (queued, not yet delivered to
|
|
221
|
+
* the account) is held back when that grant is the slug's ONLY local basis, i.e. the clone has no common-dir consent: its
|
|
222
|
+
* spans stay unsent with their offset unchanged, and go once a sync delivers the grant. It is not held either when the last
|
|
223
|
+
* synced account state already grants the repo or its owner (the server admits it, so a queued grant that was refused must
|
|
224
|
+
* not hold it for ever).
|
|
225
|
+
* ⭐ **A MACHINE THAT HAS NEVER SYNCED HOLDS NOTHING** (VQ, "0.8.0 truth fixes" F1): the server refuses only a credential that
|
|
226
|
+
* has synced (`grants_synced_at`), so before the first sync the hold protects nothing and would only defer uploads, for ever
|
|
227
|
+
* against a server without the routes. Such a machine decides capture by its own choices, as under 0.7.0.
|
|
228
|
+
* ⭐ **WHY A COMMON-DIR CONSENT LIFTS THE HOLD** (VQ's ruling): a remote repo gets a common-dir consent only from pre-0.8.0
|
|
229
|
+
* flows (0.8.0's in-repo `connect` pre-ticks the picker, which writes GRANTS). The first sync uploads those consented slugs, so
|
|
230
|
+
* either this credential has not synced, and the server never refuses anything for it, or the first sync already granted the
|
|
231
|
+
* slug, and holding it would only delay bytes the server accepts.
|
|
232
|
+
* 4. Otherwise: admitted on the common-dir consent, or on a slug matched by `owners` or `repos`. ⛔ The account's own
|
|
233
|
+
* `owners`/`repos` are NOT consulted — a repo only the account grants is not captured on this machine (per-machine naming).
|
|
234
|
+
*
|
|
235
|
+
* `local:` slugs never match a grant and keep common-dir consent alone. They are also not subject to
|
|
236
|
+
* exclusion or to an unreadable file: there is no repo identity to exclude.
|
|
237
|
+
*
|
|
238
|
+
* `grants-account.json` is read on every call and an unreadable one is the empty state, never a denial (`AccountState`).
|
|
239
|
+
*
|
|
240
|
+
* ⚠ **`slug` MAY BE A THUNK, and the hooks pass one.** Computing a slug can spawn `git remote get-url` and `ssh -G`,
|
|
241
|
+
* which is real time on a hook's budget; a machine with no `grants.json` (every 0.6.x machine) needs none of it, and
|
|
242
|
+
* neither does a clone with no consent and no grants to match. The slug is asked for only when the answer depends on it.
|
|
243
|
+
* Pass a memoized thunk (`git.ts`'s `onceRepoSlug`) so the caller's own later use shares the one spawn.
|
|
244
|
+
*
|
|
245
|
+
* `keys` is the `resolveProjectKeys` result the caller already holds, so the confinement roots it derives afterwards
|
|
246
|
+
* come from the very resolution this gate used (`D184 §3`).
|
|
247
|
+
*/
|
|
248
|
+
export declare function isCaptureAllowed(home: string, keys: ProjectKeys | null, slug: SlugSource): boolean;
|
|
249
|
+
/** `updateGrants` refuses to rewrite a file it cannot read: overwriting would silently drop exclusions it never saw. */
|
|
250
|
+
export declare class GrantsUnreadableError extends Error {
|
|
251
|
+
constructor();
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read-modify-write `grants.json`. 0700 on the directory, 0600 on the file, atomic (a hook reading while a verb saves
|
|
255
|
+
* sees the old file or the new, never half). Entries are normalized and de-duplicated on the way out.
|
|
256
|
+
*
|
|
257
|
+
* ⛔ **Throws `GrantsUnreadableError` rather than start from empty** when the file is present and unusable: a rewrite
|
|
258
|
+
* would turn "denies everything" into "admits on common-dir consent" without the user having decided anything.
|
|
259
|
+
*
|
|
260
|
+
* ⛔ **Keeps the top-level keys it does not know**, verbatim and uninterpreted, after the five it rebuilds (which always
|
|
261
|
+
* win). A newer version's file read by this one is written back whole: dropping a key it added would silently undo its
|
|
262
|
+
* state. No lock: same read-modify-write as `projects.json`, so two simultaneous verbs can lose one write.
|
|
263
|
+
*/
|
|
264
|
+
export declare function updateGrants(home: string, change: (current: Grants) => Grants): Grants;
|
|
265
|
+
/** `vibecommit off`: the slug joins `excluded`. Returns true when that changed the file. */
|
|
266
|
+
export declare function excludeSlug(home: string, slug: string): boolean;
|
|
267
|
+
/**
|
|
268
|
+
* A yes at the disclosure re-includes a turned-off repo; nothing else does. `"none"` when the slug was not excluded
|
|
269
|
+
* (no file is written then), `"unreadable"` when `grants.json` cannot be read.
|
|
270
|
+
*/
|
|
271
|
+
export declare function clearExclusion(home: string, slug: string): "cleared" | "none" | "unreadable";
|
|
55
272
|
/**
|
|
56
273
|
* Interpret an answer to the consent prompt. **Only an explicit yes is a yes.**
|
|
57
274
|
* Empty (the user pressed return) is NO, which is what makes `[y/N]` honest.
|
package/dist/consent.js
CHANGED
|
@@ -30,6 +30,15 @@
|
|
|
30
30
|
* `.consent`, and the parameter is named for it so a toplevel passed by habit
|
|
31
31
|
* reads wrong at the call site.
|
|
32
32
|
*
|
|
33
|
+
* ## ⛔ A SECOND KEY, AND ONE DOOR: `isCaptureAllowed` (0.7.0, D1 · B)
|
|
34
|
+
*
|
|
35
|
+
* A repo can now also be admitted by its SLUG (`owner/repo`) through
|
|
36
|
+
* `grants.json`, so any clone of it matches, not only the one that ran
|
|
37
|
+
* `connect`. Every gate asks `isCaptureAllowed`; `isProjectAllowed` is the
|
|
38
|
+
* common-dir half of it and nothing outside this file calls it
|
|
39
|
+
* (`test/consent-gates.test.ts` scans for exactly that). Two keys behind two
|
|
40
|
+
* doors is how `post_rewrite.ts` was missed once.
|
|
41
|
+
*
|
|
33
42
|
* ⚠ **No migration for allow lists written under the old key**, and that is a
|
|
34
43
|
* decision rather than an omission: pre-launch there are zero users and zero
|
|
35
44
|
* rows (`project_pre_launch_no_data`), so there is nothing to migrate. See
|
|
@@ -37,7 +46,8 @@
|
|
|
37
46
|
*/
|
|
38
47
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
48
|
import { dirname } from "node:path";
|
|
40
|
-
import {
|
|
49
|
+
import { writeFileAtomic } from "./atomic_write.js";
|
|
50
|
+
import { grantsAccountPath, grantsPath, projectsPath, rootDir } from "./paths.js";
|
|
41
51
|
/**
|
|
42
52
|
* Read the allow list. **Every failure returns an empty list**, which means "no
|
|
43
53
|
* project is consented" — a corrupt or unreadable file must not be able to grant
|
|
@@ -100,6 +110,395 @@ export function revokeProject(home, consentKey) {
|
|
|
100
110
|
chmodSync(path, 0o600);
|
|
101
111
|
return true;
|
|
102
112
|
}
|
|
113
|
+
export const EMPTY_GRANTS = {
|
|
114
|
+
disclosed_at: null,
|
|
115
|
+
owners: [],
|
|
116
|
+
repos: [],
|
|
117
|
+
excluded: [],
|
|
118
|
+
refused: [],
|
|
119
|
+
pending: [],
|
|
120
|
+
synced_at: null,
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* ⛔ **THE ONE NORMALIZATION.** The whole host-qualified slug, lowercased, for every host — the identity the
|
|
124
|
+
* server already stores (`resolve_repository` sets `v_slug := lower(trim(...))`). Owners, repos, exclusions,
|
|
125
|
+
* every `grants.json` write and slice 2's server comparison all go through this, so two clones whose remotes
|
|
126
|
+
* differ only in case are one repo on both sides.
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeSlug(slug) {
|
|
129
|
+
return slug.trim().toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
/** An owner as stored: normalized, and without the trailing `/` the picker displays (`alexvibecommit/`). */
|
|
132
|
+
export function normalizeOwner(owner) {
|
|
133
|
+
return normalizeSlug(owner).replace(/\/+$/, "");
|
|
134
|
+
}
|
|
135
|
+
/** `local:` slugs name a path, not a repo: no grant can match one, and their consent stays per clone. */
|
|
136
|
+
export function isLocalSlug(slug) {
|
|
137
|
+
return slug.startsWith("local:");
|
|
138
|
+
}
|
|
139
|
+
/** The owner part of a slug: everything before the last `/`. `""` when there is none. */
|
|
140
|
+
export function ownerOfSlug(slug) {
|
|
141
|
+
const cut = slug.lastIndexOf("/");
|
|
142
|
+
return cut <= 0 ? "" : slug.slice(0, cut);
|
|
143
|
+
}
|
|
144
|
+
function stringList(value, normalize) {
|
|
145
|
+
if (!Array.isArray(value))
|
|
146
|
+
return null;
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const item of value) {
|
|
149
|
+
if (typeof item !== "string")
|
|
150
|
+
return null;
|
|
151
|
+
const n = normalize(item);
|
|
152
|
+
if (n !== "" && !out.includes(n))
|
|
153
|
+
out.push(n);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
const PENDING_OPS = new Set(["grant", "revoke", "exclude", "include"]);
|
|
158
|
+
/**
|
|
159
|
+
* `pending`: ABSENT is none (every 0.7.0 file), but a field that is there and malformed is `null` — the caller reads the
|
|
160
|
+
* whole file as unreadable, the same rule as the other lists, since a queue we cannot read may hold a grant we are holding.
|
|
161
|
+
*/
|
|
162
|
+
function parsePending(value) {
|
|
163
|
+
if (value === undefined)
|
|
164
|
+
return [];
|
|
165
|
+
if (!Array.isArray(value))
|
|
166
|
+
return null;
|
|
167
|
+
const out = [];
|
|
168
|
+
for (const item of value) {
|
|
169
|
+
if (item === null || typeof item !== "object" || Array.isArray(item))
|
|
170
|
+
return null;
|
|
171
|
+
const { op, kind, value: v, at } = item;
|
|
172
|
+
if (typeof op !== "string" || !PENDING_OPS.has(op))
|
|
173
|
+
return null;
|
|
174
|
+
if (kind !== "owner" && kind !== "repo")
|
|
175
|
+
return null;
|
|
176
|
+
if (typeof v !== "string" || typeof at !== "string")
|
|
177
|
+
return null;
|
|
178
|
+
if ((op === "exclude" || op === "include") && kind !== "repo")
|
|
179
|
+
return null;
|
|
180
|
+
const clean = kind === "owner" ? normalizeOwner(v) : normalizeSlug(v);
|
|
181
|
+
if (clean === "")
|
|
182
|
+
return null;
|
|
183
|
+
out.push({ op: op, kind, value: clean, at });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The seven fields this version knows and REBUILDS on every write (`pending` and `synced_at` are slice 2's); every other top-level key
|
|
189
|
+
* of `grants.json` is carried through untouched.
|
|
190
|
+
*/
|
|
191
|
+
const KNOWN_GRANTS_KEYS = new Set(["disclosed_at", "owners", "repos", "excluded", "refused", "pending", "synced_at"]);
|
|
192
|
+
/**
|
|
193
|
+
* `readGrants` plus the top-level keys it does not know, in file order. One read and one parse, so `updateGrants`
|
|
194
|
+
* rewrites exactly the file it read. `extras` is empty unless `read` is `ok`.
|
|
195
|
+
*/
|
|
196
|
+
function readGrantsWithExtras(home) {
|
|
197
|
+
const none = [];
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = readFileSync(grantsPath(home), "utf8");
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return { read: error.code === "ENOENT" ? { kind: "missing" } : { kind: "unreadable" }, extras: none };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(raw);
|
|
207
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
208
|
+
return { read: { kind: "unreadable" }, extras: none };
|
|
209
|
+
const o = parsed;
|
|
210
|
+
// A missing list is NOT read as empty: a typo'd `exclude` key would otherwise silently drop every
|
|
211
|
+
// exclusion. All four are required, extra keys are ignored.
|
|
212
|
+
const owners = stringList(o.owners, normalizeOwner);
|
|
213
|
+
const repos = stringList(o.repos, normalizeSlug);
|
|
214
|
+
const excluded = stringList(o.excluded, normalizeSlug);
|
|
215
|
+
const refused = stringList(o.refused, normalizeSlug);
|
|
216
|
+
const pending = parsePending(o.pending);
|
|
217
|
+
const syncedAt = o.synced_at === undefined ? null : o.synced_at;
|
|
218
|
+
const at = o.disclosed_at;
|
|
219
|
+
if (owners === null || repos === null || excluded === null || refused === null || pending === null) {
|
|
220
|
+
return { read: { kind: "unreadable" }, extras: none };
|
|
221
|
+
}
|
|
222
|
+
if (at !== null && typeof at !== "string")
|
|
223
|
+
return { read: { kind: "unreadable" }, extras: none };
|
|
224
|
+
if (syncedAt !== null && typeof syncedAt !== "string")
|
|
225
|
+
return { read: { kind: "unreadable" }, extras: none };
|
|
226
|
+
return {
|
|
227
|
+
read: { kind: "ok", grants: { disclosed_at: at, owners, repos, excluded, refused, pending, synced_at: syncedAt } },
|
|
228
|
+
extras: Object.entries(o).filter(([key]) => !KNOWN_GRANTS_KEYS.has(key)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return { read: { kind: "unreadable" }, extras: none };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function readGrants(home) {
|
|
236
|
+
return readGrantsWithExtras(home).read;
|
|
237
|
+
}
|
|
238
|
+
/** True when a `grants.json` is present and unusable — the state `status` reports and a SessionStart notice names. */
|
|
239
|
+
export function grantsUnreadable(home) {
|
|
240
|
+
return readGrants(home).kind === "unreadable";
|
|
241
|
+
}
|
|
242
|
+
export const EMPTY_ACCOUNT_STATE = { account: null, refused: [], stale: false, last_get_at: null };
|
|
243
|
+
function parseAccountGrants(value) {
|
|
244
|
+
if (value === null)
|
|
245
|
+
return null;
|
|
246
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
247
|
+
return undefined;
|
|
248
|
+
const o = value;
|
|
249
|
+
const owners = stringList(o.owners, normalizeOwner);
|
|
250
|
+
const repos = stringList(o.repos, normalizeSlug);
|
|
251
|
+
const excluded = stringList(o.excluded, normalizeSlug);
|
|
252
|
+
if (owners === null || repos === null || excluded === null)
|
|
253
|
+
return undefined;
|
|
254
|
+
if (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0)
|
|
255
|
+
return undefined;
|
|
256
|
+
if (typeof o.synced_at !== "string")
|
|
257
|
+
return undefined;
|
|
258
|
+
return { owners, repos, excluded, epoch: o.epoch, synced_at: o.synced_at };
|
|
259
|
+
}
|
|
260
|
+
/** Read it. **NEVER THROWS, and any failure — missing, unreadable, not JSON, any field off-shape — is the empty state.** */
|
|
261
|
+
export function readAccountState(home) {
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(readFileSync(grantsAccountPath(home), "utf8"));
|
|
264
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
265
|
+
return EMPTY_ACCOUNT_STATE;
|
|
266
|
+
const o = parsed;
|
|
267
|
+
const account = o.account === undefined ? null : parseAccountGrants(o.account);
|
|
268
|
+
const refused = o.refused === undefined ? [] : stringList(o.refused, normalizeSlug);
|
|
269
|
+
const last = o.last_get_at === undefined ? null : o.last_get_at;
|
|
270
|
+
if (account === undefined || refused === null)
|
|
271
|
+
return EMPTY_ACCOUNT_STATE;
|
|
272
|
+
if (o.stale !== undefined && typeof o.stale !== "boolean")
|
|
273
|
+
return EMPTY_ACCOUNT_STATE;
|
|
274
|
+
if (last !== null && typeof last !== "string")
|
|
275
|
+
return EMPTY_ACCOUNT_STATE;
|
|
276
|
+
return { account, refused, stale: o.stale === true, last_get_at: last };
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return EMPTY_ACCOUNT_STATE;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Write it whole: normalized, atomic, 0600. Throws on an I/O failure (the destination is then untouched). */
|
|
283
|
+
export function writeAccountState(home, state) {
|
|
284
|
+
const clean = {
|
|
285
|
+
account: state.account === null
|
|
286
|
+
? null
|
|
287
|
+
: {
|
|
288
|
+
owners: [...new Set(state.account.owners.map(normalizeOwner).filter((o) => o !== ""))],
|
|
289
|
+
repos: [...new Set(state.account.repos.map(normalizeSlug).filter((r) => r !== ""))],
|
|
290
|
+
excluded: [...new Set(state.account.excluded.map(normalizeSlug).filter((x) => x !== ""))],
|
|
291
|
+
epoch: state.account.epoch,
|
|
292
|
+
synced_at: state.account.synced_at,
|
|
293
|
+
},
|
|
294
|
+
refused: [...new Set(state.refused.map(normalizeSlug).filter((x) => x !== "" && !isLocalSlug(x)))],
|
|
295
|
+
stale: state.stale,
|
|
296
|
+
last_get_at: state.last_get_at,
|
|
297
|
+
};
|
|
298
|
+
const path = grantsAccountPath(home);
|
|
299
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
300
|
+
writeFileAtomic(path, `${JSON.stringify(clean, null, 2)}\n`, 0o600);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* A hook saw `repository_excluded` for this slug: remember it, so this machine stops reading that repo's transcripts until
|
|
304
|
+
* the next sync says otherwise. **NEVER THROWS** — it runs on the hook's path, where a failed write only means the server
|
|
305
|
+
* refuses the next upload too. `local:` slugs are never recorded (there is no repo identity to refuse).
|
|
306
|
+
*/
|
|
307
|
+
export function recordRefused(home, slug) {
|
|
308
|
+
const name = normalizeSlug(slug);
|
|
309
|
+
if (name === "" || isLocalSlug(name))
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
const current = readAccountState(home);
|
|
313
|
+
if (current.refused.includes(name))
|
|
314
|
+
return;
|
|
315
|
+
writeAccountState(home, { ...current, refused: [...current.refused, name] });
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// The hook goes on: nothing was written, and the next refusal tries again.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A hook saw `X-Grant-Epoch` on a server answer (an ingest 2xx, or a grants refusal): if it is not the epoch this machine last
|
|
323
|
+
* recorded, mark the account state `stale`, which is what SessionStart's one bounded `GET` looks for (`session_grants.ts`).
|
|
324
|
+
* A machine with no recorded account state counts as epoch 0, as the server does when a user has no grants row.
|
|
325
|
+
*
|
|
326
|
+
* Writes `grants-account.json` and never `grants.json` (see `AccountState`), only when something changed — so a Stop hook that sees
|
|
327
|
+
* the same epoch every turn does no file write at all — and only ever sets `stale`; the GET (or a sync) clears it. **NEVER THROWS**:
|
|
328
|
+
* it runs on the hook's path, and a lost mark only means the next answer marks it again.
|
|
329
|
+
*/
|
|
330
|
+
export function noteGrantEpoch(home, epoch) {
|
|
331
|
+
if (epoch === null || epoch === undefined || !Number.isSafeInteger(epoch) || epoch < 0)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
const current = readAccountState(home);
|
|
335
|
+
if (current.stale)
|
|
336
|
+
return;
|
|
337
|
+
if (epoch === (current.account?.epoch ?? 0))
|
|
338
|
+
return;
|
|
339
|
+
writeAccountState(home, { ...current, stale: true });
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// The hook goes on: nothing was written, and the next answer marks it again.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* True when the last synced account state (the last 200) already grants this repo, or its owner. ⚠ Read only to avoid a NEEDLESS
|
|
347
|
+
* hold — say an owner grant was queued and refused with a 400 while the server already admits this repo — and never to admit: the
|
|
348
|
+
* account's grants do not admit anything on this machine.
|
|
349
|
+
*/
|
|
350
|
+
function accountGrants(state, name) {
|
|
351
|
+
const a = state.account;
|
|
352
|
+
return a !== null && (a.repos.includes(name) || a.owners.includes(ownerOfSlug(name)));
|
|
353
|
+
}
|
|
354
|
+
/** True when a queued `grant` op names this slug, or its owner. */
|
|
355
|
+
function grantPendingFor(pending, name) {
|
|
356
|
+
const owner = ownerOfSlug(name);
|
|
357
|
+
return pending.some((p) => p.op === "grant" && (p.kind === "repo" ? p.value === name : p.value === owner));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Does the hold of `isCaptureAllowed` (step 3) apply to ANYTHING on this machine right now? True when the machine has synced
|
|
361
|
+
* (`synced_at` is set) and a queued `grant` op names an owner or a repo the last synced account state does not already grant
|
|
362
|
+
* (the same two conditions the per-slug hold uses: a synced machine, and the account not already admitting it). "0.8.0 truth
|
|
363
|
+
* fixes" R2-2: `connect`'s untold line says sessions wait only when something actually waits. Reads `grants.json` and
|
|
364
|
+
* `grants-account.json`; an unreadable `grants.json` holds nothing here (the gate denies for its own reason).
|
|
365
|
+
*/
|
|
366
|
+
export function holdsAny(home) {
|
|
367
|
+
const read = readGrants(home);
|
|
368
|
+
if (read.kind !== "ok" || read.grants.synced_at === null)
|
|
369
|
+
return false;
|
|
370
|
+
const state = readAccountState(home);
|
|
371
|
+
// `accountGrants` is the gate's own predicate: an owner op is asked as `owner/` (its owner is `owner`, and no account repo is spelled that way).
|
|
372
|
+
return read.grants.pending.some((p) => p.op === "grant" && !accountGrants(state, p.kind === "repo" ? p.value : `${p.value}/`));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* ⛔ **THE ONE ADMISSION DECISION.** Every gate calls this; `entry.ts`, `post_commit.ts`, `post_rewrite.ts` (twice),
|
|
376
|
+
* `finalize.ts`, `status.ts` and `connect.ts` are the seven call sites. Absence is a NO and every error path denies.
|
|
377
|
+
*
|
|
378
|
+
* Precedence:
|
|
379
|
+
* 1. A MISSING `grants.json` is empty (0.6.x machines keep capturing on their common-dir consent). A PRESENT file
|
|
380
|
+
* that cannot be read denies every clone that has a slug, whatever `projects.json` says.
|
|
381
|
+
* 2. A denied slug is denied, whatever the basis: `grants.json`'s `excluded` (and its legacy `refused`), the account file's
|
|
382
|
+
* `refused`, or the account's own `excluded`.
|
|
383
|
+
* 3. On a machine that HAS SYNCED (`grants.synced_at` is set), a slug with a `grant` still PENDING (queued, not yet delivered to
|
|
384
|
+
* the account) is held back when that grant is the slug's ONLY local basis, i.e. the clone has no common-dir consent: its
|
|
385
|
+
* spans stay unsent with their offset unchanged, and go once a sync delivers the grant. It is not held either when the last
|
|
386
|
+
* synced account state already grants the repo or its owner (the server admits it, so a queued grant that was refused must
|
|
387
|
+
* not hold it for ever).
|
|
388
|
+
* ⭐ **A MACHINE THAT HAS NEVER SYNCED HOLDS NOTHING** (VQ, "0.8.0 truth fixes" F1): the server refuses only a credential that
|
|
389
|
+
* has synced (`grants_synced_at`), so before the first sync the hold protects nothing and would only defer uploads, for ever
|
|
390
|
+
* against a server without the routes. Such a machine decides capture by its own choices, as under 0.7.0.
|
|
391
|
+
* ⭐ **WHY A COMMON-DIR CONSENT LIFTS THE HOLD** (VQ's ruling): a remote repo gets a common-dir consent only from pre-0.8.0
|
|
392
|
+
* flows (0.8.0's in-repo `connect` pre-ticks the picker, which writes GRANTS). The first sync uploads those consented slugs, so
|
|
393
|
+
* either this credential has not synced, and the server never refuses anything for it, or the first sync already granted the
|
|
394
|
+
* slug, and holding it would only delay bytes the server accepts.
|
|
395
|
+
* 4. Otherwise: admitted on the common-dir consent, or on a slug matched by `owners` or `repos`. ⛔ The account's own
|
|
396
|
+
* `owners`/`repos` are NOT consulted — a repo only the account grants is not captured on this machine (per-machine naming).
|
|
397
|
+
*
|
|
398
|
+
* `local:` slugs never match a grant and keep common-dir consent alone. They are also not subject to
|
|
399
|
+
* exclusion or to an unreadable file: there is no repo identity to exclude.
|
|
400
|
+
*
|
|
401
|
+
* `grants-account.json` is read on every call and an unreadable one is the empty state, never a denial (`AccountState`).
|
|
402
|
+
*
|
|
403
|
+
* ⚠ **`slug` MAY BE A THUNK, and the hooks pass one.** Computing a slug can spawn `git remote get-url` and `ssh -G`,
|
|
404
|
+
* which is real time on a hook's budget; a machine with no `grants.json` (every 0.6.x machine) needs none of it, and
|
|
405
|
+
* neither does a clone with no consent and no grants to match. The slug is asked for only when the answer depends on it.
|
|
406
|
+
* Pass a memoized thunk (`git.ts`'s `onceRepoSlug`) so the caller's own later use shares the one spawn.
|
|
407
|
+
*
|
|
408
|
+
* `keys` is the `resolveProjectKeys` result the caller already holds, so the confinement roots it derives afterwards
|
|
409
|
+
* come from the very resolution this gate used (`D184 §3`).
|
|
410
|
+
*/
|
|
411
|
+
export function isCaptureAllowed(home, keys, slug) {
|
|
412
|
+
if (keys === null)
|
|
413
|
+
return false;
|
|
414
|
+
const byClone = isProjectAllowed(home, keys.consent);
|
|
415
|
+
const read = readGrants(home);
|
|
416
|
+
const resolve = () => normalizeSlug(typeof slug === "function" ? slug() : slug);
|
|
417
|
+
if (read.kind === "unreadable")
|
|
418
|
+
return isLocalSlug(resolve()) ? byClone : false;
|
|
419
|
+
const grants = read.kind === "ok" ? read.grants : EMPTY_GRANTS;
|
|
420
|
+
const account = readAccountState(home);
|
|
421
|
+
const denyLists = [grants.excluded, grants.refused, account.refused, account.account?.excluded ?? []];
|
|
422
|
+
const mayDeny = denyLists.some((list) => list.length > 0);
|
|
423
|
+
const mayHold = !byClone && grants.synced_at !== null && grants.pending.some((p) => p.op === "grant");
|
|
424
|
+
if (!mayDeny && !mayHold && (byClone || (grants.owners.length === 0 && grants.repos.length === 0)))
|
|
425
|
+
return byClone;
|
|
426
|
+
const name = resolve();
|
|
427
|
+
if (isLocalSlug(name))
|
|
428
|
+
return byClone;
|
|
429
|
+
if (denyLists.some((list) => list.includes(name)))
|
|
430
|
+
return false;
|
|
431
|
+
if (!byClone && grants.synced_at !== null && grantPendingFor(grants.pending, name) && !accountGrants(account, name))
|
|
432
|
+
return false;
|
|
433
|
+
return byClone || grants.repos.includes(name) || grants.owners.includes(ownerOfSlug(name));
|
|
434
|
+
}
|
|
435
|
+
/** `updateGrants` refuses to rewrite a file it cannot read: overwriting would silently drop exclusions it never saw. */
|
|
436
|
+
export class GrantsUnreadableError extends Error {
|
|
437
|
+
constructor() {
|
|
438
|
+
super("grants.json is present and cannot be read");
|
|
439
|
+
this.name = "GrantsUnreadableError";
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Read-modify-write `grants.json`. 0700 on the directory, 0600 on the file, atomic (a hook reading while a verb saves
|
|
444
|
+
* sees the old file or the new, never half). Entries are normalized and de-duplicated on the way out.
|
|
445
|
+
*
|
|
446
|
+
* ⛔ **Throws `GrantsUnreadableError` rather than start from empty** when the file is present and unusable: a rewrite
|
|
447
|
+
* would turn "denies everything" into "admits on common-dir consent" without the user having decided anything.
|
|
448
|
+
*
|
|
449
|
+
* ⛔ **Keeps the top-level keys it does not know**, verbatim and uninterpreted, after the five it rebuilds (which always
|
|
450
|
+
* win). A newer version's file read by this one is written back whole: dropping a key it added would silently undo its
|
|
451
|
+
* state. No lock: same read-modify-write as `projects.json`, so two simultaneous verbs can lose one write.
|
|
452
|
+
*/
|
|
453
|
+
export function updateGrants(home, change) {
|
|
454
|
+
const { read, extras } = readGrantsWithExtras(home);
|
|
455
|
+
if (read.kind === "unreadable")
|
|
456
|
+
throw new GrantsUnreadableError();
|
|
457
|
+
const next = change(read.kind === "ok" ? read.grants : EMPTY_GRANTS);
|
|
458
|
+
const clean = {
|
|
459
|
+
disclosed_at: next.disclosed_at,
|
|
460
|
+
owners: [...new Set(next.owners.map(normalizeOwner).filter((o) => o !== ""))],
|
|
461
|
+
repos: [...new Set(next.repos.map(normalizeSlug).filter((r) => r !== ""))],
|
|
462
|
+
excluded: [...new Set(next.excluded.map(normalizeSlug).filter((x) => x !== ""))],
|
|
463
|
+
refused: [...new Set(next.refused.map(normalizeSlug).filter((x) => x !== ""))],
|
|
464
|
+
pending: next.pending.flatMap((p) => {
|
|
465
|
+
const value = p.kind === "owner" ? normalizeOwner(p.value) : normalizeSlug(p.value);
|
|
466
|
+
return value === "" ? [] : [{ ...p, value }];
|
|
467
|
+
}),
|
|
468
|
+
synced_at: next.synced_at,
|
|
469
|
+
};
|
|
470
|
+
const path = grantsPath(home);
|
|
471
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
472
|
+
chmodSync(rootDir(home), 0o700);
|
|
473
|
+
// `Object.fromEntries` defines own properties, so even a key named `__proto__` is written back as data.
|
|
474
|
+
const out = Object.fromEntries([...Object.entries(clean), ...extras]);
|
|
475
|
+
writeFileAtomic(path, `${JSON.stringify(out, null, 2)}\n`, 0o600);
|
|
476
|
+
return clean;
|
|
477
|
+
}
|
|
478
|
+
/** `vibecommit off`: the slug joins `excluded`. Returns true when that changed the file. */
|
|
479
|
+
export function excludeSlug(home, slug) {
|
|
480
|
+
const name = normalizeSlug(slug);
|
|
481
|
+
let changed = false;
|
|
482
|
+
updateGrants(home, (g) => {
|
|
483
|
+
changed = !g.excluded.includes(name);
|
|
484
|
+
return changed ? { ...g, excluded: [...g.excluded, name] } : g;
|
|
485
|
+
});
|
|
486
|
+
return changed;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* A yes at the disclosure re-includes a turned-off repo; nothing else does. `"none"` when the slug was not excluded
|
|
490
|
+
* (no file is written then), `"unreadable"` when `grants.json` cannot be read.
|
|
491
|
+
*/
|
|
492
|
+
export function clearExclusion(home, slug) {
|
|
493
|
+
const name = normalizeSlug(slug);
|
|
494
|
+
const read = readGrants(home);
|
|
495
|
+
if (read.kind === "unreadable")
|
|
496
|
+
return "unreadable";
|
|
497
|
+
if (read.kind === "missing" || !read.grants.excluded.includes(name))
|
|
498
|
+
return "none";
|
|
499
|
+
updateGrants(home, (g) => ({ ...g, excluded: g.excluded.filter((x) => x !== name) }));
|
|
500
|
+
return "cleared";
|
|
501
|
+
}
|
|
103
502
|
/**
|
|
104
503
|
* Interpret an answer to the consent prompt. **Only an explicit yes is a yes.**
|
|
105
504
|
* Empty (the user pressed return) is NO, which is what makes `[y/N]` honest.
|