Skip to content

Policy → sandbox config builder

What it does: Reads a policy and produces the per-layer config one sandbox runs against.

When an operator authors policy via the intent-to-policy flow, the runtime today reads that policy directly at every call. That works for chat (one process, stateless). It does not work for hosted agents — agents that run for hours on customer machines, each needing its own derived config, all needing to stay current as policy changes.

This component is the bridge: policy in → multi-layer config out, deterministically, ready to be delivered to a host machine and applied at five enforcement layers. The output (a SandboxConfig, sometimes called "the keyring") is the artifact that connects "policy authored" to "policy applied at every chokepoint." See concepts/governed-agents-architecture.md §10 for full vision context.

Inputs:

NameTypeDescription
pipeline_configdictThe L4 effective pipeline output of the intent-to-policy resolver
sandbox_specdictThe named template (e.g. customer-support-emailer) — selects which policy applies and which layers are enabled
cascade_levelslistOrdered: [org_policy_id, project_policy_id, agent_policy_id]. Any may be null.
tool_universelistThe set of tools known to the catalog (curated + per-tenant) at derivation time
org_idstrFor per-org cache keying and AAD scoping

Outputs: a SandboxConfig object:

@dataclass
class SandboxConfig:
# identity
sandbox_id: str
sandbox_name: str
policy_version: int # monotonic per sandbox
sync_url: str
# 5 enforcement layers
layer_5_gateway: GatewayLayerConfig # gateway_url, gateway_token, capabilities
layer_4_mcp: McpLayerConfig # mcp_proxy_url, mcp_session_token, pipeline, capabilities
layer_3_codemode: CodemodeLayerConfig # allowed_imports, denied_imports, executor_network
layer_2_harness: HarnessLayerConfig # permissions, hooks
layer_1_kernel: KernelLayerConfig # network_egress_allowlist, fs rules, landlock_rules
# provenance — every rule traces back to a cascade level + concern + category
provenance: dict[str, list[ProvenanceEntry]]
# validity
issued_at: datetime
expires_at: datetime

Error cases:

ConditionBehavior
Cascade contradiction (project tries to relax org floor)Raise CascadeViolationError at apply time; no SandboxConfig produced
sandbox_spec references unknown layersRaise UnknownLayerError; refuse to produce partial config
pipeline_config references tools not in tool_universeEmit warning; bind to unknown_tool capability bucket; flag for review
Forbidden capability appears in agent overrideRaise ForbiddenCapabilityViolation; floor cannot be lifted
Cache stale (policy_store written since last derivation)Invalidate; re-derive on next request

