Tool Access Control: Restricting Sub-Agent Capabilities

James Phoenix
James Phoenix

Summary

Tool access control enforces the principle of least privilege for AI sub-agents by restricting which tools each agent can use and which files it can access. A backend engineer gets Read, Write, Edit, and Bash for its domain. A code reviewer gets only Read, Grep, and Glob with no write permissions. By granting each agent the minimum capabilities needed for its role, you prevent domain violations, accidental overwrites, and the common mistake of reviewers introducing bugs while “fixing” issues.

The Problem

When sub-agents have unrestricted tool access, several problems emerge:

Agents Escape Their Domain

A frontend engineer with full write access might “helpfully” fix a backend bug it noticed:

// Frontend engineer sees an issue in the API response
// With full Write access, it decides to "fix" the backend:

// packages/api/src/handlers/users.ts
// Agent adds a change that breaks the authentication flow
export async function getUser(req: Request, res: Response) {
  const user = await userService.getUser(req.params.id);
  // Agent added this "fix" - but broke the auth pattern
  delete user.password;  // ❌ Service should handle this, not handler
  return res.json(user);
}

The frontend agent meant well, but violated the layer architecture. Backend changes should come from the backend engineer who understands the domain patterns.

Write-Capable Reviewers Introduce Bugs

The most dangerous anti-pattern: giving code reviewers write access.

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course
// Code reviewer finds an issue and decides to fix it directly:

// Original code (correct but reviewer misunderstands)
const users = await db.query(`SELECT * FROM users WHERE id = $1`, [userId]);

// Reviewer "fixes" it (introduces SQL injection!)
const users = await db.query(`SELECT * FROM users WHERE id = '${userId}'`);
// ❌ Reviewer introduced a vulnerability while trying to help

Reviewers lack the full context of implementation decisions. When they “fix” code, they often break things the original author understood.

Uncontrolled Side Effects

An agent with Bash access can bypass file restrictions:

# Agent restricted to packages/api/** for file writes
# But Bash isn't restricted...

# Agent runs:
cat > packages/ui/src/components/Header.tsx << 'EOF'
// Frontend component written by backend agent
// Doesn't follow frontend patterns
export function Header() {
  return <div>Header</div>;
}
EOF

# ❌ Bypassed file restrictions through Bash

Security Risks

Overly permissive access creates security vulnerabilities:

// Agent has full Bash access
// Task: "Check if the API is running"

// Agent runs:
bash: curl -X POST http://admin:admin@localhost/admin/reset-database

// ❌ Destructive operation - no guardrails

The Solution

Apply the principle of least privilege: each agent receives only the tools and paths necessary for its specific role.

Core Restriction Dimensions

Tool access control operates on three dimensions:

interface AgentPermissions {
  // 1. Which tools can the agent invoke?
  allowedTools: Tool[];

  // 2. Which paths can the agent access?
  allowedPaths: string[];

  // 3. What operations are permitted?
  operations: {
    canRead: boolean;
    canWrite: boolean;
    canDelete: boolean;
    canExecute: boolean;  // Bash, shell commands
  };
}

Standard Role Configurations

Backend Engineer

Full development capabilities within the backend domain:

const backendEngineer: AgentPermissions = {
  allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
  allowedPaths: [
    'packages/api/**',
    'packages/domain/**',
    'packages/database/**',
    'prisma/**',
  ],
  operations: {
    canRead: true,
    canWrite: true,
    canDelete: false,  // Prevent accidental file deletion
    canExecute: true,  // For migrations, schema generation
  },
};

// Bash restrictions for backend
const backendBashWhitelist = [
  'npm run',
  'npx prisma',
  'tsc --noEmit',
  'jest',
];

Frontend Engineer

Development capabilities for UI work, typically no shell access needed:

const frontendEngineer: AgentPermissions = {
  allowedTools: ['Read', 'Write', 'Edit', 'Grep', 'Glob'],
  allowedPaths: [
    'packages/ui/**',
    'apps/web/**',
    'apps/mobile/**',
  ],
  operations: {
    canRead: true,
    canWrite: true,
    canDelete: false,
    canExecute: false,  // No Bash - prevents build script manipulation
  },
};

