Harden development gates and align the governing rules #213
+139
-10
@@ -17,16 +17,145 @@ jobs:
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
# Credential handling stays inline: no file from the candidate is executed.
|
||||
# Workflow provenance and runner isolation still require platform controls.
|
||||
python3 - <<'PY_CHECKOUT'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
|
||||
# 2026-10-05 修复(run 948/949 实测):注入值为内网直连形态
|
||||
# 'http://172.17.0.1:3000'(docker 网关 → 宿主 Gitea),与公网拼写不同且为
|
||||
# http——此前硬编码常量严格比对会拒绝合法检出。校验收宽为「http(s) + 非空
|
||||
# netloc」;凭据仍只附着于「该基址 + 下方仓库白名单」构造的 URL。
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
# Reviewed allowlist, independent of candidate .gitmodules. Add repositories
|
||||
# here only with an explicit review of the checkout credential's scope.
|
||||
REPOSITORIES = {
|
||||
"modules/core": "Agent-System-Core",
|
||||
"modules/backend": "Agent-System-Backend",
|
||||
"modules/frontend": "Agent-System-Frontend",
|
||||
"modules/ui": "Agent-System-UI",
|
||||
"modules/documents": "Agent-System-Documents",
|
||||
"deps/agent-system-core": "Agent-System-Core",
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
"deps/agent-native": "Agent-Native",
|
||||
"deps/agent-squad": "Agent-Squad",
|
||||
"deps/agent-intent": "Agent-Intent",
|
||||
"deps/agent-command": "Agent-Command",
|
||||
"deps/error-code-system": "Error-Code-System",
|
||||
"deps/logging-system": "Logging-System",
|
||||
}
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
if not root_token or not component_token:
|
||||
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
|
||||
if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Dev":
|
||||
sys.exit("Checkout repository is outside the reviewed allowlist")
|
||||
sha = os.environ.get("GITHUB_SHA", "")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
sys.exit("Checkout requires a fixed commit SHA")
|
||||
|
||||
# Discard inherited Git tracing/config/SSH hooks. Auth lives only in one
|
||||
# Git process tree, in an exact URL-scoped header, never in a URL or file.
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
|
||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
|
||||
config = {
|
||||
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
|
||||
"init.templateDir": "", "credential.helper": "",
|
||||
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never",
|
||||
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
|
||||
}
|
||||
|
||||
|
||||
def git(repo, *args, extra=None):
|
||||
values = config | (extra or {})
|
||||
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return subprocess.run(["git", "-C", str(repo), *args], env=env,
|
||||
check=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def materialize(repo, depth=0):
|
||||
if depth > 16:
|
||||
raise ValueError("Submodule depth exceeds the reviewed checkout limit")
|
||||
links = {}
|
||||
for entry in git(repo, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
if not links:
|
||||
return
|
||||
raw = git(repo, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
raise ValueError("Unsupported candidate .gitmodules setting")
|
||||
name, field = match.groups()
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
if set(entries) != set(links):
|
||||
raise ValueError("Submodule names must match the fixed gitlink paths")
|
||||
# Validate the entire level before any authenticated component fetch.
|
||||
for name, fields in entries.items():
|
||||
repository = REPOSITORIES.get(name)
|
||||
if not repository or fields.get("path") != name:
|
||||
raise ValueError("Submodule path is outside the reviewed allowlist")
|
||||
trusted_url = f"{SERVER}/weichen/{repository}.git"
|
||||
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
|
||||
raise ValueError("Submodule URL is outside the reviewed allowlist")
|
||||
for name in entries:
|
||||
url = f"{SERVER}/weichen/{REPOSITORIES[name]}.git"
|
||||
# Persist only the reviewed public URL so Git recognizes the
|
||||
# initialized submodule after process-scoped config is gone.
|
||||
git(repo, "config", f"submodule.{name}.url", url)
|
||||
git(repo, "submodule", "update", "--init", "--checkout", "--", name,
|
||||
extra={f"submodule.{name}.url": url,
|
||||
f"submodule.{name}.update": "checkout",
|
||||
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
|
||||
child = repo / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
|
||||
raise ValueError("Materialized submodule does not match the fixed SHA")
|
||||
materialize(child, depth + 1)
|
||||
|
||||
|
||||
try:
|
||||
root = Path.cwd()
|
||||
# An ephemeral clean checkout is required; do not reuse prior Git state.
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
url = SERVER + "/weichen/Agent-System-Dev.git"
|
||||
git(root, "remote", "add", "origin", url)
|
||||
git(root, "fetch", "--depth=1", "origin", sha,
|
||||
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
materialize(root)
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit(f"Checkout refused: {error}")
|
||||
print("Checkout complete; credentials were not persisted in Git configuration")
|
||||
PY_CHECKOUT
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/bin/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
/data/
|
||||
/data-test/
|
||||
.zcode/plans/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"status": "complete",
|
||||
"verdict": "Ready with fixes",
|
||||
"verdict_reason": "Resolve the P1 cleanup-preservation verification gate (#1), then validate the quoted-credential redaction fix (#3), stale-receipt detection (#2), and abandon lifecycle coverage (#4). Finding #1 remains a blocker despite being excluded from the actionable queue. This verdict covers only c0abfb0, not the root's subsequent repairs.",
|
||||
"scope": {
|
||||
"base": "43d33aa4921078bc202393b99cba017d7f731b98",
|
||||
"branch": "feature/governance-reliability",
|
||||
"head_sha": "c0abfb0250980af7f1b18c785eb4b55240a8d855",
|
||||
"pr_url": null,
|
||||
"files_changed": 24
|
||||
},
|
||||
"intent": "Implement approved development-governance audit repairs: safe task cleanup and recursive pinned component validation; trustworthy SELF evidence and startup receipt flow; path-specific write guards and bounded CI checkout credentials. Preserve dirty/ignored/unique child history and fail closed without claiming live platform controls.",
|
||||
"intent_confidence": "explicit",
|
||||
"reviewers": [
|
||||
"correctness",
|
||||
"project-standards",
|
||||
"testing",
|
||||
"maintainability",
|
||||
"agent-native",
|
||||
"security",
|
||||
"reliability"
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"#": 1,
|
||||
"autofix_class": "advisory",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/task.sh:438-445 rechecks finish safety, adds --force for gitlinks, and removes the parent worktree; scripts/check-worktree.py:51-69 checks the current child checkout and reachable commits but not its additional linked worktrees.",
|
||||
"SELF scratch reproduction: initialize the pinned component; add an external linked component worktree at HEAD; modify its content; finish returns 0; git status in the external worktree then exits 128 because its common metadata under the removed parent registration is gone.",
|
||||
"Verified provenance: 43d33aa scripts/task.sh:381-386 already used remove || rm -rf plus prune; source artifact also records originating fdf63e3 and a failing BASE reproduction. The reviewed diff rewrites that removal around new preservation checks."
|
||||
],
|
||||
"file": "scripts/task.sh",
|
||||
"first_evidence": "scripts/task.sh:443: removal+=(--force)",
|
||||
"independent_reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"line": 443,
|
||||
"owner": "human",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"severity": "P1",
|
||||
"suggested_fix": "Before allowing parent removal, recursively inspect each component's worktree registrations/common Git directory. Refuse cleanup when a component owns any additional registered linked worktree whose metadata would be removed, and require the owner to preserve or relocate it explicitly. Add a regression with an external linked component worktree at the same pinned SHA, including staged or uncommitted content, and assert cleanup refuses and the external worktree remains usable.",
|
||||
"title": "Preserve submodule metadata used by other registered worktrees",
|
||||
"why_it_matters": "The rewritten forced-removal path trusts finish checks that inspect only each component's current checkout and reachable commits. An initialized submodule can own another linked worktree at the same pinned commit, so all checks pass even when that separate worktree contains staged or uncommitted work. Removing the parent task deletes its embedded submodule common directory and the other worktree's Git index/config/object database. Reproduced: task finish returned 0, while git status in the surviving external component worktree then failed with exit 128. This is secondary to the new guard-dependent deletion path; the older removal implementation also exhibited the defect.",
|
||||
"protected_subject": "data-loss",
|
||||
"validation_status": "unresolved",
|
||||
"validation_reason": "Protected-subject rejection reclassified as unresolved. Base 43d33aa scripts/task.sh:381-386 confirms older destructive behavior, but does not establish unrelatedness or refute the failure at reviewed c0abfb0: task.sh:438-445 expressly makes the new preservation checks the basis for forced removal, while check-worktree.py:51-69 omits linked component worktree ownership. Resolve this gate with a discriminating cleanup regression that preserves an external component worktree and its staged/uncommitted content, or inspected evidence disproving this interaction. Root accepts the remediation scope and reports repairs in progress; those repairs are outside this reviewed commit."
|
||||
},
|
||||
{
|
||||
"#": 2,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/evidence.py:227-239 appends fenced JSON receipts; scripts/task.sh:242-251 only scans timestamp-leading Markdown table rows for stale tested HEADs.",
|
||||
"SELF scratch reproduction: capture a receipt, commit it, advance origin/main with a non-card change, and sync. The recorded before HEAD differs in implementation content, but output contains no [evidence] WARN."
|
||||
],
|
||||
"file": "scripts/evidence.py",
|
||||
"first_evidence": "scripts/evidence.py:231: + \"```json\\n\" + encoded + \"\\n```\\n\")",
|
||||
"independent_reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"line": 231,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Update task-sync's stale-evidence reader to parse the fenced JSON receipts produced by evidence.py and inspect their recorded object SHA/state when deciding whether non-card changes invalidate a check. Retain legacy-table support if historical cards remain supported. Add a regression recording a JSON receipt, advancing main with a non-card change, and checking the stale-evidence warning.",
|
||||
"title": "Teach stale-evidence checking to read the new receipt format",
|
||||
"why_it_matters": "Evidence now appends fenced JSON, but task-sync still finds old evidence only by matching timestamped Markdown table rows. Consequently every newly recorded receipt is invisible to the existing stale-object warning. A normal sequence of recording a check, merging changed implementation from main, and syncing now silently retains stale evidence without the warning that previously requested reruns.",
|
||||
"protected_subject": null,
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "The reviewed diff adds JSON receipt append_card output in scripts/evidence.py:228-239, while scripts/task.sh:250 still scans only timestamp-leading Markdown table rows, so a recorded JSON receipt is unreachable by the stale-evidence warning after a non-card sync change."
|
||||
},
|
||||
{
|
||||
"#": 3,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/evidence.py:44 requires an unquoted Authorization header; lines 117-125 sanitize before receipt construction, lines 193-194 reuse the sanitizer during import, and lines 227-239 append to the task card.",
|
||||
"SELF synthetic capture/import reproduction at c0abfb0250980af7f1b18c785eb4b55240a8d855: quoted JSON Authorization remains in both receipt and task card; import exits 0; receipt source is SELF. No real credential was used.",
|
||||
"Reviewer-reported SELF targeted suites: test_ci_credentials.py 7 tests, test_evidence.py 28 tests, and test_write_guard.py 12 tests passed. These suites do not refute the synthetic bypass."
|
||||
],
|
||||
"file": "scripts/evidence.py",
|
||||
"first_evidence": "scripts/evidence.py:44: value = re.sub(r\"(?i)(\\bAuthorization[ \\t]*:[ \\t]*(?:Bearer|token|Basic)[ \\t]+)[^\\s'\\\";]+\",",
|
||||
"independent_reviewers": [
|
||||
"security"
|
||||
],
|
||||
"line": 44,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"security"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Extend Authorization redaction to recognize optionally quoted header keys and quoted values used by JSON and Python dictionary dumps while retaining plain-header support. Add capture-and-import regression cases for double-quoted JSON and single-quoted dictionary headers, using a synthetic credential absent from the recorder environment, and assert that command, output, receipt, console, and task card contain no credential.",
|
||||
"title": "CWE-532: Redact quoted Authorization headers before storing evidence",
|
||||
"why_it_matters": "A command that logs request headers as JSON or a Python dictionary can persist its bearer/token credential into the receipt and tracked task card. For example, {\"Authorization\": \"Bearer synthetic-json-header-secret\"} bypasses sanitize() because the regex requires an unquoted header name immediately followed by a colon and an unquoted scheme. Known-environment-value replacement cannot help when the credential was read from a protected file or created inside the child command. Import accepts the same unredacted receipt, so subsequent publication of the task card exposes the credential.",
|
||||
"protected_subject": "secrets-exposure",
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "At the reviewed revision, sanitize('{\"Authorization\": \"Bearer synthetic-json-header-secret\"}') returned that value unchanged, and the new evidence.py stores sanitize_tree output in receipts and task cards, so a quoted JSON Authorization value can be persisted when such command output occurs."
|
||||
},
|
||||
{
|
||||
"#": 4,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 75,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/task.sh:420-424 records the abandon reason; lines 449-450 retain the branch and skip remote deletion.",
|
||||
"Validator AST inspection and report-leaf pinned-source text inspection found no --abandon invocation in reviewed tests/test_task.py."
|
||||
],
|
||||
"file": "scripts/task.sh",
|
||||
"first_evidence": "scripts/task.sh:420: if [ \"$abandon\" = \"--abandon\" ]; then",
|
||||
"independent_reviewers": [
|
||||
"testing"
|
||||
],
|
||||
"line": 420,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"testing"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Add a disposable-repository lifecycle test that creates unmerged local and remote task work, runs `finish --abandon <reason>` with `TASK_WRITERS_STOPPED=1`, then asserts the worktree is removed, the local and remote branch tips remain reachable, and `refs/notes/task-abandon` records the reason. Add a companion missing-reason assertion that preserves the worktree.",
|
||||
"title": "Test the abandon cleanup path",
|
||||
"why_it_matters": "The new explicit-abandon path removes a worktree while deliberately retaining the local branch and skipping remote deletion. No regression test exercises that lifecycle contract, so a future change can silently recycle unmerged work incorrectly or delete the retained ref without the governance suite detecting it.",
|
||||
"protected_subject": null,
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "The reviewed diff's finish --abandon branch records a note and retains the branch, but AST inspection of tests/test_task.py found no invocation with --abandon, leaving that lifecycle contract without regression coverage."
|
||||
}
|
||||
],
|
||||
"actionable_findings": [
|
||||
{
|
||||
"#": 2,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/evidence.py:227-239 appends fenced JSON receipts; scripts/task.sh:242-251 only scans timestamp-leading Markdown table rows for stale tested HEADs.",
|
||||
"SELF scratch reproduction: capture a receipt, commit it, advance origin/main with a non-card change, and sync. The recorded before HEAD differs in implementation content, but output contains no [evidence] WARN."
|
||||
],
|
||||
"file": "scripts/evidence.py",
|
||||
"first_evidence": "scripts/evidence.py:231: + \"```json\\n\" + encoded + \"\\n```\\n\")",
|
||||
"independent_reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"line": 231,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"correctness"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Update task-sync's stale-evidence reader to parse the fenced JSON receipts produced by evidence.py and inspect their recorded object SHA/state when deciding whether non-card changes invalidate a check. Retain legacy-table support if historical cards remain supported. Add a regression recording a JSON receipt, advancing main with a non-card change, and checking the stale-evidence warning.",
|
||||
"title": "Teach stale-evidence checking to read the new receipt format",
|
||||
"why_it_matters": "Evidence now appends fenced JSON, but task-sync still finds old evidence only by matching timestamped Markdown table rows. Consequently every newly recorded receipt is invisible to the existing stale-object warning. A normal sequence of recording a check, merging changed implementation from main, and syncing now silently retains stale evidence without the warning that previously requested reruns.",
|
||||
"protected_subject": null,
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "The reviewed diff adds JSON receipt append_card output in scripts/evidence.py:228-239, while scripts/task.sh:250 still scans only timestamp-leading Markdown table rows, so a recorded JSON receipt is unreachable by the stale-evidence warning after a non-card sync change."
|
||||
},
|
||||
{
|
||||
"#": 3,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 100,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/evidence.py:44 requires an unquoted Authorization header; lines 117-125 sanitize before receipt construction, lines 193-194 reuse the sanitizer during import, and lines 227-239 append to the task card.",
|
||||
"SELF synthetic capture/import reproduction at c0abfb0250980af7f1b18c785eb4b55240a8d855: quoted JSON Authorization remains in both receipt and task card; import exits 0; receipt source is SELF. No real credential was used.",
|
||||
"Reviewer-reported SELF targeted suites: test_ci_credentials.py 7 tests, test_evidence.py 28 tests, and test_write_guard.py 12 tests passed. These suites do not refute the synthetic bypass."
|
||||
],
|
||||
"file": "scripts/evidence.py",
|
||||
"first_evidence": "scripts/evidence.py:44: value = re.sub(r\"(?i)(\\bAuthorization[ \\t]*:[ \\t]*(?:Bearer|token|Basic)[ \\t]+)[^\\s'\\\";]+\",",
|
||||
"independent_reviewers": [
|
||||
"security"
|
||||
],
|
||||
"line": 44,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"security"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Extend Authorization redaction to recognize optionally quoted header keys and quoted values used by JSON and Python dictionary dumps while retaining plain-header support. Add capture-and-import regression cases for double-quoted JSON and single-quoted dictionary headers, using a synthetic credential absent from the recorder environment, and assert that command, output, receipt, console, and task card contain no credential.",
|
||||
"title": "CWE-532: Redact quoted Authorization headers before storing evidence",
|
||||
"why_it_matters": "A command that logs request headers as JSON or a Python dictionary can persist its bearer/token credential into the receipt and tracked task card. For example, {\"Authorization\": \"Bearer synthetic-json-header-secret\"} bypasses sanitize() because the regex requires an unquoted header name immediately followed by a colon and an unquoted scheme. Known-environment-value replacement cannot help when the credential was read from a protected file or created inside the child command. Import accepts the same unredacted receipt, so subsequent publication of the task card exposes the credential.",
|
||||
"protected_subject": "secrets-exposure",
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "At the reviewed revision, sanitize('{\"Authorization\": \"Bearer synthetic-json-header-secret\"}') returned that value unchanged, and the new evidence.py stores sanitize_tree output in receipts and task cards, so a quoted JSON Authorization value can be persisted when such command output occurs."
|
||||
},
|
||||
{
|
||||
"#": 4,
|
||||
"autofix_class": "gated_auto",
|
||||
"confidence": 75,
|
||||
"evidence": [
|
||||
"c0abfb0 scripts/task.sh:420-424 records the abandon reason; lines 449-450 retain the branch and skip remote deletion.",
|
||||
"Validator AST inspection and report-leaf pinned-source text inspection found no --abandon invocation in reviewed tests/test_task.py."
|
||||
],
|
||||
"file": "scripts/task.sh",
|
||||
"first_evidence": "scripts/task.sh:420: if [ \"$abandon\" = \"--abandon\" ]; then",
|
||||
"independent_reviewers": [
|
||||
"testing"
|
||||
],
|
||||
"line": 420,
|
||||
"owner": "downstream-resolver",
|
||||
"pre_existing": false,
|
||||
"requires_verification": true,
|
||||
"reviewers": [
|
||||
"testing"
|
||||
],
|
||||
"severity": "P2",
|
||||
"suggested_fix": "Add a disposable-repository lifecycle test that creates unmerged local and remote task work, runs `finish --abandon <reason>` with `TASK_WRITERS_STOPPED=1`, then asserts the worktree is removed, the local and remote branch tips remain reachable, and `refs/notes/task-abandon` records the reason. Add a companion missing-reason assertion that preserves the worktree.",
|
||||
"title": "Test the abandon cleanup path",
|
||||
"why_it_matters": "The new explicit-abandon path removes a worktree while deliberately retaining the local branch and skipping remote deletion. No regression test exercises that lifecycle contract, so a future change can silently recycle unmerged work incorrectly or delete the retained ref without the governance suite detecting it.",
|
||||
"protected_subject": null,
|
||||
"validation_status": "confirmed",
|
||||
"validation_reason": "The reviewed diff's finish --abandon branch records a note and retains the branch, but AST inspection of tests/test_task.py found no invocation with --abandon, leaving that lifecycle contract without regression coverage."
|
||||
}
|
||||
],
|
||||
"triage_groups": [
|
||||
{
|
||||
"title": "Task cleanup preservation",
|
||||
"findings": [
|
||||
1,
|
||||
4
|
||||
],
|
||||
"context": "Task disposal must preserve component metadata and abandoned branch history.",
|
||||
"preferred_resolution": "Decision-gate: resolve the cleanup-preservation verification gate (#1) before cleanup acceptance; add the independent abandon-retention regression (#4). Only #4 is in the actionable queue.",
|
||||
"why": "Both concern irreversible checkout disposal and can share disposable Git lifecycle fixtures."
|
||||
},
|
||||
{
|
||||
"title": "Evidence receipt integrity",
|
||||
"findings": [
|
||||
2,
|
||||
3
|
||||
],
|
||||
"context": "JSON receipts are newly consumed as tracked evidence and must stay current and sanitized.",
|
||||
"preferred_resolution": "Apply-queue: close quoted credential redaction (#3), then update stale-receipt detection (#2), each with its discriminating regression.",
|
||||
"why": "The receipt producer and consumer need to preserve the same confidentiality and tested-object contract."
|
||||
}
|
||||
],
|
||||
"pre_existing_findings": [],
|
||||
"requirements_completeness": null,
|
||||
"learnings": [],
|
||||
"agent_native_gaps": [],
|
||||
"deployment_notes": [],
|
||||
"residual_risks": [
|
||||
"Live runner isolation, workflow provenance and checkout-token scope were not replayed; policy/platform-admission.md explicitly leaves those controls to platform validation."
|
||||
],
|
||||
"testing_gaps": [
|
||||
"Live CI, runner isolation, workflow provenance, and production incidence of the synthetic Authorization-header exposure were not measured."
|
||||
],
|
||||
"coverage": {
|
||||
"notes": [
|
||||
"Review is pinned to c0abfb0250980af7f1b18c785eb4b55240a8d855 against 43d33aa4921078bc202393b99cba017d7f731b98. Report-stage source reads used git show; ongoing root repairs were not reviewed.",
|
||||
"All seven selected local reviewers completed. Project standards used the AGENTS.md fallback because no CODING_STANDARDS.md was found.",
|
||||
"No applicable plan was discovered; requirements completeness and settlement suppression were not evaluated.",
|
||||
"All local command and reproduction claims remain SELF. Author-reported make test/build results were not promoted to GATE or REPLAY.",
|
||||
"Synthesis checked all four anchors and surrounding code, merged no semantic duplicates, and omitted the editorial make-help observation under the scope constraint.",
|
||||
"No repository edits, commits, pushes, branch changes, or local fixes were performed by this review."
|
||||
],
|
||||
"reviewer_selection": [
|
||||
{
|
||||
"reviewer": "correctness",
|
||||
"tier": "session",
|
||||
"reason": "logic and state changes in lifecycle and evidence"
|
||||
},
|
||||
{
|
||||
"reviewer": "project-standards",
|
||||
"tier": "gpt-5.6-terra",
|
||||
"reason": "AGENTS.md explicitly governs changed commands and safety contracts"
|
||||
},
|
||||
{
|
||||
"reviewer": "testing",
|
||||
"tier": "gpt-5.6-terra",
|
||||
"reason": "new Git fixture suites and silent-pass test gates"
|
||||
},
|
||||
{
|
||||
"reviewer": "maintainability",
|
||||
"tier": "gpt-5.6-terra",
|
||||
"reason": "1573 executable changed lines and new Python modules"
|
||||
},
|
||||
{
|
||||
"reviewer": "agent-native",
|
||||
"tier": "gpt-5.6-terra",
|
||||
"reason": "agent instructions and tool write authorization changed"
|
||||
},
|
||||
{
|
||||
"reviewer": "security",
|
||||
"tier": "session",
|
||||
"reason": "credentials, evidence redaction and path authorization"
|
||||
},
|
||||
{
|
||||
"reviewer": "reliability",
|
||||
"tier": "gpt-5.6-terra",
|
||||
"reason": "failure propagation and partially completed cleanup/sync"
|
||||
}
|
||||
],
|
||||
"lite": false,
|
||||
"suppressed_by_confidence": {},
|
||||
"suppressed_findings": 0,
|
||||
"malformed_findings": 0,
|
||||
"malformed_returns": 0,
|
||||
"first_evidence_backfilled": 0,
|
||||
"quote_demotions": 0,
|
||||
"mode_aware_demotions": 0,
|
||||
"semantic_duplicates_merged": 0,
|
||||
"settlement_suppression_evaluated": false,
|
||||
"settlement_suppressed": 0,
|
||||
"failed_reviewers": [],
|
||||
"bound_exceeded": [],
|
||||
"cross_model": "Cross-model adversarial peer: timeout, reaped on caller request to bound review latency before completion; no usable artifact. Route=claude; requested model=claude-opus-5; requested effort=high; receipt_supported=unverified; model_actual=unverified; effort_actual=unverified; independence_verified=false. Adversarial lens degraded; no cross-model confidence promotion. No detached job remains.",
|
||||
"compound_packs": {
|
||||
"roots": [],
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
},
|
||||
"validation": {
|
||||
"batch_outcome": "verdicts",
|
||||
"selected": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
],
|
||||
"bound_exceeded": false,
|
||||
"confirmed": 3,
|
||||
"rejected": 0,
|
||||
"unresolved": 1,
|
||||
"malformed": 0,
|
||||
"failed": 0,
|
||||
"shortcut_skipped": 0,
|
||||
"shortcut_basis": "No finding had verified cross-model corroboration; every selected finding entered the single validator batch.",
|
||||
"raw_verdicts": [
|
||||
{
|
||||
"#": 1,
|
||||
"status": "rejected",
|
||||
"protected_subject": "data-loss",
|
||||
"reason": "Although a scratch fixture reproduced the loss when a clean pinned component has another linked worktree with uncommitted content, base 43d33aa's scripts/task.sh:381-386 already removed the parent with rm -rf plus worktree prune, so this destructive behavior predates rather than being introduced by the reviewed diff."
|
||||
},
|
||||
{
|
||||
"#": 2,
|
||||
"status": "confirmed",
|
||||
"protected_subject": null,
|
||||
"reason": "The reviewed diff adds JSON receipt append_card output in scripts/evidence.py:228-239, while scripts/task.sh:250 still scans only timestamp-leading Markdown table rows, so a recorded JSON receipt is unreachable by the stale-evidence warning after a non-card sync change."
|
||||
},
|
||||
{
|
||||
"#": 3,
|
||||
"status": "confirmed",
|
||||
"protected_subject": "secrets-exposure",
|
||||
"reason": "At the reviewed revision, sanitize('{\"Authorization\": \"Bearer synthetic-json-header-secret\"}') returned that value unchanged, and the new evidence.py stores sanitize_tree output in receipts and task cards, so a quoted JSON Authorization value can be persisted when such command output occurs."
|
||||
},
|
||||
{
|
||||
"#": 4,
|
||||
"status": "confirmed",
|
||||
"protected_subject": null,
|
||||
"reason": "The reviewed diff's finish --abandon branch records a note and retains the branch, but AST inspection of tests/test_task.py found no invocation with --abandon, leaving that lifecycle contract without regression coverage."
|
||||
}
|
||||
],
|
||||
"protected_subject_reclassifications": [
|
||||
{
|
||||
"#": 1,
|
||||
"from": "rejected",
|
||||
"to": "unresolved",
|
||||
"reason": "Protected-subject rejection reclassified as unresolved. Base 43d33aa scripts/task.sh:381-386 confirms older destructive behavior, but does not establish unrelatedness or refute the failure at reviewed c0abfb0: task.sh:438-445 expressly makes the new preservation checks the basis for forced removal, while check-worktree.py:51-69 omits linked component worktree ownership. Resolve this gate with a discriminating cleanup regression that preserves an external component worktree and its staged/uncommitted content, or inspected evidence disproving this interaction. Root accepts the remediation scope and reports repairs in progress; those repairs are outside this reviewed commit."
|
||||
}
|
||||
],
|
||||
"degraded_blockers": [
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"artifact_path": "/tmp/compound-engineering-1000/ce-code-review/20261005-123925-a2fa0a6f",
|
||||
"run_id": "20261005-123925-a2fa0a6f"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"status": "complete",
|
||||
"verdict": "All four specified repair findings resolved at fixed source SHA",
|
||||
"evidence_source": "REPLAY",
|
||||
"reviewer": "/root/verify_review_repairs",
|
||||
"scope": "Bounded verification of original findings 1-4; not a whole-repository review or release gate",
|
||||
"source_sha": "b84667a697ab88e7eb09b78ba6b00175729937b2",
|
||||
"diff_base_sha": "976d2e84434f6a089cb8ccd1da33e151a6cc375d",
|
||||
"original_review_source_sha": "c0abfb0250980af7f1b18c785eb4b55240a8d855",
|
||||
"original_review_artifact_commit": "b84667a697ab88e7eb09b78ba6b00175729937b2",
|
||||
"original_review_artifact_path": ".task/reviews/governance-reliability.json",
|
||||
"snapshot": "/tmp/governance-repair-replay-urtmaen7",
|
||||
"snapshot_file_count_verified_against_commit": 22,
|
||||
"commands": [
|
||||
{
|
||||
"command": "PYTHONDONTWRITEBYTECODE=1 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ALLOW_PROTOCOL=file GIT_TERMINAL_PROMPT=0 python3 -m unittest discover -s tests -p test_task.py -v",
|
||||
"cwd": "/tmp/governance-repair-replay-urtmaen7",
|
||||
"exit_code": 0,
|
||||
"tests_run": 42,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"result": "OK",
|
||||
"elapsed_seconds": 9.509,
|
||||
"output_path": "/tmp/governance-review-repair-task.log",
|
||||
"output_sha256": "eeb1bba7e4eae6369c5931a4c4756c8cdef78da49f97b7d4af2aa0c80de642fb"
|
||||
},
|
||||
{
|
||||
"command": "PYTHONDONTWRITEBYTECODE=1 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_ALLOW_PROTOCOL=file GIT_TERMINAL_PROMPT=0 python3 -m unittest discover -s tests -p test_evidence.py -v",
|
||||
"cwd": "/tmp/governance-repair-replay-urtmaen7",
|
||||
"exit_code": 0,
|
||||
"tests_run": 31,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"result": "OK",
|
||||
"elapsed_seconds": 3.981,
|
||||
"output_path": "/tmp/governance-review-repair-evidence.log",
|
||||
"output_sha256": "3824093772141bb2fbe7e2f72bfa8ba8113dadc5dbe20ecb2d242e3a8088bd44"
|
||||
}
|
||||
],
|
||||
"total_tests": 73,
|
||||
"findings": [
|
||||
{
|
||||
"id": 1,
|
||||
"severity": "P1",
|
||||
"resolution": "RESOLVED",
|
||||
"evidence_source": "REPLAY",
|
||||
"summary": "Finish refuses parent removal when recursively inspected component common metadata belongs to the removal roots and serves another registered worktree. Clean, staged, unstaged, and nested linked component cases preserve Git usability and content; normal clean component cleanup passes.",
|
||||
"tests": [
|
||||
"test_finish_preserves_linked_component_worktrees_at_the_pinned_head",
|
||||
"test_finish_preserves_linked_nested_component_worktree",
|
||||
"test_finish_recycles_clean_submodule_without_shared_config_changes"
|
||||
],
|
||||
"historical_provenance_used_to_dismiss": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"severity": "P2",
|
||||
"resolution": "RESOLVED",
|
||||
"evidence_source": "REPLAY",
|
||||
"summary": "Sync detects stale JSON and legacy receipts, warns for malformed or missing fields and unresolved recorded commits, and treats non-card before/after dirt as unverified. Card-only changes remain fresh. Recorded command is not executed.",
|
||||
"tests": [
|
||||
"test_sync_warns_when_json_evidence_predates_noncard_changes",
|
||||
"test_sync_still_warns_for_stale_legacy_table_evidence",
|
||||
"test_sync_warns_for_malformed_json_evidence",
|
||||
"test_sync_warns_for_missing_json_receipt_fields_even_with_valid_checksum",
|
||||
"test_sync_warns_for_unresolved_json_evidence_head",
|
||||
"test_sync_warns_for_unresolved_legacy_evidence_head",
|
||||
"test_sync_warns_when_json_before_state_contains_uncommitted_code",
|
||||
"test_sync_warns_when_json_after_state_contains_uncommitted_code",
|
||||
"test_sync_keeps_json_evidence_fresh_after_metadata_only_changes",
|
||||
"test_sync_does_not_execute_json_receipt_command"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"severity": "P2",
|
||||
"resolution": "RESOLVED",
|
||||
"evidence_source": "REPLAY",
|
||||
"summary": "Thirty Authorization syntax/scheme combinations cover JSON, dictionary, plain, assignment, case, and whitespace forms. Capture command/output/receipt/card/console and import contain no synthetic credential. Imported unredacted receipts are rejected even with valid checksums.",
|
||||
"tests": [
|
||||
"test_authorization_forms_are_redacted_in_capture_console_and_import",
|
||||
"test_authorization_forms_are_redacted_on_direct_card_capture",
|
||||
"test_import_rejects_unredacted_authorization_with_valid_checksums"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"severity": "P2",
|
||||
"resolution": "RESOLVED",
|
||||
"evidence_source": "REPLAY",
|
||||
"summary": "Explicit abandon removes the clean worktree while keeping local and file-remote branch tips and the abandonment reason note; missing reason refuses removal and preserves checkout and refs without writing a note.",
|
||||
"tests": [
|
||||
"test_finish_abandon_retains_local_remote_tips_and_reason",
|
||||
"test_finish_abandon_without_reason_preserves_checkout_and_refs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"remaining_scope_blockers": [],
|
||||
"limitations": [
|
||||
"Canonical working directory had scripts/task.sh merge conflict at initial inspection; the tested exported snapshot is byte-equal to b84667a. This verdict does not validate subsequent source changes or merge resolutions.",
|
||||
"No real remote or service used; Git allowed protocol file only. No canonical source or task-state mutations performed.",
|
||||
"No full make test/build, CI, broad review, or cross-model pipeline executed."
|
||||
]
|
||||
}
|
||||
@@ -10,27 +10,34 @@
|
||||
- 颜色/圆角/字号只消费 `@ui/tokens.css` 令牌(字号走 `--fs-ui-*` 标尺);动效只用
|
||||
`@ui/motion.css` 的 `mk-*` 工具类;折叠一律用 `@ui` Collapsible 原语,禁止 `<details>`。
|
||||
- 守护测试(UI 仓与 Frontend 仓各一份 `design-guard.test.ts`)以棘轮拦截新增裸色值、
|
||||
私有 `@keyframes` 与行内字号;改风格 = `DESIGN.md` + 令牌文件同 PR 成对修改。
|
||||
私有 `@keyframes` 与行内字号;风格调整须在同一变更集中关联 UI 令牌 PR 与 Dev 的
|
||||
`DESIGN.md`/gitlink 组合 PR,并验证精确组件 SHA(跨仓不要求两个文件出现在同一 PR)。
|
||||
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
|
||||
|
||||
## 任务启动门禁(强制)
|
||||
|
||||
- 任务启动或恢复后,**第一次写操作前**运行 `make precheck` 并附完整输出;verdict 为
|
||||
`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED` 时不得编辑。
|
||||
- 按其必读清单完成阅读,然后 `make task-start NAME=feature/<slug>` 建立独立 worktree +
|
||||
feature 分支(自动记录 R0 与任务卡);任务中途不重复运行 precheck。
|
||||
- 任务启动或恢复后,**第一次项目写操作前**通过
|
||||
`RECEIPT=/tmp/<本次唯一名称>.json CMD="make precheck" make evidence` 运行一次启动检查并保存
|
||||
完整脱敏收据(不要求已有任务卡);按输出完成必读。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED /
|
||||
REMOTE_UNVERIFIED` 均不得进入写入流程;失败后排除原因再启动,不把缓存远端当最新事实。
|
||||
- 新任务运行 `make task-start NAME=feature/<slug> PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json`,
|
||||
建立独立 worktree 与任务卡并导入首次收据。恢复已有任务在原任务工作区导入收据:
|
||||
`IMPORT_RECEIPT=/tmp/<本次唯一名称>.json make evidence`。任务中途不重复启动检查。
|
||||
- **分支层级**:任务分支一律从权威 `origin/main` 切(`task-start` 已强制);同批互不依赖的
|
||||
子任务用同一 R0 的兄弟分支。确有顺序依赖需从父 feature 分支切子分支时(collaboration.md
|
||||
允许的堆叠例外):任务卡显式记录父任务与父 PR;子分支命名 `<type>/<父slug>-<子题>`,
|
||||
允许的堆叠例外):启动命令增加 `PARENT=feature/<父slug> PARENT_PR=<父PR链接>`,
|
||||
任务卡记录主干 R0、父分支及其 SHA、父 PR;子分支命名 `<type>/<父slug>-<子题>`,
|
||||
不嵌 SHA;合入顺序固定父→子;父合入后子分支须 `make task-sync` 并入最新 main 并把 PR
|
||||
base 改回 main,方可声明可合入(分支保护要求 head 含 origin/main,未同步必被拦)。
|
||||
- **验收、请求审核、合入前**各运行一次 `make task-sync`;完成声明的 precheck、task-sync、
|
||||
测试三段输出由 `make evidence` 写入任务卡(见「证据与验证门禁」),不手抄。
|
||||
- 合入后**在 worktree 内**运行 `make task-finish` 回收 worktree 与分支(脚本会把
|
||||
当前目录当 worktree 回收——在主检出运行会误删主检出,已加防护);放弃未合入
|
||||
工作用 `make task-finish ABANDON="--abandon <原因>"` 并记录原因。
|
||||
- 合入后由工作区 owner 确认所有写者退出,在**已登记任务 worktree 根目录**运行
|
||||
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
|
||||
保留现场并按原因处理,禁止强删兜底。放弃未合入工作用
|
||||
`make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。
|
||||
- **任务卡闭合(防欠账)**:分支合入 main 后,其任务卡须回填"已完成"+主记录 PR 链接并
|
||||
归档至 `.task/archive/`。自动路径:`make task-finish` 收尾触发、主检出 pull 触发 post-merge
|
||||
归档至 `.task/archive/`。`task-finish` 回收后提示所有者运行 `make task-close`,不借回收修改
|
||||
共享主检出或推送另一任务。自动路径保留主检出 pull 触发 post-merge
|
||||
钩子(实测 ff-only pull 也触发);钩子内 close 只扫主检出本地 `.task/`,远端刚合入的卡片
|
||||
要等 pull 完成才能被看到,扫不到时由手动 `make task-close` 兜底。手动闭合在主检出运行→建
|
||||
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。
|
||||
@@ -43,10 +50,13 @@
|
||||
规范依据:guidelines `standards/verification.md`「声明与证据来源」(draft.10)。
|
||||
|
||||
- **来源三分**:产出物(任务卡、PR、报告、review 结论)中的通过类断言须标注来源——
|
||||
`GATE`(CI/分支保护/precheck 实测)/ `REPLAY`(审阅者独立重跑)/ `SELF`(作者粘贴)。
|
||||
合入与验收判定只认 GATE/REPLAY;SELF 仅为线索,与重验不一致时以重验为准。
|
||||
`GATE`(可核验身份及固定对象的 CI/受信执行器)/ `REPLAY`(独立审阅者的重跑记录)/
|
||||
`SELF`(作者或本地工具记录,含 precheck)。工具写入、摘要和来源标签不提升可信等级。
|
||||
合入与验收须核验 GATE 的运行链接/身份及 SHA,或 REPLAY 的独立审阅者、命令和对象;
|
||||
本地 evidence 只生成 SELF,不能靠 `SRC` 自行升级;与重验不一致时以重验为准。
|
||||
- **证据由工具写入**:命令输出进入任务卡/PR 一律经 `CMD="<命令>" make evidence` 写入
|
||||
(自动记录命令、被测 HEAD、退出码、输出 sha256 与尾部摘录并脱敏 token)。手抄、转写、
|
||||
(记录脱敏命令、执行前后对象、退出码、完整脱敏输出与摘要)。凭据只经环境或受保护文件
|
||||
引用;命令、输出和摘要统一脱敏,摘要只证明记录完整性,不证明执行者身份。手抄、转写、
|
||||
凭记忆重构的输出视为无效证据;补写输出与伪造测试同级红线。
|
||||
- **完成声明三段输出**(precheck / task-sync / 测试)必须由 evidence 工具写入任务卡,
|
||||
PR 描述引用任务卡段落,不另手抄。测试通过的权威源是 CI(`.gitea/workflows/ci.yaml`,
|
||||
@@ -70,6 +80,8 @@
|
||||
- 验证命令见 PROJECT.md「验证」节;完成声明的三段输出按「证据与验证门禁」节由
|
||||
`make evidence` 写入任务卡,不手抄。
|
||||
- 首次克隆本仓后运行 `make setup` 启用本地 pre-commit 钩子。
|
||||
- 治理脚本改动须运行 `make test-governance`:临时真实仓库覆盖正常、拒绝及异常路径;该套件
|
||||
同时纳入 `make test`。证据、工作区和 CI 凭据边界的操作细则见 `policy/governance.md`。
|
||||
|
||||
## Gitea 与 Git 访问事实(2026-09-30 实测;登记表 `policy/facts.md`)
|
||||
|
||||
|
||||
@@ -281,7 +281,8 @@ Primary(`--primary` 反色)、Ghost(透明,hover 出表面)、Danger
|
||||
|
||||
## 12. 演进规则
|
||||
|
||||
改风格 = `DESIGN.md` 与 `tokens.css`(必要时 `motion.css`)**同 PR 成对修改**,附前后值对照;
|
||||
改风格须在**同一变更集**中成对修改:UI 仓 PR 调整 `tokens.css`(必要时 `motion.css`),
|
||||
Dev 组合 PR 更新 `DESIGN.md` 并固定对应 UI 提交;两者互链,附前后值对照并验证精确组合。
|
||||
不允许只改代码不改文档。新增视觉规则先问:它属于结构、表面、交互还是状态?
|
||||
拿不准时,选更安静的方案、用更强的信息层级。
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ version:
|
||||
@echo "agent-system $(VERSION)"
|
||||
|
||||
TASK := scripts/task.sh
|
||||
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED
|
||||
TASK_PARENT_BRANCH ?= $(PARENT)
|
||||
TASK_PARENT_PR ?= $(PARENT_PR)
|
||||
export TASK_PARENT_BRANCH TASK_PARENT_PR
|
||||
|
||||
setup:
|
||||
git config core.hooksPath scripts/hooks
|
||||
@@ -42,7 +46,8 @@ task-finish:
|
||||
@bash $(TASK) finish $(ABANDON)
|
||||
|
||||
# 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。
|
||||
# 用法:CMD="<单行命令>" make evidence [SRC=SELF|REPLAY|GATE] [TAIL=15];退出码随被执行命令。
|
||||
# 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。
|
||||
# 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。
|
||||
# CMD 经环境直传(不经 make 变量展开重引号,命令内可含引号/管道);SRC/TAIL 为简单词,走 make 变量。
|
||||
evidence:
|
||||
@SRC='$(SRC)' TAIL='$(TAIL)' bash scripts/evidence.sh
|
||||
@@ -55,6 +60,7 @@ help:
|
||||
@echo "make sync 物化全部 submodule(递归)"
|
||||
@echo "make build 构建单二进制 bin/agent-system(契约同步→前端构建→embed→静态链接)"
|
||||
@echo "make test 全量测试(boundaries + pins + libs + core + backend + frontend + ui + agent-command)"
|
||||
@echo "make test-governance 临时真实仓库中的任务、证据、写守卫与 CI 凭据回归"
|
||||
@echo "make svc 服务级操作:make svc ENV=<name> [ACTION=deploy|start|stop|restart|status|logs] [ARGS='--from dev@<sha> --tag ...']"
|
||||
@echo "make svc-list 服务总览:make svc-list [ARGS='--tag core'];数据库运维:scripts/svc.sh db create|dsn|backup|backups|restore|clone|copy|ls|set"
|
||||
@echo "make svc-info 综合信息报告(只读):make svc-info [ARGS='pg1 --reveal']——运行服务/数据库实例与内部库/配置快照"
|
||||
@@ -72,7 +78,7 @@ status:
|
||||
@git submodule status
|
||||
|
||||
# 契约同步:Core 的 TS 类型镜像到前端与 UI 组件库(Core 为权威,单一来源)。
|
||||
contracts:
|
||||
contracts: test-worktree
|
||||
cp $(CORE)/ts/types.ts $(FRONTEND)/src/contracts.ts.tmp
|
||||
{ echo "// 从 Agent-System-Core ts/types.ts 同步(make contracts 生成;Core 为权威)。"; cat $(FRONTEND)/src/contracts.ts.tmp; } > $(FRONTEND)/src/contracts.ts
|
||||
rm $(FRONTEND)/src/contracts.ts.tmp
|
||||
@@ -128,7 +134,18 @@ plugin-new:
|
||||
@test -n "$(NAME)" || (echo "用法:make plugin-new NAME=<id>"; exit 2)
|
||||
bash scripts/plugin-new.sh "$(NAME)"
|
||||
|
||||
test: test-boundaries test-pins test-libs test-core test-backend test-frontend test-ui test-agentcmd
|
||||
.PHONY: test test-governance test-worktree contracts
|
||||
|
||||
test-governance:
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
|
||||
test-worktree:
|
||||
python3 scripts/check-worktree.py verify
|
||||
|
||||
# 即使 make -j,也先确认实际被测子仓与固定组合一致。
|
||||
test-boundaries test-pins test-libs test-core test-backend test-frontend test-ui test-agentcmd: | test-worktree
|
||||
|
||||
test: test-worktree test-governance test-boundaries test-pins test-libs test-core test-backend test-frontend test-ui test-agentcmd
|
||||
|
||||
# 运行演示(mock 模式):单二进制 + 示例插件 + 嵌入式存储,浏览器 http://127.0.0.1:8787
|
||||
# 环境服务管理(scripts/svc.sh):deploy = 停旧 → 构建 → 启动;dev/prod 相互隔离。
|
||||
|
||||
+13
-1
@@ -2,5 +2,17 @@
|
||||
|
||||
- 用途:开发集成父仓(gitlink 权威);非分发单元。
|
||||
- 组合:modules/{core,backend,frontend,ui,documents} + deps/{development-guidelines,agent-native,agent-squad,agent-intent,agent-command,error-code-system,logging-system}。
|
||||
- 验证:`make test` = test-boundaries(公开边界,另含三库之间导入配对棘轮)+ test-pins + test-libs(agent-intent、agent-native、agent-squad 各自 `GOWORK=off` 的 vet 与 test)+ test-core + test-backend + test-frontend + test-ui + test-agentcmd。`make build` 在 backend 模块内编译,replace 指向该模块自己的 `deps/`;`scripts/check-pins.sh` 要求这些 gitlink 与 Dev 顶层指针相同。未加 go.work:工作区最小版本选择会把 `golang.org/x/crypto`、`exp`、`net`、`term`、`text` 选得比 backend 单独构建更新。Releases 仓的 `scripts/build-release.sh` 递归克隆本仓固定提交后运行 `make build`,因此跟随同一套指针。
|
||||
- 部署:非容器;Releases 仓从本仓固定提交导出版本。
|
||||
|
||||
## 验证
|
||||
|
||||
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
|
||||
测试。实际目标以根 Makefile 为准。`make test-governance` 可独立检查开发门禁,使用临时
|
||||
真实仓库,不访问生产数据;操作与证据要求见 [开发门禁](policy/governance.md)。
|
||||
|
||||
`make build` 在 backend 模块内编译,replace 指向该模块自己的 `deps/`;
|
||||
`scripts/check-pins.sh` 核对这些 gitlink 与 Dev 顶层指针,`make test-worktree` 另核对
|
||||
真正被测的组件工作树。两者分别验证固定组合与实际检出,不能相互替代。
|
||||
|
||||
未加 go.work:工作区最小版本选择会抬高 backend 单独构建使用的部分 `golang.org/x` 版本。
|
||||
发布消费方须从固定 Dev 提交递归物化后运行 `make build`,再核验制品与实际运行状态。
|
||||
|
||||
+1
-1
Submodule modules/documents updated: a47754b494...19e89ae10b
+1
-1
Submodule modules/ui updated: 27d5adb5c6...2cb1496320
+6
-5
@@ -10,14 +10,14 @@
|
||||
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
|
||||
|---|---|---|
|
||||
| core/baseline.md | 日常 | manual |
|
||||
| workflows/delivery.md(含启动自检 §1) | 交付 | advisory(make precheck);判定输出 BUSY/SYNC 时阻断 |
|
||||
| workflows/delivery.md(含启动自检 §1) | 交付 | 本地命令检查;首次收据与 BUSY/SYNC/REMOTE_UNVERIFIED 语义见 policy/governance.md |
|
||||
| standards/collaboration.md | 并行/同步节点 | **enforced(本地)**:pre-commit 拒绝集成分支直提与坏分支名(负例:hook-main/badbranch 拒绝输出);节点同步由 task-sync 落卡 |
|
||||
| 提交格式(主题行/四段 body) | 全部提交 | **enforced(本地)**:commit-msg 拒绝 >100 列与句点结尾(负例:longsubject 拒绝输出);受保护分支经 Gitea PR 合入(本记录 3 个 PR 为正例) |
|
||||
| standards/verification.md | 验收 | manual:make test(12 ok 行)+ scratch 19/19 为证 |
|
||||
| 提交格式(主题行/四段 body) | 全部提交 | 主题行 enforced(local),body manual;任务工具生成四段 body,历史 PR 仅作旧版本证据 |
|
||||
| standards/verification.md | 验收 | 本地 make test-governance + 组合测试;当前证据与历史 scratch 记录分开 |
|
||||
| standards/multi-repo.md | 跨仓/gitlink | manual;受控历史迁移通道已于 2026-09-30 行使一次(documents/records/2026-09-30-history-migration.md) |
|
||||
| 工作区回收 | 交付终点 | advisory(task-finish);负例:合入前 finish 拒绝输出 |
|
||||
| standards/tool-adaptation.md(draft.8) | 为工具创建规则/钩子实例 | 本组合 CLI/git 级实例已登记:policy/tool-adaptation-matrix.md(A1–A7 逐行定级+正负例证据) |
|
||||
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫(.zcode/config.json + scripts/zcode/write-guard.sh),A1/A3 落笔前 enforced |
|
||||
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
|
||||
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
|
||||
|
||||
## 项目差异
|
||||
@@ -26,4 +26,5 @@
|
||||
|---|---|---|---|
|
||||
| baseline 硬边界 1(授权范围) | 高后果双钥匙:改写历史/解除分支保护/删归档/发布——Agent 只准备,用户执行(AGENTS.md 条文) | 单人+Agent 场景下消除单点;风险=用户不在场时高后果操作排队 | weichen;首次触发时复查 |
|
||||
|
||||
- Gitea 分支保护已启用(2026-09-30):11 个组合仓 main 禁直推、拒绝陈旧候选;负例:直推被 pre-receive hook declined。平台层 A3 升级为 enforced。
|
||||
- 历史记录(2026-09-30)报告 11 个组合仓 main 禁直推、拒绝陈旧候选;当前平台能力须独立重验,本地脚本测试不替代远端证据。
|
||||
- 当前规范内容以 gitlink 及其 VERSION 为准。治理可靠性修复的命令与证据约定见 [开发门禁](governance.md),本地与客户端/平台能力分别登记;本轮不改变分支保护、发布授权或通用库版本。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 开发门禁的执行与证据
|
||||
|
||||
本文件细化根 AGENTS.md 的启动、验证和回收规则。通用语义以固定的
|
||||
`deps/development-guidelines` 为准;门禁实现及回归用例随本仓版本管理。
|
||||
|
||||
## 启动与恢复
|
||||
|
||||
从当前检出运行一次启动检查,使用每次唯一、仓外的绝对收据路径:
|
||||
|
||||
```bash
|
||||
RECEIPT=/tmp/my-task-precheck.json CMD="make precheck" make evidence
|
||||
make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
|
||||
```
|
||||
|
||||
第一步先捕获原始执行,再创建任务卡;收据保存完整脱敏输出、退出码、执行位置和对象。
|
||||
第二步核验收据与当前仓库/基线,再创建工作区并导入;不靠事后重跑冒充首次检查。
|
||||
同一收据不可覆盖。首次检查失败时处理原因,使用新路径重新启动。主干在两步间前进时,
|
||||
重新确认新的启动基线,不能把旧收据移花接木到新对象。
|
||||
|
||||
恢复已有任务仍先捕获本次启动检查,再在原任务工作区用
|
||||
`IMPORT_RECEIPT=/tmp/my-task-resume.json make evidence` 导入;不重新建分支。
|
||||
工作区忙或落后时先由 owner 保存现场并同步;远端不可达时只继续不依赖新鲜基线的阅读和分析。
|
||||
|
||||
堆叠任务使用 `PARENT=feature/parent PARENT_PR=<父PR链接>`。父分支须包含本批主干 R0;
|
||||
卡片同时记录父分支、父 SHA 和父 PR。父先合入,子同步最新 main 并改回 main 为 PR base。
|
||||
|
||||
## 被测对象
|
||||
|
||||
`make task-sync` 在父仓合并后按固定 gitlink 物化未修改组件,并核对实际组件工作树。
|
||||
组件 feature 分支若与新指针不同,先保留其分支,交给 owner 显式同步并更新组合;
|
||||
不能用自动 checkout 丢弃组件分支现场。任何失败都保留现场并阻止同步成功声明。
|
||||
|
||||
`make test-worktree` 核对递归子模块:未初始化、冲突、指针漂移及子仓未保存改动均拒绝。
|
||||
它是组合测试和构建的前置检查。父仓未提交改动可以局部测试,但证据必须记录实际对象状态;
|
||||
正式验收使用已提交的固定对象,不能仅写一个与工作树不同的 HEAD。
|
||||
|
||||
## 本地记录与独立证据
|
||||
|
||||
本地 `make evidence` 一律生成 SELF。`SRC=GATE/REPLAY` 不可把本地执行升级为权威来源。
|
||||
GATE 引用受信平台的运行链接、执行身份、完整 SHA 和结果;REPLAY 引用独立审阅者的
|
||||
环境、命令、对象和结果。审核者核验这些来源,不能只读作者填入的标签。
|
||||
|
||||
收据中的摘要检测损坏,不是签名或身份凭据。保存的是完整脱敏输出及其 SHA-256;
|
||||
命令含敏感值时展示脱敏形式,并记录原命令摘要。执行前后对象分别记录;命令改变 HEAD
|
||||
时不把执行前 SHA 冒充执行后对象。不能读取执行后状态时明确记录缺项。
|
||||
|
||||
凭据从环境或受保护文件引用,避免出现在命令字面量。脱敏是补充防护,不是允许输出秘密。
|
||||
执行脚本不通过 shell tracing 暴露环境;管道任何一段失败应传回非零退出码。
|
||||
工具写入失败本身也必须报错,不能只保留一个看似成功的退出状态。
|
||||
|
||||
## 回收
|
||||
|
||||
仅在登记的任务 worktree 根目录回收。owner 先确认全部写者已退出、任务成果与证据已保存,
|
||||
以 `TASK_WRITERS_STOPPED=1 make task-finish` 明确确认;
|
||||
脚本检查主检出/子目录误用、Git 操作和锁、父子仓修改及未跟踪/ignored 文件;
|
||||
子模块另有登记 worktree 时拒绝回收,避免删除其他检出依赖的 Git 管理目录。
|
||||
可再生缓存由 owner 按已识别路径单独处置;无法确认用途的文件保留。
|
||||
|
||||
任务合入需以本次成功获取的权威目标验证。合入提交不能证明未提交文件已经落盘。
|
||||
Git 对含子模块的工作区需要显式删除选项时,只有全部现场检查通过才允许该路径;
|
||||
它不豁免任何脏状态、忽略文件、独有提交/引用或锁检查。普通删除失败不转入强删兜底。
|
||||
保留无法确认安全的目录和分支,输出下一步处理依据。
|
||||
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
|
||||
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
|
||||
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
|
||||
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。
|
||||
|
||||
## CI 凭据边界
|
||||
|
||||
候选代码执行前结束有凭据的源码获取阶段。获取只访问受信主机及登记的组件仓,
|
||||
不根据候选提供的任意 URL 携带 token;认证配置限单次进程,不写全局 Git 配置或 remote URL。
|
||||
测试/构建阶段检查配置中不存在凭据,并使用不含 checkout token 的环境。
|
||||
|
||||
仓内测试只能证明脚本边界。runner 隔离、token 权限、工作流来源和平台分支保护
|
||||
由平台维护者验证;没有对应 GATE/REPLAY 时标未验证,不由本地测试推断。
|
||||
|
||||
## 持续回归
|
||||
|
||||
`make test-governance` 在临时真实 Git 仓库中覆盖回收、组件同步、离线启动、收据捕获/导入、
|
||||
管道失败、假凭据脱敏、写路径及 CI 凭据隔离;临时 remote 均为本地路径,禁止使用真实秘密。
|
||||
它纳入 `make test`,新机制必须有正常、拒绝和故障分支。历史临时测试记录仅作定位线索。
|
||||
|
||||
普通文档核对真实入口和相对链接;规范升级检查条文、命令、模板与能力矩阵是否一致。
|
||||
机制未变可按固定版本复用证据,变更的机制重新运行对应负例。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 平台准入的所有者操作
|
||||
|
||||
状态:待所有者执行;本次仅准备,不修改远端分支保护。
|
||||
依据:根 AGENTS.md「高后果操作双钥匙」要求分支保护变更由用户确认并亲手执行。
|
||||
|
||||
## 已核实的差距
|
||||
|
||||
2026-10-05 读取 Dev 的 main 分支保护 API,观察到禁直推与陈旧分支拦截开启,
|
||||
必需状态检查关闭、批准数为 0。读取历史 PR head 的状态 API,实际检查名为
|
||||
`ci / test (pull_request)`。完整命令与输出由 evidence 工具记录在
|
||||
`.task/feature-governance-reliability.md`,来源 SELF(只读配置观察,不是门禁负例)。
|
||||
执行以下步骤前再次读取配置,不能把本记录当长期有效的平台事实。
|
||||
|
||||
## 所有者执行
|
||||
|
||||
1. 在 Gitea 的 Agent-System-Dev 仓库设置中打开 main 的分支保护,先保留当前配置截图或导出。
|
||||
2. 确认本次修复 PR 的固定 head 已实际运行 CI,并在提交状态中核对完整检查名。
|
||||
3. 启用必需状态检查,选择实际的 `ci / test (pull_request)`;保持禁直推及陈旧分支拦截。
|
||||
不用仅在合入后运行的 `ci / test (push)` 替代 PR 检查。
|
||||
4. 批准数暂不随此修复自动调整。若另行启用非作者批准,先确认有可用且符合权限要求的
|
||||
独立审核账号;不能把切换 Agent 角色当独立账号,也不能盲设阈值使单账号仓库无法交付。
|
||||
5. 在隔离的测试 PR 上验证:无检查、检查失败和陈旧候选均不可合入;合法且检查通过的
|
||||
PR 可进入所有者审核。记录 head/base、运行链接及实际拒绝结果,标 GATE/REPLAY。
|
||||
|
||||
任一判据未满足时暂停合入并保留测试对象。配置调整失败由所有者对照保存的配置恢复;
|
||||
不把“配置 API 返回成功”写成“门禁已有效”。其他组合仓分别核验,不外推 Dev 的结果。
|
||||
|
||||
## CI 信任前提
|
||||
|
||||
本次 workflow 修复减少 checkout token 对候选代码的暴露;仍须由平台维护者核验
|
||||
workflow 来源策略、runner 隔离/销毁及 token 最小权限。仅要求一个可由候选自行修改的
|
||||
绿色检查,不能证明测试定义或执行身份可信。平台验证结果回填任务主记录与能力矩阵。
|
||||
@@ -1,33 +1,28 @@
|
||||
# CLI/git 级门禁机制实例——能力矩阵
|
||||
# CLI/git 门禁能力矩阵
|
||||
|
||||
- 适配对象:任何经终端操作本仓的执行者(CLI Agent、IDE 内嵌终端);机制为工具无关的 shell + git hooks
|
||||
- 依据:Development-Guidelines 0.1.0-draft.8(c9ab623)standards/tool-adaptation.md + policy/adoption.md
|
||||
- 状态:verified(正负例见下);owner:weichen
|
||||
- 实例位置:scripts/task.sh(precheck/start/sync/finish)、scripts/hooks/pre-commit、scripts/hooks/commit-msg、Makefile 目标、AGENTS.md 常驻条文
|
||||
- 单一权威声明:本实例是上述规范的机制实现,语义以库与项目入口为准
|
||||
适配对象:通过 shell/git 操作本仓的执行者。要求来自固定的 Development-Guidelines
|
||||
`standards/tool-adaptation.md` 与根 AGENTS.md;本表登记机制范围,不复制另一套规则。
|
||||
当前修订为治理可靠性修复候选;实际验证记录见 `.task/feature-governance-reliability.md`
|
||||
及其关联 PR。历史 19/19 临时测试不代替本次回归。
|
||||
|
||||
## 能力矩阵
|
||||
|
||||
| 要求 | 等级 | 机制(执行时机) | 正例证据 | 负例证据 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 启动门禁 | advisory(verdict 纪律)+ enforced(start 拒绝) | make precheck 任务首次写前;task-start 拒绝脏树(脚本退出码) | Dev PR#1 验证记录 case1 READY | 同套件 case2/4:BUSY exit 3、start-dirty exit 1;2026-09-30 实战拦截 data/ 脏树 | 「不得编辑」属执行者纪律,如实记 advisory |
|
||||
| A2 节点同步 | advisory | make task-sync(验收/审核/合入前),落卡三字段 | PR#1/2/3 均经 sync | 实战两次拒绝脏树 sync(exit 1,不隐式 stash) | MUST 属流程义务,机制为建议级 |
|
||||
| A3 集成分支保护 | enforced(local)+enforced(平台) | pre-commit 拒绝 main 直提;Gitea 分支保护:11 组合仓 main 禁直推+拒绝陈旧候选(2026-09-30 起) | 13+ PR 全部经平台合入 | 套件 hook-main-refused exit 1;平台负例:直推 Logging-System main 被 pre-receive hook declined | |
|
||||
| A4 提交与任务记录格式 | enforced(local)(主题行)+manual(body 四段) | commit-msg 钩子查宽度/句点 | 本批提交全部通过 | 套件 hook-longsubject-refused exit 1 | body 四段结构无机器校验,如实 manual |
|
||||
| A5 工作区回收 | advisory + enforced(未合入拒绝) | task-finish(合入核对→deinit→回收);abandon 双确认 | PR#3 后 worktree/分支回收 | 套件 finish-before-merge exit 1、abandon 需 FORCE_ABANDON | |
|
||||
| A6 秘密边界 | manual | Gitea token 从 ~/.secrets/gitea 读取;会话/日志无明文 | 全程 API 自动化无泄漏 | 无构造负例(纪律项) | 凭证文件模式已验证 |
|
||||
| A7 证据落盘 | manual | 完成声明附 precheck/sync/测试三段输出 | 各 PR body 携带 | 无构造负例 | |
|
||||
|
||||
## 降级与失败行为
|
||||
|
||||
| 机制 | 依赖缺失时行为 | 对应等级调整 | 解锁路径 |
|
||||
| 要求 | 执行能力及范围 | 机制 | 复验入口 |
|
||||
|---|---|---|---|
|
||||
| task.sh(bash+python3) | 脚本报错退出(fail-closed) | 不变 | 修复解释器后重跑 |
|
||||
| commit-msg 宽度计算(python3) | 缺 python3 时宽度为空使比较失败→拒绝(fail-closed,报错信息可读性差) | 不变 | 安装 python3;改进报错文案待办 |
|
||||
| Gitea API(网络/凭证) | 拒绝并打印 http 码(fail-closed) | 合入退化为人工网页操作 | 修凭证/网络 |
|
||||
| A1 启动 | 本地命令阻断;执行者首次调用仍属 manual | precheck 区分忙/落后/远端未核实;start 验证首次收据 | `tests/test_task.py`、`tests/test_evidence.py` |
|
||||
| A2 同步 | 本地命令阻断;最终准入由平台承担 | 合并父仓、保护组件分支、物化固定指针、递归核对 | `tests/test_task.py`、`make test-worktree` |
|
||||
| A3 集成分支 | 本地提交闸;平台状态须独立核验 | pre-commit 拒绝 main/master 与坏分支名 | 本地 hook 负例;远端缺 CI/陈旧候选/直推等负例另验 |
|
||||
| A4 提交格式 | 主题行 enforced(local),body manual | commit-msg 校验宽度/句点;任务工具生成四段 body | `tests/test_task.py`;人工核对 PR/卡片同源 |
|
||||
| A5 回收 | 本地命令检查;写者退出由 owner 确认 | 登记根目录、锁/操作状态、父子仓与唯一文件、合入核验 | `tests/test_task.py` |
|
||||
| A6 秘密 | 本地脱敏及 CI 获取边界;权限与宿主隔离另验 | 命令/输出/摘要脱敏,凭据不持久写 Git 配置 | `tests/test_evidence.py`、`tests/test_ci_credentials.py` |
|
||||
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
|
||||
|
||||
## 验证记录
|
||||
## 失败处理
|
||||
|
||||
- 正负例:2026-09-30 临时真实仓套件 19/19 PASS(precheck 三态、start/hook/sync/finish/abandon 拒绝与放行);执行者 weichen;证据摘录见 Dev PR#1 描述与 .task 卡
|
||||
- 实战补充:data/ 脏树拦截、task-finish 子模块回收缺陷(PR#4/#6 两击修复后实战回收通过)、子仓未初始化盲区修复(本 PR)
|
||||
- 证据复用:guidelines 升级到 draft.8 仅新增专题,A 表未变,本矩阵行级证据沿用(verification.md 证据复用条款)
|
||||
网络不可达、引用缺失、必要解释器不可用均显式失败,不能给 READY 或同步成功。
|
||||
命令非零、管道失败及证据写入失败返回非零;保留已有收据,不覆盖历史结果。
|
||||
工作区回收被拒时保留现场;本地及远端回收分开报告。具体操作见 [开发门禁](governance.md)。
|
||||
|
||||
## 平台与历史证据
|
||||
|
||||
2026-09-30 记录曾报告 11 个组合仓 main 已启用禁直推与陈旧候选保护;这是历史结论,
|
||||
本次代码/文档修复不据此认定当前远端准入有效。正式审核仍须核验当前配置和匹配负例,
|
||||
与本地回归分开记录。机制升级只复用未变化范围的证据。
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
# zcode 客户端机制实例——能力矩阵
|
||||
# zcode 写路径门禁能力矩阵
|
||||
|
||||
- 适配对象:zcode CLI(工作区级配置)
|
||||
- 依据:Development-Guidelines 0.1.0-draft.8(c9ab623)standards/tool-adaptation.md + policy/adoption.md
|
||||
- 状态:verified(脚本级正负例 8/8;客户端实弹拦截待下个会话日志确认,见备注)
|
||||
- 实例位置:.zcode/config.json(PreToolUse 钩子)、scripts/zcode/write-guard.sh、AGENTS.md(常驻条文 = zcode 的 rules 层)
|
||||
- 单一权威声明:本实例是上述规范的机制实现,语义以库与项目入口为准
|
||||
适配对象:`.zcode/config.json` 登记的 Edit/Write/ApplyPatch PreToolUse 钩子。
|
||||
要求来自 Development-Guidelines `standards/tool-adaptation.md` 与根 AGENTS.md;
|
||||
实现为 `scripts/zcode/write-guard.sh` 及其 Python 解析器。
|
||||
|
||||
## 能力矩阵
|
||||
| 要求 | 范围与等级 | 验证与限制 |
|
||||
|---|---|---|
|
||||
| A1/A3 写入前检查 | 脚本对 main/master、detached HEAD、非法任务分支拒绝 | `tests/test_write_guard.py` 覆盖现有/新目录、相对路径、软链接、多文件补丁与移动目标 |
|
||||
| A1 启动检查 | manual | 写路径判断不证明 precheck 已执行,仍须首次收据 |
|
||||
| A2/A4/A5/A6/A7 | 复用 CLI/git 对应机制 | 见 [CLI/git 矩阵](tool-adaptation-matrix.md),不另行声明接线 |
|
||||
|
||||
| 要求 | 等级 | 机制(执行时机) | 正例证据 | 负例证据 | 备注 |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 启动门禁 | enforced(写路径)+ advisory(检测纪律) | PreToolUse 拦 Edit/Write/ApplyPatch:目标仓在集成分支、detached HEAD、坏分支名时 exit 2 阻断(落笔前,git 钩子只拦提交时) | 脚本套件 P1–P4 放行 | N1 main 拦截、N2 detached 子仓拦截、N3 坏分支名拦截(均 exit 2 带指引) | detached 拦截正是多租户事故形态;检测/verdict 仍靠 precheck 纪律 |
|
||||
| A2 节点同步 | advisory | 无对应 zcode 事件;走 task-sync 纪律 | Dev PR#1–6 均经 sync | 实战脏树拒绝 | 不虚报 |
|
||||
| A3 集成分支保护 | enforced(local) 双层 | zcode 写路径(落笔前)+ git pre-commit(提交时);平台分支保护未配置 | 12+ PR 全走平台 | N1 + 套件 hook-main-refused | 平台层配置后升级 |
|
||||
| A4 提交与任务记录格式 | enforced(local) 主题行 + manual body | git commit-msg 钩子(对 zcode 的 Bash git commit 同样生效) | 本批提交 | 套件 hook-longsubject-refused | body 四段无机器校验 |
|
||||
| A5 工作区回收 | advisory | task-finish(rm+prune 兜底) | PR#5/#6 后实战回收 | finish-before-merge 拒绝 | |
|
||||
| A6 秘密边界 | manual | token 自 ~/.secrets/gitea;守卫与配置无凭证 | 全程无明文泄漏 | 无构造负例 | 纪律项 |
|
||||
| A7 证据落盘 | manual | 完成声明附三段输出 | 各 PR body | 无构造负例 | |
|
||||
写请求输入无法解析、匹配工具缺少可识别路径、必要解释器缺失时显式拒绝并给出原因。
|
||||
仓内新目录从最近已存在祖先定位,软链接按实际目标判断;补丁逐个检查全部目标。
|
||||
识别为仓外路径时显式报告该钩子不管辖;该结果不授予仓外写权限,仍由工具授权策略决定。
|
||||
|
||||
## 降级与失败行为
|
||||
## 客户端接线状态
|
||||
|
||||
| 机制 | 依赖缺失时行为 | 对应等级调整 | 解锁路径 |
|
||||
|---|---|---|---|
|
||||
| write-guard 输入解析(python3) | 缺 python3 或 stdin 无路径/垃圾 → 放行(fail-open) | A1 写路径降级 manual | 装 python3;提交时仍有 git 钩子兜底,构成第二道 |
|
||||
| write-guard(bash/git) | 脚本报错非 0/2 → 客户端记为错误事件 | 同上 | 修复后重跑套件 |
|
||||
| zcode 钩子运行器 | 配置文件钩子须 hooks.enabled=true(已设) | A1 写路径整体失效 | 客户端设置确认运行器激活 |
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 2026-09-30 脚本级套件 8/8 PASS(N1–N3 拦截、P1–P5 放行与配置合法),执行者 weichen,输出留存任务卡
|
||||
- 客户端内实弹拦截:pending-live——两次无副作用探针(配置创建当期会话)均未被拦,ZCode 日志无 hook 执行记录,证实钩子配置随会话启动加载;本会话早于配置存在,属预期。下个会话首次写入即自然验证
|
||||
- 证据复用:A2–A7 行沿用 CLI/git 实例矩阵证据(机制未变)
|
||||
脚本级正负例与客户端实际拦截分开。原记录中的客户端状态为 pending-live;
|
||||
本次本地测试不替代真实 zcode 会话中 hook 加载及拒绝路径证据。
|
||||
在客户端实测前,端到端能力仍标未验证,不能仅凭脚本 exit 2 登记客户端 enforced。
|
||||
已有 git 提交钩子和平台准入分别承担各自范围,不能证明落笔前检查已生效。
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Warn when a task card's recorded objects no longer support current checks.
|
||||
|
||||
Receipt commands and output are data only. This is a freshness check, not an
|
||||
upgrade of local SELF evidence to reviewer or CI evidence.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Reading a receipt must not create ignored files that prevent task-finish.
|
||||
sys.dont_write_bytecode = True
|
||||
import evidence
|
||||
|
||||
|
||||
def warn(label, reason):
|
||||
print(f"[evidence] WARN {label}: {reason} ——关键检查须重跑并以 make evidence 重新写入")
|
||||
|
||||
|
||||
def check_head(head):
|
||||
if not isinstance(head, str) or not re.fullmatch(r"[0-9a-f]{4,64}", head):
|
||||
raise ValueError("invalid recorded HEAD")
|
||||
resolved = subprocess.run(["git", "rev-parse", "--verify", head + "^{commit}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if resolved.returncode:
|
||||
raise ValueError("recorded HEAD is unavailable or ambiguous")
|
||||
result = subprocess.run(["git", "diff", "--quiet", "--no-ext-diff",
|
||||
resolved.stdout.decode().strip(), "HEAD", "--", ".", ":(exclude).task"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode not in (0, 1):
|
||||
raise ValueError("recorded HEAD could not be compared")
|
||||
return result.returncode == 1
|
||||
|
||||
|
||||
def card_path(path):
|
||||
return path == ".task" or path.startswith(".task/")
|
||||
|
||||
|
||||
def check_snapshot(snapshot):
|
||||
evidence.validate_snapshot(snapshot)
|
||||
stale = check_head(snapshot["head"])
|
||||
for line in snapshot["status"].splitlines():
|
||||
if len(line) < 4 or line[2] != " ":
|
||||
raise ValueError("invalid recorded worktree status")
|
||||
# Parse quoting as text, never as a shell command. Renames must have
|
||||
# both paths inside .task to qualify as metadata-only changes.
|
||||
paths = shlex.split(line[3:]) if line[3:].startswith('"') else [line[3:]]
|
||||
paths = [part for path in paths for part in path.split(" -> ") if part != "->"]
|
||||
if not paths or not all(card_path(path) for path in paths):
|
||||
raise ValueError("recorded object includes uncommitted non-card changes")
|
||||
if any(not card_path(path) for path in snapshot["untracked"]):
|
||||
raise ValueError("recorded object includes untracked non-card files")
|
||||
if any(not line.startswith(" ") for line in snapshot["submodules"].splitlines()):
|
||||
raise ValueError("recorded component state was not at its pinned SHA")
|
||||
if not snapshot["status"] and snapshot["tracked_diff_sha256"] != evidence.digest(b""):
|
||||
raise ValueError("recorded diff cannot be reconstructed from HEAD")
|
||||
return stale
|
||||
|
||||
|
||||
def check_receipt(value):
|
||||
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA:
|
||||
raise ValueError("unsupported or malformed receipt")
|
||||
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
if set(value) not in (expected, expected | {"capture_note"}):
|
||||
raise ValueError("invalid receipt fields")
|
||||
for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"):
|
||||
if not isinstance(value[key], str):
|
||||
raise ValueError("invalid receipt field: " + key)
|
||||
for key in ("command_sha256", "output_sha256", "receipt_sha256"):
|
||||
if not isinstance(value[key], str) or not evidence.SHA256.fullmatch(value[key]):
|
||||
raise ValueError("invalid receipt digest: " + key)
|
||||
if value["source"] != "SELF" or type(value["exit_code"]) is not int or not 0 <= value["exit_code"] <= 255:
|
||||
raise ValueError("invalid receipt source or exit code")
|
||||
if any(not Path(value[key]).is_absolute() for key in ("cwd", "common_dir")):
|
||||
raise ValueError("invalid receipt identity paths")
|
||||
if "capture_note" in value and not isinstance(value["capture_note"], str):
|
||||
raise ValueError("invalid capture note")
|
||||
try:
|
||||
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ValueError("invalid receipt timestamp") from None
|
||||
payload = {key: item for key, item in value.items() if key != "receipt_sha256"}
|
||||
if evidence.digest(evidence.canonical(payload)) != value.get("receipt_sha256"):
|
||||
raise ValueError("receipt checksum mismatch")
|
||||
if not isinstance(value.get("output"), str) or evidence.digest(value["output"]) != value.get("output_sha256"):
|
||||
raise ValueError("receipt output checksum mismatch")
|
||||
if value.get("after") is None or value.get("after_error") is not None:
|
||||
raise ValueError("receipt has no complete after object")
|
||||
before_stale = check_snapshot(value.get("before"))
|
||||
after_stale = check_snapshot(value["after"])
|
||||
return before_stale or after_stale
|
||||
|
||||
|
||||
def records(content):
|
||||
"""Yield fenced JSON and timestamped legacy rows without interpreting text."""
|
||||
block = None
|
||||
start = None
|
||||
for number, line in enumerate(content.splitlines(), 1):
|
||||
if block is not None:
|
||||
if line.strip() == "```":
|
||||
yield f"JSON receipt line {start}", "json", "\n".join(block)
|
||||
block = None
|
||||
else:
|
||||
block.append(line)
|
||||
elif line.strip() == "```json":
|
||||
block, start = [], number
|
||||
elif re.match(r"^\|\s*\d{4}-\d{2}-\d{2}T[^|]*\|", line):
|
||||
fields = re.split(r"(?<!\\)\|", line)
|
||||
yield f"legacy receipt line {number}", "legacy", fields[3].strip(" `") if len(fields) > 3 else ""
|
||||
if block is not None:
|
||||
yield f"JSON receipt line {start}", "json", ""
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
content = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||
except (IndexError, OSError, UnicodeError):
|
||||
warn("task card", "unverified: evidence could not be read")
|
||||
return
|
||||
for label, kind, record in records(content):
|
||||
try:
|
||||
stale = check_receipt(json.loads(record)) if kind == "json" else check_head(record)
|
||||
except (ValueError, TypeError, KeyError, OSError, evidence.EvidenceError) as exc:
|
||||
warn(label, "unverified: " + str(exc))
|
||||
continue
|
||||
if stale:
|
||||
warn(label, "recorded HEAD predates non-card changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail closed before task synchronization, verification, or worktree disposal."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
result = subprocess.run(["git", "-C", str(repo), *args], stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Git check failed in {repo}: {' '.join(args)}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def text(repo, *args):
|
||||
return os.fsdecode(git(repo, *args)).strip()
|
||||
|
||||
|
||||
def check_operations(repo):
|
||||
for marker in ("index.lock", "HEAD.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD",
|
||||
"REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_LOG"):
|
||||
path = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-path", marker))
|
||||
if path.exists():
|
||||
raise RuntimeError(f"active Git operation/lock in {repo}: {marker}; owner must finish it")
|
||||
|
||||
|
||||
def check_clean(repo, ignored=False, allow_gitlink_drift=False):
|
||||
args = ["status", "--porcelain=v1", "--untracked-files=all",
|
||||
"--ignore-submodules=all" if allow_gitlink_drift else "--ignore-submodules=none"]
|
||||
if ignored:
|
||||
args.append("--ignored=matching")
|
||||
status = git(repo, *args)
|
||||
if status:
|
||||
raise RuntimeError(f"unsaved, untracked{', or ignored' if ignored else ''} files in {repo}; preserve them before continuing")
|
||||
|
||||
|
||||
def submodules(repo):
|
||||
for item in git(repo, "ls-files", "--stage", "-z").split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
meta, path = item.split(b"\t", 1)
|
||||
mode, sha, stage = meta.split()
|
||||
if stage != b"0":
|
||||
raise RuntimeError(f"unmerged index in {repo}; resolve conflicts first")
|
||||
if mode == b"160000":
|
||||
yield repo / os.fsdecode(path), sha.decode("ascii")
|
||||
|
||||
|
||||
def worktrees(repo):
|
||||
records = git(repo, "worktree", "list", "--porcelain", "-z").split(b"\0\0")
|
||||
parsed = [record.split(b"\0") for record in records if record]
|
||||
if not parsed or any(not record[0].startswith(b"worktree ") for record in parsed):
|
||||
raise RuntimeError(f"cannot identify registered worktrees in {repo}")
|
||||
return parsed
|
||||
|
||||
|
||||
def check_component_worktrees(repo, removal_roots):
|
||||
common = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
|
||||
if not any(common == root or root in common.parents for root in removal_roots):
|
||||
return
|
||||
current = Path(text(repo, "rev-parse", "--absolute-git-dir")).resolve()
|
||||
for index, record in enumerate(worktrees(repo)):
|
||||
other = Path(os.fsdecode(record[0][9:])).resolve()
|
||||
# Git may report an absorbed submodule's primary checkout as its common
|
||||
# directory. It is the current checkout only when no linked gitdir is used.
|
||||
if other != repo.resolve() and not (index == 0 and current == common):
|
||||
raise RuntimeError(f"registered component worktree {other} depends on metadata in {common}; preserve or relocate it before removal")
|
||||
|
||||
|
||||
def check_children(repo, mode, removal_roots=()):
|
||||
for child, expected in submodules(repo):
|
||||
if child.is_symlink():
|
||||
raise RuntimeError(f"submodule path is a symbolic link: {child}")
|
||||
if not (child / ".git").exists():
|
||||
if mode in ("verify", "after-sync", "finish"):
|
||||
raise RuntimeError(f"uninitialized submodule: {child}; initialize and inspect it first")
|
||||
continue
|
||||
if mode == "finish":
|
||||
check_component_worktrees(child, removal_roots)
|
||||
actual = text(child, "rev-parse", "HEAD")
|
||||
check_operations(child)
|
||||
check_clean(child, ignored=(mode == "finish"), allow_gitlink_drift=(mode == "before-update"))
|
||||
branch = text(child, "branch", "--show-current")
|
||||
if mode == "before-update" and branch and actual != expected:
|
||||
raise RuntimeError(f"submodule {child} owns branch {branch}; keep that work and explicitly reconcile pin {expected} before retrying task-sync")
|
||||
if mode in ("verify", "after-sync", "finish") and actual != expected:
|
||||
raise RuntimeError(f"submodule SHA drift: {child}: expected {expected}, actual {actual}")
|
||||
if mode == "finish" and git(child, "rev-list", "--all", "--reflog", "--not", expected).strip():
|
||||
raise RuntimeError(f"submodule has commits outside its pinned history: {child}; preserve branches, stash and reflogs before removal")
|
||||
check_children(child, mode, removal_roots)
|
||||
|
||||
|
||||
def materialize(repo):
|
||||
"""Check out one level, then inspect the newly exposed nested pins before moving them."""
|
||||
for child, expected in submodules(repo):
|
||||
check_children(repo, "before-update")
|
||||
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
|
||||
"--checkout", "--", str(child.relative_to(repo))])
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
|
||||
materialize(child)
|
||||
|
||||
|
||||
def check_finish_root(repo):
|
||||
if Path.cwd().resolve() != repo:
|
||||
raise RuntimeError("task-finish must run at the registered worktree root, not a subdirectory")
|
||||
parsed = worktrees(repo)
|
||||
main = Path(os.fsdecode(parsed[0][0][9:])).resolve()
|
||||
if main == repo:
|
||||
raise RuntimeError("task-finish must run in a linked task worktree, never the main checkout")
|
||||
matches = [r for r in parsed if r[0].startswith(b"worktree ")
|
||||
and Path(os.fsdecode(r[0][9:])).resolve() == repo]
|
||||
if len(matches) != 1:
|
||||
raise RuntimeError("worktree is not registered exactly once; preserve it for owner inspection")
|
||||
if any(field.startswith((b"locked", b"prunable", b"bare", b"detached")) for field in matches[0][1:]):
|
||||
raise RuntimeError("locked, detached, bare, or prunable worktree must be preserved")
|
||||
common = Path(text(repo, "rev-parse", "--path-format=absolute", "--git-common-dir"))
|
||||
if any(common.rglob("*.lock")):
|
||||
raise RuntimeError("Git lock exists in shared repository metadata; wait for its owner")
|
||||
|
||||
|
||||
def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "verify"
|
||||
if mode not in ("verify", "before-sync", "before-update", "materialize", "after-sync", "finish"):
|
||||
raise RuntimeError("usage: check-worktree.py [verify|before-sync|before-update|materialize|after-sync|finish]")
|
||||
repo = Path(text(Path.cwd(), "rev-parse", "--show-toplevel")).resolve()
|
||||
check_operations(repo)
|
||||
removal_roots = ()
|
||||
if mode == "finish":
|
||||
check_finish_root(repo)
|
||||
removal_roots = (repo, Path(text(repo, "rev-parse", "--absolute-git-dir")).resolve())
|
||||
if mode in ("before-sync", "after-sync", "finish"):
|
||||
check_clean(repo, ignored=(mode == "finish"))
|
||||
if mode == "materialize":
|
||||
materialize(repo)
|
||||
else:
|
||||
check_children(repo, mode, removal_roots)
|
||||
print(f"[worktree] {mode}: recursive component checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/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")
|
||||
|
||||
|
||||
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, "<redacted>")
|
||||
value = re.sub(r"-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----",
|
||||
"<redacted-private-key>", value, flags=re.S)
|
||||
value = re.sub(r"(?i)(\bAuthorization['\"]?[ \t]*[=:][ \t]*['\"]?"
|
||||
r"(?:Bearer|token|Basic)[ \t]+)[^\s'\";]+", r"\1<redacted>", value)
|
||||
value = re.sub(r"(?i)\b(https?|ssh)://[^\s/@:]+:[^\s/@]+@", r"\1://<redacted>@", 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<redacted>\2", value, flags=re.S)
|
||||
value = re.sub(r"(?i)(--" + credential + r"[ \t]+)(?!<redacted>)[^\s'\";]+",
|
||||
r"\1<redacted>", value)
|
||||
value = re.sub(
|
||||
r"(?i)(\b[\w-]*(?:token|password|passwd|secret|api[_-]?key|credential)[\w-]*"
|
||||
r"['\"]?[ \t]*[=:][ \t]*['\"]?)(?!<redacted>)[^\s'\"&;,}]+",
|
||||
r"\1<redacted>", 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 create_receipt(command, output, exit_code, before, after, *, timestamp=None,
|
||||
cwd=None, git_common_dir=None, after_error=None, capture_note=None):
|
||||
"""Package an actual captured run; callers must not reconstruct missing output."""
|
||||
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,
|
||||
})
|
||||
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")
|
||||
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
if set(value) not in (expected, expected | {"capture_note"}):
|
||||
raise EvidenceError("Invalid receipt fields")
|
||||
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 validate_precheck(receipt):
|
||||
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")
|
||||
if receipt["exit_code"] != 0 or not re.search(r"^\[verdict\] READY(?:\s|$)", receipt["output"], re.M):
|
||||
raise EvidenceError("Precheck receipt is not READY")
|
||||
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"] or after["target_head"] != current["target_head"]:
|
||||
raise EvidenceError("Precheck authoritative target has changed")
|
||||
|
||||
|
||||
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" + (
|
||||
"<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->\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")
|
||||
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)
|
||||
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()
|
||||
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"):
|
||||
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)
|
||||
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())
|
||||
+7
-54
@@ -1,55 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# 证据写入工具:执行命令,把(命令、被测 HEAD、退出码、输出 sha256、尾部摘录、时间、来源)
|
||||
# 追加到当前分支任务卡的「## 证据(Evidence)」表。规范依据:AGENTS.md「证据与验证门禁」、
|
||||
# guidelines standards/verification.md「声明与证据来源」——转写/手抄的输出视为无效证据。
|
||||
# 用法:CMD="<单行命令>" make evidence [SRC=SELF|REPLAY|GATE] [TAIL=15]
|
||||
# (CMD 经环境变量直传;也可直接 CMD=… SRC=… TAIL=… bash scripts/evidence.sh)
|
||||
# 退出码 = 被执行命令的退出码(失败可见),证据行无论成败都会写入。
|
||||
set -uo pipefail
|
||||
|
||||
CMD="${CMD:-}"; SRC="${SRC:-SELF}"; TAILN="${TAIL:-15}"
|
||||
[ -n "$CMD" ] || { echo 'usage: make evidence CMD="<command>" [SRC=SELF|REPLAY|GATE] [TAIL=15]' >&2; exit 2; }
|
||||
case "$SRC" in SELF|REPLAY|GATE) ;; *) echo "ERROR: SRC 须为 SELF|REPLAY|GATE(当前:$SRC)" >&2; exit 2 ;; esac
|
||||
case "$TAILN" in ''|*[!0-9]*) TAILN=15 ;; esac
|
||||
case "$CMD" in *$'\n'*) echo 'ERROR: CMD 须为单行命令' >&2; exit 2 ;; esac
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
[ -n "$branch" ] || { echo "ERROR: 不在 git 仓库内" >&2; exit 1; }
|
||||
slug=$(echo "$branch" | tr '/' '-')
|
||||
card=".task/${slug}.md"
|
||||
if [ ! -f "$card" ]; then
|
||||
# 回退:按卡片「分支:」字段匹配(与 task.sh cmd_sync 同款口径)
|
||||
for pc in .task/*.md; do
|
||||
[ -f "$pc" ] || continue
|
||||
if [ "$(sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$pc" | head -1)" = "$branch" ]; then
|
||||
card=$pc; break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
[ -f "$card" ] || { echo "ERROR: 找不到分支 ${branch} 的任务卡(.task/${slug}.md)" >&2; exit 1; }
|
||||
|
||||
head=$(git rev-parse HEAD)
|
||||
short_head=$(echo "$head" | cut -c1-10)
|
||||
ts=$(date -u +%FT%TZ)
|
||||
|
||||
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
|
||||
bash -c "$CMD" >"$tmp" 2>&1; rc=$?
|
||||
digest=$(sha256sum "$tmp" | cut -c1-16)
|
||||
|
||||
# 脱敏(baseline 硬边界 2):token 风格的 40 位 hex / Authorization 头不打进任务卡。
|
||||
sanitize() {
|
||||
sed -E -e 's/(token[ =:]+)[0-9a-fA-F]{40}/\1<redacted>/g' \
|
||||
-e 's/(Authorization: *token +)[^ ]+/\1<redacted>/Ig' \
|
||||
-e 's/(access_token[=:]+)[0-9a-fA-F]{40}/\1<redacted>/Ig'
|
||||
}
|
||||
|
||||
if ! grep -q '^## 证据(Evidence)' "$card"; then
|
||||
printf '\n## 证据(Evidence)\n\n<!-- make evidence CMD="…" 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->\n| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |\n|---|---|---|---|---|---|\n' >> "$card"
|
||||
fi
|
||||
cmd_cell=$(echo "$CMD" | sed -e 's/|/\\|/g' -e 's/`/‘/g')
|
||||
printf '| %s | `%s` | %s | %d | %s | %s |\n' "$ts" "$cmd_cell" "$short_head" "$rc" "$SRC" "$digest" >> "$card"
|
||||
printf '\n<!-- evidence:run %s -->\n```\n$ %s\n[exit %d | HEAD %s | sha256:%s] 输出尾部 %s 行:\n%s\n```\n' \
|
||||
"$ts" "$CMD" "$rc" "$short_head" "$digest" "$TAILN" "$(tail -n "$TAILN" "$tmp" | sanitize)" >> "$card"
|
||||
|
||||
echo "[evidence] card=$card cmd=\"${CMD}\" exit=$rc src=$SRC head=$short_head sha256=$digest"
|
||||
exit "$rc"
|
||||
# Local evidence is always SELF; GATE/REPLAY require external run/reviewer identity.
|
||||
# CMD='make test' make evidence append a recorded run to the card
|
||||
# RECEIPT=/tmp/precheck.json CMD='make precheck' make evidence capture before a card exists
|
||||
# IMPORT_RECEIPT=/tmp/precheck.json make evidence import without re-executing
|
||||
# VALIDATE_RECEIPT=/tmp/precheck.json bash scripts/evidence.sh task-start preflight
|
||||
set -euo pipefail
|
||||
exec python3 "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/evidence.py"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地钩子(post-merge):merge 型 pull/merge 落地后自动闭合已合入任务卡(ff-only 不触发
|
||||
# 本钩子——由 task-finish 收尾与 precheck [cards] 报告兜底)。
|
||||
# 本地钩子(post-merge):pull/merge 落地后尝试闭合已合入任务卡;ff-only 也会触发。
|
||||
# precheck [cards] 报告欠账,所有者可用 make task-close 手动兜底。
|
||||
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
|
||||
# 防止把卡片闭合混进任务分支现场。失败只警告,绝不阻断 pull。
|
||||
# 注意:task.sh close 在主检出会另建 feature/card-close-* 分支提交(集成分支不直推),
|
||||
|
||||
+122
-59
@@ -4,24 +4,36 @@
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}"
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
need_repo() { git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"; }
|
||||
width() { python3 -c 'import sys,unicodedata;print(sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in sys.argv[1]))' "$1"; }
|
||||
fetch_target() {
|
||||
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
|
||||
if ! git fetch -q origin "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh authoritative origin/${TARGET_BRANCH}; preserve local work and retry" >&2
|
||||
return 6
|
||||
fi
|
||||
git rev-parse --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null \
|
||||
|| { echo "[verdict] REMOTE_UNVERIFIED — target commit missing" >&2; return 6; }
|
||||
}
|
||||
check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; }
|
||||
main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; }
|
||||
|
||||
cmd_precheck() {
|
||||
need_repo
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
local branch head rmain dirty sub_drift sub_uninit
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
head=$(git rev-parse HEAD)
|
||||
rmain=$(git rev-parse --verify -q "origin/${TARGET_BRANCH}" 2>/dev/null || echo "$head")
|
||||
rmain=$(git rev-parse --verify "origin/${TARGET_BRANCH}")
|
||||
dirty=$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)
|
||||
sub_drift=$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })
|
||||
sub_uninit=$(git submodule status --recursive 2>/dev/null | { grep -cE '^-' || true; })
|
||||
local behind ahead
|
||||
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}" 2>/dev/null || echo 0)
|
||||
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD" 2>/dev/null || echo 0)
|
||||
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}")
|
||||
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")
|
||||
echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)"
|
||||
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
|
||||
echo "[tree] $([ "$dirty" -eq 0 ] && echo clean || echo "dirty(${dirty})") submodules_drift=${sub_drift} uninit=${sub_uninit}"
|
||||
@@ -93,18 +105,26 @@ cmd_precheck() {
|
||||
echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认"
|
||||
exit 3
|
||||
fi
|
||||
if [ "$behind" -gt 0 ] && [ "$branch" = "${TARGET_BRANCH}" ]; then
|
||||
echo "[verdict] SYNC_REQUIRED — 本地 ${TARGET_BRANCH} 落后远端 ${behind} 提交;先 git pull --ff-only"
|
||||
if ! check_worktree before-sync; then
|
||||
echo "[verdict] SHARED_WORKSPACE_BUSY — component or active operation check failed; preserve the workspace"
|
||||
exit 3
|
||||
fi
|
||||
if [ "$behind" -gt 0 ]; then
|
||||
echo "[verdict] SYNC_REQUIRED — ${branch} 缺少 origin/${TARGET_BRANCH} 的 ${behind} 个提交;主检出 pull --ff-only,任务分支 make task-sync"
|
||||
exit 4
|
||||
fi
|
||||
echo "[verdict] READY — 可建任务工作区:make task-start NAME=feature/your-branch"
|
||||
echo "[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
need_repo
|
||||
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix|release>/<slug>"
|
||||
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
|
||||
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
check_worktree before-sync
|
||||
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
|
||||
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
|
||||
local guard_ref
|
||||
@@ -117,15 +137,26 @@ cmd_start() {
|
||||
[ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)"
|
||||
[ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)"
|
||||
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first"
|
||||
local rmain slug wt mainroot
|
||||
local rmain slug wt mainroot start_ref parent_head="" parent_note=""
|
||||
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
start_ref=$rmain
|
||||
if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
|
||||
[ -n "${TASK_PARENT_BRANCH:-}" ] && [ -n "${TASK_PARENT_PR:-}" ] || die "stacked task requires TASK_PARENT_BRANCH and TASK_PARENT_PR"
|
||||
echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch"
|
||||
echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL"
|
||||
case "${name#*/}" in "${TASK_PARENT_BRANCH#*/}"-?*) ;; *) die "stacked child slug must start with parent slug plus '-'" ;; esac
|
||||
parent_head=$(git rev-parse --verify "refs/heads/${TASK_PARENT_BRANCH}^{commit}") || die "parent branch missing locally"
|
||||
git merge-base --is-ancestor "$rmain" "$parent_head" || die "parent branch does not include current origin/${TARGET_BRANCH}; sync it first"
|
||||
start_ref=$parent_head
|
||||
parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}"
|
||||
fi
|
||||
slug=$(echo "$name" | tr '/' '-')
|
||||
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
|
||||
# 改用 git-common-dir 的父目录定位主检出(与 cmd_finish 同款惯用法)。
|
||||
mainroot=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)")
|
||||
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
|
||||
mainroot=$(main_worktree_root)
|
||||
wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}"
|
||||
[ -e "$wt" ] && die "worktree path exists: $wt"
|
||||
git worktree add -q "$wt" -b "$name" "$rmain"
|
||||
git worktree add -q "$wt" -b "$name" "$start_ref"
|
||||
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
|
||||
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
|
||||
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
|
||||
@@ -139,12 +170,13 @@ cmd_start() {
|
||||
cat > "$card" <<EOF
|
||||
# ${name} 任务卡
|
||||
|
||||
任务主记录:<PR 链接,task-finish 时填写>;状态:进行中;owner:$(git config user.name || echo unknown)
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:$(git config user.name || echo unknown)
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
|
||||
- 工作区:${wt}(worktree);分支:${name}
|
||||
${parent_note}
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
|
||||
## 目标与非目标
|
||||
@@ -169,16 +201,31 @@ cmd_start() {
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
- 影响与剩余项:
|
||||
EOF
|
||||
git -C "$wt" add .task && git -C "$wt" commit -q -m "Record the task card for ${slug}" && echo "[card] $card(已随分支提交)"
|
||||
(cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh")
|
||||
git -C "$wt" add .task
|
||||
git -C "$wt" commit -q -m "Record the task card for ${slug}" \
|
||||
-m "Why: establish the authorized task workspace and its initial baseline." \
|
||||
-m "Evidence: preserved precheck receipt in .task/${slug}.md; R0=${rmain}." \
|
||||
-m "Impact: task metadata only; the feature workspace starts at ${start_ref}." \
|
||||
-m "Refs: .task/${slug}.md"
|
||||
echo "[card] $card(已随分支提交)"
|
||||
echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)"
|
||||
}
|
||||
|
||||
cmd_sync() {
|
||||
need_repo
|
||||
git fetch -q origin "${TARGET_BRANCH}"
|
||||
[ "$(git status --porcelain | grep -v '^.. \.zcode/' | wc -l)" -eq 0 ] || die "dirty tree — 先显式提交;不隐式 stash(collaboration.md:11)"
|
||||
git merge --no-edit "origin/${TARGET_BRANCH}"
|
||||
fetch_target
|
||||
check_worktree before-sync
|
||||
git merge --no-edit "origin/${TARGET_BRANCH}" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=$(git rev-parse "origin/${TARGET_BRANCH}")." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete"
|
||||
check_worktree before-update
|
||||
check_worktree materialize
|
||||
check_worktree after-sync
|
||||
local base head card branch slug pc
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD)
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
@@ -197,20 +244,12 @@ cmd_sync() {
|
||||
if [ -n "$card" ] && [ -f "$card" ]; then
|
||||
printf '| 同步前验收 | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
|
||||
git add "$card"
|
||||
git commit -q -m "Record the sync checkpoint" || true
|
||||
# [evidence] 同步改变了被测对象:卡片证据表中旧 HEAD 的行,其后若有非 .task 变更则失效须重跑。
|
||||
# 按 | 切行会撞上 cmd 单元格里被转义的 \|\|(2026-10-05 实测:碎片 '\' 传给 git diff
|
||||
# 触发 fatal 128,set -e 下直接杀死 sync)——非十六进制碎片一律跳过,不作 HEAD 解析。
|
||||
local eh stale_ev="" short_head changed
|
||||
short_head=$(echo "$head" | cut -c1-10)
|
||||
while IFS='|' read -r _ _ _ eh _ _ _; do
|
||||
eh=$(echo "$eh" | tr -d ' `')
|
||||
case "$eh" in ''|*[!0-9a-f]*) continue ;; esac
|
||||
[ "$eh" != "$short_head" ] || continue
|
||||
changed=$(git diff --name-only "${eh}" HEAD -- . ':(exclude).task' 2>/dev/null | head -1)
|
||||
[ -n "$changed" ] && stale_ev="$stale_ev ${eh}"
|
||||
done < <(grep -E '^\| [0-9]{4}-[0-9]{2}-[0-9]{2}T[^|]*\|' "$card" 2>/dev/null || true)
|
||||
[ -n "$stale_ev" ] && echo "[evidence] WARN 证据表被测 HEAD 落后且其后有非卡片变更:$(echo $stale_ev | tr ' ' '\n' | sort -u | tr '\n' ' ')(当前 $short_head)——关键检查须重跑并以 make evidence 重新写入"
|
||||
git commit -q -m "Record the sync checkpoint" \
|
||||
-m "Why: bind the task validation checkpoint to the latest integration baseline." \
|
||||
-m "Evidence: SELF recursive component checks; sync_base=${base}; candidate=${head}." \
|
||||
-m "Impact: task metadata only." -m "Refs: ${card}"
|
||||
# JSON receipts carry before/after state; older table rows remain readable.
|
||||
python3 "${SCRIPT_DIR}/check-evidence.py" "$card"
|
||||
else
|
||||
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
|
||||
fi
|
||||
@@ -264,7 +303,7 @@ cmd_close() {
|
||||
need_repo
|
||||
local fold=0
|
||||
[ "${1:-}" = "--fold" ] && fold=1
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
# 默认值取远端通告的 Web 面(git push 输出的 PR 建议链接);可用 TASK_CARD_WEB_BASE 覆盖。
|
||||
# git-panel 前缀已失效(TLS 握手失败),默认值改为实测可用的 git-home(AGENTS.md 访问事实节)。
|
||||
local web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
|
||||
@@ -370,37 +409,61 @@ for p in json.load(sys.stdin):
|
||||
|
||||
cmd_finish() {
|
||||
need_repo
|
||||
local abandon="${1:-}"
|
||||
local branch head common mainrepo
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
local abandon="${1:-}" branch head root mainrepo remote_head="" remote_status
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
check_worktree finish
|
||||
[ "${TASK_WRITERS_STOPPED:-}" = 1 ] || die "TASK_WRITERS_STOPPED=1 required: the workspace owner must first confirm all writers have stopped"
|
||||
branch=$(git symbolic-ref --quiet --short HEAD) || die "detached task worktree must be preserved"
|
||||
case "$branch" in feature/*|fix/*|hotfix/*|release/*) ;; *) die "refuse to recycle non-task branch ${branch}" ;; esac
|
||||
head=$(git rev-parse HEAD)
|
||||
common=$(git rev-parse --git-common-dir); common=$(cd "$common" && pwd)
|
||||
mainrepo=$(dirname "$common")
|
||||
# 防护(T0 事故教训):finish 必须在任务 worktree 内运行——本脚本把 $PWD 当
|
||||
# worktree 回收(rm -rf);从主检出运行会误删主检出。mainrepo==PWD 即主检出。
|
||||
[ "$(cd "$mainrepo" && pwd)" = "$PWD" ] && die "task-finish 须在任务 worktree 内运行(cd 到 worktree 再执行),防止误删主检出"
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
mainrepo=$(main_worktree_root)
|
||||
fetch_target
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon 需要原因:task.sh finish --abandon <reason>"
|
||||
local untracked; untracked=$(git status --porcelain | grep -c '^??' || true)
|
||||
[ "$untracked" -gt 0 ] && die "存在 ${untracked} 个未跟踪文件 — 先处置或用 task-finish FORCE_ABANDON=1"
|
||||
[ "${FORCE_ABANDON:-0}" = "1" ] || { echo "[confirm] 放弃未合入工作属破坏性动作:重跑 FORCE_ABANDON=1 task.sh finish --abandon \"${reason}\""; exit 5; }
|
||||
echo "[abandoned] branch=${branch} reason=${reason} recorded_at=$(date -u +%FT%TZ)"
|
||||
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason"
|
||||
# An explicit abandon removes only the clean checkout; commits stay reachable.
|
||||
git notes --ref=refs/notes/task-abandon append -m "Task ${branch} abandoned at $(date -u +%FT%TZ): ${reason}" "$head"
|
||||
echo "[abandoned] branch=${branch}; commits retained; reason recorded in refs/notes/task-abandon"
|
||||
elif [ -n "$abandon" ]; then
|
||||
die "usage: task.sh finish [--abandon <reason>]"
|
||||
else
|
||||
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "分支未合入 origin/${TARGET_BRANCH}——先走 PR 合入;放弃则用 --abandon <reason>"
|
||||
echo "[verified] ${branch} 已包含于 origin/${TARGET_BRANCH};成果已落盘"
|
||||
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout"
|
||||
remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace"
|
||||
if [ -n "$remote_status" ]; then
|
||||
# Refresh its object, then use that exact observation as a deletion lease.
|
||||
git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace"
|
||||
remote_head=$(git rev-parse --verify FETCH_HEAD)
|
||||
git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch"
|
||||
fi
|
||||
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
|
||||
fi
|
||||
# git 对含 gitlink 的 worktree 无条件拒绝 remove(查索引),回收走 rm -rf + prune。
|
||||
# 此处不得跑 submodule deinit:submodule.<name>.url 写在共享的 .git/config 里,deinit
|
||||
# 会把其他检出/worktree 的子模块一并标成未初始化(2026-09-30 实测 finish 后主检出 uninit=12)。
|
||||
git -C "$mainrepo" worktree remove "$PWD" 2>/dev/null || { rm -rf "$PWD"; git -C "$mainrepo" worktree prune; }
|
||||
git -C "$mainrepo" branch -d "$branch" 2>/dev/null || git -C "$mainrepo" branch -D "$branch"
|
||||
git -C "$mainrepo" worktree prune
|
||||
git push -q --delete origin "$branch" 2>/dev/null || true
|
||||
echo "[recycled] worktree 与分支 ${branch} 已回收(本地+远端,$(date -u +%FT%TZ))"
|
||||
# 合入成功→自动闭合任务卡(含本任务卡;经 feature/card-close-* 分支+PR,合入权在所有者)
|
||||
( cd "$mainrepo" && bash scripts/task.sh close ) \
|
||||
|| echo "[next] 卡片自动闭合未完成——在主检出运行 make task-close"
|
||||
# Re-check after fetch. --force only compensates Git's gitlink limitation after all
|
||||
# registered-root, owner, lock, recursive dirt and ignored-file checks have passed.
|
||||
check_worktree finish
|
||||
local -a removal=(worktree remove)
|
||||
if git ls-files --stage | grep -q '^160000 '; then
|
||||
removal+=(--force)
|
||||
fi
|
||||
if ! git -C "$mainrepo" "${removal[@]}" "$root"; then
|
||||
die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted."
|
||||
fi
|
||||
cd "$mainrepo"
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN"
|
||||
elif git branch -d "$branch"; then
|
||||
if [ -z "$remote_head" ]; then
|
||||
echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT"
|
||||
elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then
|
||||
echo "[recycled] worktree and local branch=${branch} removed; remote=DELETED observed_head=${remote_head}"
|
||||
else
|
||||
echo "[partial] local worktree/branch removed; remote=${branch} retained or result unknown; query remote before retrying" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "[partial] worktree removed; local branch=${branch} retained because safe branch deletion was refused; remote=NOT_RUN" >&2
|
||||
return 1
|
||||
fi
|
||||
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
|
||||
echo "[next] inspect task-card closure with make task-close in the main checkout when authorized"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# zcode PreToolUse 写路径守卫(Edit/Write/ApplyPatch)。
|
||||
# 单一权威声明:本脚本是 Development-Guidelines standards/tool-adaptation.md
|
||||
# 要求 A1/A3 在 zcode 客户端的机制实例;语义以库与项目入口为准,本文件不复制规则正文。
|
||||
# 退出码:0 放行;2 阻断(PreToolUse 的 deny);其余非零为错误。
|
||||
# PreToolUse 写路径守卫。解析/依赖错误均拒绝,不能把未知请求当作安全请求。
|
||||
set -uo pipefail
|
||||
|
||||
input=$(cat)
|
||||
file=$(printf '%s' "$input" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
ti = d.get("tool_input") if isinstance(d.get("tool_input"), dict) else {}
|
||||
for src in (ti, d):
|
||||
for k in ("file_path", "path", "filename"):
|
||||
v = src.get(k)
|
||||
if isinstance(v, str) and v:
|
||||
print(v)
|
||||
sys.exit(0)
|
||||
' 2>/dev/null)
|
||||
[ -n "$file" ] || exit 0
|
||||
|
||||
repo=$(git -C "$(dirname "$file")" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
branch=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null) || exit 0
|
||||
|
||||
deny() { echo "write-guard: 拒绝——${1}(目标 ${file},仓库 ${repo})" >&2; exit 2; }
|
||||
|
||||
case "$branch" in
|
||||
main|master)
|
||||
deny "集成分支 ${branch} 不得直接写入;先 make task-start NAME=feature|fix/<slug>(A1/A3)" ;;
|
||||
HEAD)
|
||||
deny "仓库处于 detached HEAD(常为他人现场或未建分支的子仓);先显式建任务分支(A1)" ;;
|
||||
esac
|
||||
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' \
|
||||
|| deny "分支名 ${branch} 不符 feature|fix|hotfix|release/<slug>(A3)"
|
||||
exit 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo 'write-guard: 拒绝——缺少 python3,无法检查写入目标' >&2
|
||||
exit 2
|
||||
fi
|
||||
python3 "$(dirname "$0")/write_guard.py"
|
||||
result=$?
|
||||
if [ "$result" -ne 0 ]; then exit 2; fi
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check every write target; a local branch guard, not an OS sandbox."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def deny(reason):
|
||||
raise ValueError(reason)
|
||||
|
||||
|
||||
def path_value(value):
|
||||
if not isinstance(value, str) or not value.strip() or any(c in value for c in "\x00\r\n"):
|
||||
deny("写入路径缺失或无法解析")
|
||||
return value
|
||||
|
||||
|
||||
def patch_paths(patch):
|
||||
if not isinstance(patch, str):
|
||||
deny("ApplyPatch 缺少 patch_text")
|
||||
lines = patch.strip().splitlines()
|
||||
if len(lines) < 3 or lines[0] != "*** Begin Patch" or lines[-1] != "*** End Patch":
|
||||
deny("无法解析 ApplyPatch 边界")
|
||||
paths = []
|
||||
operation = None
|
||||
moved = False
|
||||
for line in lines[1:-1]:
|
||||
match = re.fullmatch(r"\*\*\* (Add File|Update File|Delete File): (.+)", line)
|
||||
if match:
|
||||
operation = match[1]
|
||||
moved = False
|
||||
paths.append(path_value(match[2]))
|
||||
elif line.startswith("*** Move to: ") and operation == "Update File" and not moved:
|
||||
paths.append(path_value(line[len("*** Move to: "):]))
|
||||
moved = True
|
||||
elif line == "*** End of File" and operation == "Update File":
|
||||
continue
|
||||
elif line.startswith("***") or operation is None:
|
||||
deny("ApplyPatch 包含未知路径指令")
|
||||
elif not (line.startswith(("+", "-", " ", "@@")) or line == ""):
|
||||
deny("ApplyPatch 包含无法解析的内容")
|
||||
if not paths:
|
||||
deny("ApplyPatch 没有可检查的路径")
|
||||
return paths
|
||||
|
||||
|
||||
def check_path(raw, cwd):
|
||||
target = Path(raw)
|
||||
if not target.is_absolute():
|
||||
target = cwd / target
|
||||
# A tool can follow a symlink OR replace/delete its directory entry. Check
|
||||
# both locations, including a dangling final symlink on a protected branch.
|
||||
location = target.parent.resolve() / target.name
|
||||
check_location(location, entry_only=location.is_symlink())
|
||||
resolved = target.resolve()
|
||||
if resolved != location:
|
||||
check_location(resolved)
|
||||
|
||||
|
||||
def check_location(target, entry_only=False):
|
||||
ancestor = target if target.is_dir() and not entry_only else target.parent
|
||||
while not ancestor.exists():
|
||||
ancestor = ancestor.parent
|
||||
if not ancestor.is_dir():
|
||||
deny(f"目标祖先不是目录:{ancestor}")
|
||||
# GIT_DIR/GIT_WORK_TREE must not redirect inspection to another checkout.
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
env["LC_ALL"] = "C"
|
||||
result = subprocess.run(["git", "-C", str(ancestor), "rev-parse", "--show-toplevel"],
|
||||
env=env, capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
if any((p / ".git").exists() or (p / ".git").is_symlink()
|
||||
for p in (ancestor, *ancestor.parents)):
|
||||
deny(f"无法读取目标仓库状态:{target}")
|
||||
if "not a git repository" not in result.stderr:
|
||||
deny(f"无法确认目标仓库:{target}")
|
||||
print(f"write-guard: outside-git(未受 Git 分支规则覆盖):{target}", file=sys.stderr)
|
||||
return
|
||||
repo = result.stdout.strip()
|
||||
branch_result = subprocess.run(["git", "-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
env=env, capture_output=True, text=True)
|
||||
if branch_result.returncode:
|
||||
deny(f"仓库处于 detached HEAD 或状态不可读:{repo};先建立任务分支(A1)")
|
||||
branch = branch_result.stdout.strip()
|
||||
if not re.fullmatch(r"(?:feature|fix|hotfix|release)/[A-Za-z0-9._-]+", branch):
|
||||
deny(f"分支 {branch} 不允许写入(目标 {target},仓库 {repo});先 make task-start NAME=feature/<slug>(A1/A3)")
|
||||
|
||||
|
||||
def main():
|
||||
data = json.load(sys.stdin)
|
||||
if not isinstance(data, dict):
|
||||
deny("钩子输入必须为对象")
|
||||
tool = data.get("tool_name")
|
||||
if tool in {"Read", "Glob", "Grep", "WebFetch", "WebSearch"}:
|
||||
return
|
||||
if tool not in {None, "Edit", "Write", "ApplyPatch", "apply_patch"}:
|
||||
deny("未知写入工具,必须先补充适配")
|
||||
payload = data.get("tool_input", data)
|
||||
if isinstance(payload, str) and tool in {"ApplyPatch", "apply_patch"}:
|
||||
paths = patch_paths(payload)
|
||||
elif isinstance(payload, dict):
|
||||
# Installed zcode uses patch_text; aliases use the same structured format.
|
||||
patches = [payload[k] for k in ("patch_text", "patch", "input") if k in payload]
|
||||
paths = [path_value(payload[k]) for k in ("file_path", "path", "filename") if k in payload]
|
||||
for patch in patches:
|
||||
paths.extend(patch_paths(patch))
|
||||
else:
|
||||
deny("无法解析写入工具参数")
|
||||
if not paths:
|
||||
deny("写入请求未提供可检查路径")
|
||||
cwd = Path(path_value(data.get("cwd", os.getcwd()))).resolve()
|
||||
if not cwd.is_dir():
|
||||
deny("钩子 cwd 不存在")
|
||||
for path in paths:
|
||||
check_path(path, cwd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, RuntimeError) as error:
|
||||
print(f"write-guard: 拒绝——{error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Execute the workflow checkout against real disposable HTTP Git repositories."""
|
||||
import http.server
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import threading
|
||||
import unittest
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/ci.yaml"
|
||||
SERVER = "https://git-home.u1680832.nyat.app:30319"
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
ROOT_TOKEN = "synthetic-root-token-not-a-real-secret"
|
||||
SUB_TOKEN = "synthetic-component-token-not-a-real-secret"
|
||||
|
||||
|
||||
def checkout_script():
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == "- name: Checkout")
|
||||
start = next(i for i in range(start, len(lines)) if lines[i].strip() == "run: |") + 1
|
||||
end = next(i for i in range(start, len(lines)) if lines[i].startswith(" - name:"))
|
||||
return textwrap.dedent("\n".join(lines[start:end]))
|
||||
|
||||
|
||||
class CheckoutTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.published = self.base / "published"
|
||||
self.published.mkdir()
|
||||
self.home = self.base / "home"
|
||||
self.home.mkdir()
|
||||
self.checkout = self.base / "checkout"
|
||||
self.checkout.mkdir()
|
||||
self.env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("GIT_", "GITHUB_")) and k not in {"TOKEN", "SUBMODULE_TOKEN"}}
|
||||
self.env.update(HOME=str(self.home), GIT_CONFIG_NOSYSTEM="1", GIT_TERMINAL_PROMPT="0")
|
||||
self.requests = []
|
||||
self.redirect_repo = None
|
||||
outer = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.respond()
|
||||
|
||||
def do_POST(self):
|
||||
self.respond()
|
||||
|
||||
def respond(self):
|
||||
parsed = urlsplit(self.path)
|
||||
outer.requests.append((parsed.path, self.headers.get("Authorization")))
|
||||
if outer.redirect_repo and outer.redirect_repo in parsed.path:
|
||||
self.send_response(302)
|
||||
self.send_header("Location", outer.url + "/untrusted/receiver")
|
||||
self.end_headers()
|
||||
return
|
||||
env = outer.env | {"GIT_PROJECT_ROOT": str(outer.published), "GIT_HTTP_EXPORT_ALL": "1",
|
||||
"PATH_INFO": parsed.path, "QUERY_STRING": parsed.query,
|
||||
"REQUEST_METHOD": self.command,
|
||||
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
|
||||
"CONTENT_LENGTH": self.headers.get("Content-Length", "0")}
|
||||
body = self.rfile.read(int(env["CONTENT_LENGTH"]))
|
||||
result = subprocess.run(["git", "http-backend"], input=body, env=env, capture_output=True)
|
||||
headers, body = result.stdout.split(b"\r\n\r\n", 1)
|
||||
pairs = [line.decode().split(": ", 1) for line in headers.split(b"\r\n")]
|
||||
status = next((int(value.split()[0]) for key, value in pairs if key == "Status"), 200)
|
||||
self.send_response(status)
|
||||
for key, value in pairs:
|
||||
if key != "Status":
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
self.http = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.url = f"http://127.0.0.1:{self.http.server_port}"
|
||||
# Any regression that tries a production HTTPS URL must fail locally.
|
||||
self.env.update(https_proxy=self.url, HTTPS_PROXY=self.url,
|
||||
no_proxy="127.0.0.1,localhost", NO_PROXY="127.0.0.1,localhost")
|
||||
thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(self.http.server_close)
|
||||
self.addCleanup(self.http.shutdown)
|
||||
|
||||
def git(self, directory, *args):
|
||||
return subprocess.run(["git", "-C", str(directory), *args], env=self.env,
|
||||
check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
def publish(self, name, children=(), extra_gitmodules=""):
|
||||
source = self.base / name
|
||||
source.mkdir()
|
||||
self.git(source, "init", "-q", "-b", "main")
|
||||
(source / "README").write_text("Fixture repository\n")
|
||||
# Candidate code tries the channels available after the checkout step.
|
||||
(source / "probe.py").write_text(textwrap.dedent(f"""\
|
||||
import os, pathlib, subprocess
|
||||
sources = [repr(dict(os.environ)), subprocess.check_output(['git', 'config', '--list'], text=True)]
|
||||
for path in [pathlib.Path.home() / '.gitconfig', *pathlib.Path('.git').rglob('config')]:
|
||||
if path.is_file(): sources.append(path.read_text())
|
||||
assert not any(secret in source for secret in ({ROOT_TOKEN!r}, {SUB_TOKEN!r}) for source in sources), 'credential readable by candidate'
|
||||
print('candidate cannot read checkout credentials')
|
||||
"""))
|
||||
gitmodules = ""
|
||||
for path, repo, sha, override in children:
|
||||
gitmodules += f'[submodule "{path}"]\n\tpath = {path}\n\turl = {override or SSH + repo + ".git"}\n'
|
||||
if gitmodules or extra_gitmodules:
|
||||
(source / ".gitmodules").write_text(gitmodules + extra_gitmodules)
|
||||
self.git(source, "add", ".")
|
||||
for path, repo, sha, override in children:
|
||||
self.git(source, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}")
|
||||
self.git(source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
||||
"commit", "-q", "-m", "Fixture")
|
||||
sha = self.git(source, "rev-parse", "HEAD")
|
||||
target = self.published / "weichen" / (name + ".git")
|
||||
target.parent.mkdir(exist_ok=True)
|
||||
self.git(self.base, "clone", "-q", "--bare", str(source), str(target))
|
||||
return sha
|
||||
|
||||
def run_checkout(self, sha):
|
||||
# Only the fixed transport endpoint changes; execute the actual workflow
|
||||
# shell/Python logic, not a test-specific implementation of checkout.
|
||||
script = checkout_script().replace(SERVER, self.url)
|
||||
return subprocess.run(["bash", "-c", script], cwd=self.checkout,
|
||||
env=self.env | {"TOKEN": ROOT_TOKEN, "SUBMODULE_TOKEN": SUB_TOKEN,
|
||||
"GITHUB_SERVER_URL": self.url,
|
||||
"GITHUB_REPOSITORY": "weichen/Agent-System-Dev",
|
||||
"GITHUB_SHA": sha},
|
||||
capture_output=True, text=True, timeout=30)
|
||||
|
||||
def test_candidate_cannot_read_checkout_credentials(self):
|
||||
sha = self.publish("Agent-System-Dev")
|
||||
result = self.run_checkout(sha)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
candidate = subprocess.run(["python3", "probe.py"], cwd=self.checkout,
|
||||
env=self.env, capture_output=True, text=True)
|
||||
self.assertEqual(candidate.returncode, 0, candidate.stderr)
|
||||
self.assertNotIn(ROOT_TOKEN, result.stdout + result.stderr)
|
||||
self.assertNotIn(SUB_TOKEN, result.stdout + result.stderr)
|
||||
|
||||
def test_recursive_gitlinks_materialize_exact_sha_without_persisting_credentials(self):
|
||||
core = self.publish("Agent-System-Core")
|
||||
backend = self.publish("Agent-System-Backend", [("deps/agent-system-core", "Agent-System-Core", core, None)])
|
||||
root = self.publish("Agent-System-Dev", [("modules/backend", "Agent-System-Backend", backend, None)])
|
||||
result = self.run_checkout(root)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.git(self.checkout / "modules/backend", "rev-parse", "HEAD"), backend)
|
||||
self.assertEqual(self.git(self.checkout / "modules/backend/deps/agent-system-core", "rev-parse", "HEAD"), core)
|
||||
self.assertNotIn("-", self.git(self.checkout, "submodule", "status", "--recursive")[:1])
|
||||
candidate = subprocess.run(["python3", "probe.py"], cwd=self.checkout,
|
||||
env=self.env, capture_output=True, text=True)
|
||||
self.assertEqual(candidate.returncode, 0, candidate.stderr)
|
||||
for path, auth in self.requests:
|
||||
self.assertEqual(auth, "token " + (ROOT_TOKEN if "Agent-System-Dev.git/" in path else SUB_TOKEN))
|
||||
for config in (self.checkout / ".git").rglob("config"):
|
||||
self.assertNotIn(SUB_TOKEN, config.read_text())
|
||||
|
||||
def test_external_or_unlisted_urls_are_rejected_before_component_fetch(self):
|
||||
core = self.publish("Agent-System-Core")
|
||||
root = self.publish("Agent-System-Dev", [("modules/core", "Agent-System-Core", core,
|
||||
self.url + "/untrusted/receiver")])
|
||||
result = self.run_checkout(root)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any("untrusted" in path for path, auth in self.requests))
|
||||
self.assertFalse((self.checkout / "modules/core/.git").exists())
|
||||
|
||||
def test_candidate_submodule_update_commands_are_rejected(self):
|
||||
core = self.publish("Agent-System-Core")
|
||||
root = self.publish("Agent-System-Dev", [("modules/core", "Agent-System-Core", core, None)],
|
||||
"\tupdate = !touch /tmp/should-never-execute\n")
|
||||
result = self.run_checkout(root)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any("Agent-System-Core.git/" in path for path, auth in self.requests))
|
||||
|
||||
def test_nested_external_url_is_rejected_before_authenticated_fetch(self):
|
||||
core = self.publish("Agent-System-Core")
|
||||
backend = self.publish("Agent-System-Backend", [("deps/agent-system-core", "Agent-System-Core", core,
|
||||
self.url + "/untrusted/receiver")])
|
||||
root = self.publish("Agent-System-Dev", [("modules/backend", "Agent-System-Backend", backend, None)])
|
||||
result = self.run_checkout(root)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any("untrusted" in path for path, auth in self.requests))
|
||||
self.assertFalse(any("Agent-System-Core.git/" in path for path, auth in self.requests))
|
||||
|
||||
def test_inherited_template_hook_does_not_execute_with_credentials(self):
|
||||
template = self.base / "template"
|
||||
(template / "hooks").mkdir(parents=True)
|
||||
marker = self.base / "hook-executed"
|
||||
hook = template / "hooks/post-checkout"
|
||||
hook.write_text(f"#!/bin/sh\ntouch '{marker}'\n")
|
||||
hook.chmod(0o755)
|
||||
self.git(self.base, "config", "--global", "init.templateDir", str(template))
|
||||
root = self.publish("Agent-System-Dev")
|
||||
result = self.run_checkout(root)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_redirects_do_not_forward_credential_or_fetch_unlisted_target(self):
|
||||
core = self.publish("Agent-System-Core")
|
||||
root = self.publish("Agent-System-Dev", [("modules/core", "Agent-System-Core", core, None)])
|
||||
self.redirect_repo = "Agent-System-Core.git"
|
||||
result = self.run_checkout(root)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(any("untrusted" in path for path, auth in self.requests))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Exercise the evidence recorder against disposable real Git repositories."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
|
||||
|
||||
# Literal delimiters exercise header, JSON, repr and assignment boundaries.
|
||||
AUTHORIZATION_FORMS = (
|
||||
'Authorization: {scheme} {secret}',
|
||||
'{{"Authorization":"{scheme} {secret}"}}',
|
||||
"{{'Authorization': '{scheme} {secret}'}}",
|
||||
'Authorization: "{scheme} {secret}"',
|
||||
"Authorization: '{scheme} {secret}'",
|
||||
'AUTHORIZATION = {scheme} {secret}',
|
||||
'"authorization" = "{scheme} {secret}"',
|
||||
"'aUtHoRiZaTiOn' = '{scheme} {secret}'",
|
||||
'authorization={scheme} {secret}',
|
||||
'{{ "Authorization"\t:\t"{scheme}\t{secret}", "ok": true }}',
|
||||
)
|
||||
|
||||
|
||||
def authorization_cases():
|
||||
for scheme in ("Bearer", "tOkEn", "bAsIc"):
|
||||
for index, form in enumerate(AUTHORIZATION_FORMS):
|
||||
secret = f"syntheticAuthValue{scheme}{index}"
|
||||
yield (form.format(scheme=scheme, secret=secret), secret,
|
||||
form.format(scheme=scheme, secret="<redacted>"))
|
||||
|
||||
|
||||
def authorization_command(headers):
|
||||
# Use a shell quote that leaves each credential spelling intact in CMD.
|
||||
args = [('"' + header + '"') if "'" in header else ("'" + header + "'")
|
||||
for header in headers]
|
||||
return "printf '%s\\n' " + " ".join(args)
|
||||
|
||||
|
||||
class EvidenceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.repo = self.base / "repo"
|
||||
self.repo.mkdir()
|
||||
self.git("init", "-b", "main")
|
||||
self.git("config", "user.name", "Evidence Test")
|
||||
self.git("config", "user.email", "evidence@example.invalid")
|
||||
(self.repo / "tracked").write_text("initial\n")
|
||||
self.git("add", "tracked")
|
||||
self.git("commit", "-qm", "Initial")
|
||||
self.head = self.git("rev-parse", "HEAD")
|
||||
self.git("update-ref", "refs/remotes/origin/main", self.head)
|
||||
self.receipt = self.base / "receipt.json"
|
||||
self.card = self.repo / ".task/main.md"
|
||||
self.card.parent.mkdir()
|
||||
self.card.write_text("# Test task\n")
|
||||
|
||||
def git(self, *args, cwd=None):
|
||||
return subprocess.check_output(
|
||||
["git", *args], cwd=cwd or self.repo, text=True,
|
||||
stderr=subprocess.DEVNULL).strip()
|
||||
|
||||
def run_evidence(self, command=None, cwd=None, **values):
|
||||
env = os.environ.copy()
|
||||
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT"):
|
||||
env.pop(key, None)
|
||||
if command is not None:
|
||||
env["CMD"] = command
|
||||
env.update({key: str(value) for key, value in values.items()})
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPTS / "evidence.sh")], cwd=cwd or self.repo,
|
||||
env=env, capture_output=True, text=True)
|
||||
|
||||
def capture(self, command="printf 'hello\\n'", **kwargs):
|
||||
result = self.run_evidence(command, RECEIPT=self.receipt, **kwargs)
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
self.assertTrue(self.receipt.exists(), "capture must write a receipt")
|
||||
return json.loads(self.receipt.read_text())
|
||||
|
||||
def import_receipt(self, **kwargs):
|
||||
return self.run_evidence(IMPORT_RECEIPT=self.receipt, **kwargs)
|
||||
|
||||
def resign(self, data):
|
||||
payload = {key: value for key, value in data.items() if key != "receipt_sha256"}
|
||||
data["receipt_sha256"] = hashlib.sha256(json.dumps(
|
||||
payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
||||
).encode()).hexdigest()
|
||||
self.receipt.write_text(json.dumps(data))
|
||||
|
||||
def test_pipeline_failure_is_recorded_and_returned(self):
|
||||
result = self.run_evidence("false | tail -n 1")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn('"exit_code": 1', self.card.read_text())
|
||||
|
||||
def test_child_failure_code_is_preserved(self):
|
||||
result = self.run_evidence("exit 7")
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertIn('"exit_code": 7', self.card.read_text())
|
||||
|
||||
def test_capture_without_card_and_import_without_rerun(self):
|
||||
self.card.unlink()
|
||||
data = self.capture("printf 'once\\n' >> invocation; cat invocation")
|
||||
self.assertEqual(data["before"]["head"], self.head)
|
||||
self.assertEqual(data["output"], "once\n")
|
||||
self.assertEqual(data["source"], "SELF")
|
||||
self.assertFalse(self.card.exists())
|
||||
self.card.write_text("# Test task\n")
|
||||
result = self.import_receipt()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.repo / "invocation").read_text(), "once\n")
|
||||
self.assertIn(data["receipt_sha256"], self.card.read_text())
|
||||
|
||||
def test_recorder_environment_does_not_leak_into_nested_commands(self):
|
||||
data = self.capture("printf '%s,%s,%s' \"${CMD-unset}\" \"${RECEIPT-unset}\" \"${SRC-unset}\"", SRC="SELF")
|
||||
self.assertEqual(data["output"], "unset,unset,unset")
|
||||
|
||||
def test_missing_card_does_not_execute(self):
|
||||
self.card.unlink()
|
||||
result = self.run_evidence("touch should-not-exist")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.repo / "should-not-exist").exists())
|
||||
|
||||
def test_import_missing_card_fails_without_losing_receipt(self):
|
||||
self.capture()
|
||||
self.card.unlink()
|
||||
result = self.import_receipt()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(self.receipt.exists())
|
||||
|
||||
def test_linked_worktree_import_preserves_source_identity(self):
|
||||
data = self.capture()
|
||||
other = self.base / "linked"
|
||||
self.git("worktree", "add", "-qb", "feature/linked", str(other))
|
||||
card = other / ".task/feature-linked.md"
|
||||
card.parent.mkdir()
|
||||
card.write_text("# Linked task\n")
|
||||
result = self.import_receipt(cwd=other)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(str(self.repo), card.read_text())
|
||||
self.assertIn(data["before"]["head"], card.read_text())
|
||||
|
||||
def test_unrelated_repo_cannot_import(self):
|
||||
self.capture()
|
||||
other = self.base / "unrelated"
|
||||
subprocess.check_call(["git", "clone", "-q", str(self.repo), str(other)])
|
||||
card = other / ".task/main.md"
|
||||
card.parent.mkdir()
|
||||
card.write_text("# Other task\n")
|
||||
result = self.import_receipt(cwd=other)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(card.read_text(), "# Other task\n")
|
||||
|
||||
def test_all_persisted_and_displayed_text_is_sanitized(self):
|
||||
secrets = ["syntheticBearerValue", "syntheticEnvValue", "syntheticUrlPassword",
|
||||
"syntheticQueryValue", "syntheticHeaderValue", "syntheticPlainValue"]
|
||||
command = "printf '%s\\n' " + " ".join([
|
||||
"'Authorization: Bearer syntheticBearerValue'",
|
||||
"'GITEA_TOKEN=syntheticEnvValue'",
|
||||
"'https://user:syntheticUrlPassword@example.invalid'",
|
||||
"'https://example.invalid?access_token=syntheticQueryValue&ok=1'",
|
||||
"'Authorization: token syntheticHeaderValue'",
|
||||
"'password: syntheticPlainValue'",
|
||||
])
|
||||
data = self.capture(command)
|
||||
imported = self.import_receipt()
|
||||
all_text = self.receipt.read_text() + self.card.read_text() + imported.stdout + imported.stderr
|
||||
for secret in secrets:
|
||||
self.assertNotIn(secret, all_text)
|
||||
self.assertIn("<redacted>", data["command"])
|
||||
self.assertEqual(data["output_sha256"], hashlib.sha256(data["output"].encode()).hexdigest())
|
||||
self.assertEqual(data["command_sha256"], hashlib.sha256(command.encode()).hexdigest())
|
||||
|
||||
def test_authorization_forms_are_redacted_in_capture_console_and_import(self):
|
||||
cases = list(authorization_cases())
|
||||
command = authorization_command([header for header, _, _ in cases])
|
||||
result = self.run_evidence(command, RECEIPT=self.receipt)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
data = json.loads(self.receipt.read_text())
|
||||
imported = self.import_receipt()
|
||||
self.assertEqual(imported.returncode, 0, imported.stderr)
|
||||
persisted = self.receipt.read_text() + self.card.read_text()
|
||||
displayed = result.stdout + result.stderr + imported.stdout + imported.stderr
|
||||
for header, secret, expected in cases:
|
||||
with self.subTest(header=header):
|
||||
self.assertNotIn(secret, persisted + displayed)
|
||||
self.assertIn(expected, data["output"].splitlines())
|
||||
self.assertEqual(data["command_sha256"], hashlib.sha256(command.encode()).hexdigest())
|
||||
self.assertEqual(data["output_sha256"], hashlib.sha256(data["output"].encode()).hexdigest())
|
||||
self.assertEqual(data["source"], "SELF")
|
||||
self.assertEqual(data["schema"], 1)
|
||||
|
||||
def test_authorization_forms_are_redacted_on_direct_card_capture(self):
|
||||
cases = list(authorization_cases())
|
||||
command = authorization_command([header for header, _, _ in cases])
|
||||
result = self.run_evidence(command)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
visible = self.card.read_text() + result.stdout + result.stderr
|
||||
for header, secret, _ in cases:
|
||||
with self.subTest(header=header):
|
||||
self.assertNotIn(secret, visible)
|
||||
self.assertIn("<redacted>", visible)
|
||||
|
||||
def test_import_rejects_unredacted_authorization_with_valid_checksums(self):
|
||||
original = self.capture()
|
||||
for header, _, _ in authorization_cases():
|
||||
with self.subTest(header=header):
|
||||
self.card.write_text("# Test task\n")
|
||||
data = dict(original, output=header)
|
||||
data["output_sha256"] = hashlib.sha256(header.encode()).hexdigest()
|
||||
self.resign(data)
|
||||
result = self.import_receipt()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("unredacted credential syntax", result.stderr)
|
||||
self.assertEqual(self.card.read_text(), "# Test task\n")
|
||||
|
||||
def test_known_secret_environment_value_is_redacted_without_label(self):
|
||||
data = self.capture("printf %s \"$GITEA_TOKEN\"", GITEA_TOKEN="unlabelledSyntheticCredential")
|
||||
self.assertNotIn("unlabelledSyntheticCredential", self.receipt.read_text())
|
||||
self.assertIn("<redacted>", data["output"])
|
||||
|
||||
def test_quoted_credentials_with_spaces_are_fully_redacted(self):
|
||||
secret = "synthetic phrase with spaces"
|
||||
data = self.capture('printf \'password="synthetic phrase with spaces"\\n\'')
|
||||
self.assertNotIn(secret, self.receipt.read_text())
|
||||
self.assertNotIn("phrase with spaces", data["command"] + data["output"])
|
||||
|
||||
def test_command_line_token_arguments_are_redacted(self):
|
||||
data = self.capture("printf '%s\\n' 'tool --token syntheticArgumentValue'")
|
||||
self.assertNotIn("syntheticArgumentValue", self.receipt.read_text())
|
||||
self.assertIn("<redacted>", data["command"])
|
||||
|
||||
def test_source_cannot_be_promoted_by_environment(self):
|
||||
for source in ("GATE", "REPLAY"):
|
||||
with self.subTest(source=source):
|
||||
result = self.run_evidence("touch should-not-exist", SRC=source)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.repo / "should-not-exist").exists())
|
||||
|
||||
def test_tamper_is_rejected_before_card_append(self):
|
||||
data = self.capture()
|
||||
data["output"] = "changed output"
|
||||
self.receipt.write_text(json.dumps(data))
|
||||
result = self.import_receipt()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.card.read_text(), "# Test task\n")
|
||||
|
||||
def test_invalid_field_types_and_forged_source_are_rejected(self):
|
||||
original = self.capture()
|
||||
for field, value in (("exit_code", True), ("source", "GATE"), ("before", [])):
|
||||
with self.subTest(field=field):
|
||||
changed = dict(original, **{field: value})
|
||||
self.resign(changed)
|
||||
self.assertNotEqual(self.import_receipt().returncode, 0)
|
||||
self.assertEqual(self.card.read_text(), "# Test task\n")
|
||||
|
||||
def test_output_digest_checked_even_if_receipt_digest_recomputed(self):
|
||||
data = self.capture()
|
||||
data["output_sha256"] = "0" * 64
|
||||
self.resign(data)
|
||||
self.assertNotEqual(self.import_receipt().returncode, 0)
|
||||
|
||||
def test_missing_required_snapshot_fields_are_rejected(self):
|
||||
original = self.capture()
|
||||
for field in ("target_head", "status"):
|
||||
with self.subTest(field=field):
|
||||
self.card.write_text("# Test task\n")
|
||||
data = json.loads(json.dumps(original))
|
||||
del data["before"][field]
|
||||
self.resign(data)
|
||||
result = self.import_receipt()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
self.assertEqual(self.card.read_text(), "# Test task\n")
|
||||
|
||||
def test_before_after_heads_do_not_relabel_changed_command(self):
|
||||
data = self.capture("printf changed > tracked; git add tracked; git commit -qm Changed")
|
||||
self.assertEqual(data["before"]["head"], self.head)
|
||||
self.assertEqual(data["after"]["head"], self.git("rev-parse", "HEAD"))
|
||||
self.assertNotEqual(data["before"]["head"], data["after"]["head"])
|
||||
|
||||
def test_dirty_and_untracked_contents_have_object_digests(self):
|
||||
(self.repo / "tracked").write_text("modified\n")
|
||||
(self.repo / "extra").write_text("untracked\n")
|
||||
data = self.capture()
|
||||
self.assertIn("tracked", data["before"]["status"])
|
||||
self.assertEqual(data["before"]["untracked"]["extra"], hashlib.sha256(b"untracked\n").hexdigest())
|
||||
self.assertNotEqual(data["before"]["tracked_diff_sha256"], hashlib.sha256(b"").hexdigest())
|
||||
|
||||
def test_nested_cwd_records_untracked_files_from_repository_root(self):
|
||||
nested = self.repo / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "extra").write_text("nested source\n")
|
||||
data = self.capture(cwd=nested)
|
||||
self.assertEqual(data["before"]["untracked"]["nested/extra"],
|
||||
hashlib.sha256(b"nested source\n").hexdigest())
|
||||
|
||||
def test_after_snapshot_failure_keeps_command_exit_and_output(self):
|
||||
result = self.run_evidence("mv .git unavailable-git; printf captured; exit 7", RECEIPT=self.receipt)
|
||||
self.assertEqual(result.returncode, 7)
|
||||
self.assertTrue(self.receipt.exists())
|
||||
data = json.loads(self.receipt.read_text())
|
||||
self.assertIsNone(data["after"])
|
||||
self.assertTrue(data["after_error"])
|
||||
self.assertEqual(data["output"], "captured")
|
||||
|
||||
def test_import_retry_is_idempotent(self):
|
||||
self.capture()
|
||||
self.assertEqual(self.import_receipt().returncode, 0)
|
||||
recorded = self.card.read_text()
|
||||
self.assertEqual(self.import_receipt().returncode, 0)
|
||||
self.assertEqual(self.card.read_text(), recorded)
|
||||
|
||||
def test_full_output_is_preserved_not_only_tail(self):
|
||||
data = self.capture("seq 1 30", TAIL=2)
|
||||
self.assertTrue(data["output"].startswith("1\n2\n"))
|
||||
self.assertTrue(data["output"].endswith("29\n30\n"))
|
||||
|
||||
def test_receipt_write_failure_cannot_claim_success(self):
|
||||
bad = self.base / "missing" / "receipt.json"
|
||||
result = self.run_evidence("printf hello", RECEIPT=bad)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def test_failed_child_exit_survives_receipt_write_failure(self):
|
||||
bad = self.base / "missing" / "receipt.json"
|
||||
result = self.run_evidence("exit 7", RECEIPT=bad)
|
||||
self.assertEqual(result.returncode, 7)
|
||||
|
||||
def test_capture_does_not_overwrite_an_existing_receipt(self):
|
||||
self.receipt.write_text("preserve me")
|
||||
result = self.run_evidence("touch should-not-exist", RECEIPT=self.receipt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(self.receipt.read_text(), "preserve me")
|
||||
self.assertFalse((self.repo / "should-not-exist").exists())
|
||||
|
||||
def test_validate_precheck_checks_result_and_current_base(self):
|
||||
(self.repo / "Makefile").write_text("precheck:\n\t@echo '[verdict] READY — fixture'\n")
|
||||
self.capture("make precheck")
|
||||
valid = self.run_evidence(VALIDATE_RECEIPT=self.receipt)
|
||||
self.assertEqual(valid.returncode, 0, valid.stderr)
|
||||
self.git("commit", "--allow-empty", "-qm", "Advance base")
|
||||
stale = self.run_evidence(VALIDATE_RECEIPT=self.receipt)
|
||||
self.assertNotEqual(stale.returncode, 0)
|
||||
|
||||
def test_validate_precheck_rejects_successful_arbitrary_command(self):
|
||||
self.capture("printf '[verdict] READY\\n'")
|
||||
result = self.run_evidence(VALIDATE_RECEIPT=self.receipt)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Lifecycle regressions against real disposable Git repositories and remotes."""
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TaskLifecycleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="task-lifecycle-")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.repo = self.root / "repo"
|
||||
self.remote = self.root / "remote.git"
|
||||
self.env = os.environ.copy()
|
||||
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_AUTHOR_NAME": "Lifecycle Test", "GIT_AUTHOR_EMAIL": "test@example.invalid",
|
||||
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid"})
|
||||
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
|
||||
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH"):
|
||||
self.env.pop(key, None)
|
||||
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
|
||||
self.git(self.root, "init", "--initial-branch=main", str(self.repo))
|
||||
shutil.copytree(SOURCE / "scripts", self.repo / "scripts")
|
||||
(self.repo / "Makefile").write_text("precheck:\n\t@bash scripts/task.sh precheck\n")
|
||||
(self.repo / "tracked").write_text("saved\n")
|
||||
(self.repo / ".gitignore").write_text("cache/\n")
|
||||
self.git(self.repo, "add", ".")
|
||||
self.git(self.repo, "commit", "-m", "Create fixture")
|
||||
self.git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
self.git(self.repo, "push", "-u", "origin", "main")
|
||||
|
||||
def run_cmd(self, cwd, *args, check=True, env=None):
|
||||
result = subprocess.run(args, cwd=cwd, env=env or self.env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=45)
|
||||
if check:
|
||||
self.assertEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def git(self, cwd, *args, **kwargs):
|
||||
return self.run_cmd(cwd, "git", *args, **kwargs)
|
||||
|
||||
def task(self, cwd, command, *args, **kwargs):
|
||||
if command == "finish" and "env" not in kwargs:
|
||||
kwargs["env"] = self.env | {"TASK_WRITERS_STOPPED": "1"}
|
||||
return self.run_cmd(cwd, "bash", str(self.repo / "scripts/task.sh"), command, *args, **kwargs)
|
||||
|
||||
def worktree(self, name="feature/test"):
|
||||
wt = self.root / "worktree"
|
||||
self.git(self.repo, "worktree", "add", "-b", name, str(wt), "origin/main")
|
||||
return wt
|
||||
|
||||
def advance_remote(self):
|
||||
(self.repo / "new-main").write_text("new main\n")
|
||||
self.git(self.repo, "add", "new-main")
|
||||
self.git(self.repo, "commit", "-m", "Advance main")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
|
||||
def add_submodule(self):
|
||||
child = self.root / "component"
|
||||
self.git(self.root, "init", "--initial-branch=main", str(child))
|
||||
(child / "content").write_text("v1\n")
|
||||
self.git(child, "add", ".")
|
||||
self.git(child, "commit", "-m", "Component v1")
|
||||
self.git(self.repo, "submodule", "add", str(child), "component")
|
||||
self.git(self.repo, "commit", "-m", "Pin component")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
return child
|
||||
|
||||
def test_precheck_rejects_unreachable_authority(self):
|
||||
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
|
||||
result = self.task(self.repo, "precheck", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
|
||||
|
||||
def test_precheck_feature_behind_requires_sync(self):
|
||||
wt = self.worktree()
|
||||
self.advance_remote()
|
||||
result = self.task(wt, "precheck", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("SYNC_REQUIRED", result.stdout)
|
||||
|
||||
def test_sync_materializes_updated_component(self):
|
||||
child = self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
(child / "content").write_text("v2\n")
|
||||
self.git(child, "commit", "-am", "Component v2")
|
||||
latest = self.git(child, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(self.repo / "component", "fetch", "origin")
|
||||
self.git(self.repo / "component", "checkout", "--detach", latest)
|
||||
self.git(self.repo, "commit", "-am", "Update component")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[synced]", result.stdout)
|
||||
self.assertEqual((wt / "component/content").read_text(), "v2\n")
|
||||
self.assertEqual(self.git(wt / "component", "rev-parse", "HEAD").stdout.strip(), latest)
|
||||
|
||||
def test_sync_preserves_component_feature_branch(self):
|
||||
child = self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
self.git(wt / "component", "switch", "-c", "feature/owned")
|
||||
before = self.git(wt / "component", "rev-parse", "HEAD").stdout
|
||||
(child / "content").write_text("v2\n")
|
||||
self.git(child, "commit", "-am", "Component v2")
|
||||
latest = self.git(child, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(self.repo / "component", "fetch", "origin")
|
||||
self.git(self.repo / "component", "checkout", "--detach", latest)
|
||||
self.git(self.repo, "commit", "-am", "Update component")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertEqual(self.git(wt / "component", "rev-parse", "HEAD").stdout, before)
|
||||
self.assertEqual(self.git(wt / "component", "branch", "--show-current").stdout.strip(), "feature/owned")
|
||||
|
||||
def test_sync_keeps_unchanged_component_branch(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
self.git(wt / "component", "switch", "-c", "feature/owned")
|
||||
self.task(wt, "sync")
|
||||
self.assertEqual(self.git(wt / "component", "branch", "--show-current").stdout.strip(), "feature/owned")
|
||||
|
||||
def test_sync_preserves_dirty_child(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
(wt / "component/content").write_text("unsaved\n")
|
||||
self.advance_remote()
|
||||
before = self.git(wt, "rev-parse", "HEAD").stdout
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout, before)
|
||||
self.assertEqual((wt / "component/content").read_text(), "unsaved\n")
|
||||
|
||||
def test_finish_preserves_modified_untracked_and_ignored_files(self):
|
||||
for kind, rel in (("tracked", "tracked"), ("untracked", "unique"), ("ignored", "cache/unique")):
|
||||
with self.subTest(kind=kind):
|
||||
wt = self.root / f"worktree-{kind}"
|
||||
self.git(self.repo, "worktree", "add", "-b", f"feature/{kind}", str(wt), "origin/main")
|
||||
path = wt / rel
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text("unique user content\n")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(path.exists(), result.stdout)
|
||||
self.assertEqual(path.read_text(), "unique user content\n")
|
||||
|
||||
def test_finish_rejects_subdirectory_without_deleting_it(self):
|
||||
wt = self.worktree()
|
||||
result = self.task(wt / "scripts", "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue((wt / "scripts/task.sh").is_file(), result.stdout)
|
||||
|
||||
def test_finish_preserves_locked_worktree(self):
|
||||
wt = self.worktree()
|
||||
self.git(self.repo, "worktree", "lock", "--reason", "active owner", str(wt))
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
|
||||
def test_finish_preserves_component_untracked_file(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
unique = wt / "component/unique"
|
||||
unique.write_text("cannot regenerate\n")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(unique.is_file(), result.stdout)
|
||||
|
||||
def test_finish_preserves_component_commits_off_the_pinned_head(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
child = wt / "component"
|
||||
pinned = self.git(child, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(child, "switch", "-c", "feature/unique-history")
|
||||
(child / "content").write_text("unique committed work\n")
|
||||
self.git(child, "commit", "-am", "Preserve this commit")
|
||||
unique = self.git(child, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(child, "checkout", "--detach", pinned)
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertEqual(self.git(child, "rev-parse", "feature/unique-history").stdout.strip(), unique)
|
||||
|
||||
def test_finish_preserves_linked_component_worktrees_at_the_pinned_head(self):
|
||||
self.add_submodule()
|
||||
for state in ("clean", "staged", "unstaged"):
|
||||
with self.subTest(state=state):
|
||||
wt = self.root / f"worktree-{state}"
|
||||
self.git(self.repo, "worktree", "add", "-b", f"feature/{state}", str(wt), "origin/main")
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
child = wt / "component"
|
||||
sibling = self.root / f"component-{state}"
|
||||
self.git(child, "worktree", "add", "--detach", str(sibling), "HEAD")
|
||||
head = self.git(sibling, "rev-parse", "HEAD").stdout
|
||||
if state != "clean":
|
||||
(sibling / "content").write_text("unique sibling work\n")
|
||||
if state == "staged":
|
||||
self.git(sibling, "add", "content")
|
||||
status = self.git(sibling, "status", "--porcelain").stdout
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertEqual(self.git(child, "rev-parse", "HEAD").stdout, head)
|
||||
self.assertEqual(self.git(child, "status", "--porcelain").stdout, "")
|
||||
self.assertEqual(self.git(sibling, "rev-parse", "HEAD").stdout, head)
|
||||
self.assertEqual(self.git(sibling, "status", "--porcelain").stdout, status)
|
||||
self.assertEqual((sibling / "content").read_text(),
|
||||
"v1\n" if state == "clean" else "unique sibling work\n")
|
||||
|
||||
def nested_component_update(self, attached):
|
||||
child = self.add_submodule()
|
||||
inner = self.root / "inner"
|
||||
self.git(self.root, "init", "--initial-branch=main", str(inner))
|
||||
(inner / "inner-content").write_text("inner v1\n")
|
||||
self.git(inner, "add", ".")
|
||||
self.git(inner, "commit", "-m", "Inner v1")
|
||||
self.git(child, "submodule", "add", str(inner), "inner")
|
||||
self.git(child, "commit", "-m", "Pin inner")
|
||||
self.git(self.repo / "component", "fetch", "origin")
|
||||
self.git(self.repo / "component", "checkout", "--detach", "origin/main")
|
||||
self.git(self.repo, "commit", "-am", "Pin nested component")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
nested = wt / "component/inner"
|
||||
if attached:
|
||||
self.git(nested, "switch", "-c", "feature/owned-inner")
|
||||
before = self.git(nested, "rev-parse", "HEAD").stdout.strip()
|
||||
(inner / "inner-content").write_text("inner v2\n")
|
||||
self.git(inner, "commit", "-am", "Inner v2")
|
||||
latest = self.git(inner, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(child / "inner", "fetch", "origin")
|
||||
self.git(child / "inner", "checkout", "--detach", latest)
|
||||
self.git(child, "commit", "-am", "Update inner pin")
|
||||
self.git(self.repo / "component", "fetch", "origin")
|
||||
self.git(self.repo / "component", "checkout", "--detach", "origin/main")
|
||||
self.git(self.repo, "commit", "-am", "Update nested component")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
return wt, nested, before, latest
|
||||
|
||||
def test_sync_preserves_nested_feature_branch_when_parent_pin_changes(self):
|
||||
wt, nested, before, _ = self.nested_component_update(attached=True)
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), before)
|
||||
self.assertEqual(self.git(nested, "branch", "--show-current").stdout.strip(), "feature/owned-inner")
|
||||
|
||||
def test_sync_materializes_nested_detached_components(self):
|
||||
wt, nested, _, latest = self.nested_component_update(attached=False)
|
||||
self.task(wt, "sync")
|
||||
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), latest)
|
||||
self.assertEqual((nested / "inner-content").read_text(), "inner v2\n")
|
||||
|
||||
def test_finish_preserves_linked_nested_component_worktree(self):
|
||||
wt, nested, before, _ = self.nested_component_update(attached=False)
|
||||
sibling = self.root / "nested-sibling"
|
||||
self.git(nested, "worktree", "add", "--detach", str(sibling), "HEAD")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertEqual(self.git(nested, "rev-parse", "HEAD").stdout.strip(), before)
|
||||
self.assertEqual(self.git(sibling, "rev-parse", "HEAD").stdout.strip(), before)
|
||||
self.assertEqual(self.git(sibling, "status", "--porcelain").stdout, "")
|
||||
self.assertEqual((nested / "inner-content").read_text(), "inner v1\n")
|
||||
self.assertEqual((sibling / "inner-content").read_text(), "inner v1\n")
|
||||
|
||||
def test_finish_clean_worktree_has_accurate_remote_result(self):
|
||||
wt = self.worktree()
|
||||
# A missing remote branch is not a successful remote deletion.
|
||||
result = self.task(wt, "finish")
|
||||
self.assertFalse(wt.exists(), result.stdout)
|
||||
self.assertNotIn("本地+远端", result.stdout)
|
||||
|
||||
def test_finish_recycles_clean_submodule_without_shared_config_changes(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
result = self.task(wt, "finish")
|
||||
self.assertFalse(wt.exists(), result.stdout)
|
||||
self.assertTrue((self.repo / "component/content").is_file(), result.stdout)
|
||||
self.assertEqual(self.git(self.repo, "config", "--get", "submodule.component.url").stdout.strip(), str(self.root / "component"))
|
||||
|
||||
def test_finish_requires_writer_exit_confirmation(self):
|
||||
wt = self.worktree()
|
||||
result = self.task(wt, "finish", check=False, env=self.env)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
|
||||
def unmerged_task(self):
|
||||
wt = self.worktree()
|
||||
(wt / "tracked").write_text("unmerged task work\n")
|
||||
self.git(wt, "commit", "-am", "Keep unmerged work")
|
||||
self.git(wt, "push", "-u", "origin", "HEAD")
|
||||
return wt, self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
def test_finish_abandon_retains_local_remote_tips_and_reason(self):
|
||||
wt, head = self.unmerged_task()
|
||||
reason = "superseded by a smaller task"
|
||||
result = self.task(wt, "finish", "--abandon", reason)
|
||||
self.assertFalse(wt.exists(), result.stdout)
|
||||
self.assertEqual(self.git(self.repo, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
|
||||
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
|
||||
note = self.git(self.repo, "notes", "--ref=refs/notes/task-abandon", "show", head).stdout
|
||||
self.assertIn(reason, note)
|
||||
self.assertIn("feature/test", note)
|
||||
self.assertEqual(self.git(self.repo, "show", f"{head}:tracked").stdout, "unmerged task work\n")
|
||||
|
||||
def test_finish_abandon_without_reason_preserves_checkout_and_refs(self):
|
||||
wt, head = self.unmerged_task()
|
||||
result = self.task(wt, "finish", "--abandon", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(self.git(self.repo, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
|
||||
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/test").stdout.strip(), head)
|
||||
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
|
||||
self.assertNotEqual(self.git(self.repo, "notes", "--ref=refs/notes/task-abandon", "show", head,
|
||||
check=False).returncode, 0)
|
||||
|
||||
def test_finish_deletes_only_merged_remote_branch(self):
|
||||
wt = self.worktree()
|
||||
self.git(wt, "push", "-u", "origin", "HEAD")
|
||||
result = self.task(wt, "finish")
|
||||
self.assertIn("remote=DELETED", result.stdout)
|
||||
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout, "")
|
||||
|
||||
def test_finish_preserves_remote_unmerged_tip(self):
|
||||
wt = self.worktree()
|
||||
self.git(self.repo, "switch", "-c", "feature/remote-update")
|
||||
(self.repo / "remote-work").write_text("another writer")
|
||||
self.git(self.repo, "add", "remote-work")
|
||||
self.git(self.repo, "commit", "-m", "Unmerged work")
|
||||
self.git(self.repo, "push", "origin", "HEAD:refs/heads/feature/test")
|
||||
self.git(self.repo, "switch", "main")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertTrue(self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout)
|
||||
|
||||
def test_finish_remote_deletion_lease_preserves_concurrent_update(self):
|
||||
wt = self.worktree()
|
||||
old = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(wt, "push", "-u", "origin", "HEAD")
|
||||
self.git(self.repo, "switch", "-c", "feature/racing")
|
||||
(self.repo / "race-work").write_text("concurrent remote work")
|
||||
self.git(self.repo, "add", "race-work")
|
||||
self.git(self.repo, "commit", "-m", "Concurrent remote work")
|
||||
latest = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(self.repo, "push", "origin", "feature/racing")
|
||||
self.git(self.repo, "switch", "main")
|
||||
hook = self.repo / ".git/hooks/pre-push"
|
||||
hook.write_text("#!/bin/sh\n" + shlex.join(["git", "--git-dir", str(self.remote), "update-ref",
|
||||
"refs/heads/feature/test", latest, old]) + "\n")
|
||||
hook.chmod(0o755)
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("[partial]", result.stdout)
|
||||
current = self.git(self.repo, "ls-remote", "--heads", "origin", "refs/heads/feature/test").stdout
|
||||
self.assertTrue(current.startswith(latest), current)
|
||||
|
||||
def test_sync_commit_failure_never_reports_synced(self):
|
||||
wt = self.worktree()
|
||||
(wt / ".task").mkdir()
|
||||
(wt / ".task/feature-test.md").write_text("# Task\n")
|
||||
self.git(wt, "add", ".task")
|
||||
self.git(wt, "commit", "-m", "Create task card")
|
||||
hook = self.repo / ".git/hooks/pre-commit"
|
||||
hook.write_text("#!/bin/sh\nexit 1\n")
|
||||
hook.chmod(0o755)
|
||||
result = self.task(wt, "sync", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertNotIn("[synced]", result.stdout)
|
||||
self.assertIn(".task/feature-test.md", self.git(wt, "status", "--porcelain").stdout)
|
||||
|
||||
def task_card(self, wt):
|
||||
card = wt / ".task/feature-test.md"
|
||||
card.parent.mkdir(exist_ok=True)
|
||||
card.write_text("# Task\n")
|
||||
self.git(wt, "add", ".task")
|
||||
self.git(wt, "commit", "-m", "Create task card")
|
||||
return card
|
||||
|
||||
def capture_evidence(self, wt, command="printf 'checked\\n'"):
|
||||
card = self.task_card(wt)
|
||||
self.run_cmd(wt, "bash", "scripts/evidence.sh", env=self.env | {"CMD": command})
|
||||
return card
|
||||
|
||||
def commit_evidence(self, wt):
|
||||
self.git(wt, "add", ".task")
|
||||
self.git(wt, "commit", "-m", "Record evidence")
|
||||
|
||||
def test_sync_warns_when_json_evidence_predates_noncard_changes(self):
|
||||
wt = self.worktree()
|
||||
self.capture_evidence(wt)
|
||||
self.commit_evidence(wt)
|
||||
self.advance_remote()
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("重跑", result.stdout)
|
||||
|
||||
def test_sync_keeps_json_evidence_fresh_after_metadata_only_changes(self):
|
||||
wt = self.worktree()
|
||||
self.capture_evidence(wt)
|
||||
# A second run sees only the first run's uncommitted card receipt.
|
||||
self.run_cmd(wt, "bash", "scripts/evidence.sh", env=self.env | {"CMD": "true"})
|
||||
self.commit_evidence(wt)
|
||||
(self.repo / ".task").mkdir()
|
||||
(self.repo / ".task/other.md").write_text("Other task metadata\n")
|
||||
self.git(self.repo, "add", ".task")
|
||||
self.git(self.repo, "commit", "-m", "Record other task")
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
result = self.task(wt, "sync")
|
||||
self.assertNotIn("[evidence] WARN", result.stdout)
|
||||
|
||||
def test_sync_warns_when_json_before_state_contains_uncommitted_code(self):
|
||||
wt = self.worktree()
|
||||
(wt / "tracked").write_text("uncommitted code\n")
|
||||
self.capture_evidence(wt, command="git restore tracked")
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_warns_when_json_after_state_contains_uncommitted_code(self):
|
||||
wt = self.worktree()
|
||||
self.capture_evidence(wt, command="printf 'uncommitted code\\n' > tracked")
|
||||
self.git(wt, "restore", "tracked")
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_does_not_execute_json_receipt_command(self):
|
||||
wt = self.worktree()
|
||||
sentinel = self.root / "command-was-executed"
|
||||
self.capture_evidence(wt, command="printf ran > " + shlex.quote(str(sentinel)))
|
||||
self.assertTrue(sentinel.is_file())
|
||||
sentinel.unlink()
|
||||
self.commit_evidence(wt)
|
||||
self.task(wt, "sync")
|
||||
self.assertFalse(sentinel.exists())
|
||||
|
||||
def rewrite_receipt(self, card, change):
|
||||
prefix, block = card.read_text().split("```json\n", 1)
|
||||
record, suffix = block.split("\n```", 1)
|
||||
receipt = json.loads(record)
|
||||
change(receipt)
|
||||
receipt.pop("receipt_sha256")
|
||||
serialized = json.dumps(receipt, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
receipt["receipt_sha256"] = hashlib.sha256(serialized.encode()).hexdigest()
|
||||
card.write_text(prefix + "```json\n" + json.dumps(receipt) + "\n```" + suffix)
|
||||
|
||||
def test_sync_warns_for_unresolved_json_evidence_head(self):
|
||||
wt = self.worktree()
|
||||
card = self.capture_evidence(wt)
|
||||
self.rewrite_receipt(card, lambda receipt: receipt["before"].update(head="0" * 40))
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_warns_for_missing_json_receipt_fields_even_with_valid_checksum(self):
|
||||
wt = self.worktree()
|
||||
card = self.capture_evidence(wt)
|
||||
self.rewrite_receipt(card, lambda receipt: receipt.pop("command"))
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_warns_for_malformed_json_evidence(self):
|
||||
wt = self.worktree()
|
||||
card = self.task_card(wt)
|
||||
card.write_text(card.read_text() + '\n```json\n{"schema": 1, invalid}\n```\n')
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_warns_for_unresolved_legacy_evidence_head(self):
|
||||
wt = self.worktree()
|
||||
card = self.task_card(wt)
|
||||
card.write_text(card.read_text() + "\n| 2026-10-05T00:00:00Z | check | `0000000000` | 0 | SELF | abc |\n")
|
||||
self.commit_evidence(wt)
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertIn("unverified", result.stdout)
|
||||
|
||||
def test_sync_still_warns_for_stale_legacy_table_evidence(self):
|
||||
wt = self.worktree()
|
||||
card = self.task_card(wt)
|
||||
head = self.git(wt, "rev-parse", "--short=10", "HEAD").stdout.strip()
|
||||
card.write_text(card.read_text() + f"\n| 2026-10-05T00:00:00Z | check | `{head}` | 0 | SELF | abc |\n")
|
||||
self.commit_evidence(wt)
|
||||
self.advance_remote()
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
|
||||
def test_sync_reads_legacy_commands_with_escaped_pipes(self):
|
||||
wt = self.worktree()
|
||||
card = self.task_card(wt)
|
||||
head = self.git(wt, "rev-parse", "--short=10", "HEAD").stdout.strip()
|
||||
card.write_text(card.read_text() +
|
||||
f"\n| 2026-10-05T00:00:00Z | `false \\|\\| true` | `{head}` | 0 | SELF | abc |\n")
|
||||
self.commit_evidence(wt)
|
||||
self.advance_remote()
|
||||
result = self.task(wt, "sync")
|
||||
self.assertIn("[synced]", result.stdout)
|
||||
self.assertIn("[evidence] WARN", result.stdout)
|
||||
self.assertNotIn("unverified", result.stdout)
|
||||
|
||||
def test_finish_preserves_main_and_active_index_lock(self):
|
||||
result = self.task(self.repo, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue((self.repo / "tracked").is_file())
|
||||
wt = self.worktree()
|
||||
index_lock = Path(self.git(wt, "rev-parse", "--path-format=absolute", "--git-path", "index.lock").stdout.strip())
|
||||
index_lock.write_text("another writer")
|
||||
result = self.task(wt, "finish", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertTrue(wt.exists(), result.stdout)
|
||||
self.assertEqual(index_lock.read_text(), "another writer")
|
||||
|
||||
def test_verify_rejects_recursive_drift_even_when_root_has_local_edits(self):
|
||||
child = self.add_submodule()
|
||||
wt = self.worktree()
|
||||
self.git(wt, "submodule", "update", "--init", "--recursive")
|
||||
(wt / "tracked").write_text("root implementation edit\n")
|
||||
self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify")
|
||||
(child / "content").write_text("v2\n")
|
||||
self.git(child, "commit", "-am", "Component v2")
|
||||
self.git(wt / "component", "fetch", "origin")
|
||||
self.git(wt / "component", "checkout", "--detach", "origin/main")
|
||||
result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
def capture_precheck(self, cwd=None):
|
||||
receipt = self.root / "precheck.json"
|
||||
env = self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}
|
||||
self.run_cmd(cwd or self.repo, "bash", str(self.repo / "scripts/evidence.sh"), env=env)
|
||||
return receipt
|
||||
|
||||
def test_start_requires_precheck_receipt_before_creating_worktree(self):
|
||||
result = self.task(self.repo, "start", "feature/new", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
|
||||
|
||||
def test_start_preserves_merged_local_and_remote_branch_names(self):
|
||||
receipt = self.capture_precheck()
|
||||
head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
for kind in ("local", "remote"):
|
||||
with self.subTest(kind=kind):
|
||||
name = "feature/merged-" + kind
|
||||
if kind == "local":
|
||||
self.git(self.repo, "branch", name, head)
|
||||
ref = "refs/heads/" + name
|
||||
else:
|
||||
self.git(self.repo, "push", "origin", head + ":refs/heads/" + name)
|
||||
ref = "refs/remotes/origin/" + name
|
||||
self.git(self.repo, "fetch", "origin", "refs/heads/" + name + ":" + ref)
|
||||
result = self.task(self.repo, "start", name, check=False,
|
||||
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("already merged", result.stdout)
|
||||
self.assertFalse((self.root / ("repo-wt-" + name.replace("/", "-"))).exists())
|
||||
self.assertEqual(self.git(self.repo, "rev-parse", ref).stdout.strip(), head)
|
||||
|
||||
def test_start_imports_precheck_and_creates_four_part_commit(self):
|
||||
receipt = self.capture_precheck()
|
||||
self.task(self.repo, "start", "feature/new", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
wt = self.root / "repo-wt-feature-new"
|
||||
self.assertTrue((wt / ".task/feature-new.md").is_file())
|
||||
self.assertIn("make precheck", (wt / ".task/feature-new.md").read_text())
|
||||
body = self.git(wt, "log", "-1", "--format=%B").stdout
|
||||
for field in ("Why:", "Evidence:", "Impact:", "Refs:"):
|
||||
self.assertIn(field, body)
|
||||
|
||||
def test_start_stacked_branch_records_parent_and_main_baseline(self):
|
||||
base = self.git(self.repo, "rev-parse", "origin/main").stdout.strip()
|
||||
parent = self.worktree("feature/parent")
|
||||
(parent / "parent-change").write_text("parent feature")
|
||||
self.git(parent, "add", "parent-change")
|
||||
self.git(parent, "commit", "-m", "Parent change")
|
||||
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||||
receipt = self.capture_precheck(parent)
|
||||
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_PARENT_BRANCH": "feature/parent",
|
||||
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/42"}
|
||||
self.task(parent, "start", "feature/parent-child", env=env)
|
||||
wt = self.root / "repo-wt-feature-parent-child"
|
||||
card = (wt / ".task/feature-parent-child.md").read_text()
|
||||
self.assertIn(f"initial_base_sha)= {base}", card)
|
||||
self.assertIn(f"parent_head_sha={parent_head}", card)
|
||||
self.assertIn("https://example.invalid/repo/pulls/42", card)
|
||||
self.assertEqual((wt / "parent-change").read_text(), "parent feature")
|
||||
|
||||
def test_start_offline_does_not_create_worktree(self):
|
||||
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
|
||||
result = self.task(self.repo, "start", "feature/offline", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
|
||||
self.assertFalse((self.root / "repo-wt-feature-offline").exists())
|
||||
|
||||
def test_start_rejects_receipt_when_authoritative_base_changed(self):
|
||||
receipt = self.capture_precheck()
|
||||
self.advance_remote()
|
||||
result = self.task(self.repo, "start", "feature/stale", check=False,
|
||||
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertFalse((self.root / "repo-wt-feature-stale").exists())
|
||||
|
||||
def test_verify_rejects_uninitialized_component(self):
|
||||
self.add_submodule()
|
||||
wt = self.worktree()
|
||||
result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Exercise the actual hook against disposable repositories; never write on real main."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
GUARD = Path(__file__).resolve().parents[1] / "scripts/zcode/write-guard.sh"
|
||||
|
||||
|
||||
class WriteGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.repo = self.base / "repo"
|
||||
self.repo.mkdir()
|
||||
self.git("init", "-q", "-b", "main")
|
||||
self.git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
||||
"commit", "-q", "--allow-empty", "-m", "Initial")
|
||||
|
||||
def git(self, *args):
|
||||
return subprocess.run(["git", "-C", str(self.repo), *args], check=True,
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
|
||||
def invoke(self, payload, cwd=None):
|
||||
return subprocess.run(["bash", str(GUARD)], input=json.dumps(payload),
|
||||
cwd=cwd or self.repo, capture_output=True, text=True)
|
||||
|
||||
def write(self, path, **kw):
|
||||
return self.invoke({"tool_name": "Write", "tool_input": {"file_path": str(path)}, **kw})
|
||||
|
||||
def test_missing_parent_on_main_is_denied(self):
|
||||
self.assertEqual(self.write(self.repo / "new/deep/file.txt").returncode, 2)
|
||||
|
||||
def test_relative_path_uses_hook_cwd_not_process_cwd(self):
|
||||
self.assertEqual(self.invoke({"tool_name": "Write", "cwd": str(self.repo),
|
||||
"tool_input": {"file_path": "new/file.txt"}},
|
||||
cwd=self.base).returncode, 2)
|
||||
|
||||
def test_symlink_into_main_with_missing_parents_is_denied(self):
|
||||
(self.base / "alias").symlink_to(self.repo, target_is_directory=True)
|
||||
self.assertEqual(self.write(self.base / "alias/new/file.txt").returncode, 2)
|
||||
|
||||
def test_deleting_main_symlink_to_outside_is_denied(self):
|
||||
(self.repo / "link").symlink_to(self.base / "outside")
|
||||
patch = "*** Begin Patch\n*** Delete File: link\n*** End Patch"
|
||||
result = self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}})
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
|
||||
def test_feature_missing_parent_is_allowed(self):
|
||||
self.git("switch", "-q", "-c", "feature/fixture")
|
||||
self.assertEqual(self.write(self.repo / "new/deep/file.txt").returncode, 0)
|
||||
|
||||
def test_detached_and_bad_branches_are_denied(self):
|
||||
for ref in ("--detach", "-c"):
|
||||
self.git("switch", "-q", ref, "HEAD" if ref == "--detach" else "bad-branch")
|
||||
self.assertEqual(self.write(self.repo / "new.txt").returncode, 2)
|
||||
|
||||
def test_non_git_destination_is_explicitly_outside_scope(self):
|
||||
result = self.write(self.base / "notes/new.txt")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("outside-git", result.stderr)
|
||||
|
||||
def test_every_patch_file_and_move_destination_is_checked(self):
|
||||
self.git("switch", "-q", "-c", "feature/fixture")
|
||||
other = self.base / "protected"
|
||||
subprocess.run(["git", "init", "-q", "-b", "main", str(other)], check=True)
|
||||
for operation in (f"*** Add File: {other}/new/deep.txt\n+x",
|
||||
f"*** Update File: {self.repo}/old.txt\n*** Move to: {other}/new.txt\n@@\n-a\n+b"):
|
||||
patch = f"*** Begin Patch\n*** Add File: {self.repo}/safe.txt\n+ok\n{operation}\n*** End Patch"
|
||||
result = self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}})
|
||||
self.assertEqual(result.returncode, 2, result.stderr)
|
||||
|
||||
def test_valid_multi_file_patch_on_feature_is_allowed(self):
|
||||
self.git("switch", "-q", "-c", "fix/fixture")
|
||||
patch = "*** Begin Patch\n*** Add File: new/a.txt\n+a\n*** Delete File: b.txt\n*** End Patch"
|
||||
self.assertEqual(self.invoke({"tool_name": "ApplyPatch", "tool_input": {"patch_text": patch}}).returncode, 0)
|
||||
|
||||
def test_missing_unknown_and_malformed_write_input_are_denied(self):
|
||||
for payload in ({"tool_name": "Write", "tool_input": {}},
|
||||
{"tool_name": "ApplyPatch", "tool_input": {"patch_text": "garbage"}},
|
||||
{"tool_name": "ApplyPatch", "tool_input": {"patch_text": "*** Begin Patch\n*** Add File: a\n+a\n*** Unexpected: b\n*** End Patch"}},
|
||||
{"tool_name": "Write", "tool_input": {"file_path": 12}},
|
||||
{"tool_name": "UnknownWriter", "tool_input": {"file_path": "a"}}):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(self.invoke(payload).returncode, 2)
|
||||
|
||||
def test_invalid_json_is_denied(self):
|
||||
result = subprocess.run(["bash", str(GUARD)], input="not-json", cwd=self.repo,
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
|
||||
def test_nonwrite_tools_are_outside_scope(self):
|
||||
self.assertEqual(self.invoke({"tool_name": "Read", "tool_input": {"file_path": "a"}}).returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user