Non-goals:

  • Delivering the config to the host (that's live-policy-push-channel)
  • Enforcing the config (that's the layer appliers in q09-rings-and-cross-cutting/)
  • Authoring the policy (that's intent-to-policy, already shipped)
  • Choosing the sandbox-spec (that's the operator's decision via wizard or tappass sandbox-spec create)
tappass/policy/sandbox_config_builder/
├── __init__.py
├── derive.py # the core derivation function (~150 LOC)
├── cascade.py # strictest-wins merge (~80 LOC, reuses intent-to-policy resolver helpers)
├── layer_emitters/
│ ├── gateway.py # pipeline_config → GatewayLayerConfig
│ ├── mcp.py # pipeline_config + tool_universe → McpLayerConfig
│ ├── codemode.py # pipeline_config → CodemodeLayerConfig
│ ├── harness.py # pipeline_config → HarnessLayerConfig
│ └── kernel.py # pipeline_config → KernelLayerConfig
├── provenance.py # threads "because" trail through every emit
├── cache.py # per-org, version-keyed; invalidated on policy_store write
└── schema/
└── sandbox-config.schema.json
pipeline_config + sandbox_spec + cascade_levels
[cascade.merge() — strictest-wins, validates floors]
[per-layer emitters run in parallel]
[provenance map built from emit trails]
[issued: SandboxConfig with monotonic policy_version + provenance]
[cached per (org_id, sandbox_id, policy_version_set)]
  • Reads from policy_store (intent-to-policy substrate, live on main).
  • Reads from tool_catalog (intent-to-policy + runtime-tool-discovery components).
  • Reads from vault_llm_keys, vault_mcp_servers (existing + new in this concept).
  • Writes to sandbox_keyring_store (new table in this concept).
  • Triggers live-policy-push-channel to push the new config to active sandboxes.
  • Emits audit events: policy_intent_applied, cascade_violation, sandbox_config_derived.

Reuses the merge primitives from intent-to-policy.md §10b (strictest-wins). New here: cascade-level awareness, so a project rule that would relax an org rule is rejected rather than merged.

def merge_cascade(
org: PipelineConfig | None,
project: PipelineConfig | None,
agent: PipelineConfig | None,
) -> PipelineConfig:
"""Strictest-wins merge with floor enforcement.
Each level can ADD rules or make existing rules STRICTER.
No level can RELAX rules from a level above it.
"""
floor = org or empty_pipeline()
if project:
validate_does_not_relax(floor, project) # raises CascadeViolationError
floor = strictest_wins_merge(floor, project)
if agent:
validate_does_not_relax(floor, agent)
floor = strictest_wins_merge(floor, agent)
return floor

Every rule in the output carries a ProvenanceEntry:

@dataclass
class ProvenanceEntry:
rule_id: str # e.g. "step:detect_pii"
introduced_by: Literal["org", "project", "agent", "compliance_pack", "manual"]
via_cascade_level: str | None # e.g. "org" | "project:eu-team" | "agent:ag_8cAxWM4H"
via_concern: str | None # e.g. "data_leak"
via_category: str | None # e.g. "customer_pii"
via_compliance_pack: str | None # e.g. "eu-ai-act"
operator: str # who introduced it (SSO identity)
at: datetime

The dashboard's because-trail (architecture §5.2) reads from this.

  • All acceptance_criteria pass.
  • Property test: 1000 random (pipeline_config, sandbox_spec, cascade_levels) triples produce identical output across runs.
  • Property test: cascade order doesn't matter (merge(merge(o,p), a) == merge(o, merge(p, a))).
  • Integration test: org-level eu-ai-act pack applied → every derived sandbox in that org has the EU AI Act floor in provenance.
  • Integration test: project tries to relax org floor → CascadeViolationError raised at apply, no keyring written, audit event recorded.
  • Performance: derivation completes in <100ms for typical inputs (P50), <500ms (P99).
  • Cache: per-org cache invalidated on policy_store write; subsequent reads see fresh derivation.
  • Audit: policy_intent_applied emitted with full provenance for every derivation.
  • Spec section in concepts/tappass-keyring-and-sync.md written and reviewed.
  • JSON schema sandbox-config.schema.json published; downstream layer appliers consume it.

We need the async trust engine for token issuance during derivation. This must close in the first two weeks of Q3 or this component blocks. Owner of trust-engine-async: existing platform team per memory project_async_deadlock.md.

We co-own the cascade merge logic. Lean: cascade-merge-engine is the authoritative implementation of validate_does_not_relax and strictest_wins_merge; this builder calls into it. Avoid duplicating merge logic in two places.

We emit; they apply. The contract is sandbox-config.schema.json. Every layer applier must conform to its slice's schema. Schema changes require coordination with all five appliers (and the upstream-tool-proxy that consumes layer-4).

We trigger pushes on derivation. Push channel reads from sandbox_keyring_store. We should never push directly — the channel owns delivery.

The evaluator derives temporary SandboxConfigs for evaluation runs (not persisted). We expose derive(...) as a pure function (no side effects, no DB write, no audit emit) when called with mode=evaluation. Production calls use mode=production which persists + emits.

  • (Q1) Provenance storage. Do we store the full provenance map alongside every keyring (storage cost), or recompute it on demand for the dashboard (compute cost)? Lean: store. Storage is cheap; the because-trail must be instantly readable.
  • (Q2) Cascade re-derive triggers. When org policy changes, do we re-derive every sandbox in the org synchronously (could be thousands), or queue and process async? Lean: queue + process async with a strict TTL on the old config (5 min via expires_at); incoming sync pushes happen in priority order (most-active sandboxes first). Owner: this component + push channel coordinate.
  • (Q3) Backward compatibility for existing pipeline_config. Today's runtime reads pipeline_config directly; we add a new derivation step that produces SandboxConfig. Do we ship both code paths during transition, or rip-and-replace per feedback_no_backcompat_saas? Lean: rip-and-replace; existing runtime starts reading SandboxConfig exclusively at GA.
  • Delivery. Pushing the config to a host is live-policy-push-channel.
  • Application. Each layer's applier in q09-rings-and-cross-cutting/ takes the config and makes it real.
  • Authoring. Operators write policy via intent-to-policy and compliance packs; we consume.
  • Evaluation. pre-deployment-evaluator calls us in evaluation mode; we don't run probes ourselves.
  • Provider routing. The gateway's gateway_token is opaque to us — we issue it; the gateway interprets.
  • Capability token signing. ES256 happens in tappass/gateway/ — we just include the issued tokens in the layer-5 slice.