QA Engineer

Test-focused permissions with execution capabilities:

const qaEngineer: AgentPermissions = {
  allowedTools: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
  allowedPaths: [
    '**/*.test.ts',
    '**/*.spec.ts',
    '**/*.test.tsx',
    'tests/**',
    'e2e/**',
    '__tests__/**',
  ],
  operations: {
    canRead: true,   // Can read implementation to understand what to test
    canWrite: true,  // Can create test files
    canDelete: false,
    canExecute: true,  // Must run tests
  },
};

// QA can read any file but only write to test files
const qaReadPaths = ['**/*'];  // Read everything
const qaWritePaths = ['**/*.test.*', '**/*.spec.*', 'tests/**'];  // Write only tests

Code Reviewer (READ-ONLY)

The most restricted role, and for good reason:

const codeReviewer: AgentPermissions = {
  allowedTools: ['Read', 'Grep', 'Glob'],  // No Write, Edit, or Bash
  allowedPaths: ['**/*'],  // Can read everything
  operations: {
    canRead: true,
    canWrite: false,   // CRITICAL: Cannot modify code
    canDelete: false,
    canExecute: false,
  },
};

Why read-only reviewers matter:

  1. No introduced bugs: Reviewer cannot accidentally break working code
  2. Clear responsibility: Issues are identified, not “fixed” in place
  3. Audit trail: All fixes go through the appropriate engineer
  4. No context loss: Original author makes informed fixes

Security Auditor

Similar to reviewer but with specific security focus:

const securityAuditor: AgentPermissions = {
  allowedTools: ['Read', 'Grep', 'Glob'],
  allowedPaths: ['**/*'],
  operations: {
    canRead: true,
    canWrite: false,
    canDelete: false,
    canExecute: false,
  },
};

// Security auditor gets additional context
const securityContext = `
Focus on:
- SQL injection vulnerabilities
- XSS attack vectors
- Authentication bypasses
- Secret exposure
- OWASP Top 10 issues

Report format:
- Severity: CRITICAL/HIGH/MEDIUM/LOW
- Location: file:line
- Issue: description
- Recommendation: how to fix
- DO NOT attempt to fix - report only
`;

Orchestrator

Coordinates work but minimizes direct file access:

const orchestrator: AgentPermissions = {
  allowedTools: ['Read', 'Grep', 'Glob', 'Task'],  // Can spawn sub-agents
  allowedPaths: [
    'CLAUDE.md',
    '.claude/**',
    'tasks/**',
    'docs/**',
  ],
  operations: {
    canRead: true,
    canWrite: false,  // Orchestrator delegates, doesn't implement
    canDelete: false,
    canExecute: false,
  },
};

Implementation Patterns

Pattern 1: Tool Whitelist at Agent Spawn

Pass allowed tools when creating sub-agents:

async function spawnSubAgent(
  role: AgentRole,
  task: string
): Promise<AgentResult> {
  const permissions = getPermissionsForRole(role);

  return await claude.complete({
    system: buildAgentContext(role),
    prompt: task,
    allowedTools: permissions.allowedTools,
    allowedPaths: permissions.allowedPaths,
  });
}

// Usage
const reviewResult = await spawnSubAgent(
  'code-reviewer',
  'Review the payment implementation for security issues'
);
// Reviewer cannot accidentally modify code - tools restricted

Pattern 2: Path-Based Write Restrictions

Allow reads everywhere, restrict writes by domain:

interface PathPermissions {
  readPaths: string[];   // Where agent can read
  writePaths: string[];  // Where agent can write
}

const domainPermissions: Record<string, PathPermissions> = {
  'backend-engineer': {
    readPaths: ['**/*'],  // Read everything for context
    writePaths: ['packages/api/**', 'packages/domain/**'],
  },
  'frontend-engineer': {
    readPaths: ['**/*'],
    writePaths: ['packages/ui/**', 'apps/web/**'],
  },
  'qa-engineer': {
    readPaths: ['**/*'],
    writePaths: ['**/*.test.*', '**/*.spec.*', 'tests/**'],
  },
};

Pattern 3: Bash Command Whitelist

Restrict shell access to specific commands:

