Skip to content

Commit 32a8919

Browse files
seanbeardenclaude
andauthored
fix(guard): block Task tool for Mayor via GT_ROLE check (#1529)
* fix(guard): block Task tool for Mayor via GT_ROLE check Adds `gt tap guard task-dispatch` PreToolUse guard that blocks the Claude Code Task tool when GT_ROLE=mayor, directing the Mayor to use bd create + gt sling instead. This prevents subagent spawning that blocks the Mayor's context and violates GUPP. Key changes from v1 (#1524): - Check GT_ROLE=mayor instead of GT_MAYOR (which is never set) - Built-in defaults always merge first, on-disk overrides layer on top (prevents custom overrides from silently dropping the guard) - Removed --force reference from help text Fixes #904 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(guard): clarify override semantics, add conflict tests Address review feedback: the doc comment incorrectly claimed built-in guards remain active with on-disk overrides. The intended design is defaults-first with on-disk layered on top — on-disk overrides CAN replace or disable built-in guards via per-matcher merge semantics. Updated doc comment and added two tests documenting the behavior: - Conflicting Task matcher replaces built-in guard command - Empty Hooks list on Task matcher disables guard entirely Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bd5e27b commit 32a8919

File tree

4 files changed

+251
-3
lines changed

4 files changed

+251
-3
lines changed

internal/cmd/tap_guard.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ is violated. They're called before the tool runs, preventing the
1818
forbidden operation entirely.
1919
2020
Available guards:
21-
pr-workflow - Block PR creation and feature branches
21+
pr-workflow - Block PR creation and feature branches
22+
task-dispatch - Block Task tool for Mayor (use gt sling instead)
2223
2324
Example hook configuration:
2425
{
@@ -51,9 +52,33 @@ witness, etc.). Humans running outside Gas Town can still use PRs.`,
5152
RunE: runTapGuardPRWorkflow,
5253
}
5354

55+
var tapGuardTaskDispatchCmd = &cobra.Command{
56+
Use: "task-dispatch",
57+
Short: "Block Task tool for Mayor (use gt sling instead)",
58+
Long: `Block the Claude Code Task tool when running as Mayor.
59+
60+
The Mayor should dispatch work via gt sling, not by spawning Claude Code
61+
subagents. Subagents block the Mayor's context until they finish, breaking
62+
GUPP (the propulsion principle) and preventing parallel polecat execution.
63+
64+
This guard blocks:
65+
- Task tool invocations (Claude Code subagent spawning)
66+
67+
Exit codes:
68+
0 - Operation allowed (not Mayor)
69+
2 - Operation BLOCKED (Mayor context detected)
70+
71+
The guard only blocks when GT_ROLE=mayor. Crew members and polecats
72+
can still use the Task tool for parallel research.
73+
74+
See: https://github.com/steveyegge/gastown/issues/904`,
75+
RunE: runTapGuardTaskDispatch,
76+
}
77+
5478
func init() {
5579
tapCmd.AddCommand(tapGuardCmd)
5680
tapGuardCmd.AddCommand(tapGuardPRWorkflowCmd)
81+
tapGuardCmd.AddCommand(tapGuardTaskDispatchCmd)
5782
}
5883

