diff --git a/.github/actions/term-wall/CONTRACT.md b/.github/actions/term-wall/CONTRACT.md new file mode 100644 index 0000000..e54e6f1 --- /dev/null +++ b/.github/actions/term-wall/CONTRACT.md @@ -0,0 +1,101 @@ +# The term wall — contract v1 + +Names this organisation does not use must not appear in any of its +repositories: not affirmed, not negated, not cited. The wall refuses +them, and the wall itself never carries them in any readable or +encoded form. + +## Instruments + +1. `.github/actions/term-wall/term-wall.sh` (run by the composite + action `.github/actions/term-wall`) — every repo's `ci` job runs it. +2. `ops/devlane/workflow/checks/term_wall.py` — the lane's local copy, + run by the commit-msg hook and by apply-push's guards. + +## The pattern is configuration, never tree content + +- The pattern is an extended, case-insensitive regular expression read + from the environment variable `TERM_WALL`. In CI the calling step + passes it: `env: TERM_WALL: ${{ vars.TERM_WALL }}` — a composite + action cannot read `vars` itself, so the action declares no default + and refuses when the step did not pass one. Locally the + Python check reads `TERM_WALL`, falling back to the gitignored file + `/ops/bin/term-wall.conf` (one line: the pattern). +- No tracked file may contain the pattern, a piece of it, or any + encoding of the names (hex, base64, bracket tricks, escapes). The + self-test's planted fault comes from `vars.TERM_WALL_PLANT` (a string + the pattern matches), never from the tree. +- An unset or empty pattern is a refusal, exit 2, stdout empty, one + line on stderr: `pattern: expected TERM_WALL set; found empty; + needed the org variable (CI) or ops/bin/term-wall.conf (local)`. + The wall never passes vacuously. + +## Surfaces (term-wall.sh) + +1. tracked content — every `git ls-files` path, binaries skipped, + case-insensitive; +2. tracked paths; +3. the commit messages of the change — `pull_request`: `base..head`; + `push`: `before..head`, or only the head commit when `before` is + all zeros — read from git (fetching what the checkout lacks), never + from an API: the action needs no token and declares none; +4. the pull request title and body (from the event payload); +5. the branch name (`GITHUB_HEAD_REF` for a PR, `GITHUB_REF_NAME` for + a push). + +Outside GitHub Actions (no `GITHUB_EVENT_PATH`), surfaces 1 and 2 run +against the current directory. The Python check covers surface 1 and +2 (`[--root DIR] [PATH ...]`), one message (`--message-file FILE`), a +range of commit messages (`--range BASE..HEAD`), or `--stdin`. + +## Outcomes, on the wire + +| exit | meaning | +|---|---| +| 0 | clean; exactly one summary line on stdout | +| 1 | at least one hit, every hit printed on stdout in the pinned format below; a surface that could not be read (fetch failed, payload unreadable) is itself a hit — could-not-look is never a pass | +| 2 | refusal; stdout empty, one stderr line `: expected …; found …; needed …` | + +Refusal classes: `pattern` (unset or empty), `git work tree` (not +inside one), `message` (missing message file), `range` (unresolvable). + +## Hit format, pinned + +One stdout line per hit: `: : `. The location never contains a +colon. The raw matched text never appears in any output. + +| surface | location | +|---|---| +| `content` | ` line ` | +| `path` | `` | +| `commit messages` | ` line ` | +| `pull request title` | `line ` | +| `pull request body` | `line ` | +| `branch name` | `` | +| `event payload` | `` — the hit when the payload cannot be read | +| `message` (`--message-file`) | `line ` | +| `range` (`--range`) | ` line ` | +| `stdin` | `line ` | + +## Self-test (the `.github` repo's own ci) + +With the calling step passing `env: TERM_WALL_PLANT: ${{ vars.TERM_WALL_PLANT }}` as the planted fault: a planted file fires +(exit 1, masked hit), a clean neighbour stays quiet (exit 0), and an +empty `TERM_WALL` refuses (exit 2). + +## Tests + +Tests execute the real instrument as a subprocess inside temporary git +repositories they create; nothing about the wall is mocked. They set +`TERM_WALL` explicitly to a test-only pattern (for example +`zz[q]orblat`) and plant matches of it, so no forbidden name exists +anywhere. They are deterministic and hermetic: no network, no sleeps, +no dependence on the caller's cwd, environment, or git identity +(configure user.name/user.email in each temp repo). Every test asserts +the exit code and the output shape. Push and pull-request events are +simulated with an event JSON file and the `GITHUB_*` variables. + +## Content surface, pinned + +The content surface is every blob the scanned commit's tree tracks, read from the object store, never from the working tree. Every blob is scanned bytewise; nothing tracked is unscannable. A symlink entry is scanned as the blob it is, its target path text, and is never followed. A blob that is not valid UTF-8 reports each hit with the location ` line `, `n` counting newline-separated segments from 1, and the third field `[binary blob]` in place of the line. A blob the wall cannot read is a refusal of class `git work tree`: `git work tree: expected a readable blob at ; found ; needed the object`. diff --git a/.github/actions/term-wall/action.yml b/.github/actions/term-wall/action.yml new file mode 100644 index 0000000..fbdd7b1 --- /dev/null +++ b/.github/actions/term-wall/action.yml @@ -0,0 +1,17 @@ +name: term wall +description: >- + Refuses names this organisation does not use — in tracked content, in + file paths, in the change's commit messages (read from git, never from + an API: no token needed or declared), in the pull request title and + body, and in the branch name. The pattern comes only from the + TERM_WALL environment variable the calling step passes from the + org-level Actions variable — a composite action cannot read vars + itself, so this action declares no default; an unset or empty + pattern is a refusal, never a pass. The wall never spells what it + refuses and masks every hit it prints. +runs: + using: composite + steps: + - name: term wall + shell: bash + run: bash "$GITHUB_ACTION_PATH/term-wall.sh" diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh new file mode 100755 index 0000000..64b8bbe --- /dev/null +++ b/.github/actions/term-wall/term-wall.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# term-wall.sh — names this organisation does not use, refused everywhere. +# +# The pattern is configuration, never tree content: it is read only from +# the TERM_WALL environment variable (in CI the calling step passes it, +# `env: TERM_WALL: ${{ vars.TERM_WALL }}` — a composite action cannot +# read `vars` itself, so the action declares no default). An unset or +# empty pattern is a refusal, exit 2 — the wall never passes vacuously. +# Every hit it prints is masked, so the log does not carry what the tree +# may not. +# +# Surfaces, in order: +# 1. tracked file content — every blob the scanned commit's tree +# tracks, read from the object store, never from the working tree, +# and scanned bytewise (case-insensitive): nothing tracked is +# unscannable. A symlink entry is scanned as the blob it is — its +# target path text — and never followed. A blob that is not valid +# UTF-8 reports each hit with "[binary blob]" in place of the line. +# 2. tracked file paths +# 3. the commit messages of the change — pull_request: base..head; +# push: before..head, or only the head commit when before is all +# zeros — read from git, fetching what the checkout lacks; never +# from an API: the action needs no token and declares none +# 4. the pull request title and body (from the event payload) +# 5. the branch name (GITHUB_HEAD_REF for a PR, GITHUB_REF_NAME for a push) +# +# Exit 0 clean, one summary line on stdout. Exit 1 on any hit, every hit +# on stdout as ": : " — the location never contains a colon; a surface +# that could not be read (fetch failed, payload unreadable) is itself a +# hit — could-not-look is never a pass. Exit 2 refusal: stdout empty, +# one line on stderr shaped "class: expected …; found …; needed …". +# +# Outside GitHub Actions (no GITHUB_EVENT_PATH) only surfaces 1 and 2 +# run, against the current directory. +set -uo pipefail + +refuse() { printf '%s\n' "$1" >&2; exit 2; } + +pat=${TERM_WALL:-} +[[ -n $pat ]] || refuse 'pattern: expected TERM_WALL set; found empty; needed the org variable (CI) or ops/bin/term-wall.conf (local)' + +# A pattern grep or sed cannot use would make every scan silently vacuous +# and could leak raw text past the mask — refuse it up front. +printf '' | grep -i -E -- "$pat" >/dev/null 2>&1 +[[ $? -le 1 ]] || refuse 'pattern: expected TERM_WALL to be an extended regular expression grep accepts; found one it rejects; needed a working pattern in the org variable (CI) or ops/bin/term-wall.conf (local)' +printf '' | sed -E "s/($pat)/[forbidden name]/Ig" >/dev/null 2>&1 \ + || refuse 'pattern: expected TERM_WALL usable in a sed substitution; found one sed rejects (an unescaped "/"?); needed a working pattern in the org variable (CI) or ops/bin/term-wall.conf (local)' + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ + || refuse 'git work tree: expected to run inside a git work tree; found none; needed a checkout (CI) or a repository directory (local)' + +rc=0 +mask() { sed -E "s/($pat)/[forbidden name]/Ig"; } + +emit() { # emit — one hit. Called only + # from the main shell, never from a pipeline stage, so the exit + # code it sets survives. + printf '%s: %s: %s\n' "$1" "$2" "$3" + rc=1 +} + +scan() { # scan — text as an argument, + # never a pipe: a hit must set rc in this shell, and a pipeline's + # stages run in subshells. Each hit's location is + # "line ". + local surface=$1 prefix=$2 text=$3 found line + [[ -n $text ]] || return 0 + found=$(printf '%s\n' "$text" | grep -i -n -E -- "$pat" 2>/dev/null | mask || true) + [[ -n $found ]] || return 0 + while IFS= read -r line; do + emit "$surface" "${prefix}line ${line%%:*}" "${line#*:}" + done <<< "$found" +} + +scan_name() { # scan_name — for surfaces whose location + # is the (masked) value itself: a path, a branch name. + local surface=$1 value=$2 masked + [[ -n $value ]] || return 0 + printf '%s\n' "$value" | grep -i -E -- "$pat" >/dev/null 2>&1 || return 0 + masked=$(printf '%s\n' "$value" | mask) + emit "$surface" "$masked" "$masked" +} + +# 1. tracked content — every blob of the scanned commit's tree, from the +# object store, bytewise (grep -a, never -I). A symlink entry (mode +# 120000) is a blob holding its target path and is never followed; a +# gitlink (type commit) is not a blob and has no content here. A blob +# the wall cannot read is a refusal — could-not-look is never a pass. +if git rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + git ls-tree -r HEAD >/dev/null 2>&1 \ + || refuse 'git work tree: expected a readable tree at HEAD; found git ls-tree cannot read it; needed the object' + while IFS= read -r -d '' entry; do + meta=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r _mode type oid <<< "$meta" + [[ $type == blob ]] || continue + if ! err=$(git cat-file -e "$oid" 2>&1); then + err=$(printf '%s' "${err:-git cat-file cannot read $oid}" | tr '\n' ' ') + refuse "$(printf 'git work tree: expected a readable blob at %s; found %s; needed the object' "$path" "$err" | mask)" + fi + if git cat-file blob "$oid" 2>/dev/null | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then + # NUL is valid UTF-8 (U+0000) but a shell variable cannot hold + # it — drop it before masking, never after. + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | tr -d '\000' | mask || true) + [[ -n $found ]] || continue + while IFS= read -r line; do + emit "content" "$path line ${line%%:*}" "${line#*:}" + done <<< "$found" + else + # Not valid UTF-8: never echo its bytes — only line numbers + # (newline-separated segments, from 1) and "[binary blob]". + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | cut -d: -f1 || true) + [[ -n $found ]] || continue + while IFS= read -r n; do + emit "content" "$path line $n" "[binary blob]" + done <<< "$found" + fi + done < <(git ls-tree -r -z HEAD 2>/dev/null) +fi + +# 2. tracked paths +paths=$(git ls-files 2>/dev/null | grep -i -E -- "$pat" 2>/dev/null | mask || true) +if [[ -n $paths ]]; then + while IFS= read -r line; do + emit "path" "$line" "$line" + done <<< "$paths" +fi + +# 3-5. the change itself, when running under Actions +if [[ -n ${GITHUB_EVENT_PATH:-} && -f ${GITHUB_EVENT_PATH:-} ]]; then + event=${GITHUB_EVENT_NAME:-} + payload_ok=1 + jq empty "$GITHUB_EVENT_PATH" >/dev/null 2>&1 || payload_ok=0 + if [[ $payload_ok -eq 0 ]]; then + emit "event payload" "$GITHUB_EVENT_PATH" "could not read the event payload (could-not-look is never a pass)" + fi + base="" head="" want_messages=0 + case $event in + pull_request|pull_request_target) + if [[ $payload_ok -eq 1 ]]; then + base=$(jq -r '.pull_request.base.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.pull_request.head.sha // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" + scan "pull request title" "" "$(jq -r '.pull_request.title // ""' "$GITHUB_EVENT_PATH" 2>/dev/null)" + scan "pull request body" "" "$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" 2>/dev/null)" + want_messages=1 + fi + scan_name "branch name" "${GITHUB_HEAD_REF:-}" + ;; + push) + if [[ $payload_ok -eq 1 ]]; then + base=$(jq -r '.before // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || base="" + head=$(jq -r '.after // empty' "$GITHUB_EVENT_PATH" 2>/dev/null) || head="" + want_messages=1 + fi + scan_name "branch name" "${GITHUB_REF_NAME:-}" + ;; + esac + if [[ $want_messages -eq 1 ]]; then + only_head=0 + [[ $event == push && ( -z $base || $base =~ ^0+$ ) ]] && only_head=1 + if [[ -z $head || ( $only_head -eq 0 && -z $base ) ]]; then + emit "commit messages" "$event" "could not resolve the change's commits from the event payload (could-not-look is never a pass)" + else + refs=("$head"); [[ $only_head -eq 0 ]] && refs=("$base" "$head") + # Fetch what the checkout lacks; a shallow clone would walk a + # truncated history without erroring, so unshallow it. + shallow=$(git rev-parse --is-shallow-repository 2>/dev/null) || shallow=false + missing=0 + for ref in "${refs[@]}"; do + git cat-file -e "$ref^{commit}" 2>/dev/null || missing=1 + done + fetch_failed=0 + if [[ $shallow == true || $missing -eq 1 ]]; then + fetch_opts=(--no-tags --quiet) + [[ $shallow == true ]] && fetch_opts+=(--unshallow) + if ! fetch_err=$(git fetch "${fetch_opts[@]}" origin "${refs[@]}" 2>&1); then + emit "commit messages" "${refs[*]}" "could not fetch the change from origin — $(printf '%s' "$fetch_err" | mask | tr '\n' ' ' | head -c 300) (could-not-look is never a pass)" + fetch_failed=1 + fi + fi + if [[ $fetch_failed -eq 0 ]]; then + if [[ $only_head -eq 1 ]]; then + range=$head + shas=$(git rev-list -n 1 "$head" 2>/dev/null); list_rc=$? + else + range="$base..$head" + shas=$(git rev-list "$base..$head" 2>/dev/null); list_rc=$? + fi + if [[ $list_rc -eq 0 ]]; then + while IFS= read -r sha; do + [[ -n $sha ]] || continue + if msg=$(git log -1 --format=%B "$sha" 2>/dev/null); then + scan "commit messages" "${sha:0:12} " "$msg" + else + emit "commit messages" "${sha:0:12}" "could not read the commit message from git (could-not-look is never a pass)" + fi + done <<< "$shas" + else + emit "commit messages" "$range" "could not read the commit messages from git (could-not-look is never a pass)" + fi + fi + fi + fi +fi + +if [[ $rc -eq 0 ]]; then + echo "term wall: clean — $(git ls-files 2>/dev/null | wc -l) tracked files, their paths, and the change's messages, title, body and branch name carry no forbidden name" +fi +exit "$rc" diff --git a/.github/actions/term-wall/tests/test_term_wall.py b/.github/actions/term-wall/tests/test_term_wall.py new file mode 100644 index 0000000..bdd2e43 --- /dev/null +++ b/.github/actions/term-wall/tests/test_term_wall.py @@ -0,0 +1,290 @@ +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "term-wall.sh" +PATTERN = "zz[q]orblat" +PLANT = "zzqor" + "blat" +REFUSAL = ( + "pattern: expected TERM_WALL set; found empty; needed the org variable (CI) " + "or ops/bin/term-wall.conf (local)" +) + + +class TermWallTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.home = self.root / "home" + self.home.mkdir() + self.git("init", "-q") + self.git("config", "user.name", "Term Wall Test") + self.git("config", "user.email", "term-wall@example.invalid") + self.write("clean.txt", "ordinary text\n") + self.commit("initial clean commit") + + def tearDown(self): + self.temporary.cleanup() + + def environment(self, **values): + env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(self.home), + "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + "TERM_WALL": PATTERN, + } + env.update({key: str(value) for key, value in values.items()}) + return env + + def git(self, *arguments, cwd=None): + return subprocess.run( + ["git", *arguments], + cwd=cwd or self.repo, + env=self.environment(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ).stdout.strip() + + def write(self, relative, contents): + path = self.repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + return path + + def commit(self, message): + self.git("add", "--all") + self.git("commit", "-q", "--allow-empty", "-m", message) + return self.git("rev-parse", "HEAD") + + def event(self, name, payload, *, head_ref="feature", ref_name="main"): + path = self.root / f"{name}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return { + "GITHUB_EVENT_PATH": path, + "GITHUB_EVENT_NAME": name, + "GITHUB_REPOSITORY": "example/term-wall-test", + "GITHUB_HEAD_REF": head_ref, + "GITHUB_REF_NAME": ref_name, + } + + def run_wall(self, *, cwd=None, env=None): + return subprocess.run( + [str(SCRIPT)], + cwd=cwd or self.repo, + env=env or self.environment(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=15, + ) + + def assert_clean(self, result): + self.assertEqual(result.returncode, 0, result) + self.assertEqual(result.stderr, "") + lines = result.stdout.splitlines() + self.assertEqual(len(lines), 1, result.stdout) + self.assertRegex(lines[0], r"^term wall: clean(?:\b|\s|$)") + + def assert_hit(self, result, surface): + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertNotIn(PLANT, result.stdout.lower()) + self.assertNotIn(PLANT, result.stderr.lower()) + lines = result.stdout.splitlines() + self.assertTrue(lines, "a hit must be printed on stdout") + matching = [line for line in lines if line.startswith(surface + ": ")] + self.assertTrue(matching, f"missing {surface!r} hit in {result.stdout!r}") + for line in matching: + self.assertRegex( + line, + rf"^{re.escape(surface)}: [^:]+: .*\[forbidden name\].*$", + ) + + def test_clean_tree(self): + self.assert_clean(self.run_wall()) + + def test_tracked_content_hit_is_masked(self): + self.write("planted.txt", f"before {PLANT.upper()} after\n") + self.commit("add fixture") + self.assert_hit(self.run_wall(), "content") + + def test_non_utf8_committed_blob_hit_uses_binary_placeholder(self): + (self.repo / "fixture.bin").write_bytes( + b"ordinary first line\ncontains " + PLANT.encode("ascii") + b" \xff\n" + ) + self.commit("add non-UTF-8 fixture") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: fixture.bin line 2: [binary blob]\n", + ) + + def test_dangling_symlink_target_text_is_scanned_as_content(self): + os.symlink(f"absent-{PLANT}-target", self.repo / "dangling-link") + self.commit("add dangling symlink") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: dangling-link line 1: absent-[forbidden name]-target\n", + ) + + def test_clean_symlink_to_committed_clean_file_is_clean(self): + os.symlink("clean.txt", self.repo / "clean-link") + self.commit("add clean symlink") + + self.assert_clean(self.run_wall()) + + def test_uncommitted_working_tree_content_is_not_scanned(self): + self.write("tracked.txt", "committed clean content\n") + self.commit("add clean tracked file") + self.write("tracked.txt", f"working tree contains {PLANT}\n") + + self.assert_clean(self.run_wall()) + + def test_committed_blob_deleted_only_from_working_tree_is_scanned(self): + self.write("deleted.txt", f"committed content contains {PLANT}\n") + self.commit("add planted tracked file") + (self.repo / "deleted.txt").unlink() + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: deleted.txt line 1: committed content contains " + "[forbidden name]\n", + ) + + def test_tracked_path_hit_is_masked(self): + self.write(f"notes-{PLANT}.txt", "ordinary text\n") + self.commit("add fixture") + self.assert_hit(self.run_wall(), "path") + + def pr_environment(self, *, title="Clean title", body="Clean body", head_ref="feature"): + base = self.git("rev-parse", "HEAD^") + head = self.git("rev-parse", "HEAD") + payload = { + "pull_request": { + "base": {"sha": base}, + "head": {"sha": head}, + "title": title, + "body": body, + } + } + values = self.event("pull_request", payload, head_ref=head_ref) + return self.environment(**values) + + def test_pull_request_title_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(title=f"Review {PLANT} now")) + self.assert_hit(result, "pull request title") + + def test_pull_request_body_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(body=f"Body has {PLANT}.")) + self.assert_hit(result, "pull request body") + + def test_pull_request_branch_name_hit(self): + self.commit("clean feature commit") + result = self.run_wall(env=self.pr_environment(head_ref=f"topic-{PLANT}")) + self.assert_hit(result, "branch name") + + def test_pull_request_range_commit_message_is_fetched_offline(self): + base = self.git("rev-parse", "HEAD") + self.commit(f"message contains {PLANT}") + head = self.git("rev-parse", "HEAD") + self.git("branch", "base-for-test", base) + self.git("remote", "add", "origin", str(self.repo)) + payload = { + "pull_request": { + "base": {"sha": base}, + "head": {"sha": head}, + "title": "Clean title", + "body": "Clean body", + } + } + result = self.run_wall(env=self.environment(**self.event("pull_request", payload))) + self.assert_hit(result, "commit messages") + + def test_push_with_zero_before_scans_head_commit_only(self): + head = self.commit(f"new branch says {PLANT}") + payload = {"before": "0" * 40, "after": head} + result = self.run_wall(env=self.environment(**self.event("push", payload))) + self.assert_hit(result, "commit messages") + + def test_push_with_range_scans_changed_commit_messages(self): + before = self.git("rev-parse", "HEAD") + after = self.commit(f"range says {PLANT}") + payload = {"before": before, "after": after} + result = self.run_wall(env=self.environment(**self.event("push", payload))) + self.assert_hit(result, "commit messages") + + def test_push_branch_name_hit(self): + head = self.commit("clean push commit") + payload = {"before": "0" * 40, "after": head} + values = self.event("push", payload, ref_name=f"release-{PLANT}") + self.assert_hit(self.run_wall(env=self.environment(**values)), "branch name") + + def test_unreadable_event_payload_is_a_hit(self): + payload = self.root / "malformed.json" + payload.write_text("not valid JSON\n", encoding="utf-8") + env = self.environment( + GITHUB_EVENT_PATH=payload, + GITHUB_EVENT_NAME="pull_request", + GITHUB_REPOSITORY="example/term-wall-test", + GITHUB_HEAD_REF="feature", + GITHUB_REF_NAME="main", + ) + result = self.run_wall(env=env) + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertNotIn(PLANT, result.stdout + result.stderr) + self.assertRegex(result.stdout, r"(?m)^event payload: [^:]+: .+$") + + def test_term_wall_unset_refuses(self): + env = self.environment() + del env["TERM_WALL"] + result = self.run_wall(env=env) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, REFUSAL + "\n") + + def test_term_wall_empty_refuses(self): + result = self.run_wall(env=self.environment(TERM_WALL="")) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, REFUSAL + "\n") + + def test_not_a_git_work_tree_refuses(self): + outside = self.root / "outside" + outside.mkdir() + result = self.run_wall(cwd=outside) + self.assertEqual(result.returncode, 2, result) + self.assertEqual(result.stdout, "") + lines = result.stderr.splitlines() + self.assertEqual(len(lines), 1, result.stderr) + self.assertRegex(lines[0], r"^git work tree: expected .+; found .+; needed .+$") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a6f1df7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: ci +on: + pull_request: + types: [opened, synchronize, reopened, edited] + push: + branches: [dev, main] +permissions: + contents: read +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: term wall + uses: ./.github/actions/term-wall + env: + TERM_WALL: ${{ vars.TERM_WALL }} + - name: term wall tests + run: python3 -m unittest discover -s .github/actions/term-wall/tests + - name: term wall self-test — fires on a planted fault, stays quiet on a clean neighbour, refuses an empty pattern + env: + TERM_WALL: ${{ vars.TERM_WALL }} + TERM_WALL_PLANT: ${{ vars.TERM_WALL_PLANT }} + run: | + set -euo pipefail + wall="$GITHUB_WORKSPACE/.github/actions/term-wall/term-wall.sh" + [ -n "$TERM_WALL_PLANT" ] || { echo "::error::TERM_WALL_PLANT is unset — the self-test cannot plant a fault"; exit 1; } + t=$(mktemp -d); cd "$t"; git init -q; git config user.email ci@ci.invalid; git config user.name ci + printf 'clean\n' > c.txt; git add c.txt; git commit -qm clean + env -u GITHUB_EVENT_PATH bash "$wall" + printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt; git commit -qm plant + rc=0; out=$(env -u GITHUB_EVENT_PATH bash "$wall") || rc=$? + [ "$rc" -eq 1 ] || { echo "::error::the wall did not exit 1 on a planted fault (exit $rc)"; exit 1; } + case $out in *"$TERM_WALL_PLANT"*) echo "::error::the wall printed the raw plant"; exit 1;; esac + case $out in *"[forbidden name]"*) ;; *) echo "::error::the hit was not masked as [forbidden name]"; exit 1;; esac + rc=0; out=$(env -u GITHUB_EVENT_PATH TERM_WALL= bash "$wall" 2>/dev/null) || rc=$? + { [ "$rc" -eq 2 ] && [ -z "$out" ]; } || { echo "::error::an empty TERM_WALL did not refuse with exit 2 and empty stdout (exit $rc)"; exit 1; } + echo "the wall fires on a planted fault, stays quiet on a clean neighbour, and refuses an empty pattern" + - name: JSON validity + run: find . -name '*.json' -not -path './.git/*' -print0 | xargs -0 -r -n1 jq empty + - name: YAML validity + run: python3 -c "import glob,yaml; [list(yaml.safe_load_all(open(p))) for g in ('**/*.yml','**/*.yaml') for p in glob.glob(g,recursive=True) if '/.git/' not in p]" diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..7a030cd --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena@43ae0211d7f3bba4101cd0552707fa21d37f4c84", + "serena", + "start-mcp-server", + "--context", + "claude-code", + "--project", + "." + ] + } + } +} diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..fe5b470 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,11 @@ +# Serena project config for minspec/.github — tracked, so every worktree and +# session resolves the same scope. Uniform across the MinSpec org; the +# PHP backend is Phpactor (PHAR, PHP >= 8.1, no Node). +project_name: "minspec-github" +languages: ["php_phpactor"] +encoding: "utf-8" +ignore_all_files_in_gitignore: true +ignored_paths: + - "var/**" + - "vendor/**" +read_only: false diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6e3438e..8d0c42a 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -92,6 +92,7 @@ That means: - pull requests are enabled but restricted to collaborators only - collaborator PR access is an operational mechanism for trusted maintainers and approved collaborators, not a public contribution path - AI/security tools may generate evidence, but they do not approve, merge, mutate authority, or gain source authority from repository access settings +- maintainer-directed agent work is a recognized operational lane: agents working under the maintainer's explicit direction, through accounts the maintainer granted, may prepare, commit, and submit changes as draft pull requests with full origin trailers; they do not approve, merge, ratify, or change settings (see the org `CONTRIBUTING.md`, Maintainer-Directed Agent Work, maintainer decision 2026-09-01) MinSpec may be publicly visible before it is publicly governable. @@ -99,7 +100,9 @@ MinSpec may be publicly visible before it is publicly governable. ## Incubation Contribution Boundary -During incubation, MinSpec does not accept unsolicited external contributors, code pull requests, documentation pull requests, package submissions, recipe submissions, workflow changes, dependency changes, or AI-generated contribution patches. +During incubation, MinSpec does not accept unsolicited external contributors, code pull requests, documentation pull requests, package submissions, recipe submissions, workflow changes, dependency changes, or unsolicited AI-generated contribution patches. + +Maintainer-directed agent work enters through the lane defined in the org `CONTRIBUTING.md` (Maintainer-Directed Agent Work); it is maintainer work, not an external contribution. Only users with repository write, maintain, or admin access may open pull requests. That access is reserved for trusted maintainers and approved collaborators. @@ -131,6 +134,8 @@ MinSpec should not accept trusted source material from: - unapproved GitHub Actions or workflow edits - agent-generated mutations outside a controlled source path +The maintainer-directed agent lane is a controlled source path: work in that lane is directed by the maintainer, trailer-attributed to its producing agent, submitted as a reviewable draft pull request, and ratified by the maintainer before it lands. + AI agents, GitHub Apps, bots, automation, Dependabot, Copilot agents, browser agents, and external tools are not maintainers and do not gain source authority from collaborator-only PR settings. Security review is not a substitute for source authority.