toolgovern-cli 0.1.1__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 (36) hide show
  1. toolgovern/__init__.py +222 -0
  2. toolgovern/approval/__init__.py +25 -0
  3. toolgovern/approval/pending_registry.py +379 -0
  4. toolgovern/classifier/__init__.py +19 -0
  5. toolgovern/classifier/credential_access.py +180 -0
  6. toolgovern/classifier/cross_agent_inheritance.py +216 -0
  7. toolgovern/classifier/filesystem_scope.py +202 -0
  8. toolgovern/classifier/index.py +80 -0
  9. toolgovern/classifier/information_flow.py +154 -0
  10. toolgovern/classifier/network_egress.py +289 -0
  11. toolgovern/classifier/shell_risk.py +357 -0
  12. toolgovern/classifier/util.py +259 -0
  13. toolgovern/cli.py +298 -0
  14. toolgovern/mcp_trust/__init__.py +342 -0
  15. toolgovern/middleware/__init__.py +30 -0
  16. toolgovern/middleware/idempotency_cache.py +117 -0
  17. toolgovern/middleware/on_tool_call.py +538 -0
  18. toolgovern/policy/__init__.py +10 -0
  19. toolgovern/policy/load_policy.py +41 -0
  20. toolgovern/policy/validate_policy.py +106 -0
  21. toolgovern/py.typed +0 -0
  22. toolgovern/scoping/__init__.py +13 -0
  23. toolgovern/scoping/inheritance_enforcer.py +145 -0
  24. toolgovern/scoping/scope_declaration.py +84 -0
  25. toolgovern/shared/__init__.py +0 -0
  26. toolgovern/shared/paths.py +266 -0
  27. toolgovern/trace/__init__.py +33 -0
  28. toolgovern/trace/canonical_json.py +27 -0
  29. toolgovern/trace/trace_reader.py +206 -0
  30. toolgovern/trace/trace_writer.py +247 -0
  31. toolgovern/types.py +247 -0
  32. toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
  33. toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
  34. toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
  35. toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
  36. toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,41 @@
