From 7f83541b0143051573560c61cf0dda235052bbb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 00:48:37 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(a2a):=20delegated-principal=20extensio?= =?UTF-8?q?n=20=E2=80=94=20carry=20the=20originating=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1's AuthContext.caller is a single string, and valid_caller_identity() accepts a bare repo name or an Exec-* principal (authz.md §2). There is no end-user subject anywhere in the model, so a CEO and a warehouse worker arriving through the same calling repo are LITERALLY INDISTINGUISHABLE to the callee: if FuzeExecutive is in FuzePlan's providesTo, both pass, identically. The contract's authors already had the intent right in authz.md §7 — "providesTo grants the right to ask, not the right to command... A2A adds a front door; it does not widen any room behind it." What was missing was the identity precision to act on it, because identity resolved to a repo rather than a person. An EXTENSION, not a v2. contracts/a2a/v1 is frozen and this does not edit one byte of it. The mechanism is the one A2A already defines — AgentCapabilities.extensions[], which v1's card schema already accepts. The test for "extension, not version" is that adopting must not require editing anything frozen, and not adopting must change nothing. The first half has teeth here: v1's manifest-a2a-extension.schema.json sets additionalProperties:false on the a2a block, so putting the policy there would have made every adopting repo's manifest fail v1 validation. It lives in its own .fuze/a2a-delegation.json instead. Found by checking rather than assuming, and it is the difference between an extension and a version wearing an extension's name. THE POD IS A DELEGATE, NEVER A PRINCIPAL. It holds no standing authority over product data; its workload credential authorizes exactly one thing — presenting delegated tokens. The rejected alternative, giving the pod broad rights and having it check the caller before acting, is the confused deputy: it makes the guard a matter of remembering to look, which is a convention, not a construction. Same "auth-shaped, not auth" class as FuzeFinance's mock identity. Four layers; the middle two are what this adds: 1. Channel may repo X reach repo Y? providesTo — unchanged, still first 2. Principal may THIS SUBJECT do this? RFC 8693 sub + act chain, in the credential, never the body 3. Intersection effective = permitted(sub) ∩ brokerable(actor) 4. Non-delegable set closed classification, no unclassified bucket (3) is load-bearing and both directions are closed: the agent cannot grant more than the subject has, AND the subject cannot reach past what the agent may broker. A union is not a weaker version of this rule, it is the opposite of it, and there is a test asserting each direction separately. (4) is closed: delegable / principal-required / never-delegable, no fourth member and no default. An unclassified skill is DENIED. A default would let a newly written skill acquire the weakest rule in the system by being written rather than by being decided — the same closed-set property as the route and OpenAPI gates. never-delegable is the machine-readable form of what authz.md §7 already seeds (reach_human, and the _base guardrails on kubectl patch / helm rollback / terraform destroy). Fail-closed with no escape: missing sub, unverifiable credential, untyped actor, unclassified skill, no authz client wired, and Permit unreachable are each a DENY. DECISION_UNAVAILABLE is never an allow and there is no flag that makes it one — that branch is spelled out in code because it is the one a fail-open implementation quietly turns into an ALLOW. 19 new tests, weighted toward denial; 195 pass across agent-templates/a2a. The schemas are mutation-checked: they reject an untyped subject, an untyped actor, a wrong extension URI, an unknown class, and an untyped brokerable key. NOT in scope: the token-exchange endpoint. Obtaining the delegated token is the CALLER's side, and that is where the remaining implementation sits. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- agent-templates/a2a/authz.py | 89 ++++++++ agent-templates/a2a/delegation.py | 175 ++++++++++++++++ agent-templates/a2a/tests/test_delegation.py | 192 ++++++++++++++++++ agent-templates/contracts/a2a/ext/README.md | 24 +++ .../a2a/ext/delegated-principal/v1/README.md | 59 ++++++ .../a2a/ext/delegated-principal/v1/VERSION | 1 + .../v1/examples/card-capability.json | 11 + .../v1/examples/delegated-token-claims.json | 9 + .../v1/examples/delegation-policy.json | 13 ++ .../delegated-principal-token.schema.json | 35 ++++ .../v1/schema/delegation-policy.schema.json | 53 +++++ .../a2a/ext/delegated-principal/v1/spec.md | 124 +++++++++++ 12 files changed, 785 insertions(+) create mode 100644 agent-templates/a2a/delegation.py create mode 100644 agent-templates/a2a/tests/test_delegation.py create mode 100644 agent-templates/contracts/a2a/ext/README.md create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md diff --git a/agent-templates/a2a/authz.py b/agent-templates/a2a/authz.py index c1a5ca8..5999326 100644 --- a/agent-templates/a2a/authz.py +++ b/agent-templates/a2a/authz.py @@ -18,6 +18,8 @@ from dataclasses import dataclass, field from enum import Enum +from .delegation import Delegation, DelegationPolicy, SkillClass + _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") _EXEC_PRINCIPAL_RE = re.compile(r"^Exec-[a-z0-9_-]+$") @@ -37,6 +39,10 @@ class AuthContext: caller: str scopes: frozenset[str] = frozenset() authenticated: bool = True + #: `delegated-principal` extension claims, from an ALREADY-VERIFIED credential. + #: None = the caller sent none, which is exactly v1 and is only a problem for a + #: skill the callee classified `principal-required`. + delegation: Delegation | None = None @dataclass(frozen=True) @@ -77,6 +83,9 @@ def authorize( *, skill_role: dict | None = None, skill_known: bool = True, + skill_key: str | None = None, + delegation_policy: DelegationPolicy | None = None, + permit_check=None, ) -> AuthzResult: """Run the callee-side decision procedure (authz.md §3). @@ -87,6 +96,12 @@ def authorize( 4. providesTo: ABSENT -> DENY, [] -> DENY, caller not in -> DENY. 5. skill: unknown/unpublished-to-caller -> DENY; a2a.scopes present but token lacks them -> SCOPE_REQUIRED (the sole legitimate AUTH_REQUIRED). + 6. delegated-principal extension, ONLY when ``delegation_policy`` is given. + Absent policy == the callee did not adopt the extension == exact v1. + + Step 6 runs AFTER step 4 on purpose: channel authz still decides first, so a + caller outside ``providesTo`` is denied before any principal is consulted. The + extension narrows; it never widens. """ if not ctx.authenticated or not valid_caller_identity(ctx.caller): return AuthzResult(Decision.DENY, "unauthenticated or invalid caller identity") @@ -118,4 +133,78 @@ def authorize( missing_scopes=missing, ) + if delegation_policy is not None: + return _authorize_delegated(ctx, delegation_policy, skill_key, permit_check) + return AuthzResult(Decision.ALLOW, "authorized") + + +def _authorize_delegated( + ctx: AuthContext, + policy: DelegationPolicy, + skill_key: str | None, + permit_check, +) -> AuthzResult: + """Extension step 6 (ext/delegated-principal/v1 spec.md §§3-5). + + Every branch that cannot be evaluated is a DENY. There is no fail-open mode + and no flag that creates one: `DECISION_UNAVAILABLE` is never an allow. + """ + if not skill_key: + return AuthzResult( + Decision.DENY, + "delegation policy present but no skill key to classify -> fail closed") + + klass = policy.classify(skill_key) + if klass is None: + # spec.md §3: the closed set has no fourth bucket. Defaulting an + # unclassified skill to `delegable` would let a new skill acquire the + # weakest rule in the system by being written rather than decided. + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is unclassified in delegation.skills -> fail closed") + + if klass is SkillClass.NEVER_DELEGABLE: + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is never-delegable; no principal reaches it via A2A") + + if klass is SkillClass.DELEGABLE: + return AuthzResult(Decision.ALLOW, "authorized (delegable)") + + # principal-required: BOTH terms of the intersection must allow (spec.md §4). + delegation = ctx.delegation + if delegation is None: + return AuthzResult( + Decision.DENY, + f"skill {skill_key!r} is principal-required but the call carries no " + f"verified subject") + + # Term 2 first, because it needs no network call: the subject cannot reach past + # what the agent may broker, however privileged the subject is. + if not policy.brokerable_by(delegation.actor_chain, skill_key): + return AuthzResult( + Decision.DENY, + f"no actor in {delegation.actor_chain} may broker {skill_key!r}") + + # Term 1: what the SUBJECT may do, decided by Permit via FuzeFront's Security + # API. A product never calls Permit directly. + if permit_check is None: + return AuthzResult( + Decision.DENY, + "principal-required skill but no authz client is wired -> cannot " + "evaluate the subject's permission -> fail closed") + try: + permitted = permit_check(delegation.subject, skill_key) + except Exception as exc: + # DECISION_UNAVAILABLE is a DENY. This is the branch that a fail-open + # implementation would turn into an ALLOW, so it is spelled out. + return AuthzResult(Decision.DENY, f"authz decision unavailable: {exc}") + if not permitted: + return AuthzResult( + Decision.DENY, + f"subject {delegation.subject!r} is not permitted {skill_key!r}") + + # The agent cannot grant more than the subject has, AND the subject cannot + # reach past what the agent may broker. Both were required; neither alone. + return AuthzResult(Decision.ALLOW, "authorized (principal-required, intersection)") diff --git a/agent-templates/a2a/delegation.py b/agent-templates/a2a/delegation.py new file mode 100644 index 0000000..b5ab541 --- /dev/null +++ b/agent-templates/a2a/delegation.py @@ -0,0 +1,175 @@ +"""The `delegated-principal` A2A extension (contracts/a2a/ext/delegated-principal/v1). + +WHY THIS EXISTS. v1's `AuthContext.caller` is one string, and a caller identity is +a bare repo name or an `Exec-*` principal (authz.md §2). There is no end-user +subject anywhere in the model, so a CEO and a warehouse worker arriving through +the same calling repo are indistinguishable to the callee: if `FuzeExecutive` is +in `FuzePlan`'s `providesTo`, both pass identically. + +THE POD IS A DELEGATE, NEVER A PRINCIPAL. It holds no standing authority over +product data; its workload credential authorizes exactly one thing — presenting +delegated tokens. Every bit of data-plane authority arrives with the call. The +alternative, giving the pod broad rights and having it check the caller first, is +the confused deputy: the guard becomes a matter of remembering to look. + +This module is ADDITIVE. `Delegation` absent from an AuthContext means the callee +did not adopt the extension, and `authorize()` behaves exactly as v1. Nothing +here can make a v1 deployment stricter or looser than it was. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum + +EXTENSION_URI = "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1" + +_SUBJECT_RE = re.compile(r"^(user|service):[^\s:]+$") +_ACTOR_RE = re.compile(r"^(repo|agent|service):[^\s:]+$") + + +class SkillClass(str, Enum): + """Closed. There is deliberately no fourth member and no default. + + An unclassified skill is DENIED, not treated as `DELEGABLE`. A default would + mean a newly written skill acquires the weakest rule in the system by being + written rather than by being decided — the same closed-set property the route + and OpenAPI gates enforce. + """ + + DELEGABLE = "delegable" + PRINCIPAL_REQUIRED = "principal-required" + NEVER_DELEGABLE = "never-delegable" + + +class DelegationError(ValueError): + """A malformed policy. Raised at load, never carried into a decision.""" + + +@dataclass(frozen=True) +class Delegation: + """Claims read from an ALREADY-VERIFIED credential. + + Constructing this from an unverified token is the one way to misuse the + module: an unverified `sub` is worth less than no `sub`, because it looks + like authority. The caller of `parse_claims` verifies signature and `aud` + first — this module never sees a raw token and cannot check that for you. + """ + + #: RFC 8693 `sub` — the ORIGINATING principal, whom the work is ultimately for. + subject: str + #: RFC 8693 `act` chain, outermost first: immediate actor, then its actor. + actor_chain: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class DelegationPolicy: + """A callee's classification of its own skills, plus who may broker what. + + Loaded from `.fuze/a2a-delegation.json` — see `from_manifest` for why it is a + separate file rather than a manifest key. + """ + + skills: dict[str, SkillClass] + brokerable: dict[str, frozenset[str]] + authz_base_url: str | None = None + + @classmethod + def from_manifest(cls, block: dict | None) -> "DelegationPolicy | None": + """Build from `.fuze/a2a-delegation.json`, or None when the file is absent. + + Its OWN file, not a key in the manifest's `a2a` block: v1's + manifest-a2a-extension.schema.json sets additionalProperties:false there, + so a `delegation` key would make every adopting repo's manifest fail v1 + validation. An extension you must edit the frozen contract to adopt is a + version, not an extension. + + None means "this callee did not adopt the extension" and restores exact v1 + behaviour. A block that is PRESENT but malformed raises: half-configured + delegation is more dangerous than none, because it reads as protection. + """ + if not block: + return None + if block.get("extension") != EXTENSION_URI: + raise DelegationError( + f"delegation block does not name {EXTENSION_URI}; refusing to guess " + f"which extension's rules apply" + ) + raw_skills = block.get("skills") + if not isinstance(raw_skills, dict) or not raw_skills: + raise DelegationError("delegation.skills must be a non-empty object") + skills = {} + for key, value in raw_skills.items(): + try: + skills[key] = SkillClass(value) + except ValueError as exc: + raise DelegationError( + f"skill {key!r} has unknown class {value!r}; the classes are " + f"{[c.value for c in SkillClass]} and there is no default" + ) from exc + raw_brokerable = block.get("brokerable") + if not isinstance(raw_brokerable, dict): + raise DelegationError("delegation.brokerable must be an object") + brokerable = {} + for actor, roles in raw_brokerable.items(): + if not _ACTOR_RE.match(actor): + raise DelegationError( + f"brokerable key {actor!r} is not a typed actor reference " + f"(repo:/agent:/service:)" + ) + if not isinstance(roles, list): + raise DelegationError(f"brokerable[{actor!r}] must be an array") + brokerable[actor] = frozenset(roles) + return cls(skills=skills, brokerable=brokerable, + authz_base_url=block.get("authzBaseUrl")) + + def classify(self, skill: str) -> SkillClass | None: + """The skill's class, or None when unclassified. None is DENY, not a default.""" + return self.skills.get(skill) + + def brokerable_by(self, actor_chain: tuple[str, ...], skill: str) -> bool: + """May any actor in the chain broker this skill? + + An actor absent from `brokerable` brokers NOTHING — absence is not a + wildcard. An explicit empty list says the same thing on purpose, which is + a meaningful statement in a way absence is not. + """ + return any(skill in self.brokerable.get(actor, frozenset()) + for actor in actor_chain) + + +def parse_claims(claims: dict | None) -> Delegation | None: + """Delegation from VERIFIED credential claims, or None when absent. + + Raises DelegationError on a malformed chain rather than dropping the bad + entry: an actor reference that cannot be evaluated must not be treated as + satisfied, and silently ignoring it is exactly that. + """ + if not claims: + return None + subject = claims.get("sub") + if not subject: + return None + if not _SUBJECT_RE.match(subject): + raise DelegationError( + f"sub {subject!r} is not a typed principal (user:/service:); an untyped " + f"id cannot be resolved to a principal kind and so cannot be checked" + ) + chain: list[str] = [] + node = claims.get("act") + while isinstance(node, dict): + actor = node.get("sub") + if not actor or not _ACTOR_RE.match(actor or ""): + raise DelegationError( + f"actor {actor!r} is not a typed reference (repo:/agent:/service:); " + f"it cannot be matched against the brokerable set" + ) + chain.append(actor) + node = node.get("act") + if not chain: + raise DelegationError( + "sub is present but the act chain is empty; a delegated call must name " + "the actor doing the delegating" + ) + return Delegation(subject=subject, actor_chain=tuple(chain)) diff --git a/agent-templates/a2a/tests/test_delegation.py b/agent-templates/a2a/tests/test_delegation.py new file mode 100644 index 0000000..e934bc6 --- /dev/null +++ b/agent-templates/a2a/tests/test_delegation.py @@ -0,0 +1,192 @@ +"""Tests for the delegated-principal extension. + +The property under test is mostly that things are DENIED. An authz test suite +that only proves "the allowed call is allowed" proves nothing an unconditional +`return ALLOW` would not also pass. + +Two of these are the reason the extension exists at all: + * test_ceo_and_worker_are_distinguishable — under v1 they are not. + * test_intersection_not_union — the load-bearing rule; a union is not a weaker + version of it, it is the opposite of it. +""" + +import unittest + +from a2a.authz import AuthContext, Decision, authorize +from a2a.delegation import ( + EXTENSION_URI, Delegation, DelegationError, DelegationPolicy, SkillClass, + parse_claims, +) + +MANIFEST = {"providesTo": ["FuzeExecutive", "FuzeSales"]} + +POLICY_BLOCK = { + "extension": EXTENSION_URI, + "authzBaseUrl": "http://fuzefront-backend:3001", + "skills": { + "plan-reader": "delegable", + "plan-editor": "principal-required", + "plan-purge": "never-delegable", + }, + "brokerable": { + "repo:FuzeExecutive": ["plan-reader", "plan-editor"], + "repo:FuzeSales": ["plan-reader"], + }, +} + +CEO_CLAIMS = {"sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}} +WORKER_CLAIMS = {"sub": "user:worker@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}} + + +def policy(): + return DelegationPolicy.from_manifest(POLICY_BLOCK) + + +def ctx(claims=None, caller="FuzeExecutive"): + return AuthContext(caller=caller, delegation=parse_claims(claims)) + + +def decide(claims=None, skill="plan-editor", permit=None, caller="FuzeExecutive"): + return authorize(ctx(claims, caller), MANIFEST, skill_key=skill, + delegation_policy=policy(), permit_check=permit) + + +class BackwardCompatibilityTests(unittest.TestCase): + """No policy == the callee never adopted the extension == exact v1.""" + + def test_without_a_policy_v1_behaviour_is_untouched(self): + r = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + self.assertIs(r.decision, Decision.ALLOW) + + def test_a_caller_outside_providesto_is_denied_before_any_principal(self): + """Channel authz still decides first; the extension narrows, never widens.""" + r = decide(CEO_CLAIMS, caller="FuzeSocial", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("providesTo", r.reason) + + +class ClosedClassificationTests(unittest.TestCase): + def test_an_unclassified_skill_is_denied_not_defaulted_to_delegable(self): + """spec.md §3: no fourth bucket, and no default.""" + r = decide(CEO_CLAIMS, skill="plan-undeclared", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("unclassified", r.reason) + + def test_never_delegable_denies_even_a_fully_permitted_subject(self): + r = decide(CEO_CLAIMS, skill="plan-purge", permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("never-delegable", r.reason) + + def test_delegable_needs_no_principal(self): + r = decide(None, skill="plan-reader") + self.assertIs(r.decision, Decision.ALLOW) + + def test_an_unknown_class_in_the_policy_is_a_load_error(self): + bad = dict(POLICY_BLOCK, skills={"x": "maybe"}) + with self.assertRaises(DelegationError): + DelegationPolicy.from_manifest(bad) + + def test_a_policy_naming_no_extension_is_refused(self): + bad = dict(POLICY_BLOCK) + del bad["extension"] + with self.assertRaises(DelegationError): + DelegationPolicy.from_manifest(bad) + + +class PrincipalTests(unittest.TestCase): + def test_ceo_and_worker_are_distinguishable(self): + """THE gap. Under v1 both arrive as caller='FuzeExecutive' and both pass.""" + v1_ceo = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + v1_worker = authorize(AuthContext(caller="FuzeExecutive"), MANIFEST) + self.assertEqual(v1_ceo.decision, v1_worker.decision) # indistinguishable + + permit = lambda sub, _skill: sub == "user:ceo@fuzefront.com" + self.assertIs(decide(CEO_CLAIMS, permit=permit).decision, Decision.ALLOW) + self.assertIs(decide(WORKER_CLAIMS, permit=permit).decision, Decision.DENY) + + def test_principal_required_without_a_subject_is_denied(self): + r = decide(None, permit=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("no verified subject", r.reason) + + +class IntersectionTests(unittest.TestCase): + """effective = permitted(sub) ∩ brokerable(actor). Both, or neither counts.""" + + def test_intersection_not_union(self): + allow_all = lambda *_: True + deny_all = lambda *_: False + + # Subject permitted, actor may broker -> ALLOW. + self.assertIs(decide(CEO_CLAIMS, permit=allow_all).decision, Decision.ALLOW) + + # Subject permitted, actor may NOT broker -> DENY. A union would allow. + sales_claims = {"sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeSales"}} + r = authorize(ctx(sales_claims, caller="FuzeSales"), MANIFEST, + skill_key="plan-editor", delegation_policy=policy(), + permit_check=allow_all) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("broker", r.reason) + + # Actor may broker, subject NOT permitted -> DENY. A union would allow. + r = decide(CEO_CLAIMS, permit=deny_all) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("not permitted", r.reason) + + def test_an_actor_absent_from_brokerable_brokers_nothing(self): + """Absence is not a wildcard.""" + claims = {"sub": "user:ceo@fuzefront.com", "act": {"sub": "repo:FuzeUnknown"}} + r = authorize(ctx(claims), MANIFEST, skill_key="plan-editor", + delegation_policy=policy(), permit_check=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + + +class FailClosedTests(unittest.TestCase): + def test_permit_unreachable_is_deny_never_allow(self): + """DECISION_UNAVAILABLE is a DENY. This is the fail-open branch, spelled out.""" + def boom(*_): + raise ConnectionError("authz service unreachable") + r = decide(CEO_CLAIMS, permit=boom) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("unavailable", r.reason) + + def test_no_authz_client_wired_is_deny_not_skip(self): + r = decide(CEO_CLAIMS, permit=None) + self.assertIs(r.decision, Decision.DENY) + self.assertIn("cannot", r.reason) + + def test_missing_skill_key_is_deny(self): + r = authorize(ctx(CEO_CLAIMS), MANIFEST, delegation_policy=policy(), + permit_check=lambda *_: True) + self.assertIs(r.decision, Decision.DENY) + + +class ClaimParsingTests(unittest.TestCase): + def test_untyped_subject_is_rejected(self): + with self.assertRaises(DelegationError): + parse_claims({"sub": "izzy", "act": {"sub": "repo:X"}}) + + def test_untyped_actor_is_rejected_not_dropped(self): + """A reference that cannot be evaluated must not be treated as satisfied.""" + with self.assertRaises(DelegationError): + parse_claims({"sub": "user:a@b.c", "act": {"sub": "FuzeExecutive"}}) + + def test_subject_without_an_actor_chain_is_rejected(self): + with self.assertRaises(DelegationError): + parse_claims({"sub": "user:a@b.c"}) + + def test_no_claims_is_none_not_an_error(self): + self.assertIsNone(parse_claims(None)) + self.assertIsNone(parse_claims({})) + + def test_chain_is_outermost_first(self): + d = parse_claims(CEO_CLAIMS) + self.assertEqual(d.actor_chain, ("repo:FuzeExecutive", "agent:a2a-shared")) + self.assertEqual(d.subject, "user:ceo@fuzefront.com") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/agent-templates/contracts/a2a/ext/README.md b/agent-templates/contracts/a2a/ext/README.md new file mode 100644 index 0000000..956d926 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/README.md @@ -0,0 +1,24 @@ +# A2A contract extensions + +`contracts/a2a/v1` is **frozen**. Capability added after the freeze lands here as +a versioned extension, never as an edit to v1 and never as a v2 unless the wire +protocol itself must change. + +The mechanism is the one A2A already defines: `AgentCapabilities.extensions[]` on +the Agent Card, which v1's `agent-card.schema.json` already accepts. A callee +publishes an extension URI to say it enforces those rules; a callee that does not +publish it behaves exactly as v1. + +## The test for "extension, not version" + +**Adopting it must not require editing anything frozen, and not adopting it must +not change anything.** Both halves matter, and the first one has teeth: the +`delegated-principal` policy lives in its own `.fuze/a2a-delegation.json` file +precisely because v1's `manifest-a2a-extension.schema.json` sets +`additionalProperties: false` on the `a2a` block — putting the policy there would +have made every adopting repo's manifest fail v1 validation, which would have made +this a version wearing an extension's name. + +| Extension | Version | What it adds | +|---|---|---| +| [`delegated-principal`](delegated-principal/v1/) | 1.0.0 | The ORIGINATING principal rides in the credential (RFC 8693 `sub`/`act`), so a callee can tell a CEO from a warehouse worker arriving through the same repo — which v1 cannot. Plus intersection-not-union authorization and a closed delegable/principal-required/never-delegable classification. | diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md new file mode 100644 index 0000000..d5cda4f --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/README.md @@ -0,0 +1,59 @@ +# `delegated-principal` — A2A extension v1.0.0 + +**URI:** `https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1` + +An **extension**, not a v2. `contracts/a2a/v1` is frozen and this tree does not +edit one byte of it. The mechanism is the one A2A already defines: +`AgentCapabilities.extensions[]` on the card (`agent-card.schema.json` → +`AgentExtension`), which v1's schema already accepts. A callee that does not +publish this URI behaves exactly as v1 specifies; a caller that does not send a +delegated credential is handled exactly as v1 specifies. + +## The gap it closes + +v1's `AuthContext.caller` is a **single string**, and `valid_caller_identity()` +accepts a bare repo name or an `Exec-*` principal (authz.md §2). There is no +end-user subject anywhere in the model. + +So a CEO and a warehouse worker arriving through the same calling repo are +**literally indistinguishable to the callee**. If `FuzeExecutive` is in +`FuzePlan`'s `providesTo`, both pass, identically. + +The contract's authors already stated the intent in authz.md §7 — *"`providesTo` +grants the right to ask, not the right to command… A2A adds a front door; it does +not widen any room behind it."* What was missing was the identity precision to act +on it, because identity resolved to a **repo** rather than a **person**. + +## The pod is a delegate, never a principal + +The A2A pod holds **no standing authority over product data**. Its workload +credential authorizes exactly one thing: *may present delegated tokens*. Every bit +of data-plane authority arrives with the call. + +The rejected alternative — give the pod broad rights and have it check the caller +before acting — is the confused deputy. It makes the guard a matter of the pod +remembering to look, which is a convention, not a construction. + +## Four layers + +| # | Layer | Mechanism | Status in v1 | +|---|---|---|---| +| 1 | **Channel** — may repo X reach repo Y at all? | `providesTo` | ✅ exists, unchanged | +| 2 | **Principal** — may *this subject* do this action? | RFC 8693 `sub` + `act` chain, in the credential, never the body | ❌ this extension | +| 3 | **Intersection** — effective = subject's rights **∩** agent's brokerable set | both escalation directions closed | ❌ this extension | +| 4 | **Non-delegable set** — operations no principal reaches via A2A | closed classification, no unclassified bucket | partial (§7 seeds it) | + +Layer 3 is the load-bearing one: the agent cannot grant more than the caller has, +**and** a caller cannot use the agent to reach what the agent may not broker. + +## Files + +| Path | What | +|---|---| +| `spec.md` | the normative rules | +| `schema/delegated-principal-token.schema.json` | the credential claims | +| `schema/delegation-policy.schema.json` | the per-callee classification, closed | +| `examples/` | a token, a policy, and the card capability entry | + +Implementation: `agent-templates/a2a/delegation.py`, wired into `authz.authorize()` +behind `AuthContext.delegation` — absent means v1 behaviour, exactly. diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION b/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json new file mode 100644 index 0000000..295f746 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/card-capability.json @@ -0,0 +1,11 @@ +{ + "capabilities": { + "extensions": [ + { + "uri": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "required": true, + "description": "Calls carry the originating principal (RFC 8693 sub/act). Skills classified principal-required or never-delegable are denied without a conforming credential." + } + ] + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json new file mode 100644 index 0000000..af1dd3e --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegated-token-claims.json @@ -0,0 +1,9 @@ +{ + "iss": "https://auth.fuzefront.com/", + "aud": "a2a-shared", + "sub": "user:izzy@fuzefront.com", + "act": { + "sub": "repo:FuzeExecutive", + "act": { "sub": "agent:a2a-shared" } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json new file mode 100644 index 0000000..63cc8f8 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/examples/delegation-policy.json @@ -0,0 +1,13 @@ +{ + "extension": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "authzBaseUrl": "http://fuzefront-backend.fuzefront.svc.cluster.local:3001", + "skills": { + "plan-reader": "delegable", + "plan-editor": "principal-required", + "plan-purge": "never-delegable" + }, + "brokerable": { + "repo:FuzeExecutive": ["plan-reader", "plan-editor"], + "repo:FuzeSales": ["plan-reader"] + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json new file mode 100644 index 0000000..f36d94c --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1/schema/delegated-principal-token.schema.json", + "title": "Delegated-principal claims (RFC 8693)", + "description": "The delegation claims a callee reads AFTER verifying signature and aud. Validating this shape is never a substitute for that verification: an unverified `sub` is worth less than no `sub`, because it looks like authority.", + "type": "object", + "required": ["sub", "act"], + "properties": { + "sub": { + "type": "string", + "pattern": "^(user|service):[^\\s:]+$", + "description": "The ORIGINATING principal — whom the work is ultimately for. This is the subject Permit decides on. Typed prefix is mandatory; a bare id cannot be resolved to a principal kind and so cannot be checked." + }, + "act": { + "$ref": "#/$defs/Actor", + "description": "The actor chain, outermost first: the immediate actor, then its actor. BOUNDS what `sub` may reach and never widens it." + } + }, + "additionalProperties": true, + "$defs": { + "Actor": { + "type": "object", + "required": ["sub"], + "properties": { + "sub": { + "type": "string", + "pattern": "^(repo|agent|service):[^\\s:]+$", + "description": "A TYPED reference. An untyped actor is rejected: it cannot be matched against the callee's brokerable set, and a rule that cannot be evaluated must not be treated as satisfied." + }, + "act": { "$ref": "#/$defs/Actor" } + }, + "additionalProperties": false + } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json new file mode 100644 index 0000000..009a080 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1/schema/delegation-policy.schema.json", + "title": "Per-callee delegation policy", + "description": "The callee's delegation policy, stored as its OWN file at `.fuze/a2a-delegation.json`. Deliberately NOT a key inside the manifest's `a2a` block: contracts/a2a/v1's manifest-a2a-extension.schema.json sets additionalProperties:false on that block, so adding `delegation` there would make every adopting repo's manifest fail v1 validation. An extension that requires editing the frozen contract to adopt is a version, not an extension \u2014 a separate file is what keeps this additive. It classifies EVERY published skill; there is no default class and no wildcard that could supply one, so an unclassified skill is denied and a new skill cannot acquire the weakest rule in the system merely by being written.", + "type": "object", + "required": [ + "extension", + "skills", + "brokerable" + ], + "additionalProperties": false, + "properties": { + "extension": { + "const": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "description": "Pinned URI. A policy that does not name the extension it implements cannot be checked against the right rules." + }, + "authzBaseUrl": { + "type": "string", + "description": "Base URL of FuzeFront's Security API for the Permit decision (/api/v1/security/authz/check). A product never calls Permit directly. Unreachable is DENY \u2014 there is no fail-open option." + }, + "skills": { + "type": "object", + "description": "role key -> class. Every published skill MUST appear. Absence is DENY, never `delegable`.", + "propertyNames": { + "pattern": "^[a-z0-9_-]+$" + }, + "additionalProperties": { + "enum": [ + "delegable", + "principal-required", + "never-delegable" + ], + "description": "delegable = v1 behaviour, any allowlisted caller. principal-required = verified `sub` AND a Permit ALLOW for that subject. never-delegable = no principal reaches it via A2A regardless of `sub` (the machine-readable form of authz.md \u00a77's reach_human and _base guardrails)." + } + }, + "brokerable": { + "type": "object", + "description": "actor reference -> the role keys that actor may BROKER. The second half of the intersection in spec.md \u00a74: the subject cannot reach past this, no matter what Permit says about the subject. An actor absent here brokers nothing.", + "propertyNames": { + "pattern": "^(repo|agent|service):[^\\s:]+$" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9_-]+$" + }, + "description": "Role keys. An empty array means this actor brokers nothing \u2014 which is a meaningful, explicit statement, unlike absence." + } + } + } +} diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md new file mode 100644 index 0000000..6ecd221 --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/spec.md @@ -0,0 +1,124 @@ +# `delegated-principal` v1 — normative spec + +Key words per RFC 2119. + +## §1 Negotiation + +A callee that enforces this extension MUST publish it in its Agent Card: + +```json +{ "capabilities": { "extensions": [ + { "uri": "https://contracts.fuzefront.com/a2a/ext/delegated-principal/v1", + "required": true, + "description": "Calls carry the originating principal; see spec.md" } ] } } +``` + +`required: true` means a call WITHOUT a conforming credential MUST be denied for +every skill this callee classifies as `principal-required` or `never-delegable`. +It does not change the handling of `delegable` skills, which remain exactly v1. + +A callee that does not publish the URI MUST behave as v1. A caller that does not +send the credential to such a callee MUST be handled as v1. **Neither side gets a +new failure mode from an unadopted extension** — that is what makes this an +extension rather than a version. + +## §2 The credential + +Delegation is carried by **RFC 8693 token exchange**, in the credential, and +**NEVER in the request body**. v1 authz.md §1 already forbids trusting the body +for authorization; this extension adds a claim, not an exemption. + +```json +{ + "iss": "https://auth.fuzefront.com/", + "aud": "a2a-shared", + "sub": "user:izzy@fuzefront.com", + "act": { "sub": "repo:FuzeExecutive", + "act": { "sub": "agent:a2a-shared" } } +} +``` + +- **`sub` is the ORIGINATING principal** — the human or service on whose behalf the + work is ultimately done. This is what Permit decides on. +- **`act` is the actor chain**, outermost first: the immediate actor, then its + actor, and so on. It BOUNDS what `sub` can reach; it never widens it. +- The chain MUST be non-empty when `sub` is present, and every entry MUST be a + typed reference (`repo:`, `agent:`, `service:`). A bare id is REJECTED — an + untyped actor cannot be checked against the brokerable set. +- The callee MUST verify the credential's signature and `aud` before reading any + claim. An unverified `sub` is worth less than no `sub`, because it looks like + authority. + +## §3 Where the policy lives + +The policy is its own file: **`.fuze/a2a-delegation.json`**, validated against +`schema/delegation-policy.schema.json`. + +It is deliberately NOT a key inside the manifest's `a2a` block. v1's +`manifest-a2a-extension.schema.json` sets `additionalProperties: false` on that +block, so a `delegation` key there would make **every adopting repo's manifest +fail v1 validation**. An extension that requires editing the frozen contract in +order to adopt it is a version, not an extension. A separate file keeps it +additive — a repo that has not adopted has no such file, and nothing changes for +it. + +## §4 Classification is CLOSED + +Every skill a callee publishes MUST fall in exactly one class: + +| Class | Meaning | +|---|---| +| `delegable` | any allowlisted caller may invoke; no principal needed (v1 behaviour) | +| `principal-required` | requires a verified `sub` AND an ALLOW from Permit for that subject | +| `never-delegable` | no principal reaches it through A2A, regardless of `sub` | + +**There is no fourth bucket.** A skill that is not classified MUST be DENIED, not +treated as `delegable`. An "unclassified" default is how a new endpoint acquires +the weakest rule in the system by being written rather than by being decided — +the same closed-set property applied by the route-ownership and OpenAPI gates. + +`never-delegable` is the machine-readable form of what authz.md §7 already seeds: +`reach_human` for binding decisions, and the `_base` guardrails on `kubectl patch`, +`helm rollback`, `terraform destroy`. + +## §5 Intersection, never union + +For a `principal-required` skill the effective permission is + +``` +effective = permitted(sub, action, resource) ∩ brokerable(actor_chain, skill) +``` + +Both terms MUST be evaluated and both MUST allow. Specifically: + +- **The agent cannot grant more than the subject has.** A subject with no right to + delete a ticket does not acquire one by asking through an agent that has it. +- **The subject cannot reach past what the agent may broker.** A CEO with every + right in Permit still cannot use an agent to reach a skill that agent is not + permitted to broker. + +Implementations MUST NOT short-circuit on either term alone. A union — "allow if +either permits" — is not a weaker version of this rule; it is the opposite of it. + +## §6 Failure is closed and uninformative + +- Missing `sub` on a `principal-required` skill → DENY. +- Unverifiable credential → DENY. +- Permit unreachable → DENY. `DECISION_UNAVAILABLE` is never an allow; there is no + fail-open mode and no configuration flag that creates one. +- Unclassified skill → DENY (§4). +- Untyped actor entry → DENY (§2). + +Wire errors MUST NOT disclose which of these applied, per v1 authz.md §6. The +distinction belongs in the callee's logs. + +## §7 What this extension does NOT do + +- It does not weaken `providesTo`. Channel authz still runs first, and a caller + outside `providesTo` is denied before any of this is consulted. +- It does not move authorization into the request body. +- It does not give the pod standing authority. The pod's own credential authorizes + presenting delegated tokens and nothing else. +- It does not define the token-exchange endpoint. Obtaining the delegated token is + the CALLER's side of the work and is out of scope here — which is where the real + remaining implementation sits. From e4794ad172ad8ed9686bba5aa90e7924e3e7b5ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 02:00:03 +0000 Subject: [PATCH 2/3] docs(a2a-ext): give delegated-principal its own changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension shipped with a VERSION and no changelog. It needs its own rather than an entry in contracts/a2a/v1/CHANGELOG.md, which is frozen with the rest of v1 — appending there would be an edit to exactly the thing this extension exists to avoid editing. Records the 1.0.0 surface, and states the compatibility property that makes this an extension rather than a version: a callee that does not publish the URI behaves as v1, a caller that sends no delegated credential is handled as v1, and adopting it requires editing nothing frozen (which is why the policy lives in its own .fuze/a2a-delegation.json). This also has a second effect worth stating plainly, because it is the reason the commit exists now: PR #195's real CI has NEVER RUN. The stranded-branch detector opened the PR as a DRAFT, and `pull_request` workflows do not fire on `ready_for_review` — so flipping it to ready left it with two meta checks and nothing else. "No failures" on that PR meant "nothing was tested", which is the same shape as the vacuous gates this branch's own tests are written against. Pushing a real commit fires `synchronize` and gets the suite to actually run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- .../ext/delegated-principal/v1/CHANGELOG.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md diff --git a/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md b/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md new file mode 100644 index 0000000..73c31ad --- /dev/null +++ b/agent-templates/contracts/a2a/ext/delegated-principal/v1/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog — `delegated-principal` + +This extension keeps its own changelog. `contracts/a2a/v1/CHANGELOG.md` is frozen +along with the rest of v1, and appending to it would be an edit to the thing this +extension exists to avoid editing. + +## 1.0.0 + +Initial release. + +### Added + +- **The originating principal rides in the credential.** RFC 8693 `sub` (the human + or service the work is ultimately for) plus an `act` chain (the immediate actor, + then its actor). v1 carried a single `caller` string, so a CEO and a warehouse + worker arriving through the same repo were indistinguishable to the callee. +- **Intersection authorization**: `effective = permitted(sub) ∩ brokerable(actor)`. + Both terms are required. The agent cannot grant more than the subject has, and + the subject cannot reach past what the agent may broker. +- **A closed skill classification**: `delegable` / `principal-required` / + `never-delegable`. No fourth member and no default — an unclassified skill is + denied. `never-delegable` is the machine-readable form of the `reach_human` and + `_base` guardrails authz.md §7 already describes in prose. +- `schema/delegated-principal-token.schema.json`, `schema/delegation-policy.schema.json`, + worked examples, and `a2a/delegation.py` wired into `authz.authorize()`. + +### Compatibility + +Additive in both directions, which is the test for an extension rather than a +version: + +- A callee that does not publish the extension URI behaves exactly as v1. +- A caller that sends no delegated credential is handled exactly as v1. +- The policy lives in its own `.fuze/a2a-delegation.json`, **not** under the + manifest's `a2a` block — v1's `manifest-a2a-extension.schema.json` sets + `additionalProperties: false` there, so a `delegation` key would have made every + adopting repo's manifest fail v1 validation. + +### Not included + +The **token-exchange endpoint**. Minting the delegated token before dialling is +the caller's side of the work and is where the remaining implementation sits. +This release is the callee-side enforcement plus the contract it enforces against. From 3c2fd7ecff0b3c52390b2fa20c0d2a320747933d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 02:02:44 +0000 Subject: [PATCH 3/3] style: black-format the three files this branch touches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authz.py` is black-clean on main and my edit broke that; delegation.py and test_delegation.py are new. Restoring the first and matching the second two. NOT what fixed the reported `lint` failure — see the rebase in this push. CI's black step is scoped to services/orchestrator (`black --check .` at repo ROOT reports 83 pre-existing failures, so it cannot be running there), and this branch no longer touches that tree at all. This commit is consistency, stated as such rather than dressed up as the fix. Verified inert the same way as the earlier one: `ast.dump()` before and after is IDENTICAL for all three files, which ignores whitespace entirely. 180 tests still pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv --- agent-templates/a2a/authz.py | 21 ++++--- agent-templates/a2a/delegation.py | 12 ++-- agent-templates/a2a/tests/test_delegation.py | 62 +++++++++++++++----- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/agent-templates/a2a/authz.py b/agent-templates/a2a/authz.py index 5999326..467c938 100644 --- a/agent-templates/a2a/authz.py +++ b/agent-templates/a2a/authz.py @@ -153,7 +153,8 @@ def _authorize_delegated( if not skill_key: return AuthzResult( Decision.DENY, - "delegation policy present but no skill key to classify -> fail closed") + "delegation policy present but no skill key to classify -> fail closed", + ) klass = policy.classify(skill_key) if klass is None: @@ -162,12 +163,14 @@ def _authorize_delegated( # weakest rule in the system by being written rather than decided. return AuthzResult( Decision.DENY, - f"skill {skill_key!r} is unclassified in delegation.skills -> fail closed") + f"skill {skill_key!r} is unclassified in delegation.skills -> fail closed", + ) if klass is SkillClass.NEVER_DELEGABLE: return AuthzResult( Decision.DENY, - f"skill {skill_key!r} is never-delegable; no principal reaches it via A2A") + f"skill {skill_key!r} is never-delegable; no principal reaches it via A2A", + ) if klass is SkillClass.DELEGABLE: return AuthzResult(Decision.ALLOW, "authorized (delegable)") @@ -178,14 +181,16 @@ def _authorize_delegated( return AuthzResult( Decision.DENY, f"skill {skill_key!r} is principal-required but the call carries no " - f"verified subject") + f"verified subject", + ) # Term 2 first, because it needs no network call: the subject cannot reach past # what the agent may broker, however privileged the subject is. if not policy.brokerable_by(delegation.actor_chain, skill_key): return AuthzResult( Decision.DENY, - f"no actor in {delegation.actor_chain} may broker {skill_key!r}") + f"no actor in {delegation.actor_chain} may broker {skill_key!r}", + ) # Term 1: what the SUBJECT may do, decided by Permit via FuzeFront's Security # API. A product never calls Permit directly. @@ -193,7 +198,8 @@ def _authorize_delegated( return AuthzResult( Decision.DENY, "principal-required skill but no authz client is wired -> cannot " - "evaluate the subject's permission -> fail closed") + "evaluate the subject's permission -> fail closed", + ) try: permitted = permit_check(delegation.subject, skill_key) except Exception as exc: @@ -203,7 +209,8 @@ def _authorize_delegated( if not permitted: return AuthzResult( Decision.DENY, - f"subject {delegation.subject!r} is not permitted {skill_key!r}") + f"subject {delegation.subject!r} is not permitted {skill_key!r}", + ) # The agent cannot grant more than the subject has, AND the subject cannot # reach past what the agent may broker. Both were required; neither alone. diff --git a/agent-templates/a2a/delegation.py b/agent-templates/a2a/delegation.py index b5ab541..d367c94 100644 --- a/agent-templates/a2a/delegation.py +++ b/agent-templates/a2a/delegation.py @@ -121,8 +121,11 @@ def from_manifest(cls, block: dict | None) -> "DelegationPolicy | None": if not isinstance(roles, list): raise DelegationError(f"brokerable[{actor!r}] must be an array") brokerable[actor] = frozenset(roles) - return cls(skills=skills, brokerable=brokerable, - authz_base_url=block.get("authzBaseUrl")) + return cls( + skills=skills, + brokerable=brokerable, + authz_base_url=block.get("authzBaseUrl"), + ) def classify(self, skill: str) -> SkillClass | None: """The skill's class, or None when unclassified. None is DENY, not a default.""" @@ -135,8 +138,9 @@ def brokerable_by(self, actor_chain: tuple[str, ...], skill: str) -> bool: wildcard. An explicit empty list says the same thing on purpose, which is a meaningful statement in a way absence is not. """ - return any(skill in self.brokerable.get(actor, frozenset()) - for actor in actor_chain) + return any( + skill in self.brokerable.get(actor, frozenset()) for actor in actor_chain + ) def parse_claims(claims: dict | None) -> Delegation | None: diff --git a/agent-templates/a2a/tests/test_delegation.py b/agent-templates/a2a/tests/test_delegation.py index e934bc6..419ccc7 100644 --- a/agent-templates/a2a/tests/test_delegation.py +++ b/agent-templates/a2a/tests/test_delegation.py @@ -14,7 +14,11 @@ from a2a.authz import AuthContext, Decision, authorize from a2a.delegation import ( - EXTENSION_URI, Delegation, DelegationError, DelegationPolicy, SkillClass, + EXTENSION_URI, + Delegation, + DelegationError, + DelegationPolicy, + SkillClass, parse_claims, ) @@ -34,10 +38,14 @@ }, } -CEO_CLAIMS = {"sub": "user:ceo@fuzefront.com", - "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}} -WORKER_CLAIMS = {"sub": "user:worker@fuzefront.com", - "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}} +CEO_CLAIMS = { + "sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}, +} +WORKER_CLAIMS = { + "sub": "user:worker@fuzefront.com", + "act": {"sub": "repo:FuzeExecutive", "act": {"sub": "agent:a2a-shared"}}, +} def policy(): @@ -49,8 +57,13 @@ def ctx(claims=None, caller="FuzeExecutive"): def decide(claims=None, skill="plan-editor", permit=None, caller="FuzeExecutive"): - return authorize(ctx(claims, caller), MANIFEST, skill_key=skill, - delegation_policy=policy(), permit_check=permit) + return authorize( + ctx(claims, caller), + MANIFEST, + skill_key=skill, + delegation_policy=policy(), + permit_check=permit, + ) class BackwardCompatibilityTests(unittest.TestCase): @@ -123,11 +136,17 @@ def test_intersection_not_union(self): self.assertIs(decide(CEO_CLAIMS, permit=allow_all).decision, Decision.ALLOW) # Subject permitted, actor may NOT broker -> DENY. A union would allow. - sales_claims = {"sub": "user:ceo@fuzefront.com", - "act": {"sub": "repo:FuzeSales"}} - r = authorize(ctx(sales_claims, caller="FuzeSales"), MANIFEST, - skill_key="plan-editor", delegation_policy=policy(), - permit_check=allow_all) + sales_claims = { + "sub": "user:ceo@fuzefront.com", + "act": {"sub": "repo:FuzeSales"}, + } + r = authorize( + ctx(sales_claims, caller="FuzeSales"), + MANIFEST, + skill_key="plan-editor", + delegation_policy=policy(), + permit_check=allow_all, + ) self.assertIs(r.decision, Decision.DENY) self.assertIn("broker", r.reason) @@ -139,16 +158,23 @@ def test_intersection_not_union(self): def test_an_actor_absent_from_brokerable_brokers_nothing(self): """Absence is not a wildcard.""" claims = {"sub": "user:ceo@fuzefront.com", "act": {"sub": "repo:FuzeUnknown"}} - r = authorize(ctx(claims), MANIFEST, skill_key="plan-editor", - delegation_policy=policy(), permit_check=lambda *_: True) + r = authorize( + ctx(claims), + MANIFEST, + skill_key="plan-editor", + delegation_policy=policy(), + permit_check=lambda *_: True, + ) self.assertIs(r.decision, Decision.DENY) class FailClosedTests(unittest.TestCase): def test_permit_unreachable_is_deny_never_allow(self): """DECISION_UNAVAILABLE is a DENY. This is the fail-open branch, spelled out.""" + def boom(*_): raise ConnectionError("authz service unreachable") + r = decide(CEO_CLAIMS, permit=boom) self.assertIs(r.decision, Decision.DENY) self.assertIn("unavailable", r.reason) @@ -159,8 +185,12 @@ def test_no_authz_client_wired_is_deny_not_skip(self): self.assertIn("cannot", r.reason) def test_missing_skill_key_is_deny(self): - r = authorize(ctx(CEO_CLAIMS), MANIFEST, delegation_policy=policy(), - permit_check=lambda *_: True) + r = authorize( + ctx(CEO_CLAIMS), + MANIFEST, + delegation_policy=policy(), + permit_check=lambda *_: True, + ) self.assertIs(r.decision, Decision.DENY)