Skip to content

Commit 3382c30

Browse files
authored
Merge pull request #50 from chiruu12/feat/agentos-guardrails
AgentOS Phase 2: content guardrails (PII + prompt injection)
2 parents abb8f16 + 40f2b2b commit 3382c30

11 files changed

Lines changed: 557 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ src/hive/
9898
|-----------|-----|------|
9999
| Custom tool | Subclass `Toolkit`, `@tool()` methods | `src/hive/tools/base.py` |
100100
| Gated tool (HITL) | `@tool(requires_approval=True)` or `ApprovalGate` protocol | `src/hive/runtime/approval.py` |
101+
| Custom guardrail | `Guardrail` protocol, `GuardrailRegistry.default().register(...)` | `src/hive/runtime/guardrails.py` |
101102
| Custom model provider | Subclass `BaseProvider` | `src/hive/models/base.py` |
102103
| Custom stressor | `StressorRegistry.default().register(name, rate, desc)` | `src/hive/agents/suffering.py` |
103104
| Custom A2A pattern | Subclass `A2APattern`, `PatternRegistry.default().register(name, instance)` | `src/hive/interactions/registry.py` |

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
- **Per-user/per-session isolation**: the `sessions` table gains tenant columns and a
1919
`SessionService` resolves a session per request (via the `X-Hive-User` header),
2020
isolating one tenant's sessions from another's.
21+
- **Content guardrails**: a `Guardrail` protocol with pre-hook (task input) and
22+
post-hook (model output) inspection in the ReAct loop, plus built-in `PIIGuardrail`
23+
(emails/phones/SSNs/cards/IPs) and `PromptInjectionGuardrail` (instruction-override /
24+
jailbreak phrasing). Config-driven (`guardrails.enabled`, per-guardrail `flag`/
25+
`redact`/`block` actions); `GuardrailRegistry` and `GuardrailPipeline` compose custom
26+
guardrails. Off by default. `Agent(guardrails=...)`.
2127

2228
## [0.6.1] -- 2026-06-03
2329

