From 4be041a492f2d5aa038bf5aaeabc63b88aa5da1a Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:24:12 -0700 Subject: [PATCH] export-safety: scope history mode to the local clone and add a triage note History mode walks every ref in the local clone, so a clone retaining pre-rewrite refs fails the gate even when the canonical remote is clean. Four contributor-facing surfaces instructed running the gate locally and escalating under SECURITY.md on failure with no warning about that scope, steering contributors on a stale clone into a false private advisory. Document the scope rule and add an advisory triage note. The note is purely additive: no violation is suppressed, the exit code and the RESULT line are unchanged, no network call is made, and no ref names or counts are emitted. It stays silent whenever the active tree is contaminated, so present-state findings are never accompanied by a reassuring signal, and its wording names current-candidate contamination alongside the benign explanation rather than clearing any remote. Co-Authored-By: Claude Opus 5 --- .github/PULL_REQUEST_TEMPLATE.md | 4 +- AGENTS.md | 10 +- ...07-28-export-safety-history-clone-scope.md | 7 + docs/migration-from-legacy-packages.md | 8 + docs/public-governance.md | 19 +++ scripts/check-public-export-safety.py | 79 ++++++++- tests/test_public_export_safety.py | 159 ++++++++++++++++++ 7 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2026-07-28-export-safety-history-clone-scope.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec1ba37..39a8a29 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,7 +26,9 @@ List the exact deterministic tests, schema/generation checks, secret scan, and p - [ ] `python3 -m unittest discover -s scripts -p 'test_*.py' -v` - [ ] `python3 scripts/check_release_consistency.py` - [ ] `python3 scripts/secret_scan.py` -- [ ] `python3 scripts/check-public-export-safety.py --active-tree --history` +- [ ] `python3 scripts/check-public-export-safety.py --active-tree --history` (history + mode covers refs reachable in the *local* clone; see the history-mode scope rule in + `docs/public-governance.md` before treating a failure as public contamination) - [ ] `git diff --check` ## Review and post-condition diff --git a/AGENTS.md b/AGENTS.md index aac866d..0d8435c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,12 @@ Every active path, reachable ref, and release archive must remain free of provider executor source, raw provider invocation recipes, private absolute paths, credentials, retired package trees, and unreviewed native artifacts. Before publication or release, run both active-tree and history modes of -`scripts/check-public-export-safety.py`. If contamination is suspected, stop +`scripts/check-public-export-safety.py`. History mode covers refs reachable in +the *local* clone, so a clone retaining pre-rewrite refs fails it even when the +canonical remote is clean; compare against a disposable full clone of the +canonical remote and record the snapshot checked. That comparison clears only +the recorded snapshot, never the failing clone, the publication candidate, +unfetched refs, or prior exposure. If contamination is suspected, stop publication and follow `SECURITY.md`; never paste suspect material into a public -issue or pull request. +issue or pull request. See +[`docs/public-governance.md`](docs/public-governance.md) for the full rule. diff --git a/changelog.d/2026-07-28-export-safety-history-clone-scope.md b/changelog.d/2026-07-28-export-safety-history-clone-scope.md new file mode 100644 index 0000000..d176a29 --- /dev/null +++ b/changelog.d/2026-07-28-export-safety-history-clone-scope.md @@ -0,0 +1,7 @@ +### Changed + +- Document that public-export history mode covers refs reachable in the *local* clone, so a clone retaining pre-rewrite refs fails the gate even when the canonical remote is clean. `AGENTS.md`, `docs/public-governance.md`, `docs/migration-from-legacy-packages.md`, and the pull-request template now require comparing against a disposable full clone of the canonical remote, recording the snapshot checked, and treating that comparison as evidence only about the recorded snapshot — never as clearance of the failing clone, the publication candidate, unfetched refs, or prior exposure. Uncertain provenance, a failing publication candidate, a failing canonical fetched ref, or credential material still requires stopping publication under `SECURITY.md`. + +### Added + +- Emit an advisory triage note when public-export history mode fails, the active tree is clean, and the clone holds commits no remote-tracking ref reaches. The note is purely additive: it never suppresses a violation, never changes the exit code, keeps `RESULT:` as the final line, makes no network call, reports no ref names or counts, and is silent whenever the active tree is contaminated so present-state findings are never accompanied by a reassuring signal. diff --git a/docs/migration-from-legacy-packages.md b/docs/migration-from-legacy-packages.md index 18c410a..fb82f68 100644 --- a/docs/migration-from-legacy-packages.md +++ b/docs/migration-from-legacy-packages.md @@ -120,6 +120,14 @@ Cumulative archive depth, member count, per-member size, and decompressed-size limits fail closed; static Python argv construction is scanned semantically rather than by raw substrings alone. +"Every reachable ref" is scoped to the clone the gate runs in. A migration clone +that retains pre-rewrite refs fails history mode even when the canonical remote +is clean, so compare against a disposable full clone of the canonical remote and +record the snapshot checked. That comparison is evidence only about the recorded +snapshot; it does not clear the failing clone, the current publication candidate, +unfetched refs, or prior public exposure. See +[`public-governance.md`](public-governance.md) for the full triage rule. + If a migration exposes suspect material, stop and use the private reporting path in [`SECURITY.md`](../SECURITY.md). Do not attach the material to a public issue or pull request. diff --git a/docs/public-governance.md b/docs/public-governance.md index 189d6c5..206d40b 100644 --- a/docs/public-governance.md +++ b/docs/public-governance.md @@ -84,6 +84,25 @@ python3 scripts/secret_scan.py git diff --check ``` +### Scope of history mode + +`--history` inspects every ref reachable in the *local* clone, not live remote +state. A clone that retains pre-rewrite refs therefore fails this gate even when +the canonical remote is clean. Run the gate on a disposable full clone of the +canonical remote and record the commit and refs it checked. + +A large `legacy_history` or `legacy_release_ref` wall naming retired package +trees is consistent with retained pre-rewrite refs, but violation kind and volume +carry no provenance. A clean disposable-clone comparison is evidence only about +the snapshot it recorded. It does not clear the failing clone, the current +publication candidate, refs the comparison did not fetch, or the possibility of +prior public exposure. + +Stop publication and use `SECURITY.md` when the publication candidate itself +fails, a canonical fetched ref fails, credential material appears, prior exposure +is possible, or provenance stays uncertain. Scanner output is not safe to paste +publicly: `FAIL` lines carry paths, object identifiers, and ref names. + Suspected source-boundary or secret exposure is a security incident. Stop publication and use `SECURITY.md`; do not preserve suspect material in a public issue, PR body, fixture, or log. diff --git a/scripts/check-public-export-safety.py b/scripts/check-public-export-safety.py index 408ef70..a6f442b 100644 --- a/scripts/check-public-export-safety.py +++ b/scripts/check-public-export-safety.py @@ -71,6 +71,24 @@ b"exact_binary_qualification", b"provider_credential_isolation", ) +LINEAGE_NOTE_TIMEOUT_SECONDS = 10 +# History mode walks every ref in the LOCAL clone, so a clone retaining pre-rewrite +# lineage fails even when the canonical remote is clean. This note reports that +# topology signal ONLY. Topology carries no provenance, so the wording must never +# clear a remote, never attribute a FAIL line, and never read as a pass. +LOCAL_LINEAGE_NOTE = ( + "NOTE: this clone contains commit history that is not reachable from any " + "remote-tracking ref. This may result from retained pre-rewrite refs, from " + "ordinary unpushed local work, or from contamination in the current publication " + "candidate. It does not identify the source of any FAIL line above and does not " + "establish that any remote is clean. The verdict remains UNSAFE. Compare against " + "a disposable full clone of the canonical remote and record the snapshot checked; " + "a clean comparison is evidence only about that recorded snapshot, not clearance " + "of this clone. If the publication candidate itself fails, a canonical fetched ref " + "fails, credential material appears, or provenance stays uncertain, stop " + "publication and follow SECURITY.md. Do not paste this output publicly: FAIL lines " + "contain paths, object IDs, and ref names." +) MAX_SCANNED_FILE_BYTES = 256 * 1024 * 1024 MAX_ARCHIVE_MEMBERS = 4096 MAX_ARCHIVE_MEMBER_BYTES = 64 * 1024 * 1024 @@ -1050,6 +1068,47 @@ def inspect_ref_object( return sorted(set(violations), key=lambda item: (item.kind, item.evidence)) +def _local_lineage_note(root: Path) -> str | None: + """Advisory triage note for history-mode failures; never affects the verdict. + + Fires only on the topology signal "this clone holds commits no remote-tracking + ref reaches". Multiple root commits is deliberately NOT a trigger: every root can + still be remote-reachable, which would make the note assert something false. + Any error, timeout, or absent remote yields no note. + """ + try: + remotes = subprocess.run( + [ + "git", + "-C", + str(root), + "for-each-ref", + "--format=%(refname)", + "refs/remotes/", + ], + capture_output=True, + text=True, + check=False, + timeout=LINEAGE_NOTE_TIMEOUT_SECONDS, + ) + # Without a remote-tracking ref, `--not --remotes` excludes nothing and every + # commit would look local-only. Stay silent rather than assert a false signal. + if remotes.returncode != 0 or not remotes.stdout.strip(): + return None + outside = subprocess.run( + ["git", "-C", str(root), "rev-list", "--all", "--not", "--remotes"], + capture_output=True, + text=True, + check=False, + timeout=LINEAGE_NOTE_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError): + return None + if outside.returncode != 0 or not outside.stdout.strip(): + return None + return LOCAL_LINEAGE_NOTE + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--export-root", type=Path, default=REPO_ROOT) @@ -1058,14 +1117,28 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) if not args.active_tree and not args.history: parser.error("select --active-tree and/or --history") - violations: list[Violation] = [] + active_violations: list[Violation] = [] + history_violations: list[Violation] = [] if args.active_tree: - violations.extend(scan_active_tree(args.export_root)) + active_violations = scan_active_tree(args.export_root) if args.history: - violations.extend(scan_history(args.export_root)) + history_violations = scan_history(args.export_root) + violations = active_violations + history_violations for item in violations: print(f"FAIL [{item.kind}] {item.evidence}") if violations: + # Gate on a clean active tree: an active-tree violation is present-state + # contamination, and a lineage note beside it would reassure an operator out + # of escalating. Both modes must have run, or "active tree is clean" is unproven. + if ( + args.active_tree + and args.history + and not active_violations + and history_violations + ): + note = _local_lineage_note(args.export_root) + if note is not None: + print(note) print("RESULT: UNSAFE FOR PUBLIC EXPORT") return 1 print("RESULT: SAFE FOR REQUESTED PUBLIC EXPORT CHECKS") diff --git a/tests/test_public_export_safety.py b/tests/test_public_export_safety.py index 8c1136c..df90dc9 100644 --- a/tests/test_public_export_safety.py +++ b/tests/test_public_export_safety.py @@ -520,5 +520,164 @@ def test_compiled_runtime_rejects_partial_matrix_and_missing_signing_team(self) self.assertFalse(any(item.kind == "unmanifested_runtime" for item in violations)) +class LocalLineageNoteTests(unittest.TestCase): + """The history-mode triage note must inform without ever excusing a failure.""" + + @classmethod + def setUpClass(cls) -> None: + cls.audit = _load() + + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.origin = self.root / "origin" + self.clone = self.root / "clone" + + def tearDown(self) -> None: + self.temp.cleanup() + + @staticmethod + def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True) + + def _identify(self, cwd: Path) -> None: + self._git(cwd, "config", "user.email", "test@example.com") + self._git(cwd, "config", "user.name", "Test") + + def _clone_with_origin(self) -> None: + """A published origin plus a clone of it, so refs/remotes/* exists.""" + self.origin.mkdir() + self._git(self.origin, "init", "-q", "-b", "main") + self._identify(self.origin) + (self.origin / "README.md").write_text("safe", encoding="utf-8") + self._git(self.origin, "add", ".") + self._git(self.origin, "commit", "-q", "-m", "published") + subprocess.run( + ["git", "clone", "-q", str(self.origin), str(self.clone)], + check=True, + capture_output=True, + ) + self._identify(self.clone) + + def _add_unpushed_history_violation(self) -> None: + """A retired package tree in an unpushed commit: history-only contamination.""" + retired = self.clone / "plugins" / "codex-tools" + retired.mkdir(parents=True) + (retired / "README.md").write_text("retired", encoding="utf-8") + self._git(self.clone, "add", ".") + self._git(self.clone, "commit", "-q", "-m", "retired tree") + subprocess.run(["rm", "-rf", str(retired)], check=True) + self._git(self.clone, "add", "-A") + self._git(self.clone, "commit", "-q", "-m", "remove retired tree") + + def _run(self, *flags: str) -> tuple[int, list[str]]: + buffer = io.StringIO() + with mock.patch("sys.stdout", buffer): + code = self.audit.main(["--export-root", str(self.clone), *flags]) + return code, buffer.getvalue().splitlines() + + def test_note_emitted_when_only_history_fails_with_local_only_commits(self) -> None: + self._clone_with_origin() + self._add_unpushed_history_violation() + code, lines = self._run("--active-tree", "--history") + self.assertEqual(code, 1) + self.assertTrue(any(line.startswith("NOTE:") for line in lines), lines) + self.assertEqual(lines[-1], "RESULT: UNSAFE FOR PUBLIC EXPORT") + + def test_note_names_current_candidate_contamination_and_clears_nothing(self) -> None: + self._clone_with_origin() + self._add_unpushed_history_violation() + _, lines = self._run("--active-tree", "--history") + note = next(line for line in lines if line.startswith("NOTE:")) + # Must offer the incriminating reading, not only the exculpatory one. + self.assertIn("contamination in the current publication candidate", note) + self.assertIn("does not establish that any remote is clean", note) + self.assertIn("The verdict remains UNSAFE", note) + self.assertIn("SECURITY.md", note) + + def test_note_suppressed_when_the_active_tree_is_contaminated(self) -> None: + """Present-state contamination must never be accompanied by a reassuring note.""" + self._clone_with_origin() + self._add_unpushed_history_violation() + # Any active-tree violation must suppress the note. This uses an executor-source + # filename because that literal is already allowlisted in HARMLESS_AUDIT_LITERALS + # for this file; embedding a fresh credential pattern here would trip the + # active-tree gate on this very repository. + (self.clone / "codex_exec.py").write_text("x", encoding="utf-8") + code, lines = self._run("--active-tree", "--history") + self.assertEqual(code, 1) + self.assertTrue(any("executor_source" in line for line in lines), lines) + self.assertFalse(any(line.startswith("NOTE:") for line in lines), lines) + + def test_note_suppressed_when_history_mode_was_not_requested(self) -> None: + self._clone_with_origin() + self._add_unpushed_history_violation() + (self.clone / "leak.md").write_text("/Users/private/project", encoding="utf-8") + _, lines = self._run("--active-tree") + self.assertFalse(any(line.startswith("NOTE:") for line in lines), lines) + + def test_note_suppressed_when_every_root_is_remote_reachable(self) -> None: + """Multiple roots alone must not assert history outside remote-tracking refs.""" + self._clone_with_origin() + self._git(self.clone, "checkout", "-q", "--orphan", "second-root") + self._git(self.clone, "rm", "-rqf", ".") + retired = self.clone / "plugins" / "grok-worker" + retired.mkdir(parents=True) + (retired / "README.md").write_text("retired", encoding="utf-8") + self._git(self.clone, "add", ".") + self._git(self.clone, "commit", "-q", "-m", "second root") + # Publish every local commit, so nothing is outside refs/remotes/*. + self._git(self.clone, "push", "-q", "origin", "second-root") + self._git(self.clone, "fetch", "-q", "origin") + roots = subprocess.run( + ["git", "-C", str(self.clone), "rev-list", "--all", "--max-parents=0"], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual(len(roots.stdout.split()), 2) + code, lines = self._run("--active-tree", "--history") + self.assertEqual(code, 1) + self.assertFalse(any(line.startswith("NOTE:") for line in lines), lines) + + def test_note_suppressed_without_any_remote_tracking_ref(self) -> None: + self.clone.mkdir() + self._git(self.clone, "init", "-q", "-b", "main") + self._identify(self.clone) + self._add_unpushed_history_violation() + code, lines = self._run("--active-tree", "--history") + self.assertEqual(code, 1) + self.assertFalse(any(line.startswith("NOTE:") for line in lines), lines) + + def test_note_never_reduces_the_reported_violation_set(self) -> None: + self._clone_with_origin() + self._add_unpushed_history_violation() + _, lines = self._run("--active-tree", "--history") + reported = [line for line in lines if line.startswith("FAIL [")] + expected = [ + f"FAIL [{item.kind}] {item.evidence}" + for item in self.audit.scan_history(self.clone) + ] + self.assertEqual(reported, expected) + self.assertTrue(expected) + + def test_diagnostic_failure_is_non_fatal(self) -> None: + self._clone_with_origin() + self._add_unpushed_history_violation() + with mock.patch.object( + self.audit.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(cmd="git", timeout=1), + ): + self.assertIsNone(self.audit._local_lineage_note(self.clone)) + + def test_clean_clone_reports_success_without_a_note(self) -> None: + self._clone_with_origin() + code, lines = self._run("--active-tree", "--history") + self.assertEqual(code, 0) + self.assertEqual(lines[-1], "RESULT: SAFE FOR REQUESTED PUBLIC EXPORT CHECKS") + self.assertFalse(any(line.startswith("NOTE:") for line in lines), lines) + + if __name__ == "__main__": unittest.main()