agentprint 0.1.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.
- agentprint/__init__.py +3 -0
- agentprint/advice.py +408 -0
- agentprint/agents/__init__.py +31 -0
- agentprint/agents/antigravity.py +162 -0
- agentprint/agents/base.py +225 -0
- agentprint/agents/claude_code.py +147 -0
- agentprint/agents/codex.py +126 -0
- agentprint/agents/copilot.py +233 -0
- agentprint/agents/cursor.py +171 -0
- agentprint/agents/gemini.py +131 -0
- agentprint/agents/hermes.py +144 -0
- agentprint/agents/opencode.py +114 -0
- agentprint/agents/scan_only.py +246 -0
- agentprint/agents/windsurf.py +106 -0
- agentprint/badge.py +53 -0
- agentprint/checks/__init__.py +28 -0
- agentprint/checks/ap001_history_secret.py +12 -0
- agentprint/checks/ap002_settings_secret.py +13 -0
- agentprint/checks/ap003_overbroad_permission.py +62 -0
- agentprint/checks/ap004_network_hook.py +93 -0
- agentprint/checks/ap005_mcp_inline_secret.py +80 -0
- agentprint/checks/ap006_package_leak.py +60 -0
- agentprint/checks/ap007_local_not_ignored.py +115 -0
- agentprint/checks/ap008_readable_by_others.py +223 -0
- agentprint/checks/ap009_source_exposure.py +505 -0
- agentprint/checks/base.py +89 -0
- agentprint/cli.py +828 -0
- agentprint/commands.py +467 -0
- agentprint/files.py +56 -0
- agentprint/fix.py +1381 -0
- agentprint/fixreport.py +257 -0
- agentprint/gitcheck.py +129 -0
- agentprint/guard.py +209 -0
- agentprint/hook.py +47 -0
- agentprint/install.py +635 -0
- agentprint/integrations/__init__.py +29 -0
- agentprint/integrations/antigravity.py +141 -0
- agentprint/integrations/base.py +483 -0
- agentprint/integrations/claude_code.py +115 -0
- agentprint/integrations/codex.py +142 -0
- agentprint/integrations/copilot.py +132 -0
- agentprint/integrations/cursor.py +150 -0
- agentprint/integrations/gemini.py +125 -0
- agentprint/integrations/hermes.py +176 -0
- agentprint/integrations/opencode.py +162 -0
- agentprint/integrations/windsurf.py +196 -0
- agentprint/mcp_server.py +86 -0
- agentprint/models.py +108 -0
- agentprint/pack.py +288 -0
- agentprint/report.py +134 -0
- agentprint/roster.py +59 -0
- agentprint/safewrite.py +44 -0
- agentprint/sanitize.py +63 -0
- agentprint/sarif.py +215 -0
- agentprint/scanner.py +40 -0
- agentprint/secrets.py +104 -0
- agentprint/sqlite_text.py +81 -0
- agentprint/structured.py +150 -0
- agentprint/ui/__init__.py +0 -0
- agentprint/ui/detect.py +96 -0
- agentprint/ui/fixview.py +239 -0
- agentprint/ui/layout.py +95 -0
- agentprint/ui/mascot.py +63 -0
- agentprint/ui/menu.py +361 -0
- agentprint/ui/results.py +102 -0
- agentprint/ui/sweep.py +59 -0
- agentprint/ui/theme.py +73 -0
- agentprint/ui/welcome.py +117 -0
- agentprint-0.1.0.dist-info/METADATA +124 -0
- agentprint-0.1.0.dist-info/RECORD +73 -0
- agentprint-0.1.0.dist-info/WHEEL +4 -0
- agentprint-0.1.0.dist-info/entry_points.txt +2 -0
- agentprint-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprint/__init__.py
ADDED
agentprint/advice.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""What to do about each finding, written for the AI agent that will do it.
|
|
2
|
+
|
|
3
|
+
`fix_steps` are actions an agent can perform itself (edit a file, add an ignore line, untrack a
|
|
4
|
+
file). `ask_human` is what only the human can do, such as rotating a key in a provider console.
|
|
5
|
+
|
|
6
|
+
Checks plug in here, not in the report code: register an adviser for a check id with
|
|
7
|
+
`@adviser("AP0xx")`. A check with no adviser falls back to its one-line `fix` hint.
|
|
8
|
+
Every value taken from scanned content goes through `sanitize.quote()`.
|
|
9
|
+
|
|
10
|
+
`advise(..., person=True)` words the same advice for a person reading `agentprint fix`'s plan:
|
|
11
|
+
without what is said only to an AI agent (NO_LEAK, "The agent cannot do this"), and with
|
|
12
|
+
rotating as the reader's own step. An adviser that takes no `person` argument is the same for
|
|
13
|
+
both.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
import shlex
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, field, replace
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
|
|
24
|
+
from .agents import ALL_AGENTS
|
|
25
|
+
from .checks import ap009_source_exposure as ap009
|
|
26
|
+
from .checks.ap004_network_hook import ASK_FIRST as HOOK_ASK_FIRST
|
|
27
|
+
from .checks.ap005_mcp_inline_secret import _MARKER as MCP_MARKER
|
|
28
|
+
from .checks.ap005_mcp_inline_secret import ASK_FIRST as MCP_ASK_FIRST
|
|
29
|
+
from .models import Finding, Severity
|
|
30
|
+
from .sanitize import clean, quote, safe_step
|
|
31
|
+
from .secrets import RULES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Moment(StrEnum):
|
|
35
|
+
"""What the user is about to do; it changes which fix applies."""
|
|
36
|
+
|
|
37
|
+
scan = "scan" # a general check of this machine or project
|
|
38
|
+
publish = "publish" # about to publish a package
|
|
39
|
+
push = "push" # about to git push
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Advice:
|
|
44
|
+
fix_steps: list[str] = field(default_factory=list)
|
|
45
|
+
ask_human: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# (finding, moment) -> Advice; an adviser may also take `person` (a keyword, default False).
|
|
49
|
+
Adviser = Callable[..., Advice]
|
|
50
|
+
ADVISERS: dict[str, Adviser] = {}
|
|
51
|
+
|
|
52
|
+
# Where to revoke and replace each kind of key. Checked 2026-11-05 against:
|
|
53
|
+
# anthropic https://platform.claude.com/docs/en/get-api-key ("API keys" page link)
|
|
54
|
+
# openai platform.openai.com/api-keys is the dashboard's keys page (OpenAI's help centre
|
|
55
|
+
# pages were not fetchable; matches the dashboard's own navigation)
|
|
56
|
+
# github https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/
|
|
57
|
+
# managing-your-personal-access-tokens (Settings > Developer settings > Tokens)
|
|
58
|
+
# aws https://docs.aws.amazon.com/IAM/latest/UserGuide/id-credentials-access-keys-update.html
|
|
59
|
+
# stripe https://docs.stripe.com/keys ("Rotate an API key" on the API keys page)
|
|
60
|
+
# slack https://docs.slack.dev/authentication/best-practices-for-security (api.slack.com/apps)
|
|
61
|
+
# google https://docs.cloud.google.com/docs/authentication/api-keys (Credentials page)
|
|
62
|
+
ROTATE_AT: dict[str, str] = {
|
|
63
|
+
"anthropic": "https://platform.claude.com/settings/keys",
|
|
64
|
+
"openai": "https://platform.openai.com/api-keys",
|
|
65
|
+
"github": "https://github.com/settings/tokens",
|
|
66
|
+
"aws": "https://console.aws.amazon.com/iam/",
|
|
67
|
+
"stripe": "https://dashboard.stripe.com/apikeys",
|
|
68
|
+
"slack": "https://api.slack.com/apps",
|
|
69
|
+
"google": "https://console.cloud.google.com/apis/credentials",
|
|
70
|
+
}
|
|
71
|
+
_RULE_BY_NAME = {rule.name: rule.id for rule in RULES}
|
|
72
|
+
_PACKAGE_SUFFIXES = (".tgz", ".tar.gz", ".whl", ".zip")
|
|
73
|
+
NO_LEAK = "Never print, echo, copy or reuse the key's value while doing this."
|
|
74
|
+
# How the person-worded AP004 step starts instead of the check's own (agent) sentence.
|
|
75
|
+
HOOK_PERSON_FIRST = "If you did not add this hook, delete it."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def adviser(check_id: str) -> Callable[[Adviser], Adviser]:
|
|
79
|
+
def register(fn: Adviser) -> Adviser:
|
|
80
|
+
ADVISERS[check_id] = fn
|
|
81
|
+
return fn
|
|
82
|
+
|
|
83
|
+
return register
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def advise(finding: Finding, moment: Moment, *, person: bool = False) -> Advice:
|
|
87
|
+
"""Fix steps and ask_human items, each shown whole and unchanged or replaced by SAFE_STEP.
|
|
88
|
+
A check's fix can embed shell-quoted scanned paths: shortening or cleaning it could break
|
|
89
|
+
the quoting and run part of a hostile file name, so it is never edited.
|
|
90
|
+
|
|
91
|
+
`person`: worded for a person who reads the steps and does them (agentprint fix's human
|
|
92
|
+
view), not for an AI agent. The agent wording (the default) is what every report shows."""
|
|
93
|
+
fn = ADVISERS.get(finding.check_id)
|
|
94
|
+
if fn is None:
|
|
95
|
+
raw = Advice([finding.fix], [])
|
|
96
|
+
elif person and _takes_person(fn):
|
|
97
|
+
raw = fn(finding, moment, person=True)
|
|
98
|
+
else:
|
|
99
|
+
raw = fn(finding, moment)
|
|
100
|
+
return Advice(
|
|
101
|
+
[safe_step(step) for step in raw.fix_steps],
|
|
102
|
+
[safe_step(step) for step in raw.ask_human],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---- helpers ---------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _takes_person(fn: Adviser) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
return "person" in inspect.signature(fn).parameters
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def split_location(location: str) -> tuple[str | None, str]:
|
|
117
|
+
"""'pkg-1.0.tgz!package/x' -> ('pkg-1.0.tgz', 'package/x'); a plain path -> (None, path)."""
|
|
118
|
+
head, sep, tail = location.partition("!")
|
|
119
|
+
if sep and head.endswith(_PACKAGE_SUFFIXES):
|
|
120
|
+
return head, tail
|
|
121
|
+
return None, location
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def agent_path(member: str) -> str:
|
|
125
|
+
"""The agent-owned part of a path: 'package/sub/.claude/x.json' -> '.claude/x.json'."""
|
|
126
|
+
for agent in ALL_AGENTS:
|
|
127
|
+
for rule in agent.package_rules:
|
|
128
|
+
match = rule.pattern.search(member)
|
|
129
|
+
if match:
|
|
130
|
+
return member[match.start() :].lstrip("/")
|
|
131
|
+
return member.rsplit("/", 1)[-1]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _ignore_line(path: str) -> str:
|
|
135
|
+
"""Ignore the whole agent folder ('.claude/'), or the single file if it's top level."""
|
|
136
|
+
first, sep, _ = path.partition("/")
|
|
137
|
+
return first + "/" if sep else path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _where(finding: Finding) -> str:
|
|
141
|
+
line = f" line {finding.line}" if finding.line else ""
|
|
142
|
+
return f"{quote(finding.location)}{line}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rotate_text(finding: Finding, *, person: bool = False) -> str:
|
|
146
|
+
"""Rotate this finding's key, and where. For an AI agent it says the agent cannot do this;
|
|
147
|
+
for a person (`person`) it is simply their step."""
|
|
148
|
+
name, _, shown = finding.detail.partition(": ")
|
|
149
|
+
rule_id = _RULE_BY_NAME.get(name)
|
|
150
|
+
url = ROTATE_AT.get(rule_id or "")
|
|
151
|
+
key = f"the {clean(name)} that starts {quote(shown)}"
|
|
152
|
+
if person:
|
|
153
|
+
tail = "Treat the old key as public."
|
|
154
|
+
else:
|
|
155
|
+
tail = "The agent cannot do this; treat the old key as public."
|
|
156
|
+
if url:
|
|
157
|
+
return f"Rotate {key}: revoke it and create a new one at {url}. {tail}"
|
|
158
|
+
return f"Rotate {key} with the service that issued it (agentprint can't tell which). {tail}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _rotate(finding: Finding, person: bool = False) -> list[str]:
|
|
162
|
+
return [rotate_text(finding, person=person)]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _no_leak(step: str, person: bool) -> str:
|
|
166
|
+
"""A step that handles a key: an AI agent is also told never to show or reuse it."""
|
|
167
|
+
return step if person else f"{step} {NO_LEAK}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- advisers --------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@adviser("AP001")
|
|
174
|
+
def _history_secret(finding: Finding, moment: Moment, person: bool = False) -> Advice:
|
|
175
|
+
return Advice(
|
|
176
|
+
[
|
|
177
|
+
_no_leak(
|
|
178
|
+
f"In {_where(finding)}, replace the key with the text REDACTED, or delete the "
|
|
179
|
+
"file if that history is not needed.",
|
|
180
|
+
person,
|
|
181
|
+
),
|
|
182
|
+
],
|
|
183
|
+
_rotate(finding, person),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@adviser("AP002")
|
|
188
|
+
def _settings_secret(finding: Finding, moment: Moment, person: bool = False) -> Advice:
|
|
189
|
+
package, member = split_location(finding.location)
|
|
190
|
+
if package is not None:
|
|
191
|
+
source = quote(agent_path(member))
|
|
192
|
+
steps = [
|
|
193
|
+
_no_leak(
|
|
194
|
+
f"Delete the key from the source file {source} in the project (line "
|
|
195
|
+
f"{finding.line}); the package copy is built from it. Load the key from an "
|
|
196
|
+
"environment variable instead.",
|
|
197
|
+
person,
|
|
198
|
+
),
|
|
199
|
+
"Also keep that file out of the package (see the matching AP006 finding).",
|
|
200
|
+
]
|
|
201
|
+
elif moment is Moment.push:
|
|
202
|
+
steps = [
|
|
203
|
+
_no_leak(
|
|
204
|
+
f"Delete the key from {_where(finding)}, load it from an environment variable "
|
|
205
|
+
"instead, and commit the change.",
|
|
206
|
+
person,
|
|
207
|
+
),
|
|
208
|
+
"The key stays in earlier commits, so rotating it (ask_human) is what makes it safe.",
|
|
209
|
+
]
|
|
210
|
+
else:
|
|
211
|
+
steps = [
|
|
212
|
+
_no_leak(
|
|
213
|
+
f"Delete the key from {_where(finding)} and load it from an environment variable "
|
|
214
|
+
"instead (set it in your shell profile or a secret manager).",
|
|
215
|
+
person,
|
|
216
|
+
),
|
|
217
|
+
]
|
|
218
|
+
return Advice(steps, _rotate(finding, person))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@adviser("AP004")
|
|
222
|
+
def _network_hook(finding: Finding, moment: Moment, person: bool = False) -> Advice:
|
|
223
|
+
# The check's own fix is the step. It asks the human first, which a person reads as their
|
|
224
|
+
# own question: "If you did not add this hook, delete it."
|
|
225
|
+
if person and HOOK_ASK_FIRST in finding.fix:
|
|
226
|
+
return Advice([finding.fix.replace(HOOK_ASK_FIRST, HOOK_PERSON_FIRST, 1)])
|
|
227
|
+
return Advice([finding.fix])
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@adviser("AP005")
|
|
231
|
+
def _mcp_inline_secret(finding: Finding, moment: Moment, person: bool = False) -> Advice:
|
|
232
|
+
# The check's own fix already names this agent's way to read the key from the environment.
|
|
233
|
+
# It starts by telling the agent to ask the human to rotate; a person gets rotating as a
|
|
234
|
+
# step of its own, after it.
|
|
235
|
+
key_part = finding.detail.partition(MCP_MARKER)[0]
|
|
236
|
+
step = finding.fix
|
|
237
|
+
if person and step.startswith(MCP_ASK_FIRST):
|
|
238
|
+
rest = step.removeprefix(MCP_ASK_FIRST)
|
|
239
|
+
step = rest[:1].upper() + rest[1:]
|
|
240
|
+
return Advice([step], _rotate(replace(finding, detail=key_part), person))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@adviser("AP006")
|
|
244
|
+
def _agent_file_shipped(finding: Finding, moment: Moment) -> Advice:
|
|
245
|
+
package, member = split_location(finding.location)
|
|
246
|
+
path = agent_path(member)
|
|
247
|
+
if package is None or moment is Moment.push:
|
|
248
|
+
if clean(member) == member: # the real path, shown whole: safe to put in a command
|
|
249
|
+
untrack = f"git rm --cached -- {shlex.quote(member)}"
|
|
250
|
+
else: # a cleaned or shortened path would name some other file
|
|
251
|
+
untrack = "run git rm --cached on that file (it keeps your local copy)"
|
|
252
|
+
return Advice(
|
|
253
|
+
[
|
|
254
|
+
f"Stop tracking {quote(member)} but keep the local file: {untrack}",
|
|
255
|
+
f"Add the line {quote(path)} to .gitignore, commit both changes, then push again.",
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
exclude = quote(_ignore_line(path))
|
|
259
|
+
steps: list[str] = []
|
|
260
|
+
if package.endswith(".tgz"):
|
|
261
|
+
steps.append(
|
|
262
|
+
f"Add the line {exclude} to .npmignore in the package folder. If .npmignore does not "
|
|
263
|
+
"exist yet, first copy .gitignore to .npmignore: npm stops reading .gitignore once "
|
|
264
|
+
'.npmignore exists. If package.json has a "files" list, make sure no entry in it '
|
|
265
|
+
"includes this path."
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
steps.append(
|
|
269
|
+
f"Exclude {exclude} from the package in your build config: hatchling "
|
|
270
|
+
"[tool.hatch.build.targets.sdist] and .wheel exclude = [...], setuptools "
|
|
271
|
+
"MANIFEST.in prune/exclude, poetry exclude = [...], flit [tool.flit.sdist] exclude."
|
|
272
|
+
)
|
|
273
|
+
steps.append(f"Add the line {quote(path)} to .gitignore so it is never committed either.")
|
|
274
|
+
return Advice(steps)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@adviser("AP007")
|
|
278
|
+
def _agent_file_not_ignored(finding: Finding, moment: Moment) -> Advice:
|
|
279
|
+
path = agent_path(split_location(finding.location)[1])
|
|
280
|
+
return Advice([f"Add the line {quote(path)} to .gitignore (it holds personal agent data)."])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@adviser("AP008")
|
|
284
|
+
def _readable_by_others(finding: Finding, moment: Moment) -> Advice:
|
|
285
|
+
# The check's fix is already the exact, shell-quoted chmod for these files.
|
|
286
|
+
ask = []
|
|
287
|
+
if finding.severity >= Severity.HIGH:
|
|
288
|
+
ask.append(
|
|
289
|
+
"If other people or programs use accounts on this computer, treat the keys in "
|
|
290
|
+
f"{_where(finding)} as exposed and rotate them with the services that issued them."
|
|
291
|
+
)
|
|
292
|
+
return Advice([finding.fix], ask)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
OPEN_SOURCE_ASK = (
|
|
296
|
+
"Decide whether this package is meant to be open source; if it is, give it an open-source "
|
|
297
|
+
"licence (an SPDX id such as MIT) and the medium and high prints go away."
|
|
298
|
+
)
|
|
299
|
+
SPDX_ASK = (
|
|
300
|
+
"If it is open source, use an SPDX licence id (such as MIT or Apache-2.0) as the licence, "
|
|
301
|
+
"or as one OR choice next to the custom one."
|
|
302
|
+
)
|
|
303
|
+
GUESS_ASK = "Decide whether this package is meant to be open source."
|
|
304
|
+
# When "closed source" is only a guess (no licence found, or not readable): how to state one.
|
|
305
|
+
_STATE_LICENCE = {
|
|
306
|
+
ap009.NPM_MISSING: 'add a license field (for example "license": "MIT") to package.json',
|
|
307
|
+
ap009.PYPI_MISSING: (
|
|
308
|
+
'add its licence to the package metadata (for example license = "MIT" in the [project] '
|
|
309
|
+
"table of pyproject.toml)"
|
|
310
|
+
),
|
|
311
|
+
}
|
|
312
|
+
_STATE_ANY = (
|
|
313
|
+
'make sure it states its licence (a "license" field in package.json, or license in the '
|
|
314
|
+
"[project] table of pyproject.toml)"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@adviser("AP009")
|
|
319
|
+
def _rebuildable_source(finding: Finding, moment: Moment, person: bool = False) -> Advice:
|
|
320
|
+
# The check's fix for each kind names the build settings (tsc sourceMap, esbuild
|
|
321
|
+
# --sources-content, Vite build.sourcemap, webpack devtool). Nothing here edits a build
|
|
322
|
+
# config. Only the check's own words in the detail are read: the example path after them
|
|
323
|
+
# comes from the package.
|
|
324
|
+
step = finding.fix if finding.fix in ap009.FIXES else ap009.MAP_FIX
|
|
325
|
+
why = ap009.reason(finding.detail)
|
|
326
|
+
if ap009.PRIVATE in why or finding.severity is Severity.LOW:
|
|
327
|
+
# An open-source package's maps, or a private registry: a note, and its fix (already
|
|
328
|
+
# worded as optional by the check) is the whole advice. Nothing to ask the human.
|
|
329
|
+
return Advice([step])
|
|
330
|
+
if any(guess in why for guess in ap009.GUESSED):
|
|
331
|
+
# "Closed" is a guess: a licence may be the whole fix, so it comes before the build.
|
|
332
|
+
how = next((h for g, h in _STATE_LICENCE.items() if g in why), _STATE_ANY)
|
|
333
|
+
first = (
|
|
334
|
+
f"If this package is open source, {how}: that is the whole fix, and the medium and "
|
|
335
|
+
"high prints go away."
|
|
336
|
+
)
|
|
337
|
+
if not person:
|
|
338
|
+
first += " If you cannot tell, ask the human before changing the build."
|
|
339
|
+
steps = [first, f"If it is not open source: {step[0].lower()}{step[1:]}"]
|
|
340
|
+
return Advice(steps, [GUESS_ASK])
|
|
341
|
+
ask = [OPEN_SOURCE_ASK]
|
|
342
|
+
if ap009.CUSTOM_ID in why:
|
|
343
|
+
ask.append(SPDX_ASK)
|
|
344
|
+
return Advice([step], ask)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---- explanations (agentprint mcp: explain_finding) ------------------------------------------
|
|
348
|
+
|
|
349
|
+
EXPLAIN: dict[str, str] = {
|
|
350
|
+
"AP001": (
|
|
351
|
+
"AP001 Secret in agent history (high). An API key sits in a file where an AI agent keeps "
|
|
352
|
+
"its chat history, transcripts, pasted text or backups of files it edited. Those files "
|
|
353
|
+
"are copied by backups and sync tools, read by other programs, and sometimes published "
|
|
354
|
+
"by accident, so the key must be treated as exposed. Fix: the human rotates the key at "
|
|
355
|
+
"the provider; the agent replaces the key in that file with REDACTED or deletes the file."
|
|
356
|
+
),
|
|
357
|
+
"AP002": (
|
|
358
|
+
"AP002 Secret in agent settings (critical). An API key is written into an agent's "
|
|
359
|
+
"settings, permission allowlist or MCP config. These files are read on every start, often "
|
|
360
|
+
"shared, and can end up in a git commit or a published package. Fix: Rotate the key with "
|
|
361
|
+
"its provider (the human must do this), remove it from the file, and load it from an "
|
|
362
|
+
"environment variable instead."
|
|
363
|
+
),
|
|
364
|
+
"AP006": (
|
|
365
|
+
"AP006 Agent file would ship in package (high/medium). A file an AI agent writes for "
|
|
366
|
+
"itself (personal allowlist, conversation log, local notes, login token) is inside the "
|
|
367
|
+
"package you are about to publish, where anyone can download it. Fix: exclude it "
|
|
368
|
+
"(.npmignore or the package.json files list for npm; the build tool's exclude list for "
|
|
369
|
+
"Python), add it to .gitignore, and check again. Rotate any key it held."
|
|
370
|
+
),
|
|
371
|
+
"AP009": (
|
|
372
|
+
"AP009 Package lets an AI rebuild your source (high/medium/low). The package you are "
|
|
373
|
+
"about to publish holds source maps with your original source, source maps that give "
|
|
374
|
+
"away your file layout, original .ts/.vue/.svelte source next to the compiled files, or "
|
|
375
|
+
"AI instruction files. Nobody can stop reverse engineering of code you ship; this shows "
|
|
376
|
+
"what hands an AI your source or a map of it. Severity depends on the licence. Closed "
|
|
377
|
+
"source (high and medium prints): no licence, UNLICENSED, SEE LICENSE IN, a proprietary "
|
|
378
|
+
"marker, or an SPDX expression whose every OR choice is a custom LicenseRef id. Open "
|
|
379
|
+
"source: only maps that hold the full original source are mentioned, as a low note; "
|
|
380
|
+
"nothing is wrong, and sourcesContent can be turned off if that was not meant. A package "
|
|
381
|
+
"whose package.json publishConfig.registry is a private registry gets low notes only. "
|
|
382
|
+
"Fix for closed source: turn off source maps or their sourcesContent in the build (tsc "
|
|
383
|
+
"sourceMap/inlineSources, esbuild --sources-content=false, Vite build.sourcemap, webpack "
|
|
384
|
+
"devtool), or keep the files out with .npmignore, the package.json files list or the "
|
|
385
|
+
"Python build tool's exclude list. When no licence was found, ask the human first: if "
|
|
386
|
+
"the package is open source, adding a licence (an SPDX id such as MIT) is the whole fix "
|
|
387
|
+
"and the medium and high prints go away."
|
|
388
|
+
),
|
|
389
|
+
"AP007": (
|
|
390
|
+
"AP007 Personal agent file not gitignored (low). A personal agent file is neither "
|
|
391
|
+
"committed nor in .gitignore, so the next `git add .` would commit it. Fix: add it to "
|
|
392
|
+
".gitignore."
|
|
393
|
+
),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def explain(check_id: str) -> str:
|
|
398
|
+
check_id = clean(check_id).strip().upper()[:20]
|
|
399
|
+
if check_id in EXPLAIN:
|
|
400
|
+
return EXPLAIN[check_id]
|
|
401
|
+
from .checks import SCAN_CHECKS # checks added later explain themselves via title + fix
|
|
402
|
+
|
|
403
|
+
for check in SCAN_CHECKS:
|
|
404
|
+
if getattr(check, "id", None) == check_id:
|
|
405
|
+
severity = getattr(getattr(check, "severity", None), "name", "").lower()
|
|
406
|
+
return f"{check_id} {check.title} ({severity}). Fix: {check.fix}"
|
|
407
|
+
known = ", ".join(sorted(EXPLAIN))
|
|
408
|
+
return f"agentprint has no check called {quote(check_id)}. Known checks: {known}."
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Registry of supported agents. Add a new agent here; checks pick it up automatically."""
|
|
2
|
+
|
|
3
|
+
from .antigravity import Antigravity
|
|
4
|
+
from .base import Agent
|
|
5
|
+
from .claude_code import ClaudeCode
|
|
6
|
+
from .codex import Codex
|
|
7
|
+
from .copilot import Copilot
|
|
8
|
+
from .cursor import Cursor
|
|
9
|
+
from .gemini import Gemini
|
|
10
|
+
from .hermes import Hermes
|
|
11
|
+
from .opencode import OpenCode
|
|
12
|
+
from .scan_only import SCAN_ONLY_AGENTS
|
|
13
|
+
from .windsurf import Windsurf
|
|
14
|
+
|
|
15
|
+
# Fully supported agents first, then agents known only by their package and project files.
|
|
16
|
+
ALL_AGENTS: tuple[Agent, ...] = (
|
|
17
|
+
ClaudeCode(),
|
|
18
|
+
Codex(),
|
|
19
|
+
Cursor(),
|
|
20
|
+
Gemini(),
|
|
21
|
+
Windsurf(),
|
|
22
|
+
OpenCode(),
|
|
23
|
+
Copilot(),
|
|
24
|
+
Hermes(),
|
|
25
|
+
Antigravity(),
|
|
26
|
+
*SCAN_ONLY_AGENTS,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def by_name(name: str) -> Agent | None:
|
|
31
|
+
return next((agent for agent in ALL_AGENTS if agent.name == name), None)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Google Antigravity: the desktop app, the `agy` CLI and the IDE (Gemini CLI's successor for
|
|
2
|
+
individual users since 2026-06-18).
|
|
3
|
+
|
|
4
|
+
Layout verified on macOS on 2026-11-05 (names only): ~/.gemini/antigravity/{brain/<id>/
|
|
5
|
+
(task.md, implementation_plan.md, browser/, .system_generated/), conversations/*.pb, annotations/,
|
|
6
|
+
knowledge/, mcp_config.json (empty)}. ~/.gemini/config/ and ~/.gemini/antigravity-cli/ are not
|
|
7
|
+
present yet on the verification Mac; their names come from the docs:
|
|
8
|
+
~/.gemini/config/hooks.json ({<hook set>: {<event>: [{matcher, hooks: [{type, command}]}]}}),
|
|
9
|
+
~/.gemini/config/mcp_config.json (mcpServers.<name>.{command,args,env,serverUrl,headers}),
|
|
10
|
+
<app_data_dir>/brain/<conversation>/.system_generated/logs/transcript.jsonl for app_data_dir
|
|
11
|
+
~/.gemini/antigravity, antigravity-cli, antigravity-ide; ~/.gemini/antigravity-cli/settings.json
|
|
12
|
+
(toolPermission, permissions.allow); ~/.gemini/antigravity/mcp_oauth_tokens.json (plain file).
|
|
13
|
+
Workspace files live in .agents/.
|
|
14
|
+
Docs: https://antigravity.google/docs/hooks/, https://antigravity.google/docs/mcp,
|
|
15
|
+
https://antigravity.google/docs/permissions/, https://antigravity.google/docs/settings/.
|
|
16
|
+
|
|
17
|
+
Gemini CLI keeps ~/.gemini/settings.json and ~/.gemini/tmp/ (see gemini.py); nothing here
|
|
18
|
+
overlaps. Not covered: conversations/*.pb (binary protobuf). Global instruction files
|
|
19
|
+
(~/.gemini/AGENTS.md, GEMINI.md) are not scanned, as for every other agent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..models import AgentConfig, Artifact, Grant, HookCommand, Kind, PackageRule, Severity
|
|
30
|
+
from ..structured import load
|
|
31
|
+
from .base import (
|
|
32
|
+
artifacts,
|
|
33
|
+
dig,
|
|
34
|
+
from_home,
|
|
35
|
+
gemini_folder,
|
|
36
|
+
hook_commands,
|
|
37
|
+
is_blanket_command,
|
|
38
|
+
matches_any,
|
|
39
|
+
mcp_servers,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
NAME = "antigravity"
|
|
43
|
+
|
|
44
|
+
_APP_DIRS = ("antigravity", "antigravity-cli", "antigravity-ide")
|
|
45
|
+
_COMMAND_RULE = re.compile(r"command\((.*)\)", re.DOTALL)
|
|
46
|
+
_REGEX_ALL = re.compile(r"regex:\^?\.[*+]\$?")
|
|
47
|
+
_TOOL_FIX = (
|
|
48
|
+
'Set "toolPermission": "request-review" in ~/.gemini/antigravity-cli/settings.json; use '
|
|
49
|
+
"--dangerously-skip-permissions only for a one-off run inside a container or VM."
|
|
50
|
+
)
|
|
51
|
+
_ALLOW_FIX = (
|
|
52
|
+
"Delete this entry from permissions.allow in ~/.gemini/antigravity-cli/settings.json and "
|
|
53
|
+
'allow exact command prefixes instead, e.g. "command(git status)".'
|
|
54
|
+
)
|
|
55
|
+
_SECRET_FIX = (
|
|
56
|
+
"keep this server only in ~/.gemini/config/mcp_config.json (never in a workspace "
|
|
57
|
+
".agents/mcp_config.json), make that file private with chmod 600 "
|
|
58
|
+
"~/.gemini/config/mcp_config.json, and put the new key there: Antigravity documents no "
|
|
59
|
+
"environment-variable expansion for mcp_config.json, so <VAR> cannot be read from your shell."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _grants(data: dict[str, Any]) -> tuple[Grant, ...]:
|
|
64
|
+
grants: list[Grant] = []
|
|
65
|
+
if data.get("toolPermission") == "always-proceed":
|
|
66
|
+
grants.append(Grant("toolPermission", "always-proceed", _TOOL_FIX))
|
|
67
|
+
allow = dig(data, "permissions", "allow")
|
|
68
|
+
for rule in allow if isinstance(allow, list) else []:
|
|
69
|
+
if not isinstance(rule, str):
|
|
70
|
+
continue
|
|
71
|
+
rule_text = rule.strip()
|
|
72
|
+
command = _COMMAND_RULE.fullmatch(rule_text)
|
|
73
|
+
# command(x) matches command prefixes word by word, so command(bash) runs anything.
|
|
74
|
+
if command and (
|
|
75
|
+
_REGEX_ALL.fullmatch(command.group(1).strip())
|
|
76
|
+
or is_blanket_command(command.group(1), prefix=True)
|
|
77
|
+
):
|
|
78
|
+
grants.append(Grant("permissions.allow", rule_text, _ALLOW_FIX))
|
|
79
|
+
return tuple(grants)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _hooks(data: dict[str, Any]) -> tuple[HookCommand, ...]:
|
|
83
|
+
"""hooks.json is keyed by hook set, then by event; accept bare events too. A set with
|
|
84
|
+
"enabled": false never runs, so its commands are skipped."""
|
|
85
|
+
found = list(hook_commands(data))
|
|
86
|
+
for value in data.values():
|
|
87
|
+
if isinstance(value, dict) and value.get("enabled") is not False:
|
|
88
|
+
found += hook_commands(value)
|
|
89
|
+
return tuple(found)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# History that is only a record (fix masks keys there): the transcripts each conversation keeps
|
|
93
|
+
# under brain/<id>/.system_generated/logs/. The rest of brain/ (the task lists and plans the
|
|
94
|
+
# agent writes and re-reads) and knowledge/ (saved knowledge it reads back) are not.
|
|
95
|
+
_RECORDS = (
|
|
96
|
+
re.compile(r"(^|/)\.gemini/antigravity(-cli|-ide)?/brain/[^/]+/\.system_generated/logs/"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Antigravity:
|
|
101
|
+
name = NAME
|
|
102
|
+
package_rules = (
|
|
103
|
+
PackageRule(
|
|
104
|
+
NAME,
|
|
105
|
+
re.compile(r"(^|/)\.gemini/antigravity(-cli|-ide)?/"),
|
|
106
|
+
Severity.HIGH,
|
|
107
|
+
"Google Antigravity data: conversation transcripts, artifacts and MCP OAuth tokens.",
|
|
108
|
+
),
|
|
109
|
+
PackageRule(
|
|
110
|
+
NAME,
|
|
111
|
+
re.compile(r"(^|/)\.gemini/config/(mcp_config|hooks)\.json$"),
|
|
112
|
+
Severity.MEDIUM,
|
|
113
|
+
"Personal Antigravity MCP or hook config; may hold server tokens.",
|
|
114
|
+
),
|
|
115
|
+
PackageRule(
|
|
116
|
+
NAME,
|
|
117
|
+
re.compile(r"(^|/)\.agents/mcp_config\.json$"),
|
|
118
|
+
Severity.LOW,
|
|
119
|
+
"Workspace MCP config; rarely meant for a package (keys inside are reported "
|
|
120
|
+
"separately).",
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@from_home
|
|
125
|
+
def discover_home(self, home: Path) -> Iterator[Artifact]:
|
|
126
|
+
gemini = gemini_folder(home) # honours GEMINI_CLI_HOME
|
|
127
|
+
apps = [gemini / name for name in _APP_DIRS]
|
|
128
|
+
yield from artifacts(
|
|
129
|
+
NAME,
|
|
130
|
+
Kind.SETTINGS,
|
|
131
|
+
gemini / "config" / "hooks.json",
|
|
132
|
+
gemini / "config" / "mcp_config.json",
|
|
133
|
+
gemini / "antigravity" / "mcp_config.json", # older app versions
|
|
134
|
+
gemini / "antigravity-cli" / "settings.json",
|
|
135
|
+
)
|
|
136
|
+
# brain/: transcripts and the artifacts the agent writes; knowledge/: saved knowledge.
|
|
137
|
+
yield from artifacts(
|
|
138
|
+
NAME, Kind.HISTORY, *(app / name for app in apps for name in ("brain", "knowledge"))
|
|
139
|
+
)
|
|
140
|
+
yield from artifacts(
|
|
141
|
+
NAME, Kind.CREDENTIALS, *(app / "mcp_oauth_tokens.json" for app in apps)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def discover_project(self, root: Path) -> Iterator[Artifact]:
|
|
145
|
+
agents = root / ".agents"
|
|
146
|
+
yield from artifacts(NAME, Kind.SETTINGS, agents / "hooks.json", agents / "mcp_config.json")
|
|
147
|
+
|
|
148
|
+
def pure_history(self, path: Path) -> bool:
|
|
149
|
+
return matches_any(path, _RECORDS)
|
|
150
|
+
|
|
151
|
+
def read_config(self, artifact: Artifact) -> AgentConfig:
|
|
152
|
+
name = artifact.path.name
|
|
153
|
+
if name not in ("hooks.json", "mcp_config.json", "settings.json"):
|
|
154
|
+
return AgentConfig()
|
|
155
|
+
data = load(artifact.path)
|
|
156
|
+
if not isinstance(data, dict):
|
|
157
|
+
return AgentConfig()
|
|
158
|
+
if name == "hooks.json":
|
|
159
|
+
return AgentConfig(hooks=_hooks(data))
|
|
160
|
+
if name == "mcp_config.json":
|
|
161
|
+
return AgentConfig(mcp_servers=mcp_servers(data.get("mcpServers"), _SECRET_FIX))
|
|
162
|
+
return AgentConfig(grants=_grants(data))
|