Control¶
The admission gate: decide whether a consequential action may run, and record the decision either way.
tulip.control is the domain-neutral surface. The implementations live under
tulip.security for historical reasons — that is where the layer grew up —
and are re-exported here, which is the import path to use.
For the concepts, start with The control layer and Writing a policy that holds.
Admitting an action¶
admit() evaluates the policy, records the decision on the audit trail, and
runs the action only if it was allowed. A held or denied action raises
AdmissionError carrying the ApprovalDecision that explains why.
admit
async
¶
admit(action: Action, perform: Callable[[], Awaitable[T]], *, policy: ControlPolicy, finding: Evidence | None = None, verdict: VerificationResult | None = None, trail: AuditTrail | None = None) -> T
Run perform only if action clears the trust chain; else reject.
The mandatory gate that turns the composable chain into an enforced one:
- :func:
~tulip.security.policy.approveweighs the action against the evidence (finding), the verification (verdict), and thepolicy. - The decision is recorded to
trail(if given) — admitted or not — so no side effect is un-audited. - On ALLOW,
performis awaited and its result returned. On require_human or deny, :class:AdmissionErroris raised with the decision attached.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action
|
Action
|
The proposed side-effecting action. |
required |
perform
|
Callable[[], Awaitable[T]]
|
A zero-arg async callable that performs the side effect. |
required |
policy
|
ControlPolicy
|
The governing :class: |
required |
finding
|
Evidence | None
|
The evidence the action responds to. |
None
|
verdict
|
VerificationResult | None
|
The :func: |
None
|
trail
|
AuditTrail | None
|
An :class: |
None
|
Returns:
| Type | Description |
|---|---|
T
|
Whatever |
Raises:
| Type | Description |
|---|---|
AdmissionError
|
if the action is not admitted (require_human or deny). |
Source code in .sdk/src/tulip/security/admit.py
AdmissionError ¶
Bases: Exception
A side-effecting action failed admission — it did not clear the trust chain.
Carries the :class:~tulip.security.policy.ApprovalDecision so the caller can
route a require_human hold to an approver or surface a deny reason.
Source code in .sdk/src/tulip/security/admit.py
Deciding¶
approve() is the pure decision function — no I/O, no side effects. It takes
an action and a policy and returns the outcome. Rules combine by taking the
strongest result, so deny beats require_human beats allow.
approve ¶
approve(action: Action, *, policy: ControlPolicy, finding: Evidence | None = None, verdict: VerificationResult | None = None, advisor: ControlAdvisor | None = None) -> ApprovalDecision
Decide whether action may proceed: allow / require_human / deny.
Weighs every rule and returns the strongest triggered outcome (deny > require_human > allow), recording each check that fired so the decision is auditable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action
|
Action
|
The proposed action. |
required |
policy
|
ControlPolicy
|
The governing :class: |
required |
finding
|
Evidence | None
|
The evidence the action responds to (optional). |
None
|
verdict
|
VerificationResult | None
|
The :func: |
None
|
advisor
|
ControlAdvisor | None
|
An optional trained control model. It may only raise the
decision toward caution — see :func: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
ApprovalDecision
|
class: |
Source code in .sdk/src/tulip/security/policy.py
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
ControlPolicy
dataclass
¶
ControlPolicy(require_verification_score: float = 0.8, max_blast_radius: int = 1, require_human_for: frozenset[str] = (lambda: frozenset({'production'}))(), deny_for: frozenset[str] = frozenset(), min_severity: Severity = Severity.LOW, require_sandbox_for: frozenset[str] = frozenset())
The CISO knobs. Defaults are conservative — auto-allow only the safe path.
require_verification_score: minimum :class:VerificationResultconfidence to auto-allow; below it (or with no verdict) a human is required.max_blast_radius: most assets an action may affect to auto-allow.require_human_for: action labels (environment / kind / tag) that always need a human (default: anything inproduction).deny_for: labels that are hard-denied outright.min_severity: don't act on findings below this band.require_sandbox_for: labels whose actions must execute in a sandbox — an action matching one of these is denied unless it carries the :data:SANDBOXED_TAGtag. Enforced at the agent loop's tool seam by :class:~tulip.tools.sandbox.SandboxEnforcerHook.
ApprovalDecision
dataclass
¶
ApprovalDecision(outcome: str, reason: str, action: Action, checks: list[str] = list(), policy_outcome: str = ApprovalOutcome.ALLOW, model_outcome: str | None = None)
The outcome of weighing an action against evidence, verification, and policy.
escalated_by_model
property
¶
Whether a control model made this decision stricter than policy alone.
ApprovalOutcome ¶
Outcome labels (kept simple/stable as plain strings).
Describing an action¶
A policy matches on what an action is — its environment, kind, blast radius, and tags — never on the name of the tool performing it.
Action
dataclass
¶
Action(name: str, asset: str = '', blast_radius: int = 1, environment: str = 'unknown', kind: str = '', tags: frozenset[str] = frozenset())
A proposed response action, with the attributes policy reasons over.
labels ¶
Deriving action labels¶
Turn a tool call into an Action using declarative rules, so the labels a
policy matches on are not hand-written per call site.
resolve_action ¶
Resolve an :class:ActionSpec (or None) into a concrete :class:Action.
Source code in .sdk/src/tulip/control/action.py
default_action ¶
default_action(name: str, kwargs: Mapping[str, Any], *, environment: str = 'unknown', kind: str = '', blast_radius: int = 1, tags: frozenset[str] | None = None) -> Action
A conservative :class:Action for name when none was supplied.
Fail-safe by construction: environment="unknown" plus the stock
:class:~tulip.security.policy.ControlPolicy (which requires a verification
score) lands an un-verified call on require_human rather than
auto-allowing it.
tags defaults to the action's own name, so a policy can always gate one
specific tool by naming it — the one thing that worked before labels were
derived at all.
Source code in .sdk/src/tulip/control/action.py
action_from_labels ¶
action_from_labels(name: str, kwargs: Mapping[str, Any], *, labels: Mapping[str, Any] | None = None, environment: str | None = None, blast_radius: int = 1) -> Action
Build an :class:Action from a tool's declared labels.
labels is what a tool definition declares about the actions it performs
— environment, kind, blast_radius, tags. Anything absent
falls back: the caller's environment (the agent's, or the deployment's),
then "unknown".
The tool's own name is always among the tags, so naming a tool in
require_human_for keeps working regardless of what it declares.
labels["derive"] may carry argument-derived rules (see
:func:derive_labels) — the only part of this that reads kwargs for
labelling. Derived tags join the declared ones, a derived set_kind /
set_environment wins over the declared value (it describes this call),
and a derived blast radius only ever raises the declared one. With no
derive key the result is exactly what it was before.
Source code in .sdk/src/tulip/control/action.py
derive_labels ¶
Evaluate a tool's derive rules against one call's arguments.
Declarative and total: comparisons only, never eval, never a callable,
so nothing a tool receives can execute during labelling. Rules apply in
order and all matching rules apply. Anything that cannot be evaluated — a
missing argument, an argument of the wrong type, a malformed rule — is
skipped and records :data:UNDETERMINED_TAG, so "we could not tell"
reaches the policy as a fact rather than as silence.
Source code in .sdk/src/tulip/control/action.py
DerivedLabels ¶
Accumulator for what a derive list adds to an action.
Source code in .sdk/src/tulip/control/action.py
raise_radius ¶
Deriving may raise the blast radius; it may never lower it.
asset_from_args ¶
Best-effort asset label from a tool call's arguments.
The record¶
A hash-chained log of every decision. Each record commits to the previous
hash, so editing any record breaks verify().
Tamper-evident, not tamper-proof
This is a keyless SHA-256 chain held in memory. It detects edits when checked against a head hash you retain out-of-band; it does not prevent them, sign them, or anchor the log. Persist the JSONL and pin the head hash externally before relying on it as compliance evidence.
AuditTrail ¶
An append-only, hash-chained log of agent actions.
Append with :meth:record (or :meth:record_event for a Tulip event);
check integrity with :meth:verify; ship with :meth:export_jsonl.
Pass clock to make timestamps deterministic in tests.
Source code in .sdk/src/tulip/security/audit.py
record ¶
Append a record committing to the current chain head.
Source code in .sdk/src/tulip/security/audit.py
record_event ¶
Append a record for a Tulip event (duck-typed; safe scalar fields).
Source code in .sdk/src/tulip/security/audit.py
records ¶
verify ¶
Whether the chain is intact — no edit, deletion, or reorder.
Source code in .sdk/src/tulip/security/audit.py
export_jsonl ¶
The chain as newline-delimited JSON — one record per line, SIEM-ready.
from_records
classmethod
¶
Rebuild a trail from records (e.g. to :meth:verify an exported chain).
AuditRecord
dataclass
¶
One link in the audit chain. hash commits to prev_hash.
AuditHook ¶
Bases: HookProvider
Records the agent's lifecycle into a tamper-evident :class:AuditTrail.
Source code in .sdk/src/tulip/security/secure.py
on_iteration_start
async
¶
Called at the start of each agent iteration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
iteration
|
int
|
Current iteration number (0-indexed) |
required |
state
|
AgentState
|
Current agent state |
required |
Source code in .sdk/src/tulip/hooks/provider.py
on_iteration_end
async
¶
Called at the end of each agent iteration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
iteration
|
int
|
Current iteration number (0-indexed) |
required |
state
|
AgentState
|
Current agent state |
required |
Source code in .sdk/src/tulip/hooks/provider.py
on_before_model_call
async
¶
Called before each model.complete() call.
Modify event.messages to change what the model sees. event.tools is read-only (inspect only).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
BeforeModelCallEvent
|
Write-protected event. Writable: messages. |
required |
Source code in .sdk/src/tulip/hooks/provider.py
on_after_model_call
async
¶
Called after each model.complete() call.
Set event.retry = True to discard response and re-call. Set event.response to replace the response. event.messages is read-only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event
|
AfterModelCallEvent
|
Write-protected event. Writable: response, retry. |
required |
Source code in .sdk/src/tulip/hooks/provider.py
Governed agents¶
An Agent pre-wired with grounding, guardrails, and an audit trail.
governed_agent ¶
governed_agent(model: Any = None, tools: list[Any] | None = None, *, system_prompt: str | None = None, profile: GovernanceProfile | None = None, audit_trail: AuditTrail | None = None, hooks: list[Any] | None = None, **kwargs: Any) -> GovernedAgent
Build a secure-by-default agent: grounded, guarded, and audited.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Any
|
Model string or instance (as :class: |
None
|
tools
|
list[Any] | None
|
Tools available to the agent. |
None
|
system_prompt
|
str | None
|
System prompt. |
None
|
profile
|
GovernanceProfile | None
|
Which controls to enable (default: all on). |
None
|
audit_trail
|
AuditTrail | None
|
Reuse an existing trail; one is created if omitted. |
None
|
hooks
|
list[Any] | None
|
Extra hooks to add alongside the security hooks. |
None
|
**kwargs
|
Any
|
Passed through to :class: |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
GovernedAgent
|
class: |
Source code in .sdk/src/tulip/security/secure.py
GovernedAgent
dataclass
¶
A secure-by-default :class:tulip.Agent plus its audit trail.
run / run_sync pass through to the wrapped agent; audit_trail
is the tamper-evident record of everything it did.
arun
async
¶
Async, thread-free twin of run_sync — delegates to the wrapped
agent's arun so a governed agent runs where threads aren't
available (e.g. the browser / Pyodide).
Source code in .sdk/src/tulip/security/secure.py
GovernanceProfile
dataclass
¶
Which secure-by-default controls a :func:governed_agent turns on.
All on by default — that is what makes the agent secure out of the box.
Verification¶
Evidence quality and adversarial refutation, feeding the
require_verification_score and min_severity rules on a policy.
verify
async
¶
verify(finding: FindingLike, *, skeptics: Sequence[Skeptic] | None = None, threshold: float = 0.6) -> VerificationResult
Independently challenge a finding; return whether it survives.
Runs each skeptic (default: a single :class:EvidenceQualitySkeptic),
collects their refutations, and re-grades confidence as the grounding score
minus the refutation penalties — where non-fatal penalties are capped
(:data:_MAX_NONFATAL_PENALTY) so volume of caveats alone can't refute a
well-grounded finding; a single fatal refutation zeroes it outright. A
finding survives only if nothing fatal was raised and confidence clears
threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
finding
|
FindingLike
|
A :class: |
required |
skeptics
|
Sequence[Skeptic] | None
|
The challenge panel; defaults to the deterministic skeptic. Plug semantic/LLM skeptics here. |
None
|
threshold
|
float
|
Minimum confidence to survive (default 0.6). |
0.6
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
VerificationResult
|
class: |
Source code in .sdk/src/tulip/security/verify.py
VerificationResult
dataclass
¶
VerificationResult(survives: bool, confidence: float, evidence_quality: float, refutations: list[Refutation] = list(), alternatives: list[str] = list(), notes: str = '')
The outcome of verifying a finding.
survives is False if any refutation is fatal or confidence falls
below the threshold. alternatives is populated by semantic skeptics
(the deterministic one leaves it empty).
Evidence ¶
Bases: BaseModel
A grounded security finding.
The gsar_score and evidence_refs fields are required: a
Evidence always knows how strongly it is grounded and what it is
grounded in. Build findings via :func:tulip.security.ground_finding
rather than constructing them directly — that is the path that
enforces the grounding threshold.
Severity ¶
Bases: StrEnum
Ordered severity band. StrEnum so it serialises as the bare string.
Not directly comparable with < (string ordering would be wrong);
use :func:severity_at_least or :data:SEVERITY_ORDER for ranking.