Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ src/panopticon/
container/ # entrypoint (`python -m panopticon.container` = connect/register/slug/
# heartbeat liveness) + agent.py (`-m panopticon.container.agent` = the tmux
# pane's launcher: render skills + operations, point claude at the /mcp server,
# put the workflow overview in its system prompt → exec `claude`) — the ONLY LLM pkg
# put the workflow overview in its system prompt → exec `claude`) — the ONLY LLM pkg;
# tarot_gate.py = the `PreToolUse` hook on `apply_operation` that real-verifies
# (not self-attests) the opt-in tarot review-artifact responsibility for the
# GitHub-forge workflows (`Repo.capabilities.tarot_review`, docs/repos.md) — runs
# `tarot strands check` / `tarot tour check` and denies `advance` on failure
docker/Dockerfile # base task-container image (ADR 0005 base layer): python + git + bash +
# the panopticon package + the `claude` CLI the agent execs; runs as the
# unprivileged `panopticon` user. docker/entrypoint.sh = remap that user to the
Expand Down
28 changes: 23 additions & 5 deletions docs/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,29 @@ and secrets are never baked in — they're injected at run time via `env_file`.
## Capabilities

`capabilities` is a JSON opt-in map for elevated container privileges the runner grants at
spawn. The first (and currently only) capability is `docker_in_docker`: set it and the runner
spawns the container `--privileged`, gives it a volume for `/var/lib/docker`, and the entrypoint
starts a nested Docker daemon. It's **off by default** because it's a trust escalation — a
privileged container is effectively host root — so a repo opts in only when its tasks genuinely
need to run Docker.
spawn. `docker_in_docker`: set it and the runner spawns the container `--privileged`, gives it a
volume for `/var/lib/docker`, and the entrypoint starts a nested Docker daemon. It's **off by
default** because it's a trust escalation — a privileged container is effectively host root — so
a repo opts in only when its tasks genuinely need to run Docker.

### `tarot_review` — the tarot review-artifact gate

For the GitHub-forge workflows (`github-peer-reviewed`, `github-self-reviewed`), setting
`capabilities.tarot_review` adds a real, verified ITERATING responsibility: before `advance` out
of ITERATING is allowed, the task must author `.tarot/strands.json` and (for non-trivial changes)
a tour, passing `tarot strands check` / `tarot tour check`. Unlike every other responsibility
(agent self-attested), this one is checked by an in-container hook that runs the actual commands
and denies the `advance` call — with the checks' output as the reason — on failure. A trivial
diff (below a changed-line threshold, default 20; override with the integer capability
`tarot_review_threshold`) skips the checks and resolves the responsibility automatically.

The `tarot` CLI itself isn't installed by panopticon — a repo that opts in must add it to its own
`image_layer_file` (for example `RUN pip install tarot-review`), since installing it into the
shared workflow layer would force it onto every forge repo whether opted in or not. If `tarot`
isn't on `PATH` for an opted-in repo, the gate denies with a message pointing back here.

Off by default, like `docker_in_docker` — a repo opts in only once it has installed `tarot` via
its image layer.

## Host hook (`hook_file`)

