How to Build a Per-Tenant AI Agent Tool Permission Inheritance Pipeline That Prevents Privilege Escalation in Nested Workflow Hierarchies
As agentic AI systems mature in 2026, one architectural challenge has quietly become the most dangerous blind spot in enterprise deployments: privilege escalation across nested agent hierarchies. When a parent orchestrator agent spawns a child agent, which in turn spawns its own subagents, the question of "what tools can this agent actually use?" becomes surprisingly complex and, if answered incorrectly, catastrophically insecure.
In multi-tenant platforms, this problem is amplified. Tenant A's orchestrator agent should never, under any circumstance, be able to leverage a permission that was granted only to Tenant B's workflow, even if both share the same underlying agent runtime. Yet without a deliberate, well-architected permission inheritance pipeline, that boundary is trivially broken the moment you allow dynamic subagent spawning.
This guide walks you through building a robust, per-tenant tool permission inheritance pipeline from the ground up. By the end, you will have a concrete architecture that enforces the principle of least privilege across every node in a nested agent hierarchy, regardless of how deep the tree goes.
Understanding the Core Problem: Why Default Inheritance Fails
Most agentic frameworks in 2026, whether you are working with LangGraph, AutoGen, CrewAI, or a custom orchestration layer, treat tool availability as a relatively flat concern. You define a set of tools, attach them to an agent, and that agent can call them. The problem begins when that agent is empowered to spawn children.
Consider this scenario:
- A root orchestrator agent for Tenant A is granted tools:
read_database,write_report, andcall_external_api. - It spawns a child research agent to gather data. Naively, many frameworks pass the full tool context down to the child.
- That child agent, now possessing
call_external_api, spawns a subagent to summarize findings. The subagent inherits the same tool set. - A prompt injection buried in retrieved web content instructs the subagent to call
call_external_apiwith exfiltration parameters.
This is a textbook confused deputy attack combined with privilege escalation. The subagent at the leaf of the tree has no legitimate business need for call_external_api, yet it inherited that capability through unchecked propagation. In a multi-tenant system, this risk compounds because a single compromised agent runtime can bleed permissions across tenant boundaries.
The Four Pillars of a Secure Permission Inheritance Pipeline
Before writing a single line of code, you need to internalize four design principles that will govern every architectural decision in this system:
1. Capability Confinement (Never Amplify, Only Restrict)
A child agent can only ever receive a strict subset of its parent's tool permissions. It can never receive a superset, even if the parent's system prompt tries to grant additional tools at runtime. This is the golden rule. Any architecture that allows a child to exceed its parent's permission surface is broken by design.
2. Tenant Scope Isolation
Every agent in every hierarchy must carry an immutable, cryptographically verifiable tenant identity token. Tool resolution always intersects the requested tool set with the tenant's registered tool manifest before any inheritance logic runs. Cross-tenant tool resolution must be architecturally impossible, not just policy-prohibited.
3. Explicit Delegation, Not Implicit Inheritance
Permissions do not flow down automatically. A parent agent must explicitly declare which tools it is delegating to a child at spawn time. Anything not explicitly delegated is denied. This forces developers and orchestration logic to make deliberate, auditable decisions about capability propagation.
4. Immutable Permission Chains
Every agent in the hierarchy carries a signed permission chain, a linked list of delegation decisions from the root down to the current node. No agent can modify its own chain or its ancestors' entries. The chain is validated at every tool invocation.
System Architecture Overview
The pipeline consists of five components working in concert:
- Tenant Tool Registry (TTR): The source of truth for what tools exist and which tenants are authorized to use them.
- Agent Spawn Controller (ASC): The gatekeeper that intercepts every agent spawn request and enforces capability confinement.
- Permission Chain Builder (PCB): Creates and signs the immutable permission chain attached to each spawned agent.
- Tool Invocation Interceptor (TII): Validates the permission chain before every tool call, regardless of which agent is making it.
- Audit Ledger: An append-only log of every spawn event, delegation decision, and tool invocation, keyed by tenant and chain ID.
Step 1: Define the Tenant Tool Registry
The TTR is the foundation. It maps each tenant to an explicit set of allowed tools, along with metadata about each tool's risk classification. Here is a representative schema in Python using Pydantic:
from pydantic import BaseModel
from typing import Set, Dict, Literal
import uuid
class ToolDefinition(BaseModel):
tool_id: str
name: str
risk_level: Literal["low", "medium", "high", "critical"]
requires_explicit_delegation: bool = False
class TenantToolManifest(BaseModel):
tenant_id: str
allowed_tools: Set[str] # Set of tool_ids
tool_definitions: Dict[str, ToolDefinition]
# Example manifest for Tenant A
tenant_a_manifest = TenantToolManifest(
tenant_id="tenant_a",
allowed_tools={"read_database", "write_report", "call_external_api"},
tool_definitions={
"read_database": ToolDefinition(
tool_id="read_database",
name="Read Database",
risk_level="medium"
),
"write_report": ToolDefinition(
tool_id="write_report",
name="Write Report",
risk_level="low"
),
"call_external_api": ToolDefinition(
tool_id="call_external_api",
name="Call External API",
risk_level="high",
requires_explicit_delegation=True
),
}
)
Notice the requires_explicit_delegation flag. High-risk and critical tools must be explicitly named in the delegation payload at spawn time. They are never inherited passively, even if the parent holds them.
Step 2: Build the Permission Chain Data Structure
The permission chain is the backbone of the entire system. Each node in the chain represents one agent in the hierarchy and records exactly what tools were delegated at that level.
import hashlib
import json
import time
from typing import Optional, List
class PermissionChainNode(BaseModel):
node_id: str
agent_id: str
tenant_id: str
granted_tools: Set[str]
parent_node_id: Optional[str]
depth: int
created_at: float
signature: str # HMAC or asymmetric signature of the node content
class PermissionChain(BaseModel):
chain_id: str
tenant_id: str
root_node_id: str
nodes: Dict[str, PermissionChainNode] # node_id -> node
def compute_node_signature(node_data: dict, signing_key: str) -> str:
import hmac
payload = json.dumps(node_data, sort_keys=True).encode()
return hmac.new(
signing_key.encode(),
payload,
hashlib.sha256
).hexdigest()
The signature over each node ensures that no downstream agent can tamper with its own granted tool set or forge an ancestor's delegation. The signing key is held exclusively by the Agent Spawn Controller and is never exposed to agent runtimes.
Step 3: Implement the Agent Spawn Controller
This is the most critical component. Every agent spawn request must pass through the ASC. The controller enforces capability confinement and builds the permission chain node for the new child agent.
class AgentSpawnController:
def __init__(self, tenant_registry: Dict[str, TenantToolManifest], signing_key: str):
self.tenant_registry = tenant_registry
self.signing_key = signing_key
def spawn_child_agent(
self,
parent_chain: PermissionChain,
parent_node_id: str,
requested_tools: Set[str],
new_agent_id: str,
) -> PermissionChain:
parent_node = parent_chain.nodes[parent_node_id]
tenant_id = parent_node.tenant_id
manifest = self.tenant_registry[tenant_id]
# Step A: Intersect with tenant manifest (tenant scope isolation)
tenant_allowed = manifest.allowed_tools
requested_after_tenant_filter = requested_tools & tenant_allowed
# Step B: Intersect with parent's granted tools (capability confinement)
parent_granted = parent_node.granted_tools
effective_tools = requested_after_tenant_filter & parent_granted
# Step C: For high-risk tools, require explicit delegation flag
for tool_id in list(effective_tools):
tool_def = manifest.tool_definitions.get(tool_id)
if tool_def and tool_def.requires_explicit_delegation:
if tool_id not in requested_tools:
# Not explicitly requested; strip it out
effective_tools.discard(tool_id)
# Step D: Build and sign the new chain node
new_node_id = str(uuid.uuid4())
node_data = {
"node_id": new_node_id,
"agent_id": new_agent_id,
"tenant_id": tenant_id,
"granted_tools": sorted(list(effective_tools)),
"parent_node_id": parent_node_id,
"depth": parent_node.depth + 1,
"created_at": time.time(),
}
signature = compute_node_signature(node_data, self.signing_key)
new_node = PermissionChainNode(**node_data, signature=signature)
# Step E: Append node to the chain (immutable; no modifications to existing nodes)
updated_nodes = dict(parent_chain.nodes)
updated_nodes[new_node_id] = new_node
return PermissionChain(
chain_id=parent_chain.chain_id,
tenant_id=tenant_id,
root_node_id=parent_chain.root_node_id,
nodes=updated_nodes,
), new_node_id
Notice the three-layer intersection: tenant manifest, parent grants, and explicit delegation check. A tool must pass all three gates to be available to the child agent. This is not optional defense-in-depth; each layer catches a distinct attack vector.
Step 4: Implement the Tool Invocation Interceptor
Granting tools in the chain means nothing if invocations are not also validated at runtime. The TII wraps every tool call and performs chain validation before execution proceeds.
class ToolInvocationInterceptor:
def __init__(self, signing_key: str):
self.signing_key = signing_key
def validate_and_invoke(
self,
tool_id: str,
agent_id: str,
chain: PermissionChain,
current_node_id: str,
tool_fn: callable,
tool_args: dict,
audit_ledger: "AuditLedger",
):
node = chain.nodes.get(current_node_id)
# Validation 1: Node exists and belongs to this agent
if not node or node.agent_id != agent_id:
raise PermissionError(f"Agent {agent_id} has no valid chain node.")
# Validation 2: Verify node signature (tamper detection)
node_data = {
"node_id": node.node_id,
"agent_id": node.agent_id,
"tenant_id": node.tenant_id,
"granted_tools": sorted(list(node.granted_tools)),
"parent_node_id": node.parent_node_id,
"depth": node.depth,
"created_at": node.created_at,
}
expected_sig = compute_node_signature(node_data, self.signing_key)
if node.signature != expected_sig:
audit_ledger.log_tamper_attempt(agent_id, chain.chain_id, tool_id)
raise PermissionError("Permission chain node signature invalid. Possible tampering detected.")
# Validation 3: Tool is in the agent's granted set
if tool_id not in node.granted_tools:
audit_ledger.log_denied_invocation(agent_id, chain.chain_id, tool_id)
raise PermissionError(
f"Agent {agent_id} (depth={node.depth}) is not authorized to invoke tool '{tool_id}'."
)
# Validation 4: Tenant boundary check
if node.tenant_id != chain.tenant_id:
raise PermissionError("Tenant identity mismatch in permission chain. Cross-tenant access blocked.")
# All checks passed: log and invoke
audit_ledger.log_successful_invocation(agent_id, chain.chain_id, tool_id)
return tool_fn(**tool_args)
Step 5: Set Up the Audit Ledger
An append-only audit ledger is non-negotiable in multi-tenant environments. It provides forensic traceability and is the foundation for anomaly detection and compliance reporting.
from enum import Enum
class AuditEventType(str, Enum):
SPAWN = "spawn"
TOOL_INVOKED = "tool_invoked"
TOOL_DENIED = "tool_denied"
TAMPER_DETECTED = "tamper_detected"
class AuditEvent(BaseModel):
event_id: str
event_type: AuditEventType
tenant_id: str
agent_id: str
chain_id: str
tool_id: Optional[str]
depth: Optional[int]
timestamp: float
metadata: dict = {}
class AuditLedger:
def __init__(self):
self._events: List[AuditEvent] = []
def _append(self, event: AuditEvent):
# In production: write to an immutable store (e.g., AWS QLDB, Azure Immutable Blob)
self._events.append(event)
def log_successful_invocation(self, agent_id, chain_id, tool_id):
self._append(AuditEvent(
event_id=str(uuid.uuid4()),
event_type=AuditEventType.TOOL_INVOKED,
tenant_id="", # Populate from chain context
agent_id=agent_id,
chain_id=chain_id,
tool_id=tool_id,
timestamp=time.time()
))
def log_denied_invocation(self, agent_id, chain_id, tool_id):
self._append(AuditEvent(
event_id=str(uuid.uuid4()),
event_type=AuditEventType.TOOL_DENIED,
tenant_id="",
agent_id=agent_id,
chain_id=chain_id,
tool_id=tool_id,
timestamp=time.time()
))
def log_tamper_attempt(self, agent_id, chain_id, tool_id):
self._append(AuditEvent(
event_id=str(uuid.uuid4()),
event_type=AuditEventType.TAMPER_DETECTED,
tenant_id="",
agent_id=agent_id,
chain_id=chain_id,
tool_id=tool_id,
timestamp=time.time(),
metadata={"alert": "CRITICAL"}
))
In production, the audit ledger should write to a cryptographically tamper-evident store. AWS QLDB, Azure Immutable Blob Storage, or a purpose-built blockchain ledger are all valid choices depending on your compliance requirements.
Step 6: Wiring It All Together with a Root Agent Bootstrap
When a tenant's root orchestrator agent is initialized, the pipeline creates the root permission chain node. Every subsequent spawn flows from there.
def bootstrap_root_agent(
tenant_id: str,
agent_id: str,
tenant_registry: Dict[str, TenantToolManifest],
signing_key: str,
) -> tuple[PermissionChain, str]:
manifest = tenant_registry[tenant_id]
root_node_id = str(uuid.uuid4())
chain_id = str(uuid.uuid4())
node_data = {
"node_id": root_node_id,
"agent_id": agent_id,
"tenant_id": tenant_id,
"granted_tools": sorted(list(manifest.allowed_tools)),
"parent_node_id": None,
"depth": 0,
"created_at": time.time(),
}
signature = compute_node_signature(node_data, signing_key)
root_node = PermissionChainNode(**node_data, signature=signature)
chain = PermissionChain(
chain_id=chain_id,
tenant_id=tenant_id,
root_node_id=root_node_id,
nodes={root_node_id: root_node}
)
return chain, root_node_id
# Example: spawning a child agent that only gets read_database and write_report
asc = AgentSpawnController(tenant_registry={"tenant_a": tenant_a_manifest}, signing_key="super-secret-key")
root_chain, root_node_id = bootstrap_root_agent("tenant_a", "orchestrator-001", {"tenant_a": tenant_a_manifest}, "super-secret-key")
child_chain, child_node_id = asc.spawn_child_agent(
parent_chain=root_chain,
parent_node_id=root_node_id,
requested_tools={"read_database", "write_report"}, # Deliberately NOT delegating call_external_api
new_agent_id="research-agent-002",
)
# The research agent can now only use read_database and write_report
# Even if a prompt injection tells it to call call_external_api, the TII will deny it
Handling Edge Cases and Advanced Scenarios
Maximum Depth Limits
Unbounded agent recursion is itself a denial-of-service vector. Enforce a configurable max_depth per tenant in the ASC. When a spawn request would exceed the depth limit, reject it and log the attempt. A sensible default for most enterprise workflows is a depth of 5 to 8.
Temporary Permission Grants (Time-Scoped Delegation)
Some workflows legitimately need a child agent to briefly use a high-risk tool. Support time-scoped delegation by adding valid_until: Optional[float] to PermissionChainNode. The TII checks this timestamp before every invocation and revokes the tool automatically when it expires. This is far safer than granting persistent access.
Cross-Tenant Collaboration Workflows
Occasionally, two tenants need to share data through a jointly orchestrated workflow. The correct pattern here is never to merge permission chains. Instead, use a dedicated bridge agent that holds its own minimal, explicitly scoped permission chain and acts as the sole intermediary. Each tenant's agent hierarchy terminates at the bridge; neither side can see the other's chain.
Prompt Injection Resistance
The permission chain is your last line of defense against prompt injection attacks that attempt to escalate privileges at runtime. Because the chain is signed and validated at the infrastructure layer (the TII), no amount of adversarial content in a tool's response can grant additional permissions. The LLM runtime is explicitly untrusted with respect to permission decisions.
Testing Your Pipeline
A permission system is only as good as its test coverage. Write explicit tests for each of these scenarios:
- Happy path: A child agent successfully invokes a tool it was explicitly delegated.
- Confinement enforcement: A child agent is denied a tool its parent holds but did not delegate.
- Tenant isolation: An agent with a valid chain for Tenant A cannot invoke tools from Tenant B's manifest, even if the tool IDs are identical.
- Tamper detection: A node whose
granted_toolsfield has been modified post-signing is rejected by the TII. - Depth limit enforcement: A spawn request that would create a node at depth 9 (when max is 8) is rejected.
- Expired time-scoped grant: A tool with a
valid_untilin the past is denied even if it appears ingranted_tools.
Deployment Considerations for Production
When moving this architecture to production, keep these operational realities in mind:
- Key management: The ASC signing key must be stored in a hardware security module (HSM) or a secrets manager like HashiCorp Vault or AWS Secrets Manager. Rotation should be automated and zero-downtime.
- Chain storage: For long-running workflows, serialize and store permission chains in a fast, consistent key-value store (Redis with persistence, or DynamoDB). Never store them in agent memory alone.
- Observability: Pipe your audit ledger events into your SIEM (Splunk, Datadog, etc.) and set alerts for
TAMPER_DETECTEDevents and unusual spikes inTOOL_DENIEDevents, which can signal probing behavior. - Framework integration: If you are using LangGraph or a similar framework, wrap the ASC as a custom node type and the TII as a tool decorator. This keeps the security logic orthogonal to business logic.
Conclusion
Building a per-tenant AI agent tool permission inheritance pipeline is not glamorous work, but in 2026, it is table stakes for any enterprise-grade agentic system. The combination of capability confinement, tenant scope isolation, explicit delegation, and immutable signed chains gives you a mathematically sound guarantee that no child agent in any nested hierarchy can exceed the permissions its lineage explicitly authorized.
The architecture described here is intentionally framework-agnostic. Whether you are orchestrating agents with LangGraph, AutoGen, a custom Python runtime, or a cloud-native agent service, the five components (TTR, ASC, PCB, TII, and Audit Ledger) map cleanly onto any stack. The key insight to carry forward is this: treat your permission system as infrastructure, not application logic. It should be invisible to the agents themselves, enforced at the layer below them, and completely immune to anything an LLM might output.
The agents of 2026 are powerful enough to cause real damage when misconfigured. Build the guardrails before you build the workflows.