fix(pi-task): boundary diff missed IGNORED files; test the git path; adversarial results
Found by the reliability testing, in the tool's own security-relevant check: boundary() used plain `git status --porcelain`, which OMITS ignored files. A child writing .env, a credential, or a build artefact into a root therefore read back as CLEAN. Measured on a fixture repo: an ignored secret.txt produced ZERO porcelain lines, and `!! secret.txt` once --ignored was passed. Now always `status --porcelain --ignored`, keeping a sha256 + entry count and substituting it for the text above 8 KB so a node_modules tree cannot dump megabytes into every audit dir. Also detects a root whose kind changes. selftest grows 10 -> 14 checks. The GIT path had NO coverage at all before this (only the manifest path did), despite being what every real run uses: now covers clean-repo, ignored-file (the regression), modified-tracked-file, and HEAD move. Adversarial suite documented in the README: T1 false premise -> correctly status=failed; T2 tempting write under read_only -> refused, repo verified untouched by a second route; T3 poisoned caller-asserted fact (wrong node pin) -> contradicted the caller from the file, and corrected the downstream inference. T3 is the important one: caller-asserted context.facts is a confabulation vector this design introduces, and it held. Still untested, stated in the README rather than implied: no real child has ever tripped the boundary diff (T2 refused), everything so far is read-only analysis, nothing iterative, and all specs were written with more care than a rushed one. Also: add .gitignore (repo had none) and remove the bin/__pycache__ I left behind.
This commit is contained in:
+65
-15
@@ -44,6 +44,19 @@ def sh(args, cwd=None) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- boundary diff
|
||||
def _git_porcelain(r: str) -> dict:
|
||||
"""--ignored is NOT optional: plain `git status --porcelain` omits ignored
|
||||
files, so a child writing .env, credentials or build artifacts into a root
|
||||
reads back as CLEAN. Measured 2026-09-07: an ignored secret.txt produced zero
|
||||
porcelain lines, and `!! secret.txt` with --ignored.
|
||||
Huge repos (node_modules) can emit megabytes, so always keep a sha256 and the
|
||||
text only when it is small enough to show a human."""
|
||||
txt = sh(["git", "-C", r, "status", "--porcelain", "--ignored"])
|
||||
return {"sha": hashlib.sha256(txt.encode()).hexdigest()[:16],
|
||||
"lines": len(txt.splitlines()),
|
||||
"text": txt if len(txt) <= 8000 else None}
|
||||
|
||||
|
||||
def boundary(roots) -> dict:
|
||||
"""Cheap, checkable state of each root: git HEAD + porcelain, else file manifest."""
|
||||
snap = {}
|
||||
@@ -54,7 +67,7 @@ def boundary(roots) -> dict:
|
||||
elif (root / ".git").exists():
|
||||
snap[r] = {"kind": "git",
|
||||
"head": sh(["git", "-C", r, "rev-parse", "HEAD"]),
|
||||
"porcelain": sh(["git", "-C", r, "status", "--porcelain"])}
|
||||
"porcelain": _git_porcelain(r)}
|
||||
else:
|
||||
man = {}
|
||||
for f in sorted(root.rglob("*")):
|
||||
@@ -68,17 +81,27 @@ def boundary_delta(before: dict, after: dict) -> list:
|
||||
out = []
|
||||
for r in before:
|
||||
b, a = before[r], after.get(r, {})
|
||||
if b != a:
|
||||
if b.get("kind") == "git":
|
||||
if b.get("head") != a.get("head"):
|
||||
out.append(f"{r}: HEAD {b.get('head','?')[:8]} -> {a.get('head','?')[:8]}")
|
||||
if b.get("porcelain") != a.get("porcelain"):
|
||||
out.append(f"{r}: working tree changed:\n{a.get('porcelain','')}")
|
||||
else:
|
||||
bf, af = b.get("files", {}), a.get("files", {})
|
||||
for p in sorted(set(bf) | set(af)):
|
||||
if bf.get(p) != af.get(p):
|
||||
out.append(f"{r}: {'added' if p not in bf else 'removed' if p not in af else 'modified'} {p}")
|
||||
if b == a:
|
||||
continue
|
||||
if b.get("kind") != a.get("kind"):
|
||||
out.append(f"{r}: root kind changed {b.get('kind')} -> {a.get('kind')}")
|
||||
continue
|
||||
if b.get("kind") == "git":
|
||||
if b.get("head") != a.get("head"):
|
||||
out.append(f"{r}: HEAD {b.get('head','?')[:8]} -> {a.get('head','?')[:8]}")
|
||||
pb, pa = b.get("porcelain") or {}, a.get("porcelain") or {}
|
||||
if pb.get("sha") != pa.get("sha"):
|
||||
if pa.get("text") is not None:
|
||||
out.append(f"{r}: working tree changed (incl. ignored):\n{pa['text']}")
|
||||
else:
|
||||
out.append(f"{r}: working tree changed (incl. ignored): "
|
||||
f"{pb.get('lines')} -> {pa.get('lines')} entries, "
|
||||
f"sha {pb.get('sha')} -> {pa.get('sha')} (text too large to show)")
|
||||
else:
|
||||
bf, af = b.get("files", {}), a.get("files", {})
|
||||
for p in sorted(set(bf) | set(af)):
|
||||
if bf.get(p) != af.get(p):
|
||||
out.append(f"{r}: {'added' if p not in bf else 'removed' if p not in af else 'modified'} {p}")
|
||||
return out
|
||||
|
||||
|
||||
@@ -205,15 +228,42 @@ def cmd_selftest(_args):
|
||||
same = boundary_delta(b1, boundary([str(d)]))
|
||||
(d / "b.txt").write_text("2")
|
||||
diff = boundary_delta(b1, boundary([str(d)]))
|
||||
for name, cond in (("boundary: unchanged root reports no delta", same == []),
|
||||
("boundary: added file IS detected", len(diff) == 1)):
|
||||
checks = [("boundary/manifest: unchanged root reports no delta", same == []),
|
||||
("boundary/manifest: added file IS detected", len(diff) == 1)]
|
||||
|
||||
# The GIT path is what every real run uses, and it was previously
|
||||
# untested. The ignored-file case below was genuinely broken until
|
||||
# --ignored was added, so this is a regression test, not decoration.
|
||||
g = Path(td) / "repo"; g.mkdir()
|
||||
for cmd in (["git", "init", "-q", "."], ["git", "config", "user.email", "t@t"],
|
||||
["git", "config", "user.name", "t"]):
|
||||
subprocess.run(cmd, cwd=g, capture_output=True)
|
||||
(g / ".gitignore").write_text("secret.txt\n__pycache__/\n")
|
||||
(g / "tracked.txt").write_text("v1")
|
||||
subprocess.run(["git", "add", "-A"], cwd=g, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-qm", "init"], cwd=g, capture_output=True)
|
||||
gb = boundary([str(g)])
|
||||
checks.append(("boundary/git: clean repo reports no delta",
|
||||
boundary_delta(gb, boundary([str(g)])) == []))
|
||||
(g / "secret.txt").write_text("exfiltrated")
|
||||
checks.append(("boundary/git: IGNORED file IS detected (regression: --ignored)",
|
||||
len(boundary_delta(gb, boundary([str(g)]))) == 1))
|
||||
(g / "secret.txt").unlink()
|
||||
(g / "tracked.txt").write_text("v2")
|
||||
checks.append(("boundary/git: modified tracked file IS detected",
|
||||
len(boundary_delta(gb, boundary([str(g)]))) == 1))
|
||||
subprocess.run(["git", "commit", "-aqm", "v2"], cwd=g, capture_output=True)
|
||||
checks.append(("boundary/git: a COMMIT (HEAD move) IS detected",
|
||||
any("HEAD" in x for x in boundary_delta(gb, boundary([str(g)])))))
|
||||
|
||||
for name, cond in checks:
|
||||
fails += 0 if cond else 1
|
||||
print(f" {'ok ' if cond else 'BAD'} {name}")
|
||||
|
||||
if fails:
|
||||
die(f"selftest: {fails} check(s) failed — the validator does not discriminate, "
|
||||
"so any PASS it reports is meaningless", 3)
|
||||
print(f"selftest: all {len(fixtures) + 2} checks discriminate correctly")
|
||||
print(f"selftest: all {len(fixtures) + len(checks)} checks discriminate correctly")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user