mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
8e1273ee0e
commit
4c0b59f95a
6 changed files with 76 additions and 27 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ----------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue