Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions changelog.d/2026-07-28-export-safety-history-clone-scope.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/migration-from-legacy-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/public-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
79 changes: 76 additions & 3 deletions scripts/check-public-export-safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
159 changes: 159 additions & 0 deletions tests/test_public_export_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading