From 96eede6f209b050769fe0a7bcddcd1223543b299 Mon Sep 17 00:00:00 2001 From: rosspeili Date: Mon, 28 Sep 2026 13:06:18 +0300 Subject: [PATCH] feat: egress schema and constitution checks at high bind (#78) Add variables.schema_refs and schema_check constraint rules for JSON Schema validation on tool.call args and tool.result payloads. Spectrum high/full auto-wires refs unless schema_enforcement is false. Audit finding SCHEMA_VIOLATION, conformance schema check, merged_profile_rules helper, tests, docs, and host stress sim scenario. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- aura/api.py | 29 +++- aura/cli/commands.py | 13 ++ aura/core/audit_report.py | 24 ++- aura/core/conformance.py | 50 ++++++ aura/core/constraints.py | 42 +++++ aura/core/schema_validation.py | 163 ++++++++++++++++++ aura/core/session.py | 14 ++ docs/ROADMAP.md | 2 +- docs/TESTING.md | 2 +- docs/architecture.md | 1 + docs/aura-levels.md | 20 ++- docs/capabilities.md | 2 +- docs/comparison.md | 3 +- docs/concepts.md | 4 +- docs/onboarding.md | 3 +- docs/outputs.md | 4 +- docs/sequencer.md | 2 +- docs/skillware-integration.md | 2 +- docs/using-aura.md | 2 + pyproject.toml | 1 + scripts/aura_host_stress_sim.py | 40 +++++ spec/manifest.schema.json | 5 + tests/test_schema_checks.py | 291 ++++++++++++++++++++++++++++++++ 25 files changed, 700 insertions(+), 22 deletions(-) create mode 100644 aura/core/schema_validation.py create mode 100644 tests/test_schema_checks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cba7b65..9051e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Egress schema and constitution checks ([#78](https://github.com/ARPAHLS/aura/issues/78))** — profile `variables.schema_refs` (named JSON Schema specs per tool) and explicit `schema_check` constraint rules validate `tool.call` args and/or `tool.result` payloads at egress; spectrum `high` / `full` auto-wires refs when `schema_enforcement` is not false; audit finding `SCHEMA_VIOLATION`; conformance `schema` check on close; `session.open` and `aura agent show` spectrum summary include `schema` block; `merged_profile_rules()` helper for session/CLI parity; stress sim `schema_high_bind` scenario; depends on `jsonschema`. - **Capability broker ([#48](https://github.com/ARPAHLS/aura/issues/48))** — profile `capabilities[]` declares named intents and secret **refs** only; constraint `capability_scope` on `tool.call`; SecretBroker (`EnvSecretBroker`, `MapSecretBroker`, `CallableSecretBroker`, `ChainSecretBroker`) injects the live token after allow; spine/summary/OTel redact secret-like keys and injected values; spectrum `low` records misses (`audit_only`, finding `CAPABILITY_AUDIT`) while `mid`+ blocks (`CAPABILITY_DENIED`); unresolved refs emit `tool.error` / `SECRET_BROKER_ERROR`; `capability.injected` records ref not value; CLI `aura agent set --capabilities-json` / `--capabilities-file`; `session(secret_broker=...)`; example `examples/14-capability-broker/`; stress sims `scripts/aura_capability_stress_sim.py` (16 scenarios) and six host-sim coats (33 host scenarios total). - **Escalation playbooks ([#47](https://github.com/ARPAHLS/aura/issues/47))** — profile `escalations[]` (agent registry / SDK — not manifest bindings) maps trigger events (`slo.missed`, `conformance.drift`, `observer.alert`, `constraint.violated`) to actions (`log`, `alert`, `nudge`, `pause`, `email`, `wake`, `custom`); spine events `escalation.fired`, `membrane.nudge`, `escalation.email`; destructive `pause` requires spectrum ≥ mid and uses `escalation_pause` + `approve()`; session-only `escalation_handler` callback; audit finding `ESCALATION_FIRED`; example `examples/12-escalation-playbooks/` (six scenarios); stress sim `escalation_slo_playbook` scenario (27 total). - **Verified identity via spectrum ([#73](https://github.com/ARPAHLS/aura/issues/73))** — `spectrum.identity_required` and level defaults (`high` / `full` → verified operator mandatory when a profile has a `spectrum` block); session open fails with `IdentityRequiredError` when policy requires verified IdP identity and none resolves; lite `aura_id` / `agent_ref` unchanged; `session.open` spectrum summary includes `verified_identity_required` + source; audit finding `VERIFIED_IDENTITY_REQUIRED`; `aura run --require-identity` session override; `aura agent show` / `aura config show` document policy. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b625f4c..f6af06c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,7 +157,7 @@ Pure internal refactors with no user-visible effect may omit CHANGELOG; ask on t | `spectrum.level` / enforcement rules | `aura/core/spectrum_enforcement.py`, `docs/aura-levels.md`, `docs/comparison.md`, `docs/ROADMAP.md`, `tests/test_spectrum_enforcement.py`, stress sim / flow report scripts, CHANGELOG | | `spectrum.services[]` / field-service wiring | `aura/core/spectrum_services.py`, `aura/observers/presets/limit.py`, `spec/manifest.schema.json`, `docs/observers.md`, `docs/field-services.md`, `docs/using-aura.md`, `tests/test_spectrum_services.py`, stress sim / flow report scripts, CHANGELOG | | `escalations[]` playbooks | `aura/core/escalations.py`, `aura/core/constraints.py` (`escalation_pause`), `aura/agents/profile.py`, `docs/observers.md`, `docs/outputs.md`, `examples/12-escalation-playbooks/`, `tests/test_escalations.py`, `tests/test_example_12_escalation_playbooks.py`, stress sim, CHANGELOG | -| Constraint rule types | `docs/concepts.md`, `docs/capabilities.md` (for `capability_scope`), `aura/core/constraints.py` tests, CHANGELOG | +| Constraint rule types | `docs/concepts.md`, `docs/capabilities.md` (for `capability_scope`), `docs/aura-levels.md` (for `schema_check`), `aura/core/constraints.py`, `aura/core/schema_validation.py`, `tests/test_schema_checks.py`, stress sim, CHANGELOG | | Sequencer step model | `spec/sequencer.schema.json`, `docs/sequencer.md`, `tests/test_v02.py`, CHANGELOG | | Skillware host / egress | `integrations/skillware/` (when shipped), `docs/skillware-integration.md` redirect, CHANGELOG | | Integration example (Ollama, API, framework) | `integrations//`, `docs/integrations/README.md`, `.env.example`, CHANGELOG | diff --git a/aura/api.py b/aura/api.py index 1cd8dc3..a15881a 100644 --- a/aura/api.py +++ b/aura/api.py @@ -147,6 +147,25 @@ def session( _finalize_session_run(run, session, do_export=do_export) +def merged_profile_rules( + profile: AgentProfile, + *, + session_rules: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Profile rules + spectrum enforcement + capability + schema merge.""" + from aura.core.capabilities import merge_capability_rules + from aura.core.schema_validation import merge_schema_rules + from aura.core.spectrum_enforcement import enforcement_rules + + merged = list(profile.rules or []) + merged.extend(enforcement_rules(profile)) + merged = merge_capability_rules(profile, merged) + merged = merge_schema_rules(profile, merged) + if session_rules: + merged.extend(session_rules) + return merged + + def _build_session( agent: AgentHandle, mode: str | None, @@ -158,15 +177,7 @@ def _build_session( session_mode = SessionMode(mode_str) except ValueError: session_mode = SessionMode.SCRIPT - merged_rules = list(agent.profile.rules) - from aura.core.spectrum_enforcement import enforcement_rules - - merged_rules.extend(enforcement_rules(agent.profile)) - from aura.core.capabilities import merge_capability_rules - - merged_rules = merge_capability_rules(agent.profile, merged_rules) - if rules: - merged_rules.extend(rules) + merged_rules = merged_profile_rules(agent.profile, session_rules=rules) from aura.sequencer.spec import merge_sequencer_spec seq_spec = merge_sequencer_spec(agent.profile.sequencer, sequencer) diff --git a/aura/cli/commands.py b/aura/cli/commands.py index 6d75e53..a2cbe04 100644 --- a/aura/cli/commands.py +++ b/aura/cli/commands.py @@ -98,8 +98,10 @@ def cmd_agent_show(name: str, *, console: Console | None = None) -> int: else: console.print(message, style="bold #FF9AA2") return 1 + from aura.api import merged_profile_rules from aura.core.capabilities import capabilities_summary, parse_capabilities from aura.core.escalations import escalation_summary + from aura.core.schema_validation import parse_schema_refs, schema_enforcement_enabled from aura.core.spectrum_enforcement import effective_spectrum, enforcement_rules from aura.core.spectrum_identity import identity_policy_summary @@ -108,6 +110,17 @@ def cmd_agent_show(name: str, *, console: Console | None = None) -> int: payload["effective_spectrum"] = spec.summary() payload["effective_spectrum"].update(identity_policy_summary(profile)) payload["effective_spectrum"]["enforcement_rules"] = enforcement_rules(profile) + effective_rules = merged_profile_rules(profile) + schema_rules = [ + r for r in effective_rules if (r.get("type") or r.get("kind")) == "schema_check" + ] + schema_refs = parse_schema_refs(profile.variables) + if schema_refs or schema_rules: + payload["effective_spectrum"]["schema"] = { + "refs": len(schema_refs), + "active_rules": len(schema_rules), + "auto_enforced": schema_enforcement_enabled(profile), + } payload["escalations"] = escalation_summary(profile) caps = parse_capabilities(profile.capabilities, strict=False) payload["capabilities_summary"] = capabilities_summary(caps) diff --git a/aura/core/audit_report.py b/aura/core/audit_report.py index f6150ef..23db33e 100644 --- a/aura/core/audit_report.py +++ b/aura/core/audit_report.py @@ -63,7 +63,13 @@ def build( 1 for e in tool_denied if (e.payload.get("rule") or {}).get("type") - in ("deny_tools", "allow_tools", "capability_scope") + in ("deny_tools", "allow_tools", "capability_scope", "schema_check") + and not e.payload.get("audit_only") + ), + "schema_violations": sum( + 1 + for e in tool_denied + if (e.payload.get("rule") or {}).get("type") == "schema_check" and not e.payload.get("audit_only") ), "capability_audit": sum( @@ -113,6 +119,22 @@ def build( "whose allowed fields match the request, or update profile.capabilities." ) continue + if rtype == "schema_check": + findings.append( + { + "severity": "high", + "code": "SCHEMA_VIOLATION", + "message": event.payload.get("message", "Schema validation failed"), + "rule_type": rtype, + "rule_ref": (rule.get("ref")), + "event_id": event.event_id, + } + ) + recommendations.append( + "Tool args or result did not match the declared JSON Schema — " + "fix the payload or update variables.schema_refs / schema_check rules." + ) + continue findings.append( { "severity": "high", diff --git a/aura/core/conformance.py b/aura/core/conformance.py index a3c51df..3371d03 100644 --- a/aura/core/conformance.py +++ b/aura/core/conformance.py @@ -67,6 +67,14 @@ def summarize( for item in goal_slo_check.get("violations", []): violations.append(item) + schema_check = self._check_schema(spine, declared_rules) + if schema_check: + checks.append(schema_check) + if not schema_check.get("passed", True): + passed = False + for item in schema_check.get("violations", []): + violations.append(item) + return ConformanceReport( passed=passed, violations=violations, @@ -141,3 +149,45 @@ def _check_goal_slo(self, spine: AuditSpine) -> dict[str, Any] | None: "miss_count": len(misses), "violations": violations, } + + def _check_schema( + self, + spine: AuditSpine, + declared_rules: list[dict[str, Any]], + ) -> dict[str, Any] | None: + schema_rules = [ + r for r in declared_rules if (r.get("type") or r.get("kind")) == "schema_check" + ] + if not schema_rules: + return None + + schema_violations = [ + e + for e in spine.stream() + if e.kind == "constraint.violated" + and (e.payload.get("rule") or {}).get("type") == "schema_check" + and not e.payload.get("audit_only") + ] + tool_calls = [e for e in spine.stream() if e.kind == "tool.call"] + tool_results = [e for e in spine.stream() if e.kind == "tool.result"] + + passed = not schema_violations + result: dict[str, Any] = { + "type": "schema", + "declared_schema_rules": len(schema_rules), + "tool_calls": len(tool_calls), + "tool_results": len(tool_results), + "schema_violations": len(schema_violations), + "passed": passed, + } + if not passed: + result["violations"] = [ + { + "kind": "schema.violation", + "message": evt.payload.get("message", "Schema validation failed"), + "event_id": evt.event_id, + "rule_ref": (evt.payload.get("rule") or {}).get("ref"), + } + for evt in schema_violations + ] + return result diff --git a/aura/core/constraints.py b/aura/core/constraints.py index bca2138..4d9c311 100644 --- a/aura/core/constraints.py +++ b/aura/core/constraints.py @@ -202,6 +202,47 @@ def _rule_capability_scope(ctx: ConstraintContext, rule: dict[str, Any]) -> Cons return evaluate_capability_scope(ctx, rule) +def _rule_schema_check(ctx: ConstraintContext, rule: dict[str, Any]) -> ConstraintResult | None: + from aura.core.schema_validation import ( + payload_subject, + resolve_schema, + rule_applies_to_event, + validate_payload, + ) + + if not rule_applies_to_event(rule, ctx.event_kind, ctx.payload): + return None + + schema = resolve_schema(rule, ctx.session_state) + if not schema: + ref = rule.get("ref") + return ConstraintResult( + passed=False, + rule=rule, + message=f"Schema ref not found or invalid: {ref or '(inline schema missing)'}", + blocked=True, + ) + + subject = payload_subject(ctx.payload, ctx.event_kind) + errors = validate_payload(schema, subject) + if errors: + tool = ctx.payload.get("tool") or ctx.payload.get("skill_id") or "unknown" + detail = "; ".join(errors[:3]) + if len(errors) > 3: + detail += f" (+{len(errors) - 3} more)" + return ConstraintResult( + passed=False, + rule=rule, + message=f"Schema validation failed for {tool} on {ctx.event_kind}: {detail}", + blocked=True, + ) + return ConstraintResult( + passed=True, + rule=rule, + message=f"Schema valid for {ctx.event_kind}", + ) + + def _rule_escalation_pause(ctx: ConstraintContext, rule: dict[str, Any]) -> ConstraintResult | None: if ctx.event_kind not in ("tool.call", "action.request"): return None @@ -231,4 +272,5 @@ def _rule_escalation_pause(ctx: ConstraintContext, rule: dict[str, Any]) -> Cons "sequencer_required": _rule_sequencer_required, "escalation_pause": _rule_escalation_pause, "capability_scope": _rule_capability_scope, + "schema_check": _rule_schema_check, } diff --git a/aura/core/schema_validation.py b/aura/core/schema_validation.py new file mode 100644 index 0000000..3f3c6df --- /dev/null +++ b/aura/core/schema_validation.py @@ -0,0 +1,163 @@ +"""JSON Schema validation for tool.call / tool.result at egress (#78).""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from aura.agents.profile import AgentProfile + +STATE_SCHEMA_REFS = "_schema_refs" + + +def parse_schema_refs(variables: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + """ + Parse ``variables.schema_refs`` into a ref → spec map. + + Each entry may include ``tool``, ``skill_id``, ``on`` (call|result|both), + and inline ``schema`` (JSON Schema object). + """ + if not isinstance(variables, dict): + return {} + raw = variables.get("schema_refs") + if not isinstance(raw, dict): + return {} + refs: dict[str, dict[str, Any]] = {} + for name, spec in raw.items(): + if not isinstance(spec, dict): + continue + schema = spec.get("schema") + if not isinstance(schema, dict): + continue + refs[str(name)] = dict(spec) + return refs + + +def rules_from_schema_refs( + refs: dict[str, dict[str, Any]], + *, + source: str = "profile", + level: str | None = None, +) -> list[dict[str, Any]]: + """Build ``schema_check`` rules from named schema refs.""" + rules: list[dict[str, Any]] = [] + for ref_name, spec in refs.items(): + rule: dict[str, Any] = { + "type": "schema_check", + "ref": ref_name, + "schema": spec["schema"], + "source": source, + } + if spec.get("tool"): + rule["tool"] = spec["tool"] + if spec.get("skill_id"): + rule["skill_id"] = spec["skill_id"] + on = spec.get("on") or spec.get("phase") or "call" + rule["on"] = str(on).lower() + if level: + rule["level"] = level + rules.append(rule) + return rules + + +def schema_enforcement_enabled(profile: AgentProfile) -> bool: + """Whether spectrum auto-wires schema refs at high/full bind.""" + from aura.core.spectrum_enforcement import effective_spectrum + + spectrum = profile.spectrum or {} + if spectrum.get("schema_enforcement") is False: + return False + level = effective_spectrum(profile).level.lower() + return level in {"high", "full"} + + +def merge_schema_rules( + profile: AgentProfile, + merged_rules: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + Append auto-generated schema rules when spectrum high/full and refs exist. + + Explicit ``schema_check`` entries in ``profile.rules`` are left untouched. + Mid-only profiles are not auto-wired unless rules are explicit. + """ + refs = parse_schema_refs(profile.variables) + if not refs or not schema_enforcement_enabled(profile): + return merged_rules + from aura.core.spectrum_enforcement import effective_spectrum + + level = effective_spectrum(profile).level.lower() + auto = rules_from_schema_refs(refs, source="spectrum", level=level) + if not auto: + return merged_rules + return list(merged_rules) + auto + + +def attach_schema_state(session: Any) -> None: + """Store parsed schema refs on session state for ``ref`` resolution.""" + session.state[STATE_SCHEMA_REFS] = parse_schema_refs(session.profile.variables) + + +def resolve_schema(rule: dict[str, Any], session_state: dict[str, Any]) -> dict[str, Any] | None: + """Inline ``schema`` on the rule, or lookup ``ref`` in session state.""" + schema = rule.get("schema") + if isinstance(schema, dict): + return schema + ref_name = rule.get("ref") + if not ref_name: + return None + refs = session_state.get(STATE_SCHEMA_REFS) or {} + spec = refs.get(str(ref_name)) + if not isinstance(spec, dict): + return None + inner = spec.get("schema") + return inner if isinstance(inner, dict) else None + + +def validate_payload(schema: dict[str, Any], data: Any) -> list[str]: + """Validate data against JSON Schema; return human-readable error strings.""" + try: + import jsonschema + except ImportError: + return ["jsonschema package required for schema_check rules (pip install jsonschema)"] + + validator = jsonschema.Draft202012Validator(schema) + errors: list[str] = [] + for err in sorted(validator.iter_errors(data), key=lambda e: list(e.path)): + path = ".".join(str(p) for p in err.path) or "(root)" + errors.append(f"{path}: {err.message}") + return errors + + +def payload_subject(payload: dict[str, Any], event_kind: str) -> Any: + """Extract the value to validate from a tool event payload.""" + if event_kind == "tool.call": + args = payload.get("args") + return args if isinstance(args, dict) else {} + if event_kind == "tool.result": + return payload.get("result") + return None + + +def rule_applies_to_event(rule: dict[str, Any], event_kind: str, payload: dict[str, Any]) -> bool: + """Whether this schema_check rule applies to the current event.""" + on = str(rule.get("on") or rule.get("phase") or "call").lower() + if event_kind == "tool.call" and on not in {"call", "both"}: + return False + if event_kind == "tool.result" and on not in {"result", "both"}: + return False + if event_kind not in {"tool.call", "tool.result"}: + return False + + tool = rule.get("tool") + if tool: + payload_tool = payload.get("tool") or payload.get("name") or payload.get("tool_name") + skill_id = payload.get("skill_id") + if str(tool) not in {str(payload_tool), str(skill_id)}: + return False + + skill_id = rule.get("skill_id") + if skill_id and str(payload.get("skill_id")) != str(skill_id): + return False + + return True diff --git a/aura/core/session.py b/aura/core/session.py index 9781015..eda0c0f 100644 --- a/aura/core/session.py +++ b/aura/core/session.py @@ -120,12 +120,14 @@ def open( self._open = True self._attach_profile_observers() from aura.core.capabilities import attach_capability_state + from aura.core.schema_validation import attach_schema_state from aura.core.escalations import attach_escalation_engine, escalation_summary from aura.core.spectrum_enforcement import effective_spectrum, enforcement_rules from aura.core.spectrum_identity import resolve_verified_identity_required from aura.core.spectrum_services import attach_spectrum_services services_activation = attach_spectrum_services(self) + attach_schema_state(self) spectrum = effective_spectrum(self.profile) spectrum_meta = spectrum.summary() spectrum_meta["enforcement_rule_count"] = len(enforcement_rules(self.profile)) @@ -142,6 +144,18 @@ def open( capability_meta = attach_capability_state(self, secret_broker) if capability_meta.get("count"): spectrum_meta["capabilities"] = capability_meta + from aura.core.schema_validation import parse_schema_refs, schema_enforcement_enabled + + schema_refs = parse_schema_refs(self.profile.variables) + schema_rules = sum( + 1 for rule in self.rules if (rule.get("type") or rule.get("kind")) == "schema_check" + ) + if schema_refs or schema_rules: + spectrum_meta["schema"] = { + "refs": len(schema_refs), + "active_rules": schema_rules, + "auto_enforced": schema_enforcement_enabled(self.profile), + } self.emit( "membrane.ingress", ingress_event_payload(self.profile, self.mode.value, self.snapshot_hash), diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 97bba83..31ddadc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Shipped work stays in [CHANGELOG.md](../CHANGELOG.md). This file lists what is * | Item | Why | |---|---| -| **Constitution / schema checks at high bind** | Skill allowlist and capability scope shipped; broader manifest schema validation at egress still open | +| ~~Constitution / schema checks at high bind~~ | Shipped ([#78](https://github.com/ARPAHLS/aura/issues/78)) — `variables.schema_refs`, `schema_check` rules, spectrum auto-wire at high/full | | Brain / memory adapters | Plug models and retention without core changes | | Middleware ops | PII mask, compress — schema exists | | Signed audit packs | WORM / external sink hooks | diff --git a/docs/TESTING.md b/docs/TESTING.md index 8c2de1b..7f08cb1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,7 +78,7 @@ The workflow also emits a gate job named **`lint-test`** that succeeds only when - **New behavior needs a test** — extend the closest file (`test_core.py`, `test_v02.py`, `test_v03.py`, `test_cli.py`, or `test_core_gaps.py`). - Shared fixtures live in **`tests/conftest.py`** — do not duplicate `aura_home` in test modules. - Optional Skillware registry tests: `tests/test_skillware_integration.py` (`@pytest.mark.skillware`) — run in CI via the **skillware-live** job when `[skillware]` is installed ([#36](https://github.com/ARPAHLS/aura/issues/36)). -- **Host stress simulation:** `python scripts/aura_host_stress_sim.py` — thirty-three scenarios (loose/tight/tailored coats, spectrum low/mid/high/full bind, verified-identity gate edge cases, escalation SLO playbook, capability broker allow/deny/low-audit/bypass/high/escalation, single/multi/chain Skillware paths, sequencer, observers, export compare). CI: `tests/test_host_stress_sim.py` (`@pytest.mark.skillware`). +- **Host stress simulation:** `python scripts/aura_host_stress_sim.py` — thirty-four scenarios (loose/tight/tailored coats, spectrum low/mid/high/full bind, schema high bind, verified-identity gate edge cases, escalation SLO playbook, capability broker allow/deny/low-audit/bypass/high/escalation, single/multi/chain Skillware paths, sequencer, observers, export compare). CI: `tests/test_host_stress_sim.py` (`@pytest.mark.skillware`). - **Capability broker stress:** `python scripts/aura_capability_stress_sim.py` — sixteen scenarios (payment + generic GitHub-style scope, inject, redaction, spectrum, bypass, brokers, plaintext reject). CI: `tests/test_capability_stress_sim.py` (default gate, no Skillware extra). - **Capability broker:** `tests/test_capability_broker.py` — profile refs-only, allow/deny, low audit-only, inject, leak scrub, CLI ([#48](https://github.com/ARPAHLS/aura/issues/48)); example smoke `tests/test_example_14_capability_broker.py`. - **Coat flow report:** `python scripts/aura_coat_flow_report.py --json` — full session breakdown per spectrum level; CI: `tests/test_coat_flow_report.py`. diff --git a/docs/architecture.md b/docs/architecture.md index 106702f..bb10c45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ flowchart LR | `aura/core/spectrum_identity.py` | `spectrum.identity_required` + level defaults → verified operator gate at session open ([#73](https://github.com/ARPAHLS/aura/issues/73)) | | `aura/core/escalations.py` | `escalations[]` playbooks on SLO/drift/alert/constraint triggers ([#47](https://github.com/ARPAHLS/aura/issues/47)) | | `aura/core/capabilities.py` | `capabilities[]` parse, `capability_scope`, last-moment inject ([#48](https://github.com/ARPAHLS/aura/issues/48)) | +| `aura/core/schema_validation.py` | `variables.schema_refs`, `schema_check` egress validation ([#78](https://github.com/ARPAHLS/aura/issues/78)) | | `aura/core/secrets.py` | SecretBroker protocol (env / map / callable / chain) | | `aura/core/payload_redaction.py` | Secret-like keys and injected values stripped from spine payloads | | `aura/membrane/` | Ingress context, egress guarded calls | diff --git a/docs/aura-levels.md b/docs/aura-levels.md index 04e03b8..c291336 100644 --- a/docs/aura-levels.md +++ b/docs/aura-levels.md @@ -62,14 +62,30 @@ spectrum: limit: max_tool_calls_per_minute: 30 strict_services: false # true → observer.alert on unknown service names + schema_enforcement: true # false → do not auto-wire variables.schema_refs at high/full + +variables: + schema_refs: + sql_write: + tool: sql.append + on: call # call | result | both + schema: + type: object + properties: + amount: { type: number, maximum: 50 } + required: [amount] ``` +Explicit `schema_check` rules in `profile.rules` apply at any level. **`variables.schema_refs`** auto-wire at **high** / **full** only (unless `schema_enforcement: false`). Mid profiles need explicit rules. + +Finding code on violation: **`SCHEMA_VIOLATION`** in the audit report. + | Level | Coat | Enforcement | Default services (when `services` omitted) | |---|---|---|---| | **low** | Loose | Audit only — explicit profile `rules` still apply; off-scope tools pass; capability misses are recorded (`audit_only`) and the call still runs without inject | none | | **mid** | Tight | Explicit profile rules only (default when unset); capability misses **block** | none | -| **high** | Tight | Auto `allow_tools` from profile `skills` (+ sequencer refs + capability tools) | `monitor` | verified operator **required** (opt out) | -| **full** | Tailored | High bind + `tool.call` must include `step_id` (sequencer bind) | `monitor`, `break` | verified operator **required** (opt out) | +| **high** | Tight | Auto `allow_tools` from profile `skills` (+ sequencer refs + capability tools); auto `schema_check` from `variables.schema_refs` when present | `monitor` | verified operator **required** (opt out) | +| **full** | Tailored | High bind + `tool.call` must include `step_id` (sequencer bind); schema refs same as high | `monitor`, `break` | verified operator **required** (opt out) | **Verified identity** — lite `aura_id` / `agent_ref` always exist for audit trails. **Verified** operator identity comes from your IdP adapter (OIDC, Auth0, mock). When `identity_required` is true (explicitly or by high/full default), session open **fails** if no verified operator resolves — not warn-only. Opt out per profile with `spectrum.identity_required: false`, or override a single run with `aura run --require-identity`. diff --git a/docs/capabilities.md b/docs/capabilities.md index a103bec..5ee9109 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -135,7 +135,7 @@ Applied on **every** `session.emit` (JSONL, then summary and OTel inherit it): ## Limits (not in this change) - **One secret per capability** (`inject_as` is a single args key). AWS-style key+secret pairs need two capabilities or a callable broker that returns a composite the host unpacks. -- **Exact / list / wildcard equality** on `allowed` — not ranges (`amount <= 50`), regex, or JSON Schema. Broader constitution/schema checks remain on the roadmap. +- **Exact / list / wildcard equality** on `allowed` — not ranges or regex. Use **`variables.schema_refs`** or **`schema_check`** rules for JSON Schema on tool args/results ([#78](https://github.com/ARPAHLS/aura/issues/78)). - **Allowed values are labels**, not credentials. Strict parse rejects secret-like **keys** (`token`, `api_key`, …) and values that look like live tokens (`sk-…`, `tok_…`, `ghp_…`, …). Ordinary long strings (`acme/private-ledger`, `organic whole milk`) are valid. - **Inject runs only on `guarded_tool_call` / ToolHost execute.** Direct `run.emit("tool.call", …)` still enforces scope but does not inject (there is no execute). - **Not** rewind, retry-N, or Skillware `SecretProvider` types. diff --git a/docs/comparison.md b/docs/comparison.md index a9f4ef5..f62ff78 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -225,7 +225,8 @@ Honest scope — reference ToolHost coat, membrane presets, spectrum bind, opera | **Sequencer** — linear steps, gates, retries, **`when`** skip | | | **Observers** — Monitor + Break + Limit + goal drift + schedule SLO presets ([#77](https://github.com/ARPAHLS/aura/issues/77)) | Webhooks | | **Escalation playbooks** — `escalations[]` on drift / SLO miss / alerts / `constraint.violated` ([#47](https://github.com/ARPAHLS/aura/issues/47)) | Real email/webhook delivery via coat ops ([#49](https://github.com/ARPAHLS/aura/issues/49)) | -| **Spectrum** — `spectrum.level` bind + `spectrum.services[]` runtime activation at session open ([#27](https://github.com/ARPAHLS/aura/issues/27), [#77](https://github.com/ARPAHLS/aura/issues/77)) | Constitution schema validation at high bind | +| **Spectrum** — `spectrum.level` bind + `spectrum.services[]` runtime activation ([#27](https://github.com/ARPAHLS/aura/issues/27), [#77](https://github.com/ARPAHLS/aura/issues/77)) | — | +| **Schema at egress** — `variables.schema_refs` + `schema_check` at high/full ([#78](https://github.com/ARPAHLS/aura/issues/78)) | — | | **Skill manifest merge** at bind | Capability broker | | **OTel exporter** + promoted span attributes (incl. operator) | HTTP fleet API | | **CLI** — `report show`, `agent set`, config/paths, `identity show`, onboarding guide | | diff --git a/docs/concepts.md b/docs/concepts.md index 1936e66..3e30c34 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -63,7 +63,9 @@ Rules, guardrails, and constraints the run must obey — on the agent profile, i A constraint checked when relevant events are emitted. -Built-in types: `max_tokens_per_step`, `confirm_before`, `allow_tools`, `deny_tools`, `sequencer_required`, `escalation_pause`, `capability_scope`. +Built-in types: `max_tokens_per_step`, `confirm_before`, `allow_tools`, `deny_tools`, `sequencer_required`, `escalation_pause`, `capability_scope`, `schema_check`. + +Profile `variables.schema_refs` holds named JSON Schema specs; at spectrum **high** / **full** they auto-wire as `schema_check` rules unless `spectrum.schema_enforcement: false`. Mid profiles use explicit `schema_check` rules only. ## Conformance diff --git a/docs/onboarding.md b/docs/onboarding.md index f65ae1e..4a02fd1 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -66,7 +66,8 @@ aura agent create research-bot \ | **`aura_id`** | Internal ULID (auto unless you set `--aura-id`) | | **`policy_version`** | Tie runs to a policy snapshot (profile or per-session) | | **`purpose`** | Declared intent — appears in profile and spine | -| **`rules`** | Constitution — `confirm_before`, `allow_tools`, `deny_tools`, token limits, `capability_scope` | +| **`rules`** | Constitution — `confirm_before`, `allow_tools`, `deny_tools`, token limits, `capability_scope`, `schema_check` | +| **`variables.schema_refs`** | Named JSON Schema specs per tool — auto-wired at spectrum high/full ([#78](https://github.com/ARPAHLS/aura/issues/78)) | | **`capabilities[]`** | Named intents + secret **refs** (broker injects at egress; never store values) — [capabilities.md](capabilities.md) | | **`ids`** | Your external IDs (company, vendor assistant id) — AURA does not replace them | | **`ids.operator`** (optional) | Human/service principal when using an identity adapter — see [integrations/identity](../integrations/identity/README.md) | diff --git a/docs/outputs.md b/docs/outputs.md index ad7bdf5..5e48c8a 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -28,6 +28,8 @@ CLI: `aura report show `, `aura report show --json`, `a **Spine events (capabilities):** When `profile.capabilities[]` is set, `session.open` spectrum summary includes a capabilities block (`count`, `ids`, `gated_tools`, `secret_refs`, broker kind). Scope misses emit `constraint.violated` (with `audit_only` at spectrum `low`). `constraint.passed` lists only rules that passed. Successful inject emits `capability.injected` (capability id + secret **ref**, never the value). Secret-like payload keys (`token`, `api_key`, …) are redacted on every emit, including sessions without capabilities. Findings: `CAPABILITY_DENIED`, `CAPABILITY_AUDIT`, `SECRET_BROKER_ERROR`. See [capabilities.md](capabilities.md). +**Spine events (schema):** When `variables.schema_refs` or `schema_check` rules are active, `session.open` spectrum summary includes a `schema` block (`refs`, `active_rules`, `auto_enforced`). Malformed `tool.call` args or `tool.result` payloads emit `constraint.violated`. Audit finding: **`SCHEMA_VIOLATION`**. Conformance includes a `schema` check on close. See [aura-levels.md](aura-levels.md#profile-spec). + --- ## Audit report (summary JSON) @@ -50,7 +52,7 @@ Use `aura report show` for a human-readable audit report from the session summar ## Conformance -Binary pass/fail plus violations list — declared rules and sequencer step order vs observed spine. +Binary pass/fail plus violations list — declared rules, sequencer step order, goal/SLO drift, and schema rule violations vs observed spine. --- diff --git a/docs/sequencer.md b/docs/sequencer.md index 8ac22bc..51808fd 100644 --- a/docs/sequencer.md +++ b/docs/sequencer.md @@ -73,7 +73,7 @@ Each step emits telemetry on the audit spine: `sequencer.step.start`, `sequencer | Gate | When | |---|---| | `human_confirm` | Raises approval; resume with `run.approve(request_id)` | -| `constitution` | Emits gate event; rules enforced on egress | +| `constitution` | Emits gate event; rules enforced on egress (including `schema_check` when declared) | | `budget` | Emits gate event; token rules apply on tool events | ### Conditional steps (`when`) diff --git a/docs/skillware-integration.md b/docs/skillware-integration.md index bc34132..a00dac5 100644 --- a/docs/skillware-integration.md +++ b/docs/skillware-integration.md @@ -81,7 +81,7 @@ Every execution emits: 1. `skill.registered` — when the skill carries a manifest (merged into session rules) 2. `tool.intent` — egress intent -3. `tool.call` — constraint checks (allow/deny, confirm_before, `capability_scope` when `capabilities[]` is set, …) +3. `tool.call` — constraint checks (allow/deny, confirm_before, `capability_scope` when `capabilities[]` is set, `schema_check` when refs or rules are set, …) 4. `tool.result` or `tool.error` ### Manifest guardrails at bind diff --git a/docs/using-aura.md b/docs/using-aura.md index 0018be8..40e3ff9 100644 --- a/docs/using-aura.md +++ b/docs/using-aura.md @@ -33,6 +33,8 @@ When the profile has a `spectrum` block but omits `services`, level defaults wir **Capabilities ([#48](https://github.com/ARPAHLS/aura/issues/48)):** `profile.capabilities[]` names intents and **secret refs** (not values). Egress `capability_scope` checks `capability_id` + allowed fields; a SecretBroker injects the live token only after allow. Spectrum `low` records misses without blocking; `mid`+ blocks. See [capabilities.md](capabilities.md). +**Schema at egress ([#78](https://github.com/ARPAHLS/aura/issues/78)):** `variables.schema_refs` holds named JSON Schema specs per tool (`on`: `call`, `result`, or `both`). At spectrum **high** / **full**, refs auto-wire as `schema_check` rules unless `spectrum.schema_enforcement: false`. **Mid** profiles need explicit `schema_check` rules in `profile.rules`. Violations block egress and emit audit finding `SCHEMA_VIOLATION`. Requires `jsonschema` (core dependency). + See [aura-levels.md](aura-levels.md) for the level table. Debug a full session receipt: `python scripts/aura_coat_flow_report.py --json`. AURA is the **harness (coat)**, not the runtime. Your **body** owns the loop; AURA wraps it with **membrane** boundaries and an **audit trail**. diff --git a/pyproject.toml b/pyproject.toml index ec62386..7920f2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ classifiers = [ "Topic :: System :: Monitoring", ] dependencies = [ + "jsonschema>=4.21", "pyyaml>=6.0", "rich>=13.0", ] diff --git a/scripts/aura_host_stress_sim.py b/scripts/aura_host_stress_sim.py index 6333aaf..2dbd7ea 100644 --- a/scripts/aura_host_stress_sim.py +++ b/scripts/aura_host_stress_sim.py @@ -449,6 +449,45 @@ def scenario_spectrum_low_off_scope() -> ScenarioResult: ) +def scenario_schema_high_bind() -> ScenarioResult: + """Spectrum high + schema_refs — malformed tool.call args blocked.""" + schema_refs = { + "write_cap": { + "tool": "sql.append", + "on": "call", + "schema": { + "type": "object", + "properties": {"amount": {"type": "number", "maximum": 50}}, + "required": ["amount"], + }, + } + } + ag = agent( + "stress-schema-high", + skills=["sql.append"], + spectrum={"level": "high", "identity_required": False}, + variables={"schema_refs": schema_refs}, + ) + blocked = False + with ag.session(mode="script", export=False) as run: + try: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 99}}) + except ConstraintViolation: + blocked = True + _assert(blocked, "high bind schema must block malformed args") + with ag.session(mode="script", export=False) as run2: + run2.emit("tool.call", {"tool": "sql.append", "args": {"amount": 10}}) + kinds = _kinds(run2._session) + _assert("tool.call" in kinds, "valid schema args should pass") + return ScenarioResult( + name="schema_high_bind", + coat="tight", + skillware_mode="none", + passed=True, + metrics={"blocked_bad_args": blocked}, + ) + + def scenario_spectrum_high_bind() -> ScenarioResult: """Spectrum high — skill allowlist blocks off-scope; declared skill passes via host.""" ag = agent("stress-spec-high", skills=[FIREWALL], spectrum={"level": "high"}) @@ -1082,6 +1121,7 @@ def scenario_capability_escalation() -> ScenarioResult: ("skillcontext + host", scenario_skillcontext_metadata_only, True), ("spectrum low off-scope", scenario_spectrum_low_off_scope, False), ("spectrum mid off-scope", scenario_spectrum_mid_off_scope, False), + ("schema high bind", scenario_schema_high_bind, False), ("spectrum high bind", scenario_spectrum_high_bind, True), ("spectrum full host block", scenario_spectrum_full_host_block, True), ("spectrum services no block", scenario_spectrum_services_no_block, False), diff --git a/spec/manifest.schema.json b/spec/manifest.schema.json index 9f6519f..72fa457 100644 --- a/spec/manifest.schema.json +++ b/spec/manifest.schema.json @@ -59,6 +59,11 @@ "type": "boolean", "description": "Require verified operator identity at session open (high/full default true when set)" }, + "schema_enforcement": { + "type": "boolean", + "default": true, + "description": "When true (default), high/full auto-wires schema_check rules from profile variables.schema_refs" + }, "output": { "type": "array", "items": { "type": "string" } diff --git a/tests/test_schema_checks.py b/tests/test_schema_checks.py new file mode 100644 index 0000000..b54e581 --- /dev/null +++ b/tests/test_schema_checks.py @@ -0,0 +1,291 @@ +"""Egress schema and constitution checks at high bind (#78).""" + +from __future__ import annotations + +import json + +import pytest + +from aura import agent +from aura.core.audit_report import AuditReportBuilder +from aura.core.conformance import ConformanceEngine +from aura.core.constraints import ConstraintEngine, ConstraintContext, ConstraintViolation +from aura.core.schema_validation import ( + merge_schema_rules, + parse_schema_refs, + rules_from_schema_refs, +) +from aura.membrane.egress import guarded_tool_call +from tests.spectrum_helpers import spectrum_block + +WRITE_SCHEMA = { + "type": "object", + "properties": {"amount": {"type": "number", "maximum": 50}}, + "required": ["amount"], +} + +SCHEMA_REFS = { + "sql_write": { + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } +} + + +def test_parse_schema_refs(): + refs = parse_schema_refs({"schema_refs": SCHEMA_REFS, "goal": "nickel"}) + assert "sql_write" in refs + assert refs["sql_write"]["tool"] == "sql.append" + + +def test_rules_from_schema_refs(): + rules = rules_from_schema_refs(parse_schema_refs({"schema_refs": SCHEMA_REFS})) + assert len(rules) == 1 + assert rules[0]["type"] == "schema_check" + assert rules[0]["ref"] == "sql_write" + + +def test_schema_check_engine_blocks_malformed(): + engine = ConstraintEngine() + results = engine.evaluate( + ConstraintContext( + event_kind="tool.call", + payload={"tool": "sql.append", "args": {"amount": 99}}, + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } + ], + session_state={"_schema_refs": SCHEMA_REFS}, + ) + ) + assert results[0].blocked is True + assert "maximum" in results[0].message.lower() or "50" in results[0].message + + +def test_schema_check_engine_passes_valid(): + engine = ConstraintEngine() + results = engine.evaluate( + ConstraintContext( + event_kind="tool.call", + payload={"tool": "sql.append", "args": {"amount": 25}}, + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } + ], + session_state={}, + ) + ) + assert results[0].passed is True + + +def test_mid_does_not_auto_wire_schema_refs(aura_home): + ag = agent( + "schema-mid", + skills=["sql.append"], + spectrum={"level": "mid"}, + variables={"schema_refs": SCHEMA_REFS}, + ) + with ag.session(export=False) as run: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 999}}) + kinds = [e.kind for e in run._session.spine.stream()] + assert "tool.call" in kinds + assert "constraint.violated" not in kinds + + +def test_high_auto_wires_schema_refs_blocks(aura_home): + ag = agent( + "schema-high", + skills=["sql.append"], + spectrum=spectrum_block("high"), + variables={"schema_refs": SCHEMA_REFS}, + ) + with ag.session(export=False) as run: + with pytest.raises(ConstraintViolation): + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 999}}) + + +def test_high_auto_wires_schema_refs_passes(aura_home): + ag = agent( + "schema-high-ok", + skills=["sql.append"], + spectrum=spectrum_block("high"), + variables={"schema_refs": SCHEMA_REFS}, + ) + with ag.session(export=False) as run: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 10}}) + assert any(e.kind == "tool.call" for e in run._session.spine.stream()) + + +def test_schema_enforcement_opt_out(aura_home): + ag = agent( + "schema-opt-out", + skills=["sql.append"], + spectrum={**spectrum_block("high"), "schema_enforcement": False}, + variables={"schema_refs": SCHEMA_REFS}, + ) + with ag.session(export=False) as run: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 999}}) + assert "constraint.violated" not in [e.kind for e in run._session.spine.stream()] + + +def test_explicit_schema_rule_at_mid(aura_home): + ag = agent( + "schema-explicit-mid", + skills=["sql.append"], + spectrum={"level": "mid"}, + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } + ], + ) + with ag.session(export=False) as run: + with pytest.raises(ConstraintViolation): + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 100}}) + + +def test_schema_check_on_tool_result(aura_home): + ag = agent( + "schema-result", + skills=["sql.append"], + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "result", + "schema": {"type": "object", "properties": {"ok": {"const": True}}}, + } + ], + ) + with ag.session(export=False) as run: + with pytest.raises(ConstraintViolation): + run.emit( + "tool.result", + {"tool": "sql.append", "args": {}, "result": {"ok": False}}, + ) + run.emit( + "tool.result", + {"tool": "sql.append", "args": {}, "result": {"ok": True}}, + ) + + +def test_guarded_tool_call_schema_block(aura_home): + ag = agent( + "schema-egress", + skills=["sql.append"], + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } + ], + ) + with ag.session(export=False) as run: + with pytest.raises(ConstraintViolation): + guarded_tool_call( + run._session, + tool="sql.append", + args={"amount": 75}, + execute=lambda _args: {"ok": True}, + ) + + +def test_audit_report_schema_violation_finding(aura_home): + ag = agent( + "schema-audit", + skills=["sql.append"], + rules=[ + { + "type": "schema_check", + "tool": "sql.append", + "on": "call", + "schema": WRITE_SCHEMA, + } + ], + ) + with ag.session(export=False) as run: + try: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 200}}) + except ConstraintViolation: + pass + conf = ConformanceEngine().summarize(run._session.spine, run._session.declared_rules) + report = AuditReportBuilder().build(run._session.spine, conf) + assert any(f["code"] == "SCHEMA_VIOLATION" for f in report.findings) + + +def test_conformance_schema_check(aura_home): + ag = agent( + "schema-conf", + skills=["sql.append"], + spectrum=spectrum_block("high"), + variables={"schema_refs": SCHEMA_REFS}, + ) + with ag.session(export=False) as run: + try: + run.emit("tool.call", {"tool": "sql.append", "args": {"amount": 500}}) + except ConstraintViolation: + pass + conf = ConformanceEngine().summarize(run._session.spine, run._session.declared_rules) + schema_check = next(c for c in conf.checks if c.get("type") == "schema") + assert schema_check["passed"] is False + assert schema_check["schema_violations"] >= 1 + + +def test_merge_schema_rules_appends_at_high(): + from aura.agents.profile import AgentProfile + + profile = AgentProfile( + aura_id="x", + skills=["sql.append"], + spectrum={"level": "high"}, + variables={"schema_refs": SCHEMA_REFS}, + ) + merged = merge_schema_rules(profile, [{"type": "allow_tools", "tools": ["sql.append"]}]) + assert any(r.get("type") == "schema_check" for r in merged) + + +def test_merged_profile_rules_includes_schema_at_high(): + from aura.agents.profile import AgentProfile + from aura.api import merged_profile_rules + + profile = AgentProfile( + aura_id="x", + skills=["sql.append"], + spectrum={"level": "high", "identity_required": False}, + variables={"schema_refs": SCHEMA_REFS}, + ) + merged = merged_profile_rules(profile) + assert any(r.get("type") == "schema_check" for r in merged) + + +def test_cli_agent_show_includes_schema_block(aura_home, run_aura): + from aura import agent + + agent( + "schema-cli", + agent_ref="acme/schema-cli", + skills=["sql.append"], + spectrum={"level": "high", "identity_required": False}, + variables={"schema_refs": SCHEMA_REFS}, + ) + show = run_aura("agent", "show", "acme/schema-cli") + assert show.returncode == 0 + payload = json.loads(show.stdout) + schema = payload["effective_spectrum"].get("schema") or {} + assert schema.get("refs") == 1 + assert schema.get("active_rules", 0) >= 1 + assert schema.get("auto_enforced") is True