diff --git a/actions/docs-sync/docs-sync.sh b/actions/docs-sync/docs-sync.sh index f6a3cff..ba42647 100644 --- a/actions/docs-sync/docs-sync.sh +++ b/actions/docs-sync/docs-sync.sh @@ -39,11 +39,24 @@ set -euo pipefail # vendored copy — mirror means mirror, or "extra" files accumulate as # unverified doctrine). # +# AND THE MIRROR IS PLAIN FILES. A symlink committed anywhere this tool +# touches — a vendored path, a subdirectory, `.ceremony/` itself, the root +# AGENTS.md — redirects the tool outside the mirror: cp writes THROUGH the +# link (anywhere the CI token can reach), cmp reads through it and reports +# the target's bytes as the mirror's, and a `find -type f` scan skips link +# nodes entirely, so the stray poses as doctrine while staying invisible +# (PR #43's review round, both findings reproduced). So both modes refuse +# any non-regular node before touching anything: the fix for a symlink is +# a human deleting it, never a tool following it. +# # TWO FILES ARE SPECIAL, both deliberately: # * `.ceremony/README.md` is GENERATED here — the machine-managed marker # plus where the pin lives — instead of per-file banners, so every # vendored file stays byte-identical to its source and the check is a -# plain cmp, never a strip-the-banner parse. +# plain cmp, never a strip-the-banner parse. It is verified like +# everything else (against the generated text, not the source tree): +# the file that says "a hand edit goes red" must itself go red when +# hand-edited, or the marker is the one unverified spot in the mirror. # * the consumer's ROOT AGENTS.md is scaffolded once by --fix and never # overwritten. Agent harnesses auto-load root AGENTS.md (the cross-agent # convention), so the stub is what makes "you are a reviewer here" a @@ -221,6 +234,44 @@ a drill means here, code conventions) live in CONTRIBUTING.md. EOF } +# --- the mirror is plain files (see the header; PR #43's review round) ---------- + +# Refusals, not repairs, in BOTH modes — deliberately unlike drift, where +# --fix is the advertised cure: repairing a symlink means either deleting a +# node that points somewhere or writing through it, and a tool must do +# neither on its own. -L before -d/-f everywhere: the test that follows the +# link is exactly the bug. +guard_plain_tree() { + local offenders + if [ -L "$MIRROR" ]; then + die "docs-sync: $MIRROR is a symlink, not a directory — a linked mirror" \ + " redirects every write outside the tree this tool is allowed to" \ + " touch. Refusing both modes: delete the symlink, then re-run" \ + " docs-sync --fix." + fi + if [ -d "$MIRROR" ]; then + offenders="$(find "$MIRROR" -mindepth 1 ! -type f ! -type d | LC_ALL=C sort)" + [ -z "$offenders" ] || die \ + "docs-sync: non-regular node(s) in the mirror — a symlink (or fifo," \ + " socket, …) under $MIRROR/ makes cp write and cmp read outside the" \ + " mirror, and hides from the file scan. Refusing both modes; delete" \ + " these by hand, then re-run docs-sync --fix:" \ + "$offenders" + fi + if [ -L AGENTS.md ]; then + die "docs-sync: the root AGENTS.md is a symlink — the scaffold and the" \ + " existence check must never resolve through a link (a dangling one" \ + " would even make --fix write through it). Refusing both modes:" \ + " replace the symlink with a regular file (or delete it and let" \ + " docs-sync --fix scaffold the stub)." + fi + if [ -e AGENTS.md ] && [ ! -f AGENTS.md ]; then + die "docs-sync: the root AGENTS.md exists but is not a regular file —" \ + " nothing this tool could do to it is right. Refusing both modes:" \ + " remove it, then re-run docs-sync --fix to scaffold the stub." + fi +} + # --- check ---------------------------------------------------------------------- run_check() { @@ -253,6 +304,18 @@ run_check() { " $MIRROR/ must be vendored and verified, or it poses as doctrine" \ " without being checked. Fix: run docs-sync --fix (it deletes orphans)." done < <(mirror_files) + + # The README is machine-written against generated text, so it is + # machine-verified against the same text — the marker that warns "a hand + # edit goes red" is not itself an unverified hole (kimi-bot, PR #43). + if [ ! -f "$MIRROR/$README_NAME" ]; then + complain "docs-sync: $MIRROR/$README_NAME is missing — the machine-managed" \ + " marker is part of the mirror. Fix: run docs-sync --fix." + elif ! readme_content | cmp -s - "$MIRROR/$README_NAME"; then + complain "docs-sync: $MIRROR/$README_NAME has drifted from its generated" \ + " content — the README is machine-written, and a hand edit here is" \ + " exactly what its own text warns against. Fix: run docs-sync --fix." + fi fi # Existence only, content free: the stub is per-repo the moment the repo @@ -313,6 +376,7 @@ run_fix() { fi } +guard_plain_tree case "$mode" in check) run_check ;; fix) run_fix ;; diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh index a632d9e..6987304 100644 --- a/test/docs-sync.test.sh +++ b/test/docs-sync.test.sh @@ -176,6 +176,103 @@ check "root AGENTS.md missing → check fails, teaching --fix" 1 \ "run docs-sync --fix" in_consumer fresh --check --source "$SRC" in_consumer fresh --fix --source "$SRC" >/dev/null +# --- the README is machine-verified, not just machine-written ------------------- +# The marker that says "a hand edit goes red" must itself go red when +# hand-edited (kimi-bot, PR #43's review round). + +printf 'hand edit\n' >>"$TMP/fresh/.ceremony/README.md" +check "hand-edited README → check fails naming it" 1 ".ceremony/README.md" \ + in_consumer fresh --check --source "$SRC" +check "--fix rewrites the drifted README" 0 "wrote .ceremony/README.md" \ + in_consumer fresh --fix --source "$SRC" + +rm "$TMP/fresh/.ceremony/README.md" +check "missing README → check fails naming it" 1 \ + ".ceremony/README.md is missing" in_consumer fresh --check --source "$SRC" +in_consumer fresh --fix --source "$SRC" >/dev/null +check "README repaired → check green again" 0 "exact mirror" \ + in_consumer fresh --check --source "$SRC" + +# --- the mirror is plain files: symlinks and friends refused --------------------- +# PR #43's review round (codex-bot + kimi-bot, independent repros): cp +# writes THROUGH a committed link, cmp reads through it, and a `find +# -type f` scan cannot even see it. Both modes refuse; every row with a +# victim asserts the victim untouched. + +consumer sneaky 0.3.0 +in_consumer sneaky --fix --source "$SRC" >/dev/null +printf 'victim v1\n' >"$TMP/sneaky/victim.md" + +rm "$TMP/sneaky/.ceremony/RULES.md" +ln -s ../victim.md "$TMP/sneaky/.ceremony/RULES.md" +check "vendored path as symlink → check refuses naming it" 1 \ + ".ceremony/RULES.md" in_consumer sneaky --check --source "$SRC" +check "vendored path as symlink → fix refuses (never writes through)" 1 \ + "non-regular" in_consumer sneaky --fix --source "$SRC" +check "the link's target is untouched" 0 "victim v1" cat "$TMP/sneaky/victim.md" +rm "$TMP/sneaky/.ceremony/RULES.md" +in_consumer sneaky --fix --source "$SRC" >/dev/null + +# A stray link is exactly what the -type f extra-file scan was blind to: +# unlisted doctrine, previously invisible. +ln -s ../victim.md "$TMP/sneaky/.ceremony/STRAYLINK.md" +check "stray symlink (invisible to -type f) → check refuses" 1 \ + "STRAYLINK.md" in_consumer sneaky --check --source "$SRC" +check "stray symlink → fix refuses too (no silent deletion of a link)" 1 \ + "STRAYLINK.md" in_consumer sneaky --fix --source "$SRC" +rm "$TMP/sneaky/.ceremony/STRAYLINK.md" + +mkfifo "$TMP/sneaky/.ceremony/PIPE" +check "a fifo in the mirror → refused, not read" 1 "non-regular" \ + in_consumer sneaky --check --source "$SRC" +rm "$TMP/sneaky/.ceremony/PIPE" + +rm -rf "$TMP/sneaky/.ceremony/guide" +mkdir -p "$TMP/sneaky/elsewhere" +ln -s ../elsewhere "$TMP/sneaky/.ceremony/guide" +check "vendored subdirectory as symlink → fix refuses" 1 \ + ".ceremony/guide" in_consumer sneaky --fix --source "$SRC" +check "nothing was written into the linked directory's target" 1 "" \ + test -e "$TMP/sneaky/elsewhere/DEEP.md" +rm "$TMP/sneaky/.ceremony/guide" +in_consumer sneaky --fix --source "$SRC" >/dev/null +check "sneaky consumer repaired → check green" 0 "exact mirror" \ + in_consumer sneaky --check --source "$SRC" + +consumer linked-mirror 0.3.0 +mkdir -p "$TMP/linked-mirror-target" +ln -s ../linked-mirror-target "$TMP/linked-mirror/.ceremony" +check ".ceremony/ itself a symlink → check refuses" 1 "symlink" \ + in_consumer linked-mirror --check --source "$SRC" +check ".ceremony/ itself a symlink → fix refuses" 1 "symlink" \ + in_consumer linked-mirror --fix --source "$SRC" +check "the link's target directory stayed empty" 0 "" \ + test -z "$(ls -A "$TMP/linked-mirror-target")" + +consumer linked-stub 0.3.0 +printf 'stub victim\n' >"$TMP/linked-stub/other.md" +ln -s other.md "$TMP/linked-stub/AGENTS.md" +check "root AGENTS.md as symlink → check refuses" 1 \ + "AGENTS.md is a symlink" in_consumer linked-stub --check --source "$SRC" +check "root AGENTS.md as symlink → fix refuses" 1 \ + "AGENTS.md is a symlink" in_consumer linked-stub --fix --source "$SRC" +check "the scaffold did not write through the link" 0 "stub victim" \ + cat "$TMP/linked-stub/other.md" + +# Dangling is the sharpest case: -e is false through a dangling link, so a +# naive `[ ! -e ] && scaffold` writes the stub through it. +rm "$TMP/linked-stub/AGENTS.md" +ln -s does-not-exist.md "$TMP/linked-stub/AGENTS.md" +check "dangling AGENTS.md symlink → fix refuses (would write through)" 1 \ + "symlink" in_consumer linked-stub --fix --source "$SRC" +check "nothing appeared at the dangling target" 1 "" \ + test -e "$TMP/linked-stub/does-not-exist.md" + +rm "$TMP/linked-stub/AGENTS.md" +mkdir "$TMP/linked-stub/AGENTS.md" +check "root AGENTS.md as a directory → refused, named" 1 \ + "not a regular file" in_consumer linked-stub --fix --source "$SRC" + # --- the action's wiring: inputs arrive as env vars ----------------------------- consumer env-wired 0.3.0