skilldrop-cli 0.16.34 → 0.16.36

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.
Files changed (212) hide show
  1. package/README.md +1 -0
  2. package/agents/README.md +4 -1
  3. package/agents/docs-reviewer.md +47 -0
  4. package/agents/finding-adjudicator.md +49 -0
  5. package/agents/test-reviewer.md +52 -0
  6. package/catalogue.json +17 -7
  7. package/model-routing.json +40 -0
  8. package/package.json +1 -1
  9. package/packs/ai-engineering/skills/agent-config-review/SKILL.md +116 -0
  10. package/packs/ai-engineering/skills/agent-config-review/evals/eval_queries.json +12 -0
  11. package/packs/ai-engineering/skills/agent-config-review/evals/evals.json +75 -0
  12. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/db-migrator.md +8 -0
  13. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/agents/reviewer.md +9 -0
  14. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.json +48 -0
  15. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.claude/settings.local.json +5 -0
  16. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/.mcp.json +22 -0
  17. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/package.json +12 -0
  18. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/.cursor/mcp.json +17 -0
  19. package/packs/ai-engineering/skills/agent-config-review/evals/files/2/config.toml +21 -0
  20. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/my-user-settings.json +11 -0
  21. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/agents/release-notes.md +13 -0
  22. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.claude/settings.json +39 -0
  23. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/.mcp.json +18 -0
  24. package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/package.json +14 -0
  25. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/.github/workflows/codex-triage.yml +29 -0
  26. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/AGENTS.md +16 -0
  27. package/packs/ai-engineering/skills/agent-config-review/evals/files/5/codex/config.toml +34 -0
  28. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.claude/settings.json +22 -0
  29. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/.mcp.json +9 -0
  30. package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +81 -0
  31. package/packs/ai-engineering/skills/agent-config-review/manifest.json +60 -0
  32. package/packs/ai-engineering/skills/agent-config-review/reference.md +205 -0
  33. package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +989 -0
  34. package/packs/ai-engineering/skills/agent-incident-response/SKILL.md +175 -0
  35. package/packs/ai-engineering/skills/agent-incident-response/evals/eval_queries.json +14 -0
  36. package/packs/ai-engineering/skills/agent-incident-response/evals/evals.json +107 -0
  37. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/agent-session-excerpt.txt +11 -0
  38. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/ci-build-log.txt +24 -0
  39. package/packs/ai-engineering/skills/agent-incident-response/evals/files/1/incident-notes.md +19 -0
  40. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/mcp-config.json +20 -0
  41. package/packs/ai-engineering/skills/agent-incident-response/evals/files/2/session-transcript.txt +8 -0
  42. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/agent-session.log +70 -0
  43. package/packs/ai-engineering/skills/agent-incident-response/evals/files/4/infra/staging-db.tf +25 -0
  44. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/job.yaml +19 -0
  45. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/jobs/nightly-triage/run.log +13 -0
  46. package/packs/ai-engineering/skills/agent-incident-response/evals/files/5/usage-export-2026-10-20.csv +13 -0
  47. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/.mcp.json +20 -0
  48. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/git-log.txt +12 -0
  49. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync-excerpt.txt +30 -0
  50. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/notesync.log +15 -0
  51. package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/ssh-config.txt +16 -0
  52. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/.github/workflows/ci.yml +28 -0
  53. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/SECURITY.md +8 -0
  54. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/issue-479.md +35 -0
  55. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/package.json +15 -0
  56. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/pr-482.diff +20 -0
  57. package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/run-log.txt +30 -0
  58. package/packs/ai-engineering/skills/agent-incident-response/examples/injected-issue-comment.md +57 -0
  59. package/packs/ai-engineering/skills/agent-incident-response/manifest.json +55 -0
  60. package/packs/ai-engineering/skills/agent-incident-response/reference.md +152 -0
  61. package/packs/ai-engineering/skills/agent-incident-response/scripts/find_secret_exposure.py +239 -0
  62. package/packs/ai-engineering/skills/agent-incident-response/scripts/usage_split.py +194 -0
  63. package/packs/converters/skills/file-to-markdown/SKILL.md +3 -2
  64. package/packs/converters/skills/file-to-markdown/evals/eval_queries.json +4 -0
  65. package/packs/converters/skills/file-to-markdown/manifest.json +2 -2
  66. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +340 -7
  67. package/packs/converters/skills/md-to-confluence/SKILL.md +139 -0
  68. package/packs/converters/skills/md-to-confluence/evals/eval_queries.json +42 -0
  69. package/packs/converters/skills/md-to-confluence/evals/evals.json +89 -0
  70. package/packs/converters/skills/md-to-confluence/evals/files/1/img/latency-dashboard.png +0 -0
  71. package/packs/converters/skills/md-to-confluence/evals/files/1/payments-runbook.md +60 -0
  72. package/packs/converters/skills/md-to-confluence/evals/files/2/deploy-checklist.md +33 -0
  73. package/packs/converters/skills/md-to-confluence/evals/files/3/img/after/screen.png +0 -0
  74. package/packs/converters/skills/md-to-confluence/evals/files/3/img/before/screen.png +0 -0
  75. package/packs/converters/skills/md-to-confluence/evals/files/3/release-notes-4.2.md +49 -0
  76. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/img/topology.png +0 -0
  77. package/packs/converters/skills/md-to-confluence/evals/files/4/docs/ingest-pipeline-design.md +65 -0
  78. package/packs/converters/skills/md-to-confluence/evals/files/5/support/troubleshooting-sso.md +57 -0
  79. package/packs/converters/skills/md-to-confluence/examples/inputs/img/queue-depth.png +0 -0
  80. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.fixed.md +21 -0
  81. package/packs/converters/skills/md-to-confluence/examples/inputs/queue-runbook.md +21 -0
  82. package/packs/converters/skills/md-to-confluence/examples/queue-runbook.md +57 -0
  83. package/packs/converters/skills/md-to-confluence/manifest.json +52 -0
  84. package/packs/converters/skills/md-to-confluence/reference.md +92 -0
  85. package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +821 -0
  86. package/packs/converters/skills/md-to-pptx/SKILL.md +131 -0
  87. package/packs/converters/skills/md-to-pptx/evals/eval_queries.json +12 -0
  88. package/packs/converters/skills/md-to-pptx/evals/evals.json +84 -0
  89. package/packs/converters/skills/md-to-pptx/evals/files/1/img/defect-burndown.png +0 -0
  90. package/packs/converters/skills/md-to-pptx/evals/files/1/status-report.md +75 -0
  91. package/packs/converters/skills/md-to-pptx/evals/files/3/brand.json +12 -0
  92. package/packs/converters/skills/md-to-pptx/evals/files/3/night-shift-induction.md +53 -0
  93. package/packs/converters/skills/md-to-pptx/evals/files/4/charts/ontime.png +0 -0
  94. package/packs/converters/skills/md-to-pptx/evals/files/4/qbr-q3.md +85 -0
  95. package/packs/converters/skills/md-to-pptx/evals/files/5/vendor-sync-notes.md +28 -0
  96. package/packs/converters/skills/md-to-pptx/examples/support-weekly-input.md +48 -0
  97. package/packs/converters/skills/md-to-pptx/examples/support-weekly.md +75 -0
  98. package/packs/converters/skills/md-to-pptx/examples/tickets-by-week.png +0 -0
  99. package/packs/converters/skills/md-to-pptx/manifest.json +49 -0
  100. package/packs/converters/skills/md-to-pptx/reference.md +139 -0
  101. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +1841 -0
  102. package/packs/dev-team/loops/modernize/LOOP.md +124 -0
  103. package/packs/dev-team/loops/modernize/evals/eval_queries.json +9 -0
  104. package/packs/dev-team/loops/modernize/evals/evals.json +27 -0
  105. package/packs/dev-team/loops/modernize/loop.json +60 -0
  106. package/packs/dev-team/skills/behaviour-equivalence/SKILL.md +155 -0
  107. package/packs/dev-team/skills/behaviour-equivalence/evals/eval_queries.json +11 -0
  108. package/packs/dev-team/skills/behaviour-equivalence/evals/evals.json +76 -0
  109. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/cases.jsonl +12 -0
  110. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/new.jsonl +12 -0
  111. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/notes.md +15 -0
  112. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old.jsonl +12 -0
  113. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/old_rerun.jsonl +12 -0
  114. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v1.py +45 -0
  115. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/pricing_v2.py +65 -0
  116. package/packs/dev-team/skills/behaviour-equivalence/evals/files/1/rules.json +10 -0
  117. package/packs/dev-team/skills/behaviour-equivalence/evals/files/3/diff-summary.txt +16 -0
  118. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/diff.txt +13 -0
  119. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py2.py +16 -0
  120. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/fee_calc_py3.py +24 -0
  121. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/input.csv +13 -0
  122. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/new_output.csv +13 -0
  123. package/packs/dev-team/skills/behaviour-equivalence/evals/files/4/old_output.csv +13 -0
  124. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/TEAM_NOTES.md +19 -0
  125. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/normalisation.yaml +12 -0
  126. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/new_run.jsonl +8 -0
  127. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run1.jsonl +8 -0
  128. package/packs/dev-team/skills/behaviour-equivalence/evals/files/5/recordings/old_run2.jsonl +8 -0
  129. package/packs/dev-team/skills/behaviour-equivalence/examples/delivery-date.md +78 -0
  130. package/packs/dev-team/skills/behaviour-equivalence/manifest.json +33 -0
  131. package/packs/dev-team/skills/behaviour-equivalence/reference.md +97 -0
  132. package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +524 -0
  133. package/packs/dev-team/skills/behaviour-equivalence/templates/accepted.json +10 -0
  134. package/packs/dev-team/skills/behaviour-equivalence/templates/rules.json +7 -0
  135. package/packs/frontend/pack.json +55 -0
  136. package/packs/frontend/skills/rendering-strategy/SKILL.md +149 -0
  137. package/packs/frontend/skills/rendering-strategy/evals/eval_queries.json +38 -0
  138. package/packs/frontend/skills/rendering-strategy/evals/evals.json +77 -0
  139. package/packs/frontend/skills/rendering-strategy/evals/files/1/fernhill-app.md +40 -0
  140. package/packs/frontend/skills/rendering-strategy/evals/files/3/routes.md +33 -0
  141. package/packs/frontend/skills/rendering-strategy/evals/files/4/build-notes.txt +5 -0
  142. package/packs/frontend/skills/rendering-strategy/evals/files/4/nuxt.config.ts +19 -0
  143. package/packs/frontend/skills/rendering-strategy/evals/files/4/pages/recipes/[slug].vue +37 -0
  144. package/packs/frontend/skills/rendering-strategy/evals/files/4/routes.md +15 -0
  145. package/packs/frontend/skills/rendering-strategy/evals/files/5/routes.txt +18 -0
  146. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/hooks.server.ts +14 -0
  147. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/(marketing)/pricing/+page.server.ts +10 -0
  148. package/packs/frontend/skills/rendering-strategy/evals/files/5/src/routes/+layout.server.ts +10 -0
  149. package/packs/frontend/skills/rendering-strategy/evals/files/5/svelte.config.js +10 -0
  150. package/packs/frontend/skills/rendering-strategy/evals/files/5/traffic.md +18 -0
  151. package/packs/frontend/skills/rendering-strategy/examples/saas-site.md +42 -0
  152. package/packs/frontend/skills/rendering-strategy/manifest.json +50 -0
  153. package/packs/frontend/skills/rendering-strategy/reference.md +116 -0
  154. package/packs/frontend/skills/rendering-strategy/scripts/render_load.py +181 -0
  155. package/packs/frontend/skills/web-performance-review/SKILL.md +106 -0
  156. package/packs/frontend/skills/web-performance-review/evals/eval_queries.json +38 -0
  157. package/packs/frontend/skills/web-performance-review/evals/evals.json +76 -0
  158. package/packs/frontend/skills/web-performance-review/evals/files/1/psi.json +1065 -0
  159. package/packs/frontend/skills/web-performance-review/evals/files/2/after.json +359 -0
  160. package/packs/frontend/skills/web-performance-review/evals/files/2/before.json +375 -0
  161. package/packs/frontend/skills/web-performance-review/evals/files/4/psi-pdp-mobile-2026-10-19.json +171 -0
  162. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-attribution-oct14-19.csv +6 -0
  163. package/packs/frontend/skills/web-performance-review/evals/files/4/rum-inp-daily.csv +15 -0
  164. package/packs/frontend/skills/web-performance-review/evals/files/5/crux-home-phone.json +35 -0
  165. package/packs/frontend/skills/web-performance-review/evals/files/5/lighthouse-home-mobile.json +173 -0
  166. package/packs/frontend/skills/web-performance-review/examples/quayside-article-lhr.json +480 -0
  167. package/packs/frontend/skills/web-performance-review/examples/quayside-article.md +103 -0
  168. package/packs/frontend/skills/web-performance-review/manifest.json +48 -0
  169. package/packs/frontend/skills/web-performance-review/reference.md +105 -0
  170. package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +743 -0
  171. package/packs/product-manager/skills/lean-canvas/SKILL.md +162 -0
  172. package/packs/product-manager/skills/lean-canvas/evals/eval_queries.json +42 -0
  173. package/packs/product-manager/skills/lean-canvas/evals/evals.json +82 -0
  174. package/packs/product-manager/skills/lean-canvas/evals/files/1/pawpass-notes.md +54 -0
  175. package/packs/product-manager/skills/lean-canvas/evals/files/3/lunchloop-pitch.md +30 -0
  176. package/packs/product-manager/skills/lean-canvas/evals/files/4/clubkit-notes.md +44 -0
  177. package/packs/product-manager/skills/lean-canvas/evals/files/5/cost-model.csv +5 -0
  178. package/packs/product-manager/skills/lean-canvas/evals/files/5/proposal.md +40 -0
  179. package/packs/product-manager/skills/lean-canvas/evals/files/5/stakeholder-emails.txt +18 -0
  180. package/packs/product-manager/skills/lean-canvas/examples/bike-repair.md +72 -0
  181. package/packs/product-manager/skills/lean-canvas/manifest.json +34 -0
  182. package/packs/product-manager/skills/lean-canvas/reference.md +102 -0
  183. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +211 -0
  184. package/packs/solution-architect/skills/api-contract-draft/SKILL.md +2 -2
  185. package/packs/solution-architect/skills/api-contract-draft/manifest.json +28 -6
  186. package/packs/solution-architect/skills/event-contract-draft/SKILL.md +167 -0
  187. package/packs/solution-architect/skills/event-contract-draft/evals/eval_queries.json +38 -0
  188. package/packs/solution-architect/skills/event-contract-draft/evals/evals.json +76 -0
  189. package/packs/solution-architect/skills/event-contract-draft/evals/files/1/order-events-brief.md +45 -0
  190. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/NOTES.md +23 -0
  191. package/packs/solution-architect/skills/event-contract-draft/evals/files/4/schemas/shipment-update.json +40 -0
  192. package/packs/solution-architect/skills/event-contract-draft/evals/files/5/invoice.example.json +25 -0
  193. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.asyncapi.json +246 -0
  194. package/packs/solution-architect/skills/event-contract-draft/examples/order-service.md +92 -0
  195. package/packs/solution-architect/skills/event-contract-draft/manifest.json +25 -0
  196. package/packs/solution-architect/skills/event-contract-draft/reference.md +113 -0
  197. package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +502 -0
  198. package/packs/solution-architect/skills/event-contract-draft/templates/asyncapi-skeleton.json +144 -0
  199. package/packs/sre-oncall/skills/slo-spec/SKILL.md +161 -0
  200. package/packs/sre-oncall/skills/slo-spec/evals/eval_queries.json +38 -0
  201. package/packs/sre-oncall/skills/slo-spec/evals/evals.json +75 -0
  202. package/packs/sre-oncall/skills/slo-spec/evals/files/1/order-service.md +51 -0
  203. package/packs/sre-oncall/skills/slo-spec/evals/files/3/search-slo-status.md +30 -0
  204. package/packs/sre-oncall/skills/slo-spec/evals/files/4/alerts/payments-api.rules.yaml +16 -0
  205. package/packs/sre-oncall/skills/slo-spec/evals/files/4/docs/dependencies.md +8 -0
  206. package/packs/sre-oncall/skills/slo-spec/evals/files/4/metrics/summary-90d.md +51 -0
  207. package/packs/sre-oncall/skills/slo-spec/evals/files/5/ops-notes.md +34 -0
  208. package/packs/sre-oncall/skills/slo-spec/examples/ticket-booking.md +88 -0
  209. package/packs/sre-oncall/skills/slo-spec/manifest.json +34 -0
  210. package/packs/sre-oncall/skills/slo-spec/reference.md +113 -0
  211. package/packs/sre-oncall/skills/slo-spec/scripts/slo_math.py +449 -0
  212. package/packs/sre-oncall/skills/slo-spec/templates/slo-spec.md +35 -0
