fix(factory-audit): flag unbraced plugin-root tokens and close hook check gaps

Why: PR #144 review round 4 reproduced hooks referencing $PLUGIN_ROOT or
${PLUGIN_ROOT} without a path separator passing the audit, although apm
only rewrites ${TOKEN}/ and the deployed hook points nowhere.

- FAIL unbraced or unseparated plugin-root tokens
- check the exec bit for scripts run via an interpreter -c string
- skip env NAME=value prefixes when locating bare relative paths
- correct input: and empty-frontmatter messages, depth-walk applyTo braces
- INFO on unrecognised targets; failing-case tests for untested checks
- document tiers, blind spots and crash exit 2; drop rtk from portable flow
- restore the after-a-hand-edit trigger; pin upstream apm source URL

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
This commit is contained in:
2026-09-29 08:00:17 +00:00
parent 5d0f988ed8
commit 9285b29e3c
11 changed files with 363 additions and 43 deletions

View File

@@ -129,7 +129,9 @@ def package_root_for(subdir):
return os.path.dirname(apm_dir)
FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
# The inner group is lazy-optional so an empty block (`---` directly followed
# by `---`) matches as empty rather than as no block at all.
FRONTMATTER_RE = re.compile(r'\A---[ \t]*\r?\n(?:(.*?)\r?\n)??---[ \t]*(?:\r?\n|\Z)', re.DOTALL)
def split_frontmatter(content):
@@ -141,7 +143,7 @@ def split_frontmatter(content):
fail(f"has no YAML frontmatter block (--- ... ---) — description and every other key live there — {fname}")
return None, content, False
try:
fm = yaml.safe_load(m.group(1))
fm = yaml.safe_load(m.group(1) or '')
except yaml.YAMLError as exc:
mark = getattr(exc, 'problem_mark', None)
where = f" at line {mark.line + 2}" if mark is not None else ''
@@ -252,6 +254,12 @@ ROOT_TOKEN_RE = re.compile(r'\$\{(' + '|'.join(ROOT_TOKENS) + r')\}')
# matches after an interpreter (`bash ./x.sh`) too.
APM_ROOT_REF_RE = re.compile(r'\$\{(?:' + '|'.join(ROOT_TOKENS) + r')\}([\\/][^\s"\']+)')
APM_REL_REF_RE = re.compile(r'(\.[\\/][^\s"\']+)')
# A plugin-root token apm never rewrites: unbraced, so its pattern cannot see it.
UNBRACED_ROOT_RE = re.compile(r'\$(?:CLAUDE_|CURSOR_|KIRO_)?PLUGIN_ROOT\b')
# A NAME=value shell assignment before the command, bare or after `env`.
ASSIGN_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
# env options that consume the next token as their value.
ENV_VALUE_OPTS = {'-u', '--unset', '-C', '--chdir'}
# An interpreter whose first operand is the script it runs. A reference in
@@ -276,16 +284,33 @@ def _prefix_tokens(prefix):
return [t.strip('"\'') for t in prefix.split()]
def _command_start(tokens):
"""Index of the token that actually runs: past leading NAME=value
assignments and an `env` with its options and assignments."""
i = 0
while i < len(tokens) and ASSIGN_RE.match(tokens[i]):
i += 1
if i < len(tokens) and os.path.basename(tokens[i]) == 'env':
i += 1
while i < len(tokens):
tok = tokens[i]
if tok in ENV_VALUE_OPTS:
i += 2
elif tok.startswith('-') or ASSIGN_RE.match(tok):
i += 1
else:
break
return i
def _interp_arg_index(tokens):
"""(index, is_command_string) of the script operand after a known
interpreter (optionally behind `env`), or None when the command does not
interpreter (optionally behind assignments or `env`), or None when the command does not
open with one. Option flags are skipped (`bash -e x.sh`, `python3 -u x.py`);
for a sh-family `-c` the operand is the command string, whose own first
token is the script (`sh -c 'scripts/x.sh'`). Inline code (`python3 -c`,
`node -e`) has no script operand."""
i = 0
if tokens and os.path.basename(tokens[0]) == 'env':
i = 1
i = _command_start(tokens)
if not (len(tokens) > i and os.path.basename(tokens[i]) in INTERPRETERS):
return None
interp = os.path.basename(tokens[i])
@@ -308,12 +333,21 @@ def _interp_arg_index(tokens):
def _position(prefix):
"""(is_first_token, is_interpreter_arg) for a reference preceded by prefix."""
"""(is_direct, is_interpreter_arg) for a reference preceded by prefix.
Direct means the reference is what runs: the command's first token, or the
first token of a sh-family `-c` command string (`bash -c "./x.sh"`)."""
toks = [t for t in _prefix_tokens(prefix) if t]
if not toks:
return True, False
slot = _interp_arg_index(toks)
return False, slot is not None and slot[0] == len(toks)
while True:
if _command_start(toks) == len(toks):
return True, False
slot = _interp_arg_index(toks)
if slot is None:
return False, False
idx, is_command_string = slot
if is_command_string and idx <= len(toks):
toks = toks[idx:]
continue
return False, idx == len(toks)
def is_handler(h):
@@ -327,7 +361,7 @@ def is_handler(h):
def extract_script_refs(cmd, pkg_root, where):
"""Return (kind, relpath, is_first_token, is_interpreter_arg) for each
"""Return (kind, relpath, is_direct, is_interpreter_arg) for each
package-relative reference apm would rewrite, reading the command exactly
as apm does. kind is 'root' for a ${*_PLUGIN_ROOT} token, 'rel' for a
./path, 'up' for a ../path. A token apm reads wrongly — split-quoted, or a
@@ -339,6 +373,10 @@ def extract_script_refs(cmd, pkg_root, where):
start, end = m.start(), m.end()
if end < len(cmd) and cmd[end] in '"\'' and cmd[end + 1:end + 2] in ('/', '\\'):
fail(f"script path '{cmd[start:]}' splits the quote after ${{{m.group(1)}}} — apm rewrites only a path that follows the token directly, so this one deploys unrewritten and unbundled; quote the whole token: \"${{PLUGIN_ROOT}}/<path>\" — {where}")
elif cmd[end:end + 1] not in ('/', '\\'):
fail(f"${{{m.group(1)}}} is not followed directly by / or \\ — apm rewrites the token only as the head of a path (${{PLUGIN_ROOT}}/<path>), so here it deploys unrewritten and expands to nothing on most targets — {where}")
for m in UNBRACED_ROOT_RE.finditer(cmd):
fail(f"unbraced {m.group(0)} — apm rewrites only the braced ${{PLUGIN_ROOT}}/<path> form, so this deploys unrewritten and the script is not bundled; write ${{{m.group(0)[1:]}}}/<path> — {where}")
for m in APM_ROOT_REF_RE.finditer(cmd):
start, end = m.start(), m.end()
opener = cmd[start - 1] if start > 0 and cmd[start - 1] in '"\'' else None
@@ -397,7 +435,10 @@ def check_unanchored_script(cmd, pkg_root, where):
toks = command_tokens(cmd)
if not toks:
return
slots = [0]
start = _command_start(toks)
if start >= len(toks):
return
slots = [start]
arg = _interp_arg_index(toks)
if arg is not None and arg[0] < len(toks):
if arg[1]:
@@ -414,7 +455,7 @@ def check_unanchored_script(cmd, pkg_root, where):
# In the first slot an extension-less absolute path outside the
# package (`/usr/bin/env`, `/bin/bash`) is the host's interpreter;
# in the interpreter-argument slot it is the script being run.
if inside or SCRIPT_EXT_RE.search(tok) or idx > 0:
if inside or SCRIPT_EXT_RE.search(tok) or idx > start:
fail(f"script '{tok}' is an absolute path — apm neither bundles nor rewrites it, so it breaks on every other machine; reference it as ${{PLUGIN_ROOT}}/<path> — {where}")
continue
if '/' in tok:
@@ -424,7 +465,7 @@ def check_unanchored_script(cmd, pkg_root, where):
break
def check_script(kind_, rel, first, interp_arg, pkg_root, where):
def check_script(kind_, rel, direct, interp_arg, pkg_root, where):
if not rel:
return
# apm's ./ pattern also matches plain arguments — a cwd directory
@@ -433,7 +474,7 @@ def check_script(kind_, rel, first, interp_arg, pkg_root, where):
# meant, so a ./ or ../ match is held to the script rules only in command
# position or when it names a script by extension (or a package entry that
# is not a file).
strong = kind_ == 'root' or first or interp_arg or bool(SCRIPT_EXT_RE.search(rel))
strong = kind_ == 'root' or direct or interp_arg or bool(SCRIPT_EXT_RE.search(rel))
if kind_ == 'up':
in_pkg = os.path.exists(os.path.join(pkg_root, rel)) and not os.path.isfile(os.path.join(pkg_root, rel))
if strong or in_pkg:
@@ -468,7 +509,7 @@ def check_script(kind_, rel, first, interp_arg, pkg_root, where):
else:
suggest(f"argument './{rel}' matches apm's ./ script pattern but names no package file — apm will warn 'Hook script not found' and leave it unrewritten; harmless if it is a path in the consumer's working directory — {where}")
return
if first and not os.access(found, os.X_OK):
if direct and not os.access(found, os.X_OK):
fail(f"script '{rel}' is run directly but is not executable — chmod +x it, or invoke it through an interpreter — {where}")
@@ -510,7 +551,10 @@ def package_targets(pkg_root):
tokens = {TARGET_ALIASES.get(t, t) for t in tokens if t}
if not tokens or 'all' in tokens:
return every
return tokens & HOOK_TARGETS
known = tokens & HOOK_TARGETS
if not known:
info(f"targets: in apm.yml names no hook target apm 0.28.0 recognises ({', '.join(sorted(tokens))}) — event names were checked against no harness; apm's hook targets are {', '.join(sorted(HOOK_TARGETS))} — {fname}")
return known
def check_event(event, deploys_to):
@@ -653,8 +697,8 @@ def audit_hook():
continue
if '${CLAUDE_PLUGIN_ROOT}' in cmd:
uses_claude_token = True
for kind_, rel, first, interp_arg in extract_script_refs(cmd, pkg_root, where):
check_script(kind_, rel, first, interp_arg, pkg_root, where)
for kind_, rel, direct, interp_arg in extract_script_refs(cmd, pkg_root, where):
check_script(kind_, rel, direct, interp_arg, pkg_root, where)
check_unanchored_script(cmd, pkg_root, where)
if uses_claude_token:
@@ -692,6 +736,28 @@ def split_top_level(value):
return [s.strip() for s in segs if s.strip()]
def _balanced(glob):
# A depth walk per bracket kind: a closer before its opener (`}{`) is as
# unbalanced as a missing one, which equal counts would not catch.
for opener, closer in ('{}', '[]'):
depth, i = 0, 0
while i < len(glob):
c = glob[i]
if c == '\\':
i += 2
continue
if c == opener:
depth += 1
elif c == closer:
depth -= 1
if depth < 0:
return False
i += 1
if depth:
return False
return True
def check_apply_to(apply_to):
if isinstance(apply_to, list):
entries = [e for e in apply_to if e is not None and str(e).strip()]
@@ -706,7 +772,7 @@ def check_apply_to(apply_to):
return False
ok = True
for g in globs:
if g.count('{') != g.count('}') or g.count('[') != g.count(']'):
if not _balanced(g):
fail(f"applyTo glob '{g}' has unbalanced braces or brackets — it matches nothing, so the rule never fires — {fname}")
ok = False
return ok
@@ -850,7 +916,7 @@ def audit_prompt():
for m in INPUT_REF_RE.finditer(body):
if m.group(1) not in used:
used.append(m.group(1))
if used and not declared:
if used and fm.get('input') is None:
fail(f"body uses {', '.join('${input:' + u + '}' for u in used)} but no input: is declared — apm rewrites references only when input: names them, so Claude receives the literal text — {fname}")
else:
for u in used: