sf-smartdelegate 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. sf_smartdelegate/__init__.py +32 -0
  2. sf_smartdelegate/__main__.py +3 -0
  3. sf_smartdelegate/_version.py +1 -0
  4. sf_smartdelegate/audit.py +113 -0
  5. sf_smartdelegate/backend.py +84 -0
  6. sf_smartdelegate/bridges/__init__.py +56 -0
  7. sf_smartdelegate/bridges/__main__.py +6 -0
  8. sf_smartdelegate/bridges/aws.py +293 -0
  9. sf_smartdelegate/bridges/aws_identity_center.py +105 -0
  10. sf_smartdelegate/bridges/azure.py +177 -0
  11. sf_smartdelegate/bridges/ceiling.py +107 -0
  12. sf_smartdelegate/bridges/claims.py +221 -0
  13. sf_smartdelegate/bridges/cli.py +257 -0
  14. sf_smartdelegate/bridges/cloudmap.py +247 -0
  15. sf_smartdelegate/bridges/directory.py +386 -0
  16. sf_smartdelegate/bridges/entra.py +141 -0
  17. sf_smartdelegate/bridges/gcp.py +190 -0
  18. sf_smartdelegate/bridges/google.py +90 -0
  19. sf_smartdelegate/bridges/http.py +259 -0
  20. sf_smartdelegate/bridges/keycloak.py +152 -0
  21. sf_smartdelegate/bridges/okta.py +71 -0
  22. sf_smartdelegate/bridges/scim.py +102 -0
  23. sf_smartdelegate/bridges/sigv4.py +72 -0
  24. sf_smartdelegate/bundle.py +265 -0
  25. sf_smartdelegate/canon.py +24 -0
  26. sf_smartdelegate/cedar.py +489 -0
  27. sf_smartdelegate/cli.py +416 -0
  28. sf_smartdelegate/client.py +60 -0
  29. sf_smartdelegate/conformance.py +87 -0
  30. sf_smartdelegate/core.py +164 -0
  31. sf_smartdelegate/daemon.py +139 -0
  32. sf_smartdelegate/engine.py +1086 -0
  33. sf_smartdelegate/errors.py +20 -0
  34. sf_smartdelegate/example_bundle/catalog.json +38 -0
  35. sf_smartdelegate/example_bundle/entities.json +15 -0
  36. sf_smartdelegate/example_bundle/policies/billing.cedar +26 -0
  37. sf_smartdelegate/example_bundle/policies/crm.cedar +38 -0
  38. sf_smartdelegate/example_bundle/policies/delegation.cedar +23 -0
  39. sf_smartdelegate/example_bundle/policies/hr.cedar +11 -0
  40. sf_smartdelegate/example_bundle/policies/roles.cedar +15 -0
  41. sf_smartdelegate/example_bundle/policies.json +1394 -0
  42. sf_smartdelegate/example_bundle/roles.json +44 -0
  43. sf_smartdelegate/example_bundle/tests/basics.json +158 -0
  44. sf_smartdelegate/fields.py +122 -0
  45. sf_smartdelegate/guard.py +79 -0
  46. sf_smartdelegate/httpproxy.py +525 -0
  47. sf_smartdelegate/mcpproxy.py +370 -0
  48. sf_smartdelegate/models.py +133 -0
  49. sf_smartdelegate/sql.py +173 -0
  50. sf_smartdelegate/state.py +63 -0
  51. sf_smartdelegate/tokens.py +299 -0
  52. sf_smartdelegate-0.3.0.dist-info/METADATA +395 -0
  53. sf_smartdelegate-0.3.0.dist-info/RECORD +58 -0
  54. sf_smartdelegate-0.3.0.dist-info/WHEEL +5 -0
  55. sf_smartdelegate-0.3.0.dist-info/entry_points.txt +2 -0
  56. sf_smartdelegate-0.3.0.dist-info/licenses/LICENSE +176 -0
  57. sf_smartdelegate-0.3.0.dist-info/licenses/NOTICE +23 -0
  58. sf_smartdelegate-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,32 @@
1
+ """SmartDelegate: identity and access management for LLM agents.
2
+
3
+ This package is the reference implementation. Other languages are ports of it
4
+ and are held to the same conformance vectors (spec/conformance).
5
+
6
+ from sf_smartdelegate import Engine, Guard
7
+
8
+ iam = Engine({"bundle_dir": "bundle", "state_dir": "state"})
9
+ guard = Guard(iam, token=lambda: current_task_token)
10
+
11
+ @guard.tool(action="read", resource="crm.customer", id="customer_id")
12
+ def get_customer(customer_id): ...
13
+
14
+ The short way in is `sf_smartdelegate.core`: `delegate` (a human lends an agent a
15
+ role for one task), `narrow` (a sub-agent gets less) and `check`.
16
+
17
+ `Engine` decides in-process. `DaemonClient` asks a running `smartdelegate serve`.
18
+ Both have the same methods. In-process checks bind an agent that cooperates;
19
+ the boundary an agent cannot skip is a proxy (`smartdelegate http-proxy`,
20
+ `smartdelegate mcp-proxy`). See docs/LIMITATIONS.md.
21
+ """
22
+ from ._version import __version__
23
+
24
+ from .backend import SmartDelegateError, Backend # noqa: E402
25
+ from .client import DaemonClient # noqa: E402
26
+ from .engine import Engine # noqa: E402
27
+ from .errors import EngineError # noqa: E402
28
+ from .guard import ApprovalRequired, Denied, Guard # noqa: E402
29
+ from .sql import SqlGuard # noqa: E402
30
+ from . import core, models # noqa: E402
31
+
32
+ __all__ = ["Engine", "DaemonClient", "Guard", "SqlGuard", "Denied", "ApprovalRequired", "SmartDelegateError", "EngineError", "Backend"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,113 @@
1
+ """Append-only, hash-chained decision log (JSON Lines).
2
+
3
+ record.hash = sha256(prev_hash + canonical_json(record without "hash")).
4
+ Editing, deleting or reordering any line breaks every hash after it.
5
+ Truncating the tail is only detectable against a head hash kept elsewhere.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from .canon import canon, sha256_hex
14
+ from .errors import EngineError
15
+
16
+ GENESIS = "0" * 64
17
+
18
+
19
+ def verify(path: Path) -> dict:
20
+ """Recompute the whole chain. Raises audit_broken naming the first bad line."""
21
+ prev, n = GENESIS, 0
22
+ try:
23
+ raw = Path(path).read_bytes()
24
+ except OSError as e:
25
+ raise EngineError("audit_io", f"cannot read audit log: {e.strerror or e}") from None
26
+ try:
27
+ text = raw.decode("utf-8")
28
+ except UnicodeDecodeError:
29
+ raise EngineError("audit_broken", "the audit log is not UTF-8") from None
30
+ for lineno, line in enumerate(text.split("\n"), 1):
31
+ if not line.strip():
32
+ continue
33
+ try:
34
+ rec = json.loads(line)
35
+ assert isinstance(rec, dict)
36
+ except Exception:
37
+ raise EngineError("audit_broken", f"line {lineno}: not JSON") from None
38
+ claimed = rec.pop("hash", "")
39
+ if rec.get("prev") != prev:
40
+ raise EngineError("audit_broken", f"line {lineno}: prev hash does not match the line before")
41
+ if rec.get("seq") != n + 1:
42
+ raise EngineError("audit_broken", f"line {lineno}: sequence number is not {n + 1}")
43
+ if sha256_hex((prev + canon(rec)).encode("utf-8")) != claimed:
44
+ raise EngineError("audit_broken", f"line {lineno}: content does not match its hash")
45
+ prev = claimed
46
+ n += 1
47
+ return {"ok": True, "records": n, "head": prev}
48
+
49
+
50
+ def tail(path: Path, n: int) -> list:
51
+ if not path.exists():
52
+ return []
53
+ # split on "\n" only: canonical JSON writes U+2028, U+0085 and friends
54
+ # as themselves, and str.splitlines() would cut a record in two there
55
+ lines = [l for l in path.read_bytes().decode("utf-8", "replace").split("\n") if l.strip()]
56
+ out = []
57
+ for l in lines[max(0, len(lines) - n):]:
58
+ try:
59
+ out.append(json.loads(l))
60
+ except ValueError:
61
+ pass
62
+ return out
63
+
64
+
65
+ class AuditLog:
66
+ def __init__(self, path: Path):
67
+ self.path = Path(path)
68
+ self.seq, self.head = 0, GENESIS
69
+ if self.path.exists():
70
+ v = verify(self.path)
71
+ self.seq, self.head = v["records"], v["head"]
72
+ self.len = self.path.stat().st_size if self.path.exists() else 0
73
+
74
+ def _resync(self) -> None:
75
+ """Chain onto the last record if another process appended. The engine
76
+ holds the state-directory lock while this runs."""
77
+ try:
78
+ size = self.path.stat().st_size
79
+ except OSError:
80
+ size = 0
81
+ if size == self.len:
82
+ return
83
+ if size == 0:
84
+ raise EngineError("audit_broken", "the audit log was truncated while in use")
85
+ try:
86
+ with open(self.path, "rb") as f:
87
+ f.seek(max(0, size - (1 << 20)))
88
+ lines = [l for l in f.read().decode("utf-8", "replace").split("\n") if l.strip()]
89
+ except OSError as e:
90
+ raise EngineError("audit_io", f"cannot read audit log: {e.strerror or e}") from None
91
+ try:
92
+ last = json.loads(lines[-1])
93
+ self.seq, self.head, self.len = int(last["seq"]), str(last["hash"]), size
94
+ except Exception:
95
+ raise EngineError("audit_broken", "the last audit record is unreadable") from None
96
+
97
+ def append(self, record: dict, ts: int) -> str:
98
+ self._resync()
99
+ rec = dict(record)
100
+ rec.pop("hash", None)
101
+ rec.update(seq=self.seq + 1, ts=ts, prev=self.head)
102
+ h = sha256_hex((self.head + canon(rec)).encode("utf-8"))
103
+ rec["hash"] = h
104
+ try:
105
+ with open(self.path, "a", encoding="utf-8", newline="\n") as f:
106
+ f.write(canon(rec) + "\n")
107
+ f.flush()
108
+ self.len = os.fstat(f.fileno()).st_size
109
+ except OSError as e:
110
+ raise EngineError("audit_io", f"cannot write audit log: {e.strerror or e}") from None
111
+ self.seq += 1
112
+ self.head = h
113
+ return h
@@ -0,0 +1,84 @@
1
+ """Operations shared by every backend. Each returns the engine's JSON result
2
+ as a dict; a result with ok == False raises SmartDelegateError."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .errors import EngineError
8
+
9
+
10
+ # One error type for the engine and the clients. A deny is not an error: it
11
+ # is a normal result with effect == "deny".
12
+ SmartDelegateError = EngineError
13
+
14
+
15
+ class Backend:
16
+ def call(self, op: str, payload: dict) -> dict: # pragma: no cover - overridden
17
+ raise NotImplementedError
18
+
19
+ def _ok(self, op: str, payload: dict) -> dict:
20
+ result = self.call(op, payload)
21
+ if not result.get("ok"):
22
+ err = result.get("error") or {}
23
+ raise SmartDelegateError(err.get("code", "error"), err.get("message", "unknown error"))
24
+ return result
25
+
26
+ @staticmethod
27
+ def _request(token, action, resource, id, attrs, context, extra) -> dict:
28
+ res: dict[str, Any] = {"path": resource, "id": id if id is not None else "any"}
29
+ if attrs:
30
+ res["attrs"] = attrs
31
+ body: dict[str, Any] = {"token": token, "action": action, "resource": res}
32
+ if context:
33
+ body["context"] = context
34
+ body.update({k: v for k, v in extra.items() if v is not None})
35
+ return body
36
+
37
+ def authorize(self, token: str, action: str, resource: str, id: str | None = None, *, attrs: dict | None = None,
38
+ context: dict | None = None, fields: list[str] | None = None, dry_run: bool | None = None) -> dict:
39
+ return self._ok("authorize", self._request(token, action, resource, id, attrs, context, {"fields": fields, "dry_run": dry_run}))
40
+
41
+ def filter(self, token: str, action: str, resource: str, data: Any, id: str | None = None, *, attrs: dict | None = None,
42
+ context: dict | None = None, strict: bool = False) -> dict:
43
+ body = self._request(token, action, resource, id, attrs, context, {"strict": strict or None})
44
+ body["data"] = data
45
+ return self._ok("filter", body)
46
+
47
+ def explain(self, token: str, action: str, resource: str, id: str | None = None, *, attrs: dict | None = None, context: dict | None = None) -> dict:
48
+ return self._ok("explain", self._request(token, action, resource, id, attrs, context, {}))
49
+
50
+ def apply_obligations(self, decision: dict, data: Any) -> dict:
51
+ """Mask data that arrived after the decision (a tool result)."""
52
+ return self._ok("apply_obligations", {"obligations": decision["obligations"], "data": data, "decision_id": decision.get("decision_id")})
53
+
54
+ def issue_token(self, agent: str, cap: dict | None = None, *, user: str | None = None, id_token: str | None = None,
55
+ ttl: int | None = None, tenant: str | None = None, task: str | None = None, role: str | None = None,
56
+ groups: list | None = None) -> dict:
57
+ """`role`: lend the agent a role the user holds; the token can never
58
+ carry more than the role allows, and `cap` may then be omitted.
59
+ `groups`: the user's live group membership, when the caller has it
60
+ from the identity provider."""
61
+ body = {"agent": agent, "cap": cap, "user": user, "id_token": id_token, "ttl": ttl, "tenant": tenant, "task": task,
62
+ "role": role, "groups": groups}
63
+ return self._ok("issue_token", {k: v for k, v in body.items() if v is not None})
64
+
65
+ def exchange_token(self, subject_token: str, actor: str, *, cap: dict | None = None, ttl: int | None = None) -> dict:
66
+ body = {"subject_token": subject_token, "actor": actor, "cap": cap, "ttl": ttl}
67
+ return self._ok("exchange_token", {k: v for k, v in body.items() if v is not None})
68
+
69
+ def inspect_token(self, token: str) -> dict:
70
+ return self._ok("inspect_token", {"token": token})
71
+
72
+ def revoke(self, token_id: str) -> dict:
73
+ return self._ok("revoke", {"token_id": token_id})
74
+
75
+ def revoke_user(self, user: str, by: str | None = None) -> dict:
76
+ """End every token already issued for this human (and their agents'
77
+ sub-agent tokens). Use it when a person is offboarded or loses a role."""
78
+ return self._ok("revoke", {k: v for k, v in {"user": user, "by": by}.items() if v is not None})
79
+
80
+ def approve(self, approval_id: str, by: str) -> dict:
81
+ return self._ok("approve", {"approval_id": approval_id, "by": by})
82
+
83
+ def info(self) -> dict:
84
+ return self._ok("info", {})
@@ -0,0 +1,56 @@
1
+ """Bridges between SmartDelegate and real IAM systems. See docs/BRIDGES.md.
2
+
3
+ Inbound (the agent's bound follows the human's):
4
+
5
+ claims verified ID/access-token claims -> the `groups` list for issue_token
6
+ directory a directory snapshot -> entities.json (atomic, guarded write)
7
+ scim, okta, entra, keycloak, google, aws_identity_center
8
+ read users, groups and memberships into a `directory.Snapshot`
9
+ ceiling what the human may do in the cloud -> a cap on a role's ceiling
10
+
11
+ Outbound (a cloud credential no wider than the SmartDelegate token):
12
+
13
+ aws STS AssumeRole kwargs with a generated session policy and tags
14
+ gcp Credential Access Boundary + STS token exchange (Cloud Storage only)
15
+ azure On-Behalf-Of request with the scopes the cap maps to
16
+
17
+ Everything here is standard library only. Network calls go through an
18
+ injectable `http.Transport`; functions for a cloud SDK return plain dicts and
19
+ never import the SDK.
20
+
21
+ STATUS: every adapter was written from the provider's public documentation
22
+ and, except for Keycloak, has been run only against fake servers in tests/.
23
+ The Keycloak adapter and claim rules are verified against a real server
24
+ (live/idp). Nothing here has been run against a real AWS, Azure, Google or
25
+ Okta service. Details that
26
+ could not be checked in the documentation are marked `# UNVERIFIED:` in the
27
+ code and listed in docs/BRIDGES.md.
28
+ """
29
+ from __future__ import annotations
30
+
31
+
32
+ class BridgeError(Exception):
33
+ """A bridge could not do what was asked. Bridges fail closed: an HTTP
34
+ error, a malformed or truncated response, or an unexpected shape raises
35
+ this instead of returning a partial answer. `code` is stable and short;
36
+ `message` never contains a credential."""
37
+
38
+ def __init__(self, code: str, message: str, detail=None):
39
+ super().__init__(f"{code}: {message}")
40
+ self.code = code
41
+ self.message = message
42
+ #: optional structured data for the caller (never a secret)
43
+ self.detail = detail
44
+
45
+
46
+ from . import http # noqa: E402
47
+ from . import claims, cloudmap, directory, ceiling # noqa: E402
48
+ from . import scim, okta, entra, keycloak, google, sigv4, aws_identity_center # noqa: E402
49
+ from . import aws, gcp, azure # noqa: E402
50
+ from .claims import groups_from_claims # noqa: E402
51
+ from .directory import Group, Snapshot, User, diff, revoke_changed, sync, to_entities, users_to_revoke, write_entities # noqa: E402
52
+ from .http import Transport, urllib_transport # noqa: E402
53
+
54
+ __all__ = ["BridgeError", "Transport", "urllib_transport", "groups_from_claims", "Snapshot", "User", "Group",
55
+ "to_entities", "diff", "write_entities", "sync", "revoke_changed", "users_to_revoke", "http", "claims", "cloudmap", "directory", "ceiling",
56
+ "scim", "okta", "entra", "keycloak", "google", "sigv4", "aws_identity_center", "aws", "gcp", "azure"]
@@ -0,0 +1,6 @@
1
+ """`python -m sf_smartdelegate.bridges <subcommand>`: the same as `smartdelegate bridge <subcommand>`."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -0,0 +1,293 @@
1
+ """AWS: down-scope a role session to a SmartDelegate capability, and read a
2
+ human's ceiling from the IAM policy simulator.
3
+
4
+ Nothing here imports boto3. `assume_role_request` returns the keyword
5
+ arguments for `sts_client.assume_role(**kwargs)`; `simulation_request` those
6
+ for `iam_client.simulate_principal_policy(**kwargs)`.
7
+
8
+ Written from:
9
+ https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
10
+ Policy: inline session policy, at most 2,048 characters; DurationSeconds 900..43200
11
+ (default 3600); RoleSessionName 2..64 characters of [\\w+=,.@-]; Tags: at most 50, key
12
+ <= 128, value <= 256; TransitiveTagKeys
13
+ https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html
14
+ the role's trust policy must allow sts:TagSession; session tags appear in CloudTrail
15
+ under requestParameters.principalTags; policy and tags share a packed size limit
16
+ https://docs.aws.amazon.com/IAM/latest/APIReference/API_SimulatePrincipalPolicy.html
17
+ PolicySourceArn, ActionNames, ResourceArns -> EvaluationResults[] {EvalActionName,
18
+ EvalResourceName, EvalDecision}, IsTruncated, Marker
19
+ Tested only with a fake STS client and hand-written simulator results. AWS was not contacted.
20
+
21
+ What a session policy does: the session's permissions are the intersection of the role's
22
+ identity policies and the session policy. It can only take away. So the credential is never
23
+ wider than BOTH the role (`role.bindings.aws.role_arn`) and the capability as mapped.
24
+
25
+ # UNVERIFIED: EvalDecision values. The page read shows "allowed" and "implicitDeny";
26
+ # "explicitDeny" is from memory. Only the exact string "allowed" counts as allowed here.
27
+ # UNVERIFIED: how the simulator treats `*` inside a ResourceArns entry. `simulation_request`
28
+ # sends each mapped resource template with {id} replaced by `*`; a rule counts as allowed only
29
+ # if that exact entry comes back "allowed".
30
+ # UNVERIFIED: the packed (binary) size limit of policy plus tags cannot be computed locally; STS
31
+ # can still refuse a request that is under 2,048 characters. `assume_role` then raises.
32
+ # UNVERIFIED: that `?` and `${...}` are the only characters besides `*` with a special meaning in
33
+ # an IAM Resource element. Ids containing them are not granted (reported as "unsafe_id").
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import re
39
+ import time
40
+
41
+ from .. import tokens
42
+ from . import BridgeError
43
+ from .cloudmap import allowed_pairs, as_cap, not_enforced, pairs_to_cap, rules, select
44
+
45
+ POLICY_MAX_CHARS = 2048
46
+ DURATION_MIN, DURATION_MAX = 900, 43200
47
+ TAG_KEYS = {"user": "sd_user", "agent": "sd_agent", "role": "sd_role", "task": "sd_task", "token_id": "sd_token_id"}
48
+ _ROLE_ARN = re.compile(r"arn:aws[a-z-]*:iam::\d{12}:role/[\w+=,.@/-]+")
49
+ _DENY_ALL = {"Effect": "Deny", "Action": "*", "Resource": "*"}
50
+
51
+
52
+ def _safe_id(i: str) -> bool:
53
+ return "?" not in i and "${" not in i and "*" not in i[:-1]
54
+
55
+
56
+ def _statements(cap, cloud_map: dict) -> tuple:
57
+ grants, skipped = select(cap, rules(cloud_map, "aws"))
58
+ merged: dict = {}
59
+ for g in grants:
60
+ allow = g.rule["allow"]
61
+ resources = []
62
+ for t in allow["Resource"]:
63
+ if "{id}" in t:
64
+ if not _safe_id(g.id):
65
+ skipped.append({"action": g.rule["action"], "resource": g.resource, "reason": "unsafe_id"})
66
+ resources = []
67
+ break
68
+ resources.append(t.replace("{id}", g.id))
69
+ elif g.whole:
70
+ resources.append(t)
71
+ else:
72
+ skipped.append({"action": g.rule["action"], "resource": g.resource, "reason": "cannot_narrow", "template": t})
73
+ if not resources:
74
+ continue
75
+ key = (tuple(sorted(set(allow["Action"]))), json.dumps(allow.get("Condition"), sort_keys=True))
76
+ merged.setdefault(key, set()).update(resources)
77
+ out = []
78
+ for (actions, cond), res in sorted(merged.items()):
79
+ st = {"Effect": "Allow", "Action": list(actions), "Resource": sorted(res)}
80
+ if cond != "null":
81
+ st["Condition"] = json.loads(cond)
82
+ out.append(st)
83
+ return out, skipped
84
+
85
+
86
+ def session_policy(cap, cloud_map: dict) -> dict:
87
+ """The IAM session policy for a capability.
88
+
89
+ Guarantees: it has Allow statements only for (action, resource) pairs of
90
+ the capability that the map's `aws` rules cover, with every resource
91
+ narrowed to the capability; anything unmapped is absent (default deny);
92
+ the same inputs give the same policy. When nothing maps, the policy is a
93
+ single explicit Deny of everything - an empty policy would not be valid
94
+ and NO policy would leave the session with the role's full permissions.
95
+ Raises BridgeError("policy_too_large") when the compact JSON exceeds
96
+ 2,048 characters, the documented limit for a session policy.
97
+
98
+ Not enforced by this policy: deny_labels, deny_fields and budget of the
99
+ capability (see `session_policy_report`)."""
100
+ statements, _ = _statements(cap, cloud_map)
101
+ policy = {"Version": "2012-10-17", "Statement": statements or [dict(_DENY_ALL)]}
102
+ size = len(policy_json(policy))
103
+ if size > POLICY_MAX_CHARS:
104
+ raise BridgeError("policy_too_large", f"the session policy is {size} characters; AWS allows {POLICY_MAX_CHARS}. "
105
+ "Narrow the capability or shorten the map", {"size": size})
106
+ return policy
107
+
108
+
109
+ def policy_json(policy: dict) -> str:
110
+ """The compact JSON text that is sent as `Policy` (and counted against the limit)."""
111
+ return json.dumps(policy, separators=(",", ":"), sort_keys=True)
112
+
113
+
114
+ def session_policy_report(cap, cloud_map: dict) -> dict:
115
+ """{"policy", "size", "skipped", "not_enforced"}: the policy plus what the
116
+ capability has that the policy does not express, so that nothing is
117
+ dropped silently."""
118
+ policy = session_policy(cap, cloud_map)
119
+ return {"policy": policy, "size": len(policy_json(policy)), "skipped": _statements(cap, cloud_map)[1],
120
+ "not_enforced": not_enforced(cap)}
121
+
122
+
123
+ def _principal(v) -> dict:
124
+ """user / agent / role / task / token_id / exp / cap from a models.Grant, an
125
+ issue_token result, an inspect_token result, or a token's claims."""
126
+ if hasattr(v, "token_id") and hasattr(v, "chain"): # models.Grant
127
+ p = {"user": v.user, "chain": list(v.chain), "role": v.role, "task": getattr(v, "task", None), "token_id": v.token_id, "exp": v.expires_at,
128
+ "cap": v.capability.to_json()}
129
+ elif isinstance(v, dict) and isinstance(v.get("claims"), dict):
130
+ return _principal(v["claims"])
131
+ elif isinstance(v, dict) and "act" in v and "sub" in v:
132
+ p = {"user": tokens.user_of(v), "chain": tokens.chain_of(v), "role": v.get("role"), "task": v.get("task"),
133
+ "token_id": v.get("jti"), "exp": v.get("exp"), "cap": v.get("cap")}
134
+ elif isinstance(v, dict) and "token_id" in v:
135
+ p = {"user": v.get("user"), "chain": list(v.get("chain") or []), "role": v.get("role"), "task": v.get("task"),
136
+ "token_id": v.get("token_id"), "exp": v.get("expires_at"), "cap": v.get("cap")}
137
+ else:
138
+ raise BridgeError("invalid_input", "expected a Grant, an issue_token result, or token claims")
139
+ if not (isinstance(p["user"], str) and p["user"] and p["chain"] and isinstance(p["token_id"], str) and p["token_id"]
140
+ and isinstance(p["exp"], int) and not isinstance(p["exp"], bool)):
141
+ raise BridgeError("invalid_input", "the grant or claims lack user, agent chain, token id or expiry")
142
+ return p
143
+
144
+
145
+ def _clean(s: str, extra: str, limit: int) -> str:
146
+ return re.sub(rf"[^A-Za-z0-9_{re.escape(extra)}]", "-", s)[:limit]
147
+
148
+
149
+ def duration_for(expires_at: int, now: int, max_duration: int = 3600) -> tuple:
150
+ """(DurationSeconds, seconds by which the credential outlives the token).
151
+ DurationSeconds = min(seconds the token has left, max_duration), raised to
152
+ AWS's minimum of 900. When the token has less than 900 seconds left the
153
+ credential therefore outlives it by the second number; the caller decides
154
+ whether that is acceptable. An expired token raises."""
155
+ left = expires_at - now
156
+ if left <= 0:
157
+ raise BridgeError("token_expired", "the capability token has expired; no cloud credential for it")
158
+ seconds = max(DURATION_MIN, min(left, max_duration, DURATION_MAX))
159
+ return seconds, max(0, seconds - left)
160
+
161
+
162
+ def assume_role_request(grant_or_claims, cap, cloud_map: dict, role_arn: str, *, max_duration: int = 3600, now: int | None = None) -> dict:
163
+ """Keyword arguments for `sts.assume_role`, bounded by a capability token.
164
+
165
+ grant_or_claims a `models.Grant`, an issue_token / inspect_token result, or verified token claims
166
+ cap the capability to down-scope to; None uses the token's own
167
+ role_arn normally `role.bindings["aws"]["role_arn"]` from roles.json
168
+
169
+ Guarantees: `Policy` is `session_policy(cap, cloud_map)` (so the session
170
+ is no wider than the capability as mapped, and no wider than the role);
171
+ `RoleSessionName` is built from the agent and the token id and contains
172
+ only [A-Za-z0-9_+=,.@-], 2..64 characters; `DurationSeconds` is
173
+ `duration_for(...)`; `Tags` carry user, agent, role, task and token id
174
+ (keys sd_user, sd_agent, sd_role, sd_task, sd_token_id; absent values are
175
+ left out) and all are transitive, so CloudTrail shows who the session
176
+ acted for. The role's trust policy must allow sts:TagSession. No secret
177
+ is in the result."""
178
+ if not isinstance(role_arn, str) or not _ROLE_ARN.fullmatch(role_arn):
179
+ raise BridgeError("invalid_input", "role_arn is not an IAM role ARN")
180
+ p = _principal(grant_or_claims)
181
+ use = cap if cap is not None else p["cap"]
182
+ if use is None:
183
+ raise BridgeError("invalid_input", "no capability: pass cap or a grant that carries one")
184
+ if cap is not None and p["cap"] is not None:
185
+ try: # an explicit cap must not exceed the token it is said to belong to
186
+ as_cap(cap).check_narrower_than(as_cap(p["cap"]))
187
+ except Exception as e:
188
+ raise BridgeError("widening", f"the capability asked for is wider than the token's: {getattr(e, 'message', e)}") from None
189
+ policy = session_policy(use, cloud_map)
190
+ seconds, _ = duration_for(p["exp"], int(time.time()) if now is None else now, max_duration)
191
+ agent = p["chain"][-1]
192
+ name = _clean(f"{agent}-{p['token_id']}", "+=,.@-", 64)
193
+ values = {"user": p["user"], "agent": agent, "role": p["role"], "task": p["task"], "token_id": p["token_id"]}
194
+ tags = [{"Key": TAG_KEYS[k], "Value": _clean(str(v), " .:/=+@-", 256)} for k, v in values.items() if v]
195
+ return {"RoleArn": role_arn, "RoleSessionName": name if len(name) >= 2 else name + "-x", "Policy": policy_json(policy),
196
+ "DurationSeconds": seconds, "Tags": tags, "TransitiveTagKeys": [t["Key"] for t in tags]}
197
+
198
+
199
+ def assume_role(request: dict, sts_client) -> dict:
200
+ """Call `sts_client.assume_role(**request)` on an injected boto3-like
201
+ client and return the `Credentials` of the answer (AccessKeyId,
202
+ SecretAccessKey, SessionToken, Expiration). Any failure raises
203
+ BridgeError("aws_error") naming the exception type only; the caller never
204
+ gets a wider credential as a fallback."""
205
+ if "Policy" not in request:
206
+ raise BridgeError("invalid_input", "refusing to assume a role without a session policy")
207
+ try:
208
+ answer = sts_client.assume_role(**request)
209
+ except Exception as e:
210
+ raise BridgeError("aws_error", f"sts.assume_role failed: {type(e).__name__}") from None
211
+ creds = answer.get("Credentials") if isinstance(answer, dict) else None
212
+ if not isinstance(creds, dict) or not all(creds.get(k) for k in ("AccessKeyId", "SecretAccessKey", "SessionToken")):
213
+ raise BridgeError("malformed_response", "sts.assume_role returned no credentials")
214
+ return creds
215
+
216
+
217
+ # ---- ceiling -------------------------------------------------------------------
218
+
219
+ def _concrete(rule: dict) -> tuple | None:
220
+ """(actions, resources) to simulate for a rule, or None when the rule
221
+ names an action with a wildcard (which cannot be shown fully allowed)."""
222
+ actions = rule["allow"]["Action"]
223
+ if any("*" in a or "?" in a for a in actions):
224
+ return None
225
+ return sorted(set(actions)), sorted({r.replace("{id}", "*") for r in rule["allow"]["Resource"]})
226
+
227
+
228
+ def simulation_request(cloud_map: dict, principal_arn: str) -> dict:
229
+ """Keyword arguments for `iam.simulate_principal_policy` that ask, for
230
+ the human's IAM principal, about every action and resource the map's
231
+ `aws` rules name ({id} replaced by `*`). Rules with a wildcard action are
232
+ left out and can never enter the ceiling. Page through the results
233
+ (IsTruncated / Marker) and give all of them to `ceiling_from_simulation`."""
234
+ if not isinstance(principal_arn, str) or not principal_arn.startswith("arn:"):
235
+ raise BridgeError("invalid_input", "principal_arn must be the ARN of the human's IAM user or role")
236
+ actions, resources = set(), set()
237
+ for r in rules(cloud_map, "aws"):
238
+ c = _concrete(r)
239
+ if c:
240
+ actions.update(c[0])
241
+ resources.update(c[1])
242
+ if not actions:
243
+ raise BridgeError("nothing_to_simulate", "the map has no aws rule with concrete actions")
244
+ return {"PolicySourceArn": principal_arn, "ActionNames": sorted(actions), "ResourceArns": sorted(resources)}
245
+
246
+
247
+ def _results(results) -> list:
248
+ if isinstance(results, dict):
249
+ results = [results]
250
+ if not isinstance(results, list):
251
+ raise BridgeError("invalid_input", "simulation results must be a response, a list of responses, or a list of EvaluationResults")
252
+ out = []
253
+ for n, r in enumerate(results):
254
+ if isinstance(r, dict) and "EvaluationResults" in r:
255
+ if r.get("IsTruncated") and n == len(results) - 1:
256
+ raise BridgeError("truncated_pagination", "the simulator results are truncated (IsTruncated); fetch every page first")
257
+ if not isinstance(r["EvaluationResults"], list):
258
+ raise BridgeError("malformed_response", "EvaluationResults is not a list")
259
+ out.extend(r["EvaluationResults"])
260
+ else:
261
+ out.append(r)
262
+ for r in out:
263
+ if not isinstance(r, dict) or not all(isinstance(r.get(k), str) for k in ("EvalActionName", "EvalResourceName", "EvalDecision")):
264
+ raise BridgeError("malformed_response", "an evaluation result lacks EvalActionName, EvalResourceName or EvalDecision")
265
+ return out
266
+
267
+
268
+ def ceiling_detail(results, cloud_map: dict) -> dict:
269
+ """`cloudmap.pairs_to_cap` of the pairs the simulator shows fully allowed:
270
+ {"cap", "pairs", "dropped"}."""
271
+ decided: dict = {}
272
+ for r in _results(results):
273
+ key = (r["EvalActionName"], r["EvalResourceName"])
274
+ decided[key] = decided.get(key, True) and r["EvalDecision"] == "allowed"
275
+ rule_list = rules(cloud_map, "aws")
276
+ ok = []
277
+ for rule in rule_list:
278
+ c = _concrete(rule)
279
+ ok.append(bool(c) and all(decided.get((a, res)) is True for a in c[0] for res in c[1]))
280
+ return pairs_to_cap(allowed_pairs(rule_list, ok))
281
+
282
+
283
+ def ceiling_from_simulation(results, cloud_map: dict) -> dict:
284
+ """What the simulated principal may do, as a capability {"actions",
285
+ "resources"} to cap a role with (`ceiling.cap_role`).
286
+
287
+ Guarantee: a SmartDelegate (action, resource) pair is in the result only
288
+ if every action and resource the map gives for it - in every rule that
289
+ overlaps the pair - came back with EvalDecision "allowed". A missing
290
+ result, any other decision, or a truncated result set keeps the pair out.
291
+ Since a capability is a product, pairs that do not fit are dropped, never
292
+ rounded up; `ceiling_detail` lists them."""
293
+ return ceiling_detail(results, cloud_map)["cap"]