1
+ """Loads and validates a ``toolgovern.policy.yml`` file from disk.
2
+
3
+ Ported from ``packages/toolgovern/src/policy/loadPolicy.ts``::
4
+
5
+ policy = load_policy("./toolgovern.policy.yml")
6
+ gated_shell_tool = govern_tool(shell_tool, policy)
7
+
8
+ ``load_policy`` is synchronous by design -- it is meant to run once at process startup, the same
9
+ way a framework loads any other config file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import List
15
+
16
+ import yaml
17
+
18
+ from ..types import Policy
19
+ from .validate_policy import as_policy, validate_policy
20
+
21
+
22
+ class PolicyValidationError(Exception):
23
+ def __init__(self, file_path: str, errors: List[str]) -> None:
24
+ self.errors = errors
25
+ message = f'Invalid policy file "{file_path}":\n' + "\n".join(f" - {e}" for e in errors)
26
+ super().__init__(message)
27
+
28
+
29
+ def load_policy(file_path: str) -> Policy:
30
+ """Parses and validates a policy file, raising ``PolicyValidationError`` if it is invalid."""
31
+ with open(file_path, "r", encoding="utf-8") as f:
32
+ raw_text = f.read()
33
+ try:
34
+ parsed = yaml.safe_load(raw_text)
35
+ except yaml.YAMLError as cause:
36
+ raise ValueError(f'Failed to parse policy file "{file_path}" as YAML.') from cause
37
+
38
+ result = validate_policy(parsed)
39
+ if not result.valid:
40
+ raise PolicyValidationError(file_path, list(result.errors))
41
+ return as_policy(parsed)
@@ -0,0 +1,106 @@
1
+ """Structural and rule-reference validation for a policy file.
2
+
3
+ Ported from ``packages/toolgovern/src/policy/validatePolicy.ts``. Used both by ``load_policy()``
4
+ (raises on failure -- a program should not start with a broken policy) and by
5
+ ``toolgovern-cli validate`` (reports every error found, without raising, so a developer gets one
6
+ full list instead of fixing errors one at a time).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, List, Optional, Sequence
13
+
14
+ from ..classifier import rule_registry
15
+ from ..scoping import is_valid_scope_declaration
16
+ from ..types import Policy, RuleOverrides, ScopeDeclaration
17
+
18
+ _VALID_RULE_IDS = {r.id for r in rule_registry}
19
+ _VALID_DECISIONS = {"allow", "deny", "require-approval"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PolicyValidationResult:
24
+ valid: bool
25
+ errors: Sequence[str] = field(default_factory=tuple)
26
+
27
+
28
+ def validate_policy(raw: Any) -> PolicyValidationResult:
29
+ """Validates a parsed (but untyped) policy object. Returns every error found, not just the
30
+ first."""
31
+ errors: List[str] = []
32
+
33
+ if not isinstance(raw, dict):
34
+ return PolicyValidationResult(
35
+ valid=False, errors=["Policy file must define a single YAML mapping (object)."]
36
+ )
37
+ candidate = raw
38
+
39
+ if "name" in candidate and candidate["name"] is not None and not isinstance(candidate["name"], str):
40
+ errors.append('"name" must be a string if present.')
41
+ if (
42
+ "policy" in candidate
43
+ and candidate["policy"] is not None
44
+ and not isinstance(candidate["policy"], str)
45
+ ):
46
+ errors.append('"policy" must be a string if present.')
47
+
48
+ if not is_valid_scope_declaration(candidate.get("scope")):
49
+ errors.append(
50
+ '"scope" is required and must have network (boolean or string[]), filesystem '
51
+ "(string[]), and credentials (string[])."
52
+ )
53
+
54
+ if "defaultDecision" in candidate or "default_decision" in candidate:
55
+ default_decision = candidate.get("defaultDecision", candidate.get("default_decision"))
56
+ if not isinstance(default_decision, str) or default_decision not in _VALID_DECISIONS:
57
+ errors.append('"defaultDecision" must be one of: allow, deny, require-approval.')
58
+
59
+ rules = candidate.get("rules")
60
+ if rules is not None:
61
+ if not isinstance(rules, dict):
62
+ errors.append('"rules" must be an object with optional "disable" and "requireApproval" arrays.')
63
+ else:
64
+ for field_name in ("disable", "requireApproval"):
65
+ value = rules.get(field_name)
66
+ if value is None:
67
+ continue
68
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
69
+ errors.append(f'"rules.{field_name}" must be an array of rule ID strings.')
70
+ continue
71
+ for rule_id in value:
72
+ if rule_id not in _VALID_RULE_IDS:
73
+ errors.append(
74
+ f'"rules.{field_name}" references unknown rule ID "{rule_id}". '
75
+ f'Valid rule IDs: {", ".join(sorted(_VALID_RULE_IDS))}.'
76
+ )
77
+
78
+ return PolicyValidationResult(valid=len(errors) == 0, errors=errors)
79
+
80
+
81
+ def as_policy(raw: dict) -> Policy:
82
+ """Narrows ``raw`` to a ``Policy`` after ``validate_policy`` has confirmed it is
83
+ structurally valid."""
84
+ scope_raw = raw.get("scope", {})
85
+ scope = ScopeDeclaration(
86
+ network=scope_raw.get("network", False),
87
+ filesystem=tuple(scope_raw.get("filesystem", ())),
88
+ credentials=tuple(scope_raw.get("credentials", ())),
89
+ )
90
+ rules_raw = raw.get("rules")
91
+ rules: Optional[RuleOverrides] = None
92
+ if rules_raw is not None:
93
+ rules = RuleOverrides(
94
+ disable=tuple(rules_raw.get("disable", ())),
95
+ require_approval=tuple(rules_raw.get("requireApproval", ())),
96
+ )
97
+ return Policy(
98
+ scope=scope,
99
+ policy=raw.get("policy"),
100
+ name=raw.get("name"),
101
+ rules=rules,
102
+ default_decision=raw.get("defaultDecision", raw.get("default_decision", "allow")),
103
+ agent_id=raw.get("agentId", raw.get("agent_id")),
104
+ session_id=raw.get("sessionId", raw.get("session_id")),
105
+ coordinator_id=raw.get("coordinatorId", raw.get("coordinator_id")),
106
+ )
toolgovern/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ from .inheritance_enforcer import ScopeRegistry, SpawnSubAgentParams, compute_inherited_scope, has_zero_capability
2
+ from .scope_declaration import EMPTY_SCOPE, is_valid_agent_id, is_valid_scope_declaration, normalize_scope
3
+
4
+ __all__ = [
5
+ "EMPTY_SCOPE",
6
+ "ScopeRegistry",
7
+ "SpawnSubAgentParams",
8
+ "compute_inherited_scope",
9
+ "has_zero_capability",
10
+ "is_valid_agent_id",
11
+ "is_valid_scope_declaration",
12
+ "normalize_scope",
13
+ ]
@@ -0,0 +1,145 @@
1
+ """Default-deny scope inheritance.
2
+
3
+ Ported from ``packages/toolgovern/src/scoping/inheritance-enforcer.ts``.
4
+
5
+ When a coordinator agent spawns a sub-agent, the sub-agent does NOT inherit the coordinator's
6
+ full access by default. Its scope is the intersection of what it requests and what the
7
+ coordinator itself actually has -- anything requested but not covered by the coordinator's own
8
+ scope is silently dropped, never silently granted. ``ScopeRegistry`` is the runtime home for
9
+ this: it records every agent's effective (granted) scope and re-checks it on every call a rule
10
+ evaluates, not just once at spawn time.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Dict, Optional
17
+
18
+ from ..shared.paths import credential_matches_granted, host_matches_allowed, is_path_within
19
+ from ..types import AgentScopeRecord, ScopeDeclaration
20
+ from .scope_declaration import EMPTY_SCOPE
21
+
22
+
23
+ def _intersect_network(coordinator, requested):
24
+ if coordinator is False or requested is False:
25
+ return False
26
+ if coordinator is True and requested is True:
27
+ return True
28
+ coordinator_list = None if coordinator is True else coordinator
29
+ requested_list = None if requested is True else requested
30
+ if coordinator_list is None:
31
+ return list(requested_list) if requested_list is not None else []
32
+ if requested_list is None:
33
+ return list(coordinator_list)
34
+ # For each (coordinator, requested) pair that are the same host or one is a subdomain of
35
+ # the other, grant the NARROWER of the two -- not unconditionally whichever list's entry
36
+ # matched. Filtering only the coordinator list (the original bug) grants a sub-agent that
37
+ # asked for a narrow host (api.example.com) the coordinator's much broader entry
38
+ # (example.com) whenever the broader entry's domain happens to cover the narrow request.
39
+ # Filtering only the requested list would fix that case but breaks the opposite one: a
40
+ # sub-agent that broadly requests example.com while the coordinator holds both
41
+ # example.com and a narrower api.example.com should still receive the narrower
42
+ # api.example.com grant intact (it's covered by the broad request, not widened by it).
43
+ granted: "dict[str, None]" = {}
44
+ for coord_host in coordinator_list:
45
+ for req_host in requested_list:
46
+ if coord_host == req_host:
47
+ granted[coord_host] = None
48
+ elif host_matches_allowed(coord_host, req_host):
49
+ granted[coord_host] = None # coordinator's entry is the narrower (subdomain) value
50
+ elif host_matches_allowed(req_host, coord_host):
51
+ granted[req_host] = None # requested entry is the narrower (subdomain) value
52
+ return list(granted.keys())
53
+
54
+
55
+ def _intersect_filesystem(coordinator, requested):
56
+ return [
57
+ req_path for req_path in requested if any(is_path_within(req_path, coord_path) for coord_path in coordinator)
58
+ ]
59
+
60
+
61
+ def _intersect_credentials(coordinator, requested):
62
+ return [
63
+ req_cred
64
+ for req_cred in requested
65
+ if any(credential_matches_granted(req_cred, coord_cred) for coord_cred in coordinator)
66
+ ]
67
+
68
+
69
+ def has_zero_capability(scope: ScopeDeclaration) -> bool:
70
+ """True if ``scope`` carries no capability at all -- no network access (False, or an
71
+ allowlist with zero entries -- both mean "cannot reach any host"), no filesystem prefix,
72
+ and no credential. An agent whose *granted* scope is this empty cannot legitimately make
73
+ any tool call, regardless of what the call's arguments happen to look like."""
74
+ has_network = scope.network is True or (isinstance(scope.network, (list, tuple)) and len(scope.network) > 0)
75
+ return not has_network and len(scope.filesystem) == 0 and len(scope.credentials) == 0
76
+
77
+
78
+ def compute_inherited_scope(
79
+ coordinator_scope: ScopeDeclaration, requested_scope: ScopeDeclaration
80
+ ) -> ScopeDeclaration:
81
+ """Pure function: given a coordinator's own effective scope and a sub-agent's requested
82
+ scope, returns the scope actually granted. Never returns anything the coordinator itself
83
+ does not have, and never grants anything the sub-agent did not explicitly request."""
84
+ return ScopeDeclaration(
85
+ network=_intersect_network(coordinator_scope.network, requested_scope.network),
86
+ filesystem=_intersect_filesystem(coordinator_scope.filesystem, requested_scope.filesystem),
87
+ credentials=_intersect_credentials(coordinator_scope.credentials, requested_scope.credentials),
88
+ )
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class SpawnSubAgentParams:
93
+ coordinator_id: str
94
+ sub_agent_id: str
95
+ session_id: str
96
+ requested_scope: ScopeDeclaration
97
+
98
+
99
+ class ScopeRegistry:
100
+ """Tracks every agent's effective (granted) scope for a governed run. Root agents register
101
+ their own declared scope directly; sub-agents are spawned against a coordinator and receive
102
+ the intersection of what they request and what their coordinator actually has."""
103
+
104
+ def __init__(self) -> None:
105
+ self._records: Dict[str, AgentScopeRecord] = {}
106
+
107
+ def register_root_agent(self, agent_id: str, session_id: str, scope: ScopeDeclaration) -> AgentScopeRecord:
108
+ record = AgentScopeRecord(agent_id=agent_id, session_id=session_id, granted_scope=scope)
109
+ self._records[agent_id] = record
110
+ return record
111
+
112
+ def spawn_sub_agent(self, params: SpawnSubAgentParams) -> AgentScopeRecord:
113
+ """Spawns a sub-agent under ``params.coordinator_id``. If the coordinator has never
114
+ been registered, the sub-agent is granted the empty scope -- default-deny applies even
115
+ when the caller forgot to register the coordinator first, rather than falling back to
116
+ "unrestricted."."""
117
+ coordinator_record = self._records.get(params.coordinator_id)
118
+ coordinator_scope = coordinator_record.granted_scope if coordinator_record else EMPTY_SCOPE
119
+ granted_scope = compute_inherited_scope(coordinator_scope, params.requested_scope)
120
+ record = AgentScopeRecord(
121
+ agent_id=params.sub_agent_id,
122
+ session_id=params.session_id,
123
+ coordinator_id=params.coordinator_id,
124
+ requested_scope=params.requested_scope,
125
+ granted_scope=granted_scope,
126
+ )
127
+ self._records[params.sub_agent_id] = record
128
+ return record
129
+
130
+ def get_record(self, agent_id: str) -> Optional[AgentScopeRecord]:
131
+ return self._records.get(agent_id)
132
+
133
+ def get_effective_scope(self, agent_id: str) -> Optional[ScopeDeclaration]:
134
+ record = self._records.get(agent_id)
135
+ return record.granted_scope if record else None
136
+
137
+ def has(self, agent_id: str) -> bool:
138
+ return agent_id in self._records
139
+
140
+ def is_zero_capability(self, agent_id: str) -> bool:
141
+ """True if ``agent_id`` is registered and its granted scope has zero capability. An
142
+ unregistered agent is not "zero capability" here -- that case is covered separately by
143
+ TG05-unregistered-sub-agent."""
144
+ record = self._records.get(agent_id)
145
+ return record is not None and has_zero_capability(record.granted_scope)
@@ -0,0 +1,84 @@
1
+ """Helpers for validating and comparing ``ScopeDeclaration`` values.
2
+
3
+ Ported from ``packages/toolgovern/src/scoping/scope-declaration.ts``.
4
+
5
+ A ``ScopeDeclaration`` is intentionally simple: an agent gets access to what it declares, and
6
+ nothing else. There is no implicit "and everything under this is fine too" beyond the explicit
7
+ path-prefix / hostname-suffix / credential-identifier matching implemented here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ from ..types import ScopeDeclaration
15
+
16
+ # The empty scope: no network, no filesystem, no credentials. This is the default-deny floor.
17
+ EMPTY_SCOPE = ScopeDeclaration(network=False, filesystem=(), credentials=())
18
+
19
+
20
+ def is_valid_scope_declaration(value: Any) -> bool:
21
+ if not isinstance(value, dict):
22
+ return False
23
+
24
+ network = value.get("network")
25
+ network_ok = isinstance(network, bool) or (
26
+ isinstance(network, list) and all(isinstance(h, str) for h in network)
27
+ )
28
+ filesystem = value.get("filesystem")
29
+ filesystem_ok = isinstance(filesystem, list) and all(isinstance(p, str) for p in filesystem)
30
+ credentials = value.get("credentials")
31
+ credentials_ok = isinstance(credentials, list) and all(isinstance(c, str) for c in credentials)
32
+
33
+ return network_ok and filesystem_ok and credentials_ok
34
+
35
+
36
+ def normalize_scope(partial: Optional[dict]) -> ScopeDeclaration:
37
+ """Normalizes a partial/loosely-typed scope object into a fully-formed ScopeDeclaration,
38
+ defaulting any missing field to the most restrictive value (default-deny)."""
39
+ partial = partial or {}
40
+ return ScopeDeclaration(
41
+ network=partial.get("network", False),
42
+ filesystem=tuple(partial.get("filesystem", ())),
43
+ credentials=tuple(partial.get("credentials", ())),
44
+ )
45
+
46
+
47
+ # Generous ceiling on agent_id length. Not a protocol limit -- just large enough that no
48
+ # realistic identity scheme (UUID, DNS name, URN, JWT sub claim) trips it, while still
49
+ # rejecting unbounded strings that look like a buffer-abuse or log-flooding attempt.
50
+ _MAX_AGENT_ID_LENGTH = 256
51
+
52
+
53
+ def _is_disallowed_control_code_point(code_point: int) -> bool:
54
+ """Code points with no legitimate reason to appear in an agent identity string: ASCII
55
+ control characters (0x00-0x1F, 0x7F) and the Unicode line/paragraph separators (0x2028,
56
+ 0x2029). Letting them through invites log-injection, null-byte truncation tricks, or
57
+ terminal/ANSI escape abuse."""
58
+ return (0x00 <= code_point <= 0x1F) or code_point == 0x7F or code_point in (0x2028, 0x2029)
59
+
60
+
61
+ def is_valid_agent_id(value: Any) -> bool:
62
+ """Format-only validation for an agent_id string.
63
+
64
+ IMPORTANT -- what this is NOT: this does not verify that a caller actually is the agent it
65
+ claims to be. toolgovern has no cryptographic identity verification mechanism in v0.1; any
66
+ caller can still supply any well-formed agent_id and have it accepted as-is (see
67
+ docs/security-model.md). A string that passes is merely *well-formed* -- it remains just as
68
+ much a bare, unverified claim as any other string that passes.
69
+
70
+ What this DOES do: reject a narrow, concrete class of malformed/malicious inputs -- an
71
+ empty string, a string past a sane length ceiling, or a string containing control
72
+ characters/embedded null bytes that could be used for log injection or to confuse
73
+ downstream string handling. This is a hygiene filter, not an authentication mechanism.
74
+ """
75
+ if not isinstance(value, str):
76
+ return False
77
+ if len(value) == 0:
78
+ return False
79
+ if len(value) > _MAX_AGENT_ID_LENGTH:
80
+ return False
81
+ for ch in value:
82
+ if _is_disallowed_control_code_point(ord(ch)):
83
+ return False
84
+ return True
File without changes
@@ -0,0 +1,266 @@
1
+ """Low-level path/host normalization helpers shared by the classifier and the scoping registry.
2
+
3
+ Ported from ``packages/toolgovern/src/shared/paths.ts``. Kept dependency-free and
4
+ side-effect-free so both modules can import from here without a classifier <-> scoping cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import List, Optional, Tuple
11
+ from urllib.parse import urlparse
12
+
13
+ _SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
14
+ _BRACKETED_HOST_PATTERN = re.compile(r"^\[([^\]]+)\]")
15
+
16
+
17
+ def normalize_path(raw_path: str) -> str:
18
+ """Collapses ``./``, trailing slashes, and duplicate slashes for stable prefix comparison."""
19
+ path = raw_path.strip()
20
+ if path.startswith("./"):
21
+ path = path[2:]
22
+ path = re.sub(r"/+", "/", path)
23
+ if len(path) > 1 and path.endswith("/"):
24
+ path = path[:-1]
25
+ return path
26
+
27
+
28
+ def is_path_within(candidate: str, prefix: str) -> bool:
29
+ """True if ``candidate`` is equal to, or a path-segment child of, ``prefix``."""
30
+ normalized_candidate = normalize_path(candidate)
31
+ normalized_prefix = normalize_path(prefix)
32
+ if normalized_prefix in ("", "."):
33
+ return True
34
+ return normalized_candidate == normalized_prefix or normalized_candidate.startswith(
35
+ f"{normalized_prefix}/"
36
+ )
37
+
38
+
39
+ def contains_path_traversal(raw_path: str) -> bool:
40
+ """True if the path contains a ``..`` segment that could escape a scoped prefix via traversal."""
41
+ return ".." in raw_path.split("/")
42
+
43
+
44
+ def normalize_host(host_like: str) -> str:
45
+ """Best-effort hostname extraction from a bare host string or a full URL."""
46
+ trimmed = host_like.strip()
47
+ if _SCHEME_PATTERN.match(trimmed):
48
+ try:
49
+ hostname = urlparse(trimmed).hostname
50
+ if hostname:
51
+ return hostname.lower()
52
+ except ValueError:
53
+ pass # fall through to the raw-string heuristics below
54
+
55
+ without_path = trimmed.split("/", 1)[0] if trimmed else trimmed
56
+ # A bracketed IPv6 literal ([::1] or [::1]:8080) -- unwrap the brackets and drop any
57
+ # trailing port, but keep the address itself intact.
58
+ bracketed = _BRACKETED_HOST_PATTERN.match(without_path)
59
+ if bracketed:
60
+ return (bracketed.group(1) or "").lower()
61
+ # A bare host containing more than one colon is an IPv6 literal, not a host:port pair --
62
+ # splitting on the first colon (as below) would truncate the address.
63
+ if without_path.count(":") >= 2:
64
+ return without_path.lower()
65
+ without_port = without_path.split(":", 1)[0] if without_path else without_path
66
+ return without_port.lower()
67
+
68
+
69
+ def _is_dotted_ipv4_literal(host: str) -> bool:
70
+ """True if ``host`` is a dotted-decimal IPv4 literal (``a.b.c.d``), and only that form --
71
+ used where a bare decimal integer must NOT be treated as an IP (parsing an IPv6 literal's
72
+ hextets, where a plain numeric group like ``1234`` in ``fe80::1234`` is ordinary hex, not
73
+ a packed IPv4 address). ``_parse_ipv4_octets`` below is the looser, general-purpose check."""
74
+ return bool(re.match(r"^(\d{1,3}\.){3}\d{1,3}$", host))
75
+
76
+
77
+ def _parse_ipv4_octets(host: str) -> Optional[Tuple[int, int, int, int]]:
78
+ """Parses ``host`` as an IPv4 address in either dotted-decimal (``a.b.c.d``) or bare
79
+ single-integer decimal form (the same address packed into one 32-bit unsigned integer,
80
+ e.g. ``2852039166`` for ``169.254.169.254``) -- the latter is accepted as a valid IP
81
+ literal by curl, browsers, and most OS resolvers, and is a well-known technique for
82
+ slipping a private/metadata target past a dotted-decimal-only IP-literal check. Returns
83
+ the four octets, or ``None`` if ``host`` is neither form."""
84
+ if _is_dotted_ipv4_literal(host):
85
+ octets_str = host.split(".")
86
+ if len(octets_str) != 4:
87
+ return None
88
+ try:
89
+ octets = [int(o) for o in octets_str]
90
+ except ValueError:
91
+ return None
92
+ if any(o > 255 for o in octets):
93
+ return None
94
+ return (octets[0], octets[1], octets[2], octets[3])
95
+ if re.match(r"^\d{1,10}$", host):
96
+ value = int(host)
97
+ if value < 0 or value > 0xFFFFFFFF:
98
+ return None
99
+ return (
100
+ (value >> 24) & 0xFF,
101
+ (value >> 16) & 0xFF,
102
+ (value >> 8) & 0xFF,
103
+ value & 0xFF,
104
+ )
105
+ return None
106
+
107
+
108
+ def _is_ipv4_literal(host: str) -> bool:
109
+ """True if ``host`` is a raw IPv4 literal (not a domain name), dotted-decimal or bare
110
+ single-integer decimal form (see ``_parse_ipv4_octets``)."""
111
+ return _parse_ipv4_octets(host) is not None
112
+
113
+
114
+ def _strip_ipv6_decoration(host: str) -> str:
115
+ """Strips an optional surrounding ``[...]`` bracket pair and a trailing ``%zone`` scope id
116
+ from an IPv6 literal, e.g. ``[fe80::1%eth0]`` -> ``fe80::1``."""
117
+ h = host.strip()
118
+ bracketed = re.match(r"^\[([^\]]+)\]$", h)
119
+ if bracketed:
120
+ h = bracketed.group(1) or ""
121
+ zone_index = h.find("%")
122
+ if zone_index != -1:
123
+ h = h[:zone_index]
124
+ return h
125
+
126
+
127
+ _HEXTET_PATTERN = re.compile(r"^[0-9a-f]{1,4}$", re.IGNORECASE)
128
+
129
+
130
+ def _parse_ipv6_groups(host: str) -> Optional[List[int]]:
131
+ """Parses a bare (undecorated) IPv6 literal into its eight 16-bit groups, expanding a
132
+ single ``::`` run and an embedded IPv4 tail. Returns ``None`` if not syntactically valid."""
133
+ if ":" not in host:
134
+ return None
135
+ has_double_colon = "::" in host
136
+ if host.count("::") > 1:
137
+ return None
138
+
139
+ head, tail = host, ""
140
+ if has_double_colon:
141
+ parts = host.split("::")
142
+ if len(parts) != 2:
143
+ return None
144
+ head, tail = parts[0], parts[1]
145
+
146
+ def split_hextets(segment: str) -> List[str]:
147
+ return segment.split(":") if segment else []
148
+
149
+ head_parts = split_hextets(head)
150
+ tail_parts = split_hextets(tail)
151
+
152
+ # An embedded IPv4 tail (::ffff:169.254.169.254) contributes two hextets worth of bits.
153
+ embedded_ipv4: Optional[List[int]] = None
154
+ if tail_parts:
155
+ last_tail_part = tail_parts[-1]
156
+ if last_tail_part and _is_dotted_ipv4_literal(last_tail_part):
157
+ octets_str = last_tail_part.split(".")
158
+ if len(octets_str) != 4:
159
+ return None
160
+ try:
161
+ octets = [int(o) for o in octets_str]
162
+ except ValueError:
163
+ return None
164
+ if any(o > 255 for o in octets):
165
+ return None
166
+ o0, o1, o2, o3 = octets
167
+ embedded_ipv4 = [(o0 << 8) | o1, (o2 << 8) | o3]
168
+ tail_parts.pop()
169
+
170
+ if any(not _HEXTET_PATTERN.match(p) for p in head_parts):
171
+ return None
172
+ if any(not _HEXTET_PATTERN.match(p) for p in tail_parts):
173
+ return None
174
+
175
+ head_groups = [int(p, 16) for p in head_parts]
176
+ tail_groups = [int(p, 16) for p in tail_parts]
177
+ embedded_length = 2 if embedded_ipv4 else 0
178
+ total = len(head_groups) + len(tail_groups) + embedded_length
179
+
180
+ if has_double_colon:
181
+ zeros = 8 - total
182
+ if zeros < 0:
183
+ return None
184
+ groups = head_groups + [0] * zeros + tail_groups + (embedded_ipv4 or [])
185
+ else:
186
+ if total != 8:
187
+ return None
188
+ groups = head_groups + tail_groups + (embedded_ipv4 or [])
189
+
190
+ return groups if len(groups) == 8 else None
191
+
192
+
193
+ def _is_ipv6_literal(host: str) -> bool:
194
+ """True if ``host`` is a raw IPv6 literal -- bracketed or bare, with or without a
195
+ ``%zone`` id or an embedded IPv4 tail."""
196
+ return _parse_ipv6_groups(_strip_ipv6_decoration(host)) is not None
197
+
198
+
199
+ def is_ip_literal(host: str) -> bool:
200
+ """True if ``host`` is a raw IP literal, IPv4 or IPv6 (not a domain name)."""
201
+ return _is_ipv4_literal(host) or _is_ipv6_literal(host)
202
+
203
+
204
+ def _is_private_ipv4_octets(octets: Tuple[int, int, int, int]) -> bool:
205
+ """True if IPv4 ``octets`` fall in a loopback, RFC1918-private, or link-local range --
206
+ link-local (169.254.0.0/16) includes the 169.254.169.254 cloud-metadata endpoint used by
207
+ AWS, GCP, Azure, and most other cloud providers."""
208
+ a, b = octets[0], octets[1]
209
+ if a == 127:
210
+ return True # loopback 127.0.0.0/8
211
+ if a == 10:
212
+ return True # RFC1918 10.0.0.0/8
213
+ if a == 172 and 16 <= b <= 31:
214
+ return True # RFC1918 172.16.0.0/12
215
+ if a == 192 and b == 168:
216
+ return True # RFC1918 192.168.0.0/16
217
+ if a == 169 and b == 254:
218
+ return True # link-local, incl. cloud metadata 169.254.169.254
219
+ return False
220
+
221
+
222
+ def is_private_or_metadata_target(host: str) -> bool:
223
+ """True if ``host`` is a raw IP literal (v4 or v6) that targets loopback, an
224
+ RFC1918/unique-local private range, link-local space, or a cloud-metadata endpoint
225
+ (169.254.169.254 and its IPv6 equivalents: ::1, fe80::/10, fc00::/7, and IPv4-mapped
226
+ ::ffff:a.b.c.d addresses that resolve into one of the above IPv4 ranges) -- the set of
227
+ destinations a rubber-stamped human approval should never be able to wave through."""
228
+ ipv4_octets = _parse_ipv4_octets(host)
229
+ if ipv4_octets is not None:
230
+ return _is_private_ipv4_octets(ipv4_octets)
231
+
232
+ groups = _parse_ipv6_groups(_strip_ipv6_decoration(host))
233
+ if not groups:
234
+ return False
235
+ g0, g1, g2, g3, g4, g5, g6, g7 = groups
236
+
237
+ # ::1 loopback
238
+ if g0 == 0 and g1 == 0 and g2 == 0 and g3 == 0 and g4 == 0 and g5 == 0 and g6 == 0 and g7 == 1:
239
+ return True
240
+ # fe80::/10 link-local
241
+ if (g0 & 0xFFC0) == 0xFE80:
242
+ return True
243
+ # fc00::/7 unique-local
244
+ if (g0 & 0xFE00) == 0xFC00:
245
+ return True
246
+ # IPv4-mapped (::ffff:a.b.c.d) -- check the embedded IPv4 address
247
+ if g0 == 0 and g1 == 0 and g2 == 0 and g3 == 0 and g4 == 0 and g5 == 0xFFFF:
248
+ octets = (g6 >> 8, g6 & 0xFF, g7 >> 8, g7 & 0xFF)
249
+ return _is_private_ipv4_octets(octets)
250
+ return False
251
+
252
+
253
+ def host_matches_allowed(host: str, allowed: str) -> bool:
254
+ """True if ``host`` matches ``allowed`` exactly or is a subdomain of it."""
255
+ h = host.lower()
256
+ a = allowed.lower()
257
+ return h == a or h.endswith(f".{a}")
258
+
259
+
260
+ def credential_matches_granted(identifier: str, granted: str) -> bool:
261
+ """True if ``identifier`` matches ``granted`` exactly, as a path suffix, or as a
262
+ substring -- used for credential-identifier comparisons where declared scopes are often
263
+ coarse-grained (e.g. granting "aws" should cover ".aws/credentials")."""
264
+ i = identifier.lower()
265
+ g = granted.lower()
266
+ return i == g or i.endswith(f"/{g}") or g in i