Proposal: Folder-Level Governance Policy Scoping¶
Status: Draft Author: Imran Siddique Issue: #1348 Created: 2026-04-23
Summary¶
Add path-scoped policy file discovery with inheritance to AGT's PolicyEvaluator, enabling different governance rules for different directories within a monorepo. Modeled after EditorConfig/ESLint cascade patterns and informed by Azure Policy/AWS SCP inheritance semantics.
Motivation¶
AGT currently loads policies from a single flat directory. In monorepos and large codebases, different projects need different governance rules:
services/billing/needs strict PII rules and financial tool restrictionsservices/docs/needs permissive web search but no code executioninternal-tools/inherits the org baseline with no overrides
Internal teams (Azure Core CTO org) and enterprise customers have requested this capability.
Prior Art Analysis¶
| System | Discovery | Merge Strategy | Override | Stop Inheritance |
|---|---|---|---|---|
| EditorConfig | Walk up from file to root | Nearest matching property wins | Child overrides parent per-property | root = true |
| ESLint | Walk up, load configs | Parent โ child, child overrides same keys | Per-rule override | root: true |
| .gitignore | Walk down from root | Later/deeper wins | Deeper file beats parent | None (ignored dirs block descent) |
| Azure Policy | Management group โ subscription โ resource group | All evaluated, most restrictive wins | Child cannot loosen parent deny | notScopes, exemptions |
| AWS SCPs | Org root โ OU โ account | Intersection of permissions | Child cannot expand parent | Detach/relocate |
| Kubernetes Gatekeeper | Namespace, labels, selectors | All matching constraints evaluated | Any deny blocks | excludedNamespaces |
Design Choice Rationale¶
AGT is a security governance system, so we adopt the security-first patterns from Azure Policy and Kubernetes:
- Deny wins over allow โ a parent deny cannot be overridden by a child allow (matches Azure Policy)
- Additive rules โ child policies add rules on top of parent, they don't replace the parent rule set
- Explicit override by name โ a child can override a parent rule only by declaring the same rule name with
override: true - Stop inheritance โ
inherit: falsein a child policy prevents loading any parent policies (like ESLintroot: true)
Specification¶
File Discovery¶
The policy file name is governance.yaml (or .yml). Discovery walks up from the action's path to the repository root:
repo/
governance.yaml โ Level 0 (root baseline)
services/
governance.yaml โ Level 1
billing/
governance.yaml โ Level 2 (most specific)
agent.py โ Action occurs here
Algorithm:
def discover_policies(action_path: Path, root: Path) -> list[Path]:
"""Walk up from action_path to root, collecting governance.yaml files.
Returns policies in root-first order (root at index 0, most specific last).
Stops early if a policy declares 'inherit: false'.
"""
policies = []
current = action_path.parent if action_path.is_file() else action_path
while True:
for name in ("governance.yaml", "governance.yml"):
candidate = current / name
if candidate.exists():
policies.append(candidate)
break
if current == root or current == current.parent:
break
current = current.parent
# Reverse: root first, most specific last
policies.reverse()
# Check for 'inherit: false' โ stop loading at that boundary
final = []
for p in reversed(policies): # Walk from most specific to root
final.insert(0, p)
doc = load_policy_header(p)
if doc.get("inherit") is False:
break # Don't include anything above this level
return final
Schema Extension¶
Add two optional fields to PolicyDocument:
# governance.yaml
name: billing-service-policy
version: "1.0"
inherit: true # NEW: default true. Set to false to stop parent loading.
scope: "services/billing/**" # NEW: optional glob โ only applies to matching paths
rules:
- name: block-pii-tools
condition:
field: tool_name
operator: in
value: [export_pii, bulk_download]
action: deny
message: "PII export tools blocked in billing service"
priority: 200
override: false # NEW: default false. Set to true to override a parent rule with same name.
New fields:
| Field | Type | Default | Description |
|---|---|---|---|
inherit | bool | true | If false, parent policies are not loaded (like ESLint root: true) |
scope | str \| null | null | Glob pattern โ policy only applies when action path matches |
PolicyRule.override | bool | false | If true and a parent rule has the same name, replaces the parent rule |
Merge Algorithm¶
Given policies [root, level1, level2] (root-first order):
def merge_policies(policy_chain: list[PolicyDocument]) -> list[PolicyRule]:
"""Merge a chain of policies into a flat, priority-sorted rule list.
Rules:
1. All rules from all levels are collected
2. If a child rule has override=true and shares a name with a parent rule,
the parent rule is removed
3. Deny rules CANNOT be overridden (security invariant)
4. Rules are sorted by priority descending (highest first)
5. First matching rule wins (existing AGT behavior)
"""
# Collect all rules with their source level
rules_by_name: dict[str, tuple[PolicyRule, int]] = {} # name โ (rule, level)
all_rules: list[PolicyRule] = []
for level, doc in enumerate(policy_chain):
for rule in doc.rules:
existing = rules_by_name.get(rule.name)
if existing and rule.override:
parent_rule, parent_level = existing
# Security invariant: deny cannot be overridden
if parent_rule.action == PolicyAction.DENY:
# Keep parent deny, also add child rule (both evaluated)
all_rules.append(rule)
else:
# Replace parent rule with child override
all_rules = [r for r in all_rules if r.name != rule.name]
all_rules.append(rule)
rules_by_name[rule.name] = (rule, level)
else:
all_rules.append(rule)
if rule.name not in rules_by_name:
rules_by_name[rule.name] = (rule, level)
# Sort by priority descending
all_rules.sort(key=lambda r: r.priority, reverse=True)
return all_rules
Security Invariants¶
-
Parent deny is immutable โ A child policy cannot override a parent
denyrule. This matches Azure Policy semantics where a management-group-level deny cannot be loosened at the subscription level. -
No scope escalation โ A child policy cannot widen its scope beyond its directory.
scope: "../../**"is rejected at load time. -
Audit trail includes chain โ Every
PolicyDecisionrecords the full policy chain that was evaluated, not just the matching rule. This enables compliance teams to understand why a decision was made and which levels contributed. -
Fast path for flat repos โ If no nested
governance.yamlfiles exist (only root), the evaluator uses the existing flat code path with zero overhead.
Integration with PolicyEvaluator¶
class PolicyEvaluator:
def __init__(self, policies=None, root_dir=None):
self.policies = policies or []
self.root_dir = Path(root_dir) if root_dir else None
self._backends = []
def evaluate(self, context: dict[str, Any]) -> PolicyDecision:
"""Evaluate policies. If root_dir is set and context contains 'path',
use folder-scoped policy discovery. Otherwise, use flat evaluation."""
if self.root_dir and "path" in context:
action_path = Path(context["path"])
chain = discover_policies(action_path, self.root_dir)
docs = [PolicyDocument.from_yaml(p) for p in chain]
merged_rules = merge_policies(docs)
return self._evaluate_rules(merged_rules, context)
# Existing flat evaluation (no breaking change)
return self._evaluate_flat(context)
Key design decision: Path-scoped evaluation is opt-in via root_dir. Existing users who don't set root_dir get exactly the current behavior with no performance impact.
Example: Monorepo Setup¶
acme-corp/
โ
โโโ governance.yaml # Org baseline
โ name: acme-baseline
โ rules:
โ - name: block-shell-exec
โ condition: { field: tool_name, operator: eq, value: shell_exec }
โ action: deny
โ priority: 1000
โ - name: require-audit
โ condition: { field: action_type, operator: eq, value: tool_call }
โ action: audit
โ priority: 50
โ
โโโ services/
โ โโโ billing/
โ โ โโโ governance.yaml # Billing overrides
โ โ name: billing-policy
โ โ rules:
โ โ - name: block-pii-export
โ โ condition: { field: tool_name, operator: in, value: [export_pii, bulk_download] }
โ โ action: deny
โ โ priority: 900
โ โ - name: require-audit # Override parent audit โ deny
โ โ condition: { field: action_type, operator: eq, value: tool_call }
โ โ action: deny
โ โ override: true
โ โ priority: 50
โ โ message: "All tool calls require explicit approval in billing"
โ โ
โ โโโ docs/
โ โ โโโ governance.yaml # Docs: permissive
โ โ name: docs-policy
โ โ rules:
โ โ - name: allow-web-search
โ โ condition: { field: tool_name, operator: eq, value: web_search }
โ โ action: allow
โ โ priority: 100
โ โ
โ โโโ sandbox/
โ โโโ governance.yaml # Sandbox: isolated
โ name: sandbox-policy
โ inherit: false # Don't inherit org baseline
โ rules:
โ - name: allow-all
โ condition: { field: action_type, operator: eq, value: tool_call }
โ action: allow
โ priority: 1
Evaluation examples:
| Action Path | Effective Policies | Result for shell_exec |
|---|---|---|
services/billing/agent.py | baseline + billing | DENY (parent block-shell-exec, priority 1000) |
services/docs/agent.py | baseline + docs | DENY (parent block-shell-exec, priority 1000) |
services/sandbox/agent.py | sandbox only | ALLOW (inherit: false, only sandbox rules apply) |
lib/utils.py | baseline only | DENY (baseline block-shell-exec) |
Performance Considerations¶
- Caching: Policy files are cached by path with mtime-based invalidation. The discovery walk is cached per-directory.
- Fast path: If
root_diris not set, zero overhead โ existing flat evaluation used. - Lazy loading: Policy files are loaded on first evaluation for a given path, not at startup.
- Merged rule cache: After merging a policy chain, the resulting rule list is cached until any file in the chain changes.
Migration Path¶
- No breaking changes โ existing
PolicyEvaluator()withoutroot_dirworks identically - Opt-in: Set
root_dirto enable folder-scoped discovery - Gradual adoption: Start with a root
governance.yaml, add folder-level policies as needed
Alternatives Considered¶
A. OPA-style package namespacing¶
Each folder defines an OPA package, and policies are scoped by package name. Rejected: Requires OPA knowledge, doesn't work with AGT's native YAML evaluator, and adds complexity for simple cases.
B. Single file with path globs¶
One governance.yaml with rules that include path conditions. Rejected: Doesn't scale for large monorepos โ single file becomes unwieldy and hard to review. Also doesn't allow teams to own their policies independently.
C. Config inheritance via extends field¶
Child policies explicitly name their parent (like TypeScript tsconfig.json). Rejected: Filesystem-based discovery is simpler, more intuitive, and doesn't require knowing the parent's path or name.
Implementation Plan¶
- Schema extension โ Add
inherit,scope,overridefields toPolicyDocumentandPolicyRule - Discovery module โ
agent_os.policies.discoverywithdiscover_policies()function - Merge module โ
agent_os.policies.mergewithmerge_policies()function - Evaluator integration โ Add
root_dirparameter toPolicyEvaluator - Tests โ Unit tests for discovery, merge, security invariants, fast path, caching
- Tutorial โ New tutorial: "Folder-Level Governance for Monorepos"
- CLI โ
agent-governance validate --root .validates all governance.yaml files in the tree