const bashWhitelists: Record<string, string[]> = {
  'backend-engineer': [
    'npm run build',
    'npm run lint',
    'npx prisma generate',
    'npx prisma migrate',
    'tsc --noEmit',
    'jest packages/api',
  ],
  'qa-engineer': [
    'npm test',
    'npm run test:*',
    'npx jest',
    'npx playwright test',
  ],
  'frontend-engineer': [],  // No Bash access
  'code-reviewer': [],      // No Bash access
};

function validateBashCommand(role: string, command: string): boolean {
  const whitelist = bashWhitelists[role] || [];
  return whitelist.some(allowed =>
    command.startsWith(allowed) ||
    new RegExp(`^${allowed.replace('*', '.*')}$`).test(command)
  );
}

Pattern 4: Role-Based Tool Registry

Centralize permission definitions:

// .claude/tool-permissions.ts
export const toolRegistry = {
  roles: {
    'backend-engineer': {
      tools: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
      paths: {
        read: ['**/*'],
        write: ['packages/api/**', 'packages/domain/**', 'packages/database/**'],
      },
      bash: {
        allowed: ['npm', 'npx', 'tsc', 'jest'],
        blocked: ['rm -rf', 'curl', 'wget', 'ssh'],
      },
    },
    'code-reviewer': {
      tools: ['Read', 'Grep', 'Glob'],
      paths: {
        read: ['**/*'],
        write: [],  // Empty = no write access
      },
      bash: {
        allowed: [],
        blocked: ['*'],  // Block all Bash
      },
    },
  },

  getPermissions(role: string): AgentPermissions {
    const config = this.roles[role];
    if (!config) {
      throw new Error(`Unknown role: ${role}`);
    }
    return {
      allowedTools: config.tools,
      allowedPaths: config.paths.write,
      readPaths: config.paths.read,
      bashWhitelist: config.bash.allowed,
      bashBlacklist: config.bash.blocked,
    };
  },
};

Advanced Patterns

Conditional Tool Access

Grant different tools based on task phase:

interface PhasePermissions {
  planning: Tool[];
  implementation: Tool[];
  review: Tool[];
}

const backendPhases: PhasePermissions = {
  planning: ['Read', 'Grep', 'Glob'],  // Research only
  implementation: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'],
  review: ['Read', 'Grep', 'Glob'],  // Back to read-only
};

async function executeWithPhase(
  phase: keyof PhasePermissions,
  task: string
): Promise<AgentResult> {
  const tools = backendPhases[phase];
  return await claude.complete({
    system: `Phase: ${phase}. Available tools: ${tools.join(', ')}`,
    prompt: task,
    allowedTools: tools,
  });
}

Escalation Requests

Allow agents to request elevated permissions through the orchestrator:

interface EscalationRequest {
  agent: string;
  requestedTool: Tool;
  reason: string;
  scope: string;  // Specific file or directory
}

async function handleEscalation(request: EscalationRequest): Promise<boolean> {
  // Log the request for audit
  await logEscalation(request);

  // Orchestrator (or human) reviews
  const approved = await reviewEscalation(request);

  if (approved) {
    // Grant temporary elevated access
    await grantTemporaryAccess(request.agent, request.requestedTool, {
      scope: request.scope,
      duration: '5m',  // Time-limited
    });
  }

  return approved;
}

// Agent requests escalation
const escalation: EscalationRequest = {
  agent: 'qa-engineer',
  requestedTool: 'Write',
  reason: 'Need to update test fixtures in packages/api/fixtures/',
  scope: 'packages/api/fixtures/**',
};

Audit Logging

Track all tool usage for debugging and compliance:

interface ToolUsageLog {
  timestamp: Date;
  agent: string;
  tool: Tool;
  arguments: Record<string, unknown>;
  path?: string;
  result: 'success' | 'denied' | 'error';
  denialReason?: string;
}

async function logToolUsage(log: ToolUsageLog): Promise<void> {
  await appendToFile('.claude/audit.log', JSON.stringify(log));

  // Alert on suspicious patterns
  if (log.result === 'denied') {
    await alertOnDenial(log);
  }
}