docs/extending/index.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,32 @@ agent = Agent(name="releaser", model=provider, toolkits=[DeployToolkit()],
592592

593593
The built-in `StoreApprovalGate` persists pending approvals so they survive the
594594
daemon's heartbeat cycles. See [Human-in-the-Loop Approvals](../guide/daemon-mode.md#human-in-the-loop-approvals).
595+
596+
## 10. Custom Guardrail
597+
598+
A guardrail inspects text entering or leaving the model. Implement the `Guardrail`
599+
protocol and register it so it composes with the built-in PII and prompt-injection
600+
guardrails.
601+
602+
```python
603+
from hive.runtime.guardrails import (
604+
Guardrail, GuardrailAction, GuardrailFinding, GuardrailStage, GuardrailRegistry,
605+
)
606+
607+
class ProfanityGuardrail:
608+
name = "profanity"
609+
action = GuardrailAction.REDACT
610+
611+
def inspect(self, text: str, stage: GuardrailStage) -> GuardrailFinding:
612+
if stage is GuardrailStage.OUTPUT and "badword" in text.lower():
613+
cleaned = text.replace("badword", "[REDACTED]")
614+
return GuardrailFinding(True, self.action, cleaned, ["profanity"])
615+
return GuardrailFinding(False, self.action, text)
616+
617+
# Compose directly into a pipeline:
618+
from hive.runtime.guardrails import GuardrailPipeline, PIIGuardrail
619+
pipeline = GuardrailPipeline([ProfanityGuardrail(), PIIGuardrail()])
620+
agent = Agent(name="writer", model=provider, guardrails=pipeline)
621+
```
622+
623+
See [Guardrails](../guide/daemon-mode.md#guardrails).

docs/getting-started/cli-quickstart.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ approval: # human-in-the-loop tool gating (off by default)
118118
auto_approve: [] # tool names never gated (overrides a tool's own flag)
119119
timeout_cycles: 0 # auto-deny after N heartbeats (0 = never)
120120

121+
guardrails: # content checks on model input/output (off by default)
122+
enabled: false
123+
pii: true # redact PII in output
124+
prompt_injection: true # block injection phrasing in input
125+
pii_action: redact # flag | redact | block
126+
injection_action: block # flag | redact | block
127+
121128
event_log_fsync: false # fsync every event-log append (crash-durable, slower)
122129
seed: null # int for a reproducible world RNG; null = system entropy
123130
```

docs/guide/daemon-mode.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,27 @@ An approval is granted for a specific `(tool, arguments)` pair and is single-use
137137
re-running the same call later prompts again. `timeout_cycles` auto-denies a request
138138
that sits unresolved too long.
139139

140+
## Guardrails
141+
142+
Guardrails inspect content around the model -- a **pre-hook** on the task input and a
143+
**post-hook** on the final output (the model-I/O analog of lifecycle hooks). Enable
144+
them in `.hive/config.yaml`:
145+
146+
```yaml
147+
guardrails:
148+
enabled: true
149+
pii: true # redact PII in output
150+
prompt_injection: true # block injection phrasing in input
151+
pii_action: redact # flag | redact | block
152+
injection_action: block # flag | redact | block
153+
```
154+
155+
Built-ins: **PII** (emails, phones, SSNs, cards, IPs) and **prompt injection**
156+
("ignore previous instructions", "you are now …", jailbreak phrasing). Each action is
157+
`flag` (log only), `redact` (mask matches), or `block` (refuse the input / withhold the
158+
output). A blocked input fails the task; a blocked output is replaced with a notice.
159+
Add your own via the `Guardrail` protocol and `GuardrailRegistry`.
160+
140161
## Life Events
141162

142163
The event engine rolls random events each cycle (30% probability). Events force agents to make decisions that affect their stats and suffering.

src/hive/config.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from collections.abc import Callable
55
from pathlib import Path
6-
from typing import Any
6+
from typing import Any, Literal
77

88
import yaml
99
from dotenv import dotenv_values
@@ -186,6 +186,21 @@ def _timeout_non_negative(cls, v: int) -> int:
186186
return v
187187

188188

189+
class GuardrailConfig(BaseModel):
190+
"""Content guardrails on model input/output.
191+
192+
Disabled by default. When enabled, a PII guardrail (redacts output by default)
193+
and a prompt-injection guardrail (blocks input by default) run around the model.
194+
Actions are ``flag`` (log only), ``redact`` (mask matches), or ``block``.
195+
"""
196+
197+
enabled: bool = False
198+
pii: bool = True
199+
prompt_injection: bool = True
200+
pii_action: Literal["flag", "redact", "block"] = "redact"
201+
injection_action: Literal["flag", "redact", "block"] = "block"
202+
203+
189204
class ModelConfig(BaseModel):
190205
default_model: str = "claude-haiku-4-5"
191206
planning_model: str = "claude-sonnet-4-6"
@@ -202,6 +217,7 @@ class HiveConfig(BaseModel):
202217
daemon: DaemonConfig = DaemonConfig()
203218
model: ModelConfig = ModelConfig()
204219
approval: ApprovalConfig = ApprovalConfig()
220+
guardrails: GuardrailConfig = GuardrailConfig()
205221
profiles_dir: str = ""
206222
logs_dir: str = "logs"
207223
# fsync every event-log append for crash durability (one fsync per event).

src/hive/daemon/loop.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from hive.models.base import BaseProvider
3131
from hive.models.factory import create_runtime_provider
3232
from hive.runtime import Agent, DaemonAgentAdapter, Message
33+
from hive.runtime.guardrails import build_guardrail_pipeline
3334
from hive.runtime.persona import Persona
3435
from hive.tools.a2a import A2AToolkit
3536
from hive.tools.alarms import AlarmToolkit, fire_notification
@@ -479,6 +480,7 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
479480
session_id=session_id,
480481
goal_id=active_goal["goal_id"],
481482
)
483+
guardrails = build_guardrail_pipeline(get_config().guardrails)
482484
if persona is not None:
483485
runtime_agent = Agent(
484486
name=agent.name,
@@ -487,6 +489,7 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
487489
toolkits=self._build_toolkits(agent.agent_id),
488490
tool_timeout=tool_timeout,
489491
approval_gate=approval_gate,
492+
guardrails=guardrails,
490493
)
491494
else:
492495
runtime_agent = Agent(
@@ -498,6 +501,7 @@ async def _run_agent_cycle_inner(self, agent: AgentState, suffering: SufferingSt
498501
toolkits=self._build_toolkits(agent.agent_id),
499502
tool_timeout=tool_timeout,
500503
approval_gate=approval_gate,
504+
guardrails=guardrails,
501505
)
502506
adapter = DaemonAgentAdapter(runtime_agent, agent.agent_id)
503507
# Give the pursuing agent its persistent self -- name and accumulated

src/hive/runtime/agent.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from hive.models.base import BaseProvider
1515
from hive.models.registry import estimate_cost
1616
from hive.runtime.approval import ApprovalDecision, ApprovalGate, AwaitingApprovalSignal
17+
from hive.runtime.guardrails import GuardrailAction, GuardrailPipeline, GuardrailStage
1718
from hive.runtime.instructions import InstructionLike, Instructions
1819
from hive.runtime.memory import ConversationMemory, PersistentMemory
1920
from hive.runtime.persona import Persona
@@ -79,13 +80,17 @@ def __init__(
7980
on_text: Callable[[str], None] | None = None,
8081
tool_timeout: float = 0.0,
8182
approval_gate: ApprovalGate | None = None,
83+
guardrails: GuardrailPipeline | None = None,
8284
):
8385
self.name = name
8486
self._model = model
8587
self._on_text = on_text
8688
# Optional human-in-the-loop gate. When set, tools it flags are paused for
8789
# approval instead of executing (see _execute_tool_calls). None = no gating.
8890
self._approval_gate = approval_gate
91+
# Optional content guardrails. When set, the task input is checked before the
92+
# model runs (pre-hook) and the final output before it is returned (post-hook).
93+
self._guardrails = guardrails
8994
# Per-tool wall-clock limit (seconds); 0 disables. A tool that exceeds it
9095
# becomes a tool-error result so one hung tool can't stall the whole cycle.
9196
self._tool_timeout = tool_timeout
@@ -473,6 +478,29 @@ async def run(self, task: Task) -> TaskResult:
473478
self._tokens_warned = False
474479
t0 = time.time()
475480

481+
# Pre-hook: inspect the task input before the model sees it. A blocking
482+
# guardrail (e.g. prompt injection) refuses the run; a redacting one rewrites
483+
# the instruction the model receives.
484+
if self._guardrails:
485+
finding = self._guardrails.run(task.instruction, GuardrailStage.INPUT)
486+
if finding.triggered:
487+
logger.warning(
488+
"Agent %r: input guardrail %s (%s)",
489+
self.name,
490+
finding.action.value,
491+
"; ".join(finding.reasons),
492+
)
493+
if finding.blocked:
494+
return TaskResult(
495+
task_id=task.id,
496+
status=TaskStatus.FAILED,
497+
output="",
498+
error=f"blocked by guardrail: {'; '.join(finding.reasons)}",
499+
duration_seconds=time.time() - t0,
500+
)
501+
if finding.action is GuardrailAction.REDACT:
502+
task = task.model_copy(update={"instruction": finding.text})
503+
476504
tools = self.get_tools()
477505
tool_map = {t.name: t for t in tools}
478506
tool_schemas = [t.to_schema() for t in tools] if tools else None
@@ -529,11 +557,33 @@ async def run(self, task: Task) -> TaskResult:
529557
conversation.add(response)
530558

531559
if not response.tool_calls:
532-
self._write_conversation_log(task.id, conversation.get_messages(), "completed")
560+
# Post-hook: inspect the final output before returning it. A blocking
561+
# guardrail withholds it; a redacting one masks matched spans (e.g. PII).
562+
output = response.content
563+
if self._guardrails:
564+
finding = self._guardrails.run(output, GuardrailStage.OUTPUT)
565+
if finding.triggered:
566+
logger.warning(
567+
"Agent %r: output guardrail %s (%s)",
568+
self.name,
569+
finding.action.value,
570+
"; ".join(finding.reasons),
571+
)
572+
if finding.blocked:
573+
output = "[output withheld by guardrail]"
574+
elif finding.action is GuardrailAction.REDACT:
575+
output = finding.text
576+
# The raw assistant message is already in the conversation; replace it
577+
# with the sanitized output for the on-disk log too, so a redacting
578+
# guardrail doesn't leak the unredacted content into the JSON log file.
579+
log_messages = conversation.get_messages()
580+
if output != response.content:
581+
log_messages = [*log_messages[:-1], Message.assistant(output)]
582+
self._write_conversation_log(task.id, log_messages, "completed")
533583
return TaskResult(
534584
task_id=task.id,
535585
status=TaskStatus.COMPLETED,
536-
output=response.content,
586+
output=output,
537587
steps_taken=steps,
538588
tool_calls_made=tool_calls_total,
539589
duration_seconds=time.time() - t0,

0 commit comments

Comments
 (0)