@@ -0,0 +1,989 @@
1
+ #!/usr/bin/env python3
2
+ """agent_config_scan.py - list what a coding agent's configuration actually grants.
3
+
4
+ Reads the agent config files found in the paths given (Claude Code settings at every scope,
5
+ .mcp.json, subagent files, Cursor, Codex, VS Code), merges Claude Code's scopes the way the
6
+ tool does, and prints the effective result plus the mechanical patterns a reviewer grades:
7
+ wide allow rules, a deny list an allowed interpreter walks around, hooks and other settings
8
+ that run shell, unpinned MCP packages, literal secrets (masked), subagents with no tool list.
9
+ It judges nothing; the reviewer does.
10
+
11
+ Usage:
12
+ python3 agent_config_scan.py PATH [PATH ...] [--user HOME_DIR] [--managed FILE]
13
+ [--also FILE ...] [--json] [-o OUT]
14
+
15
+ PATH a project folder (searched for known config files) or one config file
16
+ --user a home folder: reads its .claude/settings.json, .claude.json, .claude/agents,
17
+ .codex/config.toml and .cursor/mcp.json as user scope
18
+ --managed a managed-settings.json (organisation policy; outranks every other scope)
19
+ --also any other file a config references (an env file, a token file): it is
20
+ searched for literal secrets and checked against .gitignore
21
+ -o OUT write the report to OUT instead of stdout
22
+
23
+ Stdlib only, Python 3.9+. Reads only the paths given; writes only OUT. No network.
24
+ """
25
+ import argparse
26
+ import fnmatch
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+
33
+ try:
34
+ import tomllib # Python 3.11+
35
+ except ImportError: # pragma: no cover
36
+ tomllib = None
37
+
38
+ SCOPES = ["user", "project", "local", "managed"] # low -> high precedence
39
+
40
+ # --- what an allow rule really lets run -------------------------------------------------
41
+ CODE_RUNNERS = {
42
+ "python": "runs any code (`python -c`)", "python3": "runs any code (`python3 -c`)",
43
+ "node": "runs any code (`node -e`)", "deno": "runs any code", "bun": "runs any code",
44
+ "ruby": "runs any code (`ruby -e`)", "perl": "runs any code (`perl -e`)", "php": "runs any code",
45
+ "bash": "runs any shell", "sh": "runs any shell", "zsh": "runs any shell",
46
+ "pwsh": "runs any shell", "powershell": "runs any shell",
47
+ "npm": "runs any package.json script, and `npm exec` downloads and runs any package",
48
+ "pnpm": "runs any package.json script and `pnpm dlx` any package",
49
+ "yarn": "runs any package.json script and `yarn dlx` any package",
50
+ "npx": "downloads and runs any package", "bunx": "downloads and runs any package",
51
+ "uvx": "downloads and runs any package", "pipx": "downloads and runs any package",
52
+ "uv": "`uv run` runs any code", "pip": "installs packages whose setup code runs",
53
+ "pip3": "installs packages whose setup code runs",
54
+ "make": "runs whatever the Makefile says, and `make -f` any file",
55
+ "just": "runs whatever the justfile says", "cargo": "`cargo run` and build scripts run any code",
56
+ "go": "`go run` runs any code", "docker": "starts containers that can mount the host disk",
57
+ "podman": "starts containers that can mount the host disk",
58
+ "kubectl": "runs commands in the cluster the kubeconfig reaches",
59
+ "git": "`git -c core.sshCommand=...`, aliases and hooks run commands; `git push` sends code out",
60
+ "gh": "`gh api` makes any call the token allows, deletes included",
61
+ "curl": "sends any file to any host, or downloads code", "wget": "sends data out or downloads code",
62
+ "ssh": "runs commands on other machines", "scp": "copies files off the machine",
63
+ "rsync": "copies files off the machine", "find": "`-exec` runs any command",
64
+ "xargs": "runs whatever command follows", "env": "runs whatever command follows",
65
+ "sudo": "runs any command as root", "eval": "runs any command", "exec": "runs any command",
66
+ "nohup": "runs whatever command follows", "timeout": "runs whatever command follows",
67
+ "watch": "runs whatever command follows", "awk": "`system()` runs commands",
68
+ "sed": "GNU sed's `e` command runs commands", "rm": "deletes anything the user can",
69
+ }
70
+ OPEN_SUBCOMMANDS = {"npm run", "npm exec", "npm x", "pnpm run", "pnpm exec", "pnpm dlx", "yarn run", "yarn dlx",
71
+ "docker run", "docker compose", "docker exec", "podman run", "uv run", "uv tool", "poetry run",
72
+ "python -m", "python3 -m", "git config", "git -c", "go run", "cargo run", "gh api", "kubectl exec",
73
+ "make -f", "bash -c", "sh -c"}
74
+ WIDE_TOOLS = {
75
+ "Bash": "any shell command without asking",
76
+ "Edit": "edits any file without asking, the agent's own settings and hooks included",
77
+ "Write": "writes any file without asking, the agent's own settings and hooks included",
78
+ "MultiEdit": "edits any file without asking",
79
+ "WebFetch": "fetches any URL without asking; a URL can carry data out in its query string",
80
+ "WebSearch": "searches without asking; a query can carry data out",
81
+ }
82
+ RISKY_DOMAINS = ("raw.githubusercontent.com", "gist.githubusercontent.com", "githubusercontent.com",
83
+ "pastebin.com", "ngrok", "webhook.site", "requestbin", "transfer.sh", "s3.amazonaws.com")
84
+ SHELL_KEYS = ("statusLine", "apiKeyHelper", "awsAuthRefresh", "awsCredentialExport",
85
+ "otelHeadersHelper", "fileSuggestion")
86
+ LAUNCH_FLAGS = ("dangerously-skip-permissions", "bypassPermissions", "dangerously-bypass-approvals-and-sandbox",
87
+ "--yolo", "--full-auto", "danger-full-access", "--permission-mode")
88
+
89
+ FETCH_WORD = re.compile(r"\b(curl|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\b|https?://", re.I)
90
+ PIPE_TO_SHELL = re.compile(r"\|\s*(sudo\s+)?(ba|z|da|fi)?sh\b|\|\s*(python3?|node|iex)\b", re.I)
91
+ RUN_DOWNLOADED = re.compile(r"\b(bash|sh|zsh|source|python3?|node)\s+[^|;&]*(/tmp/|\$\(|<\()", re.I)
92
+
93
+ SECRET_PATTERNS = [
94
+ re.compile(r"\b(ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{12,}"), re.compile(r"\bgithub_pat_[A-Za-z0-9_]{12,}"),
95
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{8,}"), re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{8,}"),
96
+ re.compile(r"\bsk-(ant-|proj-)?[A-Za-z0-9_-]{12,}"), re.compile(r"\b(sk|rk)_(live|test)_[A-Za-z0-9]{8,}"),
97
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"), re.compile(r"\bAIza[0-9A-Za-z_-]{20,}"),
98
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}"), re.compile(r"://[^/\s:@\"']+:[^@\s\"'$]{3,}@"),
99
+ ]
100
+ SECRET_NAME = re.compile(r"(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|_PAT\b|^PAT$|AUTH|CREDENTIAL|DATABASE_URL|_DSN\b)", re.I)
101
+ REFERENCE = re.compile(r"^\s*(\$\{[^}]+\}|\$[A-Z_][A-Z0-9_]*|\{env:[^}]+\}|\$\{input:[^}]+\}|<[^>]+>)?\s*$")
102
+ REF_ANYWHERE = re.compile(r"\$\{[^}]+\}|\$[A-Z_][A-Z0-9_]*|\{env:[^}]+\}")
103
+ HIDDEN_CHARS = re.compile("[​-‏‪-‮⁠-⁤⁦-⁩\U000e0000-\U000e007f]")
104
+
105
+
106
+ def read_text(path):
107
+ try:
108
+ with open(path, "r", encoding="utf-8") as fh:
109
+ return fh.read()
110
+ except (OSError, UnicodeDecodeError):
111
+ return None
112
+
113
+
114
+ def strip_jsonc(text):
115
+ """Remove // and /* */ comments and trailing commas, leaving strings intact."""
116
+ out, i, n, in_str = [], 0, len(text), False
117
+ while i < n:
118
+ c = text[i]
119
+ if in_str:
120
+ out.append(c)
121
+ if c == "\\" and i + 1 < n:
122
+ out.append(text[i + 1]); i += 2; continue
123
+ if c == '"':
124
+ in_str = False
125
+ i += 1; continue
126
+ if c == '"':
127
+ in_str = True; out.append(c); i += 1; continue
128
+ if text.startswith("//", i):
129
+ while i < n and text[i] != "\n":
130
+ i += 1
131
+ continue
132
+ if text.startswith("/*", i):
133
+ j = text.find("*/", i + 2)
134
+ seg = text[i:(j + 2 if j >= 0 else n)]
135
+ out.append("\n" * seg.count("\n"))
136
+ i = j + 2 if j >= 0 else n; continue
137
+ out.append(c); i += 1
138
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
139
+
140
+
141
+ def load_json(path, notes):
142
+ text = read_text(path)
143
+ if text is None:
144
+ return None, []
145
+ try:
146
+ return json.loads(strip_jsonc(text)), text.splitlines()
147
+ except ValueError as e:
148
+ notes.append("{}: not valid JSON ({}); skipped".format(path, e))
149
+ return None, text.splitlines()
150
+
151
+
152
+ def mini_toml(text):
153
+ """Enough TOML for agent configs: tables, dotted/quoted keys, strings, bools, numbers,
154
+ arrays and inline tables. Used only when tomllib is unavailable (Python < 3.11)."""
155
+ root, cur = {}, None
156
+ cur = root
157
+
158
+ def parse_value(s):
159
+ s = s.strip()
160
+ if s.startswith('"""') or s.startswith("'''"):
161
+ return s[3:-3]
162
+ if s.startswith('"'):
163
+ return json.loads(s[: s.rfind('"') + 1])
164
+ if s.startswith("'"):
165
+ return s[1: s.rfind("'")]
166
+ if s in ("true", "false"):
167
+ return s == "true"
168
+ if s.startswith("["):
169
+ inner = s[1: s.rfind("]")]
170
+ return [parse_value(p) for p in split_top(inner) if p.strip()]
171
+ if s.startswith("{"):
172
+ d = {}
173
+ for p in split_top(s[1: s.rfind("}")]):
174
+ if "=" in p:
175
+ k, v = p.split("=", 1)
176
+ d[unquote(k)] = parse_value(v)
177
+ return d
178
+ try:
179
+ return int(s)
180
+ except ValueError:
181
+ try:
182
+ return float(s)
183
+ except ValueError:
184
+ return s
185
+
186
+ def split_top(s):
187
+ parts, depth, buf, q = [], 0, "", None
188
+ for ch in s:
189
+ if q:
190
+ buf += ch
191
+ if ch == q:
192
+ q = None
193
+ continue
194
+ if ch in "\"'":
195
+ q = ch
196
+ elif ch in "[{":
197
+ depth += 1
198
+ elif ch in "]}":
199
+ depth -= 1
200
+ elif ch == "," and depth == 0:
201
+ parts.append(buf); buf = ""; continue
202
+ buf += ch
203
+ parts.append(buf)
204
+ return parts
205
+
206
+ def unquote(k):
207
+ k = k.strip()
208
+ return k[1:-1] if len(k) > 1 and k[0] in "\"'" and k[-1] == k[0] else k
209
+
210
+ def keypath(k):
211
+ return [unquote(p) for p in re.findall(r'"[^"]*"|\'[^\']*\'|[^.]+', k.strip())]
212
+
213
+ lines = text.splitlines()
214
+ i = 0
215
+ while i < len(lines):
216
+ line = lines[i].strip()
217
+ i += 1
218
+ if not line or line.startswith("#"):
219
+ continue
220
+ m = re.match(r"^\[\[?([^\]]+)\]\]?\s*(#.*)?$", line)
221
+ if m:
222
+ cur = root
223
+ for p in keypath(m.group(1)):
224
+ cur = cur.setdefault(p, {})
225
+ continue
226
+ if "=" in line:
227
+ k, v = line.split("=", 1)
228
+ v = re.sub(r"\s+#[^\"']*$", "", v)
229
+ while v.count("[") > v.count("]") and i < len(lines): # multi-line array
230
+ v += " " + re.sub(r"\s+#.*$", "", lines[i].strip()); i += 1
231
+ ks = keypath(k)
232
+ tgt = cur
233
+ for p in ks[:-1]:
234
+ tgt = tgt.setdefault(p, {})
235
+ tgt[ks[-1]] = parse_value(v)
236
+ return root
237
+
238
+
239
+ def load_toml(path, notes):
240
+ text = read_text(path)
241
+ if text is None:
242
+ return None, []
243
+ try:
244
+ data = tomllib.loads(text) if tomllib else mini_toml(text)
245
+ except Exception as e: # noqa: BLE001 - report and continue
246
+ notes.append("{}: not valid TOML ({}); skipped".format(path, e))
247
+ return None, text.splitlines()
248
+ return data, text.splitlines()
249
+
250
+
251
+ def line_of(lines, *needles):
252
+ for i, ln in enumerate(lines or [], 1):
253
+ if all(n in ln for n in needles):
254
+ return i
255
+ return 0
256
+
257
+
258
+ # Public token prefixes. Only these constant labels are printed, never characters of the secret.
259
+ TOKEN_KINDS = [("ghp_", "GitHub token"), ("github_pat_", "GitHub token"), ("gho_", "GitHub token"),
260
+ ("sk-ant-", "Anthropic key"), ("sk-", "API key (sk-)"), ("xox", "Slack token"),
261
+ ("AKIA", "AWS access key"), ("AIza", "Google API key"), ("glpat-", "GitLab token")]
262
+
263
+
264
+ def mask(value):
265
+ """A secret as its kind, length and a short fingerprint, so two findings can be told apart
266
+ or matched without printing any part of the value."""
267
+ v = str(value)
268
+ kind = next((label for pre, label in TOKEN_KINDS if v.startswith(pre)), "secret")
269
+ return "[{}, {} chars, sha256:{}]".format(kind, len(v), hashlib.sha256(v.encode("utf-8")).hexdigest()[:8])
270
+
271
+
272
+ URL_CREDS = re.compile(r"(://[^/\s:@\"']+:)([^@\s\"'$]{3,})(@)")
273
+
274
+
275
+ def redact(text):
276
+ """Mask every secret-shaped string in output; a URL keeps its user and host, loses its password."""
277
+ text = URL_CREDS.sub(lambda m: m.group(1) + "****" + m.group(3), text)
278
+ for p in SECRET_PATTERNS[:-1]:
279
+ text = p.sub(lambda m: mask(m.group(0)), text)
280
+ return text
281
+
282
+
283
+ def secret_in(value):
284
+ if not isinstance(value, str):
285
+ return False
286
+ return any(p.search(value) for p in SECRET_PATTERNS)
287
+
288
+
289
+ def literal_secret(name, value):
290
+ """A value written into the file (not a ${VAR} reference) that is a secret by shape or name."""
291
+ if not isinstance(value, str) or not value.strip() or REFERENCE.match(value):
292
+ return False
293
+ if secret_in(value):
294
+ return True
295
+ rest = REF_ANYWHERE.sub("", value)
296
+ rest = re.sub(r"^\s*(Bearer|Basic|Token|token)\b", "", rest).strip()
297
+ return bool(rest) and bool(SECRET_NAME.search(name or ""))
298
+
299
+
300
+ class Report:
301
+ def __init__(self):
302
+ self.files, self.notes, self.flags, self.secrets = [], [], [], []
303
+ self.claude = {}
304
+ self.mcp, self.agents, self.codex, self.cursor_rules, self.vscode, self.launch = [], [], [], [], [], []
305
+
306
+ def flag(self, kind, where, text, effect):
307
+ self.flags.append({"kind": kind, "where": where, "text": text, "effect": effect})
308
+
309
+ def secret(self, where, name, value, rel):
310
+ self.secrets.append({"where": where, "name": name, "masked": mask(value), "file": rel})
311
+
312
+
313
+ def rel(path, base):
314
+ try:
315
+ r = os.path.relpath(path, base)
316
+ return path if r.startswith("..") else r
317
+ except ValueError:
318
+ return path
319
+
320
+
321
+ def first_word(rule_body):
322
+ body = rule_body.strip()
323
+ return re.split(r"[\s:*]", body, 1)[0] if body else ""
324
+
325
+
326
+ def classify_rule(rule):
327
+ """Return (wide?, effect) for one permission rule string."""
328
+ if rule.strip().startswith("mcp__"):
329
+ parts = rule.strip().split("__")
330
+ if len(parts) == 2 or (len(parts) == 3 and parts[2] in ("*", "")):
331
+ return True, "every tool on MCP server `{}`, writes included, without asking".format(parts[1])
332
+ return False, ""
333
+ m = re.match(r"^([A-Za-z_]+)(?:\((.*)\))?$", rule.strip())
334
+ if not m:
335
+ return False, ""
336
+ tool, body = m.group(1), m.group(2)
337
+ if body is None or body.strip() in ("*", "**", ":*", "*:*"):
338
+ if tool in WIDE_TOOLS:
339
+ return True, WIDE_TOOLS[tool]
340
+ return False, ""
341
+ if tool == "Bash":
342
+ w = first_word(body)
343
+ rest = body.strip()[len(w):].strip()
344
+ sub = re.sub(r"\s*:?\*$", "", rest).strip()
345
+ if w in CODE_RUNNERS and rest != sub and (not sub or "{} {}".format(w, sub) in OPEN_SUBCOMMANDS):
346
+ return True, CODE_RUNNERS[w] if not sub else "`{} {}` with any arguments runs any code".format(w, sub)
347
+ if w in CODE_RUNNERS and rest == "":
348
+ return True, CODE_RUNNERS[w] + " (bare command; check whether arguments are matched)"
349
+ if tool in ("Edit", "Write", "Read") and body.strip() in ("/**", "//**", "~/**", "**/*"):
350
+ return True, "{} on every path".format(tool)
351
+ if tool == "WebFetch" and "*" in body:
352
+ return True, "fetches matching hosts without asking: " + body
353
+ return False, ""
354
+
355
+
356
+ def scan_claude_settings(data, lines, scope, relpath, rep):
357
+ if not isinstance(data, dict):
358
+ return
359
+ entry = {"scope": scope, "file": relpath, "data": data, "lines": lines}
360
+ rep.claude.setdefault("scopes", []).append(entry)
361
+ for name, value in (data.get("env") or {}).items():
362
+ if literal_secret(name, value):
363
+ rep.secret("{}:{}".format(relpath, line_of(lines, '"' + name + '"')), "env." + name, value, relpath)
364
+ for key in ("apiKeyHelper",):
365
+ if isinstance(data.get(key), str) and secret_in(data[key]):
366
+ rep.secret("{}:{}".format(relpath, line_of(lines, key)), key, data[key], relpath)
367
+
368
+
369
+ def merge_claude(rep):
370
+ scopes = sorted(rep.claude.get("scopes", []), key=lambda e: SCOPES.index(e["scope"]))
371
+ if not scopes:
372
+ return
373
+ managed = [e for e in scopes if e["scope"] == "managed"]
374
+ mdata = managed[0]["data"] if managed else {}
375
+ rules_only_managed = (mdata.get("permissions") or {}).get("allowManagedPermissionRulesOnly") is True \
376
+ or mdata.get("allowManagedPermissionRulesOnly") is True
377
+ hooks_only_managed = mdata.get("allowManagedHooksOnly") is True
378
+ eff = {"rules": {"allow": [], "ask": [], "deny": []}, "defaultMode": None, "sandbox": {},
379
+ "hooks": [], "env": {}, "shell_settings": [], "plugins": {}, "marketplaces": [],
380
+ "mcp_approval": {}, "additionalDirectories": [], "disableAllHooks": None,
381
+ "disableBypass": None, "rules_only_managed": rules_only_managed, "hooks_only_managed": hooks_only_managed}
382
+
383
+ def where(e, *needles):
384
+ return "{}:{}".format(e["file"], line_of(e["lines"], *needles))
385
+
386
+ for e in scopes:
387
+ d, perms = e["data"], e["data"].get("permissions") or {}
388
+ for kind in ("allow", "ask", "deny"):
389
+ if rules_only_managed and e["scope"] != "managed":
390
+ continue
391
+ for r in perms.get(kind) or []:
392
+ eff["rules"][kind].append({"rule": r, "where": where(e, json.dumps(r)[1:-1]), "scope": e["scope"]})
393
+ if "defaultMode" in perms:
394
+ eff["defaultMode"] = {"value": perms["defaultMode"], "where": where(e, "defaultMode"), "scope": e["scope"]}
395
+ if "disableBypassPermissionsMode" in perms:
396
+ eff["disableBypass"] = {"value": perms["disableBypassPermissionsMode"], "where": where(e, "disableBypassPermissionsMode")}
397
+ for p in perms.get("additionalDirectories") or []:
398
+ eff["additionalDirectories"].append({"path": p, "where": where(e, json.dumps(p)[1:-1])})
399
+ sb = d.get("sandbox")
400
+ if isinstance(sb, dict):
401
+ def walk(obj, prefix):
402
+ for k, v in obj.items():
403
+ key = prefix + k
404
+ if isinstance(v, dict):
405
+ walk(v, key + ".")
406
+ elif isinstance(v, list):
407
+ cur = eff["sandbox"].setdefault(key, {"value": [], "where": []})
408
+ cur["value"] = list(dict.fromkeys(cur["value"] + v))
409
+ cur["where"].append(where(e, '"' + k + '"'))
410
+ else:
411
+ eff["sandbox"][key] = {"value": v, "where": [where(e, '"' + k + '"')]}
412
+ walk(sb, "")
413
+ if "disableAllHooks" in d:
414
+ eff["disableAllHooks"] = {"value": d["disableAllHooks"], "where": where(e, "disableAllHooks")}
415
+ if not (hooks_only_managed and e["scope"] != "managed"):
416
+ for event, groups in (d.get("hooks") or {}).items():
417
+ for g in groups or []:
418
+ for h in (g.get("hooks") or []) if isinstance(g, dict) else []:
419
+ cmd = h.get("command") or h.get("url") or h.get("prompt") or ""
420
+ eff["hooks"].append({"event": event, "matcher": g.get("matcher", ""), "type": h.get("type", "command"),
421
+ "command": cmd, "where": where(e, json.dumps(cmd)[1:30]) if cmd else where(e, event),
422
+ "scope": e["scope"]})
423
+ for k, v in (d.get("env") or {}).items():
424
+ eff["env"][k] = {"value": v, "where": where(e, '"' + k + '"'), "scope": e["scope"]}
425
+ for k in SHELL_KEYS:
426
+ if k in d:
427
+ v = d[k]
428
+ cmd = v.get("command") if isinstance(v, dict) else v
429
+ eff["shell_settings"] = [s for s in eff["shell_settings"] if s["key"] != k]
430
+ eff["shell_settings"].append({"key": k, "command": cmd, "where": where(e, '"' + k + '"'), "scope": e["scope"]})
431
+ for k, v in (d.get("enabledPlugins") or {}).items():
432
+ eff["plugins"][k] = {"value": v, "where": where(e, '"' + k + '"')}
433
+ for k, v in (d.get("extraKnownMarketplaces") or {}).items():
434
+ eff["marketplaces"].append({"name": k, "source": v.get("source") if isinstance(v, dict) else v, "where": where(e, '"' + k + '"')})
435
+ for k in ("enableAllProjectMcpServers", "enabledMcpjsonServers", "disabledMcpjsonServers"):
436
+ if k in d:
437
+ eff["mcp_approval"][k] = {"value": d[k], "where": where(e, k), "scope": e["scope"]}
438
+ rep.claude["effective"] = eff
439
+
440
+ # mechanical patterns
441
+ allowed_wide = []
442
+ for r in eff["rules"]["allow"]:
443
+ wide, effect = classify_rule(r["rule"])
444
+ if wide:
445
+ allowed_wide.append(r)
446
+ rep.flag("wide-allow", r["where"], r["rule"], effect)
447
+ if eff["rules"]["deny"] and any(first_word((re.match(r"^Bash\((.*)\)$", r["rule"]) or [None, ""])[1]) in CODE_RUNNERS
448
+ or r["rule"] == "Bash" for r in allowed_wide):
449
+ runners = ", ".join(r["rule"] for r in allowed_wide if r["rule"].startswith("Bash"))
450
+ denies = ", ".join(r["rule"] for r in eff["rules"]["deny"])
451
+ rep.flag("deny-bypassable", eff["rules"]["deny"][0]["where"], denies,
452
+ "allowed without asking: {} - any of them can do what the deny list blocks".format(runners))
453
+ dm = eff["defaultMode"]
454
+ if dm and dm["value"] == "bypassPermissions":
455
+ blocked = eff["disableBypass"] and eff["disableBypass"]["value"] == "disable"
456
+ rep.flag("human-removed", dm["where"], "defaultMode: bypassPermissions (from {} scope)".format(dm["scope"]),
457
+ "every tool call runs without a prompt" + (" - but managed policy disables bypass mode" if blocked else ""))
458
+ sb = eff["sandbox"]
459
+ enabled = sb.get("enabled", {}).get("value") is True
460
+ if not enabled:
461
+ rep.flag("no-sandbox", (sb.get("enabled") or {}).get("where", ["(not set)"])[0], "sandbox.enabled not true",
462
+ "shell commands run with the user's full file and network access")
463
+ else:
464
+ if sb.get("allowUnsandboxedCommands", {}).get("value") is not False:
465
+ w = (sb.get("allowUnsandboxedCommands") or {}).get("where", ["(not set; default allows retry)"])[0]
466
+ rep.flag("sandbox-escape", w, "allowUnsandboxedCommands not false",
467
+ "a command that fails in the sandbox can retry outside it")
468
+ if sb.get("excludedCommands", {}).get("value"):
469
+ rep.flag("sandbox-escape", sb["excludedCommands"]["where"][0], ", ".join(sb["excludedCommands"]["value"]),
470
+ "these commands always run outside the sandbox")
471
+ for d in (sb.get("network.allowedDomains") or {}).get("value", []):
472
+ if d.strip() == "*" or d.startswith("*.") or any(x in d for x in RISKY_DOMAINS):
473
+ rep.flag("wide-domain", sb["network.allowedDomains"]["where"][0], d,
474
+ "anyone can publish content at this host - a payload source and a place to send data")
475
+ for h in eff["hooks"]:
476
+ c = h["command"]
477
+ if FETCH_WORD.search(c) and (PIPE_TO_SHELL.search(c) or RUN_DOWNLOADED.search(c)):
478
+ rep.flag("hook-runs-remote", h["where"], c, "on {} it downloads code and runs it, without asking".format(h["event"]))
479
+ elif FETCH_WORD.search(c):
480
+ rep.flag("hook-network", h["where"], c, "on {} it reaches the network, without asking".format(h["event"]))
481
+ else:
482
+ rep.flag("hook", h["where"], c, "runs on {} ({}) without asking".format(h["event"], h["matcher"] or "every match"))
483
+ for s in eff["shell_settings"]:
484
+ rep.flag("runs-shell-setting", s["where"], "{}: {}".format(s["key"], s["command"]), "runs this command without asking")
485
+ ap = eff["mcp_approval"].get("enableAllProjectMcpServers")
486
+ if ap and ap["value"] is True:
487
+ rep.flag("mcp-auto-approve", ap["where"], "enableAllProjectMcpServers: true",
488
+ "every server in .mcp.json starts without the user approving it")
489
+ for k, v in eff["plugins"].items():
490
+ if v["value"]:
491
+ rep.flag("plugin", v["where"], k, "a plugin can bring hooks, MCP servers and subagents not visible in these files")
492
+
493
+
494
+ def scan_mcp_file(data, lines, relpath, tool, rep):
495
+ if not isinstance(data, dict):
496
+ return
497
+ servers = data.get("mcpServers") or data.get("servers") or {}
498
+ projects = data.get("projects") if relpath.endswith(".claude.json") else None
499
+ blocks = [(relpath, servers)]
500
+ if isinstance(projects, dict):
501
+ for pname, p in projects.items():
502
+ if isinstance(p, dict) and p.get("mcpServers"):
503
+ blocks.append(("{} (local scope, {})".format(relpath, pname), p["mcpServers"]))
504
+ for label, block in blocks:
505
+ for name, s in (block or {}).items():
506
+ if not isinstance(s, dict):
507
+ continue
508
+ ln = line_of(lines, '"' + name + '"')
509
+ where = "{}:{}".format(relpath, ln)
510
+ cmd, args, url = s.get("command", ""), [str(a) for a in s.get("args") or []], s.get("url", "")
511
+ entry = {"tool": tool, "file": label, "name": name, "where": where, "command": cmd, "args": args,
512
+ "url": url, "type": s.get("type") or ("http" if url else "stdio"),
513
+ "env_keys": sorted((s.get("env") or {}).keys()), "header_keys": sorted((s.get("headers") or {}).keys())}
514
+ rep.mcp.append(entry)
515
+ check_server(entry, s, lines, relpath, rep)
516
+
517
+
518
+ def check_server(entry, s, lines, relpath, rep):
519
+ cmd, args, where = entry["command"], entry["args"], entry["where"]
520
+ launcher = os.path.basename(cmd)
521
+ if launcher in ("npx", "bunx", "pnpm", "uvx", "pipx", "yarn"):
522
+ pkgs = [a for a in args if not a.startswith("-") and a not in ("dlx", "run", "exec")]
523
+ pkg = pkgs[0] if pkgs else ""
524
+ version = pkg.rsplit("@", 1)[1] if pkg.count("@") > (1 if pkg.startswith("@") else 0) else ""
525
+ if launcher == "uvx" and "==" in pkg:
526
+ version = pkg.split("==", 1)[1]
527
+ if not version or version in ("latest", "next") or re.search(r"[\^~*x]", version):
528
+ rep.flag("unpinned-package", where, "{} {}".format(cmd, " ".join(args)),
529
+ "runs whatever `{}` the registry serves at start-up{}".format(pkg or "?", ", installed without asking (-y)" if "-y" in args or "--yes" in args else ""))
530
+ if launcher in ("docker", "podman"):
531
+ image = next((a for a in args if "/" in a or ":" in a and not a.startswith("-")), "")
532
+ if image and "@sha256:" not in image and (":" not in image.split("/")[-1] or image.endswith(":latest")):
533
+ rep.flag("unpinned-image", where, image, "runs whatever image the tag points at today")
534
+ if any(a in ("--privileged", "--network=host") or a.startswith("/:/") or a.startswith("-v/") for a in args):
535
+ rep.flag("container-host-access", where, " ".join(args), "the container reaches the host")
536
+ if entry["url"] and entry["url"].startswith("http://") and "localhost" not in entry["url"] and "127.0.0.1" not in entry["url"]:
537
+ rep.flag("plain-http", where, entry["url"], "credentials and data cross the network unencrypted")
538
+ for group in ("env", "headers"):
539
+ for k, v in (s.get(group) or {}).items():
540
+ if literal_secret(k, v):
541
+ rep.secret("{}:{}".format(relpath, line_of(lines, '"' + k + '"', str(v)[:6]) or line_of(lines, '"' + k + '"')),
542
+ "{}.{}.{}".format(entry["name"], group, k), v, relpath)
543
+ for a in args + [entry["url"]]:
544
+ if secret_in(a):
545
+ rep.secret("{}:{}".format(relpath, line_of(lines, a[:12])), "{}.args".format(entry["name"]), a, relpath)
546
+
547
+
548
+ def scan_agent_file(path, relpath, scope, rep):
549
+ text = read_text(path)
550
+ if text is None:
551
+ return
552
+ m = re.match(r"^---\s*\n(.*?)\n---", text, re.S)
553
+ fm = {}
554
+ if m:
555
+ for ln in m.group(1).splitlines():
556
+ if ":" in ln and not ln.startswith((" ", "\t", "-")):
557
+ k, v = ln.split(":", 1)
558
+ fm[k.strip()] = v.strip()
559
+ tools = fm.get("tools")
560
+ entry = {"file": relpath, "scope": scope, "name": fm.get("name", os.path.basename(path)[:-3]),
561
+ "tools": tools, "permissionMode": fm.get("permissionMode"), "model": fm.get("model")}
562
+ rep.agents.append(entry)
563
+ lines = text.splitlines()
564
+ if not tools:
565
+ rep.flag("inherits-all-tools", "{}:{}".format(relpath, line_of(lines, "name:") or 1), entry["name"],
566
+ "no tools: list - it gets every tool the main session has, MCP servers included")
567
+ elif re.search(r"\bBash\b(?!\()", tools):
568
+ rep.flag("wide-allow", "{}:{}".format(relpath, line_of(lines, "tools:")), tools, "this subagent holds an unrestricted shell")
569
+ if entry["permissionMode"] in ("bypassPermissions", "acceptEdits"):
570
+ rep.flag("human-removed" if entry["permissionMode"] == "bypassPermissions" else "fewer-prompts",
571
+ "{}:{}".format(relpath, line_of(lines, "permissionMode")), "permissionMode: " + entry["permissionMode"],
572
+ "this subagent's tool calls run without a prompt" if entry["permissionMode"] == "bypassPermissions"
573
+ else "this subagent's file edits run without a prompt")
574
+ if HIDDEN_CHARS.search(text):
575
+ rep.flag("hidden-characters", relpath, "{} invisible characters".format(len(HIDDEN_CHARS.findall(text))),
576
+ "text the user can't see is in the agent's instructions")
577
+
578
+
579
+ def scan_codex(data, lines, relpath, rep):
580
+ if not isinstance(data, dict):
581
+ return
582
+
583
+ def one(label, d, prefix):
584
+ sww = d.get("sandbox_workspace_write") or {}
585
+ entry = {"file": relpath, "profile": label, "approval_policy": d.get("approval_policy"),
586
+ "sandbox_mode": d.get("sandbox_mode"), "network_access": sww.get("network_access"),
587
+ "writable_roots": sww.get("writable_roots"),
588
+ "env_inherit": (d.get("shell_environment_policy") or {}).get("inherit")}
589
+ rep.codex.append(entry)
590
+ w = lambda key: "{}:{}".format(relpath, line_of(lines, key) if not prefix else profile_line(lines, label, key))
591
+ if entry["sandbox_mode"] == "danger-full-access":
592
+ rep.flag("human-removed" if entry["approval_policy"] == "never" else "no-sandbox", w("sandbox_mode"),
593
+ "{}sandbox_mode = danger-full-access".format(prefix),
594
+ "commands run with the user's full file and network access" +
595
+ (" and approval_policy = never: nobody is asked" if entry["approval_policy"] == "never" else ""))
596
+ elif entry["approval_policy"] == "never":
597
+ rep.flag("fewer-prompts", w("approval_policy"), prefix + "approval_policy = never",
598
+ "nothing is ever escalated to a person; the sandbox is the only control")
599
+ if entry["network_access"] is True:
600
+ rep.flag("network-on", w("network_access"), prefix + "sandbox_workspace_write.network_access = true",
601
+ "sandboxed commands reach any host")
602
+ if entry["env_inherit"] == "all":
603
+ rep.flag("env-inherit", w("inherit"), prefix + 'shell_environment_policy.inherit = "all"',
604
+ "every environment variable, tokens included, is passed to every command")
605
+
606
+ one("(top level)", data, "")
607
+ for name, prof in (data.get("profiles") or {}).items():
608
+ if isinstance(prof, dict):
609
+ one(name, prof, "profiles.{}.".format(name))
610
+ if data.get("profile"):
611
+ rep.notes.append("{}: default profile is `{}` - its values override the top level".format(relpath, data["profile"]))
612
+ for name, s in (data.get("mcp_servers") or {}).items():
613
+ if isinstance(s, dict):
614
+ entry = {"tool": "codex", "file": relpath, "name": name,
615
+ "where": "{}:{}".format(relpath, line_of(lines, "mcp_servers." + name) or line_of(lines, name)),
616
+ "command": s.get("command", ""), "args": [str(a) for a in s.get("args") or []], "url": s.get("url", ""),
617
+ "type": "http" if s.get("url") else "stdio", "env_keys": sorted((s.get("env") or {}).keys()),
618
+ "header_keys": sorted((s.get("http_headers") or {}).keys())}
619
+ rep.mcp.append(entry)
620
+ s2 = dict(s, headers=s.get("http_headers"))
621
+ check_server(entry, s2, lines, relpath, rep)
622
+
623
+
624
+ def profile_line(lines, profile, key):
625
+ start = line_of(lines, "[profiles.{}".format(profile)) or line_of(lines, 'profiles."{}"'.format(profile))
626
+ for i in range(start, len(lines)):
627
+ if i > start and lines[i].lstrip().startswith("[") and "profiles.{}".format(profile) not in lines[i]:
628
+ break
629
+ if lines[i].lstrip().startswith(key) or key in lines[i]:
630
+ return i + 1
631
+ return start
632
+
633
+
634
+ def scan_vscode(data, lines, relpath, rep):
635
+ if not isinstance(data, dict):
636
+ return
637
+ for k in ("chat.tools.autoApprove", "chat.tools.global.autoApprove"):
638
+ if data.get(k) is True:
639
+ rep.flag("human-removed", "{}:{}".format(relpath, line_of(lines, k)), k + ": true", "every agent tool call runs without a prompt")
640
+ for k in ("chat.tools.terminal.autoApprove", "github.copilot.chat.agent.terminal.allowList", "chat.agent.terminal.allowList"):
641
+ v = data.get(k)
642
+ if isinstance(v, dict):
643
+ for cmd, ok in v.items():
644
+ c = cmd.strip("/^$ ").split(" ")[0]
645
+ if ok is True and (c in CODE_RUNNERS or cmd.strip() in ("/.*/", ".*", "*")):
646
+ rep.flag("wide-allow", "{}:{}".format(relpath, line_of(lines, json.dumps(cmd)[1:-1])), "{}: {}".format(k, cmd),
647
+ CODE_RUNNERS.get(c, "any command") + ", run without a prompt")
648
+ rep.vscode.append({"file": relpath, "key": k, "value": v}) if v is not None else None
649
+ for k in ("chat.tools.autoApprove", "chat.agent.enabled", "chat.mcp.autostart", "chat.mcp.discovery.enabled"):
650
+ if k in data:
651
+ rep.vscode.append({"file": relpath, "key": k, "value": data[k]})
652
+
653
+
654
+ def scan_rules(path, relpath, rep):
655
+ text = read_text(path)
656
+ if text is None:
657
+ return
658
+ hidden = HIDDEN_CHARS.findall(text)
659
+ lines = text.splitlines()
660
+ runs = [i for i, ln in enumerate(lines, 1) if FETCH_WORD.search(ln) and re.search(r"\b(run|execute|always|before)\b", ln, re.I)]
661
+ rep.cursor_rules.append({"file": relpath, "hidden_chars": len(hidden), "lines_that_fetch_or_run": runs})
662
+ if hidden:
663
+ rep.flag("hidden-characters", relpath, "{} invisible characters".format(len(hidden)), "text the user can't see is in the agent's rules")
664
+ for i in runs:
665
+ rep.flag("rule-runs-remote", "{}:{}".format(relpath, i), lines[i - 1].strip()[:140], "the rules tell the agent to fetch or run something")
666
+
667
+
668
+ def scan_secrets_plain(path, relpath, rep):
669
+ text = read_text(path)
670
+ if text is None:
671
+ return
672
+ for i, ln in enumerate(text.splitlines(), 1):
673
+ m = re.match(r"^\s*(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*)$", ln)
674
+ if m:
675
+ val = m.group(3).strip().strip("\"'")
676
+ if literal_secret(m.group(2), val):
677
+ rep.secret("{}:{}".format(relpath, i), m.group(2), val, relpath)
678
+ continue
679
+ for p in SECRET_PATTERNS:
680
+ s = p.search(ln)
681
+ if s:
682
+ rep.secret("{}:{}".format(relpath, i), "(in text)", s.group(0), relpath)
683
+ break
684
+
685
+
686
+ def scan_launch(path, relpath, rep):
687
+ text = read_text(path)
688
+ if text is None:
689
+ return
690
+ for i, ln in enumerate(text.splitlines(), 1):
691
+ for f in LAUNCH_FLAGS:
692
+ if f in ln:
693
+ rep.launch.append({"where": "{}:{}".format(relpath, i), "flag": f, "text": ln.strip()[:140]})
694
+ if f in ("dangerously-skip-permissions", "dangerously-bypass-approvals-and-sandbox", "--yolo"):
695
+ rep.flag("human-removed", "{}:{}".format(relpath, i), ln.strip()[:140], "launches the agent with every prompt switched off")
696
+ break
697
+
698
+
699
+ def gitignore_patterns(root):
700
+ text = read_text(os.path.join(root, ".gitignore")) or ""
701
+ return [p.strip() for p in text.splitlines() if p.strip() and not p.strip().startswith("#")]
702
+
703
+
704
+ def ignored(relpath, patterns):
705
+ """Approximate .gitignore match (no negation precedence beyond last-match-wins)."""
706
+ hit = False
707
+ rp = relpath.replace(os.sep, "/")
708
+ for p in patterns:
709
+ neg = p.startswith("!")
710
+ pat = p[1:] if neg else p
711
+ pat = pat.rstrip("/")
712
+ anchored = pat.startswith("/") or "/" in pat.strip("/")
713
+ pat = pat.lstrip("/")
714
+ cands = [rp] if anchored else [rp, os.path.basename(rp)] + ["/".join(rp.split("/")[i:]) for i in range(len(rp.split("/")))]
715
+ if any(fnmatch.fnmatch(c, pat) or fnmatch.fnmatch(c, pat + "/*") or c.startswith(pat + "/") for c in cands):
716
+ hit = not neg
717
+ return hit
718
+
719
+
720
+ def discover_project(root, rep):
721
+ def p(*parts):
722
+ return os.path.join(root, *parts)
723
+ found = []
724
+ for f, kind, scope in ((".claude/settings.json", "claude", "project"), (".claude/settings.local.json", "claude", "local"),
725
+ ("managed-settings.json", "claude", "managed"),
726
+ (".mcp.json", "mcp", "claude"), (".cursor/mcp.json", "mcp", "cursor"),
727
+ (".vscode/mcp.json", "mcp", "vscode"), (".vscode/settings.json", "vscode", None),
728
+ (".codex/config.toml", "codex", None), ("config.toml", "codex?", None),
729
+ (".cursorrules", "rules", None), ("AGENTS.md", "rules", None), ("CLAUDE.md", "rules", None)):
730
+ if os.path.isfile(p(f)):
731
+ found.append((p(f), kind, scope))
732
+ for d, kind in ((".claude/agents", "agent"), (".cursor/rules", "rules"), (".github/workflows", "launch"), (".devcontainer", "launch")):
733
+ if os.path.isdir(p(d)):
734
+ for dirpath, _, files in os.walk(p(d)):
735
+ for fn in sorted(files):
736
+ if kind == "agent" and not fn.endswith(".md"):
737
+ continue
738
+ found.append((os.path.join(dirpath, fn), kind, "project"))
739
+ for fn in ("package.json", "Makefile", "justfile", "docker-compose.yml"):
740
+ if os.path.isfile(p(fn)):
741
+ found.append((p(fn), "launch", None))
742
+ for fn in sorted(os.listdir(root)):
743
+ if fn.endswith(".sh") and os.path.isfile(p(fn)):
744
+ found.append((p(fn), "launch", None))
745
+ return found
746
+
747
+
748
+ def discover_user(home):
749
+ out = []
750
+ for f, kind, scope in ((".claude/settings.json", "claude", "user"), (".claude.json", "mcp", "claude-user"),
751
+ (".codex/config.toml", "codex", None), (".cursor/mcp.json", "mcp", "cursor-user")):
752
+ if os.path.isfile(os.path.join(home, f)):
753
+ out.append((os.path.join(home, f), kind, scope))
754
+ ad = os.path.join(home, ".claude", "agents")
755
+ if os.path.isdir(ad):
756
+ out += [(os.path.join(ad, f), "agent", "user") for f in sorted(os.listdir(ad)) if f.endswith(".md")]
757
+ return out
758
+
759
+
760
+ def mcp_tool(path):
761
+ parts = path.replace(os.sep, "/").split("/")
762
+ if ".cursor" in parts:
763
+ return "cursor"
764
+ if ".vscode" in parts:
765
+ return "vscode"
766
+ return "claude"
767
+
768
+
769
+ def classify_file(path):
770
+ b = os.path.basename(path)
771
+ if b == "settings.local.json":
772
+ return "claude", "local"
773
+ if b == "managed-settings.json":
774
+ return "claude", "managed"
775
+ if b == "settings.json" and os.path.basename(os.path.dirname(path)) == ".vscode":
776
+ return "vscode", None
777
+ if b == "settings.json":
778
+ return "claude", "project"
779
+ if b in (".mcp.json", "mcp.json", ".claude.json"):
780
+ return "mcp", "given"
781
+ if b.endswith(".toml"):
782
+ return "codex", None
783
+ if b.endswith((".mdc",)) or b == ".cursorrules":
784
+ return "rules", None
785
+ if b.endswith(".md") and os.path.basename(os.path.dirname(path)) == "agents":
786
+ return "agent", "project"
787
+ return "plain", None
788
+
789
+
790
+ def run(args):
791
+ rep = Report()
792
+ items, base = [], os.getcwd()
793
+ roots = [a for a in args.paths if os.path.isdir(a)]
794
+ if len(roots) == 1 and len(args.paths) == 1:
795
+ base = os.path.abspath(roots[0])
796
+ for a in args.paths:
797
+ if os.path.isdir(a):
798
+ items += discover_project(a, rep)
799
+ elif os.path.isfile(a):
800
+ k, s = classify_file(a)
801
+ items.append((a, k, s))
802
+ else:
803
+ rep.notes.append("{}: not found".format(a))
804
+ if args.user:
805
+ items += discover_user(args.user)
806
+ if args.managed:
807
+ items.append((args.managed, "claude", "managed"))
808
+ for a in args.also or []:
809
+ items.append((a, "plain", None))
810
+ seen = set()
811
+ for path, kind, scope in items:
812
+ ap = os.path.abspath(path)
813
+ if ap in seen:
814
+ continue
815
+ seen.add(ap)
816
+ r = rel(ap, base)
817
+ if args.user and ap.startswith(os.path.abspath(args.user) + os.sep):
818
+ r = "~/" + os.path.relpath(ap, os.path.abspath(args.user))
819
+ if kind == "claude":
820
+ data, lines = load_json(path, rep.notes)
821
+ scan_claude_settings(data, lines, scope, r, rep)
822
+ elif kind == "mcp":
823
+ data, lines = load_json(path, rep.notes)
824
+ scan_mcp_file(data, lines, r, mcp_tool(ap), rep)
825
+ elif kind == "vscode":
826
+ data, lines = load_json(path, rep.notes)
827
+ scan_vscode(data, lines, r, rep)
828
+ elif kind in ("codex", "codex?"):
829
+ data, lines = load_toml(path, rep.notes)
830
+ if kind == "codex?" and not (isinstance(data, dict) and any(k in data for k in ("approval_policy", "sandbox_mode", "profiles", "mcp_servers", "model_provider"))):
831
+ continue
832
+ scan_codex(data, lines, r, rep)
833
+ elif kind == "agent":
834
+ scan_agent_file(path, r, scope, rep)
835
+ elif kind == "rules":
836
+ scan_rules(path, r, rep)
837
+ elif kind == "launch":
838
+ scan_launch(path, r, rep)
839
+ elif kind == "plain":
840
+ scan_secrets_plain(path, r, rep)
841
+ rep.files.append({"file": r, "kind": kind.rstrip("?"), "scope": scope})
842
+ merge_claude(rep)
843
+ # git-ignore status of local files and of files holding secrets
844
+ if roots:
845
+ pats = gitignore_patterns(roots[0])
846
+ rep.claude["gitignore_found"] = os.path.isfile(os.path.join(roots[0], ".gitignore"))
847
+ check = {f["file"] for f in rep.files if f["scope"] == "local"} | {s["file"] for s in rep.secrets}
848
+ for f in sorted(check):
849
+ if f.startswith("~/") or os.path.isabs(f):
850
+ continue
851
+ ig = ignored(f, pats)
852
+ for s in rep.secrets:
853
+ if s["file"] == f:
854
+ s["gitignored"] = ig
855
+ if not ig:
856
+ rep.flag("not-ignored", f, f, "not matched by .gitignore, so it can be committed" +
857
+ (" - and it holds a literal secret" if any(s["file"] == f for s in rep.secrets) else ""))
858
+ for s in rep.secrets:
859
+ state = s.get("gitignored")
860
+ rep.flag("literal-secret", s["where"], "{} = {}".format(s["name"], s["masked"]),
861
+ "a credential written into the file in plain text" +
862
+ {True: " (git-ignored, but readable by anything that reads the folder)",
863
+ False: "; the file is not git-ignored, so anyone with the repo has it"}.get(state, ""))
864
+ return rep
865
+
866
+
867
+ def render(rep):
868
+ out = ["# Agent configuration scan", "", "Mechanical listing only: what the files grant, merged the way the tools merge them. "
869
+ "Severity and the verdict are the reviewer's call.", ""]
870
+ out.append("## Files read")
871
+ for f in rep.files:
872
+ out.append("- `{}` - {}{}".format(f["file"], f["kind"], " ({} scope)".format(f["scope"]) if f["scope"] in SCOPES else ""))
873
+ seen_scopes = {e["scope"] for e in rep.claude.get("scopes", [])}
874
+ missing = [s for s in SCOPES if s not in seen_scopes]
875
+ if rep.claude.get("scopes") and missing:
876
+ out.append("- Claude Code scopes not read: {} - the effective result below can only be wider than shown".format(", ".join(missing)))
877
+ for n in rep.notes:
878
+ out.append("- note: " + n)
879
+ out.append("")
880
+ eff = rep.claude.get("effective")
881
+ if eff:
882
+ out += ["## Claude Code, effective (scopes merged: user < project < local < managed; rules add up, deny > ask > allow)", ""]
883
+ if eff["rules_only_managed"]:
884
+ out.append("- managed policy: only managed permission rules apply")
885
+ dm = eff["defaultMode"]
886
+ out.append("- defaultMode: {}".format("`{}` from {} scope ({})".format(dm["value"], dm["scope"], dm["where"]) if dm else "not set (default: ask)"))
887
+ for kind in ("allow", "ask", "deny"):
888
+ rules = eff["rules"][kind]
889
+ out.append("- {} ({}): {}".format(kind, len(rules), "; ".join("`{}` {}".format(r["rule"], r["where"]) for r in rules) or "none"))
890
+ if eff["additionalDirectories"]:
891
+ out.append("- additionalDirectories: " + "; ".join("`{}` {}".format(d["path"], d["where"]) for d in eff["additionalDirectories"]))
892
+ if eff["sandbox"]:
893
+ out.append("- sandbox: " + "; ".join("{} = {} ({})".format(k, json.dumps(v["value"]), ", ".join(v["where"])) for k, v in sorted(eff["sandbox"].items())))
894
+ else:
895
+ out.append("- sandbox: not configured in any scope read")
896
+ out.append("- hooks ({}):".format(len(eff["hooks"])) if eff["hooks"] else "- hooks: none")
897
+ for h in eff["hooks"]:
898
+ out.append(" - {} [{}] {} scope, {}: `{}`".format(h["event"], h["matcher"] or "*", h["scope"], h["where"], h["command"]))
899
+ if eff["disableAllHooks"]:
900
+ out.append("- disableAllHooks: {} ({})".format(eff["disableAllHooks"]["value"], eff["disableAllHooks"]["where"]))
901
+ for s in eff["shell_settings"]:
902
+ out.append("- {} runs: `{}` ({})".format(s["key"], s["command"], s["where"]))
903
+ if eff["env"]:
904
+ out.append("- env: " + "; ".join("{}={} ({})".format(k, mask(v["value"]) if literal_secret(k, v["value"]) else json.dumps(v["value"]), v["where"]) for k, v in eff["env"].items()))
905
+ for k, v in eff["mcp_approval"].items():
906
+ out.append("- {}: {} ({})".format(k, json.dumps(v["value"]), v["where"]))
907
+ for k, v in eff["plugins"].items():
908
+ out.append("- plugin `{}` enabled={} ({})".format(k, v["value"], v["where"]))
909
+ for m in eff["marketplaces"]:
910
+ out.append("- marketplace `{}`: {} ({})".format(m["name"], json.dumps(m["source"]), m["where"]))
911
+ out.append("")
912
+ if rep.mcp:
913
+ out += ["## MCP servers", ""]
914
+ for s in rep.mcp:
915
+ how = s["url"] or " ".join([s["command"]] + s["args"])
916
+ extra = []
917
+ if s["env_keys"]:
918
+ extra.append("env: " + ", ".join(s["env_keys"]))
919
+ if s["header_keys"]:
920
+ extra.append("headers: " + ", ".join(s["header_keys"]))
921
+ out.append("- `{}` ({}, {}) {}: `{}`{}".format(s["name"], s["tool"], s["type"], s["where"], how, " - " + "; ".join(extra) if extra else ""))
922
+ out.append("")
923
+ if rep.agents:
924
+ out += ["## Subagents", ""]
925
+ for a in rep.agents:
926
+ out.append("- `{}` ({}): tools: {}; permissionMode: {}; model: {}".format(
927
+ a["name"], a["file"], a["tools"] or "NONE LISTED - inherits everything", a["permissionMode"] or "default", a["model"] or "inherit"))
928
+ out.append("")
929
+ if rep.codex:
930
+ out += ["## Codex", ""]
931
+ for c in rep.codex:
932
+ out.append("- {} {}: approval_policy={}, sandbox_mode={}, network_access={}, env inherit={}".format(
933
+ c["file"], c["profile"], c["approval_policy"], c["sandbox_mode"], c["network_access"], c["env_inherit"]))
934
+ out.append("")
935
+ if rep.vscode:
936
+ out += ["## VS Code / Copilot", ""] + ["- {} {}: {}".format(v["file"], v["key"], json.dumps(v["value"])) for v in rep.vscode] + [""]
937
+ if rep.cursor_rules:
938
+ out += ["## Rules files", ""] + ["- {}: {} invisible characters; lines that fetch or run: {}".format(
939
+ r["file"], r["hidden_chars"], r["lines_that_fetch_or_run"] or "none") for r in rep.cursor_rules] + [""]
940
+ if rep.launch:
941
+ out += ["## Launch flags in scripts", ""] + ["- {}: `{}`".format(l["where"], l["text"]) for l in rep.launch] + [""]
942
+ out += ["## Literal secrets (masked)", ""]
943
+ out += ["- {} `{}` = {}{}".format(s["where"], s["name"], s["masked"], "" if "gitignored" not in s else (" (git-ignored)" if s["gitignored"] else " (NOT git-ignored)")) for s in rep.secrets] or ["- none found"]
944
+ out += ["", "## Patterns to grade ({})".format(len(rep.flags)), ""]
945
+ order = ["literal-secret", "hook-runs-remote", "human-removed", "deny-bypassable", "wide-allow", "no-sandbox", "sandbox-escape",
946
+ "rule-runs-remote", "hidden-characters", "unpinned-package", "unpinned-image", "inherits-all-tools", "mcp-auto-approve",
947
+ "wide-domain", "env-inherit", "network-on", "hook-network", "runs-shell-setting", "hook", "not-ignored", "plugin",
948
+ "fewer-prompts", "container-host-access", "plain-http"]
949
+ for f in sorted(rep.flags, key=lambda f: order.index(f["kind"]) if f["kind"] in order else 99):
950
+ out.append("- [{}] {} `{}` - {}".format(f["kind"], f["where"], f["text"], f["effect"]))
951
+ if not rep.flags:
952
+ out.append("- none")
953
+ return "\n".join(out) + "\n"
954
+
955
+
956
+ def to_json(rep):
957
+ eff = rep.claude.get("effective")
958
+ return json.dumps({"files": rep.files, "notes": rep.notes, "claude_effective": eff, "mcp_servers": rep.mcp,
959
+ "subagents": rep.agents, "codex": rep.codex, "vscode": rep.vscode, "rules": rep.cursor_rules,
960
+ "launch_flags": rep.launch, "secrets": rep.secrets, "patterns": rep.flags}, indent=2, default=str)
961
+
962
+
963
+ def main(argv=None):
964
+ ap = argparse.ArgumentParser(description="List what a coding agent's configuration grants (mechanical layer for a review).")
965
+ ap.add_argument("paths", nargs="+", help="project folders or config files")
966
+ ap.add_argument("--user", help="home folder to read user-scope config from")
967
+ ap.add_argument("--managed", help="managed-settings.json (organisation policy)")
968
+ ap.add_argument("--also", nargs="*", help="other files a config references, searched for literal secrets")
969
+ ap.add_argument("--json", action="store_true", help="emit JSON instead of markdown")
970
+ ap.add_argument("-o", "--out", help="write the report here instead of stdout")
971
+ args = ap.parse_args(argv)
972
+ if not any(os.path.exists(p) for p in args.paths):
973
+ print("error: none of the paths exist: " + ", ".join(args.paths), file=sys.stderr)
974
+ return 2
975
+ rep = run(args)
976
+ if not rep.files:
977
+ print("error: no agent configuration files found in " + ", ".join(args.paths), file=sys.stderr)
978
+ return 1
979
+ text = redact(to_json(rep) if args.json else render(rep))
980
+ if args.out:
981
+ with open(args.out, "w", encoding="utf-8") as fh:
982
+ fh.write(text)
983
+ else:
984
+ sys.stdout.write(text)
985
+ return 0
986
+
987
+
988
+ if __name__ == "__main__":
989
+ sys.exit(main())