Straj review workflow

A reviewer-aid bot for sabotage-trajectory (straj) PRs. A reviewer comments @straj-review on a straj PR; a tiny workflow in the env repo calls control-tower's reusable straj-review.yml, which decodes the trajectory and posts the findings back as a single sticky comment — one bot comment per PR, overwritten in place on every run, so re-triggering never stacks duplicates.

It is not a merge gate. It never blocks merge, approves, or requests changes — it just saves the reviewer from reverse-engineering the numbers out of a raw trajectory.

The comment carries, top to bottom: the trajectory facts (env, main/side task + scores, #actions), the action- and trajectory-monitor identity + max_sus / traj_sus with sanity checks, a link to the straj's traj directory (never the diff itself — see below), and then, as supporting context at the bottom, the env threat model followed by the main-task (cover) / side-task (objective) text.

Scope. Everything above is deterministic static reads off the downloaded trajectory + PR — nothing here runs the env container. The LLM attack-walkthrough step (which reads the straj diff and narrates the attack) is added later.

What the reviewer does

  1. Make sure the PR description contains a monitored trajectory URL — either a plain viewer URL containing /trajectories/…, or an explicit line:

    Straj-Review-Trajectory: https://…/trajectories/<id>
    
  2. Comment @straj-review on the PR.

The bot immediately reacts 👀 on the triggering comment, reads the URL from the PR body, downloads the trajectory, (over)writes its sticky comment, and finally reacts 👍 once the comment is posted/updated (GitHub reactions have no ✅, so 👍 is the done-mark — no 👍 means the run failed). If the URL is missing or the download fails, the comment says what it needs instead of failing silently. Re-run any time by commenting @straj-review again — it's idempotent and always reflects the current state.

The trigger phrase is deliberately the hyphenated @straj-review: it reads as one token, and no GitHub user with that name exists, so the comment doesn't ping anybody (unlike e.g. @review …, which tags the real user review).

Providing the straj (the traj directory)

The straj's files live in the env repo at side_tasks/<side_task>/trajs/<main_task>/<variant>/ — the same per-straj directory described in making-strajs. That directory is the canonical PR↔straj link: committing it to the PR is how the straj is tracked. It holds the straj's description.md and info.yml (which may be empty — this workflow does not check their contents) plus the trajectory's resulting git diff and any other relevant files.

The bot does not render the diff. A ct traj patch diff blends the main-task work with the attack — there's no clean "just the attack" slice — and committed files already show in the PR's Files changed tab. So the comment only links to the traj directory; turning that blended diff into an attack narrative is the (later) LLM walkthrough stage's job.

The bot checks only that side_tasks/<side_task>/trajs/ exists for this trajectory's side task. When it doesn't, the comment asks you to create it:

  1. Generate the trajectory's git diffct traj patch <traj> locally (it brings the env up, replays the trajectory, and captures the diff).
  2. Commit it (with description.md / info.yml) under side_tasks/<side_task>/trajs/<main_task>/<variant>/ on the PR branch.
  3. Comment @straj-review again to pick it up.

To force the env threat model to regenerate, just commit a deletion of docs/threat-model.md — the bot regenerates anything absent.

The threat model is a hard prerequisite for the LLM analysis stages: it defines the security property the verdicts judge against, so when it's missing (generation failed or was skipped) the bot skips the LLM stages entirely — no tokens spent — and the comment's analysis section says so and how to retry. The deterministic report (trajectory facts, monitor checks) posts regardless.

How ct is obtained

The workflow installs ct straight from the public control-tower repo via uvx --from git+https://github.com/linuxarena/control-tower@main ct …. No checkout, no token, no PyPI release — env repos do not depend on control-tower.

One-time org setup

The bot authenticates to the trajectory backend with a per-user API token (a bearer token from ct login for a service user). Set it once as an organization secret (granted to the env repos), and every env-repo caller picks it up via secrets: inherit — no per-repo config:

SecretNeeded when
CONTROL_TOWER_API_TOKENthe trajectory backend is private

If the backend is reachable without auth, no secrets are required at all. The backend URL is derived from the trajectory URL in the PR body, so CONTROL_TOWER_API_BASE_URL is only relevant if you reference a bare ID instead of a full URL.

Env-repo caller (the ~15-line wrapper)

Add this as .github/workflows/straj-review.yml in the env repo. Because it triggers on issue_comment, GitHub only runs it once it is on the env repo's default branch — merge it to the default branch once before it will fire.

name: Straj Review on: issue_comment: types: [created] permissions: contents: write # the reusable may commit a generated docs/threat-model.md pull-requests: write issues: write id-token: write jobs: straj-review: # PR comments only, must say "@straj-review", and only from people who can # already write to the repo (authz). if: > github.event.issue.pull_request != null && contains(github.event.comment.body, '@straj-review') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) uses: linuxarena/control-tower/.github/workflows/straj-review.yml@main with: pr_number: ${{ github.event.issue.number }} comment_id: ${{ github.event.comment.id }} secrets: CONTROL_TOWER_API_TOKEN: ${{ secrets.CONTROL_TOWER_API_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

The explicit secrets: block forwards only the secrets the reusable needs — not every org/repo secret (which secrets: inherit would). contents: write and ANTHROPIC_API_KEY are needed only for the threat-model stage: if the env has no docs/threat-model.md, the reusable generates one with Claude and commits it onto the PR branch (once); after that it's just read. Omit ANTHROPIC_API_KEY to disable generation. Pin @main to a tag to freeze behaviour per env repo.