5984
func runTapGuardPRWorkflow(cmd *cobra.Command, args []string) error {
@@ -80,6 +105,28 @@ func runTapGuardPRWorkflow(cmd *cobra.Command, args []string) error {
80105
return NewSilentExit(2) // Exit 2 = BLOCK in Claude Code hooks
81106
}
82107

108+
func runTapGuardTaskDispatch(cmd *cobra.Command, args []string) error {
109+
// Only block when running as Mayor
110+
if os.Getenv("GT_ROLE") != "mayor" {
111+
return nil
112+
}
113+
114+
fmt.Fprintln(os.Stderr, "")
115+
fmt.Fprintln(os.Stderr, "╔══════════════════════════════════════════════════════════════════╗")
116+
fmt.Fprintln(os.Stderr, "║ TASK TOOL BLOCKED -- Mayor cannot spawn subagents ║")
117+
fmt.Fprintln(os.Stderr, "╠══════════════════════════════════════════════════════════════════╣")
118+
fmt.Fprintln(os.Stderr, "║ Subagents block your context and prevent parallel execution. ║")
119+
fmt.Fprintln(os.Stderr, "║ ║")
120+
fmt.Fprintln(os.Stderr, "║ Instead of: Task tool (spawning a subagent) ║")
121+
fmt.Fprintln(os.Stderr, "║ Do this: bd create \"...\" && gt sling <bead-id> <rig> ║")
122+
fmt.Fprintln(os.Stderr, "║ ║")
123+
fmt.Fprintln(os.Stderr, "║ Why? Polecats execute in parallel without blocking the Mayor. ║")
124+
fmt.Fprintln(os.Stderr, "║ See: https://github.com/steveyegge/gastown/issues/904 ║")
125+
fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════╝")
126+
fmt.Fprintln(os.Stderr, "")
127+
return NewSilentExit(2) // Exit 2 = BLOCK in Claude Code hooks
128+
}
129+
83130
// isGasTownAgentContext returns true if we're running as a Gas Town managed agent.
84131
func isGasTownAgentContext() bool {
85132
// Check environment variables set by Gas Town session management

internal/cmd/tap_guard_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestTapGuardTaskDispatch_BlocksWhenMayor(t *testing.T) {
9+
t.Setenv("GT_ROLE", "mayor")
10+
11+
err := runTapGuardTaskDispatch(nil, nil)
12+
if err == nil {
13+
t.Fatal("expected error (exit 2) when GT_ROLE=mayor")
14+
}
15+
16+
se, ok := err.(*SilentExitError)
17+
if !ok {
18+
t.Fatalf("expected SilentExitError, got %T: %v", err, err)
19+
}
20+
if se.Code != 2 {
21+
t.Errorf("expected exit code 2, got %d", se.Code)
22+
}
23+
}
24+
25+
func TestTapGuardTaskDispatch_AllowsWhenNotMayor(t *testing.T) {
26+
os.Unsetenv("GT_ROLE")
27+
28+
err := runTapGuardTaskDispatch(nil, nil)
29+
if err != nil {
30+
t.Errorf("expected nil error when GT_ROLE is not set, got %v", err)
31+
}
32+
}
33+
34+
func TestTapGuardTaskDispatch_AllowsForCrew(t *testing.T) {
35+
t.Setenv("GT_ROLE", "crew")
36+
37+
err := runTapGuardTaskDispatch(nil, nil)
38+
if err != nil {
39+
t.Errorf("expected nil error for crew member, got %v", err)
40+
}
41+
}
42+
43+
func TestTapGuardTaskDispatch_AllowsForPolecat(t *testing.T) {
44+
t.Setenv("GT_ROLE", "polecat")
45+
46+
err := runTapGuardTaskDispatch(nil, nil)
47+
if err != nil {
48+
t.Errorf("expected nil error for polecat, got %v", err)
49+
}
50+
}

internal/hooks/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,33 @@ func Merge(base, override *HooksConfig) *HooksConfig {
164164
return applyOverride(result, override)
165165
}
166166

167+
// DefaultOverrides returns built-in role-specific hook overrides.
168+
// These are always applied as a baseline layer; on-disk overrides merge on top.
169+
func DefaultOverrides() map[string]*HooksConfig {
170+
pathSetup := `export PATH="$HOME/go/bin:$HOME/.local/bin:$PATH"`
171+
return map[string]*HooksConfig{
172+
"mayor": {
173+
PreToolUse: []HookEntry{
174+
{
175+
Matcher: "Task",
176+
Hooks: []Hook{{
177+
Type: "command",
178+
Command: fmt.Sprintf("%s && gt tap guard task-dispatch", pathSetup),
179+
}},
180+
},
181+
},
182+
},
183+
}
184+
}
185+
167186
// ComputeExpected computes the expected HooksConfig for a target by loading
168187
// the base config and applying all applicable overrides in order of specificity.
169188
// If no base config exists, uses DefaultBase().
189+
//
190+
// For each override key, built-in defaults (from DefaultOverrides) are merged
191+
// first, then on-disk overrides layer on top. On-disk overrides can replace
192+
// or disable built-in guards by providing a matching PreToolUse entry (e.g.,
193+
// an empty Hooks list for the "Task" matcher disables the task-dispatch guard).
170194
func ComputeExpected(target string) (*HooksConfig, error) {
171195
base, err := LoadBase()
172196
if err != nil {
@@ -177,8 +201,15 @@ func ComputeExpected(target string) (*HooksConfig, error) {
177201
}
178202
}
179203

204+
defaults := DefaultOverrides()
180205
result := base
181206
for _, overrideKey := range GetApplicableOverrides(target) {
207+
// Always apply built-in defaults first
208+
if def, ok := defaults[overrideKey]; ok {
209+
result = Merge(result, def)
210+
}
211+
212+
// Then layer on-disk overrides on top
182213
override, err := LoadOverride(overrideKey)
183214
if err != nil {
184215
if os.IsNotExist(err) {

internal/hooks/config_test.go

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,14 +424,134 @@ func TestComputeExpectedNoBase(t *testing.T) {
424424
tmpDir := t.TempDir()
425425
setTestHome(t, tmpDir)
426426

427+
// Mayor should get DefaultBase + built-in mayor override (task-dispatch guard)
427428
expected, err := ComputeExpected("mayor")
428429
if err != nil {
429430
t.Fatalf("ComputeExpected failed: %v", err)
430431
}
431432

432433
defaultBase := DefaultBase()
433-
if !HooksEqual(expected, defaultBase) {
434-
t.Error("expected DefaultBase when no configs exist")
434+
mayorDefaults := DefaultOverrides()["mayor"]
435+
merged := Merge(defaultBase, mayorDefaults)
436+
if !HooksEqual(expected, merged) {
437+
t.Error("expected DefaultBase + mayor default override when no configs exist")
438+
}
439+
440+
// Non-mayor target should still just get DefaultBase
441+
crew, err := ComputeExpected("crew")
442+
if err != nil {
443+
t.Fatalf("ComputeExpected(crew) failed: %v", err)
444+
}
445+
if !HooksEqual(crew, defaultBase) {
446+
t.Error("expected DefaultBase for crew when no configs exist")
447+
}
448+
}
449+
450+
// TestComputeExpectedBuiltinPlusOnDisk verifies that on-disk overrides layer
451+
// on top of built-in defaults rather than replacing them.
452+
func TestComputeExpectedBuiltinPlusOnDisk(t *testing.T) {
453+
tmpDir := t.TempDir()
454+
setTestHome(t, tmpDir)
455+
456+
// Save an on-disk mayor override that adds a custom SessionStart hook
457+
customOverride := &HooksConfig{
458+
SessionStart: []HookEntry{
459+
{Matcher: "", Hooks: []Hook{{Type: "command", Command: "custom-mayor-session"}}},
460+
},
461+
}
462+
if err := SaveOverride("mayor", customOverride); err != nil {
463+
t.Fatalf("SaveOverride failed: %v", err)
464+
}
465+
466+
expected, err := ComputeExpected("mayor")
467+
if err != nil {
468+
t.Fatalf("ComputeExpected failed: %v", err)
469+
}
470+
471+
// Should have the built-in Task guard from DefaultOverrides
472+
foundTaskGuard := false
473+
for _, entry := range expected.PreToolUse {
474+
if entry.Matcher == "Task" {
475+
foundTaskGuard = true
476+
break
477+
}
478+
}
479+
if !foundTaskGuard {
480+
t.Error("built-in Task guard should be present even with on-disk mayor override")
481+
}
482+
483+
// Should also have the custom SessionStart from on-disk override
484+
if len(expected.SessionStart) == 0 {
485+
t.Error("on-disk SessionStart override should be present")
486+
} else if expected.SessionStart[0].Hooks[0].Command != "custom-mayor-session" {
487+
t.Errorf("expected custom-mayor-session, got %q", expected.SessionStart[0].Hooks[0].Command)
488+
}
489+
}
490+
491+
// TestComputeExpectedOnDiskReplacesBuiltin verifies that an on-disk override
492+
// with a conflicting "Task" matcher replaces the built-in guard command.
493+
func TestComputeExpectedOnDiskReplacesBuiltin(t *testing.T) {
494+
tmpDir := t.TempDir()
495+
setTestHome(t, tmpDir)
496+
497+
// On-disk override replaces the Task matcher with a custom command
498+
customOverride := &HooksConfig{
499+
PreToolUse: []HookEntry{
500+
{Matcher: "Task", Hooks: []Hook{{Type: "command", Command: "custom-task-guard"}}},
501+
},
502+
}
503+
if err := SaveOverride("mayor", customOverride); err != nil {
504+
t.Fatalf("SaveOverride failed: %v", err)
505+
}
506+
507+
expected, err := ComputeExpected("mayor")
508+
if err != nil {
509+
t.Fatalf("ComputeExpected failed: %v", err)
510+
}
511+
512+
// The on-disk Task entry should replace the built-in one
513+
foundCustom := false
514+
for _, entry := range expected.PreToolUse {
515+
if entry.Matcher == "Task" {
516+
if len(entry.Hooks) == 1 && entry.Hooks[0].Command == "custom-task-guard" {
517+
foundCustom = true
518+
} else {
519+
t.Errorf("Task matcher should have custom command, got %v", entry.Hooks)
520+
}
521+
break
522+
}
523+
}
524+
if !foundCustom {
525+
t.Error("on-disk Task override should replace built-in guard")
526+
}
527+
}
528+
529+
// TestComputeExpectedOnDiskDisablesBuiltin verifies that an on-disk override
530+
// with an empty Hooks list for "Task" disables the built-in guard entirely.
531+
func TestComputeExpectedOnDiskDisablesBuiltin(t *testing.T) {
532+
tmpDir := t.TempDir()
533+
setTestHome(t, tmpDir)
534+
535+
// On-disk override disables the Task guard with empty hooks
536+
disableOverride := &HooksConfig{
537+
PreToolUse: []HookEntry{
538+
{Matcher: "Task", Hooks: []Hook{}},
539+
},
540+
}
541+
if err := SaveOverride("mayor", disableOverride); err != nil {
542+
t.Fatalf("SaveOverride failed: %v", err)
543+
}
544+
545+
expected, err := ComputeExpected("mayor")
546+
if err != nil {
547+
t.Fatalf("ComputeExpected failed: %v", err)
548+
}
549+
550+
// The Task entry should be removed (empty Hooks = explicit disable)
551+
for _, entry := range expected.PreToolUse {
552+
if entry.Matcher == "Task" {
553+
t.Error("Task matcher should be removed when on-disk override has empty Hooks")
554+
}
435555
}
436556
}
437557

0 commit comments

Comments
 (0)