519 lines
18 KiB
JavaScript

/**
* 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 { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { basename, dirname, 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')
/**
* Subdirectories of a repository that may hold linked worktrees. A session in
* the main checkout must see the change/plan that live in one of them.
*/
const WORKTREE_DIRS = [join('.claude', 'worktrees'), '.wt']
const SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/<id>'
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/<id>` anywhere in a path (also inside an absolute path). */
const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u
/** `.superpowers/sdd/<plan-name>` 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=<path>` and `:` ends a prose label like `Plan:<path>`.
*/
const TOKEN_STOP = new Set([
' ',
'\t',
'\n',
'\r',
'"',
"'",
'`',
'(',
')',
'[',
']',
'{',
'}',
'<',
'>',
',',
';',
':',
'=',
'&',
'|',
])
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
export function isDir(path) {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* Nearest ancestor of `dir` (inclusive) that carries a `.git` entry: a
* directory in a normal checkout, a file in a linked worktree. Falls back to
* `dir` itself when nothing is found, so the gates also work in a fixture
* repository that was never `git init`-ed.
*/
export function findRepoRoot(dir) {
const start = resolve(dir || '.')
for (let current = start; ; current = dirname(current)) {
if (existsSync(join(current, '.git'))) return current
if (dirname(current) === current) return start
}
}
/**
* Roots the artifacts may live in, in a deterministic order: the repository
* root of the session, then its linked worktrees under `.claude/worktrees/*`
* and `.wt/*` (existing directories with a `.git` entry, names sorted).
*/
export function candidateRoots(repoRoot) {
const root = findRepoRoot(repoRoot)
const roots = [root]
for (const wtdir of WORKTREE_DIRS) {
let entries
try {
entries = readdirSync(join(root, wtdir), { withFileTypes: true })
} catch {
continue
}
const names = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
for (const name of names) {
const candidate = join(root, wtdir, name)
if (existsSync(join(candidate, '.git'))) roots.push(candidate)
}
}
return roots
}
/** First candidate root whose `openspec/changes/<id>` is a directory, or null. */
export function findChangeRoot(repoRoot, changeId) {
if (typeof changeId !== 'string' || changeId === '') return null
for (const candidate of candidateRoots(repoRoot)) {
if (isDir(join(candidate, CHANGE_DIR, changeId))) return candidate
}
return null
}
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/<id>` 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/<id>/...` is normalized to `openspec/changes/<id>`;
* 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/<id>(/...)` -> `<id>`.
* 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/<plan-name>(/...)` ->
* `<root>/docs/superpowers/plans/<plan-name>.md`, first existing root among
* `candidateRoots(repoRoot)` (the session root, then its linked worktrees).
*/
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
for (const root of candidateRoots(repoRoot)) {
const planPath = join(root, PLANS_DIR, `${name}.md`)
if (isFile(planPath)) return planPath
}
return 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)
const roots = candidateRoots(root)
if (!roots.some((candidate) => isInside(join(candidate, PLANS_DIR), target))) return {}
const changeId = resolveChangeFromSpec(extractSpec(content))
if (changeId === null) return { deny: SPEC_DENY }
const changeRoot = findChangeRoot(root, changeId) ?? root
if (!hasDesignApproval(readApproval(changeRoot, 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; `<id>` stays literal when unknown. */
function denyPlanNotApproved(changeId) {
return { deny: `plan not approved; run /approve ${changeId ?? '<id>'} plan` }
}
/**
* Repository-relative plan path of a direct reference: the session root first,
* then the linked worktrees (first existing file). When no root has the file,
* the session-root path is returned so the caller fails closed as before.
*/
function directPlanPath(reference, repoRoot) {
const local = resolve(repoRoot, reference.path)
if (isFile(local)) return local
for (const candidate of candidateRoots(repoRoot)) {
if (candidate === repoRoot) continue
const planPath = resolve(candidate, reference.path)
if (isFile(planPath)) return planPath
}
return local
}
/** 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) : directPlanPath(reference, repoRoot)
}
/**
* 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. The approval is read from the root
* where the change lives (a plan may live in a linked worktree).
*/
function planVerdict(repoRoot, planPath) {
const content = readText(planPath)
if (content === null) return denyPlanNotApproved()
const changeId = resolveChangeFromSpec(extractSpec(content))
if (changeId === null) return denyPlanNotApproved()
const changeRoot = findChangeRoot(repoRoot, changeId) ?? repoRoot
const verdict = planApproved(readApproval(changeRoot, 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/<plan-name>/`
* (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 = candidateRoots(root)
.map((candidate) => join(candidate, SDD_DIR))
.find((dir) => isInside(dir, target))
if (sddDir === undefined) 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 {}
}