agentlatch-security 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_latch/__init__.py +3 -0
- agent_latch/action_summary.py +211 -0
- agent_latch/agent_code.py +487 -0
- agent_latch/astutil.py +20 -0
- agent_latch/cli.py +500 -0
- agent_latch/findings.py +65 -0
- agent_latch/gates/__init__.py +17 -0
- agent_latch/gates/alr04.py +816 -0
- agent_latch/manifest.py +256 -0
- agent_latch/oversight.py +225 -0
- agent_latch/owasp.py +105 -0
- agent_latch/prompts.py +90 -0
- agent_latch/report.py +241 -0
- agent_latch/rules.py +495 -0
- agent_latch/runtime/__init__.py +34 -0
- agent_latch/runtime/cli.py +314 -0
- agent_latch/runtime/demo.py +78 -0
- agent_latch/runtime/demo_server.py +54 -0
- agent_latch/runtime/events.py +91 -0
- agent_latch/runtime/importer.py +543 -0
- agent_latch/runtime/middleware.py +201 -0
- agent_latch/runtime/normalizer.py +106 -0
- agent_latch/runtime/payloads.py +190 -0
- agent_latch/runtime/proxy.py +44 -0
- agent_latch/runtime/redact.py +100 -0
- agent_latch/runtime/results.py +274 -0
- agent_latch/runtime/sqlite_store.py +230 -0
- agent_latch/runtime/trace.py +199 -0
- agent_latch/suppress.py +169 -0
- agent_latch/taint.py +264 -0
- agent_latch/yamlload.py +43 -0
- agentlatch_security-0.4.0.dist-info/METADATA +391 -0
- agentlatch_security-0.4.0.dist-info/RECORD +37 -0
- agentlatch_security-0.4.0.dist-info/WHEEL +5 -0
- agentlatch_security-0.4.0.dist-info/entry_points.txt +3 -0
- agentlatch_security-0.4.0.dist-info/licenses/LICENSE +21 -0
- agentlatch_security-0.4.0.dist-info/top_level.txt +1 -0
agent_latch/manifest.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Checks for agent manifests: tool definitions, permissions, and prompt templates.
|
|
2
|
+
|
|
3
|
+
Like the source rules, these are heuristics over declared configuration. They do not
|
|
4
|
+
observe what the agent actually does at runtime.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from agent_latch.findings import Finding
|
|
15
|
+
from agent_latch.prompts import check_prompt_text
|
|
16
|
+
from agent_latch.rules import MANIFEST_NAMES, scan_secrets
|
|
17
|
+
from agent_latch.yamlload import LineDict as _LineDict
|
|
18
|
+
from agent_latch.yamlload import LineLoader as _LineLoader
|
|
19
|
+
from agent_latch.yamlload import line_of as _line_of
|
|
20
|
+
|
|
21
|
+
_HIGH_RISK_CAPABILITIES = {
|
|
22
|
+
"shell",
|
|
23
|
+
"exec",
|
|
24
|
+
"code_exec",
|
|
25
|
+
"code_execution",
|
|
26
|
+
"python",
|
|
27
|
+
"subprocess",
|
|
28
|
+
"filesystem:write",
|
|
29
|
+
"fs:write",
|
|
30
|
+
"file_write",
|
|
31
|
+
"filesystem:delete",
|
|
32
|
+
"database:write",
|
|
33
|
+
"db:write",
|
|
34
|
+
"email:send",
|
|
35
|
+
"payments",
|
|
36
|
+
"payments:write",
|
|
37
|
+
"sudo",
|
|
38
|
+
"admin",
|
|
39
|
+
}
|
|
40
|
+
_CODE_EXECUTION_CAPABILITIES = {"shell", "exec", "code_exec", "code_execution", "python", "subprocess"}
|
|
41
|
+
_WILDCARDS = {"*", "all", "any"}
|
|
42
|
+
_APPROVAL_KEYS = ("requires_approval", "human_in_the_loop", "require_confirmation")
|
|
43
|
+
_CAPABILITY_KEYS = ("capabilities", "permissions", "scopes")
|
|
44
|
+
_ENDPOINT_KEYS = ("endpoint", "url", "base_url", "server")
|
|
45
|
+
_NO_AUTH_VALUES = {"none", "false", "anonymous", "no", "off", ""}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ManifestError(ValueError):
|
|
49
|
+
"""Raised when a manifest cannot be read or parsed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_manifest(target: Path) -> Path | None:
|
|
53
|
+
directory = target if target.is_dir() else target.parent
|
|
54
|
+
for name in MANIFEST_NAMES:
|
|
55
|
+
candidate = directory / name
|
|
56
|
+
if candidate.is_file():
|
|
57
|
+
return candidate
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_manifest(manifest: Path) -> _LineDict:
|
|
62
|
+
try:
|
|
63
|
+
data = yaml.load(manifest.read_text(encoding="utf-8"), Loader=_LineLoader) # SafeLoader subclass
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise ManifestError(f"could not read {manifest}: {exc.strerror or exc}") from exc
|
|
66
|
+
except yaml.YAMLError as exc:
|
|
67
|
+
raise ManifestError(f"invalid YAML in {manifest}: {exc}") from exc
|
|
68
|
+
if not isinstance(data, _LineDict):
|
|
69
|
+
raise ManifestError(f"{manifest} must contain a YAML mapping at the top level")
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _display_path(path: Path, root: Path) -> str:
|
|
74
|
+
try:
|
|
75
|
+
return path.resolve().relative_to(root).as_posix()
|
|
76
|
+
except ValueError:
|
|
77
|
+
return path.as_posix()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _as_list(value: Any) -> list[Any]:
|
|
81
|
+
if value is None:
|
|
82
|
+
return []
|
|
83
|
+
return value if isinstance(value, list) else [value]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _truthy(value: Any) -> bool:
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
return value.strip().lower() in {"true", "yes", "always", "required", "on"}
|
|
89
|
+
return value is True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _iter_tools(manifest: _LineDict) -> list[_LineDict]:
|
|
93
|
+
tools = [tool for tool in _as_list(manifest.get("tools")) if isinstance(tool, _LineDict)]
|
|
94
|
+
for agent in _as_list(manifest.get("agents")):
|
|
95
|
+
if isinstance(agent, _LineDict):
|
|
96
|
+
tools.extend(tool for tool in _as_list(agent.get("tools")) if isinstance(tool, _LineDict))
|
|
97
|
+
return tools
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_tool(tool: _LineDict, path: str) -> list[Finding]:
|
|
101
|
+
findings: list[Finding] = []
|
|
102
|
+
name = str(tool.get("name", "<unnamed>"))
|
|
103
|
+
capability_key = next((key for key in _CAPABILITY_KEYS if key in tool), None)
|
|
104
|
+
capabilities = [str(item).strip().lower() for item in _as_list(tool.get(capability_key))] if capability_key else []
|
|
105
|
+
approved = any(_truthy(tool.get(key)) for key in _APPROVAL_KEYS)
|
|
106
|
+
|
|
107
|
+
wildcards = [cap for cap in capabilities if cap in _WILDCARDS or cap.endswith(":*")]
|
|
108
|
+
if wildcards:
|
|
109
|
+
findings.append(
|
|
110
|
+
Finding(
|
|
111
|
+
rule_id="AGENTLATCH-MAN002",
|
|
112
|
+
title="Tool grants wildcard permissions",
|
|
113
|
+
severity="high",
|
|
114
|
+
message=f"Tool '{name}' declares unbounded permissions. Scope it to the specific actions the agent needs.",
|
|
115
|
+
path=path,
|
|
116
|
+
line=_line_of(tool, capability_key or "name"),
|
|
117
|
+
column=1,
|
|
118
|
+
owasp=("ASI02", "ASI03"),
|
|
119
|
+
confidence="high",
|
|
120
|
+
evidence=f"tool '{name}' {capability_key}: {', '.join(wildcards)}",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
risky = [cap for cap in capabilities if cap in _HIGH_RISK_CAPABILITIES]
|
|
125
|
+
if risky and not approved:
|
|
126
|
+
executes_code = any(cap in _CODE_EXECUTION_CAPABILITIES for cap in risky)
|
|
127
|
+
owasp = ("ASI02", "ASI05", "ASI09") if executes_code else ("ASI02", "ASI03", "ASI09")
|
|
128
|
+
findings.append(
|
|
129
|
+
Finding(
|
|
130
|
+
rule_id="AGENTLATCH-MAN001",
|
|
131
|
+
title="High-risk tool without human approval",
|
|
132
|
+
severity="high",
|
|
133
|
+
message=(
|
|
134
|
+
f"Tool '{name}' can perform high-impact actions with no approval gate declared. "
|
|
135
|
+
"Require human confirmation or narrow the capability."
|
|
136
|
+
),
|
|
137
|
+
path=path,
|
|
138
|
+
line=_line_of(tool, capability_key or "name"),
|
|
139
|
+
column=1,
|
|
140
|
+
owasp=owasp,
|
|
141
|
+
confidence="medium",
|
|
142
|
+
evidence=f"tool '{name}' {capability_key}: {', '.join(risky)}; no {'/'.join(_APPROVAL_KEYS)}",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
endpoint_key = next((key for key in _ENDPOINT_KEYS if key in tool), None)
|
|
147
|
+
if endpoint_key:
|
|
148
|
+
endpoint = str(tool.get(endpoint_key))
|
|
149
|
+
if "auth" not in tool:
|
|
150
|
+
explicit = False
|
|
151
|
+
missing = True
|
|
152
|
+
else:
|
|
153
|
+
explicit = True
|
|
154
|
+
auth = tool.get("auth")
|
|
155
|
+
missing = auth is None or auth is False or (isinstance(auth, str) and auth.strip().lower() in _NO_AUTH_VALUES)
|
|
156
|
+
if missing:
|
|
157
|
+
findings.append(
|
|
158
|
+
Finding(
|
|
159
|
+
rule_id="AGENTLATCH-MAN003",
|
|
160
|
+
title="Remote tool without authentication",
|
|
161
|
+
severity="medium",
|
|
162
|
+
message=(
|
|
163
|
+
f"Tool '{name}' calls a remote endpoint "
|
|
164
|
+
+ ("with authentication explicitly disabled. " if explicit else "with no auth declared. ")
|
|
165
|
+
+ "Authenticate tool calls and scope credentials to this tool."
|
|
166
|
+
),
|
|
167
|
+
path=path,
|
|
168
|
+
line=_line_of(tool, "auth" if explicit else endpoint_key),
|
|
169
|
+
column=1,
|
|
170
|
+
owasp=("ASI03", "ASI02"),
|
|
171
|
+
confidence="medium" if explicit else "low",
|
|
172
|
+
evidence=f"tool '{name}' {endpoint_key}: {endpoint}; auth: {tool.get('auth') if explicit else '<missing>'}",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return findings
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_check_prompt_text = check_prompt_text
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _block_offset(agent: _LineDict, key: str) -> int:
|
|
182
|
+
"""Literal block scalars (|, >) start on the line after their key."""
|
|
183
|
+
return _line_of(agent, key) + 1
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_agent_prompts(
|
|
187
|
+
agent: _LineDict,
|
|
188
|
+
manifest_path: Path,
|
|
189
|
+
display: str,
|
|
190
|
+
root: Path,
|
|
191
|
+
max_file_bytes: int,
|
|
192
|
+
) -> list[Finding]:
|
|
193
|
+
findings: list[Finding] = []
|
|
194
|
+
name = str(agent.get("name", "<unnamed>"))
|
|
195
|
+
|
|
196
|
+
for key, is_system in (("system_prompt", True), ("prompt", False)):
|
|
197
|
+
text = agent.get(key)
|
|
198
|
+
if isinstance(text, str):
|
|
199
|
+
start = _block_offset(agent, key) if "\n" in text else _line_of(agent, key)
|
|
200
|
+
label = f"Agent '{name}' {key}"
|
|
201
|
+
findings.extend(_check_prompt_text(text, display, start, label, is_system))
|
|
202
|
+
|
|
203
|
+
referenced: list[tuple[str, bool, str]] = []
|
|
204
|
+
for key in ("system_prompt_file", "system_prompt_template"):
|
|
205
|
+
if isinstance(agent.get(key), str):
|
|
206
|
+
referenced.append((agent[key], True, key))
|
|
207
|
+
for key in ("prompt_templates", "prompt_template", "prompt_files"):
|
|
208
|
+
referenced.extend((str(item), False, key) for item in _as_list(agent.get(key)))
|
|
209
|
+
|
|
210
|
+
for reference, is_system, key in referenced:
|
|
211
|
+
template = (manifest_path.parent / reference).resolve()
|
|
212
|
+
template_display = _display_path(template, root)
|
|
213
|
+
try:
|
|
214
|
+
if template.stat().st_size > max_file_bytes:
|
|
215
|
+
continue
|
|
216
|
+
text = template.read_text(encoding="utf-8", errors="replace")
|
|
217
|
+
except OSError:
|
|
218
|
+
findings.append(
|
|
219
|
+
Finding(
|
|
220
|
+
rule_id="AGENTLATCH-MAN000",
|
|
221
|
+
title="Referenced prompt file not found",
|
|
222
|
+
severity="info",
|
|
223
|
+
message=f"Agent '{name}' references a prompt file that could not be read, so it was not scanned.",
|
|
224
|
+
path=display,
|
|
225
|
+
line=_line_of(agent, key),
|
|
226
|
+
column=1,
|
|
227
|
+
owasp=(),
|
|
228
|
+
confidence="high",
|
|
229
|
+
evidence=f"{key}: {reference}",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
continue
|
|
233
|
+
label = f"Prompt template for agent '{name}'"
|
|
234
|
+
findings.extend(_check_prompt_text(text, template_display, 1, label, is_system))
|
|
235
|
+
if not template.is_relative_to(root):
|
|
236
|
+
# scan_project covers every file inside the target; templates outside it are checked here.
|
|
237
|
+
findings.extend(scan_secrets(template, template.parent, text))
|
|
238
|
+
return findings
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def scan_manifest(manifest_path: Path, root: Path, max_file_bytes: int = 1_000_000) -> list[Finding]:
|
|
242
|
+
"""Audit tool definitions and prompts declared in an AgentLatch agent manifest."""
|
|
243
|
+
manifest_path = manifest_path.resolve()
|
|
244
|
+
root = root.resolve() if root.is_dir() else root.resolve().parent
|
|
245
|
+
manifest = load_manifest(manifest_path)
|
|
246
|
+
display = _display_path(manifest_path, root)
|
|
247
|
+
|
|
248
|
+
findings: list[Finding] = []
|
|
249
|
+
for tool in _iter_tools(manifest):
|
|
250
|
+
findings.extend(_check_tool(tool, display))
|
|
251
|
+
for agent in _as_list(manifest.get("agents")):
|
|
252
|
+
if isinstance(agent, _LineDict):
|
|
253
|
+
findings.extend(_check_agent_prompts(agent, manifest_path, display, root, max_file_bytes))
|
|
254
|
+
|
|
255
|
+
# A template shared by several agents would otherwise be reported once per agent.
|
|
256
|
+
return sorted(set(findings), key=lambda finding: (finding.path, finding.line, finding.rule_id))
|
agent_latch/oversight.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Human-oversight and runaway-loop checks that work from source code alone.
|
|
2
|
+
|
|
3
|
+
- AG006: human approval explicitly turned off, so the agent acts without confirmation
|
|
4
|
+
(AutoGen human_input_mode="NEVER" with code execution, auto_approve=True,
|
|
5
|
+
permission_mode="bypassPermissions", require_approval="never", ...).
|
|
6
|
+
(ASI09 Human-Agent Trust Exploitation; ASI02 / ASI05)
|
|
7
|
+
- AG007: an agent loop with no effective bound: max_iterations=None, a very high iteration
|
|
8
|
+
or recursion limit, or `while True:` around an agent call with no way out.
|
|
9
|
+
(ASI08 Cascading Failures)
|
|
10
|
+
|
|
11
|
+
Single-file, name-based heuristics over the AST. Nothing is imported or executed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
|
|
18
|
+
from agent_latch.astutil import walk
|
|
19
|
+
from agent_latch.findings import Finding
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------- shared helpers
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dotted(node: ast.AST | None) -> str:
|
|
25
|
+
if isinstance(node, ast.Name):
|
|
26
|
+
return node.id
|
|
27
|
+
if isinstance(node, ast.Attribute):
|
|
28
|
+
base = _dotted(node.value)
|
|
29
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
30
|
+
if isinstance(node, ast.Call):
|
|
31
|
+
return _dotted(node.func)
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _last(name: str) -> str:
|
|
36
|
+
return name.rsplit(".", 1)[-1]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _literal(node: ast.AST | None) -> object:
|
|
40
|
+
return node.value if isinstance(node, ast.Constant) else ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------- AG006: approval disabled
|
|
44
|
+
|
|
45
|
+
# Keyword set to True turns off confirmation before tool or code execution.
|
|
46
|
+
_AUTO_APPROVE_KWARGS = {
|
|
47
|
+
"auto_approve",
|
|
48
|
+
"auto_approve_tools",
|
|
49
|
+
"auto_confirm",
|
|
50
|
+
"skip_approval",
|
|
51
|
+
"skip_confirmation",
|
|
52
|
+
"bypass_approval",
|
|
53
|
+
"dangerously_skip_permissions",
|
|
54
|
+
"yolo",
|
|
55
|
+
}
|
|
56
|
+
# Keyword whose string value says "never ask a human".
|
|
57
|
+
_APPROVAL_MODE_KWARGS = {"approval_mode", "approval_policy", "permission_mode", "require_approval"}
|
|
58
|
+
_NO_APPROVAL_VALUES = {"never", "none", "auto", "always_approve", "bypasspermissions", "full-auto", "yolo"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _code_execution_enabled(call: ast.Call) -> bool:
|
|
62
|
+
"""AutoGen executes code unless code_execution_config is False or None."""
|
|
63
|
+
for keyword in call.keywords:
|
|
64
|
+
if keyword.arg == "code_execution_config":
|
|
65
|
+
return _literal(keyword.value) not in (False, None)
|
|
66
|
+
if keyword.arg == "code_executor":
|
|
67
|
+
return _literal(keyword.value) is not None
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _approval_findings(tree: ast.Module, path: str) -> list[Finding]:
|
|
72
|
+
results: list[Finding] = []
|
|
73
|
+
|
|
74
|
+
def add(node: ast.AST, severity: str, owasp: tuple[str, ...], evidence: str, why: str) -> None:
|
|
75
|
+
results.append(
|
|
76
|
+
Finding(
|
|
77
|
+
rule_id="AGENTLATCH-AG006",
|
|
78
|
+
title="Human approval disabled for agent actions",
|
|
79
|
+
severity=severity,
|
|
80
|
+
message=(
|
|
81
|
+
f"{why} A user or reviewer can be steered into trusting actions they never see. "
|
|
82
|
+
"Keep a human confirmation step for code execution and other high-impact actions."
|
|
83
|
+
),
|
|
84
|
+
path=path,
|
|
85
|
+
line=node.lineno,
|
|
86
|
+
column=node.col_offset + 1,
|
|
87
|
+
owasp=owasp,
|
|
88
|
+
confidence="high" if severity == "high" else "medium",
|
|
89
|
+
evidence=evidence,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
for node in walk(tree):
|
|
94
|
+
if not isinstance(node, ast.Call):
|
|
95
|
+
continue
|
|
96
|
+
callee = _last(_dotted(node.func)) or "call"
|
|
97
|
+
for keyword in node.keywords:
|
|
98
|
+
value = _literal(keyword.value)
|
|
99
|
+
if keyword.arg == "human_input_mode" and value == "NEVER":
|
|
100
|
+
if _code_execution_enabled(node):
|
|
101
|
+
add(node, "high", ("ASI09", "ASI05"), f'{callee}(human_input_mode="NEVER", code execution on)',
|
|
102
|
+
f"{callee} runs model-written code with no human in the loop.")
|
|
103
|
+
elif keyword.arg in _AUTO_APPROVE_KWARGS and value is True:
|
|
104
|
+
add(node, "high", ("ASI09", "ASI02"), f"{callee}({keyword.arg}=True)",
|
|
105
|
+
f"{keyword.arg}=True approves the agent's actions automatically.")
|
|
106
|
+
elif (
|
|
107
|
+
keyword.arg in _APPROVAL_MODE_KWARGS
|
|
108
|
+
and isinstance(value, str)
|
|
109
|
+
and value.strip().lower() in _NO_APPROVAL_VALUES
|
|
110
|
+
):
|
|
111
|
+
add(node, "high", ("ASI09", "ASI02"), f"{callee}({keyword.arg}={value!r})",
|
|
112
|
+
f"{keyword.arg}={value!r} lets the agent act without asking a human.")
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------- AG007: unbounded loops
|
|
117
|
+
|
|
118
|
+
_LIMIT_KWARGS = {
|
|
119
|
+
"max_iterations",
|
|
120
|
+
"max_iter",
|
|
121
|
+
"max_turns",
|
|
122
|
+
"max_round",
|
|
123
|
+
"max_rounds",
|
|
124
|
+
"max_steps",
|
|
125
|
+
"recursion_limit",
|
|
126
|
+
}
|
|
127
|
+
# Above this many steps, a limit no longer works as a circuit breaker. LangGraph defaults to 25,
|
|
128
|
+
# LangChain AgentExecutor to 15, CrewAI max_iter to 20-25.
|
|
129
|
+
_LIMIT_THRESHOLD = 100
|
|
130
|
+
_AGENT_METHODS = {"invoke", "ainvoke", "kickoff", "kickoff_async", "initiate_chat", "run_sync"}
|
|
131
|
+
# Generic method names count only when the receiver looks like an agent, crew, or graph.
|
|
132
|
+
_GENERIC_METHODS = {"run", "arun", "chat", "start", "stream", "astream"}
|
|
133
|
+
_AGENT_RECEIVER_HINTS = ("agent", "crew", "graph", "executor", "chain", "app", "team", "runner", "swarm")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _limit_problem(node: ast.AST | None) -> str | None:
|
|
137
|
+
value = _literal(node)
|
|
138
|
+
if value is None:
|
|
139
|
+
return "None"
|
|
140
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > _LIMIT_THRESHOLD:
|
|
141
|
+
return str(value)
|
|
142
|
+
if isinstance(node, ast.Attribute) and _dotted(node) in {"math.inf", "sys.maxsize"}:
|
|
143
|
+
return _dotted(node)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_agent_call(call: ast.Call) -> bool:
|
|
148
|
+
if not isinstance(call.func, ast.Attribute):
|
|
149
|
+
return False
|
|
150
|
+
method = call.func.attr
|
|
151
|
+
if method in _AGENT_METHODS:
|
|
152
|
+
return True
|
|
153
|
+
receiver = _dotted(call.func.value).lower()
|
|
154
|
+
return method in _GENERIC_METHODS and any(hint in receiver for hint in _AGENT_RECEIVER_HINTS)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _loop_exits(loop: ast.While) -> bool:
|
|
158
|
+
"""True if the loop body can leave the loop: break, return, raise, or sys.exit()."""
|
|
159
|
+
stack: list[ast.AST] = list(loop.body)
|
|
160
|
+
while stack:
|
|
161
|
+
node = stack.pop()
|
|
162
|
+
if isinstance(node, (ast.Break, ast.Return, ast.Raise)):
|
|
163
|
+
return True
|
|
164
|
+
if isinstance(node, ast.Call) and _dotted(node.func) in {"sys.exit", "exit", "quit", "os._exit"}:
|
|
165
|
+
return True
|
|
166
|
+
# A break inside a nested loop or function does not leave this loop.
|
|
167
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
|
|
168
|
+
continue
|
|
169
|
+
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
170
|
+
stack.extend(child for child in ast.walk(node) if isinstance(child, (ast.Return, ast.Raise)))
|
|
171
|
+
continue
|
|
172
|
+
stack.extend(ast.iter_child_nodes(node))
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _loop_findings(tree: ast.Module, path: str) -> list[Finding]:
|
|
177
|
+
results: list[Finding] = []
|
|
178
|
+
for node in walk(tree):
|
|
179
|
+
if isinstance(node, ast.Call):
|
|
180
|
+
callee = _last(_dotted(node.func)) or "call"
|
|
181
|
+
for keyword in node.keywords:
|
|
182
|
+
if keyword.arg in _LIMIT_KWARGS:
|
|
183
|
+
problem = _limit_problem(keyword.value)
|
|
184
|
+
if problem:
|
|
185
|
+
results.append(_loop_finding(keyword.value, path, f"{callee}({keyword.arg}={problem})",
|
|
186
|
+
f"{keyword.arg}={problem} gives the agent no effective step limit."))
|
|
187
|
+
elif isinstance(node, ast.Dict):
|
|
188
|
+
for key, value in zip(node.keys, node.values):
|
|
189
|
+
if isinstance(key, ast.Constant) and key.value in _LIMIT_KWARGS:
|
|
190
|
+
problem = _limit_problem(value)
|
|
191
|
+
if problem:
|
|
192
|
+
results.append(_loop_finding(value, path, f'{{"{key.value}": {problem}}}',
|
|
193
|
+
f"{key.value}={problem} gives the agent no effective step limit."))
|
|
194
|
+
elif isinstance(node, ast.While) and _literal(node.test) is True and not _loop_exits(node):
|
|
195
|
+
calls = [child for child in walk(node) if isinstance(child, ast.Call) and _is_agent_call(child)]
|
|
196
|
+
if calls:
|
|
197
|
+
results.append(_loop_finding(node, path, f"while True: ... {_dotted(calls[0].func)}(...) with no exit",
|
|
198
|
+
"An agent is called in an endless loop with no break, return, or raise."))
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _loop_finding(node: ast.AST, path: str, evidence: str, why: str) -> Finding:
|
|
203
|
+
return Finding(
|
|
204
|
+
rule_id="AGENTLATCH-AG007",
|
|
205
|
+
title="Agent loop without an effective limit",
|
|
206
|
+
severity="medium",
|
|
207
|
+
message=(
|
|
208
|
+
f"{why} One bad step can then repeat or fan out without stopping. "
|
|
209
|
+
"Set a small step limit, a timeout, or a circuit breaker."
|
|
210
|
+
),
|
|
211
|
+
path=path,
|
|
212
|
+
line=node.lineno,
|
|
213
|
+
column=node.col_offset + 1,
|
|
214
|
+
owasp=("ASI08",),
|
|
215
|
+
confidence="medium",
|
|
216
|
+
evidence=evidence,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------- entry point
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def scan_oversight(tree: ast.Module, path: str) -> list[Finding]:
|
|
224
|
+
"""AG006 and AG007 for one parsed Python file. scan_python lowers test-file severity."""
|
|
225
|
+
return [*_approval_findings(tree, path), *_loop_findings(tree, path)]
|
agent_latch/owasp.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OWASP Top 10 for Agentic Applications 2026: the single source of category IDs and names.
|
|
2
|
+
|
|
3
|
+
Names follow the table of contents of the OWASP publication
|
|
4
|
+
"OWASP Top 10 for Agentic Applications 2026" (OWASP-Top-10-for-Agentic-Applications-2026-12.6-1.pdf).
|
|
5
|
+
The mapping is informational; it is not a compliance assessment or an OWASP endorsement.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
OWASP_AGENTIC_VERSION = "2026"
|
|
14
|
+
OWASP_AGENTIC_TITLE = "OWASP Top 10 for Agentic Applications 2026"
|
|
15
|
+
OWASP_AGENTIC_URL = (
|
|
16
|
+
"https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ASI_CATEGORIES: dict[str, str] = {
|
|
20
|
+
"ASI01": "Agent Goal Hijack",
|
|
21
|
+
"ASI02": "Tool Misuse and Exploitation",
|
|
22
|
+
"ASI03": "Identity and Privilege Abuse",
|
|
23
|
+
"ASI04": "Agentic Supply Chain Vulnerabilities",
|
|
24
|
+
"ASI05": "Unexpected Code Execution (RCE)",
|
|
25
|
+
"ASI06": "Memory & Context Poisoning",
|
|
26
|
+
"ASI07": "Insecure Inter-Agent Communication",
|
|
27
|
+
"ASI08": "Cascading Failures",
|
|
28
|
+
"ASI09": "Human-Agent Trust Exploitation",
|
|
29
|
+
"ASI10": "Rogue Agents",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Every category a rule can emit. Rules whose mapping depends on context (MAN001) list all options.
|
|
33
|
+
# Keep in sync with the rules and with the coverage table in AGENTS.md.
|
|
34
|
+
RULE_CATEGORIES: dict[str, tuple[str, ...]] = {
|
|
35
|
+
"AGENTLATCH-PY001": ("ASI05", "ASI02"),
|
|
36
|
+
"AGENTLATCH-PY002": ("ASI05", "ASI02"),
|
|
37
|
+
"AGENTLATCH-PY003": ("ASI02",),
|
|
38
|
+
"AGENTLATCH-AG001": ("ASI02", "ASI03"),
|
|
39
|
+
"AGENTLATCH-AG002": ("ASI05", "ASI02"),
|
|
40
|
+
"AGENTLATCH-AG003": ("ASI01",),
|
|
41
|
+
"AGENTLATCH-AG004": ("ASI02", "ASI03", "ASI05", "ASI09"),
|
|
42
|
+
"AGENTLATCH-AG005": ("ASI02", "ASI03", "ASI05"),
|
|
43
|
+
"AGENTLATCH-AG006": ("ASI09", "ASI02", "ASI05"),
|
|
44
|
+
"AGENTLATCH-AG007": ("ASI08",),
|
|
45
|
+
"AGENTLATCH-SEC001": ("ASI03", "ASI04"),
|
|
46
|
+
"AGENTLATCH-DEP001": ("ASI04",),
|
|
47
|
+
"AGENTLATCH-MAN000": (),
|
|
48
|
+
"AGENTLATCH-MAN001": ("ASI02", "ASI03", "ASI05", "ASI09"),
|
|
49
|
+
"AGENTLATCH-MAN002": ("ASI02", "ASI03"),
|
|
50
|
+
"AGENTLATCH-MAN003": ("ASI03", "ASI02"),
|
|
51
|
+
"AGENTLATCH-PRM001": ("ASI01",),
|
|
52
|
+
"AGENTLATCH-PRM002": ("ASI01",),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _HasMapping(Protocol):
|
|
57
|
+
rule_id: str
|
|
58
|
+
owasp: tuple[str, ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def category_name(category_id: str) -> str:
|
|
62
|
+
return ASI_CATEGORIES.get(category_id, "Unknown category")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def category_label(category_id: str) -> str:
|
|
66
|
+
"""'ASI05 Unexpected Code Execution (RCE)'."""
|
|
67
|
+
return f"{category_id} {category_name(category_id)}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def category_entries(category_ids: Iterable[str]) -> list[dict[str, str]]:
|
|
71
|
+
return [{"id": cid, "name": category_name(cid)} for cid in category_ids]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def rules_for_category(category_id: str) -> list[str]:
|
|
75
|
+
return sorted(rule for rule, cats in RULE_CATEGORIES.items() if category_id in cats)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def coverage(findings: Iterable[_HasMapping]) -> list[dict[str, Any]]:
|
|
79
|
+
"""Per-category finding counts, plus whether AgentLatch has any check for the category.
|
|
80
|
+
|
|
81
|
+
`has_checks` means a rule exists; it does not mean the category is fully covered,
|
|
82
|
+
and some rules only run with a manifest or --dependencies.
|
|
83
|
+
"""
|
|
84
|
+
counts = dict.fromkeys(ASI_CATEGORIES, 0)
|
|
85
|
+
for finding in findings:
|
|
86
|
+
for category_id in set(finding.owasp):
|
|
87
|
+
if category_id in counts:
|
|
88
|
+
counts[category_id] += 1
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
"id": category_id,
|
|
92
|
+
"name": name,
|
|
93
|
+
"finding_count": counts[category_id],
|
|
94
|
+
"has_checks": bool(rules_for_category(category_id)),
|
|
95
|
+
"rules": rules_for_category(category_id),
|
|
96
|
+
}
|
|
97
|
+
for category_id, name in ASI_CATEGORIES.items()
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def coverage_status(entry: dict[str, Any]) -> str:
|
|
102
|
+
if not entry["has_checks"]:
|
|
103
|
+
return "no checks yet"
|
|
104
|
+
count = entry["finding_count"]
|
|
105
|
+
return f"{count} finding(s)" if count else "no findings from enabled checks"
|
agent_latch/prompts.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Prompt-text checks (PRM001, PRM002) shared by manifest and source-code scanning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from agent_latch.findings import Finding
|
|
8
|
+
|
|
9
|
+
INJECTION_SIGNATURES = (
|
|
10
|
+
re.compile(r"(?i)\bignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above|earlier)\s+(?:instructions|prompts|rules)"),
|
|
11
|
+
re.compile(r"(?i)\bdisregard\s+(?:all\s+)?(?:the\s+|your\s+)?(?:previous|prior|above|system)\s+(?:instructions|prompts|rules)"),
|
|
12
|
+
re.compile(r"(?i)\byou\s+are\s+now\s+(?:in\s+)?(?:developer\s+mode|dan\b|jailbroken|unrestricted)"),
|
|
13
|
+
re.compile(r"(?i)\b(?:reveal|print|output)\s+(?:your|the)\s+(?:system\s+prompt|hidden\s+instructions)"),
|
|
14
|
+
re.compile(r"(?i)\bwithout\s+(?:telling|informing|notifying)\s+the\s+user\b"),
|
|
15
|
+
)
|
|
16
|
+
UNTRUSTED_PLACEHOLDER = re.compile(
|
|
17
|
+
r"(\{\{\s*|\$\{|\{)\s*(\w*(?:user|input|query|message|request|question)\w*)\s*(?:\}\}|\})",
|
|
18
|
+
re.IGNORECASE,
|
|
19
|
+
)
|
|
20
|
+
# Names like {message_count} or {user_id} hold numbers or identifiers, not user-written text.
|
|
21
|
+
_NON_TEXT_SUFFIX = re.compile(
|
|
22
|
+
r"(?i)(?:_|^)(?:count|counts|id|ids|len|length|total|num|number|index|idx|size|type|time|at|ts)$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def untrusted_placeholder(line: str) -> re.Match[str] | None:
|
|
27
|
+
"""First user-input-like placeholder on the line that could hold free text."""
|
|
28
|
+
for match in UNTRUSTED_PLACEHOLDER.finditer(line):
|
|
29
|
+
if not _NON_TEXT_SUFFIX.search(match.group(2).split(".")[-1]):
|
|
30
|
+
return match
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_prompt_text(
|
|
35
|
+
text: str,
|
|
36
|
+
path: str,
|
|
37
|
+
base_line: int,
|
|
38
|
+
label: str,
|
|
39
|
+
is_system: bool,
|
|
40
|
+
base_column: int = 1,
|
|
41
|
+
) -> list[Finding]:
|
|
42
|
+
"""Run PRM001 (injection phrases) and, for system prompts, PRM002 (user-input placeholders).
|
|
43
|
+
|
|
44
|
+
`base_column` applies to the first line only, for strings that start mid-line in source code.
|
|
45
|
+
"""
|
|
46
|
+
findings: list[Finding] = []
|
|
47
|
+
for offset, line in enumerate(text.splitlines()):
|
|
48
|
+
column_shift = base_column - 1 if offset == 0 else 0
|
|
49
|
+
for signature in INJECTION_SIGNATURES:
|
|
50
|
+
match = signature.search(line)
|
|
51
|
+
if match:
|
|
52
|
+
findings.append(
|
|
53
|
+
Finding(
|
|
54
|
+
rule_id="AGENTLATCH-PRM001",
|
|
55
|
+
title="Prompt-injection signature in prompt",
|
|
56
|
+
severity="medium",
|
|
57
|
+
message=(
|
|
58
|
+
f"{label} contains an instruction-override phrase commonly used in prompt injection. "
|
|
59
|
+
"Check whether this text came from an untrusted source."
|
|
60
|
+
),
|
|
61
|
+
path=path,
|
|
62
|
+
line=base_line + offset,
|
|
63
|
+
column=match.start() + 1 + column_shift,
|
|
64
|
+
owasp=("ASI01",),
|
|
65
|
+
confidence="medium",
|
|
66
|
+
evidence=f'"{match.group(0)[:80]}"',
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
break
|
|
70
|
+
if is_system:
|
|
71
|
+
match = untrusted_placeholder(line)
|
|
72
|
+
if match:
|
|
73
|
+
findings.append(
|
|
74
|
+
Finding(
|
|
75
|
+
rule_id="AGENTLATCH-PRM002",
|
|
76
|
+
title="Untrusted input interpolated into system prompt",
|
|
77
|
+
severity="medium",
|
|
78
|
+
message=(
|
|
79
|
+
f"{label} inserts a user-controlled variable into system-level instructions. "
|
|
80
|
+
"Pass user input in the user turn, delimited and labeled as data."
|
|
81
|
+
),
|
|
82
|
+
path=path,
|
|
83
|
+
line=base_line + offset,
|
|
84
|
+
column=match.start() + 1 + column_shift,
|
|
85
|
+
owasp=("ASI01",),
|
|
86
|
+
confidence="low",
|
|
87
|
+
evidence=f"placeholder {match.group(0)}",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return findings
|