Make development gates preserve work and verifiable evidence
Why: cleanup, component synchronization, receipt recording and credential handling could contradict the documented development boundaries. Evidence: SELF 75 isolated governance tests and source checks are captured in .task/feature-governance-reliability.md; combination and CI verification follow. Impact: hardens local task and CI tooling, aligns policy and component documentation, and adds continuous regression tests without changing product behavior. Refs: .task/feature-governance-reliability.md; policy/governance.md; policy/platform-admission.md
This commit is contained in:
+133
-10
@@ -17,16 +17,139 @@ 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
|
||||
|
||||
SERVER = "https://git-home.u1680832.nyat.app:30319"
|
||||
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_SERVER_URL", "").rstrip("/") != SERVER:
|
||||
sys.exit("Checkout server is outside the reviewed allowlist")
|
||||
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
@@ -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
|
||||
@@ -72,7 +77,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 +133,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,81 @@
|
||||
# 开发门禁的执行与证据
|
||||
|
||||
本文件细化根 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 文件。
|
||||
可再生缓存由 owner 按已识别路径单独处置;无法确认用途的文件保留。
|
||||
|
||||
任务合入需以本次成功获取的权威目标验证。合入提交不能证明未提交文件已经落盘。
|
||||
Git 对含子模块的工作区需要显式删除选项时,只有全部现场检查通过才允许该路径;
|
||||
它不豁免任何脏状态、忽略文件、独有提交/引用或锁检查。普通删除失败不转入强删兜底。
|
||||
保留无法确认安全的目录和分支,输出下一步处理依据。
|
||||
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
|
||||
回收后由所有者执行 `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,126 @@
|
||||
#!/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 check_children(repo, mode):
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
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")
|
||||
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 not parsed[0][0].startswith(b"worktree "):
|
||||
raise RuntimeError("cannot identify the main checkout")
|
||||
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)
|
||||
if mode == "finish":
|
||||
check_finish_root(repo)
|
||||
if mode in ("before-sync", "after-sync", "finish"):
|
||||
check_clean(repo, ignored=(mode == "finish"))
|
||||
if mode == "materialize":
|
||||
materialize(repo)
|
||||
else:
|
||||
check_children(repo, mode)
|
||||
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]*(?: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"
|
||||
|
||||
+119
-45
@@ -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,8 +105,12 @@ 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"
|
||||
@@ -104,19 +120,34 @@ cmd_start() {
|
||||
need_repo
|
||||
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix>/<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
|
||||
[ "$(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) \
|
||||
@@ -130,12 +161,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}
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
|
||||
## 目标与非目标
|
||||
@@ -160,16 +192,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)
|
||||
@@ -188,7 +235,10 @@ 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
|
||||
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}"
|
||||
# [evidence] 同步改变了被测对象:卡片证据表中旧 HEAD 的行,其后若有非 .task 变更则失效须重跑。
|
||||
local eh stale_ev="" short_head changed
|
||||
short_head=$(echo "$head" | cut -c1-10)
|
||||
@@ -252,7 +302,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}"
|
||||
@@ -358,37 +408,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,284 @@
|
||||
"""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"
|
||||
|
||||
|
||||
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_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,406 @@
|
||||
"""Lifecycle regressions against real disposable Git repositories and remotes."""
|
||||
import os
|
||||
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 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_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 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 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_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