feat(ack): add project knowledge guardrails
This commit is contained in:
+455
-12
@@ -3,9 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import errno
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -63,6 +69,20 @@ from skiff.sources import (
|
||||
from skiff.yaml_io import safe_dump
|
||||
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
|
||||
|
||||
_RENAME_NOREPLACE = 1
|
||||
|
||||
|
||||
def _encode_single_path_component(value: str) -> bytes:
|
||||
encoded = os.fsencode(value)
|
||||
if (
|
||||
not encoded
|
||||
or encoded in {b".", b".."}
|
||||
or b"/" in encoded
|
||||
or b"\0" in encoded
|
||||
):
|
||||
raise ValueError(f"必须是单一路径组件: {value!r}")
|
||||
return encoded
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg, file=sys.stdout)
|
||||
@@ -78,11 +98,146 @@ def _project_root(explicit: str | None = None) -> Path:
|
||||
return find_repo_root() or Path.cwd()
|
||||
|
||||
|
||||
def _render_template(source: Path, destination: Path, values: dict[str, str]) -> None:
|
||||
def _render_template(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
values: dict[str, str],
|
||||
) -> str:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
for placeholder, value in values.items():
|
||||
content = content.replace(placeholder, value)
|
||||
destination.write_text(content, encoding="utf-8")
|
||||
return content
|
||||
|
||||
|
||||
def _open_or_create_directory_at(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
) -> tuple[int, bool]:
|
||||
if (
|
||||
not name
|
||||
or name in {".", ".."}
|
||||
or Path(name).name != name
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
or (os.altsep is not None and os.altsep in name)
|
||||
):
|
||||
raise SystemExit(f"初始化目录名必须是单个安全路径段: {name!r}")
|
||||
created = False
|
||||
try:
|
||||
os.mkdir(name, dir_fd=parent_fd)
|
||||
created = True
|
||||
except FileExistsError:
|
||||
pass
|
||||
try:
|
||||
directory_fd = os.open(
|
||||
name,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise SystemExit(
|
||||
f"初始化路径必须是普通目录且不能是软链接: {name}: {exc}"
|
||||
) from exc
|
||||
return directory_fd, created
|
||||
|
||||
|
||||
def _rename_directory_noreplace(
|
||||
source_parent_fd: int,
|
||||
source_name: str,
|
||||
destination_parent_fd: int,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
"""Atomically publish a directory without replacing an existing path."""
|
||||
source = _encode_single_path_component(source_name)
|
||||
destination = _encode_single_path_component(destination_name)
|
||||
if source == destination:
|
||||
raise ValueError("暂存目录名与目标目录名不能相同")
|
||||
try:
|
||||
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
|
||||
except (AttributeError, OSError) as exc:
|
||||
raise SystemExit(
|
||||
"当前平台缺少原子 no-replace 目录发布能力,拒绝执行初始化"
|
||||
) from exc
|
||||
|
||||
renameat2.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
renameat2.restype = ctypes.c_int
|
||||
ctypes.set_errno(0)
|
||||
result = renameat2(
|
||||
source_parent_fd,
|
||||
source,
|
||||
destination_parent_fd,
|
||||
destination,
|
||||
_RENAME_NOREPLACE,
|
||||
)
|
||||
if result == 0:
|
||||
return
|
||||
|
||||
error_number = ctypes.get_errno()
|
||||
if error_number in {errno.EEXIST, errno.ENOTEMPTY}:
|
||||
raise FileExistsError(
|
||||
error_number,
|
||||
os.strerror(error_number),
|
||||
destination_name,
|
||||
)
|
||||
if error_number in {
|
||||
errno.ENOSYS,
|
||||
errno.EINVAL,
|
||||
getattr(errno, "ENOTSUP", errno.EOPNOTSUPP),
|
||||
errno.EOPNOTSUPP,
|
||||
}:
|
||||
raise SystemExit(
|
||||
"当前文件系统不支持原子 no-replace 目录发布,拒绝执行初始化"
|
||||
)
|
||||
if error_number == 0:
|
||||
raise RuntimeError("renameat2 失败但未设置 errno")
|
||||
raise OSError(
|
||||
error_number,
|
||||
os.strerror(error_number),
|
||||
f"{source_name} -> {destination_name}",
|
||||
)
|
||||
|
||||
|
||||
def _assert_open_directory_path(
|
||||
directory_fd: int,
|
||||
path: Path,
|
||||
*,
|
||||
phase: str,
|
||||
label: str = "项目目录",
|
||||
) -> None:
|
||||
"""Fail if a named directory no longer resolves to the opened inode."""
|
||||
opened = os.fstat(directory_fd)
|
||||
try:
|
||||
current = os.stat(path, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise SystemExit(f"{phase}时{label}已移动或不可访问: {path}") from exc
|
||||
if (
|
||||
not stat.S_ISDIR(current.st_mode)
|
||||
or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino)
|
||||
):
|
||||
raise SystemExit(f"{phase}时{label}已被替换: {path}")
|
||||
|
||||
|
||||
def _directory_entry_matches_open_fd(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
opened_fd: int,
|
||||
) -> bool:
|
||||
try:
|
||||
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
except OSError:
|
||||
return False
|
||||
opened = os.fstat(opened_fd)
|
||||
return (
|
||||
stat.S_ISDIR(current.st_mode)
|
||||
and (current.st_dev, current.st_ino) == (opened.st_dev, opened.st_ino)
|
||||
)
|
||||
|
||||
|
||||
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
|
||||
@@ -1130,17 +1285,35 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
|
||||
ensure_skills_home()
|
||||
skill_source = SKILLS_DIR / args.name
|
||||
validate_skill_name(args.name)
|
||||
skills_root = SKILLS_DIR.resolve()
|
||||
skill_source = (SKILLS_DIR / args.name).resolve()
|
||||
try:
|
||||
skill_source.relative_to(skills_root)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(f"builtin skill 路径逃逸仓库边界: {args.name}") from exc
|
||||
if not (skill_source / "SKILL.md").is_file():
|
||||
raise SystemExit(f"builtin skill 不存在: {args.name}")
|
||||
|
||||
project = _project_root(args.project)
|
||||
if not project.is_dir():
|
||||
try:
|
||||
initial_project_stat = os.stat(project, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise SystemExit(f"项目目录不存在: {project}") from exc
|
||||
if not stat.S_ISDIR(initial_project_stat.st_mode):
|
||||
raise SystemExit(f"项目目录不存在: {project}")
|
||||
initial_project_identity = (
|
||||
initial_project_stat.st_dev,
|
||||
initial_project_stat.st_ino,
|
||||
stat.S_IFMT(initial_project_stat.st_mode),
|
||||
)
|
||||
destination = project / "docs" / args.name
|
||||
project_file = destination / "project.md"
|
||||
tasks_file = destination / "tasks.yaml"
|
||||
managed_targets = (project_file, tasks_file)
|
||||
knowledge_file = destination / "knowledge.yaml"
|
||||
managed_targets = [project_file, tasks_file]
|
||||
if args.name == "ack":
|
||||
managed_targets.append(knowledge_file)
|
||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||
if existing:
|
||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||
@@ -1148,12 +1321,29 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
|
||||
project_template = skill_source / "templates" / "project.template.md"
|
||||
tasks_template = skill_source / "templates" / "tasks.template.yaml"
|
||||
missing = [path for path in (project_template, tasks_template) if not path.is_file()]
|
||||
template_targets = [
|
||||
(project_template, project_file),
|
||||
(tasks_template, tasks_file),
|
||||
]
|
||||
if args.name == "ack":
|
||||
template_targets.append(
|
||||
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file)
|
||||
)
|
||||
missing = [path for path, _ in template_targets if not path.is_file()]
|
||||
if missing:
|
||||
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
|
||||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||||
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
|
||||
if args.name == "ack":
|
||||
missing_validators = [
|
||||
path
|
||||
for path in (validator, knowledge_validator)
|
||||
if not path.is_file()
|
||||
]
|
||||
if missing_validators:
|
||||
paths = ", ".join(path.name for path in missing_validators)
|
||||
raise SystemExit(f"ACK skill 缺少初始化校验器: {paths}")
|
||||
|
||||
version_file = skill_source / "VERSION"
|
||||
ack_version = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else "unknown"
|
||||
@@ -1167,17 +1357,270 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
"<接入时的 ack skill 版本>": ack_version,
|
||||
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
|
||||
}
|
||||
_render_template(project_template, project_file, values)
|
||||
_render_template(tasks_template, tasks_file, values)
|
||||
|
||||
validator = skill_source / "scripts" / "validate_tasks.py"
|
||||
if validator.is_file():
|
||||
subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True)
|
||||
with tempfile.TemporaryDirectory(prefix=f"skiff-{args.name}-init-") as temp_dir:
|
||||
staging = Path(temp_dir)
|
||||
staged_files: dict[Path, Path] = {}
|
||||
rendered_files: dict[Path, str] = {}
|
||||
for template, target in template_targets:
|
||||
staged = staging / target.relative_to(project)
|
||||
staged.parent.mkdir(parents=True, exist_ok=True)
|
||||
rendered_files[target] = _render_template(template, staged, values)
|
||||
staged_files[target] = staged
|
||||
|
||||
if validator.is_file():
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(validator), str(staged_files[tasks_file])],
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise SystemExit(
|
||||
f"初始化任务板校验失败(exit {completed.returncode})"
|
||||
)
|
||||
if args.name == "ack" and knowledge_validator.is_file():
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(knowledge_validator),
|
||||
str(staged_files[knowledge_file]),
|
||||
"--tasks",
|
||||
str(staged_files[tasks_file]),
|
||||
"--project-root",
|
||||
str(staging),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise SystemExit(
|
||||
f"初始化知识库校验失败(exit {completed.returncode})"
|
||||
)
|
||||
|
||||
for target, staged in staged_files.items():
|
||||
if staged.read_text(encoding="utf-8") != rendered_files[target]:
|
||||
raise SystemExit(
|
||||
f"初始化临时文件在校验期间发生变化: {target.name}"
|
||||
)
|
||||
|
||||
# Validation may take time, so guard against a concurrent initializer before writing.
|
||||
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
|
||||
if existing:
|
||||
paths = ", ".join(str(path.relative_to(project)) for path in existing)
|
||||
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
|
||||
|
||||
project_fd: int | None = None
|
||||
docs_fd: int | None = None
|
||||
transaction_fd: int | None = None
|
||||
staging_fd: int | None = None
|
||||
docs_created = False
|
||||
transaction_name: str | None = None
|
||||
staged_names: list[str] = []
|
||||
published = False
|
||||
committed = False
|
||||
try:
|
||||
try:
|
||||
project_fd = os.open(
|
||||
project,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise SystemExit(
|
||||
f"校验期间项目目录已移动或不可访问: {project}"
|
||||
) from exc
|
||||
opened_project_stat = os.fstat(project_fd)
|
||||
opened_project_identity = (
|
||||
opened_project_stat.st_dev,
|
||||
opened_project_stat.st_ino,
|
||||
stat.S_IFMT(opened_project_stat.st_mode),
|
||||
)
|
||||
if opened_project_identity != initial_project_identity:
|
||||
raise SystemExit(f"校验期间项目目录已被替换: {project}")
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
project,
|
||||
phase="初始化",
|
||||
)
|
||||
docs_fd, docs_created = _open_or_create_directory_at(project_fd, "docs")
|
||||
if docs_created:
|
||||
os.fsync(project_fd)
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
project,
|
||||
phase="初始化",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
phase="初始化",
|
||||
label="docs 目录",
|
||||
)
|
||||
try:
|
||||
destination_stat = os.stat(
|
||||
args.name,
|
||||
dir_fd=docs_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
if stat.S_ISLNK(destination_stat.st_mode):
|
||||
raise SystemExit(
|
||||
"初始化路径必须是普通目录且不能是软链接: "
|
||||
f"docs/{args.name}"
|
||||
)
|
||||
raise SystemExit(f"拒绝覆盖已有路径: docs/{args.name}")
|
||||
|
||||
for _ in range(32):
|
||||
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
|
||||
try:
|
||||
os.mkdir(candidate, mode=0o700, dir_fd=docs_fd)
|
||||
except FileExistsError:
|
||||
continue
|
||||
transaction_name = candidate
|
||||
break
|
||||
if transaction_name is None:
|
||||
raise SystemExit("无法创建唯一的初始化暂存目录")
|
||||
|
||||
transaction_fd = os.open(
|
||||
transaction_name,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=docs_fd,
|
||||
)
|
||||
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
|
||||
staging_fd = os.open(
|
||||
"payload",
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
dir_fd=transaction_fd,
|
||||
)
|
||||
for target in staged_files:
|
||||
file_fd = os.open(
|
||||
target.name,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=staging_fd,
|
||||
)
|
||||
staged_names.append(target.name)
|
||||
with os.fdopen(file_fd, "w", encoding="utf-8") as destination_file:
|
||||
destination_file.write(rendered_files[target])
|
||||
destination_file.flush()
|
||||
os.fsync(destination_file.fileno())
|
||||
|
||||
os.fsync(staging_fd)
|
||||
os.fsync(transaction_fd)
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
project,
|
||||
phase="发布",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
phase="发布",
|
||||
label="docs 目录",
|
||||
)
|
||||
try:
|
||||
_rename_directory_noreplace(
|
||||
transaction_fd,
|
||||
"payload",
|
||||
docs_fd,
|
||||
args.name,
|
||||
)
|
||||
except FileExistsError as exc:
|
||||
raise SystemExit(
|
||||
f"拒绝覆盖已有路径: docs/{args.name}"
|
||||
) from exc
|
||||
published = True
|
||||
_assert_open_directory_path(
|
||||
staging_fd,
|
||||
destination,
|
||||
phase="发布",
|
||||
label="ACK 目录",
|
||||
)
|
||||
if (
|
||||
transaction_name is not None
|
||||
and _directory_entry_matches_open_fd(
|
||||
docs_fd,
|
||||
transaction_name,
|
||||
transaction_fd,
|
||||
)
|
||||
):
|
||||
try:
|
||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
transaction_name = None
|
||||
try:
|
||||
os.fsync(docs_fd)
|
||||
except OSError as exc:
|
||||
raise SystemExit(
|
||||
"初始化目录已完整发布,但无法确认目录项持久化;"
|
||||
f"请检查 docs/{args.name} 后再重试"
|
||||
) from exc
|
||||
_assert_open_directory_path(
|
||||
project_fd,
|
||||
project,
|
||||
phase="完成初始化",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
docs_fd,
|
||||
project / "docs",
|
||||
phase="完成初始化",
|
||||
label="docs 目录",
|
||||
)
|
||||
_assert_open_directory_path(
|
||||
staging_fd,
|
||||
destination,
|
||||
phase="完成初始化",
|
||||
label="ACK 目录",
|
||||
)
|
||||
committed = True
|
||||
except BaseException:
|
||||
if not published:
|
||||
for name in reversed(staged_names):
|
||||
try:
|
||||
if staging_fd is not None:
|
||||
os.unlink(name, dir_fd=staging_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if transaction_fd is not None:
|
||||
try:
|
||||
os.rmdir("payload", dir_fd=transaction_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if (
|
||||
transaction_name is not None
|
||||
and transaction_fd is not None
|
||||
and docs_fd is not None
|
||||
and _directory_entry_matches_open_fd(
|
||||
docs_fd,
|
||||
transaction_name,
|
||||
transaction_fd,
|
||||
)
|
||||
):
|
||||
try:
|
||||
os.rmdir(transaction_name, dir_fd=docs_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
for directory_fd in (
|
||||
staging_fd,
|
||||
transaction_fd,
|
||||
docs_fd,
|
||||
project_fd,
|
||||
):
|
||||
if directory_fd is not None:
|
||||
os.close(directory_fd)
|
||||
|
||||
if not committed:
|
||||
raise SystemExit("初始化事务未提交")
|
||||
|
||||
_print(f"✓ skill 项目状态初始化完成: {args.name}")
|
||||
_print(f" 项目: {project}")
|
||||
_print(f" 覆盖层: {project_file}")
|
||||
_print(f" 任务板: {tasks_file}")
|
||||
if args.name == "ack":
|
||||
_print(f" 知识库: {knowledge_file}")
|
||||
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user