/** * Approval gates A/B/C of the upstream loop: the pure decision core plus the * approval.md / plan io it needs. * * No DSH runtime is imported here. Every decision function takes plain values; * where it must touch the filesystem it does so only through an explicit * repository root and only synchronously (`readFileSync` / `existsSync`). * * Failure policy mirrors the bash hooks: a call that references no plan is * never blocked, while a *referenced* plan that cannot be read or tied to an * approved change is denied (fail closed). Parsing never throws: malformed * YAML or markdown degrades to "missing", which the gates reject. */ import { createHash } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' export const APPROVAL_FILE = 'approval.md' export const PLANS_DIR = join('docs', 'superpowers', 'plans') export const CHANGE_DIR = join('openspec', 'changes') export const SDD_DIR = join('.superpowers', 'sdd') const SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/' const APPROVAL_DENY = 'approval.md is written only by /approve' const PLAN_NO_APPROVAL = 'no plan approval' const PLAN_CHANGED = 'plan changed after approval' const PLAN_CHANGED_DENY = 'plan changed after approval; re-approve' /** Scalar keys of each approval.md section, in serialization order. */ const SECTION_FIELDS = { design: ['by', 'at'], plan: ['by', 'at', 'sha256'], } /** `openspec/changes/` anywhere in a path (also inside an absolute path). */ const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u /** `.superpowers/sdd/` anywhere in a path or a prompt. */ const SDD_PATH = /(?:^|\/)\.superpowers\/sdd\/([^/\s]+)/u /** A direct plan reference; the match ends at `.md`, prose may follow it. */ const DIRECT_PLAN = /^docs\/superpowers\/plans\/([^/\s"'`()<>[\]{}]+\.md)/u const DIRECT_PREFIX = 'docs/superpowers/plans/' const SDD_PREFIX = '.superpowers/sdd/' /** * Characters that end a path token when scanning a prompt backwards. `=` * separates `--flag=` and `:` ends a prose label like `Plan:`. */ const TOKEN_STOP = new Set([ ' ', '\t', '\n', '\r', '"', "'", '`', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', ':', '=', '&', '|', ]) function isFile(path) { try { return statSync(path).isFile() } catch { return false } } function readText(path) { try { return readFileSync(path, 'utf8') } catch { return null } } /** Strip a comment / quotes / backticks from one yaml scalar. */ function scalar(raw) { let value = String(raw ?? '').trim() const comment = value.search(/\s#/u) if (comment !== -1 && !/^['"`]/u.test(value)) value = value.slice(0, comment).trim() if ( (value.startsWith('`') && value.endsWith('`')) || (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1).trim() } return value } /** `openspec/changes/` when the value points into a change, else null. */ function changeSpec(value) { const match = CHANGE_PATH.exec(value) if (!match) return null const id = match[1] if (id === '.' || id === '..') return null return `openspec/changes/${id}` } function isInside(parent, child) { const rel = relative(parent, child) return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) } /** * Parse the tolerant yaml subset used by approval.md: top-level `design:` and * `plan:` blocks with scalar `by` / `at` / `sha256` children. A section key is * kept even when its block is empty; unknown sections and malformed lines are * ignored. Any non-string input yields `{}` — the caller passes null when the * file is missing. */ export function parseApproval(text) { const parsed = {} if (typeof text !== 'string' || text === '') return parsed let section = null for (const rawLine of text.split(/\r?\n/u)) { const line = rawLine.replace(/[ \t]+$/u, '') if (line.trim() === '' || /^\s*#/u.test(line)) continue const top = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line) if (top) { // Own-key check, not `in`: a crafted `__proto__:`/`constructor:` line // must be ignored instead of touching the prototype. const known = Object.prototype.hasOwnProperty.call(SECTION_FIELDS, top[1]) section = known ? {} : null if (section) parsed[top[1]] = section continue } if (section === null) continue const child = /^\s+([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line) if (!child) continue const value = scalar(child[2]) if (value !== '') section[child[1]] = value } return parsed } /** * Deterministic approval.md text: sections in `design`, `plan` order, fields * in a fixed order, one blank line between sections, no trailing spaces, and * a final newline. An absent or non-object section is skipped. */ export function serializeApproval(parsed) { const sections = [] for (const [section, fields] of Object.entries(SECTION_FIELDS)) { const source = parsed?.[section] if (!source || typeof source !== 'object') continue const lines = [`${section}:`] for (const field of fields) { const value = source[field] if (value === undefined || value === null) continue const text = String(value).trim() if (text === '') continue lines.push(` ${field}: ${text}`) } sections.push(lines.join('\n')) } return sections.length === 0 ? '' : `${sections.join('\n\n')}\n` } /** * Normalized spec path named by the plan's `**Spec:**` line, or null. * * Backticks, a markdown link target and a trailing slash are stripped. A path * into `openspec/changes//...` is normalized to `openspec/changes/`; * any other path (legacy `docs/superpowers/specs/...`) is returned as written * so the caller can reject it explicitly (D-9). */ export function extractSpec(content) { if (typeof content !== 'string' || content === '') return null const match = /^[ \t]*\*\*Spec:\*\*[ \t]*(.+?)[ \t]*$/mu.exec(content) if (!match) return null let value = match[1].trim() const backtick = /^`([^`]+)`/u.exec(value) const link = /^\[[^\]]*\]\(([^)]+)\)/u.exec(value) if (backtick) value = backtick[1] else if (link) value = link[1] else value = value.split(/\s+/u)[0] value = value .trim() .replace(/^[`"']+/u, '') .replace(/[`"']+$/u, '') .replace(/^\.\//u, '') .replace(/[/.,;:]+$/u, '') if (value === '') return null return changeSpec(value) ?? value } /** * Change id of a spec path: `openspec/changes/(/...)` -> ``. * Legacy `docs/superpowers/specs/...` and everything else -> null. */ export function resolveChangeFromSpec(specPath) { if (typeof specPath !== 'string' || specPath === '') return null const change = changeSpec(specPath) return change === null ? null : change.slice('openspec/changes/'.length) } /** * Plan file behind an sdd ledger path: `.superpowers/sdd/(/...)` -> * `/docs/superpowers/plans/.md`, when that file exists. */ export function resolvePlanFromSdd(sddPath, repoRoot) { if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null const match = SDD_PATH.exec(sddPath) if (!match) return null const name = match[1].replace(/[/.,;:]+$/u, '') if (name === '' || name === '.' || name === '..') return null const planPath = join(resolve(repoRoot), PLANS_DIR, `${name}.md`) return isFile(planPath) ? planPath : null } /** sha256 hex of the plan content; '' when the file cannot be read. */ export function hashPlan(planPath) { try { return createHash('sha256').update(readFileSync(planPath)).digest('hex') } catch { return '' } } /** * Is the plan approved for its current content (D-10)? * Fails with `no plan approval` when the section or its sha256 is missing and * with `plan changed after approval` when the hash does not match (a missing * plan file is a hash mismatch by construction). */ export function planApproved(approval, planPath) { const plan = approval?.plan if (!plan || typeof plan !== 'object') return { ok: false, reason: PLAN_NO_APPROVAL } const expected = typeof plan.sha256 === 'string' ? plan.sha256.trim().toLowerCase() : '' if (expected === '') return { ok: false, reason: PLAN_NO_APPROVAL } const actual = hashPlan(planPath) if (actual === '' || actual !== expected) return { ok: false, reason: PLAN_CHANGED } return { ok: true, reason: '' } } function readApproval(repoRoot, changeId) { return parseApproval(readText(join(repoRoot, CHANGE_DIR, changeId, APPROVAL_FILE))) } /** * Does approval.md carry a real design approval? An empty `design:` block is * treated as no approval (R-4/T1.6f): the section must carry the `by` and `at` * scalars `/approve` writes, so a bare header cannot unlock the plan gate. */ export function hasDesignApproval(approval) { const design = approval?.design if (!design || typeof design !== 'object') return false return ( typeof design.by === 'string' && design.by !== '' && typeof design.at === 'string' && design.at !== '' ) } /** * Gate A: a plan may only be written for a change whose design is approved. * The `**Spec:**` reference is mandatory and must resolve to an openspec * change (D-9); a plan written elsewhere is not gated. */ export function checkPlanWrite({ filePath, content, repoRoot } = {}) { if (typeof filePath !== 'string' || filePath === '') return {} const root = resolve(repoRoot || '.') const target = resolve(root, filePath) if (!isInside(join(root, PLANS_DIR), target)) return {} const changeId = resolveChangeFromSpec(extractSpec(content)) if (changeId === null) return { deny: SPEC_DENY } if (!hasDesignApproval(readApproval(root, changeId))) { return { deny: `design not approved; run /approve ${changeId} design` } } return {} } /** * Gate C: `approval.md` is written only by `/approve`, never by a tool (D-1). * Applies wherever the file lives, not only inside a change directory. */ export function checkApprovalWrite({ filePath, repoRoot } = {}) { if (typeof filePath !== 'string' || filePath === '') return {} const target = resolve(resolve(repoRoot || '.'), filePath) if (basename(target) === APPROVAL_FILE) return { deny: APPROVAL_DENY } return {} } function tokenStart(text, index) { let start = index while (start > 0 && !TOKEN_STOP.has(text[start - 1])) start -= 1 return start } /** * All plan references in one string, in textual order: a direct * `docs/superpowers/plans/x.md` path (absolute or repository-relative) or an * `.superpowers/sdd/x/` ledger path. Empty when the string references none. */ function planReferences(text) { if (typeof text !== 'string' || text === '') return [] const found = [] for (let at = text.indexOf(DIRECT_PREFIX); at !== -1; at = text.indexOf(DIRECT_PREFIX, at + 1)) { const match = DIRECT_PLAN.exec(text.slice(at)) if (!match) continue const start = tokenStart(text, at) found.push({ index: at, path: text.slice(start, at + match[0].length) }) } for (let at = text.indexOf(SDD_PREFIX); at !== -1; at = text.indexOf(SDD_PREFIX, at + 1)) { const match = SDD_PATH.exec(text.slice(at)) if (!match) continue const name = match[1].replace(/[/.,;:]+$/u, '') if (name !== '' && name !== '.' && name !== '..') found.push({ index: at, name }) } return found.sort((left, right) => left.index - right.index) } /** Deny text for an unapproved plan; `` stays literal when unknown. */ function denyPlanNotApproved(changeId) { return { deny: `plan not approved; run /approve ${changeId ?? ''} plan` } } /** Plan path a parsed reference points to (the file may be missing). */ function referencePlanPath(reference, repoRoot) { if (typeof reference.name === 'string') { return ( resolvePlanFromSdd(`${SDD_PREFIX}${reference.name}`, repoRoot) ?? join(repoRoot, PLANS_DIR, `${reference.name}.md`) ) } return isAbsolute(reference.path) ? resolve(reference.path) : resolve(repoRoot, reference.path) } /** * Full gate-B chain for one referenced plan: read it, take its `**Spec:**`, * resolve the change, verify the plan approval against the file content. * Missing/spec-less plans fail closed. */ function planVerdict(repoRoot, planPath) { const content = readText(planPath) if (content === null) return denyPlanNotApproved() const changeId = resolveChangeFromSpec(extractSpec(content)) if (changeId === null) return denyPlanNotApproved() const verdict = planApproved(readApproval(repoRoot, changeId), planPath) if (verdict.ok) return {} if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId) return { deny: PLAN_CHANGED_DENY } } /** * Gate B: a dispatch may only start when every plan it references is approved * for its current content (D-10). The check covers the prompt and, when * given, the workflow script; the first failing reference denies, so an * approved plan cannot mask a later unapproved one. No plan reference at all * means no gate. */ export function checkDispatch({ prompt, script, repoRoot } = {}) { const root = resolve(repoRoot || '.') const references = [...planReferences(prompt), ...planReferences(script)] for (const reference of references) { const verdict = planVerdict(root, referencePlanPath(reference, root)) if (verdict.deny !== undefined) return verdict } return {} } /** * Gate B for Mode 1 starts: a write under `.superpowers/sdd//` * (ledger, briefs) is allowed only when that plan is approved for its current * content (D-7). A missing plan file fails closed with `plan not found`. */ export function checkSddWrite({ filePath, repoRoot } = {}) { if (typeof filePath !== 'string' || filePath === '') return {} const root = resolve(repoRoot || '.') const target = resolve(root, filePath) const sddDir = join(root, SDD_DIR) if (!isInside(sddDir, target)) return {} const segments = relative(sddDir, target).split(sep) if (segments.length < 2) return {} const name = segments[0] if (name === '' || name === '.' || name === '..') return {} const planPath = resolvePlanFromSdd(`${SDD_PREFIX}${name}`, root) if (planPath === null) return { deny: `plan not found: ${name}` } return planVerdict(root, planPath) } /** * Gates A and C for a list of absolute write targets (from * `harness_target.py targets`). First denial wins. A target under plans/ has * no content here, so it can never carry a `**Spec:**` line and is denied — * exactly the bash-heredoc case the tool-level gates would otherwise miss. */ export function checkBashTargets(targets, repoRoot) { if (!Array.isArray(targets)) return {} const root = resolve(repoRoot || '.') for (const raw of targets) { if (typeof raw !== 'string' || raw === '') continue const target = resolve(root, raw) const approval = checkApprovalWrite({ filePath: target, repoRoot: root }) if (approval.deny !== undefined) return approval const plan = checkPlanWrite({ filePath: target, content: null, repoRoot: root }) if (plan.deny !== undefined) return plan } return {} }