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="$(git rev-parse --show-toplevel)"
"$TUTORIAL_ROOT/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh" \ "$TUTORIAL_ROOT/.agents/skills/write-bpf-production-tutorial/scripts/run-claude-writer.sh" \
--repo "$TUTORIAL_ROOT" \ --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. 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" readonly GUIDE="$SKILL_DIR/references/writing-guide.md"
usage() { 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= repo=
task= task=
declare -a readmes=()
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--repo) --repo)
@@ -22,6 +23,10 @@ while (($#)); do
task=${2:-} task=${2:-}
shift 2 shift 2
;; ;;
--readme)
readmes+=("${2:-}")
shift 2
;;
--model|--fallback-model) --model|--fallback-model)
echo "model overrides and fallbacks are forbidden; required model: $MODEL" >&2 echo "model overrides and fallbacks are forbidden; required model: $MODEL" >&2
exit 2 exit 2
@@ -37,10 +42,43 @@ while (($#)); do
esac esac
done 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 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 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 state_root=${XDG_STATE_HOME:-$HOME/.local/state}/bpf-tutorial-writer/runs
mkdir -p "$state_root" mkdir -p "$state_root"
@@ -54,9 +92,11 @@ manifest="$run_dir/manifest.json"
head_before=$(git -C "$repo" rev-parse HEAD) head_before=$(git -C "$repo" rev-parse HEAD)
status_before=$(git -C "$repo" status --porcelain=v1) status_before=$(git -C "$repo" status --porcelain=v1)
task_sha=$(sha256sum "$task" | awk '{print $1}') 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.' \ '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")" \ "$(<"$GUIDE")" \
"$(<"$task")") "$(<"$task")")
@@ -64,10 +104,11 @@ set +e
( (
cd "$repo" cd "$repo"
claude -p \ claude -p \
--safe-mode \
--model "$MODEL" \ --model "$MODEL" \
--effort high \ --effort high \
--permission-mode acceptEdits \ --permission-mode acceptEdits \
--allowedTools Read Edit Write \ --tools "Read,Edit,Write" \
--output-format stream-json \ --output-format stream-json \
--verbose \ --verbose \
"$prompt" "$prompt"
@@ -116,6 +157,14 @@ if ((claude_status != 0)); then
echo "Claude exited with status $claude_status; preserved trace: $trace" >&2 echo "Claude exited with status $claude_status; preserved trace: $trace" >&2
exit "$claude_status" exit "$claude_status"
fi 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" echo "manifest=$manifest"

View File

@@ -33,9 +33,14 @@ def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--trace", required=True, type=Path) parser.add_argument("--trace", required=True, type=Path)
parser.add_argument("--model", required=True) 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") parser.add_argument("--require-write", action="store_true")
args = parser.parse_args() args = parser.parse_args()
repo = args.repo.resolve()
allowed_paths = {path.resolve() for path in args.allowed_path}
events = load_events(args.trace) events = load_events(args.trace)
init_models = { init_models = {
event.get("model") event.get("model")
@@ -49,6 +54,7 @@ def main() -> int:
authored_text = 0 authored_text = 0
write_calls = 0 write_calls = 0
assistant_models = set() assistant_models = set()
tool_names = set()
for event in events: for event in events:
if event.get("type") != "assistant": if event.get("type") != "assistant":
continue continue
@@ -58,10 +64,23 @@ def main() -> int:
if any(item.get("type") == "text" and item.get("text") for item in content): if any(item.get("type") == "text" and item.get("text") for item in content):
authored_text += 1 authored_text += 1
assistant_models.add(model) assistant_models.add(model)
write_calls += sum( for item in content:
item.get("type") == "tool_use" and item.get("name") in {"Edit", "Write"} if item.get("type") != "tool_use":
for item in content 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: if not authored_text:
raise SystemExit("trace contains no authored assistant text") raise SystemExit("trace contains no authored assistant text")
@@ -71,6 +90,9 @@ def main() -> int:
) )
if args.require_write and write_calls == 0: if args.require_write and write_calls == 0:
raise SystemExit("trace contains no Claude Edit or Write call") 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"] results = [event for event in events if event.get("type") == "result"]
if not results: if not results: