diff --git a/.agents/skills/write-bpf-production-tutorial/SKILL.md b/.agents/skills/write-bpf-production-tutorial/SKILL.md index 5c7e91b..4e93efe 100644 --- a/.agents/skills/write-bpf-production-tutorial/SKILL.md +++ b/.agents/skills/write-bpf-production-tutorial/SKILL.md @@ -58,7 +58,9 @@ Then run: TUTORIAL_ROOT="$(git rev-parse --show-toplevel)" "$TUTORIAL_ROOT/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh" \ --repo "$TUTORIAL_ROOT" \ - --task /absolute/path/to/writer-task.md + --task /absolute/path/to/writer-task.md \ + --readme "$TUTORIAL_ROOT/src//README.md" \ + --readme "$TUTORIAL_ROOT/src//README.zh.md" ``` The wrapper must finish with a verified exact-model trace. Inspect both READMEs for matching structure and technical meaning, then rerun build and documentation commands exactly as written. diff --git a/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh b/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh index 24a743f..728eaaa 100755 --- a/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh +++ b/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh @@ -7,11 +7,12 @@ readonly VERIFY="$SKILL_DIR/scripts/verify_claude_trace.py" readonly GUIDE="$SKILL_DIR/references/writing-guide.md" usage() { - echo "usage: $0 --repo ABSOLUTE_REPO_PATH --task ABSOLUTE_TASK_FILE" >&2 + echo "usage: $0 --repo ABSOLUTE_REPO_PATH --task ABSOLUTE_TASK_FILE --readme ABSOLUTE_README_PATH --readme ABSOLUTE_README_PATH" >&2 } repo= task= +declare -a readmes=() while (($#)); do case "$1" in --repo) @@ -22,6 +23,10 @@ while (($#)); do task=${2:-} shift 2 ;; + --readme) + readmes+=("${2:-}") + shift 2 + ;; --model|--fallback-model) echo "model overrides and fallbacks are forbidden; required model: $MODEL" >&2 exit 2 @@ -37,10 +42,43 @@ while (($#)); do esac done -[[ $repo = /* && -d $repo/.git ]] || { echo "--repo must be an absolute Git checkout" >&2; exit 2; } -[[ $task = /* && -f $task ]] || { echo "--task must be an absolute readable file" >&2; exit 2; } command -v claude >/dev/null || { echo "Claude Code is required" >&2; exit 127; } +command -v git >/dev/null || { echo "git is required" >&2; exit 127; } command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 127; } +command -v realpath >/dev/null || { echo "realpath is required" >&2; exit 127; } +[[ $repo = /* && -d $repo ]] || { echo "--repo must be an absolute directory" >&2; exit 2; } +repo=$(realpath -e -- "$repo") +[[ $(git -C "$repo" rev-parse --is-inside-work-tree 2>/dev/null) == true ]] || { + echo "--repo must be a Git worktree" >&2 + exit 2 +} +[[ $task = /* && -f $task ]] || { echo "--task must be an absolute readable file" >&2; exit 2; } +task=$(realpath -e -- "$task") +((${#readmes[@]} == 2)) || { echo "pass exactly two --readme paths" >&2; exit 2; } + +declare -a allowed_readmes=() +english_readme= +chinese_readme= +for readme in "${readmes[@]}"; do + [[ $readme = /* ]] || { echo "--readme must be absolute: $readme" >&2; exit 2; } + readme=$(realpath -m -- "$readme") + case "$readme" in + "$repo"/src/*/README.md) + [[ -z $english_readme ]] || { echo "pass only one README.md" >&2; exit 2; } + english_readme=$readme + ;; + "$repo"/src/*/README.zh.md) + [[ -z $chinese_readme ]] || { echo "pass only one README.zh.md" >&2; exit 2; } + chinese_readme=$readme + ;; + *) echo "--readme must name a tutorial README inside $repo/src: $readme" >&2; exit 2 ;; + esac + allowed_readmes+=("$readme") +done +[[ -n $english_readme && -n $chinese_readme && $(dirname -- "$english_readme") == $(dirname -- "$chinese_readme") ]] || { + echo "--readme paths must be the English and Chinese pair from one lesson" >&2 + exit 2 +} state_root=${XDG_STATE_HOME:-$HOME/.local/state}/bpf-tutorial-writer/runs mkdir -p "$state_root" @@ -54,9 +92,11 @@ manifest="$run_dir/manifest.json" head_before=$(git -C "$repo" rev-parse HEAD) status_before=$(git -C "$repo" status --porcelain=v1) task_sha=$(sha256sum "$task" | awk '{print $1}') +printf -v allowed_paths 'Allowed README path: %s\n' "${allowed_readmes[@]}" -prompt=$(printf '%s\n\n%s\n\n%s\n' \ +prompt=$(printf '%s\n\n%s\n%s\n%s\n' \ 'You are the sole prose author for an eBPF production tutorial. Read the repository code and evidence named in the task. Edit only the README.md and README.zh.md paths explicitly allowed by the task. Do not edit code, build files, generated indexes, or tests. Do not invent output or claims. Finish by stating which README files you edited.' \ + "$allowed_paths" \ "$(<"$GUIDE")" \ "$(<"$task")") @@ -64,10 +104,11 @@ set +e ( cd "$repo" claude -p \ + --safe-mode \ --model "$MODEL" \ --effort high \ --permission-mode acceptEdits \ - --allowedTools Read Edit Write \ + --tools "Read,Edit,Write" \ --output-format stream-json \ --verbose \ "$prompt" @@ -116,6 +157,14 @@ if ((claude_status != 0)); then echo "Claude exited with status $claude_status; preserved trace: $trace" >&2 exit "$claude_status" fi +if [[ $head_after != "$head_before" ]]; then + echo "Claude changed Git HEAD; preserved trace: $trace" >&2 + exit 1 +fi -python3 "$VERIFY" --trace "$trace" --model "$MODEL" --require-write +verify_args=(--trace "$trace" --model "$MODEL" --repo "$repo" --require-write) +for readme in "${allowed_readmes[@]}"; do + verify_args+=(--allowed-path "$readme") +done +python3 "$VERIFY" "${verify_args[@]}" echo "manifest=$manifest" diff --git a/.agents/skills/write-bpf-production-tutorial/scripts/verify_claude_trace.py b/.agents/skills/write-bpf-production-tutorial/scripts/verify_claude_trace.py index 214b584..8b30585 100755 --- a/.agents/skills/write-bpf-production-tutorial/scripts/verify_claude_trace.py +++ b/.agents/skills/write-bpf-production-tutorial/scripts/verify_claude_trace.py @@ -33,9 +33,14 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--trace", required=True, type=Path) parser.add_argument("--model", required=True) + parser.add_argument("--repo", required=True, type=Path) + parser.add_argument("--allowed-path", action="append", required=True, type=Path) parser.add_argument("--require-write", action="store_true") args = parser.parse_args() + repo = args.repo.resolve() + allowed_paths = {path.resolve() for path in args.allowed_path} + events = load_events(args.trace) init_models = { event.get("model") @@ -49,6 +54,7 @@ def main() -> int: authored_text = 0 write_calls = 0 assistant_models = set() + tool_names = set() for event in events: if event.get("type") != "assistant": continue @@ -58,10 +64,23 @@ def main() -> int: if any(item.get("type") == "text" and item.get("text") for item in content): authored_text += 1 assistant_models.add(model) - write_calls += sum( - item.get("type") == "tool_use" and item.get("name") in {"Edit", "Write"} - for item in content - ) + for item in content: + if item.get("type") != "tool_use": + continue + tool_name = item.get("name") + tool_names.add(tool_name) + if tool_name not in {"Edit", "Write"}: + continue + write_calls += 1 + raw_path = (item.get("input") or {}).get("file_path") + if not raw_path: + raise SystemExit(f"{tool_name} call has no file_path") + target = Path(raw_path) + if not target.is_absolute(): + target = repo / target + target = target.resolve() + if target not in allowed_paths: + raise SystemExit(f"{tool_name} call targeted unauthorized path: {target}") if not authored_text: raise SystemExit("trace contains no authored assistant text") @@ -71,6 +90,9 @@ def main() -> int: ) if args.require_write and write_calls == 0: raise SystemExit("trace contains no Claude Edit or Write call") + unexpected_tools = tool_names - {"Read", "Edit", "Write"} + if unexpected_tools: + raise SystemExit(f"trace contains unauthorized tools: {sorted(unexpected_tools)!r}") results = [event for event in events if event.get("type") == "result"] if not results: