Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions aura/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
create_agent,
current_session,
list_agents,
Capability,
CapabilityConfigError,
InjectTarget,
SecretBrokerError,
SecretConfigError,
SecretNotFoundError,
Expand Down Expand Up @@ -45,7 +47,9 @@
"OperatorIdentityAdapter",
"IdentityRequiredError",
"IdentityVerificationError",
"Capability",
"CapabilityConfigError",
"InjectTarget",
"SecretBrokerError",
"SecretConfigError",
"SecretNotFoundError",
Expand Down
4 changes: 3 additions & 1 deletion aura/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -240,7 +240,9 @@ def list_agents(include_archived: bool = False) -> list[AgentProfile]:
"OperatorIdentityAdapter",
"IdentityRequiredError",
"IdentityVerificationError",
"Capability",
"CapabilityConfigError",
"InjectTarget",
"SecretBrokerError",
"SecretConfigError",
"SecretNotFoundError",
Expand Down
211 changes: 186 additions & 25 deletions aura/core/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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}
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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():
Expand All @@ -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,
)


Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -476,32 +610,59 @@ 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],
*,
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


Expand Down
Loading
Loading