Enforce tutorial writer safety boundaries

This commit is contained in:
LinuxDev9002
2026-07-18 15:42:30 -07:00
parent b48a2381d7
commit 1abe2b9912
3 changed files with 84 additions and 11 deletions

View File

@@ -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/<lesson>/README.md" \
--readme "$TUTORIAL_ROOT/src/<lesson>/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.

View File

@@ -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"

View File

@@ -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: