Hardening Gastown: Role-Based Access Control for Multi-Agent Workflows

Configuring Gastown for production use with custom role contexts, Claude Code hooks, git guards, and file guards to enforce principle of least privilege across AI agents

· 9 min read
claude code multi-agent gastown security configuration ai agents hooks

In my previous post, I covered contributions to Gastown’s codebase. This post focuses on configuration—how I run Gastown safely with role-based access control, Claude Code hooks, and guard scripts that enforce the principle of least privilege.

The Problem: Autonomous Agents Need Boundaries

Multi-agent systems present a safety challenge. Agents operate autonomously, making decisions and taking actions. Without constraints:

  • A planning agent might accidentally commit code
  • A monitoring agent might push to protected branches
  • A worker agent might modify files outside its worktree
  • Any agent might merge PRs without human approval

The solution is defense in depth—multiple layers of protection that don’t rely on agents following instructions.

Configuration Architecture

My Gastown configuration uses three layers:

LayerFilePurpose
Role Contexts~/gt/settings/config.jsonCustomizes agent behavior via system prompts
Claude Code HooksPer-agent .claude/settings.jsonTriggers guard scripts before tool execution
Guard Scripts~/gt/.runtime/hooks/*.shEnforces rules at the tool level
Hook Registry~/gt/hooks/registry.tomlCentralized hook definitions for easy deployment

Each layer provides independent protection. An agent might ignore its context instructions, but the guard scripts enforce rules regardless.

Layer 1: Role Context Overrides

Gastown’s settings/config.json lets you override the default role contexts with custom system prompts. My configuration establishes clear boundaries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "context": {
    "roles": {
      "mayor": "# Mayor Context - Orchestrator\n\n## CRITICAL BOUNDARIES\n\n- **NO coding** - You do not write or modify code\n- **NO pushing** - You never push to any branch\n- **NO merging** - You never merge PRs or branches...",
      "polecat": "# Polecat Context - Worker with Approval Gates\n\n- **NEVER push to main/master** - Feature branches only\n- **NEVER merge** - You do not merge PRs...",
      "crew": "# Crew Member Context - Planning & Analysis\n\n- **NO coding** - Do not write or modify code files\n- **NO pushing** - Never push to any branch...",
      "refinery": "# Refinery Context - DISABLED\n\n## CRITICAL: Refinery Does NOTHING\n\n- **NEVER merge** - Do not merge any PR or branch..."
    }
  }
}

Role Responsibilities

RolePurposePermissions
MayorOrchestrator, reviewer, coordinatorRead-only; approves polecat work
CrewPlanning, analysis, architectureRead-only; prepares work for polecats
PolecatImplementationCan code in worktree; feature branches only
WitnessMonitoring, violation detectionRead-only; halts rule violations
DeaconDaemon patrol, lifecycle managementRead-only; manages agent sessions
RefineryDisabledDoes nothing; human handles merges

Approval Workflow

Polecats don’t have free rein. They operate under an approval gate workflow:

1. Mayor assigns bead → gt sling <bead> <rig>
2. Polecat submits PLAN_SUBMISSION → Mayor reviews
3. Mayor sends PLAN_RESPONSE (approve/reject)
4. Polecat implements and sends REVIEW_REQUEST
5. Mayor inspects logs, sends FINAL_APPROVAL
6. Polecat pushes to feature branch (never main)

This keeps human-in-the-loop even with autonomous execution.

Layer 2: Claude Code Hooks

Context instructions are suggestions—agents can ignore them. The enforcement layer uses Claude Code’s PreToolUse hook to intercept tool calls before execution.

Important: Settings must be per-agent, not at the town root. Placing settings.json at ~/gt/.claude/ would pollute all child workspaces. Each agent has its own settings at locations like:

  • ~/gt/mayor/.claude/settings.json
  • ~/gt/deacon/.claude/settings.json
  • ~/gt/<rig>/polecats/.claude/settings.json (inherited by all polecats in that rig)
  • ~/gt/<rig>/witness/.claude/settings.json

Example configuration for a non-coder role (mayor):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {"type": "command", "command": "/Users/dustin/gt/.runtime/hooks/file-guard.sh"}
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {"type": "command", "command": "/Users/dustin/gt/.runtime/hooks/git-guard.sh"}
        ]
      }
    ]
  }
}

Critical: Guards must exit with code 2 to block tool execution. Exit code 1 is treated as an error but doesn’t block. The blocking message must go to stderr for Claude to see it:

1
2
echo "BLOCKED: reason" >&2
exit 2

Every Bash command passes through git-guard.sh. Every Edit/Write/NotebookEdit passes through file-guard.sh.

Layer 3: Guard Scripts

git-guard.sh

Enforces git workflow rules based on role. The script reads tool input from stdin as JSON and detects the role from the current working directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#!/bin/bash
set -euo pipefail

# Tool input is passed via stdin as JSON
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.command // empty')

# Skip if not a git command
[[ "$CMD" =~ ^git[[:space:]] ]] || exit 0

# Detect role from PWD
detect_role() {
  case "$PWD" in
    */gt/mayor/*|*/gt/mayor) echo "mayor" ;;
    */gt/deacon/*|*/gt/deacon) echo "deacon" ;;
    */gt/*/polecats/*) echo "polecat" ;;
    */gt/*/crew/*) echo "crew" ;;
    */gt/*/witness/*) echo "witness" ;;
    */gt/*/refinery/*) echo "refinery" ;;
    *) echo "unknown" ;;
  esac
}

ROLE=$(detect_role)

# Non-coder roles: Block all git writes
if [[ "$ROLE" =~ ^(crew|mayor|deacon)$ ]]; then
  if [[ "$CMD" =~ git[[:space:]]+(push|commit|tag|merge|branch) ]]; then
    echo "BLOCKED: $ROLE role cannot perform git write operations" >&2
    exit 2  # Exit 2 = BLOCK in Claude Code hooks
  fi
fi

# Polecats: Enforce branch workflow
if [[ "$ROLE" == "polecat" ]]; then
  # Block push to protected branches
  if [[ "$CMD" =~ git[[:space:]]+push.*[[:space:]]+(main|master|release) ]]; then
    echo "BLOCKED: polecats cannot push to main/master/release" >&2
    exit 2
  fi

  # Validate branch naming
  VALID_PREFIXES="^(feat|fix|dev|test|ci|chore|docs|perf|bug|refactor|style|build)/"
  if [[ "$CMD" =~ git[[:space:]]+(checkout[[:space:]]+-b|branch)[[:space:]]+([^[:space:]]+) ]]; then
    BRANCH="${BASH_REMATCH[2]}"
    if ! [[ "$BRANCH" =~ $VALID_PREFIXES ]]; then
      echo "BLOCKED: invalid branch name '$BRANCH'" >&2
      exit 2
    fi
  fi
fi

exit 0

file-guard.sh

Blocks direct file modifications for non-coder roles and enforces worktree boundaries for polecats:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/bin/bash
set -euo pipefail

# Tool input is passed via stdin as JSON
INPUT=$(cat)

# Get tool name from environment (set by Claude Code hooks)
TOOL_NAME="${CLAUDE_TOOL_NAME:-}"

# Detect role from PWD
detect_role() {
  case "$PWD" in
    */gt/mayor/*|*/gt/mayor) echo "mayor" ;;
    */gt/deacon/*|*/gt/deacon) echo "deacon" ;;
    */gt/*/polecats/*) echo "polecat" ;;
    */gt/*/crew/*) echo "crew" ;;
    */gt/*/witness/*) echo "witness" ;;
    */gt/*/refinery/*) echo "refinery" ;;
    *) echo "unknown" ;;
  esac
}

ROLE=$(detect_role)

# Non-coder roles: Block Edit/Write/NotebookEdit tools
if [[ "$ROLE" =~ ^(crew|mayor|deacon|witness|refinery)$ ]]; then
  if [[ "$TOOL_NAME" =~ ^(Edit|Write|NotebookEdit)$ ]]; then
    echo "BLOCKED: $ROLE role cannot use Edit/Write tools" >&2
    echo "Use polecats for code changes" >&2
    exit 2  # Exit 2 = BLOCK in Claude Code hooks
  fi
  exit 0
fi

# Polecats: Enforce worktree boundaries
if [[ "$ROLE" == "polecat" ]]; then
  if [[ "$PWD" =~ ^(.*/gt/[^/]+/polecats/[^/]+) ]]; then
    WORKTREE_ROOT="${BASH_REMATCH[1]}"
  else
    exit 0  # Can't determine worktree, allow
  fi

  # Get file path from tool input
  case "$TOOL_NAME" in
    Edit|Write) FILE_PATH=$(echo "$INPUT" | jq -r '.file_path // empty') ;;
    NotebookEdit) FILE_PATH=$(echo "$INPUT" | jq -r '.notebook_path // empty') ;;
    *) exit 0 ;;
  esac

  if [[ -n "$FILE_PATH" && "$FILE_PATH" != "$WORKTREE_ROOT"/* ]]; then
    echo "BLOCKED: polecat cannot modify files outside worktree" >&2
    echo "File: $FILE_PATH" >&2
    echo "Worktree: $WORKTREE_ROOT" >&2
    exit 2
  fi
fi

exit 0

Protection Matrix

RoleEdit/WriteBashGit PushGit Commit
PolecatWithin worktreeAllFeature branchesYes
CrewBlockedAllBlockedBlocked
MayorBlockedAllBlockedBlocked
DeaconBlockedAllBlockedBlocked
WitnessBlockedAllBlockedBlocked
RefineryBlockedLimitedBlockedBlocked

What’s Explicitly Allowed

The guards are surgical—they block code modification while preserving operational capabilities. Non-coder roles retain full access to coordination tools:

ToolTypeAllowed for Crew/Mayor?Why
bd create, bd updateBashYesIssue tracking is core to planning
bd syncBashYesBeads sync doesn’t modify code
gt mail, gt nudgeBashYesInter-agent communication
gt sling, gt convoyBashYesWork coordination
mcp__memory__*MCPYesKnowledge graph has no hook
mcp__sequential-thinking__*MCPYesReasoning tools unaffected
git status, git logBashYesRead operations allowed

Key insight: The hooks only match Bash, Edit, Write, and NotebookEdit. MCP tools like memory and sequential thinking have no hook matchers, so they pass through unrestricted. This is intentional—planning agents need to record decisions and reason through problems.

The guards block code modification, not coordination work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Crew CAN do:
bd create "Design auth system architecture"
bd update gt-123 --status=in_progress
mcp__memory__create_entities [{"name": "auth-decision", ...}]
gt mail send mayor/ -s "Architecture proposal" -m "..."

# Crew CANNOT do:
# Edit tool → BLOCKED
# Write tool → BLOCKED
# git push → BLOCKED
# git commit → BLOCKED

Model Selection by Role

Different roles benefit from different models. Planning and orchestration need stronger reasoning; execution can use faster models:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "role_agents": {
    "crew": "claude-opus",
    "mayor": "claude-opus",
    "polecat": "claude-sonnet",
    "witness": "claude-sonnet",
    "refinery": "claude-sonnet",
    "deacon": "claude-sonnet"
  }
}

Opus handles the thinking (mayor, crew). Sonnet handles the doing (polecats, patrol agents).

Custom Branch Workflow

I run a fork with a custom branch that bundles unreleased features from open PRs. This enables using improvements before upstream merge.

1
2
3
git remote add upstream https://github.com/steveyegge/gastown.git
git checkout custom
go build -o gt ./cmd/gt && mv gt ~/go/bin/

When upstream merges a PR, I rebase custom to drop those commits. Extra maintenance, but necessary when running a production multi-agent setup.

Disabled Refinery

The default Gastown configuration has an active refinery that processes merge queues. I disable it completely:

1
2
3
{
  "refinery": "# Refinery Context - DISABLED\n\n## CRITICAL: Refinery Does NOTHING\n\n- **NEVER merge** - Do not merge any PR or branch\n- **NEVER push** - Do not push to any branch..."
}

The merge decision stays with the human. Agents prepare work; humans approve and merge.

Verification

Test guards by simulating tool calls. The key test is verifying exit code 2 for blocked operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Test git-guard for crew role (should exit 2)
cd ~/gt/pyspark_pipeline_framework/crew/dustin
echo '{"command": "git push origin main"}' | ~/gt/.runtime/hooks/git-guard.sh
echo "Exit code: $?"  # Should be 2

# Test file-guard for mayor role (should exit 2)
cd ~/gt/mayor
CLAUDE_TOOL_NAME=Edit echo '{"file_path": "/tmp/test.py"}' | ~/gt/.runtime/hooks/file-guard.sh
echo "Exit code: $?"  # Should be 2

# Test polecat within worktree (should exit 0)
cd ~/gt/pyspark_pipeline_framework/polecats/obsidian
CLAUDE_TOOL_NAME=Edit echo '{"file_path": "'$PWD'/src/test.py"}' | ~/gt/.runtime/hooks/file-guard.sh
echo "Exit code: $?"  # Should be 0

Run tests after any configuration change to verify guards work correctly.

Hook Registry and Installation

Managing hooks across many agents is tedious. Gastown provides a registry system to centralize hook definitions and deploy them easily.

Registry Definition

Create ~/gt/hooks/registry.toml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Gas Town Hook Registry
# Install hooks with: gt hooks install <hook-name> --role <role> --all-rigs

[hooks.file-guard]
description = "Block Edit/Write/NotebookEdit for non-coder roles"
event = "PreToolUse"
matchers = ["Edit", "Write", "NotebookEdit"]
command = "/Users/dustin/gt/.runtime/hooks/file-guard.sh"
roles = ["mayor", "deacon", "crew", "witness", "refinery", "polecat"]
scope = "town"
enabled = true

[hooks.git-guard]
description = "Block git write operations for non-coders"
event = "PreToolUse"
matchers = ["Bash"]
command = "/Users/dustin/gt/.runtime/hooks/git-guard.sh"
roles = ["mayor", "deacon", "crew", "polecat"]
scope = "town"
enabled = true

Managing Hooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# List available hooks
gt hooks list -v

# Preview what would be installed
gt hooks install file-guard --role polecat --all-rigs --dry-run

# Install to all polecats across all rigs
gt hooks install file-guard --role polecat --all-rigs
gt hooks install git-guard --role polecat --all-rigs

# View currently installed hooks
gt hooks

The registry makes it easy to add new guards, update existing ones, and deploy consistently across all agents.

Note: gt hooks install currently handles rig-level roles (polecat, crew, witness, refinery) but not town-level roles (mayor, deacon). Those require manual settings updates.

Key Takeaways

  1. Defense in depth: Context instructions + hooks + guards. Each layer is independent.

  2. Principle of least privilege: Each role can only do what it needs. Planning agents can’t code. Coders can’t merge.

  3. Human stays in control: Approval workflows for implementation. Disabled refinery for merges.

  4. Be surgical: Allow what’s needed (Bash for gt/bd commands), block what’s dangerous (Edit/Write for non-coders).

  5. Test your guards: Automated tests catch regressions when updating configuration.

The configuration files are available in my gastown fork on the custom branch.