agents-shipgate 0.2.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.
- agents_shipgate/__init__.py +3 -0
- agents_shipgate/__main__.py +5 -0
- agents_shipgate/checks/__init__.py +2 -0
- agents_shipgate/checks/api.py +400 -0
- agents_shipgate/checks/auth.py +104 -0
- agents_shipgate/checks/base.py +71 -0
- agents_shipgate/checks/documentation.py +113 -0
- agents_shipgate/checks/inventory.py +70 -0
- agents_shipgate/checks/manifest_consistency.py +166 -0
- agents_shipgate/checks/manifest_scope.py +170 -0
- agents_shipgate/checks/policy.py +65 -0
- agents_shipgate/checks/registry.py +210 -0
- agents_shipgate/checks/schema.py +129 -0
- agents_shipgate/checks/side_effects.py +49 -0
- agents_shipgate/ci/__init__.py +2 -0
- agents_shipgate/ci/exit_policy.py +35 -0
- agents_shipgate/ci/github_summary.py +27 -0
- agents_shipgate/cli/__init__.py +2 -0
- agents_shipgate/cli/discovery.py +205 -0
- agents_shipgate/cli/main.py +477 -0
- agents_shipgate/cli/scan.py +366 -0
- agents_shipgate/config/__init__.py +2 -0
- agents_shipgate/config/loader.py +120 -0
- agents_shipgate/config/schema.py +312 -0
- agents_shipgate/core/__init__.py +2 -0
- agents_shipgate/core/baseline.py +113 -0
- agents_shipgate/core/context.py +16 -0
- agents_shipgate/core/errors.py +11 -0
- agents_shipgate/core/findings.py +249 -0
- agents_shipgate/core/logging.py +38 -0
- agents_shipgate/core/models.py +272 -0
- agents_shipgate/core/risk_hints.py +173 -0
- agents_shipgate/inputs/__init__.py +2 -0
- agents_shipgate/inputs/common.py +141 -0
- agents_shipgate/inputs/mcp.py +114 -0
- agents_shipgate/inputs/openai_api.py +355 -0
- agents_shipgate/inputs/openai_sdk_static.py +162 -0
- agents_shipgate/inputs/openapi.py +324 -0
- agents_shipgate/py.typed +1 -0
- agents_shipgate/report/__init__.py +2 -0
- agents_shipgate/report/json_report.py +10 -0
- agents_shipgate/report/markdown.py +248 -0
- agents_shipgate-0.2.0.dist-info/METADATA +217 -0
- agents_shipgate-0.2.0.dist-info/RECORD +47 -0
- agents_shipgate-0.2.0.dist-info/WHEEL +4 -0
- agents_shipgate-0.2.0.dist-info/entry_points.txt +12 -0
- agents_shipgate-0.2.0.dist-info/licenses/LICENSE +185 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agents_shipgate.checks.base import agent_finding, tool_finding
|
|
6
|
+
from agents_shipgate.core.context import ScanContext
|
|
7
|
+
from agents_shipgate.core.models import Tool, ToolParameter
|
|
8
|
+
from agents_shipgate.core.risk_hints import (
|
|
9
|
+
has_risk_tag,
|
|
10
|
+
is_high_risk_tool,
|
|
11
|
+
is_write_tool,
|
|
12
|
+
risk_tags,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
BROAD_TEXT_NAMES = {
|
|
16
|
+
"action",
|
|
17
|
+
"body",
|
|
18
|
+
"command",
|
|
19
|
+
"content",
|
|
20
|
+
"instructions",
|
|
21
|
+
"message",
|
|
22
|
+
"prompt",
|
|
23
|
+
"update",
|
|
24
|
+
"updates",
|
|
25
|
+
}
|
|
26
|
+
RISKY_NUMERIC_NAMES = {"amount", "amt", "count", "qty", "quantity", "limit", "cap", "size"}
|
|
27
|
+
READ_ONLY_PROMPT_TERMS = (
|
|
28
|
+
"advise only",
|
|
29
|
+
"advice only",
|
|
30
|
+
"only advise",
|
|
31
|
+
"read-only",
|
|
32
|
+
"read only",
|
|
33
|
+
"do not take action",
|
|
34
|
+
)
|
|
35
|
+
APPROVAL_PROMPT_TERMS = ("approval", "approved", "human review", "requires review")
|
|
36
|
+
CONFIRMATION_PROMPT_TERMS = ("confirm", "confirmation", "explicit consent", "ask before")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(context: ScanContext):
|
|
40
|
+
if context.api_artifacts is None:
|
|
41
|
+
return []
|
|
42
|
+
findings = []
|
|
43
|
+
findings.extend(_function_schema_strictness(context))
|
|
44
|
+
findings.extend(_structured_output_readiness(context))
|
|
45
|
+
findings.extend(_prompt_tool_scope_mismatch(context))
|
|
46
|
+
findings.extend(_operational_readiness(context))
|
|
47
|
+
return findings
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _function_schema_strictness(context: ScanContext):
|
|
51
|
+
findings = []
|
|
52
|
+
for tool in _api_tools(context):
|
|
53
|
+
issues = _function_schema_issues(tool)
|
|
54
|
+
if not issues:
|
|
55
|
+
continue
|
|
56
|
+
high_risk = is_write_tool(tool) or is_high_risk_tool(tool)
|
|
57
|
+
findings.append(
|
|
58
|
+
tool_finding(
|
|
59
|
+
tool=tool,
|
|
60
|
+
check_id="SHIP-API-FUNCTION-SCHEMA-STRICTNESS",
|
|
61
|
+
title=f"{tool.name} function schema is not strict enough",
|
|
62
|
+
severity="high" if high_risk else "medium",
|
|
63
|
+
category="api",
|
|
64
|
+
evidence={"issues": issues, "risk_tags": risk_tags(tool, min_confidence="medium")},
|
|
65
|
+
confidence="high",
|
|
66
|
+
recommendation=(
|
|
67
|
+
f"Make {tool.name} a strict function schema: object parameters, "
|
|
68
|
+
"additionalProperties=false, complete required list, and bounded risky fields."
|
|
69
|
+
),
|
|
70
|
+
context=context,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return findings
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _structured_output_readiness(context: ScanContext):
|
|
77
|
+
artifacts = context.api_artifacts
|
|
78
|
+
if artifacts is None:
|
|
79
|
+
return []
|
|
80
|
+
high_risk_tools = [tool.name for tool in _api_tools(context) if is_high_risk_tool(tool)]
|
|
81
|
+
if not artifacts.response_formats:
|
|
82
|
+
return [
|
|
83
|
+
agent_finding(
|
|
84
|
+
check_id="SHIP-API-STRUCTURED-OUTPUT-READINESS",
|
|
85
|
+
title="OpenAI API response format is not declared",
|
|
86
|
+
severity="high" if high_risk_tools else "medium",
|
|
87
|
+
category="api",
|
|
88
|
+
evidence={"high_risk_tools": high_risk_tools},
|
|
89
|
+
confidence="high",
|
|
90
|
+
recommendation=(
|
|
91
|
+
"Declare a structured response format with decision/status, error/refusal, "
|
|
92
|
+
"and needs_review fields where downstream behavior depends on the output."
|
|
93
|
+
),
|
|
94
|
+
context=context,
|
|
95
|
+
)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
findings = []
|
|
99
|
+
for response_format in artifacts.response_formats:
|
|
100
|
+
issues = _response_schema_issues(
|
|
101
|
+
response_format.json_schema,
|
|
102
|
+
response_format.downstream_critical_fields,
|
|
103
|
+
)
|
|
104
|
+
if not issues:
|
|
105
|
+
continue
|
|
106
|
+
findings.append(
|
|
107
|
+
agent_finding(
|
|
108
|
+
check_id="SHIP-API-STRUCTURED-OUTPUT-READINESS",
|
|
109
|
+
title=f"Response format {response_format.path} is under-specified",
|
|
110
|
+
severity="medium",
|
|
111
|
+
category="api",
|
|
112
|
+
evidence={
|
|
113
|
+
"path": response_format.path,
|
|
114
|
+
"issues": issues,
|
|
115
|
+
"downstream_critical_fields": response_format.downstream_critical_fields,
|
|
116
|
+
},
|
|
117
|
+
confidence="medium",
|
|
118
|
+
recommendation=(
|
|
119
|
+
"Tighten the structured output schema with enums, "
|
|
120
|
+
"needs_review/refusal/error modeling, and declared critical fields."
|
|
121
|
+
),
|
|
122
|
+
context=context,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return findings
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _prompt_tool_scope_mismatch(context: ScanContext):
|
|
129
|
+
artifacts = context.api_artifacts
|
|
130
|
+
if artifacts is None or not artifacts.prompt_text:
|
|
131
|
+
return []
|
|
132
|
+
prompt = artifacts.prompt_text.lower()
|
|
133
|
+
api_tools = _api_tools(context)
|
|
134
|
+
write_or_high_risk = [
|
|
135
|
+
tool for tool in api_tools if is_write_tool(tool) or is_high_risk_tool(tool)
|
|
136
|
+
]
|
|
137
|
+
findings = []
|
|
138
|
+
if write_or_high_risk and any(term in prompt for term in READ_ONLY_PROMPT_TERMS):
|
|
139
|
+
findings.append(
|
|
140
|
+
agent_finding(
|
|
141
|
+
check_id="SHIP-API-PROMPT-TOOL-SCOPE-MISMATCH",
|
|
142
|
+
title=(
|
|
143
|
+
"Prompt says read-only or advise-only while write/high-risk "
|
|
144
|
+
"tools are enabled"
|
|
145
|
+
),
|
|
146
|
+
severity="high",
|
|
147
|
+
category="api",
|
|
148
|
+
evidence={"tools": [tool.name for tool in write_or_high_risk]},
|
|
149
|
+
confidence="high",
|
|
150
|
+
recommendation=(
|
|
151
|
+
"Align prompt scope with enabled tools or remove write/high-risk tools."
|
|
152
|
+
),
|
|
153
|
+
context=context,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
needs_confirmation = [
|
|
157
|
+
tool
|
|
158
|
+
for tool in api_tools
|
|
159
|
+
if has_risk_tag(
|
|
160
|
+
tool,
|
|
161
|
+
{"destructive", "external_write", "customer_communication", "financial_action"},
|
|
162
|
+
min_confidence="medium",
|
|
163
|
+
)
|
|
164
|
+
]
|
|
165
|
+
if needs_confirmation and not (
|
|
166
|
+
any(term in prompt for term in CONFIRMATION_PROMPT_TERMS)
|
|
167
|
+
and any(term in prompt for term in APPROVAL_PROMPT_TERMS)
|
|
168
|
+
):
|
|
169
|
+
findings.append(
|
|
170
|
+
agent_finding(
|
|
171
|
+
check_id="SHIP-API-PROMPT-TOOL-SCOPE-MISMATCH",
|
|
172
|
+
title="Prompt lacks approval/confirmation language for high-risk tools",
|
|
173
|
+
severity="medium",
|
|
174
|
+
category="api",
|
|
175
|
+
evidence={"tools": [tool.name for tool in needs_confirmation]},
|
|
176
|
+
confidence="medium",
|
|
177
|
+
recommendation=(
|
|
178
|
+
"Add prompt instructions requiring human approval and explicit confirmation "
|
|
179
|
+
"before financial, destructive, or external customer actions."
|
|
180
|
+
),
|
|
181
|
+
context=context,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return findings
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _operational_readiness(context: ScanContext):
|
|
188
|
+
artifacts = context.api_artifacts
|
|
189
|
+
if artifacts is None:
|
|
190
|
+
return []
|
|
191
|
+
findings = []
|
|
192
|
+
api_tools = _api_tools(context)
|
|
193
|
+
high_risk_tools = [tool for tool in api_tools if is_high_risk_tool(tool)]
|
|
194
|
+
retry_policy = artifacts.retry_policy()
|
|
195
|
+
timeouts = artifacts.timeouts()
|
|
196
|
+
output_schemas = artifacts.tool_output_schemas()
|
|
197
|
+
|
|
198
|
+
if high_risk_tools and not retry_policy:
|
|
199
|
+
findings.append(
|
|
200
|
+
agent_finding(
|
|
201
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
202
|
+
title="OpenAI API flow lacks retry policy metadata",
|
|
203
|
+
severity="medium",
|
|
204
|
+
category="api",
|
|
205
|
+
evidence={"high_risk_tools": [tool.name for tool in high_risk_tools]},
|
|
206
|
+
confidence="medium",
|
|
207
|
+
recommendation="Declare retry_policy in openai_api.policy_rules or model_config.",
|
|
208
|
+
context=context,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
if high_risk_tools and not timeouts:
|
|
212
|
+
findings.append(
|
|
213
|
+
agent_finding(
|
|
214
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
215
|
+
title="OpenAI API flow lacks timeout metadata",
|
|
216
|
+
severity="medium",
|
|
217
|
+
category="api",
|
|
218
|
+
evidence={"high_risk_tools": [tool.name for tool in high_risk_tools]},
|
|
219
|
+
confidence="medium",
|
|
220
|
+
recommendation="Declare tool-call timeout metadata for high-risk OpenAI API flows.",
|
|
221
|
+
context=context,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
if high_risk_tools and not artifacts.test_cases:
|
|
225
|
+
findings.append(
|
|
226
|
+
agent_finding(
|
|
227
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
228
|
+
title="OpenAI API flow lacks test case metadata for high-risk tools",
|
|
229
|
+
severity="medium",
|
|
230
|
+
category="api",
|
|
231
|
+
evidence={"high_risk_tools": [tool.name for tool in high_risk_tools]},
|
|
232
|
+
confidence="medium",
|
|
233
|
+
recommendation="Add simple OpenAI API test cases for high-risk tool-call flows.",
|
|
234
|
+
context=context,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
for tool in high_risk_tools:
|
|
238
|
+
if tool.name not in output_schemas:
|
|
239
|
+
findings.append(
|
|
240
|
+
tool_finding(
|
|
241
|
+
tool=tool,
|
|
242
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
243
|
+
title=f"{tool.name} lacks success/failure output modeling",
|
|
244
|
+
severity="medium",
|
|
245
|
+
category="api",
|
|
246
|
+
evidence={"tool_output_schemas": sorted(output_schemas)},
|
|
247
|
+
confidence="medium",
|
|
248
|
+
recommendation=(
|
|
249
|
+
f"Declare success_fields and failure_fields for {tool.name} "
|
|
250
|
+
"in openai_api policy rules."
|
|
251
|
+
),
|
|
252
|
+
context=context,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
if retry_policy and _needs_idempotency(tool, artifacts):
|
|
256
|
+
findings.append(
|
|
257
|
+
tool_finding(
|
|
258
|
+
tool=tool,
|
|
259
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
260
|
+
title=f"{tool.name} may be retried without idempotency evidence",
|
|
261
|
+
severity="high",
|
|
262
|
+
category="api",
|
|
263
|
+
evidence={
|
|
264
|
+
"retry_policy": retry_policy,
|
|
265
|
+
"risk_tags": risk_tags(tool, min_confidence="medium"),
|
|
266
|
+
},
|
|
267
|
+
confidence="high",
|
|
268
|
+
recommendation=(
|
|
269
|
+
f"Add idempotency evidence for {tool.name} or avoid retrying "
|
|
270
|
+
"this side effect."
|
|
271
|
+
),
|
|
272
|
+
context=context,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
_append_trace_findings(findings, context)
|
|
276
|
+
return findings
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _append_trace_findings(findings: list, context: ScanContext) -> None:
|
|
280
|
+
artifacts = context.api_artifacts
|
|
281
|
+
if artifacts is None:
|
|
282
|
+
return
|
|
283
|
+
approval_tools = context.manifest.policies.approval_tools() | artifacts.approval_tools()
|
|
284
|
+
confirmation_tools = (
|
|
285
|
+
context.manifest.policies.confirmation_tools() | artifacts.confirmation_tools()
|
|
286
|
+
)
|
|
287
|
+
for event in artifacts.trace_samples:
|
|
288
|
+
tool_name = event.get("tool_name")
|
|
289
|
+
if not isinstance(tool_name, str):
|
|
290
|
+
continue
|
|
291
|
+
if tool_name in approval_tools and event.get("approved") is False:
|
|
292
|
+
findings.append(
|
|
293
|
+
agent_finding(
|
|
294
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
295
|
+
title=f"Trace sample shows {tool_name} without approval",
|
|
296
|
+
severity="medium",
|
|
297
|
+
category="api",
|
|
298
|
+
evidence={"tool_name": tool_name, "approved": event.get("approved")},
|
|
299
|
+
confidence="medium",
|
|
300
|
+
recommendation=f"Require approval before calling {tool_name}.",
|
|
301
|
+
context=context,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
if tool_name in confirmation_tools and event.get("confirmed") is False:
|
|
305
|
+
findings.append(
|
|
306
|
+
agent_finding(
|
|
307
|
+
check_id="SHIP-API-OPERATIONAL-READINESS",
|
|
308
|
+
title=f"Trace sample shows {tool_name} without confirmation",
|
|
309
|
+
severity="medium",
|
|
310
|
+
category="api",
|
|
311
|
+
evidence={"tool_name": tool_name, "confirmed": event.get("confirmed")},
|
|
312
|
+
confidence="medium",
|
|
313
|
+
recommendation=f"Require explicit confirmation before calling {tool_name}.",
|
|
314
|
+
context=context,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _function_schema_issues(tool: Tool) -> list[str]:
|
|
320
|
+
issues: list[str] = []
|
|
321
|
+
schema = tool.input_schema
|
|
322
|
+
if not schema:
|
|
323
|
+
return ["missing_parameters_schema"]
|
|
324
|
+
if tool.annotations.get("openaiStrict") is not True:
|
|
325
|
+
issues.append("missing_strict_true")
|
|
326
|
+
if schema.get("type") != "object":
|
|
327
|
+
issues.append("parameters_schema_not_object")
|
|
328
|
+
if schema.get("additionalProperties") is not False:
|
|
329
|
+
issues.append("additional_properties_not_false")
|
|
330
|
+
properties = schema.get("properties")
|
|
331
|
+
if isinstance(properties, dict):
|
|
332
|
+
required = set(schema.get("required") or [])
|
|
333
|
+
missing_required = sorted(set(properties) - required)
|
|
334
|
+
if missing_required:
|
|
335
|
+
issues.append(f"properties_missing_from_required:{','.join(missing_required)}")
|
|
336
|
+
for parameter in tool.parameters:
|
|
337
|
+
if _risky_field_without_bounds_or_enum(parameter):
|
|
338
|
+
issues.append(f"risky_field_unbounded:{parameter.name}")
|
|
339
|
+
if _broad_free_text(parameter):
|
|
340
|
+
issues.append(f"broad_free_text:{parameter.name}")
|
|
341
|
+
return issues
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _response_schema_issues(schema: dict[str, Any], critical_fields: list[str]) -> list[str]:
|
|
345
|
+
issues: list[str] = []
|
|
346
|
+
if schema.get("type") != "object":
|
|
347
|
+
issues.append("response_schema_not_object")
|
|
348
|
+
if schema.get("additionalProperties") is not False:
|
|
349
|
+
issues.append("additional_properties_not_false")
|
|
350
|
+
properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
351
|
+
if not critical_fields:
|
|
352
|
+
issues.append("missing_downstream_critical_fields")
|
|
353
|
+
else:
|
|
354
|
+
missing_critical = sorted(set(critical_fields) - set(properties))
|
|
355
|
+
if missing_critical:
|
|
356
|
+
issues.append(f"critical_fields_missing_from_schema:{','.join(missing_critical)}")
|
|
357
|
+
if not any(field in properties for field in ("refusal", "needs_review", "error")):
|
|
358
|
+
issues.append("missing_refusal_needs_review_or_error_field")
|
|
359
|
+
for field in ("decision", "status"):
|
|
360
|
+
value = properties.get(field)
|
|
361
|
+
if isinstance(value, dict) and not value.get("enum"):
|
|
362
|
+
issues.append(f"missing_enum:{field}")
|
|
363
|
+
return issues
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _needs_idempotency(tool: Tool, artifacts) -> bool:
|
|
367
|
+
if tool.name in artifacts.idempotency_tools():
|
|
368
|
+
return False
|
|
369
|
+
if tool.annotations.get("idempotentHint") is True:
|
|
370
|
+
return False
|
|
371
|
+
if any(parameter.name == "idempotency_key" for parameter in tool.parameters):
|
|
372
|
+
return False
|
|
373
|
+
return is_write_tool(tool) and has_risk_tag(
|
|
374
|
+
tool,
|
|
375
|
+
{"financial_action", "destructive", "external_write"},
|
|
376
|
+
min_confidence="medium",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _risky_field_without_bounds_or_enum(parameter: ToolParameter) -> bool:
|
|
381
|
+
name = parameter.name.lower()
|
|
382
|
+
risky_name = any(token in name for token in RISKY_NUMERIC_NAMES)
|
|
383
|
+
return (
|
|
384
|
+
risky_name
|
|
385
|
+
and parameter.type in {"number", "integer"}
|
|
386
|
+
and parameter.maximum is None
|
|
387
|
+
and not parameter.enum
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _broad_free_text(parameter: ToolParameter) -> bool:
|
|
392
|
+
return (
|
|
393
|
+
parameter.name.lower() in BROAD_TEXT_NAMES
|
|
394
|
+
and parameter.type in {None, "string", "object"}
|
|
395
|
+
and not parameter.enum
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _api_tools(context: ScanContext) -> list[Tool]:
|
|
400
|
+
return [tool for tool in context.tools if tool.source_type == "openai_api"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agents_shipgate.checks.base import agent_finding, tool_finding
|
|
4
|
+
from agents_shipgate.core.context import ScanContext
|
|
5
|
+
from agents_shipgate.core.risk_hints import has_risk_tag, is_write_tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(context: ScanContext):
|
|
9
|
+
findings = []
|
|
10
|
+
broad_global_scopes = [scope for scope in context.manifest.permissions.scopes if _is_broad_scope(scope)]
|
|
11
|
+
if broad_global_scopes:
|
|
12
|
+
findings.append(
|
|
13
|
+
agent_finding(
|
|
14
|
+
check_id="SHIP-AUTH-MANIFEST-BROAD-SCOPE",
|
|
15
|
+
title="Manifest declares broad permission scopes",
|
|
16
|
+
severity="high",
|
|
17
|
+
category="auth",
|
|
18
|
+
evidence={"scopes": broad_global_scopes},
|
|
19
|
+
confidence="high",
|
|
20
|
+
recommendation="Replace broad manifest permission scopes with the narrowest scopes needed for this release.",
|
|
21
|
+
context=context,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
for tool in context.tools:
|
|
25
|
+
if _tool_requires_scope(tool) and not tool.auth.scopes:
|
|
26
|
+
findings.append(
|
|
27
|
+
tool_finding(
|
|
28
|
+
tool=tool,
|
|
29
|
+
check_id="SHIP-AUTH-MISSING-SCOPE",
|
|
30
|
+
title=f"{tool.name} lacks declared auth scopes",
|
|
31
|
+
severity="high",
|
|
32
|
+
category="auth",
|
|
33
|
+
evidence={"risk_tags": [hint.tag for hint in tool.risk_hints if hint.confidence in {"medium", "high"}]},
|
|
34
|
+
confidence="medium",
|
|
35
|
+
recommendation=f"Declare auth scopes for {tool.name} in OpenAPI, MCP metadata, or the manifest before release review.",
|
|
36
|
+
context=context,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
missing_scopes = [
|
|
40
|
+
scope
|
|
41
|
+
for scope in tool.auth.scopes
|
|
42
|
+
if not _scope_covered(scope, context.manifest.permissions.scopes)
|
|
43
|
+
]
|
|
44
|
+
if missing_scopes:
|
|
45
|
+
findings.append(
|
|
46
|
+
tool_finding(
|
|
47
|
+
tool=tool,
|
|
48
|
+
check_id="SHIP-AUTH-SCOPE-COVERAGE-MISSING",
|
|
49
|
+
title=f"{tool.name} requires scopes not declared in the manifest",
|
|
50
|
+
severity="high",
|
|
51
|
+
category="auth",
|
|
52
|
+
evidence={
|
|
53
|
+
"tool_scopes": tool.auth.scopes,
|
|
54
|
+
"manifest_scopes": context.manifest.permissions.scopes,
|
|
55
|
+
"missing_scopes": missing_scopes,
|
|
56
|
+
},
|
|
57
|
+
confidence="high",
|
|
58
|
+
recommendation=(
|
|
59
|
+
f"Add the required scopes for {tool.name} to permissions.scopes "
|
|
60
|
+
"or narrow the tool's declared auth requirements."
|
|
61
|
+
),
|
|
62
|
+
context=context,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
broad_scopes = [scope for scope in tool.auth.scopes if _is_broad_scope(scope)]
|
|
66
|
+
if broad_scopes:
|
|
67
|
+
findings.append(
|
|
68
|
+
tool_finding(
|
|
69
|
+
tool=tool,
|
|
70
|
+
check_id="SHIP-AUTH-TOOL-BROAD-SCOPE",
|
|
71
|
+
title=f"{tool.name} uses broad auth scopes",
|
|
72
|
+
severity="high",
|
|
73
|
+
category="auth",
|
|
74
|
+
evidence={"scopes": broad_scopes},
|
|
75
|
+
confidence="high",
|
|
76
|
+
recommendation=f"Replace broad scopes for {tool.name} with narrower operation-specific scopes.",
|
|
77
|
+
context=context,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return findings
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_broad_scope(scope: str) -> bool:
|
|
84
|
+
lowered = scope.lower()
|
|
85
|
+
return lowered in {"*", "admin"} or lowered.endswith(":*") or "write-all" in lowered or "admin" in lowered
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tool_requires_scope(tool) -> bool:
|
|
89
|
+
return is_write_tool(tool) or has_risk_tag(
|
|
90
|
+
tool,
|
|
91
|
+
{"sensitive_data_access"},
|
|
92
|
+
min_confidence="medium",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _scope_covered(required_scope: str, manifest_scopes: list[str]) -> bool:
|
|
97
|
+
required = required_scope.lower()
|
|
98
|
+
for declared_scope in manifest_scopes:
|
|
99
|
+
declared = declared_scope.lower()
|
|
100
|
+
if declared in {"*", required}:
|
|
101
|
+
return True
|
|
102
|
+
if declared.endswith(":*") and required.startswith(declared[:-1]):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agents_shipgate.core.context import ScanContext
|
|
6
|
+
from agents_shipgate.core.models import (
|
|
7
|
+
Finding,
|
|
8
|
+
SourceReference,
|
|
9
|
+
Tool,
|
|
10
|
+
parse_confidence,
|
|
11
|
+
parse_severity,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tool_finding(
|
|
16
|
+
*,
|
|
17
|
+
tool: Tool,
|
|
18
|
+
check_id: str,
|
|
19
|
+
title: str,
|
|
20
|
+
severity: str,
|
|
21
|
+
category: str,
|
|
22
|
+
evidence: dict[str, object],
|
|
23
|
+
confidence: str,
|
|
24
|
+
recommendation: str,
|
|
25
|
+
context: ScanContext,
|
|
26
|
+
) -> Finding:
|
|
27
|
+
return Finding(
|
|
28
|
+
check_id=check_id,
|
|
29
|
+
title=title,
|
|
30
|
+
severity=parse_severity(severity),
|
|
31
|
+
category=category,
|
|
32
|
+
tool_id=tool.id,
|
|
33
|
+
tool_name=tool.name,
|
|
34
|
+
agent_id=context.agent.id,
|
|
35
|
+
evidence=evidence,
|
|
36
|
+
confidence=parse_confidence(confidence),
|
|
37
|
+
source=SourceReference(
|
|
38
|
+
type=tool.source_type,
|
|
39
|
+
ref=tool.source_ref,
|
|
40
|
+
location=tool.source_location,
|
|
41
|
+
),
|
|
42
|
+
recommendation=recommendation,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def agent_finding(
|
|
47
|
+
*,
|
|
48
|
+
check_id: str,
|
|
49
|
+
title: str,
|
|
50
|
+
severity: str,
|
|
51
|
+
category: str,
|
|
52
|
+
evidence: dict[str, object],
|
|
53
|
+
confidence: str,
|
|
54
|
+
recommendation: str,
|
|
55
|
+
context: ScanContext,
|
|
56
|
+
) -> Finding:
|
|
57
|
+
return Finding(
|
|
58
|
+
check_id=check_id,
|
|
59
|
+
title=title,
|
|
60
|
+
severity=parse_severity(severity),
|
|
61
|
+
category=category,
|
|
62
|
+
agent_id=context.agent.id,
|
|
63
|
+
evidence=evidence,
|
|
64
|
+
confidence=parse_confidence(confidence),
|
|
65
|
+
source=SourceReference(type="manifest", ref=_manifest_ref(context.config_path)),
|
|
66
|
+
recommendation=recommendation,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _manifest_ref(config_path: Path) -> str:
|
|
71
|
+
return config_path.name
|