@webpieces/rules-config 0.4.718 → 0.4.720

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.
@@ -1,376 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HumanAuthorizationService = exports.AuthorizedOverrides = exports.AuthorizationCheck = exports.AuthorizationContext = exports.AuthorizationFile = exports.HumanApproval = exports.DEFAULT_APPROVAL_HOURS = exports.AUTHORIZATIONS_DIR = void 0;
4
- const tslib_1 = require("tslib");
5
- const crypto_1 = require("crypto");
6
- const fs = tslib_1.__importStar(require("fs"));
7
- const path = tslib_1.__importStar(require("path"));
8
- const inversify_1 = require("inversify");
9
- const state_dir_1 = require("./state-dir");
10
- const exclude_paths_1 = require("./exclude-paths");
11
- const to_error_1 = require("./to-error");
12
- // The directory under `.webpieces/` that holds one authorization file per branch. Gitignored with the
13
- // rest of `.webpieces/` — deliberately, see HumanAuthorizationService's docstring.
14
- exports.AUTHORIZATIONS_DIR = 'authorizations';
15
- // How long a fresh approval is good for, in hours, when the human does not say otherwise. An approval is
16
- // for TODAY'S work, not a standing grant: the thing being authorized is a specific partial delivery, and
17
- // a grant that outlives the sitting it was given in is one nobody remembers giving.
18
- exports.DEFAULT_APPROVAL_HOURS = 4;
19
- // The marker version in the signed payload. An approval signed under a different version does not verify,
20
- // so the payload can gain a field without a stale entry silently continuing to pass.
21
- const PAYLOAD_VERSION = 'wp-authorize-v1';
22
- /**
23
- * ONE human approval of ONE checklist's override, on ONE branch. Data-only (per CLAUDE.md).
24
- *
25
- * `approves` is prose the human typed at the tty in their own words, and it is the POINT of the record:
26
- * `wp-check-auth` prints it, so a reviewer can judge whether the approval actually covers the thing it is
27
- * being applied to — not merely that *an* approval exists on this branch.
28
- *
29
- * `hmac` is `HMAC-SHA256(prGate.gateSalt, canonical payload)` over every other field plus the branch. It
30
- * is what makes the approval VERIFIABLE by an agent that cannot MINT one: the agent runs `wp-check-auth`,
31
- * which recomputes the HMAC, rather than believing a claim relayed to it in a message.
32
- */
33
- class HumanApproval {
34
- checklist; // the checklist id this authorizes an override of (= the reviewer subagent name)
35
- gate; // the specific gate inside that checklist, or '' for the checklist as a whole
36
- approves; // the human's OWN words: what they are approving and why
37
- scopePaths; // globs the diff touched when approved; the approval dies if it grows past them
38
- forkPoint; // merge-base when approved; survives new commits, dies on a re-based branch
39
- issuedAt; // ISO
40
- expiresAt; // ISO
41
- hmac; // '' until signed
42
- // eslint-disable-next-line @typescript-eslint/max-params
43
- constructor(checklist, gate, approves, scopePaths, forkPoint, issuedAt, expiresAt, hmac = '') {
44
- this.checklist = checklist;
45
- this.gate = gate;
46
- this.approves = approves;
47
- this.scopePaths = scopePaths;
48
- this.forkPoint = forkPoint;
49
- this.issuedAt = issuedAt;
50
- this.expiresAt = expiresAt;
51
- this.hmac = hmac;
52
- }
53
- }
54
- exports.HumanApproval = HumanApproval;
55
- /**
56
- * The append-only authorization file for ONE branch: N approvals, each naming what it approves. Data-only.
57
- *
58
- * The unit is the BRANCH, not the gate, because one run of work routinely needs more than one override and
59
- * they arrive at different times. One file per gate would mean the human re-answering the same "where does
60
- * this go?" question on every approval.
61
- */
62
- class AuthorizationFile {
63
- branch;
64
- approvals;
65
- /**
66
- * '' when the file was absent or read cleanly. Non-empty = it EXISTS but could not be parsed, and the
67
- * reason.
68
- *
69
- * Carried rather than swallowed because "no approvals" and "approvals I could not read" are different
70
- * facts and the second one is actionable: a corrupt file means the human's approval is sitting on disk
71
- * unreadable, and the fix is to re-authorize rather than to wonder why nothing was found. `verifiedFor`
72
- * turns this into a `rejected` line, which `wp-check-auth` already prints.
73
- */
74
- unreadable;
75
- constructor(branch = '', approvals = [], unreadable = '') {
76
- this.branch = branch;
77
- this.approvals = approvals;
78
- this.unreadable = unreadable;
79
- }
80
- }
81
- exports.AuthorizationFile = AuthorizationFile;
82
- /**
83
- * The git facts an approval is verified AGAINST, gathered once by the caller. Data-only.
84
- *
85
- * A data class rather than four parameters because the four must describe ONE branch state: a `forkPoint`
86
- * resolved from one tree checked against a `changedFiles` gathered from another is a verification that
87
- * means nothing, and separate parameters are how that happens.
88
- */
89
- class AuthorizationContext {
90
- branch;
91
- forkPoint;
92
- changedFiles;
93
- now;
94
- constructor(branch, forkPoint, changedFiles = [], now = new Date()) {
95
- this.branch = branch;
96
- this.forkPoint = forkPoint;
97
- this.changedFiles = changedFiles;
98
- this.now = now;
99
- }
100
- }
101
- exports.AuthorizationContext = AuthorizationContext;
102
- /**
103
- * The verdict on ONE approval. `ok` false ⇒ `reason` says WHICH of the four bindings failed, in the
104
- * human's terms, because that is the sentence the agent has to relay back to them. Data-only.
105
- */
106
- class AuthorizationCheck {
107
- ok;
108
- reason; // '' when ok
109
- approval;
110
- constructor(ok, reason, approval) {
111
- this.ok = ok;
112
- this.reason = reason;
113
- this.approval = approval;
114
- }
115
- }
116
- exports.AuthorizationCheck = AuthorizationCheck;
117
- /**
118
- * What a branch is actually authorized to override RIGHT NOW: checklist id → the human's `approves` prose.
119
- * Data-only, with accessors so every consumer asks the question one way.
120
- *
121
- * `rejected` carries the approvals that were found but did NOT verify, one rendered line each. They are
122
- * kept rather than dropped because "there is an approval on this branch, and here is why it no longer
123
- * counts" is a completely different message from "nobody has authorized anything" — the first needs a
124
- * re-authorization, the second needs a human at all — and an empty map cannot tell them apart.
125
- */
126
- class AuthorizedOverrides {
127
- proseById;
128
- rejected;
129
- constructor(proseById = new Map(), rejected = []) {
130
- this.proseById = proseById;
131
- this.rejected = rejected;
132
- }
133
- has(checklistId) {
134
- return this.proseById.has(checklistId);
135
- }
136
- /** The human's own words for `checklistId`, or '' when it is not authorized. */
137
- proseFor(checklistId) {
138
- return this.proseById.get(checklistId) ?? '';
139
- }
140
- }
141
- exports.AuthorizedOverrides = AuthorizedOverrides;
142
- /**
143
- * Mints (for a HUMAN at a tty) and verifies (for ANYONE, agents included) the human-authorization records
144
- * that are the ONLY channel by which a review checklist's override may be granted.
145
- *
146
- * ─── The problem ───────────────────────────────────────────────────────────────────────────────────────
147
- * A required checklist goes red, the human authorizes the partial scope, and there is NO channel by which
148
- * the subagent doing the work can know that. Every channel available before this carried a CLAIM of
149
- * authorization and never EVIDENCE of it: a coordinator relaying the human's words is unverifiable by
150
- * construction (and correctly refused — that refusal is the shape a prompt injection exploits); a ticket
151
- * comment can be written by an agent holding the same MCP; and the `override` field in review-<id>.json is
152
- * the agent authorizing itself.
153
- *
154
- * So the property this class buys is exactly one sentence: **an agent can VERIFY an authorization it
155
- * cannot MINT.** `wp-authorize` reads the approval from `/dev/tty`, which an agent's Bash tool has no way
156
- * to answer; `wp-check-auth` recomputes the HMAC and is read-only, so agents run it freely.
157
- *
158
- * ─── Bound to SCOPE, never to a diff sha ───────────────────────────────────────────────────────────────
159
- * An approval bound to the head-commit diff would be void on the next commit, so the human would
160
- * re-authorize on every push and nobody would use it. Each approval binds to WHAT WAS APPROVED instead:
161
- * • `scopePaths` — the globs the diff touched when approved. A "terraform only" approval is void the
162
- * moment app files appear, which is the abuse actually worth stopping.
163
- * • `forkPoint` — the merge-base when approved. Survives new commits; dies if the branch is restarted.
164
- * • `expiresAt` — hours, not days.
165
- * Edits inside the approved scope keep working; widening it does not. That is what the human means when
166
- * they say "yes, ship the terraform half".
167
- *
168
- * ─── Where the file lives, and why it is SHARED rather than per-worktree ───────────────────────────────
169
- * `dotWebpieces.shared()/authorizations/<branch-slug>.json`, keyed by BRANCH. Not `local()`: the human
170
- * routinely types `wp-authorize` in the primary clone while the agent works in a linked worktree, and
171
- * under `local()` those are two different files — the approval would be minted somewhere the agent never
172
- * looks, which is the same stall this feature exists to end. The branch key is what makes sharing safe:
173
- * an approval names its branch inside the SIGNED payload, so a file copied to another branch does not
174
- * verify there.
175
- *
176
- * It is never committed (`.webpieces/` is gitignored in full). A committed authorization would travel to
177
- * branches nobody approved.
178
- *
179
- * ─── Honest limits — do not oversell this ─────────────────────────────────────────────────────────────
180
- * The agent runs as the SAME OS USER as the human, and the HMAC key is `prGate.gateSalt`, which lives in a
181
- * committed file agents read routinely. Nothing here is cryptographically airtight against a determined
182
- * model; the real enforcement is the tty affordance plus the harness deny rule. That is fine, because the
183
- * problem being solved is agents drifting, guessing, or being confused by relays — not an adversarial
184
- * model. Moving the key to `~/.webpieces/authorize.key` is a one-line change to `sign()` if the threat
185
- * model ever changes; nothing else in the design moves.
186
- *
187
- * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.
188
- */
189
- let HumanAuthorizationService = class HumanAuthorizationService {
190
- dotDir;
191
- constructor(dotDir = state_dir_1.dotWebpieces) {
192
- this.dotDir = dotDir;
193
- }
194
- /** The repo-wide authorizations directory. See the class docstring for why `shared()` and not `local()`. */
195
- dirFor(repoRoot) {
196
- return this.dotDir.sharedFile(repoRoot, exports.AUTHORIZATIONS_DIR);
197
- }
198
- /**
199
- * A filesystem-safe leaf for a branch name. Slashes and anything exotic collapse to `-`, so
200
- * `dean/one-2779-grants` becomes `dean-one-2779-grants`.
201
- *
202
- * A collision between two slugs is harmless: the branch is inside the SIGNED payload, so an approval
203
- * that landed in a colliding file fails `verify` on the branch check rather than leaking across.
204
- */
205
- slugFor(branch) {
206
- return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
207
- }
208
- pathFor(repoRoot, branch) {
209
- return path.join(this.dirFor(repoRoot), `${this.slugFor(branch)}.json`);
210
- }
211
- /**
212
- * The exact bytes that get signed: a version tag, the branch, and every field of the approval EXCEPT
213
- * the hmac, in a fixed order with `scopePaths` sorted.
214
- *
215
- * Fixed order and sorting are load-bearing — `JSON.stringify` over a re-parsed object preserves
216
- * insertion order, so a round-tripped approval whose keys arrived in a different order would compute a
217
- * different payload and fail to verify a signature that is perfectly good.
218
- */
219
- canonicalPayload(branch, approval) {
220
- return [
221
- PAYLOAD_VERSION,
222
- branch,
223
- approval.checklist,
224
- approval.gate,
225
- approval.approves,
226
- [...approval.scopePaths].sort().join(','),
227
- approval.forkPoint,
228
- approval.issuedAt,
229
- approval.expiresAt,
230
- ].join('\n');
231
- }
232
- /** `HMAC-SHA256(salt, canonical payload)`. '' for an empty salt — the caller treats '' as "not configured". */
233
- sign(branch, approval, salt) {
234
- if (salt.trim() === '')
235
- return '';
236
- return (0, crypto_1.createHmac)('sha256', salt).update(this.canonicalPayload(branch, approval)).digest('hex');
237
- }
238
- /** The same approval, carrying its signature. Never mutates the input. */
239
- signed(branch, approval, salt) {
240
- return new HumanApproval(approval.checklist, approval.gate, approval.approves, approval.scopePaths, approval.forkPoint, approval.issuedAt, approval.expiresAt, this.sign(branch, approval, salt));
241
- }
242
- /**
243
- * Every approval recorded for `branch`, signature UNCHECKED. An absent or unreadable file is an EMPTY
244
- * file, never a throw: a corrupt authorization must degrade to "nothing is authorized", which is the
245
- * safe direction, and a branch that cannot open its own authorization file must still be able to run
246
- * the gate and be told to go get one.
247
- */
248
- load(repoRoot, branch) {
249
- const p = this.pathFor(repoRoot, branch);
250
- if (!fs.existsSync(p))
251
- return new AuthorizationFile(branch);
252
- // webpieces-disable no-unmanaged-exceptions -- chokepoint: unreadable authorizations mean "none", never a crash
253
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
254
- try {
255
- // webpieces-disable no-any-unknown -- parsed JSON is opaque until each field is narrowed below
256
- const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
257
- const list = Array.isArray(raw['approvals']) ? raw['approvals'] : [];
258
- return new AuthorizationFile(branch, list.map((e) => this.toApproval(e)));
259
- }
260
- catch (err) {
261
- const error = (0, to_error_1.toError)(err);
262
- // Fail CLOSED on the grant, but say so: nothing is authorized either way, and reporting WHY is
263
- // the difference between "ask the human to authorize" and "ask the human to authorize AGAIN
264
- // because their last one is unreadable on disk".
265
- return new AuthorizationFile(branch, [], `${p} exists but could not be read (${error.message})`);
266
- }
267
- }
268
- // One parsed entry. Every field is read defensively — a hand-edited file is exactly the input this must
269
- // survive, and a missing field simply yields '' / [], which then fails the signature check.
270
- // webpieces-disable no-any-unknown -- opaque parsed JSON entry; each field is narrowed here
271
- toApproval(entry) {
272
- const raw = (typeof entry === 'object' && entry !== null ? entry : {});
273
- return new HumanApproval(this.str(raw['checklist']), this.str(raw['gate']), this.str(raw['approves']), this.strList(raw['scopePaths']), this.str(raw['forkPoint']), this.str(raw['issuedAt']), this.str(raw['expiresAt']), this.str(raw['hmac']));
274
- }
275
- // webpieces-disable no-any-unknown -- reading ONE opaque field out of hand-editable JSON; narrowed right here
276
- str(value) {
277
- return typeof value === 'string' ? value : '';
278
- }
279
- // webpieces-disable no-any-unknown -- reading ONE opaque field out of hand-editable JSON; narrowed right here
280
- strList(value) {
281
- if (!Array.isArray(value))
282
- return [];
283
- // webpieces-disable no-any-unknown -- element of an opaque JSON array, narrowed by the type guard
284
- return value.filter((v) => typeof v === 'string');
285
- }
286
- /**
287
- * APPEND one signed approval to the branch's file and return the path written. Append, never replace:
288
- * a run of work needs several overrides at different times, and each one is its own record of intent.
289
- */
290
- append(repoRoot, branch, approval, salt) {
291
- const file = this.load(repoRoot, branch);
292
- file.approvals.push(this.signed(branch, approval, salt));
293
- const p = this.pathFor(repoRoot, branch);
294
- fs.mkdirSync(path.dirname(p), { recursive: true });
295
- fs.writeFileSync(p, JSON.stringify(new AuthorizationFile(branch, file.approvals), null, 2) + '\n');
296
- return p;
297
- }
298
- /**
299
- * Verify ONE approval against the branch state, in the order a reader wants to hear it: signature,
300
- * expiry, fork point, scope. Signature first — an unsigned entry's other fields are not evidence of
301
- * anything, and reporting "expired" for a forged record would be answering the wrong question.
302
- */
303
- verify(ctx, approval, salt) {
304
- const expected = this.sign(ctx.branch, approval, salt);
305
- if (expected === '' || approval.hmac !== expected) {
306
- return this.fail(approval, 'its signature does not verify — it was hand-written, edited after '
307
- + 'signing, minted on a different branch, or the repo\'s prGate.gateSalt changed');
308
- }
309
- if (this.expired(approval, ctx.now)) {
310
- return this.fail(approval, `it EXPIRED at ${approval.expiresAt} (an approval is for the sitting it was given in)`);
311
- }
312
- if (approval.forkPoint !== '' && ctx.forkPoint !== '' && approval.forkPoint !== ctx.forkPoint) {
313
- return this.fail(approval, `the branch was restarted from a different base — approved at fork point `
314
- + `${approval.forkPoint.slice(0, 12)}, now ${ctx.forkPoint.slice(0, 12)}`);
315
- }
316
- const outside = this.outsideScope(approval, ctx.changedFiles);
317
- if (outside.length > 0) {
318
- return this.fail(approval, `the diff now touches ${outside.length} file(s) OUTSIDE the approved scope `
319
- + `(${approval.scopePaths.join(', ')}): ${outside.slice(0, 5).join(', ')}`);
320
- }
321
- return new AuthorizationCheck(true, '', approval);
322
- }
323
- fail(approval, reason) {
324
- return new AuthorizationCheck(false, reason, approval);
325
- }
326
- // An approval with no parseable `expiresAt` is treated as EXPIRED. Failing closed is the only safe
327
- // reading: a missing expiry on a record whose whole purpose is to be time-bounded is a broken record,
328
- // and the alternative reading is a grant that never ends.
329
- expired(approval, now) {
330
- const at = Date.parse(approval.expiresAt);
331
- if (Number.isNaN(at))
332
- return true;
333
- return at <= now.getTime();
334
- }
335
- /**
336
- * The changed files NOT covered by `scopePaths`. An approval with an EMPTY `scopePaths` covers nothing
337
- * and is therefore always out of scope when anything changed — `wp-authorize` never mints one, and the
338
- * alternative reading ("empty means everything") is precisely the widening-by-absence that makes the
339
- * permissive path the shortest thing to type.
340
- */
341
- outsideScope(approval, changedFiles) {
342
- return changedFiles.filter((f) => !(0, exclude_paths_1.matchesAnyGlob)(f, approval.scopePaths));
343
- }
344
- /**
345
- * THE question every consumer asks: what is this branch authorized to override right now? Verifies every
346
- * recorded approval and returns the ones that hold, plus a rendered line per one that does not.
347
- *
348
- * Later approvals win for the same checklist — the human appended it because they meant to say something
349
- * newer, and the file is append-only precisely so the earlier one stays readable as history.
350
- */
351
- verifiedFor(repoRoot, ctx, salt) {
352
- const prose = new Map();
353
- const rejected = [];
354
- const file = this.load(repoRoot, ctx.branch);
355
- if (file.unreadable !== '')
356
- rejected.push(`(unreadable, treated as NO approvals) — ${file.unreadable}`);
357
- for (const approval of file.approvals) {
358
- const check = this.verify(ctx, approval, salt);
359
- if (check.ok)
360
- prose.set(approval.checklist, approval.approves);
361
- else
362
- rejected.push(`"${approval.checklist}" (issued ${approval.issuedAt}) — ${check.reason}`);
363
- }
364
- return new AuthorizedOverrides(prose, rejected);
365
- }
366
- /** An expiry `hours` from `issuedAt`, as ISO — the one place the TTL arithmetic lives. */
367
- expiryFrom(issuedAt, hours) {
368
- return new Date(issuedAt.getTime() + hours * 3600 * 1000).toISOString();
369
- }
370
- };
371
- exports.HumanAuthorizationService = HumanAuthorizationService;
372
- exports.HumanAuthorizationService = HumanAuthorizationService = tslib_1.__decorate([
373
- (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
374
- tslib_1.__metadata("design:paramtypes", [state_dir_1.DotWebpieces])
375
- ], HumanAuthorizationService);
376
- //# sourceMappingURL=human-authorization.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"human-authorization.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/human-authorization.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHmac } from 'crypto';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { DotWebpieces, dotWebpieces } from './state-dir';\nimport { matchesAnyGlob } from './exclude-paths';\nimport { toError } from './to-error';\n\n// The directory under `.webpieces/` that holds one authorization file per branch. Gitignored with the\n// rest of `.webpieces/` — deliberately, see HumanAuthorizationService's docstring.\nexport const AUTHORIZATIONS_DIR = 'authorizations';\n\n// How long a fresh approval is good for, in hours, when the human does not say otherwise. An approval is\n// for TODAY'S work, not a standing grant: the thing being authorized is a specific partial delivery, and\n// a grant that outlives the sitting it was given in is one nobody remembers giving.\nexport const DEFAULT_APPROVAL_HOURS = 4;\n\n// The marker version in the signed payload. An approval signed under a different version does not verify,\n// so the payload can gain a field without a stale entry silently continuing to pass.\nconst PAYLOAD_VERSION = 'wp-authorize-v1';\n\n/**\n * ONE human approval of ONE checklist's override, on ONE branch. Data-only (per CLAUDE.md).\n *\n * `approves` is prose the human typed at the tty in their own words, and it is the POINT of the record:\n * `wp-check-auth` prints it, so a reviewer can judge whether the approval actually covers the thing it is\n * being applied to — not merely that *an* approval exists on this branch.\n *\n * `hmac` is `HMAC-SHA256(prGate.gateSalt, canonical payload)` over every other field plus the branch. It\n * is what makes the approval VERIFIABLE by an agent that cannot MINT one: the agent runs `wp-check-auth`,\n * which recomputes the HMAC, rather than believing a claim relayed to it in a message.\n */\nexport class HumanApproval {\n checklist: string; // the checklist id this authorizes an override of (= the reviewer subagent name)\n gate: string; // the specific gate inside that checklist, or '' for the checklist as a whole\n approves: string; // the human's OWN words: what they are approving and why\n scopePaths: string[]; // globs the diff touched when approved; the approval dies if it grows past them\n forkPoint: string; // merge-base when approved; survives new commits, dies on a re-based branch\n issuedAt: string; // ISO\n expiresAt: string; // ISO\n hmac: string; // '' until signed\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n checklist: string,\n gate: string,\n approves: string,\n scopePaths: string[],\n forkPoint: string,\n issuedAt: string,\n expiresAt: string,\n hmac = '',\n ) {\n this.checklist = checklist;\n this.gate = gate;\n this.approves = approves;\n this.scopePaths = scopePaths;\n this.forkPoint = forkPoint;\n this.issuedAt = issuedAt;\n this.expiresAt = expiresAt;\n this.hmac = hmac;\n }\n}\n\n/**\n * The append-only authorization file for ONE branch: N approvals, each naming what it approves. Data-only.\n *\n * The unit is the BRANCH, not the gate, because one run of work routinely needs more than one override and\n * they arrive at different times. One file per gate would mean the human re-answering the same \"where does\n * this go?\" question on every approval.\n */\nexport class AuthorizationFile {\n branch: string;\n approvals: HumanApproval[];\n /**\n * '' when the file was absent or read cleanly. Non-empty = it EXISTS but could not be parsed, and the\n * reason.\n *\n * Carried rather than swallowed because \"no approvals\" and \"approvals I could not read\" are different\n * facts and the second one is actionable: a corrupt file means the human's approval is sitting on disk\n * unreadable, and the fix is to re-authorize rather than to wonder why nothing was found. `verifiedFor`\n * turns this into a `rejected` line, which `wp-check-auth` already prints.\n */\n unreadable: string;\n\n constructor(branch = '', approvals: HumanApproval[] = [], unreadable = '') {\n this.branch = branch;\n this.approvals = approvals;\n this.unreadable = unreadable;\n }\n}\n\n/**\n * The git facts an approval is verified AGAINST, gathered once by the caller. Data-only.\n *\n * A data class rather than four parameters because the four must describe ONE branch state: a `forkPoint`\n * resolved from one tree checked against a `changedFiles` gathered from another is a verification that\n * means nothing, and separate parameters are how that happens.\n */\nexport class AuthorizationContext {\n branch: string;\n forkPoint: string;\n changedFiles: string[];\n now: Date;\n\n constructor(branch: string, forkPoint: string, changedFiles: string[] = [], now: Date = new Date()) {\n this.branch = branch;\n this.forkPoint = forkPoint;\n this.changedFiles = changedFiles;\n this.now = now;\n }\n}\n\n/**\n * The verdict on ONE approval. `ok` false ⇒ `reason` says WHICH of the four bindings failed, in the\n * human's terms, because that is the sentence the agent has to relay back to them. Data-only.\n */\nexport class AuthorizationCheck {\n ok: boolean;\n reason: string; // '' when ok\n approval: HumanApproval | null;\n\n constructor(ok: boolean, reason: string, approval: HumanApproval | null) {\n this.ok = ok;\n this.reason = reason;\n this.approval = approval;\n }\n}\n\n/**\n * What a branch is actually authorized to override RIGHT NOW: checklist id → the human's `approves` prose.\n * Data-only, with accessors so every consumer asks the question one way.\n *\n * `rejected` carries the approvals that were found but did NOT verify, one rendered line each. They are\n * kept rather than dropped because \"there is an approval on this branch, and here is why it no longer\n * counts\" is a completely different message from \"nobody has authorized anything\" — the first needs a\n * re-authorization, the second needs a human at all — and an empty map cannot tell them apart.\n */\nexport class AuthorizedOverrides {\n proseById: Map<string, string>;\n rejected: string[];\n\n constructor(proseById: Map<string, string> = new Map<string, string>(), rejected: string[] = []) {\n this.proseById = proseById;\n this.rejected = rejected;\n }\n\n has(checklistId: string): boolean {\n return this.proseById.has(checklistId);\n }\n\n /** The human's own words for `checklistId`, or '' when it is not authorized. */\n proseFor(checklistId: string): string {\n return this.proseById.get(checklistId) ?? '';\n }\n}\n\n/**\n * Mints (for a HUMAN at a tty) and verifies (for ANYONE, agents included) the human-authorization records\n * that are the ONLY channel by which a review checklist's override may be granted.\n *\n * ─── The problem ───────────────────────────────────────────────────────────────────────────────────────\n * A required checklist goes red, the human authorizes the partial scope, and there is NO channel by which\n * the subagent doing the work can know that. Every channel available before this carried a CLAIM of\n * authorization and never EVIDENCE of it: a coordinator relaying the human's words is unverifiable by\n * construction (and correctly refused — that refusal is the shape a prompt injection exploits); a ticket\n * comment can be written by an agent holding the same MCP; and the `override` field in review-<id>.json is\n * the agent authorizing itself.\n *\n * So the property this class buys is exactly one sentence: **an agent can VERIFY an authorization it\n * cannot MINT.** `wp-authorize` reads the approval from `/dev/tty`, which an agent's Bash tool has no way\n * to answer; `wp-check-auth` recomputes the HMAC and is read-only, so agents run it freely.\n *\n * ─── Bound to SCOPE, never to a diff sha ───────────────────────────────────────────────────────────────\n * An approval bound to the head-commit diff would be void on the next commit, so the human would\n * re-authorize on every push and nobody would use it. Each approval binds to WHAT WAS APPROVED instead:\n * • `scopePaths` — the globs the diff touched when approved. A \"terraform only\" approval is void the\n * moment app files appear, which is the abuse actually worth stopping.\n * • `forkPoint` — the merge-base when approved. Survives new commits; dies if the branch is restarted.\n * • `expiresAt` — hours, not days.\n * Edits inside the approved scope keep working; widening it does not. That is what the human means when\n * they say \"yes, ship the terraform half\".\n *\n * ─── Where the file lives, and why it is SHARED rather than per-worktree ───────────────────────────────\n * `dotWebpieces.shared()/authorizations/<branch-slug>.json`, keyed by BRANCH. Not `local()`: the human\n * routinely types `wp-authorize` in the primary clone while the agent works in a linked worktree, and\n * under `local()` those are two different files — the approval would be minted somewhere the agent never\n * looks, which is the same stall this feature exists to end. The branch key is what makes sharing safe:\n * an approval names its branch inside the SIGNED payload, so a file copied to another branch does not\n * verify there.\n *\n * It is never committed (`.webpieces/` is gitignored in full). A committed authorization would travel to\n * branches nobody approved.\n *\n * ─── Honest limits — do not oversell this ─────────────────────────────────────────────────────────────\n * The agent runs as the SAME OS USER as the human, and the HMAC key is `prGate.gateSalt`, which lives in a\n * committed file agents read routinely. Nothing here is cryptographically airtight against a determined\n * model; the real enforcement is the tty affordance plus the harness deny rule. That is fine, because the\n * problem being solved is agents drifting, guessing, or being confused by relays — not an adversarial\n * model. Moving the key to `~/.webpieces/authorize.key` is a one-line change to `sign()` if the threat\n * model ever changes; nothing else in the design moves.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class HumanAuthorizationService {\n constructor(private readonly dotDir: DotWebpieces = dotWebpieces) {}\n\n /** The repo-wide authorizations directory. See the class docstring for why `shared()` and not `local()`. */\n dirFor(repoRoot: string): string {\n return this.dotDir.sharedFile(repoRoot, AUTHORIZATIONS_DIR);\n }\n\n /**\n * A filesystem-safe leaf for a branch name. Slashes and anything exotic collapse to `-`, so\n * `dean/one-2779-grants` becomes `dean-one-2779-grants`.\n *\n * A collision between two slugs is harmless: the branch is inside the SIGNED payload, so an approval\n * that landed in a colliding file fails `verify` on the branch check rather than leaking across.\n */\n slugFor(branch: string): string {\n return branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');\n }\n\n pathFor(repoRoot: string, branch: string): string {\n return path.join(this.dirFor(repoRoot), `${this.slugFor(branch)}.json`);\n }\n\n /**\n * The exact bytes that get signed: a version tag, the branch, and every field of the approval EXCEPT\n * the hmac, in a fixed order with `scopePaths` sorted.\n *\n * Fixed order and sorting are load-bearing — `JSON.stringify` over a re-parsed object preserves\n * insertion order, so a round-tripped approval whose keys arrived in a different order would compute a\n * different payload and fail to verify a signature that is perfectly good.\n */\n canonicalPayload(branch: string, approval: HumanApproval): string {\n return [\n PAYLOAD_VERSION,\n branch,\n approval.checklist,\n approval.gate,\n approval.approves,\n [...approval.scopePaths].sort().join(','),\n approval.forkPoint,\n approval.issuedAt,\n approval.expiresAt,\n ].join('\\n');\n }\n\n /** `HMAC-SHA256(salt, canonical payload)`. '' for an empty salt — the caller treats '' as \"not configured\". */\n sign(branch: string, approval: HumanApproval, salt: string): string {\n if (salt.trim() === '') return '';\n return createHmac('sha256', salt).update(this.canonicalPayload(branch, approval)).digest('hex');\n }\n\n /** The same approval, carrying its signature. Never mutates the input. */\n signed(branch: string, approval: HumanApproval, salt: string): HumanApproval {\n return new HumanApproval(\n approval.checklist, approval.gate, approval.approves, approval.scopePaths,\n approval.forkPoint, approval.issuedAt, approval.expiresAt,\n this.sign(branch, approval, salt),\n );\n }\n\n /**\n * Every approval recorded for `branch`, signature UNCHECKED. An absent or unreadable file is an EMPTY\n * file, never a throw: a corrupt authorization must degrade to \"nothing is authorized\", which is the\n * safe direction, and a branch that cannot open its own authorization file must still be able to run\n * the gate and be told to go get one.\n */\n load(repoRoot: string, branch: string): AuthorizationFile {\n const p = this.pathFor(repoRoot, branch);\n if (!fs.existsSync(p)) return new AuthorizationFile(branch);\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: unreadable authorizations mean \"none\", never a crash\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-any-unknown -- parsed JSON is opaque until each field is narrowed below\n const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Record<string, unknown>;\n const list = Array.isArray(raw['approvals']) ? (raw['approvals'] as unknown[]) : [];\n return new AuthorizationFile(branch, list.map((e: unknown): HumanApproval => this.toApproval(e)));\n } catch (err: unknown) {\n const error = toError(err);\n // Fail CLOSED on the grant, but say so: nothing is authorized either way, and reporting WHY is\n // the difference between \"ask the human to authorize\" and \"ask the human to authorize AGAIN\n // because their last one is unreadable on disk\".\n return new AuthorizationFile(branch, [], `${p} exists but could not be read (${error.message})`);\n }\n }\n\n // One parsed entry. Every field is read defensively — a hand-edited file is exactly the input this must\n // survive, and a missing field simply yields '' / [], which then fails the signature check.\n // webpieces-disable no-any-unknown -- opaque parsed JSON entry; each field is narrowed here\n private toApproval(entry: unknown): HumanApproval {\n const raw = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<string, unknown>;\n return new HumanApproval(\n this.str(raw['checklist']), this.str(raw['gate']), this.str(raw['approves']),\n this.strList(raw['scopePaths']), this.str(raw['forkPoint']),\n this.str(raw['issuedAt']), this.str(raw['expiresAt']), this.str(raw['hmac']),\n );\n }\n\n // webpieces-disable no-any-unknown -- reading ONE opaque field out of hand-editable JSON; narrowed right here\n private str(value: unknown): string {\n return typeof value === 'string' ? value : '';\n }\n\n // webpieces-disable no-any-unknown -- reading ONE opaque field out of hand-editable JSON; narrowed right here\n private strList(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n // webpieces-disable no-any-unknown -- element of an opaque JSON array, narrowed by the type guard\n return value.filter((v: unknown): v is string => typeof v === 'string');\n }\n\n /**\n * APPEND one signed approval to the branch's file and return the path written. Append, never replace:\n * a run of work needs several overrides at different times, and each one is its own record of intent.\n */\n append(repoRoot: string, branch: string, approval: HumanApproval, salt: string): string {\n const file = this.load(repoRoot, branch);\n file.approvals.push(this.signed(branch, approval, salt));\n const p = this.pathFor(repoRoot, branch);\n fs.mkdirSync(path.dirname(p), { recursive: true });\n fs.writeFileSync(p, JSON.stringify(new AuthorizationFile(branch, file.approvals), null, 2) + '\\n');\n return p;\n }\n\n /**\n * Verify ONE approval against the branch state, in the order a reader wants to hear it: signature,\n * expiry, fork point, scope. Signature first — an unsigned entry's other fields are not evidence of\n * anything, and reporting \"expired\" for a forged record would be answering the wrong question.\n */\n verify(ctx: AuthorizationContext, approval: HumanApproval, salt: string): AuthorizationCheck {\n const expected = this.sign(ctx.branch, approval, salt);\n if (expected === '' || approval.hmac !== expected) {\n return this.fail(approval, 'its signature does not verify — it was hand-written, edited after '\n + 'signing, minted on a different branch, or the repo\\'s prGate.gateSalt changed');\n }\n if (this.expired(approval, ctx.now)) {\n return this.fail(approval, `it EXPIRED at ${approval.expiresAt} (an approval is for the sitting it was given in)`);\n }\n if (approval.forkPoint !== '' && ctx.forkPoint !== '' && approval.forkPoint !== ctx.forkPoint) {\n return this.fail(approval, `the branch was restarted from a different base — approved at fork point `\n + `${approval.forkPoint.slice(0, 12)}, now ${ctx.forkPoint.slice(0, 12)}`);\n }\n const outside = this.outsideScope(approval, ctx.changedFiles);\n if (outside.length > 0) {\n return this.fail(approval, `the diff now touches ${outside.length} file(s) OUTSIDE the approved scope `\n + `(${approval.scopePaths.join(', ')}): ${outside.slice(0, 5).join(', ')}`);\n }\n return new AuthorizationCheck(true, '', approval);\n }\n\n private fail(approval: HumanApproval, reason: string): AuthorizationCheck {\n return new AuthorizationCheck(false, reason, approval);\n }\n\n // An approval with no parseable `expiresAt` is treated as EXPIRED. Failing closed is the only safe\n // reading: a missing expiry on a record whose whole purpose is to be time-bounded is a broken record,\n // and the alternative reading is a grant that never ends.\n private expired(approval: HumanApproval, now: Date): boolean {\n const at = Date.parse(approval.expiresAt);\n if (Number.isNaN(at)) return true;\n return at <= now.getTime();\n }\n\n /**\n * The changed files NOT covered by `scopePaths`. An approval with an EMPTY `scopePaths` covers nothing\n * and is therefore always out of scope when anything changed — `wp-authorize` never mints one, and the\n * alternative reading (\"empty means everything\") is precisely the widening-by-absence that makes the\n * permissive path the shortest thing to type.\n */\n private outsideScope(approval: HumanApproval, changedFiles: readonly string[]): string[] {\n return changedFiles.filter((f: string): boolean => !matchesAnyGlob(f, approval.scopePaths));\n }\n\n /**\n * THE question every consumer asks: what is this branch authorized to override right now? Verifies every\n * recorded approval and returns the ones that hold, plus a rendered line per one that does not.\n *\n * Later approvals win for the same checklist — the human appended it because they meant to say something\n * newer, and the file is append-only precisely so the earlier one stays readable as history.\n */\n verifiedFor(repoRoot: string, ctx: AuthorizationContext, salt: string): AuthorizedOverrides {\n const prose = new Map<string, string>();\n const rejected: string[] = [];\n const file = this.load(repoRoot, ctx.branch);\n if (file.unreadable !== '') rejected.push(`(unreadable, treated as NO approvals) — ${file.unreadable}`);\n for (const approval of file.approvals) {\n const check = this.verify(ctx, approval, salt);\n if (check.ok) prose.set(approval.checklist, approval.approves);\n else rejected.push(`\"${approval.checklist}\" (issued ${approval.issuedAt}) — ${check.reason}`);\n }\n return new AuthorizedOverrides(prose, rejected);\n }\n\n /** An expiry `hours` from `issuedAt`, as ISO — the one place the TTL arithmetic lives. */\n expiryFrom(issuedAt: Date, hours: number): string {\n return new Date(issuedAt.getTime() + hours * 3600 * 1000).toISOString();\n }\n}\n"]}