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.
- toolgovern/__init__.py +222 -0
- toolgovern/approval/__init__.py +25 -0
- toolgovern/approval/pending_registry.py +379 -0
- toolgovern/classifier/__init__.py +19 -0
- toolgovern/classifier/credential_access.py +180 -0
- toolgovern/classifier/cross_agent_inheritance.py +216 -0
- toolgovern/classifier/filesystem_scope.py +202 -0
- toolgovern/classifier/index.py +80 -0
- toolgovern/classifier/information_flow.py +154 -0
- toolgovern/classifier/network_egress.py +289 -0
- toolgovern/classifier/shell_risk.py +357 -0
- toolgovern/classifier/util.py +259 -0
- toolgovern/cli.py +298 -0
- toolgovern/mcp_trust/__init__.py +342 -0
- toolgovern/middleware/__init__.py +30 -0
- toolgovern/middleware/idempotency_cache.py +117 -0
- toolgovern/middleware/on_tool_call.py +538 -0
- toolgovern/policy/__init__.py +10 -0
- toolgovern/policy/load_policy.py +41 -0
- toolgovern/policy/validate_policy.py +106 -0
- toolgovern/py.typed +0 -0
- toolgovern/scoping/__init__.py +13 -0
- toolgovern/scoping/inheritance_enforcer.py +145 -0
- toolgovern/scoping/scope_declaration.py +84 -0
- toolgovern/shared/__init__.py +0 -0
- toolgovern/shared/paths.py +266 -0
- toolgovern/trace/__init__.py +33 -0
- toolgovern/trace/canonical_json.py +27 -0
- toolgovern/trace/trace_reader.py +206 -0
- toolgovern/trace/trace_writer.py +247 -0
- toolgovern/types.py +247 -0
- toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
- toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
- toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
- toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|