Expand Down
19 changes: 15 additions & 4 deletions src/panopticon/container/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
- **PreToolUse**/**PostToolUse** matched to the ``AskUserQuestion`` tool flip to the *user* while
the agent is asking the user something (so the dashboard shows input is required) and back to the
*agent* once it's answered. ``AskUserQuestion`` is a mid-turn tool call — it never fires ``Stop``
— so without this the turn would wrongly read *agent* the whole time the question is pending.
— so without this the turn would wrongly read *agent* the whole time the question is pending;
- **PreToolUse** matched to the `apply_operation` MCP tool also runs the tarot review-artifact
gate (:mod:`panopticon.container.tarot_gate`) — a real, opt-in check (not just turn bookkeeping)
that can deny an `advance` call; see that module for what it does and why.

claude-specific (`.claude/settings.json`); M3 revisits for other CLIs. Pure — the callback the
hooks invoke is :mod:`panopticon.container.hook`. `:blocked:` is preserved by construction: the
claude-specific (`.claude/settings.json`); M3 revisits for other CLIs. Pure — the turn-flip
callback is :mod:`panopticon.container.hook`. `:blocked:` is preserved by construction: the
callback only sets the turn, never the block.
"""

Expand All @@ -24,6 +27,8 @@

#: The command claude runs for each hook event (sets the turn via the task service).
HOOK_COMMAND = "python -m panopticon.container.hook"
#: The command claude runs on every `apply_operation` call (the tarot review-artifact gate).
TAROT_GATE_COMMAND = "python -m panopticon.container.tarot_gate"


def settings() -> dict[str, Any]:
Expand Down Expand Up @@ -53,7 +58,13 @@ def run(actor: str, event: str | None = None, *, matcher: str | None = None) ->
"Stop": [run("user", "stop")],
"UserPromptSubmit": [run("agent", "prompt")],
# The agent stops to ask the user → flip to user; once answered → back to agent.
"PreToolUse": [run("user", matcher="AskUserQuestion")],
"PreToolUse": [
run("user", matcher="AskUserQuestion"),
{
"matcher": "mcp__panopticon__apply_operation",
"hooks": [{"type": "command", "command": TAROT_GATE_COMMAND}],
},
],
"PostToolUse": [run("agent", matcher="AskUserQuestion")],
},
"skipDangerousModePermissionPrompt": True,
Expand Down
188 changes: 188 additions & 0 deletions src/panopticon/container/tarot_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""The tarot review-artifact gate — a `PreToolUse` hook on `apply_operation` (advance).

For a repo that opts in (`Repo.capabilities["tarot_review"]`, see
:mod:`panopticon.workflows.github_forge`), every other ITERATING responsibility is agent
self-attested, but this one is real-verified: this hook intercepts the `advance` operation while
the task is in ITERATING and runs `tarot strands check` / `tarot tour check` in `/workspace`,
denying the tool call (with the checks' output as the reason, so it lands in the agent's context
like a failed test would) unless they pass. A trivial diff (below a changed-line threshold) skips
the checks and auto-resolves the responsibility instead — no tour to write for a one-line fix.

Registered unconditionally in `container/hooks.py` (like the turn-flip hooks), regardless of
workflow — irrelevant calls (a different operation, a non-ITERATING state, a non-opted-in repo)
resolve in the first couple of checks below and allow immediately. Deterministic and LLM-free:
only subprocess + REST calls, so it's unit-tested with a fake command runner and a fake client
(no real `tarot` binary needed), the same shape as :mod:`panopticon.container.hook`.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Protocol, TextIO

import httpx

from panopticon.client import JsonObj, TaskServiceClient
from panopticon.core.models import Status

#: The `Repo.capabilities` key that opts a repo into this gate (mirrors `docker_in_docker`).
TAROT_REVIEW_CAPABILITY = "tarot_review"
#: Optional per-repo override (an int) of the trivial-diff line threshold, under `capabilities`.
TAROT_REVIEW_THRESHOLD_CAPABILITY = "tarot_review_threshold"
#: The ITERATING responsibility this gate verifies (see `GithubForgeWorkflow.TAROT_REVIEW_ARTIFACTS`).
RESPONSIBILITY_KEY = "tarot-review-artifacts"
#: Below this many total changed lines (git `diff --numstat`, added + removed), the diff is
#: considered trivial and the tarot checks are skipped entirely.
DEFAULT_TRIVIAL_THRESHOLD = 20
WORKSPACE = "/workspace"


@dataclass(frozen=True)
class CommandResult:
"""The outcome of running one external command — never raises; the caller inspects it."""

returncode: int
output: str # combined stdout+stderr
found: bool = True # False when the executable itself wasn't found on PATH


class CommandRunner(Protocol):
def __call__(self, args: Sequence[str], *, cwd: str | None = None) -> CommandResult: ...


def _subprocess_run(args: Sequence[str], *, cwd: str | None = None) -> CommandResult:
try:
proc = subprocess.run(list(args), cwd=cwd, capture_output=True, text=True, check=False)
except FileNotFoundError:
return CommandResult(returncode=127, output=f"{args[0]}: command not found", found=False)
return CommandResult(returncode=proc.returncode, output=proc.stdout + proc.stderr)


def _read_payload(stdin: TextIO) -> dict[str, Any]:
"""Tolerantly parse the hook's stdin JSON; empty/invalid input yields an empty payload."""
try:
raw = stdin.read()
except (OSError, ValueError):
return {}
if not raw or not raw.strip():
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}


def _allow() -> int:
"""No stdout, exit 0 — Claude Code lets the tool call through unmodified."""
return 0


def _deny(reason: str) -> int:
"""Structured `PreToolUse` denial: the reason string lands in the agent's context as the
tool call's failure, the same seam a failed test's output would use."""
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
)
)
return 0


def _opted_in(repo: JsonObj) -> bool:
return bool((repo.get("capabilities") or {}).get(TAROT_REVIEW_CAPABILITY))


def _threshold(repo: JsonObj) -> int:
value = (repo.get("capabilities") or {}).get(TAROT_REVIEW_THRESHOLD_CAPABILITY)
return value if isinstance(value, int) else DEFAULT_TRIVIAL_THRESHOLD


def _changed_line_count(run: CommandRunner, *, base_ref: str) -> int:
"""Total added+removed lines between ``base_ref`` and ``HEAD`` (a `diff --numstat` sum) — the
trivial-diff heuristic. Binary files report ``-`` counts, which don't parse as digits and are
skipped rather than counted."""
result = run(["git", "-C", WORKSPACE, "diff", "--numstat", f"{base_ref}...HEAD"])
total = 0
for line in result.output.splitlines():
added, _, rest = line.partition("\t")
removed, _, _path = rest.partition("\t")
for count in (added, removed):
if count.isdigit():
total += int(count)
return total


def _run_tarot_checks(run: CommandRunner) -> CommandResult | None:
"""Run `tarot strands check` then `tarot tour check`; stop at the first failure (nothing to
gain running both once one has already failed). ``None`` means both passed."""
for args in (["tarot", "strands", "check"], ["tarot", "tour", "check"]):
result = run(args, cwd=WORKSPACE)
if result.returncode != 0:
return result
return None


def main(
*,
client: TaskServiceClient | None = None,
stdin: TextIO | None = None,
run: CommandRunner = _subprocess_run,
) -> int:
payload = _read_payload(stdin or sys.stdin)
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict) or tool_input.get("operation") != "advance":
return _allow()

env = os.environ
task_id = env["PANOPTICON_TASK_ID"]
client = client or TaskServiceClient(httpx.Client(base_url=env["PANOPTICON_SERVICE_URL"]))

task = client.get_task(task_id)
if task.get("state") != "ITERATING":
return _allow()

repo = client.get_repo(task["repo_id"])
if not _opted_in(repo):
return _allow()

base_ref = f"origin/{repo.get('default_base', 'main')}"
if _changed_line_count(run, base_ref=base_ref) < _threshold(repo):
client.resolve_responsibility(
task_id,
RESPONSIBILITY_KEY,
Status.MET,
comment="trivial diff — tarot review skipped",
)
return _allow()

failure = _run_tarot_checks(run)
if failure is None:
client.resolve_responsibility(
task_id,
RESPONSIBILITY_KEY,
Status.MET,
comment="verified by tarot strands check / tarot tour check",
)
return _allow()
if not failure.found:
return _deny(
"`tarot` is not installed in this container. An opted-in repo must install it via "
"its `image_layer_file` (see docs/repos.md)."
)
return _deny(failure.output)


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
Loading
Loading