From 3c0774d70a2b4966126be8af3e5a90a0a0c09c60 Mon Sep 17 00:00:00 2001 From: rosspeili Date: Fri, 9 Oct 2026 20:26:30 +0300 Subject: [PATCH] Add capability inject for multiple secrets and HTTP headers (#86) --- CHANGELOG.md | 1 + aura/__init__.py | 4 + aura/api.py | 4 +- aura/core/capabilities.py | 211 +++++++++++-- aura/hosts/mock.py | 22 +- aura/hosts/protocol.py | 9 +- aura/hosts/skillware.py | 24 +- aura/hosts/skillware_adapter.py | 16 +- aura/membrane/egress.py | 43 ++- docs/capabilities.md | 30 +- docs/outputs.md | 2 +- examples/14-capability-broker/README.md | 21 ++ examples/14-capability-broker/main.py | 52 ++++ scripts/aura_capability_stress_sim.py | 95 ++++++ tests/test_capability_broker.py | 326 +++++++++++++++++++++ tests/test_example_14_capability_broker.py | 1 + 16 files changed, 807 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9051e04..f425e74 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 +- **Capability inject for multiple secrets and HTTP headers ([#86](https://github.com/ARPAHLS/aura/issues/86))** — capability declarations support multi-target secret injection via `inject: [{ref, as, prefix}]` alongside single-secret `secret: {ref, as}` backward compatibility; supports nested dot-paths into `args` (e.g. `aws_access_key_id`) and `headers` (e.g. `headers.Authorization`); optional value `prefix` (e.g. `"Bearer "`); `ToolHost.execute` protocol and `SkillwareHost.execute` forward `headers` to executor callables; agent spoofed header keys stripped at egress; all injected secret values registered for leak scrubbing on `tool.result`; `strict_args` reserves all injected target paths; `InjectTarget` exported from `aura` and `aura.core.capabilities`; CLI parity; stress sim scenarios `capability_multi_secret_dual` and `capability_header_authorization` (18 scenarios total). - **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). diff --git a/aura/__init__.py b/aura/__init__.py index 76dc574..5fd1d5a 100644 --- a/aura/__init__.py +++ b/aura/__init__.py @@ -16,7 +16,9 @@ create_agent, current_session, list_agents, + Capability, CapabilityConfigError, + InjectTarget, SecretBrokerError, SecretConfigError, SecretNotFoundError, @@ -45,7 +47,9 @@ "OperatorIdentityAdapter", "IdentityRequiredError", "IdentityVerificationError", + "Capability", "CapabilityConfigError", + "InjectTarget", "SecretBrokerError", "SecretConfigError", "SecretNotFoundError", diff --git a/aura/api.py b/aura/api.py index a15881a..229ccc8 100644 --- a/aura/api.py +++ b/aura/api.py @@ -23,7 +23,7 @@ SecretConfigError, SecretNotFoundError, ) -from aura.core.capabilities import CapabilityConfigError +from aura.core.capabilities import Capability, CapabilityConfigError, InjectTarget from aura.core.session import Session, SessionMode from aura.exporters.jsonl import build_session_summary, export_session from aura.identity.bind import IdentityOptions @@ -240,7 +240,9 @@ def list_agents(include_archived: bool = False) -> list[AgentProfile]: "OperatorIdentityAdapter", "IdentityRequiredError", "IdentityVerificationError", + "Capability", "CapabilityConfigError", + "InjectTarget", "SecretBrokerError", "SecretConfigError", "SecretNotFoundError", diff --git a/aura/core/capabilities.py b/aura/core/capabilities.py index 9712e76..b6c8f8b 100644 --- a/aura/core/capabilities.py +++ b/aura/core/capabilities.py @@ -43,6 +43,21 @@ class CapabilityConfigError(SecretConfigError): """Invalid ``profile.capabilities[]`` declaration.""" +@dataclass(frozen=True) +class InjectTarget: + """One credential inject target into live execution args or headers.""" + + ref: str + as_: str = "token" + prefix: str | None = None + + def public_dict(self) -> dict[str, Any]: + data: dict[str, Any] = {"ref": self.ref, "as": self.as_} + if self.prefix: + data["prefix"] = self.prefix + return data + + @dataclass(frozen=True) class Capability: """One named intent the agent may request. Secret is a ref only.""" @@ -53,8 +68,22 @@ class Capability: allowed: dict[str, Any] = field(default_factory=dict) secret_ref: str | None = None inject_as: str = "token" + inject: tuple[InjectTarget, ...] = field(default_factory=tuple) strict_args: bool = False description: str | None = None + declared_via_inject: bool = False + + def __post_init__(self) -> None: + if self.inject and not self.secret_ref: + object.__setattr__(self, "secret_ref", self.inject[0].ref) + if self.inject_as == "token" and self.inject[0].as_ != "token": + object.__setattr__(self, "inject_as", self.inject[0].as_) + elif self.secret_ref and not self.inject: + object.__setattr__( + self, + "inject", + (InjectTarget(ref=self.secret_ref, as_=self.inject_as or "token"),), + ) def public_dict(self) -> dict[str, Any]: data: dict[str, Any] = {"id": self.id} @@ -64,10 +93,20 @@ def public_dict(self) -> dict[str, Any]: data["skill_id"] = self.skill_id if self.allowed: data["allowed"] = dict(self.allowed) - if self.secret_ref: + if self.declared_via_inject or len(self.inject) > 1: + data["inject"] = [t.public_dict() for t in self.inject] + elif self.inject: + t = self.inject[0] + if t.prefix: + data["inject"] = [t.public_dict()] + else: + data["secret"] = {"ref": t.ref} + if t.as_ and t.as_ != "token": + data["inject_as"] = t.as_ + elif self.secret_ref: data["secret"] = {"ref": self.secret_ref} - if self.inject_as and self.inject_as != "token": - data["inject_as"] = self.inject_as + if self.inject_as and self.inject_as != "token": + data["inject_as"] = self.inject_as if self.strict_args: data["strict_args"] = True if self.description: @@ -136,6 +175,48 @@ def secret_like_allowed_value(key: str, value: Any) -> bool: return _looks_like_secret_value(value) +def _parse_inject_target(raw: Any, *, strict: bool, cap_id: str) -> InjectTarget: + if raw is None: + raise CapabilityConfigError(f"capability {cap_id!r} inject target cannot be null") + if isinstance(raw, str): + ref = normalize_secret_ref(raw) + if _looks_like_secret_value(ref) and strict: + raise CapabilityConfigError( + f"capability {cap_id!r} inject target ref looks like a live token; " + "store references only (e.g. env:KEY)" + ) + return InjectTarget(ref=ref, as_="token") + if not isinstance(raw, dict): + raise CapabilityConfigError( + f"capability {cap_id!r} inject target must be an object or ref string" + ) + leaked = [key for key in raw if str(key).lower() in FORBIDDEN_SECRET_KEYS] + if leaked: + message = ( + f"capability {cap_id!r} inject target must be a ref only " + f"(forbidden keys: {', '.join(sorted(leaked))})" + ) + if strict: + raise CapabilityConfigError(message) + resolved_ref = _secret_ref_from_block(raw, strict=strict, cap_id=cap_id) + if not resolved_ref: + raise CapabilityConfigError( + f"capability {cap_id!r} inject target requires a valid secret ref" + ) + if _looks_like_secret_value(resolved_ref) and strict: + raise CapabilityConfigError( + f"capability {cap_id!r} inject target ref looks like a live token; " + "store references only (e.g. env:KEY)" + ) + as_target = ( + str(raw.get("as") or raw.get("inject_as") or raw.get("target") or "token").strip() + or "token" + ) + prefix = raw.get("prefix") + prefix_str = str(prefix) if prefix is not None else None + return InjectTarget(ref=resolved_ref, as_=as_target, prefix=prefix_str) + + def parse_capability(raw: Any, *, strict: bool = True) -> Capability | None: if not isinstance(raw, dict): if strict: @@ -152,19 +233,57 @@ def parse_capability(raw: Any, *, strict: bool = True) -> Capability | None: if raw.get("scope") and not allowed_raw: scope = raw.get("scope") allowed_raw = dict(scope) if isinstance(scope, dict) else {} - secret_block = raw.get("secret") if isinstance(raw.get("secret"), dict) else {} - inject_as = ( - str( - raw.get("inject_as") - or secret_block.get("as") - or secret_block.get("inject_as") + + raw_inject = raw.get("inject") + raw_secret = raw.get("secret") + raw_secret_ref = raw.get("secret_ref") + + if (raw_secret is not None or raw_secret_ref is not None) and raw_inject is not None: + if strict: + raise CapabilityConfigError( + f"capability {cap_id!r} cannot declare both 'secret' and 'inject'" + ) + + declared_via_inject = False + targets: list[InjectTarget] = [] + + if raw_inject is not None: + declared_via_inject = True + if not isinstance(raw_inject, list): + raise CapabilityConfigError( + f"capability {cap_id!r} 'inject' must be a list of target objects" + ) + seen_targets: set[str] = set() + for item in raw_inject: + t = _parse_inject_target(item, strict=strict, cap_id=cap_id) + if t.as_ in seen_targets and strict: + raise CapabilityConfigError( + f"capability {cap_id!r} duplicate inject target: {t.as_!r}" + ) + seen_targets.add(t.as_) + targets.append(t) + elif raw_secret is not None or raw_secret_ref is not None: + secret_block = raw.get("secret") if isinstance(raw.get("secret"), dict) else {} + inject_as = ( + str( + raw.get("inject_as") + or secret_block.get("as") + or secret_block.get("inject_as") + or "token" + ).strip() or "token" - ).strip() - or "token" - ) - secret_ref = _secret_ref_from_block(raw.get("secret"), strict=strict, cap_id=cap_id) - if secret_ref is None and isinstance(raw.get("secret_ref"), str): - secret_ref = normalize_secret_ref(raw["secret_ref"]) + ) + secret_ref = _secret_ref_from_block(raw.get("secret"), strict=strict, cap_id=cap_id) + if secret_ref is None and isinstance(raw.get("secret_ref"), str): + secret_ref = normalize_secret_ref(raw["secret_ref"]) + if secret_ref and _looks_like_secret_value(secret_ref) and strict: + raise CapabilityConfigError( + f"capability {cap_id!r} secret ref looks like a live token; " + "store references only (e.g. env:KEY)" + ) + if secret_ref: + targets.append(InjectTarget(ref=secret_ref, as_=inject_as)) + description = raw.get("description") strict_args = bool(raw.get("strict_args") or raw.get("strict")) for key, value in allowed_raw.items(): @@ -173,15 +292,21 @@ def parse_capability(raw: Any, *, strict: bool = True) -> Capability | None: f"capability {cap_id!r} allowed.{key} looks like a plaintext secret; " "store labels only (e.g. card: Y) and put credentials in secret.ref" ) + + primary_ref = targets[0].ref if targets else None + primary_as = targets[0].as_ if targets else "token" + return Capability( id=cap_id, tool=str(tool).strip() if tool else None, skill_id=str(skill_id).strip() if skill_id else None, allowed=dict(allowed_raw), - secret_ref=secret_ref, - inject_as=inject_as, + secret_ref=primary_ref, + inject_as=primary_as, + inject=tuple(targets), strict_args=strict_args, description=str(description) if description else None, + declared_via_inject=declared_via_inject, ) @@ -217,11 +342,16 @@ def normalize_capabilities_for_store(raw: Any) -> list[dict[str, Any]]: def capabilities_summary(caps: list[Capability] | None) -> dict[str, Any]: items = list(caps or []) gated = gated_tool_ids(items) + secret_refs: list[str] = [] + for cap in items: + for t in cap.inject: + if t.ref and t.ref not in secret_refs: + secret_refs.append(t.ref) return { "count": len(items), "ids": [cap.id for cap in items], "gated_tools": sorted(gated) if gated is not None else ["*"], - "secret_refs": [cap.secret_ref for cap in items if cap.secret_ref], + "secret_refs": secret_refs, } @@ -437,12 +567,16 @@ def _fail(message: str) -> ConstraintResult: "capability", "capability_id", "args", + "headers", "step_id", "tokens", "token_count", "request_id", cap.inject_as, } + for t in cap.inject: + reserved.add(t.as_) + reserved.add(t.as_.split(".")[0]) extra = [ key for key in args.keys() @@ -476,6 +610,16 @@ def matching_capability(session: Any, payload: dict[str, Any]) -> Capability | N return lookup_capability(caps, cap_id) +def _inject_value_at_path(target_dict: dict[str, Any], path: str, value: Any) -> None: + parts = path.split(".") + current = target_dict + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + def prepare_live_args( session: Any, payload: dict[str, Any], @@ -483,25 +627,42 @@ def prepare_live_args( constraint_results: list[ConstraintResult] | None = None, ) -> dict[str, Any]: """ - Copy args for host execute: strip agent-supplied secrets, inject broker token + Copy args for host execute: strip agent-supplied secrets, inject broker token(s) only when capability_scope passed. """ args = payload.get("args") if isinstance(payload.get("args"), dict) else {} live = strip_secret_like_keys(dict(args)) + + if "headers" in payload and isinstance(payload["headers"], dict): + live_headers = strip_secret_like_keys(dict(payload["headers"])) + if "headers" not in live: + live["headers"] = live_headers + elif isinstance(live.get("headers"), dict): + live["headers"] = {**live_headers, **live["headers"]} + cap = matching_capability(session, payload) scope = last_capability_result(constraint_results) allowed = cap is not None and scope is not None and scope.passed - if not allowed or cap is None or not cap.secret_ref: + if not allowed or cap is None or not cap.inject: return live + broker: SecretBroker | None = getattr(session, "_secret_broker", None) or session.state.get( STATE_SECRET_BROKER ) - if broker is None: - raise SecretNotFoundError(cap.secret_ref) - value = broker.resolve(cap.secret_ref) - live[cap.inject_as] = value injected: set[str] = session.state.setdefault(STATE_INJECTED_SECRETS, set()) - injected.add(value) + + for target in cap.inject: + if not target.ref: + continue + if broker is None: + raise SecretNotFoundError(target.ref) + raw_val = broker.resolve(target.ref) + injected.add(raw_val) + final_val = f"{target.prefix}{raw_val}" if target.prefix else raw_val + if len(final_val) >= 8: + injected.add(final_val) + _inject_value_at_path(live, target.as_, final_val) + return live diff --git a/aura/hosts/mock.py b/aura/hosts/mock.py index da9f3f2..cf921e2 100644 --- a/aura/hosts/mock.py +++ b/aura/hosts/mock.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from typing import Any, Callable @@ -11,7 +12,7 @@ class MockSkill: def __init__( self, skill_id: str, - handlers: dict[str, Callable[[dict[str, Any]], Any]] | None = None, + handlers: dict[str, Callable[..., Any]] | None = None, *, manifest: dict[str, Any] | None = None, ) -> None: @@ -19,13 +20,26 @@ def __init__( self._handlers = dict(handlers or {}) self.manifest = dict(manifest or {}) - def register(self, tool: str, handler: Callable[[dict[str, Any]], Any]) -> None: + def register(self, tool: str, handler: Callable[..., Any]) -> None: self._handlers[tool] = handler - def execute(self, tool: str, args: dict[str, Any] | None = None) -> Any: + def execute( + self, + tool: str, + args: dict[str, Any] | None = None, + *, + headers: dict[str, Any] | None = None, + ) -> Any: if tool not in self._handlers: raise KeyError(f"Unknown tool: {tool}") - return self._handlers[tool](args or {}) + handler = self._handlers[tool] + try: + sig = inspect.signature(handler) + if "headers" in sig.parameters: + return handler(args or {}, headers=headers) + except (TypeError, ValueError): + pass + return handler(args or {}) class MockSkillRegistry: diff --git a/aura/hosts/protocol.py b/aura/hosts/protocol.py index 2c3dca7..1c22b54 100644 --- a/aura/hosts/protocol.py +++ b/aura/hosts/protocol.py @@ -10,7 +10,13 @@ class SkillExecutor(Protocol): skill_id: str - def execute(self, tool: str, args: dict[str, Any] | None = None) -> Any: ... + def execute( + self, + tool: str, + args: dict[str, Any] | None = None, + *, + headers: dict[str, Any] | None = None, + ) -> Any: ... class ToolHost(Protocol): @@ -27,5 +33,6 @@ def execute( tool: str, args: dict[str, Any] | None = None, *, + headers: dict[str, Any] | None = None, step_id: str | None = None, ) -> Any: ... diff --git a/aura/hosts/skillware.py b/aura/hosts/skillware.py index a662484..589e895 100644 --- a/aura/hosts/skillware.py +++ b/aura/hosts/skillware.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect from typing import Any from aura.hosts.bind import record_skill_bind @@ -49,13 +50,23 @@ def execute( tool: str, args: dict[str, Any] | None = None, *, + headers: dict[str, Any] | None = None, step_id: str | None = None, ) -> Any: skill = self._skills.get(skill_id) if skill is None: raise KeyError(f"Skill not registered: {skill_id}") - def run(live_args: dict[str, Any]) -> Any: + def run(live_args: dict[str, Any], headers: dict[str, Any] | None = None) -> Any: + effective_headers = headers or ( + live_args.get("headers") if isinstance(live_args.get("headers"), dict) else None + ) + try: + sig = inspect.signature(skill.execute) + if "headers" in sig.parameters: + return skill.execute(tool, live_args, headers=effective_headers) + except (TypeError, ValueError): + pass return skill.execute(tool, live_args) audit_tool = tool or skill_id @@ -64,6 +75,7 @@ def run(live_args: dict[str, Any]) -> Any: tool=audit_tool, skill_id=skill_id, args=args, + headers=headers, execute=run, step_id=step_id, ) @@ -115,13 +127,19 @@ def __init__(self) -> None: self._skill = skill self.manifest: dict[str, Any] = {} - def execute(self, tool: str, args: dict[str, Any] | None = None) -> Any: + def execute( + self, + tool: str, + args: dict[str, Any] | None = None, + *, + headers: dict[str, Any] | None = None, + ) -> Any: adapter = SkillwareRegistrySkill( self.skill_id, self._skill, self.manifest, ) - return adapter.execute(tool, args) + return adapter.execute(tool, args, headers=headers) return _Wrapped() diff --git a/aura/hosts/skillware_adapter.py b/aura/hosts/skillware_adapter.py index d8e6649..eb03188 100644 --- a/aura/hosts/skillware_adapter.py +++ b/aura/hosts/skillware_adapter.py @@ -32,7 +32,13 @@ def __init__( manifest.get("constitution"), ) - def execute(self, tool: str, args: dict[str, Any] | None = None) -> Any: + def execute( + self, + tool: str, + args: dict[str, Any] | None = None, + *, + headers: dict[str, Any] | None = None, + ) -> Any: params = dict(args or {}) execute_fn = self._instance.execute try: @@ -44,13 +50,21 @@ def execute(self, tool: str, args: dict[str, Any] | None = None) -> Any: if not params_list: return execute_fn() + has_headers = "headers" in sig.parameters + first = params_list[0] if first.name in ("params", "parameters", "payload") or len(params_list) == 1: + if has_headers: + return execute_fn(params, headers=headers) return execute_fn(params) # MockSkill-style: execute(tool, args) if len(params_list) >= 2: + if has_headers: + return execute_fn(tool, params, headers=headers) return execute_fn(tool, params) + if has_headers: + return execute_fn(params, headers=headers) return execute_fn(params) diff --git a/aura/membrane/egress.py b/aura/membrane/egress.py index 342c7f9..9dc935e 100644 --- a/aura/membrane/egress.py +++ b/aura/membrane/egress.py @@ -15,6 +15,10 @@ def _invoke_execute(execute: Callable[..., Any], live_args: dict[str, Any]) -> A signature = inspect.signature(execute) except (TypeError, ValueError): return execute() + + headers = live_args.get("headers") if isinstance(live_args.get("headers"), dict) else None + accepts_headers = "headers" in signature.parameters + required = [ param for param in signature.parameters.values() @@ -22,6 +26,10 @@ def _invoke_execute(execute: Callable[..., Any], live_args: dict[str, Any]) -> A in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and param.default is inspect.Parameter.empty ] + if accepts_headers and headers is not None: + if required: + return execute(live_args, headers=headers) + return execute(headers=headers) if required: return execute(live_args) for param in signature.parameters.values(): @@ -36,6 +44,7 @@ def guarded_tool_call( tool: str, skill_id: str | None = None, args: dict[str, Any] | None = None, + headers: dict[str, Any] | None = None, execute: Callable[..., Any], step_id: str | None = None, ) -> Any: @@ -46,6 +55,8 @@ def guarded_tool_call( execute args. Spine events are redacted by ``session.emit``. """ payload_base = {"tool": tool, "skill_id": skill_id, "args": dict(args or {})} + if headers: + payload_base["headers"] = dict(headers) if step_id: payload_base["step_id"] = step_id cap_id = (args or {}).get("capability_id") or (args or {}).get("capability") @@ -83,19 +94,25 @@ def guarded_tool_call( raise cap = matching_capability(session, payload_base) - injected = bool(cap and cap.secret_ref and cap.inject_as in live_args) - if injected and cap is not None: - session.emit( - "capability.injected", - { - "capability_id": cap.id, - "tool": tool, - "skill_id": skill_id, - "secret_ref": cap.secret_ref, - "inject_as": cap.inject_as, - }, - step_id=step_id, - ) + if cap is not None and cap.inject: + injected_records = [ + {"secret_ref": target.ref, "inject_as": target.as_} + for target in cap.inject + if target.ref + ] + if injected_records: + session.emit( + "capability.injected", + { + "capability_id": cap.id, + "tool": tool, + "skill_id": skill_id, + "secret_ref": cap.secret_ref, + "inject_as": cap.inject_as, + "injected": injected_records, + }, + step_id=step_id, + ) try: result = _invoke_execute(execute, live_args) diff --git a/docs/capabilities.md b/docs/capabilities.md index 5ee9109..fc55967 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -28,6 +28,7 @@ If a card number or API key lives on the agent JSON, the model can echo it, the spectrum: level: mid # low = audit-only miss; mid+ = block capabilities: + # Single secret (token): - id: shop-x-milk tool: payment allowed: @@ -36,6 +37,24 @@ capabilities: card: Y # label, not a PAN secret: ref: env:AURA_CAP_SHOP_X_CARD_Y + + # Multi-secret / HTTP headers (#86): + - id: s3-reader + tool: s3_fetch + allowed: { bucket: "reports" } + inject: + - ref: env:AWS_ACCESS_KEY_ID + as: aws_access_key_id + - ref: env:AWS_SECRET_ACCESS_KEY + as: aws_secret_access_key + + - id: github-api + tool: http_request + allowed: { host: "api.github.com" } + inject: + - ref: env:GITHUB_TOKEN + as: headers.Authorization + prefix: "Bearer " ``` | Field | Role | @@ -44,8 +63,9 @@ capabilities: | `tool` / `skill_id` | Optional bind. If set, that tool/skill is **gated** (id required). If omitted, every `tool.call` is gated. | | `allowed` | Required subset of args. `"*"` = any value; a list = allowlist; dotted keys (`merchant.name`) walk nested dicts. Numbers and digit-strings compare equal (`3` / `"3"`). | | `strict_args` | When true, extra arg keys are denied. Default false so hosts may pass metadata. | -| `secret.ref` | Credential locator only (`env:VAR`, or a ref your callable broker understands). **No `value` / `token` / plaintext.** | -| `inject_as` | Live args key for the injected secret (default `token`). | +| `secret.ref` | Single credential locator (`env:VAR`, or a ref your callable broker understands). **No `value` / `token` / plaintext.** | +| `inject_as` | Live args key for single secret inject (default `token`). | +| `inject[]` | List of multi-target injects (`#86`). Each item declares `ref`, `as` (e.g. `aws_access_key_id` or `headers.Authorization`), and optional `prefix` (e.g. `"Bearer "`). | CLI: @@ -110,8 +130,8 @@ Escalations already subscribed to `constraint.violated` (`nudge`, `pause`, …) 1. `tool.intent` (redacted) 2. `tool.call` → `capability_scope` (+ other rules) 3. Strip agent-supplied secret-like keys (`token`, `api_key`, …) -4. If scope passed and `secret.ref` is set → broker resolve → inject → `capability.injected` (ref only) -5. Execute with live args +4. If scope passed and `inject` / `secret.ref` is set → broker resolve → inject (args, dotted paths, headers) → `capability.injected` (refs only) +5. Execute with live args (and HTTP headers if accepted) 6. `tool.result` / `tool.error` (redacted; live secret substrings of length ≥ 8 are scrubbed) Direct `run.emit("tool.call", …)` still enforces scope. There is no execute, so nothing is injected — that is expected for emit-only coats. @@ -134,10 +154,10 @@ 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 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). +- **Multiple targets and HTTP headers** supported via `inject: [{ref, as, prefix}]` ([#86](https://github.com/ARPAHLS/aura/issues/86)). - **Not** rewind, retry-N, or Skillware `SecretProvider` types. Runnable demo: [examples/14-capability-broker](../examples/14-capability-broker/). Stress: `python scripts/aura_capability_stress_sim.py`. diff --git a/docs/outputs.md b/docs/outputs.md index 5e48c8a..b3d3e47 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -26,7 +26,7 @@ CLI: `aura report show `, `aura report show --json`, `a **Spine events (spectrum):** `session.open` and `membrane.ingress` carry a `spectrum` summary when the profile has a `spectrum` block. `session.open` also includes `services_activation` — which presets wired at open (`activated`, `skipped_explicit`, `unknown`) — and `verified_identity_required` + source when the verified-ID gate applies ([#73](https://github.com/ARPAHLS/aura/issues/73)). See [aura-levels.md](aura-levels.md). -**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 (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 **refs** and targets in `injected[]`, never values; [#86](https://github.com/ARPAHLS/aura/issues/86)). 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). diff --git a/examples/14-capability-broker/README.md b/examples/14-capability-broker/README.md index c532f9b..731871a 100644 --- a/examples/14-capability-broker/README.md +++ b/examples/14-capability-broker/README.md @@ -32,10 +32,13 @@ python examples/14-capability-broker/main.py | `deny_wrong_card` | `card=Z` blocked at mid; finding `CAPABILITY_DENIED` | | `low_audit` | Same miss at `spectrum.level: low` still runs, no inject | | `missing_id` | Gated tool without `capability_id` is denied | +| `multi_inject` | Dual secrets (AWS access key + secret) and `headers.Authorization` with `Bearer ` prefix ([#86](https://github.com/ARPAHLS/aura/issues/86)) | | `all` | Every row (default) | ## Profile sketch +Single secret (legacy or compact): + ```yaml spectrum: level: mid @@ -50,4 +53,22 @@ capabilities: ref: env:AURA_CAP_SHOP_X_CARD_Y ``` +Multiple secrets & HTTP headers ([#86](https://github.com/ARPAHLS/aura/issues/86)): + +```yaml +spectrum: + level: mid +capabilities: + - id: s3-dual + tool: s3_read + allowed: + bucket: ledger + inject: + - ref: env:AWS_ACCESS_KEY_ID + as: aws_access_key_id + - ref: env:AWS_SECRET_ACCESS_KEY + as: headers.Authorization + prefix: "Bearer " +``` + Never put `secret.value`. Bind a broker at session open (`MapSecretBroker` in this demo; `EnvSecretBroker` in production). diff --git a/examples/14-capability-broker/main.py b/examples/14-capability-broker/main.py index a2c860b..4684301 100644 --- a/examples/14-capability-broker/main.py +++ b/examples/14-capability-broker/main.py @@ -168,11 +168,63 @@ def scenario_missing_id() -> dict[str, Any]: } +def scenario_multi_inject() -> dict[str, Any]: + captured: dict[str, Any] = {} + + def fetch(args: dict[str, Any], headers: dict[str, Any] | None = None) -> dict[str, Any]: + captured.update(args) + return {"records": 10, "auth_header": headers.get("Authorization") if headers else None} + + cap = { + "id": "s3-dual", + "tool": "s3_read", + "allowed": {"bucket": "ledger"}, + "inject": [ + {"ref": "env:AWS_ACCESS_KEY_ID", "as": "aws_access_key_id"}, + { + "ref": "env:AWS_SECRET_ACCESS_KEY", + "as": "headers.Authorization", + "prefix": "Bearer ", + }, + ], + } + broker = MapSecretBroker( + { + "env:AWS_ACCESS_KEY_ID": "AKIA_DEMO_KEY", + "env:AWS_SECRET_ACCESS_KEY": "demo_secret_xyz", + } + ) + ag = agent( + "cap-demo-dual-credentials", + capabilities=[cap], + spectrum={"level": "mid", "services": ["audit"]}, + ) + # Ensure capabilities match if agent already existed in local store + ag.profile.capabilities = [cap] + with ag.session(secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("s3", {"s3_read": fetch})) + result = host.execute( + "s3", + "s3_read", + {"capability_id": "s3-dual", "bucket": "ledger"}, + ) + summary = _report(run._session) + return { + "scenario": "multi_inject", + "session_id": run.session_id, + "result": result, + "injected_args_key": "aws_access_key_id" in captured, + **summary, + } + + SCENARIOS: dict[str, ScenarioFn] = { "allow_inject": scenario_allow_inject, "deny_wrong_card": scenario_deny_wrong_card, "low_audit": scenario_low_audit, "missing_id": scenario_missing_id, + "multi_inject": scenario_multi_inject, } diff --git a/scripts/aura_capability_stress_sim.py b/scripts/aura_capability_stress_sim.py index c87883b..0cf04af 100644 --- a/scripts/aura_capability_stress_sim.py +++ b/scripts/aura_capability_stress_sim.py @@ -494,6 +494,99 @@ def scenario_empty_capabilities_compat() -> ScenarioResult: return ScenarioResult("capability_empty_compat", "tight", True) +def scenario_multi_secret_dual_credentials() -> ScenarioResult: + key_val = "AKIA_STRESS_ACCESS_KEY_001" + secret_val = "stress_secret_key_val_999" + broker = MapSecretBroker( + { + "env:AWS_ACCESS_KEY_ID": key_val, + "env:AWS_SECRET_ACCESS_KEY": secret_val, + } + ) + captured: dict[str, Any] = {} + + def s3_op(args: dict[str, Any]) -> dict[str, Any]: + captured.update(args) + return {"fetched": True} + + cap = { + "id": "s3-multi", + "tool": "s3", + "allowed": {"bucket": "cold-vault"}, + "inject": [ + {"ref": "env:AWS_ACCESS_KEY_ID", "as": "aws_access_key_id"}, + {"ref": "env:AWS_SECRET_ACCESS_KEY", "as": "aws_secret_access_key"}, + ], + } + ag = agent( + "stress-s3-multi", capabilities=[cap], spectrum={"level": "mid", "services": ["audit"]} + ) + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("s3", {"s3": s3_op})) + host.execute("s3", "s3", {"capability_id": "s3-multi", "bucket": "cold-vault"}) + + _assert(captured.get("aws_access_key_id") == key_val, "access key not injected") + _assert(captured.get("aws_secret_access_key") == secret_val, "secret key not injected") + jsonl = Path(run.exports["jsonl"]).read_text(encoding="utf-8") + summary = Path(run.exports["summary"]).read_text(encoding="utf-8") + _assert(key_val not in jsonl, "key leaked in jsonl") + _assert(secret_val not in jsonl, "secret leaked in jsonl") + _assert(key_val not in summary, "key leaked in summary") + _assert(secret_val not in summary, "secret leaked in summary") + return ScenarioResult("capability_multi_secret_dual", "tight", True) + + +def scenario_header_authorization_inject() -> ScenarioResult: + token_val = "tok_stress_live_api_token_555" + broker = MapSecretBroker({"env:API_TOKEN": token_val}) + captured_args: dict[str, Any] = {} + + def http_op(args: dict[str, Any], headers: dict[str, Any] | None = None) -> dict[str, Any]: + captured_args.update(args) + return {"echo": f"received {args.get('headers', {}).get('Authorization')}"} + + cap = { + "id": "http-headers", + "tool": "http_get", + "allowed": {"url": "https://api.cloud.corp"}, + "inject": [ + {"ref": "env:API_TOKEN", "as": "headers.Authorization", "prefix": "Bearer "}, + ], + } + ag = agent( + "stress-http-headers", capabilities=[cap], spectrum={"level": "mid", "services": ["audit"]} + ) + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("http", {"http_get": http_op})) + host.execute( + "http", + "http_get", + { + "capability_id": "http-headers", + "url": "https://api.cloud.corp", + "headers": { + "Authorization": "spoofed_attacker_token", + "X-Request-Id": "req-123", + }, + }, + ) + + expected_auth = f"Bearer {token_val}" + _assert( + captured_args.get("headers", {}).get("Authorization") == expected_auth, + "auth header mismatch", + ) + _assert(captured_args.get("headers", {}).get("X-Request-Id") == "req-123", "custom header lost") + jsonl = Path(run.exports["jsonl"]).read_text(encoding="utf-8") + summary = Path(run.exports["summary"]).read_text(encoding="utf-8") + _assert(token_val not in jsonl, "token leaked in jsonl") + _assert(expected_auth not in jsonl, "bearer header leaked in jsonl") + _assert(token_val not in summary, "token leaked in summary") + return ScenarioResult("capability_header_authorization", "tight", True) + + SCENARIOS: list[tuple[str, Callable[[], ScenarioResult]]] = [ ("allow inject", scenario_allow_inject), ("generic github scope", scenario_generic_github_scope), @@ -511,6 +604,8 @@ def scenario_empty_capabilities_compat() -> ScenarioResult: ("escalation on deny", scenario_escalation_on_deny), ("result leak", scenario_result_leak), ("empty compat", scenario_empty_capabilities_compat), + ("multi secret dual", scenario_multi_secret_dual_credentials), + ("header authorization", scenario_header_authorization_inject), ] diff --git a/tests/test_capability_broker.py b/tests/test_capability_broker.py index 1010178..aadc46a 100644 --- a/tests/test_capability_broker.py +++ b/tests/test_capability_broker.py @@ -663,3 +663,329 @@ def test_session_open_capability_summary(aura_home: Path): assert caps.get("count") == 1 assert caps.get("ids") == ["shop-x-milk"] assert "MapSecretBroker" in str(caps.get("broker")) + + +def test_multi_secret_allow_path_dual_inject(aura_home: Path): + key_val = "AKIA_MOCK_ACCESS_KEY_123" + secret_val = "mock_secret_key_abc_999" + broker = MapSecretBroker( + { + "env:AWS_ACCESS_KEY_ID": key_val, + "env:AWS_SECRET_ACCESS_KEY": secret_val, + } + ) + captured: dict = {} + + def fetch_s3(args): + captured.update(args) + return {"records": 42} + + cap_s3 = { + "id": "s3-audit-reports", + "tool": "s3_fetch", + "allowed": {"bucket": "compliance-reports", "prefix": "q3/"}, + "inject": [ + {"ref": "env:AWS_ACCESS_KEY_ID", "as": "aws_access_key_id"}, + {"ref": "env:AWS_SECRET_ACCESS_KEY", "as": "aws_secret_access_key"}, + ], + } + ag = agent("s3-agent", capabilities=[cap_s3], spectrum={"level": "mid", "services": ["audit"]}) + + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("s3", {"s3_fetch": fetch_s3})) + res = host.execute( + "s3", + "s3_fetch", + { + "capability_id": "s3-audit-reports", + "bucket": "compliance-reports", + "prefix": "q3/", + }, + ) + assert res == {"records": 42} + assert captured["aws_access_key_id"] == key_val + assert captured["aws_secret_access_key"] == secret_val + + spine_text = _spine_text(run) + assert key_val not in spine_text + assert secret_val not in spine_text + + injected_evts = [e for e in run._session.spine.stream() if e.kind == "capability.injected"] + assert len(injected_evts) == 1 + p = injected_evts[0].payload + assert p["capability_id"] == "s3-audit-reports" + assert len(p["injected"]) == 2 + assert p["injected"][0] == { + "secret_ref": "env:AWS_ACCESS_KEY_ID", + "inject_as": "aws_access_key_id", + } + assert p["injected"][1] == { + "secret_ref": "env:AWS_SECRET_ACCESS_KEY", + "inject_as": "aws_secret_access_key", + } + + +def test_header_inject_authorization_with_prefix(aura_home: Path): + token_val = "ghp_mock_live_github_token_xyz" + broker = MapSecretBroker({"env:GITHUB_TOKEN": token_val}) + captured_args: dict = {} + captured_headers: dict = {} + + def call_gh(args, headers=None): + captured_args.update(args) + if headers: + captured_headers.update(headers) + return {"login": "octocat"} + + cap_gh = { + "id": "github-read", + "tool": "gh_api", + "allowed": {"endpoint": "/user"}, + "inject": [ + { + "ref": "env:GITHUB_TOKEN", + "as": "headers.Authorization", + "prefix": "Bearer ", + } + ], + } + ag = agent("gh-agent", capabilities=[cap_gh], spectrum={"level": "mid", "services": ["audit"]}) + + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("gh", {"gh_api": call_gh})) + res = host.execute( + "gh", + "gh_api", + { + "capability_id": "github-read", + "endpoint": "/user", + }, + ) + assert res == {"login": "octocat"} + assert captured_args["headers"]["Authorization"] == f"Bearer {token_val}" + assert captured_headers["Authorization"] == f"Bearer {token_val}" + + spine_text = _spine_text(run) + assert token_val not in spine_text + assert f"Bearer {token_val}" not in spine_text + + +def test_agent_supplied_headers_and_secrets_stripped(aura_home: Path): + token_val = "live_authorized_token_12345" + broker = MapSecretBroker({"env:AUTH_TOKEN": token_val}) + captured_args: dict = {} + + def fetch(args): + captured_args.update(args) + return {"status": 200} + + cap = { + "id": "api-call", + "tool": "http", + "allowed": {"url": "https://api.internal/data"}, + "inject": [ + { + "ref": "env:AUTH_TOKEN", + "as": "headers.Authorization", + "prefix": "Bearer ", + } + ], + } + ag = agent("strip-agent", capabilities=[cap], spectrum={"level": "mid", "services": ["audit"]}) + + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("http", {"http": fetch})) + host.execute( + "http", + "http", + { + "capability_id": "api-call", + "url": "https://api.internal/data", + "token": "agent_supplied_token_to_strip", + "headers": { + "Authorization": "Bearer agent_spoofed_stolen_token", + "X-Custom": "legitimate_custom_header", + }, + }, + ) + assert captured_args["headers"]["Authorization"] == f"Bearer {token_val}" + assert captured_args["headers"]["X-Custom"] == "legitimate_custom_header" + assert "token" not in captured_args + + +def test_leak_scrub_on_multi_secret_and_headers_in_tool_result(aura_home: Path): + key_val = "AKIA_SECRET_TO_SCRUB_999" + token_val = "tok_live_bearer_secret_888" + broker = MapSecretBroker( + { + "env:KEY": key_val, + "env:TOKEN": token_val, + } + ) + + def echoing_tool(args): + return { + "echo_key": f"key={args.get('key')}", + "echo_header": f"header={args.get('headers', {}).get('Authorization')}", + "safe": "unaffected_value", + } + + cap = { + "id": "echo-test", + "tool": "echo", + "allowed": {"action": "ping"}, + "inject": [ + {"ref": "env:KEY", "as": "key"}, + {"ref": "env:TOKEN", "as": "headers.Authorization", "prefix": "Bearer "}, + ], + } + ag = agent("echo-agent", capabilities=[cap], spectrum={"level": "mid", "services": ["audit"]}) + + with ag.session(export=True, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("echo", {"echo": echoing_tool})) + host.execute("echo", "echo", {"capability_id": "echo-test", "action": "ping"}) + + result_evt = next(e for e in run._session.spine.stream() if e.kind == "tool.result") + payload = result_evt.payload["result"] + assert key_val not in str(payload) + assert token_val not in str(payload) + assert REDACTED in payload["echo_key"] + assert REDACTED in payload["echo_header"] + assert payload["safe"] == "unaffected_value" + + +def test_multi_secret_unresolved_second_secret(aura_home: Path): + broker = MapSecretBroker({"env:FIRST_KEY": "exists_live_123"}) + cap = { + "id": "two-keys", + "tool": "dual", + "allowed": {"op": "run"}, + "inject": [ + {"ref": "env:FIRST_KEY", "as": "k1"}, + {"ref": "env:SECOND_MISSING", "as": "k2"}, + ], + } + ag = agent( + "missing-secret-agent", + capabilities=[cap], + spectrum={"level": "mid", "services": ["audit"]}, + ) + + with pytest.raises(SecretNotFoundError, match="env:SECOND_MISSING"): + with ag.session(export=False, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("dual", {"dual": lambda args: args})) + host.execute("dual", "dual", {"capability_id": "two-keys", "op": "run"}) + + kinds = [e.kind for e in run._session.spine.stream()] + assert "tool.error" in kinds + err_evt = next(e for e in run._session.spine.stream() if e.kind == "tool.error") + assert err_evt.payload.get("error") == "secret_broker_unresolved" + assert err_evt.payload.get("secret_ref") == "env:SECOND_MISSING" + + +def test_parse_rejects_duplicate_inject_as(): + with pytest.raises(CapabilityConfigError, match="duplicate inject target"): + parse_capabilities( + [ + { + "id": "dup", + "tool": "op", + "inject": [ + {"ref": "env:K1", "as": "token"}, + {"ref": "env:K2", "as": "token"}, + ], + } + ], + strict=True, + ) + + +def test_parse_rejects_both_secret_and_inject(): + with pytest.raises(CapabilityConfigError, match="cannot declare both"): + parse_capabilities( + [ + { + "id": "both", + "tool": "op", + "secret": {"ref": "env:K1"}, + "inject": [{"ref": "env:K2", "as": "k2"}], + } + ], + strict=True, + ) + + +def test_strict_args_with_multi_inject(): + broker = MapSecretBroker({"env:K1": "live_val_1", "env:K2": "live_val_2"}) + cap = { + "id": "strict-multi", + "tool": "strict_tool", + "allowed": {"mode": "fast"}, + "strict_args": True, + "inject": [ + {"ref": "env:K1", "as": "k1"}, + {"ref": "env:K2", "as": "headers.Auth"}, + ], + } + ag = agent("strict-agent", capabilities=[cap], spectrum={"level": "mid", "services": ["audit"]}) + + with ag.session(export=False, secret_broker=broker) as run: + host = SkillwareHost(run._session) + host.register(MockSkill("strict", {"strict_tool": lambda args: {"ok": True}})) + res = host.execute( + "strict", + "strict_tool", + {"capability_id": "strict-multi", "mode": "fast", "headers": {}}, + ) + assert res == {"ok": True} + + with pytest.raises(ConstraintViolation, match="rejects extra args"): + host.execute( + "strict", + "strict_tool", + {"capability_id": "strict-multi", "mode": "fast", "unwanted": "boom"}, + ) + + +def test_cli_agent_set_multi_inject(run_aura): + create = run_aura("agent", "create", "multi-cli", "--ref", "acme/multi-cli") + assert create.returncode == 0 + + caps_json = json.dumps( + [ + { + "id": "aws-s3", + "tool": "s3_fetch", + "allowed": {"bucket": "finance"}, + "inject": [ + {"ref": "env:AWS_KEY", "as": "aws_access_key_id"}, + {"ref": "env:AWS_SECRET", "as": "aws_secret_access_key"}, + ], + } + ] + ) + result = run_aura( + "agent", + "set", + "multi-cli", + "--capabilities-json", + caps_json, + ) + assert result.returncode == 0 + profile = json.loads(result.stdout) + assert profile["capabilities"][0]["id"] == "aws-s3" + assert len(profile["capabilities"][0]["inject"]) == 2 + assert profile["capabilities"][0]["inject"][0]["ref"] == "env:AWS_KEY" + assert profile["capabilities"][0]["inject"][1]["ref"] == "env:AWS_SECRET" + + show = run_aura("agent", "show", "multi-cli") + assert show.returncode == 0 + shown = json.loads(show.stdout) + assert shown["capabilities_summary"]["count"] == 1 + assert "env:AWS_KEY" in shown["capabilities_summary"]["secret_refs"] + assert "env:AWS_SECRET" in shown["capabilities_summary"]["secret_refs"] diff --git a/tests/test_example_14_capability_broker.py b/tests/test_example_14_capability_broker.py index 74f7890..40977ac 100644 --- a/tests/test_example_14_capability_broker.py +++ b/tests/test_example_14_capability_broker.py @@ -33,6 +33,7 @@ def example_14(): ("deny_wrong_card", "blocked", True), ("low_audit", "executed", 1), ("missing_id", "blocked", True), + ("multi_inject", "injected_args_key", True), ], ) def test_example_14_scenarios(aura_home, example_14, name, expect_key, expect_value):