#!/usr/bin/env python3 """Record local command evidence without upgrading its trust beyond SELF. Receipts preserve sanitized full output and the exact command when it contains no credentials. A hash of the original command remains when redaction is necessary. Checksums detect accidental modification, not forgery by a local file's author. """ import datetime import fcntl import hashlib import json import os from pathlib import Path import re import subprocess import sys SCHEMA = 1 SECRET_NAME = re.compile(r"token|password|passwd|secret|api[_-]?key|credential|private[_-]?key", re.I) SHA256 = re.compile(r"[0-9a-f]{64}\Z") GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z") # 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。 TARGET_FIELDS = ("root_target", "immediate_target") BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", "before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"} class EvidenceError(Exception): pass def digest(value): return hashlib.sha256(value.encode() if isinstance(value, str) else value).hexdigest() def canonical(value): return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")) def sanitize(value): """Redact known secret values and conventional credential syntax everywhere.""" for name, secret in sorted(os.environ.items(), key=lambda item: -len(item[1])): if SECRET_NAME.search(name) and len(secret) >= 4: value = value.replace(secret, "") value = re.sub(r"-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----", "", value, flags=re.S) value = re.sub(r"(?i)(\bAuthorization['\"]?[ \t]*[=:][ \t]*['\"]?" r"(?:Bearer|token|Basic)[ \t]+)[^\s'\";]+", r"\1", value) value = re.sub(r"(?i)\b(https?|ssh)://[^\s/@:]+:[^\s/@]+@", r"\1://@", value) credential = r"[\w-]*(?:token|password|passwd|secret|api[_-]?key|credential)[\w-]*" prefix = r"(?:\b" + credential + r"['\"]?[ \t]*[=:][ \t]*|--" + credential + r"[ \t]+)" value = re.sub(r"(?i)(" + prefix + r")(['\"])(.*?)\2", r"\1\2\2", value, flags=re.S) value = re.sub(r"(?i)(--" + credential + r"[ \t]+)(?!)[^\s'\";]+", r"\1", value) value = re.sub( r"(?i)(\b[\w-]*(?:token|password|passwd|secret|api[_-]?key|credential)[\w-]*" r"['\"]?[ \t]*[=:][ \t]*['\"]?)(?!)[^\s'\"&;,}]+", r"\1", value) return value def sanitize_tree(value): if isinstance(value, str): return sanitize(value) if isinstance(value, list): return [sanitize_tree(item) for item in value] if isinstance(value, dict): return {sanitize(key): sanitize_tree(item) for key, item in value.items()} return value def git(*args, required=True): result = subprocess.run(["git", *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode and required: raise EvidenceError("Git object could not be recorded: " + " ".join(args[:2])) return result.stdout if result.returncode == 0 else b"" def git_text(*args, **kwargs): return git(*args, **kwargs).decode("utf-8", "replace").strip() def common_dir(): return str(Path(git_text("rev-parse", "--git-common-dir")).resolve()) def snapshot(): untracked = {} root = Path(git_text("rev-parse", "--show-toplevel")) for raw in git("-C", str(root), "ls-files", "--full-name", "--others", "--exclude-standard", "-z").split(b"\0"): if not raw: continue name = raw.decode("utf-8", "replace") path = root / name if path.is_symlink(): untracked[name] = digest(os.fsencode(os.readlink(path))) elif path.is_file(): with path.open("rb") as stream: untracked[name] = hashlib.file_digest(stream, "sha256").hexdigest() else: untracked[name] = None target = os.environ.get("TASK_TARGET_BRANCH", "main") return { "head": git_text("rev-parse", "HEAD"), "branch": git_text("rev-parse", "--abbrev-ref", "HEAD"), "target_branch": target, "target_head": git_text("rev-parse", "--verify", "refs/remotes/origin/" + target, required=False) or None, "status": git("status", "--porcelain=v1", "--untracked-files=all").decode("utf-8", "replace"), "tracked_diff_sha256": digest(git("diff", "--binary", "HEAD", "--")), "untracked": untracked, "submodules": git("submodule", "status", "--recursive").decode("utf-8", "replace"), } def valid_branch(name): return (isinstance(name, str) and BRANCH.match(name) is not None and ".." not in name and "//" not in name and not name.endswith(("/", ".", ".lock"))) def receipt_keys_ok(keys): keys = set(keys) targets = keys & set(TARGET_FIELDS) if targets not in (set(), set(TARGET_FIELDS)): return False rest = keys - targets return rest in (BASE_FIELDS, BASE_FIELDS | {"capture_note"}) def task_targets(): """Root and immediate targets named by the current branch card; no metadata means main/main.""" default = os.environ.get("TASK_TARGET_BRANCH", "main") try: card = find_card() except EvidenceError: return default, default match = re.search(r"^- 分支类型:[^\n]*?;root_target:([^;\n]+);immediate_target:([^;\n]+)", card.read_text(encoding="utf-8"), re.M) if not match: return default, default root, immediate = match.group(1).strip(), match.group(2).strip() if not (valid_branch(root) and valid_branch(immediate)): raise EvidenceError("Task card target metadata is invalid") return root, immediate def create_receipt(command, output, exit_code, before, after, *, timestamp=None, cwd=None, git_common_dir=None, after_error=None, capture_note=None, targets=None): """Package an actual captured run; callers must not reconstruct missing output.""" root_target, immediate_target = targets or task_targets() receipt = sanitize_tree({ "schema": SCHEMA, "source": "SELF", "timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(), "command": command, "command_sha256": digest(command), "cwd": cwd or str(Path.cwd().resolve()), "common_dir": git_common_dir or common_dir(), "before": before, "after": after, "after_error": after_error, "exit_code": exit_code, "output": output, "root_target": root_target, "immediate_target": immediate_target, }) if capture_note is not None: receipt["capture_note"] = sanitize(capture_note) receipt["output_sha256"] = digest(receipt["output"]) receipt["receipt_sha256"] = digest(canonical(receipt)) return receipt def validate_snapshot(value): if not isinstance(value, dict): raise EvidenceError("Invalid receipt object snapshot") expected = {"head", "branch", "target_branch", "target_head", "status", "tracked_diff_sha256", "submodules", "untracked"} if set(value) != expected: raise EvidenceError("Invalid receipt object fields") for key in ("head", "branch", "target_branch", "status", "tracked_diff_sha256", "submodules"): if not isinstance(value.get(key), str): raise EvidenceError("Invalid receipt object field: " + key) if not GIT_SHA.fullmatch(value["head"]) or not SHA256.fullmatch(value["tracked_diff_sha256"]): raise EvidenceError("Invalid receipt object digest") if value.get("target_head") is not None and ( not isinstance(value["target_head"], str) or not GIT_SHA.fullmatch(value["target_head"])): raise EvidenceError("Invalid receipt target identity") if not isinstance(value.get("untracked"), dict): raise EvidenceError("Invalid receipt untracked object") for name, checksum in value["untracked"].items(): if not isinstance(name, str) or (checksum is not None and ( not isinstance(checksum, str) or not SHA256.fullmatch(checksum))): raise EvidenceError("Invalid receipt untracked digest") def read_receipt(filename): if not Path(filename).is_absolute(): raise EvidenceError("Receipt path must be absolute") value = json.loads(Path(filename).read_text()) if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA: raise EvidenceError("Unsupported receipt schema") if not receipt_keys_ok(value): raise EvidenceError("Invalid receipt fields") for key in TARGET_FIELDS: if key in value and not valid_branch(value[key]): raise EvidenceError("Invalid receipt target: " + key) for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir", "output", "output_sha256", "receipt_sha256"): if not isinstance(value.get(key), str): raise EvidenceError("Invalid receipt field: " + key) if value["source"] != "SELF" or type(value.get("exit_code")) is not int or not 0 <= value["exit_code"] <= 255: raise EvidenceError("Invalid receipt source or exit code") datetime.datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00")) for key in ("cwd", "common_dir"): if not Path(value[key]).is_absolute(): raise EvidenceError("Receipt identity paths must be absolute") for key in ("command_sha256", "output_sha256", "receipt_sha256"): if not SHA256.fullmatch(value[key]): raise EvidenceError("Invalid receipt digest: " + key) validate_snapshot(value.get("before")) if value.get("after") is not None: validate_snapshot(value["after"]) elif not isinstance(value.get("after_error"), str) or not value["after_error"]: raise EvidenceError("Missing after object or capture error") if value.get("after_error") is not None and not isinstance(value["after_error"], str): raise EvidenceError("Invalid after capture error") if "capture_note" in value and not isinstance(value["capture_note"], str): raise EvidenceError("Invalid capture note") if digest(value["output"]) != value["output_sha256"]: raise EvidenceError("Receipt output checksum mismatch") payload = {key: item for key, item in value.items() if key != "receipt_sha256"} if digest(canonical(payload)) != value["receipt_sha256"]: raise EvidenceError("Receipt checksum mismatch") if value != sanitize_tree(value): raise EvidenceError("Receipt contains unredacted credential syntax") if Path(value["common_dir"]).resolve() != Path(common_dir()): raise EvidenceError("Receipt belongs to a different Git common directory") return value def task_context(current=None): """Verify an existing task checkout; this is not an approval identity.""" current = snapshot() if current is None else current root = Path(git_text("rev-parse", "--show-toplevel")).resolve() branch = current["branch"] if not re.fullmatch(r"(?:feature|fix|hotfix|release)/[A-Za-z0-9._-]+", branch): raise EvidenceError("Local recovery requires a task branch") records = [row.split(b"\0") for row in git("worktree", "list", "--porcelain", "-z").split(b"\0\0") if row] matches = [row for row in records if row[0].startswith(b"worktree ") and Path(os.fsdecode(row[0][9:])).resolve() == root] if len(matches) != 1 or not records or matches[0] == records[0]: raise EvidenceError("Recovery requires a registered linked task worktree") entry = matches[0] if any(field.startswith((b"bare", b"detached", b"prunable")) for field in entry) or ( b"branch refs/heads/" + branch.encode() not in entry): raise EvidenceError("Task worktree registration is not valid") if (root / ".task").is_symlink(): raise EvidenceError("Task registration must belong to the current checkout") card = find_card() if card.is_symlink() or card.resolve().parent != root / ".task": raise EvidenceError("Task registration must belong to the current checkout") content = card.read_text() locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M) bases = re.findall(r"^- 权威仓:.*;目标分支:([^;]+);R0(initial_base_sha)= ([0-9a-f]+)\s*$", content, re.M) if len(locations) != 1 or Path(locations[0][0]).resolve() != root or locations[0][1] != branch: raise EvidenceError("Task card belongs to a different worktree or branch") if len(bases) != 1 or bases[0][0] != current["target_branch"] or not GIT_SHA.fullmatch(bases[0][1]): raise EvidenceError("Task card has no valid target and initial baseline") r0 = bases[0][1] if not current["target_head"]: raise EvidenceError("Task target is unverified; fetch it first") for tip in (current["head"], current["target_head"]): git("merge-base", "--is-ancestor", r0, tip) check = subprocess.run([sys.executable, str(Path(__file__).with_name("check-worktree.py")), "before-sync"], capture_output=True, text=True) if check.returncode: raise EvidenceError("Task workspace is not ready: " + (check.stdout + check.stderr).strip()) return {"root": root, "r0": r0} def validate_precheck(receipt, purpose="start"): if purpose not in ("start", "resume-delivery", "resume-local"): raise EvidenceError("Unknown precheck validation purpose") after = receipt["after"] current = snapshot() if receipt["command"] not in ("make precheck", "bash scripts/task.sh precheck"): raise EvidenceError("Task start requires a precheck receipt") expected = "LOCAL_READY" if purpose == "resume-local" else "READY" verdicts = re.findall(r"^\[verdict\] ([A-Z_]+)(?:\s|$)", receipt["output"], re.M) if receipt["exit_code"] != 0 or verdicts != [expected]: raise EvidenceError("Precheck receipt is not " + expected) if after is None or receipt["after_error"] is not None: raise EvidenceError("Precheck object capture is incomplete") if receipt["before"]["head"] != current["head"] or after["head"] != current["head"]: raise EvidenceError("Precheck source HEAD has changed") if after["target_branch"] != current["target_branch"] or not after["target_head"]: raise EvidenceError("Precheck authoritative target has changed") if purpose != "resume-local" and after["target_head"] != current["target_head"]: raise EvidenceError("Precheck authoritative target has changed") if purpose.startswith("resume-"): context = task_context(current) try: Path(receipt["cwd"]).resolve().relative_to(context["root"]) except ValueError: raise EvidenceError("Precheck belongs to a different task worktree") if any(obj["branch"] != current["branch"] or obj["target_branch"] != current["target_branch"] for obj in (receipt["before"], after)): raise EvidenceError("Precheck belongs to a different task branch") git("merge-base", "--is-ancestor", context["r0"], after["target_head"]) if purpose == "resume-local": try: git("merge-base", "--is-ancestor", after["target_head"], current["target_head"]) except EvidenceError: raise EvidenceError("Precheck target history has rewound or diverged") from None def find_card(): root = Path(git_text("rev-parse", "--show-toplevel")) branch = git_text("rev-parse", "--abbrev-ref", "HEAD") card = root / ".task" / (branch.replace("/", "-") + ".md") if card.is_file(): return card for candidate in (root / ".task").glob("*.md"): if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M): return candidate raise EvidenceError("No task card exists for the current branch") def append_card(card, receipt): encoded = json.dumps(receipt, ensure_ascii=False, indent=2) block = "\n## 证据(Evidence)\n\n" + ( "\n" + "```json\n" + encoded + "\n```\n") with card.open("r+", encoding="utf-8") as stream: fcntl.flock(stream, fcntl.LOCK_EX) existing = stream.read() if receipt["receipt_sha256"] in existing: return if "## 证据(Evidence)" in existing: block = block.replace("\n## 证据(Evidence)\n\n", "\n", 1) stream.write(block) stream.flush() os.fsync(stream.fileno()) def write_receipt(path, receipt): with path.open("x", encoding="utf-8") as stream: os.chmod(path, 0o600) json.dump(receipt, stream, ensure_ascii=False, indent=2) stream.write("\n") stream.flush() os.fsync(stream.fileno()) def report(receipt, destination): print(sanitize("[evidence] " + destination + " exit=" + str(receipt["exit_code"]) + " src=SELF before=" + receipt["before"]["head"] + " receipt_sha256=" + receipt["receipt_sha256"])) def main(): child_exit = None try: if os.environ.get("SRC", "SELF") not in ("", "SELF"): raise EvidenceError("Local evidence is SELF; GATE/REPLAY require external execution identity") if sys.argv[1:] == ["--local-context"]: context = task_context() print("[task-state] registered=" + str(context["root"]) + " initial_base=" + context["r0"]) return 0 if sys.argv[1:]: raise EvidenceError("Unknown evidence arguments") capture, imported, validated = (os.environ.get(key) for key in ("RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT")) command = os.environ.get("CMD", "") if sum(bool(item) for item in (capture, imported, validated)) > 1 or ((imported or validated) and command): raise EvidenceError("Choose one capture/import/validate operation") if imported or validated: receipt = read_receipt(imported or validated) if validated: validate_precheck(receipt, os.environ.get("VALIDATE_RECEIPT_PURPOSE", "start")) report(receipt, "precheck validated") else: card = find_card() append_card(card, receipt) report(receipt, "card=" + str(card)) return receipt["exit_code"] if not command or "\n" in command or "\r" in command or "\0" in command: raise EvidenceError("CMD must be a nonempty single-line command") path = Path(capture) if capture else None if path is not None and (not path.is_absolute() or path.exists() or path.is_symlink()): raise EvidenceError("Capture requires an absolute, unused receipt path") card = find_card() if path is None else None before = snapshot() targets = task_targets() source_cwd, source_common = str(Path.cwd().resolve()), common_dir() timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() child_env = os.environ.copy() for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL", "VALIDATE_RECEIPT_PURPOSE"): child_env.pop(key, None) result = subprocess.run(["bash", "-o", "pipefail", "-c", command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=child_env) child_exit = result.returncode if result.returncode >= 0 else 128 - result.returncode after, after_error = None, None try: after = snapshot() except (EvidenceError, OSError) as error: after_error = str(error) receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit, before, after, timestamp=timestamp, cwd=source_cwd, git_common_dir=source_common, after_error=after_error, targets=targets) if path is not None: write_receipt(path, receipt) report(receipt, "receipt=" + str(path)) else: append_card(card, receipt) report(receipt, "card=" + str(card)) print(receipt["output"], end="" if receipt["output"].endswith("\n") else "\n") if after_error: print("[evidence] after object unavailable: " + sanitize(after_error), file=sys.stderr) return child_exit except (EvidenceError, OSError, ValueError, TypeError) as error: print("[evidence] ERROR: " + sanitize(str(error)), file=sys.stderr) return child_exit if child_exit else 2 if __name__ == "__main__": sys.exit(main())