owned-read.ts
72 lines · autoplan/bin/owned-read.ts at 3f79d81
1/**
2 * /autoplan's own read-only inputs, approved without a permission card.
3 *
4 * Claude Code clears a skill's `allowed-tools` grant when the next turn starts,
5 * and a background reviewer's completion notice starts one. The skill's hooks
6 * stay registered for the session, so this hook re-approves exactly the files
7 * /autoplan itself reads outside the working directory: its installed section
8 * files, and the immutable phase artifacts in a project's `.gstack/tmp/autoplan/`
9 * when the session started below the repository root. It never denies; anything
10 * else keeps Claude Code's own permission check. Skill hooks do not run inside
11 * subagents, so reviewer inputs live in the project, which needs no approval.
12 */
13import * as fs from 'node:fs';
14import * as path from 'node:path';
15import * as os from 'node:os';
16import { nativePathSpelling, sameNativePath } from '../../lib/claude-journal-records';
17import { resolveStateRoot } from '../../lib/state-root';
19/** setup's `${GSTACK_USER_RENDER_DIR:-$GSTACK_STATE_ROOT/render/claude}`, realpath'd. */
20export function userRenderRoot(): string | undefined {
21 const configured = process.env.GSTACK_USER_RENDER_DIR || path.join(resolveStateRoot(), 'render', 'claude');
22 try { return fs.realpathSync(path.resolve(nativePathSpelling(configured))); } catch { return; }
23}
25/** scripts/gen-skill-docs.ts rewriteSectionBase, which writes that render. */
26export function renderSectionBase(content: string, linkRoot: string): string {
27 return content.replace(
28 /~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
29 (_m, p1: string) => `${linkRoot}/${p1}`,
30 );
31}
33const ARTIFACT_DIR = /^autoplan-(ceo|design|dx|eng)-[^\\/]+$/;
34const artifactFiles = (phase: string) => ['methodology.md', 'methodology.json', 'native-prompt.md', 'snapshot.json',
35 'source-implementation.md', `${phase}-implementation.md`, 'close-packet.md'];
37/** True only for this installation's autoplan/sections/*.md, or an immutable artifact in
38 * <repo>/.gstack/tmp/autoplan/autoplan-<phase>-*\/ (read-only: mode 0444, or the read-only attribute on Windows) where <repo> is the session's project directory or contains it. */
39export function ownedAutoplanRead(file: unknown, cwd: string, root: string): boolean {
40 if (typeof file !== 'string') return false;
41 try {
42 const requested = nativePathSpelling(path.resolve(cwd, file.replace(/^~(?=[\\/]|$)/, () => os.homedir())));
43 const base = path.basename(requested), directory = path.dirname(requested), actual = fs.realpathSync(requested);
44 if (path.basename(directory) === 'sections' && path.basename(path.dirname(directory)) === 'autoplan') {
45 const canonical = path.join(root, 'autoplan', 'sections', base);
46 if (!/^[a-z0-9-]+\.md$/.test(base) || !fs.lstatSync(canonical).isFile()) return false;
47 if (sameNativePath(actual, canonical)) return true;
48 const render = userRenderRoot();
49 return !!render && sameNativePath(actual, path.join(render, 'autoplan', 'sections', base)) &&
50 fs.readFileSync(actual, 'utf8') === renderSectionBase(fs.readFileSync(canonical, 'utf8'), render);
51 }
52 const phase = ARTIFACT_DIR.exec(path.basename(directory))?.[1];
53 if (!phase || !artifactFiles(phase).includes(base) || !sameNativePath(actual, requested)) return false;
54 const stat = fs.lstatSync(requested), store = path.dirname(directory), owner = path.dirname(path.dirname(path.dirname(store)));
55 const project = fs.realpathSync(nativePathSpelling(process.env.CLAUDE_PROJECT_DIR || cwd));
56 return stat.isFile() && sameNativePath(store, path.join(owner, '.gstack', 'tmp', 'autoplan')) &&
57 (sameNativePath(project, owner) || nativePathSpelling(project).startsWith(nativePathSpelling(owner) + path.sep)) &&
58 (process.platform === 'win32' ? (stat.mode & 0o222) === 0 : (stat.mode & 0o777) === 0o444);
59 } catch { return false; }
60}
62export const OWNED_READ_APPROVAL = Object.freeze({ hookSpecificOutput: Object.freeze({ hookEventName: 'PreToolUse',
63 permissionDecision: 'allow', permissionDecisionReason: '[autoplan] Reading an /autoplan section or phase artifact.' }) });
65/** The guard's own verdict wins: only a parent Read the guard left without any output is approved. */
66export function approveOwnedRead(value: unknown, output: object, root: string): object {
67 if (Object.keys(output).length || value === null || typeof value !== 'object') return output;
68 const input = value as Record<string, any>;
69 if (input.tool_name !== 'Read' || input.agent_id || typeof input.cwd !== 'string' ||
70 !ownedAutoplanRead(input.tool_input?.file_path, input.cwd, root)) return output;
71 return OWNED_READ_APPROVAL;
72}