@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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.
@@ -1,266 +0,0 @@
1
- import {
2
- chmodSync,
3
- closeSync,
4
- mkdirSync,
5
- openSync,
6
- readFileSync,
7
- renameSync,
8
- statSync,
9
- unlinkSync,
10
- writeFileSync,
11
- } from "node:fs";
12
- import { dirname } from "node:path";
13
- import { pid } from "node:process";
14
- import { OVERRIDE_ANCHOR_START, type PermissionOverride } from "./override-evidence.js";
15
-
16
- const STANDING_APPROVAL_SCOPE = "comparable" as const;
17
- export const STANDING_APPROVAL_LIMIT = 200;
18
- const STALE_LEDGER_LOCK_MS = 60_000;
19
-
20
- export interface StandingApprovalRecord {
21
- v: 1;
22
- ts: string;
23
- gate: {
24
- label: string;
25
- group: string;
26
- };
27
- command: string;
28
- scope: typeof STANDING_APPROVAL_SCOPE;
29
- project: string;
30
- reason: string;
31
- }
32
-
33
- interface StandingApprovalInput {
34
- gate: StandingApprovalRecord["gate"];
35
- command: string;
36
- project: string;
37
- reason: string;
38
- timestamp?: Date;
39
- }
40
-
41
- export function buildStandingApprovalRecord(input: StandingApprovalInput): StandingApprovalRecord {
42
- return {
43
- v: 1,
44
- ts: (input.timestamp ?? new Date()).toISOString(),
45
- gate: { label: input.gate.label, group: input.gate.group },
46
- command: input.command,
47
- scope: STANDING_APPROVAL_SCOPE,
48
- project: input.project,
49
- reason: input.reason,
50
- };
51
- }
52
-
53
- function parseRecord(value: unknown): StandingApprovalRecord | undefined {
54
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
- const record = value as Partial<StandingApprovalRecord>;
56
- const gate = record.gate as Partial<StandingApprovalRecord["gate"]> | undefined;
57
- if (
58
- record.v !== 1
59
- || typeof record.ts !== "string"
60
- || !Number.isFinite(Date.parse(record.ts))
61
- || !gate
62
- || typeof gate.label !== "string"
63
- || !gate.label.trim()
64
- || typeof gate.group !== "string"
65
- || !gate.group.trim()
66
- || typeof record.command !== "string"
67
- || !record.command.trim()
68
- || record.scope !== STANDING_APPROVAL_SCOPE
69
- || typeof record.project !== "string"
70
- || !record.project.trim()
71
- || typeof record.reason !== "string"
72
- || !record.reason.trim()
73
- ) {
74
- return undefined;
75
- }
76
- return {
77
- v: 1,
78
- ts: record.ts,
79
- gate: { label: gate.label.trim(), group: gate.group.trim() },
80
- command: record.command,
81
- scope: STANDING_APPROVAL_SCOPE,
82
- project: record.project.trim(),
83
- reason: record.reason.trim(),
84
- };
85
- }
86
-
87
- /** Read valid records in file order. Malformed and future-version lines are ignored. */
88
- export function readStandingApprovals(path: string): StandingApprovalRecord[] {
89
- let text: string;
90
- try {
91
- text = readFileSync(path, "utf8");
92
- } catch (error) {
93
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
94
- throw error;
95
- }
96
- const records: StandingApprovalRecord[] = [];
97
- for (const line of text.split("\n")) {
98
- if (!line.trim()) continue;
99
- try {
100
- const record = parseRecord(JSON.parse(line));
101
- if (record) records.push(record);
102
- } catch {
103
- // One corrupt line must not discard later user approvals.
104
- }
105
- }
106
- return records;
107
- }
108
-
109
- function openLedgerLock(lockPath: string): number {
110
- try {
111
- return openSync(lockPath, "wx", 0o600);
112
- } catch (error) {
113
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
114
-
115
- let modifiedAt: number;
116
- try {
117
- modifiedAt = statSync(lockPath).mtimeMs;
118
- } catch (statError) {
119
- if ((statError as NodeJS.ErrnoException).code === "ENOENT") {
120
- return openSync(lockPath, "wx", 0o600);
121
- }
122
- throw statError;
123
- }
124
- if (Date.now() - modifiedAt <= STALE_LEDGER_LOCK_MS) throw error;
125
-
126
- // Rename, rather than unlink, so two processes cannot both decide that the
127
- // same stale lock is theirs to remove. Only one rename can win.
128
- const stalePath = `${lockPath}.stale.${pid}.${Date.now()}`;
129
- try {
130
- renameSync(lockPath, stalePath);
131
- } catch (renameError) {
132
- if ((renameError as NodeJS.ErrnoException).code === "ENOENT") {
133
- return openSync(lockPath, "wx", 0o600);
134
- }
135
- throw renameError;
136
- }
137
- try {
138
- return openSync(lockPath, "wx", 0o600);
139
- } finally {
140
- try {
141
- unlinkSync(stalePath);
142
- } catch {
143
- // The stale lock has already been displaced; cleanup is best effort.
144
- }
145
- }
146
- }
147
- }
148
-
149
- function withLedgerLock<T>(path: string, operation: () => T): T {
150
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
151
- const lockPath = `${path}.lock`;
152
- const descriptor = openLedgerLock(lockPath);
153
- try {
154
- return operation();
155
- } finally {
156
- closeSync(descriptor);
157
- try {
158
- unlinkSync(lockPath);
159
- } catch {
160
- // The operation's result is authoritative; lock cleanup is best effort.
161
- }
162
- }
163
- }
164
-
165
- /** Callers hold the ledger lock, whose acquisition already created the directory. */
166
- function writeStandingApprovals(path: string, records: readonly StandingApprovalRecord[]): void {
167
- const temporary = `${path}.${pid}.tmp`;
168
- try {
169
- writeFileSync(
170
- temporary,
171
- records.length ? `${records.map((record) => JSON.stringify(record)).join("\n")}\n` : "",
172
- { encoding: "utf8", mode: 0o600 },
173
- );
174
- chmodSync(temporary, 0o600);
175
- renameSync(temporary, path);
176
- chmodSync(path, 0o600);
177
- } catch (error) {
178
- try {
179
- unlinkSync(temporary);
180
- } catch {
181
- // The temporary file may not have been created.
182
- }
183
- throw error;
184
- }
185
- }
186
-
187
- export function appendStandingApproval(
188
- path: string,
189
- record: StandingApprovalRecord,
190
- ): { evicted: number } {
191
- return withLedgerLock(path, () => {
192
- // Re-granting the same command refreshes one approval instead of inflating
193
- // the ledger and every review envelope that consumes it.
194
- const records = readStandingApprovals(path)
195
- .filter((existing) => existing.gate.group !== record.gate.group || existing.command !== record.command);
196
- records.push(record);
197
- const evicted = Math.max(0, records.length - STANDING_APPROVAL_LIMIT);
198
- writeStandingApprovals(path, records.slice(evicted));
199
- return { evicted };
200
- });
201
- }
202
-
203
- function standingApprovalToPermissionOverride(
204
- record: StandingApprovalRecord,
205
- seq: number,
206
- anchorKey: string = OVERRIDE_ANCHOR_START,
207
- ): PermissionOverride {
208
- return {
209
- seq,
210
- anchorKey,
211
- gateLabel: record.gate.label,
212
- command: record.command,
213
- reviewerReason: record.reason,
214
- choice: "allow_unnecessary",
215
- standing: {
216
- grantedAt: record.ts,
217
- project: record.project,
218
- gateGroup: record.gate.group,
219
- },
220
- };
221
- }
222
-
223
- /** Session-start projection: ledger records precede this session's evidence. */
224
- export function standingApprovalsToPermissionOverrides(
225
- records: readonly StandingApprovalRecord[],
226
- ): PermissionOverride[] {
227
- return records.map((record, index) => standingApprovalToPermissionOverride(record, index - records.length));
228
- }
229
-
230
- /** One prompt selection atomically writes the ledger and yields live evidence. */
231
- export function grantStandingApproval(
232
- path: string,
233
- input: StandingApprovalInput,
234
- seq: number,
235
- anchorKey?: string,
236
- ): { record: StandingApprovalRecord; override: PermissionOverride; evicted: number } {
237
- const record = buildStandingApprovalRecord(input);
238
- const { evicted } = appendStandingApproval(path, record);
239
- return {
240
- record,
241
- override: standingApprovalToPermissionOverride(record, seq, anchorKey),
242
- evicted,
243
- };
244
- }
245
-
246
- function sameRecord(left: StandingApprovalRecord, right: StandingApprovalRecord): boolean {
247
- return left.ts === right.ts
248
- && left.gate.label === right.gate.label
249
- && left.gate.group === right.gate.group
250
- && left.command === right.command
251
- && left.scope === right.scope
252
- && left.project === right.project
253
- && left.reason === right.reason;
254
- }
255
-
256
- /** Remove one selected record and preserve 0600 on the rewritten ledger. */
257
- export function revokeStandingApproval(path: string, selected: StandingApprovalRecord): boolean {
258
- return withLedgerLock(path, () => {
259
- const records = readStandingApprovals(path);
260
- const index = records.findIndex((record) => sameRecord(record, selected));
261
- if (index < 0) return false;
262
- records.splice(index, 1);
263
- writeStandingApprovals(path, records);
264
- return true;
265
- });
266
- }