// Log output example:
// {"timestamp":"2026-01-28T10:00:00Z","agent":"code-reviewer","tool":"Write","path":"packages/api/src/auth.ts","result":"denied","denialReason":"Role code-reviewer does not have Write permission"}

Common Pitfalls

Pitfall 1: Write-Capable Reviewers

The most common and dangerous mistake:

// ❌ WRONG: Reviewer can modify code
const badReviewer = {
  tools: ['Read', 'Write', 'Edit', 'Grep', 'Glob'],
  // Reviewer will "helpfully" fix issues directly
  // Often introduces new bugs or breaks patterns
};

// ✅ CORRECT: Read-only reviewer
const goodReviewer = {
  tools: ['Read', 'Grep', 'Glob'],
  // Can only report issues, not modify code
  // Fixes go through appropriate engineer
};

Pitfall 2: Overly Broad Path Patterns

// ❌ WRONG: Too broad
const badPaths = {
  backendEngineer: {
    writePaths: ['**/*'],  // Can write anywhere!
  },
};

// ✅ CORRECT: Domain-specific
const goodPaths = {
  backendEngineer: {
    writePaths: ['packages/api/**', 'packages/domain/**'],
  },
};

Pitfall 3: Unrestricted Bash Access

// ❌ WRONG: Agent can bypass file restrictions via Bash
const badConfig = {
  tools: ['Read', 'Write', 'Bash'],
  writePaths: ['packages/api/**'],
  // But Bash can write anywhere with shell redirection!
};

// ✅ CORRECT: Whitelist Bash commands
const goodConfig = {
  tools: ['Read', 'Write', 'Bash'],
  writePaths: ['packages/api/**'],
  bashWhitelist: ['npm run', 'tsc', 'jest'],
  // Shell commands that don't involve file writing
};

Pitfall 4: Forgetting Task Tool Restrictions

Sub-agents can spawn their own sub-agents unless restricted:

// ❌ WRONG: Agent can spawn unrestricted sub-agents
const badConfig = {
  tools: ['Read', 'Write', 'Task'],  // Can spawn any sub-agent
};

// ✅ CORRECT: Restrict Task tool or remove it
const goodConfig = {
  tools: ['Read', 'Write'],  // No Task tool
  // Or restrict spawnable agent types:
  taskRestrictions: {
    allowedAgentTypes: ['qa-engineer'],  // Can only spawn QA
  },
};

Benefits

1. Domain Integrity

Agents stay in their lane:

Backend changes: Backend engineer only
Frontend changes: Frontend engineer only
Test files: QA engineer only
Code reviews: Read-only reviewer

Result: No cross-domain contamination

2. Reduced Bug Introduction

Read-only reviewers catch issues without adding new ones:

Without restrictions:
- Reviewer finds 5 issues
- Reviewer "fixes" 3 issues
- Reviewer introduces 2 new bugs
- Net improvement: +1 (barely)

With restrictions:
- Reviewer finds 5 issues
- Reviewer reports all 5
- Original author fixes all 5 correctly
- Net improvement: +5

3. Clear Audit Trail

All modifications are traceable to the appropriate agent:

packages/api/src/auth.ts:
- Line 15: Backend engineer (commit abc123)
- Line 30: Backend engineer (commit def456)

Clear accountability, easy debugging

4. Security Posture

Principle of least privilege limits blast radius:

If backend agent is compromised:
- Can only affect packages/api/**, packages/domain/**
- Cannot access secrets in other directories
- Cannot execute arbitrary shell commands
- Damage is contained

Related

References

Topics
Agent ArchitectureCapability ControlOrchestrationPermissionsPrinciple Of Least PrivilegeRead OnlySandboxingSecuritySub AgentsTool Access

More Insights

Cover Image for Own Your Control Plane

Own Your Control Plane

If you use someone else’s task manager, you inherit all of their abstractions. In a world where LLMs make software a solved problem, the cost of ownership has flipped.

James Phoenix
James Phoenix
Cover Image for Indexed PRD and Design Doc Strategy

Indexed PRD and Design Doc Strategy

A documentation-driven development pattern where a single `index.md` links all PRDs and design documents, creating navigable context for both humans and AI agents.

James Phoenix
James Phoenix