From b18a1344977f809d822dca5ff4dea87e0a24462e Mon Sep 17 00:00:00 2001 From: yaojingang Date: Mon, 15 Jun 2026 23:11:31 +0800 Subject: [PATCH] Harden adaptive apply approval baselines --- references/autonomous-adaptation.md | 8 ++-- reports/adaptation_approval_ledger.json | 1 + reports/adaptation_proposals.json | 1 + reports/adaptation_regression_report.json | 1 + schemas/adaptation-proposal.schema.json | 4 ++ scripts/apply_adaptation.py | 55 ++++++++++++++++++++++- scripts/propose_adaptation.py | 1 + tests/verify_adaptation_safety.py | 30 +++++++++++++ 8 files changed, 97 insertions(+), 4 deletions(-) diff --git a/references/autonomous-adaptation.md b/references/autonomous-adaptation.md index 1bab9bd..9fa800d 100644 --- a/references/autonomous-adaptation.md +++ b/references/autonomous-adaptation.md @@ -10,8 +10,8 @@ Adaptive iteration is proposal-only until a human explicitly approves a patch ap - redact sensitive text before storing evidence excerpts; - summarize repeated preferences and operational signals; - produce adaptation proposals with target files, risks, tests, and rollback plans. -- dry-run an approved patch through `adapt-apply`, after patch hash, approval, and target allowlist checks pass. -- apply a patch only when the operator passes `--apply` and the approval ledger names the reviewer, reason, patch hash, target files, verification commands, and rollback plan. +- dry-run an approved patch through `adapt-apply`, after patch hash, approval, target allowlist, and target baseline hash checks pass. +- apply a patch only when the operator passes `--apply` and the approval ledger names the reviewer, reason, patch hash, target files, target file SHA-256 baselines, verification commands, and rollback plan. - automatically reverse an applied patch when `--run-verification` fails, unless the operator explicitly passes `--no-rollback-on-failure`. It must not: @@ -21,6 +21,7 @@ It must not: - write source files as part of scan or proposal generation; - write source files through `adapt-apply` without explicit `--apply`; - apply a patch whose target files are outside both the proposal and approval allowlists; +- apply a patch when an approved target file has changed since the reviewer recorded its baseline SHA-256; - leave a failed verified apply in place by default; - count proposals as completed implementation evidence. @@ -31,7 +32,7 @@ It must not: 3. A reviewer decides whether any proposal is worth implementing. 4. `adapt-apply --write-template` creates `reports/adaptation_approval_ledger.json` and `reports/adaptation_regression_report.json` so the review surface exists before any patch is applied. 5. `adapt-apply --proposal-id --patch-file ` defaults to a dry-run and records patch, target, approval, regression, and rollback evidence. -6. `adapt-apply --apply --run-verification` may write files only after approval, patch hash, allowlist, `git apply --check`, and safe regression command checks pass. +6. `adapt-apply --apply --run-verification` may write files only after approval, patch hash, allowlist, target baseline hash, `git apply --check`, and safe regression command checks pass. 7. If a verification command fails after a patch is applied, `adapt-apply` runs `git apply -R ` by default and records `failed-rolled-back` plus rollback evidence in `reports/adaptation_regression_report.json`. ## Evidence Standard @@ -51,6 +52,7 @@ Each approved application should include: - reviewer, reason, approval date, and optional expiry; - exact patch SHA-256; - target file allowlist; +- target file SHA-256 baselines for every patch target, or `__absent__` for approved new files; - regression commands restricted to local `make` targets or local Python verifier scripts; - rollback command or plan. - rollback result if regression failed after an apply attempt. diff --git a/reports/adaptation_approval_ledger.json b/reports/adaptation_approval_ledger.json index ab70ac3..1810438 100644 --- a/reports/adaptation_approval_ledger.json +++ b/reports/adaptation_approval_ledger.json @@ -12,6 +12,7 @@ "approval_required": true, "patch_sha256_required": true, "allowlisted_targets_required": true, + "target_file_sha256_required": true, "dry_run_default": true, "writes_repository_files_only_with_apply": true, "rollback_required": true diff --git a/reports/adaptation_proposals.json b/reports/adaptation_proposals.json index 2e5c025..43e56d3 100644 --- a/reports/adaptation_proposals.json +++ b/reports/adaptation_proposals.json @@ -15,6 +15,7 @@ "approval_required": true, "writes_repository_files": false, "allowlisted_targets_required": true, + "target_file_sha256_required_for_apply": true, "rollback_required_for_apply": true, "apply_command_available": true }, diff --git a/reports/adaptation_regression_report.json b/reports/adaptation_regression_report.json index 75d985f..f5641a2 100644 --- a/reports/adaptation_regression_report.json +++ b/reports/adaptation_regression_report.json @@ -17,6 +17,7 @@ "approval_required": true, "patch_sha256_required": true, "allowlisted_targets_required": true, + "target_file_sha256_required": true, "dry_run_default": true, "writes_repository_files_only_with_apply": true, "rollback_required": true, diff --git a/schemas/adaptation-proposal.schema.json b/schemas/adaptation-proposal.schema.json index 876e84a..ce03b56 100644 --- a/schemas/adaptation-proposal.schema.json +++ b/schemas/adaptation-proposal.schema.json @@ -51,6 +51,7 @@ "approval_required", "writes_repository_files", "allowlisted_targets_required", + "target_file_sha256_required_for_apply", "rollback_required_for_apply", "apply_command_available" ], @@ -67,6 +68,9 @@ "allowlisted_targets_required": { "const": true }, + "target_file_sha256_required_for_apply": { + "const": true + }, "rollback_required_for_apply": { "const": true }, diff --git a/scripts/apply_adaptation.py b/scripts/apply_adaptation.py index a0bb6b5..a75712a 100644 --- a/scripts/apply_adaptation.py +++ b/scripts/apply_adaptation.py @@ -18,6 +18,7 @@ SCRIPT_INTERFACE = "cli" SCRIPT_INTERFACE_REASON = "Approval-gated adaptive patch application with dry-run, allowlist, regression, and rollback evidence." BLOCKED_PATH_PARTS = {".git", "__pycache__", ".pytest_cache", "dist"} +ABSENT_FILE_SHA256 = "__absent__" def utc_now() -> str: @@ -109,7 +110,16 @@ def parse_date(value: str) -> date | None: def validate_approval(approval: dict[str, Any], today: date) -> list[str]: failures = [] - required = ["reviewer", "reason", "approved_at", "patch_sha256", "target_files", "verification_commands", "rollback_plan"] + required = [ + "reviewer", + "reason", + "approved_at", + "patch_sha256", + "target_files", + "target_file_sha256", + "verification_commands", + "rollback_plan", + ] for key in required: if not approval.get(key): failures.append(f"Approval entry missing required field: {key}") @@ -118,11 +128,36 @@ def validate_approval(approval: dict[str, Any], today: date) -> list[str]: failures.append(f"Approval entry is expired: {approval.get('expires_at')}") if not isinstance(approval.get("target_files"), list): failures.append("Approval target_files must be a list.") + if not isinstance(approval.get("target_file_sha256"), dict): + failures.append("Approval target_file_sha256 must be an object.") if not isinstance(approval.get("verification_commands"), list): failures.append("Approval verification_commands must be a list.") return failures +def validate_target_file_sha256( + skill_dir: Path, + target_files: list[str], + expected_sha256: dict[str, Any], +) -> tuple[list[str], dict[str, str]]: + failures: list[str] = [] + observed: dict[str, str] = {} + for target in target_files: + path = skill_dir / target + expected = str(expected_sha256.get(target, "")) + if not expected: + failures.append(f"Approval target_file_sha256 missing target: {target}") + continue + if path.exists() and not path.is_file(): + failures.append(f"Patch target is not a file: {target}") + continue + current = sha256_file(path) if path.exists() else ABSENT_FILE_SHA256 + observed[target] = current + if current != expected: + failures.append(f"Target file baseline sha256 does not match approval ledger: {target}") + return failures, observed + + def safe_command(command: str) -> tuple[bool, list[str], str]: try: parts = shlex.split(command) @@ -189,6 +224,7 @@ def empty_approval_ledger(generated_at: str) -> dict[str, Any]: "approval_required": True, "patch_sha256_required": True, "allowlisted_targets_required": True, + "target_file_sha256_required": True, "dry_run_default": True, "writes_repository_files_only_with_apply": True, "rollback_required": True, @@ -217,6 +253,7 @@ def empty_regression_report(skill_dir: Path, generated_at: str) -> dict[str, Any "approval_required": True, "patch_sha256_required": True, "allowlisted_targets_required": True, + "target_file_sha256_required": True, "dry_run_default": True, "writes_repository_files_only_with_apply": True, "rollback_required": True, @@ -320,6 +357,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: patch_sha = sha256_file(patch_path) if patch_path.exists() else "" target_files: list[str] = [] + observed_target_file_sha256: dict[str, str] = {} if not failures: try: target_files = patch_target_files(patch_path.read_text(encoding="utf-8", errors="replace")) @@ -337,6 +375,13 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: failures.append("Patch touches files outside approval target_files.") if not patch_targets <= proposal_targets: failures.append("Patch touches files outside proposal target_files.") + if not failures: + baseline_failures, observed_target_file_sha256 = validate_target_file_sha256( + skill_dir, + target_files, + approval.get("target_file_sha256", {}), + ) + failures.extend(baseline_failures) check = git_apply_check(skill_dir, patch_path) if not failures else {"ok": False, "returncode": None, "stdout": "", "stderr": ""} if not failures and not check["ok"]: @@ -378,6 +423,13 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: "approved_at": approval.get("approved_at", ""), "expires_at": approval.get("expires_at", ""), }, + "target_file_sha256": { + "expected": { + target: str(approval.get("target_file_sha256", {}).get(target, "")) + for target in target_files + }, + "observed": observed_target_file_sha256, + }, "git_apply_check": check, "git_apply": apply_result, "regression_runs": regression_runs, @@ -406,6 +458,7 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: "approval_required": True, "patch_sha256_required": True, "allowlisted_targets_required": True, + "target_file_sha256_required": True, "dry_run_default": True, "writes_repository_files_only_with_apply": True, "rollback_required": True, diff --git a/scripts/propose_adaptation.py b/scripts/propose_adaptation.py index c8d0701..6c4c151 100644 --- a/scripts/propose_adaptation.py +++ b/scripts/propose_adaptation.py @@ -178,6 +178,7 @@ def build_report(skill_dir: Path, patterns_json: Path, generated_at: str) -> dic "approval_required": True, "writes_repository_files": False, "allowlisted_targets_required": True, + "target_file_sha256_required_for_apply": True, "rollback_required_for_apply": True, "apply_command_available": (ROOT / "scripts" / "apply_adaptation.py").exists(), }, diff --git a/tests/verify_adaptation_safety.py b/tests/verify_adaptation_safety.py index e7175e3..498a31e 100644 --- a/tests/verify_adaptation_safety.py +++ b/tests/verify_adaptation_safety.py @@ -91,6 +91,7 @@ def main() -> None: assert proposal_payload["proposal_contract"]["proposal_only"] is True, proposal_payload assert proposal_payload["proposal_contract"]["writes_repository_files"] is False, proposal_payload assert proposal_payload["proposal_contract"]["apply_command_available"] is True, proposal_payload + assert proposal_payload["proposal_contract"]["target_file_sha256_required_for_apply"] is True, proposal_payload assert proposal_payload["summary"]["proposal_count"] >= 3, proposal_payload assert all(item["status"] == "proposal-only" for item in proposal_payload["proposals"]), proposal_payload assert all(item["requires_approval"] is True for item in proposal_payload["proposals"]), proposal_payload @@ -107,6 +108,7 @@ def main() -> None: template_payload = json.loads(template_proc.stdout) assert template_payload["summary"]["apply_supported"] is True, template_payload assert template_payload["summary"]["attempt_count"] == 0, template_payload + assert template_payload["apply_contract"]["target_file_sha256_required"] is True, template_payload assert (reports_dir / "adaptation_approval_ledger.json").exists(), reports_dir assert (reports_dir / "adaptation_regression_report.json").exists(), reports_dir @@ -166,6 +168,7 @@ def main() -> None: assert cli_proposal_payload["proposal_contract"]["proposal_only"] is True, cli_proposal_payload assert cli_proposal_payload["proposal_contract"]["writes_repository_files"] is False, cli_proposal_payload assert cli_proposal_payload["proposal_contract"]["apply_command_available"] is True, cli_proposal_payload + assert cli_proposal_payload["proposal_contract"]["target_file_sha256_required_for_apply"] is True, cli_proposal_payload policy_path = cli_skill_dir / "references" / "user-memory-policy.md" policy_path.parent.mkdir(parents=True, exist_ok=True) @@ -198,6 +201,7 @@ def main() -> None: "approval_required": True, "patch_sha256_required": True, "allowlisted_targets_required": True, + "target_file_sha256_required": True, "dry_run_default": True, "writes_repository_files_only_with_apply": True, "rollback_required": True, @@ -212,6 +216,9 @@ def main() -> None: "expires_at": "2026-12-31", "patch_sha256": sha256_text(patch_text), "target_files": ["references/user-memory-policy.md"], + "target_file_sha256": { + "references/user-memory-policy.md": sha256_text("old policy\n"), + }, "verification_commands": ["python3 tests/check_policy.py"], "rollback_plan": "git apply -R approved-adaptation.patch", } @@ -219,6 +226,29 @@ def main() -> None: } ledger_path = cli_skill_dir / "reports" / "adaptation_approval_ledger.json" ledger_path.write_text(json.dumps(approval_ledger, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + policy_path.write_text("changed outside approval\n", encoding="utf-8") + stale_proc = run_script( + str(CLI), + "adapt-apply", + str(cli_skill_dir), + "--proposal-id", + approval_proposal["proposal_id"], + "--patch-file", + str(patch_path), + "--output-json", + str(cli_skill_dir / "reports" / "stale_baseline_regression.json"), + "--output-md", + str(cli_skill_dir / "reports" / "stale_baseline_regression.md"), + "--generated-at", + "2026-06-15T00:00:00Z", + "--today", + "2026-06-15", + ) + assert stale_proc.returncode == 2, stale_proc.stdout + stale_payload = json.loads(stale_proc.stdout) + assert any("baseline sha256" in item for item in stale_payload["failures"]), stale_payload + assert "approved adaptive note" not in policy_path.read_text(encoding="utf-8"), policy_path + policy_path.write_text("old policy\n", encoding="utf-8") dry_run = run_script( str(CLI), "adapt-apply",