[READ-ONLY] Mirror of https://github.com/andrioid/pi-fence. Fence wrapper for Pi coding agent. Wraps bash calls and intercepts reads/writes according to fence config
1/**
2 * Persist a grant to the project-root `fence.jsonc` (PLAN §7).
3 *
4 * Three branches:
5 *
6 * A. File exists at `<root>/fence.jsonc` (or `.json`) — read, edit
7 * via jsonc-parser to preserve comments and formatting, validate
8 * the result against the vendored schema, mtime-check, atomic
9 * write, retain last 3 backups.
10 *
11 * B. No file at root — scaffold one with `extends: "@base"` if a
12 * non-project fence config exists in the chain (the user already
13 * has a global), else `extends: "code"` as a sensible default.
14 *
15 * C. File only exists outside project root (e.g. ~/.config/fence/) —
16 * treated as branch B; we never edit the global file.
17 *
18 * Project root resolution:
19 * 1. `git rev-parse --show-toplevel` from cwd.
20 * 2. Else cwd itself.
21 *
22 * Schema validation uses the vendored `schema/fence.schema.json` and
23 * Ajv 2020-12. We validate after our edit so we never write an invalid
24 * config; we do *not* validate the existing on-disk file before our
25 * edit (PLAN §7: "we're not the linter for existing config").
26 *
27 * Failure modes return a structured error so the caller can show a
28 * clean notify. We never throw across the extension boundary.
29 */
30
31import { execFileSync } from "node:child_process";
32import {
33 closeSync,
34 copyFileSync,
35 existsSync,
36 fstatSync,
37 fsyncSync,
38 openSync,
39 realpathSync,
40 readFileSync,
41 readdirSync,
42 renameSync,
43 statSync,
44 unlinkSync,
45 writeSync,
46} from "node:fs";
47import * as path from "node:path";
48import { fileURLToPath } from "node:url";
49import Ajv2020 from "ajv/dist/2020.js";
50import addFormats from "ajv-formats";
51import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
52import type { GrantClass } from "./store.js";
53
54const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
56export interface PersistInput {
57 cwd: string;
58 /** "read" → filesystem.allowRead, "write" → filesystem.allowWrite. */
59 tool: GrantClass;
60 /** Realpath'd absolute path to add to the allow list. */
61 path: string;
62 /** Resolution chain from `fence config show`; informs scaffold's `extends`. */
63 chain: string[];
64}
65
66export type PersistResult =
67 | { ok: true; targetFile: string; created: boolean }
68 | { ok: false; error: string };
69
70/**
71 * Project root via git, falling back to cwd.
72 *
73 * Stable target: doesn't shift when you `cd src/` mid-session.
74 * If `git rev-parse` exits non-zero, we use cwd unchanged.
75 */
76export function findProjectRoot(cwd: string): string {
77 try {
78 const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
79 cwd,
80 encoding: "utf-8",
81 stdio: ["ignore", "pipe", "ignore"],
82 timeout: 2000,
83 });
84 const root = out.trim();
85 if (root) return root;
86 } catch {
87 // not a git repo, or git not installed
88 }
89 return cwd;
90}
91
92/** Locate the existing target file, if any. Prefers `.jsonc` over `.json`. */
93function findTargetFile(root: string): { file: string; exists: boolean } {
94 const jsonc = path.join(root, "fence.jsonc");
95 if (existsSync(jsonc)) return { file: jsonc, exists: true };
96 const json = path.join(root, "fence.json");
97 if (existsSync(json)) return { file: json, exists: true };
98 return { file: jsonc, exists: false }; // proposed
99}
100
101/**
102 * Add a path to the appropriate filesystem allow-list and write it back
103 * to disk. Edits-in-place if the file exists, scaffolds otherwise.
104 *
105 * Returns a structured result; the caller surfaces the error message
106 * via `ctx.ui.notify`. We never throw.
107 */
108export async function persistGrant(input: PersistInput): Promise<PersistResult> {
109 const root = findProjectRoot(input.cwd);
110 const { file, exists } = findTargetFile(root);
111
112 if (!exists) {
113 return scaffoldNew(file, input);
114 }
115 return editExisting(file, input);
116}
117
118const FIELD: Record<GrantClass, string> = {
119 read: "allowRead",
120 write: "allowWrite",
121};
122
123/**
124 * Edit branch (PLAN §7 branch A).
125 *
126 * Steps:
127 * 1. Read + record mtime.
128 * 2. jsonc-parser: parse to a CST that preserves comments + formatting.
129 * 3. modify() to append the path to filesystem.<allow*>.
130 * 4. JSON.parse the resulting text and Ajv-validate against the schema.
131 * 5. mtime check — if the file changed since step 1, abort.
132 * 6. .bak.<ts> snapshot, atomic-rename via tmp, prune to last 3 backups.
133 */
134function editExisting(file: string, input: PersistInput): PersistResult {
135 const refusal = refuseSuspiciousTarget(file);
136 if (refusal) return { ok: false, error: refusal };
137
138 let text: string;
139 let mtimeMs: number;
140 try {
141 const st = statSync(file);
142 mtimeMs = st.mtimeMs;
143 text = readFileSync(file, "utf-8");
144 } catch (e) {
145 return { ok: false, error: `cannot read ${file}: ${msg(e)}` };
146 }
147
148 // Parse to detect malformed JSONC up front. We don't bail on schema
149 // errors here — only on syntax errors that would corrupt our edit.
150 const parseErrors: ParseError[] = [];
151 parse(text, parseErrors, { allowTrailingComma: true, disallowComments: false });
152 if (parseErrors.length > 0) {
153 return {
154 ok: false,
155 error: `${file} is not valid JSONC; refusing to edit. Fix it manually first.`,
156 };
157 }
158
159 const field = FIELD[input.tool];
160 const editsCst = modify(text, ["filesystem", field, -1], input.path, {
161 isArrayInsertion: true,
162 formattingOptions: { tabSize: 2, insertSpaces: true, eol: "\n" },
163 });
164 const newText = applyEdits(text, editsCst);
165
166 // Validate the resulting JSON against the vendored schema.
167 let parsed: unknown;
168 try {
169 parsed = JSON.parse(newText);
170 } catch (e) {
171 return { ok: false, error: `pi-fence's edit produced invalid JSON: ${msg(e)}` };
172 }
173 const schemaError = validateAgainstSchema(parsed);
174 if (schemaError) {
175 return { ok: false, error: `pi-fence's edit failed schema validation: ${schemaError}` };
176 }
177
178 // mtime re-check — abort if the file moved under us.
179 try {
180 const stNow = statSync(file);
181 if (stNow.mtimeMs !== mtimeMs) {
182 return { ok: false, error: `${file} changed on disk during edit; retry the grant.` };
183 }
184 } catch (e) {
185 return { ok: false, error: `mtime check failed: ${msg(e)}` };
186 }
187
188 // Backup, then atomic write.
189 try {
190 const ts = Math.floor(Date.now() / 1000);
191 const bak = `${file}.bak.${ts}`;
192 copyFileSync(file, bak);
193 pruneBackups(file, 3);
194 } catch {
195 // non-fatal; we still attempt the write
196 }
197
198 try {
199 atomicWrite(file, newText);
200 } catch (e) {
201 return { ok: false, error: `atomic write failed: ${msg(e)}` };
202 }
203
204 return { ok: true, targetFile: file, created: false };
205}
206
207/**
208 * Scaffold branch (PLAN §7 branches B + C).
209 *
210 * Creates a minimal `fence.jsonc` with:
211 * - `$schema` pointing at upstream
212 * - `extends: "@base"` if a non-project config exists in the chain,
213 * else `extends: "code"`
214 * - the seed allow rule
215 */
216function scaffoldNew(file: string, input: PersistInput): PersistResult {
217 const refusal = refuseSuspiciousTarget(file);
218 if (refusal) return { ok: false, error: refusal };
219
220 // Pick `extends` based on whether the user has a non-project config
221 // already loaded. The chain contains absolute paths to loaded config
222 // files. If any of them sit outside the project root, we want to
223 // inherit them via `@base` — fence's documented escape hatch for
224 // stacking project on top of user globals. Otherwise we default to
225 // the `code` template, the standard "coding agent" baseline.
226 const root = path.dirname(file);
227 const hasGlobal = input.chain.some((p) => !p.startsWith(root + path.sep) && p !== file);
228 const ext = hasGlobal ? "@base" : "code";
229
230 const field = FIELD[input.tool];
231 const body = {
232 $schema: "https://raw.githubusercontent.com/Use-Tusk/fence/main/docs/schema/fence.schema.json",
233 extends: ext,
234 filesystem: {
235 [field]: [input.path],
236 },
237 };
238
239 const schemaError = validateAgainstSchema(body);
240 if (schemaError) {
241 return { ok: false, error: `scaffold failed schema validation: ${schemaError}` };
242 }
243
244 const text = JSON.stringify(body, null, 2) + "\n";
245 try {
246 atomicWrite(file, text);
247 } catch (e) {
248 return { ok: false, error: `atomic write failed: ${msg(e)}` };
249 }
250 return { ok: true, targetFile: file, created: true };
251}
252
253/**
254 * Reject paths that look like a user/system global (defense in depth —
255 * `findProjectRoot` should never produce these, but cheap to check).
256 */
257function refuseSuspiciousTarget(file: string): string | undefined {
258 const realFile = realpathOrSelf(file);
259 const lower = realFile.toLowerCase();
260 if (lower.includes("/.config/fence/")) {
261 return `refusing to edit global fence config (${realFile}). Persist requires a project-local fence.jsonc.`;
262 }
263 if (lower.startsWith("/etc/")) {
264 return `refusing to edit ${realFile}: outside project root.`;
265 }
266 // Anything inside our own install dir is off-limits.
267 const installDir = path.resolve(__dirname, "..");
268 if (realFile.startsWith(installDir + path.sep)) {
269 return `refusing to edit ${realFile}: inside pi-fence install dir.`;
270 }
271 return undefined;
272}
273
274let cachedAjv: { validate: (data: unknown) => string | undefined } | undefined;
275
276function getValidator(): { validate: (data: unknown) => string | undefined } {
277 if (cachedAjv) return cachedAjv;
278 try {
279 const schemaPath = path.join(__dirname, "..", "schema", "fence.schema.json");
280 const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
281 // ajv and ajv-formats are CJS-with-default; the default-import unwraps it.
282 const ajv = new Ajv2020({ allErrors: false, strict: false });
283 addFormats(ajv);
284 const validateFn = ajv.compile(schema);
285 cachedAjv = {
286 validate(data: unknown) {
287 const ok = validateFn(data);
288 if (ok) return undefined;
289 const errs = validateFn.errors ?? [];
290 const first = errs[0];
291 if (!first) return "validation failed";
292 return `${first.instancePath || "/"} ${first.message ?? "invalid"}`;
293 },
294 };
295 return cachedAjv;
296 } catch (e) {
297 // If the schema itself can't be loaded, validation is best-effort.
298 // Surface the failure but don't block the user.
299 cachedAjv = {
300 validate(_) {
301 return undefined;
302 },
303 };
304 return cachedAjv;
305 }
306}
307
308function validateAgainstSchema(data: unknown): string | undefined {
309 return getValidator().validate(data);
310}
311
312/**
313 * Atomic write: write to `<file>.tmp.<pid>`, fsync, then rename.
314 *
315 * The fsync ensures the data is on disk before the rename completes;
316 * a crash mid-write leaves the original file intact and an orphan
317 * tmp file (which we don't bother to clean — next persist will
318 * overwrite it).
319 */
320function atomicWrite(file: string, text: string): void {
321 const tmp = `${file}.tmp.${process.pid}`;
322 const fd = openSync(tmp, "w");
323 try {
324 writeSync(fd, text);
325 // Best-effort fsync. Some filesystems (or sandboxes) reject this; swallow.
326 try {
327 fsyncSync(fd);
328 } catch {
329 // ignore
330 }
331 // Verify the file we're about to rename has the bytes we just wrote.
332 // Cheap sanity check: fstat says size matches.
333 const st = fstatSync(fd);
334 if (st.size !== Buffer.byteLength(text, "utf-8")) {
335 throw new Error(`tmp file size mismatch (${st.size} vs ${Buffer.byteLength(text, "utf-8")})`);
336 }
337 } finally {
338 closeSync(fd);
339 }
340 renameSync(tmp, file);
341}
342
343/** Keep the most recent `keep` backups; delete older ones. */
344function pruneBackups(file: string, keep: number): void {
345 const dir = path.dirname(file);
346 const base = path.basename(file);
347 let entries: string[];
348 try {
349 entries = readdirSync(dir);
350 } catch {
351 return;
352 }
353 const baks = entries
354 .filter((n) => n.startsWith(`${base}.bak.`))
355 .map((n) => ({ name: n, ts: parseInt(n.slice(`${base}.bak.`.length), 10) }))
356 .filter((e) => Number.isFinite(e.ts))
357 .sort((a, b) => b.ts - a.ts);
358 for (const old of baks.slice(keep)) {
359 try {
360 unlinkSync(path.join(dir, old.name));
361 } catch {
362 // ignore
363 }
364 }
365}
366
367function realpathOrSelf(p: string): string {
368 try {
369 return realpathSync(p);
370 } catch {
371 return p;
372 }
373}
374
375function msg(e: unknown): string {
376 return e instanceof Error ? e.message : String(e);
377}