[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
0

Configure Feed

Select the types of activity you want to include in your feed.

pi-fence / grants / persist.ts
12 kB 377 lines
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}