Skip to content

Commit 4612344

Browse files
feat(security): data-label enforcement end-to-end + HITL authorization + observability hardening (#61)
Data-label enforcement end-to-end: RunContext taint + provenance (tool / memory-scope / run-level), enforced at six sinks — model-routing, tool, memory-write, telemetry, session, decision-trace — via the deny-overrides PolicyStore + ambient policy context. Streaming gated; fork preserves taint. HITL: enforce approver authorization (decided_by in approvers). Observability: correct @observe generator spans; centralized telemetry redaction. Docs + data-label-clinic demo; embedder model-name normalization. CI: temporal package imports without the optional temporalio extra; ruff format. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 1f8cd3b commit 4612344

72 files changed

Lines changed: 5145 additions & 146 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/scheduled_tasks.lock

Lines changed: 0 additions & 1 deletion
This file was deleted.

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,9 @@ playground/comparisons/
150150
src/orchestrator/evaluation/eval-dataset/
151151

152152
playground/local
153+
154+
# extensions
155+
extensions/
156+
157+
# claude-flow runtime lock (per-session state, not source)
158+
.claude/scheduled_tasks.lock

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ and Continuum adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
3030
- Lifecycle `on_end` hook now fires for agents reached via handoff — previously only the entry agent's hook ran, and a raising hook no longer masks a successful handoff as a failure.
3131
- Output scanners now run in streaming mode, closing a PII-leak path where the streamed final response bypassed redaction applied in non-streaming runs.
3232
- `return_to_parent=True` handoffs are now bounded by a loop guard (`HandoffLoopError`) instead of recursing unbounded between parent and child.
33-
- `RunContext.data_labels` are now enforced as additional policy subjects by the tool-access engine (deny policies on `data:*` resources take effect).
33+
- `RunContext.data_labels` are now enforced **end-to-end** as additional policy subjects across six sinks — model routing (`llm:<model>`), tool calls (`tool:<name>`), long-term memory (`memory:<scope>`), telemetry, session persistence, and the decision trace — not just tool access. A deny `AccessPolicy` on the label subject takes effect at each, in both streaming and non-streaming runs.
34+
- HITL `ApprovalStep` now authorizes decisions: a decision is honored only when `decided_by` is in `approvers`, gating both approvals and rejections; unauthorized decisions are recorded (`unauthorized_attempts`) and ignored, leaving the step pending. An empty `approvers` list preserves the prior open behavior, and `escalated` decisions are exempt (they re-target rather than resolve).
35+
- `@observe` now records async/sync **generator** spans correctly — the span stays open across iteration, so streaming spans (e.g. `llm_chat_stream`) capture duration and exceptions, including the `WARNING` level a data-label model-routing deny raises mid-stream (previously the span closed before the body ran).
3436
- Removed docs references to a non-existent `PIIPolicy` API; clarified that PII redaction is opt-in via `pre_store_filter` (memory) and output scanners (runs).
3537

3638
---

docs/features.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ Full inventory of every feature available in Continuum (updated at 2026-05-19 ),
105105
| Feature | Description |
106106
| ----------------------- | ---------------------------------------------------------------------------------------- |
107107
| PolicyStore | Deny-overrides ACL engine; glob pattern matching on subject × resource |
108-
| AccessPolicy | Resource prefixes: `tool:*`, `memory:*`, `data:*` |
108+
| AccessPolicy | Resource families: `llm:<model>`, `tool:<name>`, `memory:<scope>`, `telemetry`, `session` (glob or exact) |
109109
| ToolAccessDeniedError | Policy denial surfaced to the LLM with a configurable message |
110-
| Data sensitivity labels | Taint labels (e.g. `pii`, `phi`) on `RunContext.data_labels` propagate through handoffs and are matched as extra policy subjects by `PolicyStore`. They gate access only when you configure a policy — add a **deny** `AccessPolicy` (e.g. deny `tool:send_email` for subject `pii`); with no policy store configured they have no effect. Tool access only — not wired into the memory path; to keep sensitive content out of memory use `pre_store_filter` (content redaction), not labels. |
110+
| Data sensitivity labels | Taint labels (e.g. `pii`, `phi`) on `RunContext.data_labels` propagate forward through a run and are matched as extra policy subjects by `PolicyStore`. They gate access only when you configure a policy — add a **deny** `AccessPolicy` for the label as subject; with no policy store configured they have no effect. Enforced **end-to-end** across six sinks: model routing (`llm:<model>`), tool calls (`tool:<name>`), long-term memory (`memory:<scope>`), telemetry, session persistence, and the decision trace. Labels come only from declared producers (`tool_data_labels`, memory `scope_data_labels`, run-level `data_labels`, or `ctx.taint()`) — the SDK ships no PII detector. |
111111
| Input sanitization | Injection detection at the system boundary |
112112
| Secret utilities | Utilities for safe handling of secrets and credentials |
113113

docs/framer/ContinuumDocs.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ const BODY = `
529529
<a class="sidebar-link" onclick="scrollToAnchor('run-temporal-deploy')">Temporal Workers</a>
530530
<div class="sidebar-group-label">Safety</div>
531531
<a class="sidebar-link" onclick="scrollToAnchor('run-safety')">Input / Output Scanning</a>
532+
<a class="sidebar-link" onclick="scrollToAnchor('run-data-labels')">Data-Label Enforcement</a>
532533
<a class="sidebar-link" onclick="scrollToAnchor('run-testing')">Testing Guide</a>
533534
</div>
534535
@@ -1701,6 +1702,105 @@ agent = <span class="cls">BaseAgent</span>(
17011702
)</pre></div>
17021703
17031704
1705+
<a class="anchor" id="run-data-labels"></a>
1706+
<h2>Data-Label Enforcement</h2>
1707+
<p>Make sensitivity labels such as <code>pii</code>, <code>confidential</code>, or <code>restricted</code> change what a run is <em>allowed to do</em>. A run carries a set of labels (<code>ctx.data_labels</code>); labels are added by <strong>producers</strong> and then <strong>gate</strong> six runtime sinks. Taint is monotonic (only added, propagates forward) and is enforced identically in streaming and non-streaming.</p>
1708+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre><span class="cm"># producer (taint) ─▶ ctx.data_labels = {"pii"} ─▶ gate at each sink (deny?)</span></pre></div>
1709+
1710+
<p><strong>Independent of <a onclick="scrollToAnchor('run-safety')" style="cursor:pointer;text-decoration:underline">Input / Output Scanning</a>.</strong> Scanners <em>sanitize text</em>; data labels <em>gate behavior</em>. Neither feeds the other — a scanner never taints a run.</p>
1711+
1712+
<h3>Defaults — opt-in &amp; inert</h3>
1713+
<p>With nothing configured the feature does nothing (zero behavior change, fully backward-compatible): no policy store on the agent, no producers, every run unlabeled. Policy evaluation is <strong>open-default-allow</strong> with <strong>deny-overrides</strong>.</p>
1714+
<table>
1715+
<tr><th>Setting</th><th>Default</th><th>Effect</th></tr>
1716+
<tr><td><code>BaseAgent(policy_store=…)</code></td><td><code>None</code></td><td>no gates run at all</td></tr>
1717+
<tr><td><code>AgentConfig.tool_data_labels</code></td><td><code>{}</code></td><td>no tool taints the run</td></tr>
1718+
<tr><td><code>AgentMemoryConfig.scope_data_labels</code></td><td><code>{}</code></td><td>no memory-read taint</td></tr>
1719+
<tr><td>run-level <code>data_labels</code></td><td><code>None</code></td><td>run starts clean</td></tr>
1720+
<tr><td>policy evaluation</td><td>open-default-allow</td><td>unmatched resource → allowed</td></tr>
1721+
</table>
1722+
1723+
<h3>Enable it in three steps</h3>
1724+
<p><strong>1 · Define policies.</strong> Subjects are matched against <code>[agent.name, *sorted(labels)]</code>, so a rule whose subject is a label fires for <em>any</em> run carrying that label. Resources support fnmatch globs.</p>
1725+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre><span class="kw">from</span> continuum.security.policy <span class="kw">import</span> <span class="cls">AccessPolicy</span>, <span class="cls">PolicyStore</span>
1726+
1727+
store = <span class="cls">PolicyStore</span>()
1728+
store.<span class="fn">add_policy</span>(<span class="cls">AccessPolicy</span>(
1729+
name=<span class="str">"pii-no-cloud-model"</span>,
1730+
subjects=[<span class="str">"pii"</span>], <span class="cm"># fires whenever the run carries "pii"</span>
1731+
resources=[<span class="str">"llm:gpt-4o"</span>], <span class="cm"># the cloud model</span>
1732+
effect=<span class="str">"deny"</span>,
1733+
denial_message=<span class="str">"PII may not be sent to the cloud model."</span>,
1734+
))
1735+
store.<span class="fn">add_policy</span>(<span class="cls">AccessPolicy</span>(
1736+
name=<span class="str">"confidential-no-exfil"</span>,
1737+
subjects=[<span class="str">"confidential"</span>],
1738+
resources=[<span class="str">"tool:send_*"</span>, <span class="str">"tool:web_*"</span>], <span class="cm"># globs supported</span>
1739+
effect=<span class="str">"deny"</span>,
1740+
denial_message=<span class="str">"Confidential data may not leave via this tool."</span>,
1741+
))</pre></div>
1742+
1743+
<p><strong>2 · Attach the store to the agent.</strong> The runner publishes it as the ambient policy for the whole run, so every sink is gated — you never thread it through call sites.</p>
1744+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre>agent = <span class="cls">BaseAgent</span>(
1745+
name=<span class="str">"assistant"</span>, instructions=<span class="str">"..."</span>, model=<span class="str">"gpt-4o"</span>,
1746+
policy_store=store,
1747+
)</pre></div>
1748+
1749+
<p><strong>3 · Declare at least one producer</strong> — otherwise nothing is ever labeled and the policies never fire.</p>
1750+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre><span class="cm"># (a) Tool provenance — a tool's result taints the run</span>
1751+
<span class="cls">AgentConfig</span>(tool_data_labels={<span class="str">"lookup_patient"</span>: {<span class="str">"pii"</span>}})
1752+
1753+
<span class="cm"># (b) Memory-read provenance — reading a scope taints the run</span>
1754+
<span class="cls">AgentMemoryConfig</span>(scope_data_labels={<span class="cls">AgentMemoryScope</span>.USER: {<span class="str">"pii"</span>}})
1755+
1756+
<span class="cm"># (c) Run-level — request known to carry sensitive data</span>
1757+
ctx = <span class="fn">create_run_context</span>(user_id=<span class="str">"u1"</span>, data_labels={<span class="str">"pii"</span>})
1758+
1759+
<span class="cm"># (d) Anywhere in your own code</span>
1760+
ctx.<span class="fn">taint</span>(<span class="str">"confidential"</span>)</pre></div>
1761+
1762+
<h3>Sink reference</h3>
1763+
<p>Write policy <code>resources=[…]</code> using these strings. Each sink checks the policy with subjects <code>[agent.name, *sorted(labels)]</code>.</p>
1764+
<table>
1765+
<tr><th>Sink</th><th>Resource string</th><th>What a deny does</th></tr>
1766+
<tr><td>Model routing</td><td><code>llm:&lt;model&gt;</code></td><td>raises <code>ModelAccessDeniedError</code> (catch → reroute to an allowed model)</td></tr>
1767+
<tr><td>Tool call</td><td><code>tool:&lt;name&gt;</code> (globs)</td><td>tool result becomes <code>POLICY DENIED: …</code> (tool not run; model sees it)</td></tr>
1768+
<tr><td>Long-term memory</td><td><code>memory:&lt;scope&gt;</code> or <code>memory:*</code></td><td>write blocked (<code>MemoryAccessDeniedError</code> on explicit add; auto-store skipped)</td></tr>
1769+
<tr><td>Telemetry</td><td><code>telemetry</code></td><td>payload replaced with <code>{"_redacted": "…"}</code> before egress</td></tr>
1770+
<tr><td>Short-term session</td><td><code>session</code></td><td>assistant answer stored as a placeholder, not verbatim</td></tr>
1771+
<tr><td>Decision trace</td><td><code>telemetry</code> (reused)</td><td>trace content redacted before persistence; audit skeleton kept</td></tr>
1772+
</table>
1773+
1774+
<h3>End-to-end</h3>
1775+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre><span class="kw">from</span> continuum <span class="kw">import</span> <span class="cls">BaseAgent</span>, <span class="cls">AgentConfig</span>, <span class="cls">AgentRunner</span>
1776+
<span class="kw">from</span> continuum.security.policy <span class="kw">import</span> <span class="cls">AccessPolicy</span>, <span class="cls">PolicyStore</span>
1777+
<span class="kw">from</span> continuum.agent.exceptions <span class="kw">import</span> <span class="cls">ModelAccessDeniedError</span>
1778+
1779+
store = <span class="cls">PolicyStore</span>()
1780+
store.<span class="fn">add_policy</span>(<span class="cls">AccessPolicy</span>(name=<span class="str">"pii-no-cloud"</span>, subjects=[<span class="str">"pii"</span>],
1781+
resources=[<span class="str">"llm:gpt-4o"</span>], effect=<span class="str">"deny"</span>,
1782+
denial_message=<span class="str">"no PII on cloud"</span>))
1783+
1784+
agent = <span class="cls">BaseAgent</span>(name=<span class="str">"clinic"</span>, instructions=<span class="str">"..."</span>, model=<span class="str">"gpt-4o"</span>,
1785+
policy_store=store,
1786+
config=<span class="cls">AgentConfig</span>(tool_data_labels={<span class="str">"lookup_patient"</span>: {<span class="str">"pii"</span>}}))
1787+
1788+
runner = <span class="cls">AgentRunner</span>() <span class="cm"># one per app; shared across runs</span>
1789+
1790+
<span class="kw">try</span>:
1791+
resp = <span class="kw">await</span> runner.<span class="fn">run</span>(agent, <span class="str">"look up patient P-123"</span>) <span class="cm"># taints pii → gpt-4o denied</span>
1792+
<span class="kw">except</span> <span class="cls">ModelAccessDeniedError</span>:
1793+
agent.model = <span class="str">"gpt-4o-mini"</span> <span class="cm"># allowed model</span>
1794+
resp = <span class="kw">await</span> runner.<span class="fn">run</span>(agent, <span class="str">"look up patient P-123"</span>)</pre></div>
1795+
1796+
<div class="callout callout-info">
1797+
<strong>"Model routing"</strong> is Continuum's gate; the gateway (<a onclick="scrollToAnchor('sec-smart')" style="cursor:pointer;text-decoration:underline">smart layer</a>) is a separate project (AURA) that does its own model routing. When using the gateway, rely on AURA for model routing instead of Continuum's "Model routing" gate.
1798+
</div>
1799+
1800+
<h3>Example</h3>
1801+
<p>A complete, runnable example lives at <code>playground/data-label-clinic/</code>.</p>
1802+
1803+
17041804
<a class="anchor" id="run-testing"></a>
17051805
<h2>Testing Guide</h2>
17061806
<p>Continuum favors integration tests over mocks. The <code>[dev]</code> extra ships <code>fakeredis</code>, <code>respx</code>, and <code>pytest-asyncio</code>.</p>
@@ -1984,6 +2084,18 @@ handle = <span class="kw">await</span> client.execute_workflow(
19842084
auto_approve_if=<span class="str">"low_risk"</span>, <span class="cm"># skip approval if condition agent returns this</span>
19852085
)</pre></div>
19862086
2087+
<div class="callout callout-info">
2088+
<strong>HITL approver authorization (<code>decided_by ∈ approvers</code>).</strong> When <code>approvers</code> is non-empty, a decision is honored only if its <code>decided_by</code> is in the list — gating both approvals <em>and</em> rejections. Unauthorized decisions are recorded and ignored; the workflow stays paused until a listed approver resolves it (or it auto-expires at <code>timeout</code>). An empty <code>approvers</code> list means anyone may decide; <code>escalated</code> always routes through. A full workflow with this approval gate lives at <code>playground/temporal-leadflow/</code>.
2089+
</div>
2090+
<div class="code-wrapper"><button class="copy-btn" onclick="copyCode(this)">copy</button><pre><span class="kw">from</span> continuum.temporal.human_in_loop <span class="kw">import</span> <span class="cls">HumanInLoopManager</span>
2091+
2092+
mgr = <span class="cls">HumanInLoopManager</span>(client=temporal_client)
2093+
2094+
<span class="cm"># A decision is honored only if decided_by is in approvers:</span>
2095+
<span class="kw">await</span> mgr.<span class="fn">approve</span>(workflow_id, request_id, decided_by=<span class="str">"alice@company.com"</span>) <span class="cm"># honored</span>
2096+
<span class="kw">await</span> mgr.<span class="fn">reject</span>(workflow_id, request_id, decided_by=<span class="str">"mallory@evil.com"</span>) <span class="cm"># ignored — not an approver; stays pending</span>
2097+
<span class="kw">await</span> mgr.<span class="fn">escalate</span>(workflow_id, request_id, escalate_to=<span class="str">"carol@company.com"</span>) <span class="cm"># always allowed (re-targets)</span></pre></div>
2098+
19872099
19882100
</section>
19892101

0 commit comments

Comments
 (0)