From 1b86036c221eaf940b6e55a08fbfdc32d787cdd5 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Sun, 26 Jul 2026 02:11:05 -0400 Subject: [PATCH 1/3] fix(work): stop Grok hooks treating home roster as a project Require .brigade/config.json via shared resolve_wired_target and brigade work resolve-target so ~/.brigade never becomes a brief target (#536). Co-authored-by: Cursor --- CHANGELOG.md | 12 ++ src/brigade/claude_hooks/runtime.py | 24 +-- src/brigade/cli/work/dispatching.py | 16 ++ src/brigade/cli/work/registration.py | 16 ++ .../templates/grok/hooks/brigade-work.json | 61 +++++++ .../templates/grok/hooks/brigade-work.sh | 153 ++++++++++++++++++ src/brigade/wiring.py | 49 ++++++ tests/test_wiring_resolve_target.py | 79 +++++++++ 8 files changed, 387 insertions(+), 23 deletions(-) create mode 100644 src/brigade/templates/grok/hooks/brigade-work.json create mode 100755 src/brigade/templates/grok/hooks/brigade-work.sh create mode 100644 src/brigade/wiring.py create mode 100644 tests/test_wiring_resolve_target.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 537a9b0f..42c545c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 show/watch/resume surfaces). Recoverable from git history; see #442 / #471. ### Added +- `brigade work resolve-target --cwd PATH [--harness NAME]` prints the + Brigade-wired project root (requires `.brigade/config.json`) so shell hooks + share Claude's discovery contract instead of matching any `.brigade/` dir. +- Shared `brigade.wiring.resolve_wired_target` helper used by Claude hooks and + the new resolver CLI; optional harness filter. +- Grok work-loop hook templates under `src/brigade/templates/grok/hooks/` that + use `resolve-target`, timeout the session brief, and do not deny edits while + a brief is running (#536). - Imported `stations/notify` Go module into the Brigade monorepo. Unified release manifests now enumerate five native components (25 platform assets plus `component-manifest-v1.json` and `checksums.txt`) with managed resolution @@ -31,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `brigade mcp sync --user-scope` (and `brigade operator sync-mcp --user-scope`) no longer writes stdio MCP servers into a user-wide client config silently: interactive runs show the destination, stdio count, and the servers-times-sessions process formula and ask for confirmation, non-interactive and `--json` runs require `--allow-global-stdio`, and plan/sync items now carry `transport` and `scope`. (#349) ### Fixed +- Grok/T3 work-loop discovery no longer treats `~/.brigade` (user-level aboyeur + roster) as a project work root. Hooks and `work resolve-target` require + `.brigade/config.json`, so sessions under `$HOME` or unwired dirs do not + background `brigade work brief --target $HOME` (#536). - `brigade run` no longer dies on the first unparsable plan when the chef's final message is prose. The corrective plan turn now restates the output contract ("reply with the JSON plan object and nothing else") alongside the parse error, diff --git a/src/brigade/claude_hooks/runtime.py b/src/brigade/claude_hooks/runtime.py index 10157986..204c73a3 100644 --- a/src/brigade/claude_hooks/runtime.py +++ b/src/brigade/claude_hooks/runtime.py @@ -13,7 +13,7 @@ from typing import Any, Iterator from .. import localio -from ..config import load_config +from ..wiring import resolve_wired_target from .package import PACKAGE_REF BRIEF_TIMEOUT_SECONDS = 10 @@ -116,28 +116,6 @@ def iter_session_states( yield state -def resolve_wired_target(cwd: object) -> Path | None: - if not isinstance(cwd, str) or not cwd.strip(): - return None - try: - current = Path(cwd).expanduser().resolve() - except OSError: - return None - if not current.is_dir(): - current = current.parent - for candidate in (current, *current.parents): - if not (candidate / ".brigade" / "config.json").is_file(): - continue - try: - config = load_config(candidate) - except (OSError, ValueError, json.JSONDecodeError): - return None - if config is not None and "claude" in config.selection.harnesses: - return candidate - return None - return None - - def _advance_quote_state(text: str, quote: str | None) -> str | None: index = 0 while index < len(text): diff --git a/src/brigade/cli/work/dispatching.py b/src/brigade/cli/work/dispatching.py index b5ae328f..fbd9731d 100644 --- a/src/brigade/cli/work/dispatching.py +++ b/src/brigade/cli/work/dispatching.py @@ -40,6 +40,22 @@ def dispatch(args) -> int: return work_cmd.resume(target=args.target) if args.work_command == "brief": return work_cmd.brief(target=args.target, limit=args.limit, json_output=args.json) + if args.work_command == "resolve-target": + from ...wiring import resolve_wired_target + + harness = args.harness.strip() if isinstance(args.harness, str) and args.harness.strip() else None + try: + cwd = str(args.cwd.expanduser().resolve()) + except OSError: + cwd = str(args.cwd) + target = resolve_wired_target(cwd, harness=harness) + if args.json: + print(json.dumps({"ok": target is not None, "target": str(target) if target else None}, indent=2)) + return 0 if target is not None else 1 + if target is None: + return 1 + print(target) + return 0 if args.work_command == "hooks": from ...claude_hooks import install_cmd diff --git a/src/brigade/cli/work/registration.py b/src/brigade/cli/work/registration.py index a72df489..0eab039e 100644 --- a/src/brigade/cli/work/registration.py +++ b/src/brigade/cli/work/registration.py @@ -47,6 +47,22 @@ def register(sub: argparse._SubParsersAction) -> None: p_work_brief.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.") p_work_brief.add_argument("--limit", type=int, default=3, help="Maximum recent sessions to include.") p_work_brief.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + p_work_resolve_target = work_sub.add_parser( + "resolve-target", + help="Print the Brigade-wired project root for a cwd (requires .brigade/config.json).", + ) + p_work_resolve_target.add_argument( + "--cwd", + type=Path, + default=Path("."), + help="Starting directory to walk upward from.", + ) + p_work_resolve_target.add_argument( + "--harness", + default=None, + help="Require this harness in .brigade/config.json (default: any wired project).", + ) + p_work_resolve_target.add_argument("--json", action="store_true", help="Print machine-readable JSON.") p_work_hooks = work_sub.add_parser("hooks", help="Manage project-scoped Claude work-loop hooks.") hooks_sub = p_work_hooks.add_subparsers(dest="hooks_command", metavar="") hooks_sub.required = True diff --git a/src/brigade/templates/grok/hooks/brigade-work.json b/src/brigade/templates/grok/hooks/brigade-work.json new file mode 100644 index 00000000..326e6d73 --- /dev/null +++ b/src/brigade/templates/grok/hooks/brigade-work.json @@ -0,0 +1,61 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|run_terminal_command|Edit|Write|MultiEdit|search_replace|write_file|apply_patch", + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|run_terminal_command|Edit|Write|MultiEdit|search_replace|write_file|apply_patch", + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 30 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "REPLACE_WITH_HOOK_SCRIPT", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/src/brigade/templates/grok/hooks/brigade-work.sh b/src/brigade/templates/grok/hooks/brigade-work.sh new file mode 100755 index 00000000..9467c949 --- /dev/null +++ b/src/brigade/templates/grok/hooks/brigade-work.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Grok/T3 Brigade work-loop hook (issue #536). +# +# Discovery rule: only a directory with .brigade/config.json is a project +# work root. ~/.brigade (user-level aboyeur roster) must never match. +set -u + +payload="$(cat)" +event="$(jq -r '.hookEventName // empty' <<<"$payload" 2>/dev/null || true)" +session_id="$(jq -r '.sessionId // empty' <<<"$payload" 2>/dev/null || true)" +workspace="$(jq -r '.workspaceRoot // .cwd // empty' <<<"$payload" 2>/dev/null || true)" +tool_name="$(jq -r '.toolName // empty' <<<"$payload" 2>/dev/null || true)" +tool_command="$(jq -r '.toolInput.command // empty' <<<"$payload" 2>/dev/null || true)" + +event="${event,,}" +session_id="${session_id:-unknown-session}" +safe_session="$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')" +brigade_bin="${BRIGADE_BIN:-$(command -v brigade 2>/dev/null || true)}" +state_root="${GROK_BRIGADE_STATE_DIR:-$HOME/.grok/state/brigade-work}" +state_dir="$state_root/$safe_session" +brief_timeout_seconds="${BRIGADE_GROK_BRIEF_TIMEOUT_SECONDS:-30}" + +allow() { + printf '{"decision":"allow"}\n' + exit 0 +} + +deny() { + jq -cn --arg reason "$1" '{decision:"deny",reason:$reason}' + exit 0 +} + +find_brigade_target() { + local path="$1" + [[ -n "$path" ]] || return 1 + # Prefer the shared resolver so shell hooks cannot reimplement discovery wrong. + if [[ -n "$brigade_bin" ]] && "$brigade_bin" work resolve-target --help >/dev/null 2>&1; then + if target="$("$brigade_bin" work resolve-target --cwd "$path" --harness grok 2>/dev/null)"; then + [[ -n "$target" ]] || return 1 + printf '%s\n' "$target" + return 0 + fi + return 1 + fi + # Fallback for older Brigade installs: require config.json, never match roster-only ~/.brigade. + path="$(cd "$path" 2>/dev/null && pwd -P)" || return 1 + local home="${HOME:-}" + while [[ -n "$path" && "$path" != "/" ]]; do + if [[ -f "$path/.brigade/config.json" ]]; then + printf '%s\n' "$path" + return 0 + fi + if [[ -n "$home" && "$path" == "$home" ]]; then + break + fi + path="$(dirname "$path")" + done + return 1 +} + +target="$(find_brigade_target "$workspace" 2>/dev/null || true)" +if [[ -z "$target" || -z "$brigade_bin" ]]; then + if [[ "$event" == "pre_tool_use" ]]; then + allow + fi + exit 0 +fi + +mkdir -p "$state_dir" "$state_root/logs" +log_file="$state_root/logs/$safe_session.log" +printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$event" "$tool_name" >>"$log_file" + +is_edit_tool() { + case "$tool_name" in + search_replace|write_file|apply_patch|Edit|Write|MultiEdit) return 0 ;; + *) return 1 ;; + esac +} + +is_raw_verification() { + [[ -n "$tool_command" ]] || return 1 + grep -Eiq '(^|[;&|[:space:]])(\.?/?scripts/verify|pytest|uv[[:space:]]+run[[:space:]]+pytest|python[0-9.]*[[:space:]]+-m[[:space:]]+pytest|ruff|mypy|pyright|eslint|tsc|vitest|jest|npm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|pnpm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|yarn[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|bun[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|go[[:space:]]+(test|vet|build)|cargo[[:space:]]+(test|check|clippy|build)|make[[:space:]]+(test|check|verify|lint|build)|just[[:space:]]+(test|check|verify|lint|build))([[:space:]]|$)' <<<"$tool_command" +} + +has_new_receipt() { + [[ -f "$state_dir/substantive" ]] || return 1 + [[ -d "$target/.brigade/work/verify-runs" ]] || return 1 + find "$target/.brigade/work/verify-runs" -type f -name receipt.json -newer "$state_dir/substantive" -print -quit 2>/dev/null | grep -q . +} + +close_out_work() { + [[ -f "$state_dir/substantive" ]] || return 0 + if has_new_receipt; then + "$brigade_bin" receipts export miseledger --target "$target" --new-only --import >>"$log_file" 2>&1 || true + rm -f "$state_dir/substantive" "$state_dir/friction.reported" + elif [[ ! -f "$state_dir/friction.reported" ]]; then + "$brigade_bin" friction add --target "$target" --type workflow_correction --severity high --workflow grok/brigade-work --evidence "grok-session:$session_id" "Grok session $session_id ended after edits without a new Brigade verify receipt" >>"$log_file" 2>&1 || true + touch "$state_dir/friction.reported" + fi +} + +case "$event" in + session_start) + printf '%s\n' "$target" >"$state_dir/target" + touch "$state_dir/started" + rm -f "$state_dir/brief.done" "$state_dir/substantive" "$state_dir/ended" "$state_dir/brief.failed" + ( + # Bound the brief like Claude's SessionStart timeout; never block edits on it. + if timeout "$brief_timeout_seconds" "$brigade_bin" work brief --target "$target" >"$state_dir/brief.log" 2>&1; then + touch "$state_dir/brief.done" + else + touch "$state_dir/brief.failed" + fi + ) >>"$log_file" 2>&1 & + ;; + + pre_tool_use) + if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then + if [[ "$tool_command" == *"brigade work brief"* || "$tool_command" == *"brigade daily status"* ]]; then + allow + fi + if [[ "$tool_command" != *"brigade work verify run"* ]] && is_raw_verification; then + deny "This repository is Brigade-wired. Run the check through: brigade work verify run --target . --command \"\" --capture brigade-work" + fi + fi + # Do not deny edits while a background brief is running (#536). + allow + ;; + + post_tool_use) + if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then + if [[ "$tool_command" == *"brigade work brief"* || "$tool_command" == *"brigade daily status"* ]]; then + touch "$state_dir/brief.done" + fi + fi + if is_edit_tool; then + touch "$state_dir/substantive" + rm -f "$state_dir/friction.reported" + fi + ;; + + stop) + close_out_work + ;; + + session_end) + [[ -f "$state_dir/ended" ]] && exit 0 + touch "$state_dir/ended" + close_out_work + ;; +esac + +exit 0 diff --git a/src/brigade/wiring.py b/src/brigade/wiring.py new file mode 100644 index 00000000..d0f71ed9 --- /dev/null +++ b/src/brigade/wiring.py @@ -0,0 +1,49 @@ +"""Shared discovery for Brigade-wired project roots. + +A directory is a project work root only when `.brigade/config.json` exists. +`~/.brigade` (the user-level aboyeur roster fallback for `brigade run`) has no +config.json and must never be treated as a project target by harness hooks. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .config import load_config + + +def resolve_wired_target(cwd: object, *, harness: str | None = "claude") -> Path | None: + """Walk from ``cwd`` upward looking for a Brigade-wired project. + + A candidate qualifies only when ``.brigade/config.json`` is present and + loads. When ``harness`` is set, that harness must also appear in the + project's selected harnesses. When a config is found but the harness is + absent (or the config is unreadable), the walk stops — nested non-matching + projects do not fall through to a parent. + """ + if not isinstance(cwd, str) or not cwd.strip(): + return None + try: + current = Path(cwd).expanduser().resolve() + except OSError: + return None + if not current.is_dir(): + current = current.parent + required = harness.strip().lower() if isinstance(harness, str) and harness.strip() else None + for candidate in (current, *current.parents): + if not (candidate / ".brigade" / "config.json").is_file(): + continue + try: + config = load_config(candidate) + except (OSError, ValueError, json.JSONDecodeError): + return None + if config is None: + return None + if required is None: + return candidate + selected = {name.lower() for name in config.selection.harnesses} + if required in selected: + return candidate + return None + return None diff --git a/tests/test_wiring_resolve_target.py b/tests/test_wiring_resolve_target.py new file mode 100644 index 00000000..62590491 --- /dev/null +++ b/tests/test_wiring_resolve_target.py @@ -0,0 +1,79 @@ +"""Wired-target discovery must not treat ~/.brigade as a project (#536).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from brigade import cli +from brigade.config import Config, write_config +from brigade.selection import Selection +from brigade.wiring import resolve_wired_target + + +def _write_config(target: Path, harnesses: list[str]) -> None: + write_config( + target, + Config( + version=1, + selection=Selection(depth="repo", harnesses=harnesses, owner="this-repo", includes=[]), + ), + ) + + +def test_home_roster_without_config_is_not_a_wired_target(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + roster = home / ".brigade" + roster.mkdir() + (roster / "roster.toml").write_text("# user-level roster\n") + plain = home / "plain" + plain.mkdir() + monkeypatch.setenv("HOME", str(home)) + + assert resolve_wired_target(str(home), harness=None) is None + assert resolve_wired_target(str(plain), harness=None) is None + assert resolve_wired_target(str(plain), harness="grok") is None + + +def test_project_with_config_resolves_and_respects_harness(tmp_path: Path): + repo = tmp_path / "repo" + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + _write_config(repo, ["claude", "grok"]) + + assert resolve_wired_target(str(nested), harness=None) == repo.resolve() + assert resolve_wired_target(str(nested), harness="grok") == repo.resolve() + assert resolve_wired_target(str(nested), harness="claude") == repo.resolve() + assert resolve_wired_target(str(nested), harness="openclaw") is None + + +def test_home_roster_does_not_shadow_child_project(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + (home / ".brigade").mkdir(parents=True) + (home / ".brigade" / "roster.toml").write_text("# roster\n") + repo = home / "repos" / "app" + repo.mkdir(parents=True) + _write_config(repo, ["grok"]) + monkeypatch.setenv("HOME", str(home)) + + assert resolve_wired_target(str(repo), harness="grok") == repo.resolve() + assert resolve_wired_target(str(home), harness="grok") is None + + +def test_work_resolve_target_cli(tmp_path: Path, capsys): + home = tmp_path / "home" + (home / ".brigade").mkdir(parents=True) + (home / ".brigade" / "roster.toml").write_text("# roster\n") + repo = home / "repo" + repo.mkdir() + _write_config(repo, ["grok"]) + + assert cli.main(["work", "resolve-target", "--cwd", str(home)]) == 1 + assert cli.main(["work", "resolve-target", "--cwd", str(repo), "--harness", "grok"]) == 0 + out = capsys.readouterr().out.strip() + assert out == str(repo.resolve()) + + assert cli.main(["work", "resolve-target", "--cwd", str(repo), "--harness", "grok", "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload == {"ok": True, "target": str(repo.resolve())} From 4ee7fa30fadf094202a540400633b0aabe6b3a5c Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Sun, 26 Jul 2026 10:11:10 -0400 Subject: [PATCH 2/3] chore(docs): regenerate command inventory for work resolve-target --- docs/command-inventory.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/command-inventory.md b/docs/command-inventory.md index 689c8165..85793b60 100644 --- a/docs/command-inventory.md +++ b/docs/command-inventory.md @@ -69,7 +69,7 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`. - `brigade untrusted` (extras): 2 command path(s) - `brigade update`: 1 command path(s) - `brigade version`: 1 command path(s) -- `brigade work`: 144 command path(s) +- `brigade work`: 145 command path(s) - `brigade workflow` (extras): 3 command path(s) ## Commands @@ -666,6 +666,7 @@ enabled: run `brigade extras on` once, or set `BRIGADE_EXTRAS=1`. - `brigade work plan-proposals` - `brigade work plans` - `brigade work recap` +- `brigade work resolve-target` - `brigade work resume` - `brigade work review closeout` - `brigade work review finding-show` From 4f7e926d493fd49fc969e2a4318cbc43156f4f09 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Sun, 26 Jul 2026 11:09:01 -0400 Subject: [PATCH 3/3] fix(hooks): harden Grok work-loop fallback Co-authored-by: Codex --- .../templates/grok/hooks/brigade-work.sh | 137 +++++++++++++-- tests/test_grok_hook_runtime.py | 165 ++++++++++++++++++ 2 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 tests/test_grok_hook_runtime.py diff --git a/src/brigade/templates/grok/hooks/brigade-work.sh b/src/brigade/templates/grok/hooks/brigade-work.sh index 9467c949..4e98aade 100755 --- a/src/brigade/templates/grok/hooks/brigade-work.sh +++ b/src/brigade/templates/grok/hooks/brigade-work.sh @@ -6,13 +6,46 @@ set -u payload="$(cat)" -event="$(jq -r '.hookEventName // empty' <<<"$payload" 2>/dev/null || true)" -session_id="$(jq -r '.sessionId // empty' <<<"$payload" 2>/dev/null || true)" -workspace="$(jq -r '.workspaceRoot // .cwd // empty' <<<"$payload" 2>/dev/null || true)" -tool_name="$(jq -r '.toolName // empty' <<<"$payload" 2>/dev/null || true)" -tool_command="$(jq -r '.toolInput.command // empty' <<<"$payload" 2>/dev/null || true)" +event="" +session_id="" +workspace="" +tool_name="" +tool_command="" +hook_fields="$( + printf '%s' "$payload" | python3 -c ' +import json +import shlex +import sys -event="${event,,}" +try: + payload = json.load(sys.stdin) +except (TypeError, ValueError): + payload = {} + +tool_input = payload.get("toolInput") +if not isinstance(tool_input, dict): + tool_input = {} + +def text(value): + return value if isinstance(value, str) else "" + +fields = { + "event": text(payload.get("hookEventName")), + "session_id": text(payload.get("sessionId")), + "workspace": text(payload.get("workspaceRoot")) or text(payload.get("cwd")), + "tool_name": text(payload.get("toolName")), + "tool_command": text(tool_input.get("command")), +} +for name, value in fields.items(): + print(f"{name}={shlex.quote(value)}") +' +)" || hook_fields="" +if [[ -n "$hook_fields" ]]; then + # Values are shell-quoted by the Python parser above, not by hook input. + eval "$hook_fields" +fi + +event="$(printf '%s' "$event" | tr '[:upper:]' '[:lower:]')" session_id="${session_id:-unknown-session}" safe_session="$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')" brigade_bin="${BRIGADE_BIN:-$(command -v brigade 2>/dev/null || true)}" @@ -26,7 +59,7 @@ allow() { } deny() { - jq -cn --arg reason "$1" '{decision:"deny",reason:$reason}' + python3 -c 'import json, sys; print(json.dumps({"decision": "deny", "reason": sys.argv[1]}))' "$1" exit 0 } @@ -45,10 +78,35 @@ find_brigade_target() { # Fallback for older Brigade installs: require config.json, never match roster-only ~/.brigade. path="$(cd "$path" 2>/dev/null && pwd -P)" || return 1 local home="${HOME:-}" + if [[ -n "$home" ]]; then + home="$(cd "$home" 2>/dev/null && pwd -P)" || home="" + fi while [[ -n "$path" && "$path" != "/" ]]; do if [[ -f "$path/.brigade/config.json" ]]; then - printf '%s\n' "$path" - return 0 + if python3 - "$path/.brigade/config.json" <<'PY' +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as config_file: + config = json.load(config_file) +except (OSError, TypeError, ValueError): + raise SystemExit(1) + +harnesses = config.get("harnesses") if isinstance(config, dict) else None +selected = { + harness.strip().lower() + for harness in harnesses + if isinstance(harness, str) +} if isinstance(harnesses, list) else set() +raise SystemExit(0 if "grok" in selected else 1) +PY + then + printf '%s\n' "$path" + return 0 + fi + # Match resolve-target: the first project config is authoritative. + return 1 fi if [[ -n "$home" && "$path" == "$home" ]]; then break @@ -82,6 +140,57 @@ is_raw_verification() { grep -Eiq '(^|[;&|[:space:]])(\.?/?scripts/verify|pytest|uv[[:space:]]+run[[:space:]]+pytest|python[0-9.]*[[:space:]]+-m[[:space:]]+pytest|ruff|mypy|pyright|eslint|tsc|vitest|jest|npm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|pnpm[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|yarn[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|bun[[:space:]]+(run[[:space:]]+)?(test|lint|build|check|typecheck)|go[[:space:]]+(test|vet|build)|cargo[[:space:]]+(test|check|clippy|build)|make[[:space:]]+(test|check|verify|lint|build)|just[[:space:]]+(test|check|verify|lint|build))([[:space:]]|$)' <<<"$tool_command" } +is_direct_brigade_verification() { + [[ "$tool_command" =~ ^[[:space:]]*brigade[[:space:]]+work[[:space:]]+verify[[:space:]]+run([[:space:]]|$) ]] || return 1 + is_simple_shell_command +} + +is_direct_brigade_status() { + [[ "$tool_command" =~ ^[[:space:]]*brigade[[:space:]]+(work[[:space:]]+brief|daily[[:space:]]+status)([[:space:]]|$) ]] || return 1 + is_simple_shell_command +} + +is_simple_shell_command() { + case "$tool_command" in + *';'*|*'&&'*|*'||'*|*'|'*|*'`'*|*'$('*|*'>'*|*'<') return 1 ;; + esac + return 0 +} + +run_brief_bounded() { + python3 - "$brief_timeout_seconds" "$brigade_bin" "$target" "$state_dir/brief.log" <<'PY' +import os +import signal +import subprocess +import sys + +try: + timeout = float(sys.argv[1]) +except ValueError: + timeout = 30.0 +if timeout <= 0: + timeout = 30.0 + +with open(sys.argv[4], "w", encoding="utf-8") as brief_log: + process = subprocess.Popen( + [sys.argv[2], "work", "brief", "--target", sys.argv[3]], + stdout=brief_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + raise SystemExit(process.wait(timeout=timeout)) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise SystemExit(124) +PY +} + has_new_receipt() { [[ -f "$state_dir/substantive" ]] || return 1 [[ -d "$target/.brigade/work/verify-runs" ]] || return 1 @@ -105,8 +214,8 @@ case "$event" in touch "$state_dir/started" rm -f "$state_dir/brief.done" "$state_dir/substantive" "$state_dir/ended" "$state_dir/brief.failed" ( - # Bound the brief like Claude's SessionStart timeout; never block edits on it. - if timeout "$brief_timeout_seconds" "$brigade_bin" work brief --target "$target" >"$state_dir/brief.log" 2>&1; then + # Python's process-group timeout works on BSD and GNU userlands. + if run_brief_bounded; then touch "$state_dir/brief.done" else touch "$state_dir/brief.failed" @@ -116,10 +225,10 @@ case "$event" in pre_tool_use) if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then - if [[ "$tool_command" == *"brigade work brief"* || "$tool_command" == *"brigade daily status"* ]]; then + if is_direct_brigade_status; then allow fi - if [[ "$tool_command" != *"brigade work verify run"* ]] && is_raw_verification; then + if ! is_direct_brigade_verification && is_raw_verification; then deny "This repository is Brigade-wired. Run the check through: brigade work verify run --target . --command \"\" --capture brigade-work" fi fi @@ -129,7 +238,7 @@ case "$event" in post_tool_use) if [[ "$tool_name" == "run_terminal_command" || "$tool_name" == "Bash" ]]; then - if [[ "$tool_command" == *"brigade work brief"* || "$tool_command" == *"brigade daily status"* ]]; then + if is_direct_brigade_status; then touch "$state_dir/brief.done" fi fi diff --git a/tests/test_grok_hook_runtime.py b/tests/test_grok_hook_runtime.py new file mode 100644 index 00000000..cb9888b0 --- /dev/null +++ b/tests/test_grok_hook_runtime.py @@ -0,0 +1,165 @@ +"""Runtime coverage for the Grok work-loop hook.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +HOOK = Path(__file__).parents[1] / "src" / "brigade" / "templates" / "grok" / "hooks" / "brigade-work.sh" + + +def _write_config(target: Path, harnesses: list[str]) -> None: + config = target / ".brigade" / "config.json" + config.parent.mkdir(parents=True) + config.write_text( + json.dumps({"version": 1, "depth": "repo", "harnesses": harnesses, "owner": "this-repo", "includes": []}) + ) + + +def _without_jq_path(tmp_path: Path) -> str: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + for command in ("cat", "date", "dirname", "find", "grep", "mkdir", "rm", "sleep", "touch", "tr"): + source = shutil.which(command) + assert source is not None + destination = bin_dir / command + if not destination.exists(): + destination.symlink_to(source) + python = bin_dir / "python3" + if not python.exists(): + python.symlink_to(sys.executable) + return str(bin_dir) + + +def _run_hook(tmp_path: Path, payload: dict[str, object], **env: str) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "BRIGADE_BIN": str(tmp_path / "missing-brigade"), + "GROK_BRIGADE_STATE_DIR": str(tmp_path / "state"), + "PATH": _without_jq_path(tmp_path), + } + ) + environment.update(env) + return subprocess.run( + ["/usr/bin/bash", str(HOOK)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + env=environment, + ) + + +def _pre_tool_payload(target: Path, command: str, *, event: str = "pre_tool_use") -> dict[str, object]: + return { + "hookEventName": event, + "sessionId": "session-1", + "workspaceRoot": str(target), + "toolName": "run_terminal_command", + "toolInput": {"command": command}, + } + + +def test_hook_parses_json_without_jq_and_uses_bash32_safe_event_lowering(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + + result = _run_hook(tmp_path, _pre_tool_payload(target, "pytest -q", event="PRE_TOOL_USE")) + + assert result.returncode == 0 + assert json.loads(result.stdout)["decision"] == "deny" + assert "jq" not in HOOK.read_text() + assert "${event,,}" not in HOOK.read_text() + + +def test_fallback_stops_at_canonical_home_and_requires_grok_config(tmp_path: Path): + real_home = tmp_path / "real-home" + real_home.mkdir() + home_link = tmp_path / "home-link" + home_link.symlink_to(real_home, target_is_directory=True) + parent = real_home / "parent" + _write_config(parent, ["GROK"]) + target = parent / "repo" + _write_config(target, ["claude"]) + nested = target / "src" + nested.mkdir() + + result = _run_hook( + tmp_path, + _pre_tool_payload(nested, "pytest -q"), + HOME=str(home_link), + ) + + assert result.returncode == 0 + assert json.loads(result.stdout) == {"decision": "allow"} + + +def test_fallback_recognizes_grok_config_and_blocks_raw_verification(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, [" GROK "]) + + result = _run_hook(tmp_path, _pre_tool_payload(target, "pytest -q")) + + assert result.returncode == 0 + assert json.loads(result.stdout)["decision"] == "deny" + + +def test_direct_brigade_verify_is_allowed_but_substring_bypasses_are_denied(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + + direct = _run_hook( + tmp_path, + _pre_tool_payload(target, 'brigade work verify run --target . --command "pytest -q" --capture brigade-work'), + ) + bypass = _run_hook( + tmp_path, + _pre_tool_payload(target, 'echo "brigade work verify run"; pytest -q'), + ) + brief_bypass = _run_hook( + tmp_path, + _pre_tool_payload(target, 'echo "brigade work brief"; pytest -q'), + ) + + assert json.loads(direct.stdout) == {"decision": "allow"} + assert json.loads(bypass.stdout)["decision"] == "deny" + assert json.loads(brief_bypass.stdout)["decision"] == "deny" + + +def test_session_start_uses_portable_bounded_brief_runner(tmp_path: Path): + target = tmp_path / "repo" + _write_config(target, ["grok"]) + brigade = tmp_path / "brigade" + brigade.write_text( + "#!/usr/bin/env bash\n" + 'if [ "$1" = "work" ] && [ "$2" = "resolve-target" ]; then exit 1; fi\n' + 'if [ "$1" = "work" ] && [ "$2" = "brief" ]; then sleep 5; fi\n' + ) + brigade.chmod(0o755) + payload = { + "hookEventName": "session_start", + "sessionId": "slow-brief", + "workspaceRoot": str(target), + } + + result = _run_hook( + tmp_path, + payload, + BRIGADE_BIN=str(brigade), + BRIGADE_GROK_BRIEF_TIMEOUT_SECONDS="1", + ) + + assert result.returncode == 0 + failed = tmp_path / "state" / "slow-brief" / "brief.failed" + deadline = time.monotonic() + 3 + while not failed.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert failed.is_file() + assert 'timeout "$brief_timeout_seconds"' not in HOOK.read_text()