Sidecar Runtime, Rule-Based Decision Gate, and Intent Guardian (v0.1 + v0.2) - #5
Conversation
Decision(status, risk, reason) -- frozen, status in {ALLOW, WARN, BLOCK}.
DecisionContext -- tool_name/tool_args plus IntentSnapshot/HistoryEntry,
both injected by Sidecar.evaluate() rather than constructed by callers.
Deliberately not frozen (unlike Decision): Sidecar.evaluate() injects
intent/history by mutating the caller's own instance in place.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ArgOp + compare() -- extracted so gate/risk.py's argument-pattern rules and intent/alignment.py's constraint bindings can share one comparator implementation instead of duplicating it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Raised by an adapter (not by Sidecar.evaluate() itself) in Govern mode when the Decision Gate returns BLOCK for a proposed tool call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deterministic, YAML-driven allow/deny rules by tool-name glob pattern. Zero LLM calls (ROADMAP.md Design Constraint 2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rule-based risk classification: tool-name glob plus an optional argument-pattern check (tool, arg_name, op, arg_value), built on core.operators.compare(). Zero LLM calls; promoting the classifier to a model is explicitly deferred (Design Constraint 4). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
goal, requester, constraints, authority, expiry (concept.md §6). is_expired()/to_snapshot() require timezone-aware datetimes -- a naive value would otherwise only fail deep inside evaluate(), swallowed by on_sidecar_failure instead of surfacing at construction time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ConstraintBinding binds one envelope constraint to a specific tool argument and comparison op; evaluate_alignment()/IntentGuardian check bindings plus envelope expiry, producing WARN or BLOCK per binding severity. Scope is deliberately narrow -- constraints only, matching concept.md §9's framing of this as the first concrete semantic-authorization check. authority has no binding mechanism yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires Policy Advisor, Risk Evaluator, and Intent Guardian into a single Decision Gate. on_sidecar_failure (fail_open|fail_closed) is required with no default. mode=observe (default, logs only) or mode=govern (v0.2: a BLOCK is enforced by the attached adapter, not by evaluate() itself). set_intent() swaps the active envelope between tasks, and raises if intent is given without 'intent_guardian' in roles rather than silently never consulting it. risk_block_threshold is validated at construction rather than only surfacing as a KeyError inside evaluate(). evaluate() injects the active intent snapshot and decision history into the caller's own DecisionContext, in place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
attach(sidecar, tools) wraps a list of tool callables so every call is evaluated by a Sidecar first. No import-time dependency on the langgraph package itself. In Observe mode the wrapped call always executes; in Govern mode a BLOCK raises SidecarBlockedError instead (WARN/ALLOW still call through in both modes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Re-export Sidecar, Decision, DecisionContext, DecisionStatus, RiskLevel, SidecarBlockedError at the top level. Add pyyaml (gate.policy/gate.risk YAML loading) as a core dependency and a langgraph extra for building a real graph around adapters.langgraph's wrapped tools. Bump version to 0.2.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Runnable, offline (no API key) example against a real langgraph.prebuilt.create_react_agent agent: a Policy Advisor deny rule and a Risk Evaluator argument-threshold rule both fire and are logged, but neither stops the call -- Observe mode only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The refund-limit scenario from concept.md §9 end to end: an $850 refund request raises SidecarBlockedError before the real tool runs; a $120 request goes through normally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Document the real (per-adapter attach(), not Sidecar.attach()) v0.1/v0.2 Python API alongside the longer-term planned shape; mark v0.1 and v0.2 deliverables shipped in ROADMAP.md; update AGENTS.md's Status/Repo Map/ Design Constraints to match what's actually implemented. Also fixes picked up in review: ROADMAP.md's v0.2 deliverable cited concept.md §22 for the refund-limit example (that's actually §9); a stray 'four' where Design Constraints lists five; a semantica.py placeholder shown as if it already existed; core/sidecar.py's Package Layout entry still describing attach() as living there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7598d939bb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| tool_name = getattr(tool, "__name__", repr(tool)) | ||
|
|
||
| @functools.wraps(tool) | ||
| def _sidecar_wrapped_tool(*args: Any, **kwargs: Any) -> Any: |
There was a problem hiding this comment.
Preserve coroutine semantics when wrapping async tools
When tool is an async function, this always-synchronous wrapper makes inspect.iscoroutinefunction(wrapped) false. LangGraph/LangChain can consequently register it as a synchronous tool and receive an unawaited coroutine object instead of the tool result, so the underlying async operation may never execute. Select an async def wrapper for coroutine functions and await the original tool.
Useful? React with 👍 / 👎.
| default_risk = data.get("default", "LOW") | ||
| return cls(rules, default_risk=default_risk) |
There was a problem hiding this comment.
Reject invalid rule-set defaults during loading
When YAML contains a misspelled default such as default: SEVERE, from_mapping() stores that unchecked value because its type is Any; the first unmatched action then raises while constructing RiskResult, and a Sidecar(on_sidecar_failure="fail_open") converts the configuration error into ALLOW. This recreates the fail-open misconfiguration problem that risk_block_threshold now validates, so validate default_risk at construction/loading time and do the same for PolicyAdvisor.default_effect.
Useful? React with 👍 / 👎.
| self._inject(context) | ||
| try: |
There was a problem hiding this comment.
Guard context injection with the failure policy
Because _inject() runs before the try, any validation error while creating the intent snapshot or history escapes evaluate() instead of resolving through fail_open or fail_closed. This is reachable through the public mutable models—for example, mutating an active envelope's goal to an invalid value or mutating a previously recorded context before the next evaluation—and contradicts the method's promise to always resolve and record a decision. Include injection in the guarded evaluation path.
AGENTS.md reference: AGENTS.md:L108-L110
Useful? React with 👍 / 👎.
Summary
Implements the v0.1 and v0.2 milestones from ROADMAP.md: the Sidecar
runtime, a rule-based Decision Gate (Policy Advisor + Risk Evaluator), and
Intent Guardian (
IntentEnvelope+ constraint validation), attached via aLangGraph adapter that supports both Observe mode (v0.1, logs only) and
Govern mode (v0.2, a
BLOCKis actually enforced).What's in each version
v0.1 — Sidecar Runtime + Rule-Based Decision Gate
Sidecar(on_sidecar_failure: fail_open | fail_closed, required, nodefault),
Decision(status, risk, reason),DecisionContextgate.policy) — deterministic YAML allow/deny rulesgate.risk) — rule-based, tool-name + argument-patternadapters.langgraph.attach) — Observe mode onlyv0.2 — Intent Guardian
IntentEnvelope,ConstraintBinding,IntentGuardian(
agentic_sidecar.intent) — constraint validation only (numeric/enum/allow-list),
authoritydeferred until a real scenario motivates itsbinding shape
WARNadded toDecision.statusalongsideALLOW/BLOCKSidecarBlockedErrorraised by the adapter (not bySidecar.evaluate(), which only ever computes aDecision) when aBLOCKshould stop the callSidecar.set_intent()to swap envelopes between tasksFull detail in
CHANGELOG.md's[0.1.0]/[0.2.0]entries.Design notes worth flagging in review
Sidecar.attach(agent)from README's original Planned Python API isnot how this actually ships:
core/must not import fromadapters/(AGENTS.md Package Boundaries), so wrapping a framework's tool-call
surface is each adapter's own function
(
agentic_sidecar.adapters.langgraph.attach(sidecar, tools)), not ageneric method on
Sidecar. Documented in README as the real v0.1/v0.2shape vs. the longer-term target.
DecisionContextis deliberately not frozen (unlikeDecision):Sidecar.evaluate()injects the active intent snapshot and decisionhistory by mutating the caller's own instance in place, so a caller
building
SidecarBlockedErrorafter the call sees the fully-evaluatedcontext, not the bare one it originally constructed.
examples/langgraph_*.py) run fully offline against asmall scripted chat model — no API key needed to try them.
Testing
make check(ruff check, ruff format --check, mypy --strict, pytest) isclean. 125 tests, 99% coverage.
python -m build+twine checkpass.