PS-5: judge git-lane exclusions by key content in all three lanes

Owner answer 3=A. The patch capture already read the head of an untracked
file for a PEM private-key header, and its own comment says why: the
filename-shape rules miss real key material under an innocent name
(notes.txt) while flagging public.pem. The cooperative checkpoint and the
attached-folder snapshot called only the name check, so a real key under a
harmless name rode into their commits while the owner's deck did not.

The existing check moves into pem_private_key_reason(root, rel) next to the
regex it uses; _untracked_blob_exclude_reason keeps its order (PEM, then the
size cap, then the binary veto) and now calls it, and the two other lanes ask
name-or-content. No new signatures (PuTTY, DER, PKCS#12): a key in a binary
container stays a disclosed residual, as it is today for any key under an
innocent name. The composite untracked_capture_veto_reason is deliberately
NOT reused in the checkpoint: it also carries junk and binary/size vetoes,
which would drop the owner's binaries from their own history.

Cost: one bounded 4096-byte read per staged path of a checkpoint, fail-soft
on OSError. It is paid per checkpoint, not per model call.

Tests: the attach-snapshot fixture now carries a real key header (excluded on
content) plus a deck.key that stays tracked, and a new checkpoint case pins
both directions with the reason string in the receipt.
This commit is contained in:
Ouroboros 2026-09-12 10:45:50 +03:00
parent 8e1273ee0e
commit 4c0b59f95a
6 changed files with 76 additions and 27 deletions

View file

@ -7,8 +7,9 @@ git history instead of an uncommitted pile a later crash/cleanup could lose.
Boundaries (BIBLE "Leaking secrets: nowhere" + owner-folder ownership):
- ONLY trees under the subagent-projects root (host-minted); an owner-attached folder
is NEVER auto-committed.
- Credential-shaped files (the same `_sensitive_untracked_reason` patterns the
workspace patch excludes) are unstaged before the commit, disclosed in the receipt.
- Credential files (the exact leaves of `_sensitive_untracked_reason` plus the
content evidence of `pem_private_key_reason`, the same two checks the workspace
patch applies) are unstaged before the commit, disclosed in the receipt.
- Skipped while the tree still has live tasks; fail-soft per root; never raises.
"""
from __future__ import annotations
@ -17,7 +18,7 @@ import pathlib
import subprocess
from typing import Any, Dict, List, Sequence
from ouroboros.headless import _sensitive_untracked_reason
from ouroboros.headless import _sensitive_untracked_reason, pem_private_key_reason
def _run_git(cmd: Sequence[str], cwd: pathlib.Path) -> "subprocess.CompletedProcess[str]":
"""Bounded git call returning the full CompletedProcess (checkpoint-commit path).
@ -155,7 +156,7 @@ def checkpoint_commit_coop_roots(
rel = rel.strip()
if not rel:
continue
reason = _sensitive_untracked_reason(rel)
reason = _sensitive_untracked_reason(rel) or pem_private_key_reason(root, rel)
if reason:
_run_git(["git", "reset", "-q", "HEAD", "--", rel], root)
receipt["skipped_sensitive"].append({"path": rel, "reason": reason})

View file

@ -56,6 +56,7 @@ from ouroboros.workspace_patch_capture import ( # noqa: F401
_workspace_patch_base,
_write_patch_separator,
build_workspace_patch,
pem_private_key_reason,
untracked_capture_veto_reason,
write_workspace_patch_artifacts,
)

View file

@ -121,12 +121,13 @@ def is_git_worktree_root(path: pathlib.Path) -> bool:
def _unstage_sensitive_paths(path: pathlib.Path) -> list[str]:
"""Unstage credential-shaped files after ``git add -A`` and keep them untracked
"""Unstage credential files after ``git add -A`` and keep them untracked
via `.git/info/exclude` (local-only — the owner's folder files are never edited).
Same `_sensitive_untracked_reason` SSOT the workspace patch and coop checkpoint
use (triad r4: an attach snapshot must not bake `.env`/keys into history).
Returns the skipped relative paths for disclosure."""
from ouroboros.headless import _sensitive_untracked_reason
Same two checks the workspace patch and the coop checkpoint apply: the exact
credential leaves of `_sensitive_untracked_reason` and the private-key content
evidence of `pem_private_key_reason` (triad r4: an attach snapshot must not
bake `.env`/keys into history). Returns the skipped paths for disclosure."""
from ouroboros.headless import _sensitive_untracked_reason, pem_private_key_reason
staged = subprocess.run(
["git", "diff", "--cached", "--name-only", "-z"],
@ -134,7 +135,7 @@ def _unstage_sensitive_paths(path: pathlib.Path) -> list[str]:
)
skipped = [
rel for rel in (staged.stdout or "").split("\0")
if rel and _sensitive_untracked_reason(rel)
if rel and (_sensitive_untracked_reason(rel) or pem_private_key_reason(path, rel))
]
if not skipped:
return []

View file

@ -603,6 +603,21 @@ _PEM_PRIVATE_KEY_RE = re.compile(rb"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
_PEM_HEAD_READ_BYTES = 4096
def pem_private_key_reason(root: pathlib.Path, rel: str) -> str:
"""Reason a file carries private-key CONTENT, or ``""`` when it does not.
The bounded head read is the evidence every git lane shares: the patch,
the cooperative checkpoint and the attached-folder snapshot. Unreadable
heads are treated as ordinary content (fail-soft: git still decides).
"""
try:
with (root / rel).open("rb") as fh:
head = fh.read(_PEM_HEAD_READ_BYTES)
except OSError:
return ""
return "private key material (PEM private-key header)" if _PEM_PRIVATE_KEY_RE.search(head) else ""
def _untracked_blob_exclude_reason(root: pathlib.Path, rel: str, *, file_outputs: Optional[List[str]] = None) -> str:
"""Reason to drop an untracked file from the workspace patch when it is a
build/runtime BINARY, exceeds the per-file size cap, or carries a PEM
@ -615,13 +630,8 @@ def _untracked_blob_exclude_reason(root: pathlib.Path, rel: str, *, file_outputs
size = (root / rel).lstat().st_size
except OSError:
return "" # unreadable/symlink races: include and let git decide
try:
with (root / rel).open("rb") as fh:
head = fh.read(_PEM_HEAD_READ_BYTES)
except OSError:
head = b""
if _PEM_PRIVATE_KEY_RE.search(head):
return "private key material (PEM private-key header)"
if reason := pem_private_key_reason(root, rel):
return reason
if size > _PATCH_MAX_UNTRACKED_FILE_BYTES:
if file_outputs is not None:
file_outputs.append(rel)

View file

@ -503,3 +503,34 @@ def test_a_clean_state_dirty_root_still_commits(tmp_path, monkeypatch):
assert all(r["committed"] for r in receipts)
assert "ouroboros: checkpoint after task root1" in _subjects(tree)
assert _porcelain(tree) == ""
@pytest.mark.serial
def test_checkpoint_keeps_a_key_named_deck_and_unstages_key_material_by_content(
tmp_path, monkeypatch,
):
"""The checkpoint decides by content, not by spelling: the owner's deck.key
is committed with the rest of the paid work, while notes.txt carrying a PEM
private-key header is unstaged and disclosed in the receipt (owner answer
3=A)."""
from ouroboros.coop_checkpoint import checkpoint_commit_coop_roots
data, tree = _dirty_coop_tree(tmp_path, monkeypatch)
(tree / "deck.key").write_text("Keynote deck, ordinary bytes\n", encoding="utf-8")
(tree / "notes.txt").write_text(
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA\n-----END OPENSSH PRIVATE KEY-----\n",
encoding="utf-8",
)
receipts = checkpoint_commit_coop_roots(data, "root1", title="Sunken city")
assert [r.get("skipped", "") for r in receipts] == [""], receipts
assert all(r["committed"] for r in receipts)
assert receipts[0]["skipped_sensitive"] == [
{"path": "notes.txt", "reason": "private key material (PEM private-key header)"}
]
committed = subprocess.run(
["git", "show", "--name-only", "--format=", "HEAD"], cwd=str(tree),
capture_output=True, text=True,
).stdout.split()
assert "deck.key" in committed and "paid-work.txt" in committed
assert "notes.txt" not in committed

View file

@ -68,35 +68,40 @@ def test_attach_snapshot_init_is_opt_in_and_idempotent(tmp_path):
assert count == "1"
def test_attach_snapshot_init_excludes_credential_shaped_files(tmp_path):
def test_attach_snapshot_init_excludes_key_material_by_content_not_by_suffix(tmp_path):
"""Triad r4 security critical: an attach snapshot must never bake `.env`/keys
into git history. Credential-shaped files are unstaged (same SSOT classifier
as workspace patch / coop checkpoint), disclosed in the returned list, and
kept untracked via .git/info/exclude — the owner's files are never edited."""
into git history. What proves a key is the CONTENT (the same PEM head read
the workspace patch and the coop checkpoint use) plus the exact credential
leaves; a `.key` deck of the owner's stays in the snapshot. Excluded files
are disclosed in the returned list and kept untracked via .git/info/exclude
— the owner's files are never edited."""
from ouroboros.project_sources import attach_snapshot_init
folder = tmp_path / "with_secrets"
folder.mkdir()
(folder / "app.py").write_text("print('ok')\n", encoding="utf-8")
(folder / ".env").write_text("API_KEY=hunter2\n", encoding="utf-8")
(folder / "deploy.pem").write_text("PRIVATE KEY\n", encoding="utf-8")
(folder / "deploy.pem").write_text(
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n",
encoding="utf-8",
)
(folder / "deck.key").write_text("Keynote deck, no key material\n", encoding="utf-8")
error, skipped = attach_snapshot_init(folder)
assert error == ""
# The .pem suffix alone no longer unstages a file; this lane gains the
# content check in PS-5, which is what a real key header will trip.
assert sorted(skipped) == [".env"]
assert sorted(skipped) == [".env", "deploy.pem"]
tracked = subprocess.run(
["git", "ls-files"], cwd=str(folder), capture_output=True, text=True
).stdout.split()
assert "app.py" in tracked
assert ".env" not in tracked
assert "deck.key" in tracked
assert ".env" not in tracked and "deploy.pem" not in tracked
# The secret files still EXIST on disk, untouched.
assert (folder / ".env").read_text(encoding="utf-8") == "API_KEY=hunter2\n"
# And stay untracked (info/exclude), so later commits don't sweep them either.
status = subprocess.run(
["git", "status", "--porcelain"], cwd=str(folder), capture_output=True, text=True
).stdout
assert ".env" not in status
assert ".env" not in status and "deploy.pem" not in status
# --- clone URL forms + typed errors ----------------------------------------------