agentsentinel-cli 0.8.3__tar.gz → 0.9.0__tar.gz

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 (38) hide show
  1. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/PKG-INFO +14 -2
  2. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/README.md +13 -1
  3. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/cli.py +70 -0
  4. agentsentinel_cli-0.9.0/agentsentinel_cli/host_report.py +275 -0
  5. agentsentinel_cli-0.9.0/agentsentinel_cli/host_rules.py +488 -0
  6. agentsentinel_cli-0.9.0/agentsentinel_cli/host_scanner.py +418 -0
  7. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/pyproject.toml +1 -1
  8. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/.gitignore +0 -0
  9. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/DOCUMENTATION.md +0 -0
  10. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/LICENSE +0 -0
  11. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/__init__.py +0 -0
  12. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_report.py +0 -0
  13. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_rules.py +0 -0
  14. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/a2a_scanner.py +0 -0
  15. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/discover.py +0 -0
  16. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/discover_report.py +0 -0
  17. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/fingerprint.py +0 -0
  18. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/frameworks.py +0 -0
  19. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/inspect.py +0 -0
  20. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/inspect_report.py +0 -0
  21. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_client.py +0 -0
  22. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_report.py +0 -0
  23. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/mcp_rules.py +0 -0
  24. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/report.py +0 -0
  25. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/rules.py +0 -0
  26. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/scanner.py +0 -0
  27. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets.py +0 -0
  28. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets_report.py +0 -0
  29. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/secrets_rules.py +0 -0
  30. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_ai.py +0 -0
  31. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_report.py +0 -0
  32. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/supply_chain_rules.py +0 -0
  33. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/agentsentinel_cli/suppress.py +0 -0
  34. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/note.md +0 -0
  35. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/README.md +0 -0
  36. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/langchain_agent.py +0 -0
  37. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/mcp_server.py +0 -0
  38. {agentsentinel_cli-0.8.3 → agentsentinel_cli-0.9.0}/tmp/test-mcp-agent/requirements.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentsentinel-cli
3
- Version: 0.8.3
3
+ Version: 0.9.0
4
4
  Summary: AI agent and MCP server security scanner — discovery, static analysis, supply chain audit, and multi-agent trust analysis
5
5
  Project-URL: Homepage, https://github.com/jaydenaung/agentsentinel-cli
6
6
  Project-URL: Repository, https://github.com/jaydenaung/agentsentinel-cli
@@ -221,6 +221,12 @@ sentinel scan ./agents/ --format json
221
221
  sentinel scan ./agents/ --ignore-rule DANGEROUS_GRANTS # suppress accepted finding
222
222
  ```
223
223
 
224
+ **Detects tools defined via:**
225
+ - `@tool` decorator · `BaseTool` / `StructuredTool` subclasses
226
+ - `StructuredTool.from_function(name=...)` · `Tool(name=...)`
227
+ - `bind_tools([...])` · `create_react_agent(llm, tools)` · `create_agent(llm, tools)`
228
+ - `AgentExecutor(tools=[...])` · direct Anthropic/OpenAI API `messages.create(tools=[...])`
229
+
224
230
  **Rules:**
225
231
 
226
232
  | Rule | Severity | Trigger |
@@ -283,7 +289,7 @@ With `ANTHROPIC_API_KEY` set, generates a plain English security summary.
283
289
 
284
290
  Builds a call graph from Python agent source and audits trust boundaries. Detects injection propagation across agent boundaries, unbounded spawning, and code-execution agents accepting unverified delegations.
285
291
 
286
- Supports **LangChain / LangGraph**, **AutoGen**, and **CrewAI**.
292
+ Supports **LangChain / LangGraph**, **AutoGen**, **CrewAI**, and **MCP client → server connections**.
287
293
 
288
294
  ```bash
289
295
  sentinel a2a ./agents/
@@ -292,6 +298,12 @@ sentinel a2a . --fail-on HIGH
292
298
  sentinel a2a . --format json
293
299
  ```
294
300
 
301
+ **Detected patterns:**
302
+ - LangGraph `StateGraph.add_node` / `add_edge` / `add_conditional_edges`
303
+ - AutoGen `initiate_chat`, `GroupChat`, `GroupChatManager`
304
+ - CrewAI `Crew(agents=[...], process=Process.hierarchical)`
305
+ - MCP client connections: `sse_client(url)`, `streamablehttp_client(url)` — surfaces agent → MCP server edges with URL resolution from constants
306
+
295
307
  **Rules:**
296
308
 
297
309
  | Rule | Severity | What it catches |
@@ -187,6 +187,12 @@ sentinel scan ./agents/ --format json
187
187
  sentinel scan ./agents/ --ignore-rule DANGEROUS_GRANTS # suppress accepted finding
188
188
  ```
189
189
 
190
+ **Detects tools defined via:**
191
+ - `@tool` decorator · `BaseTool` / `StructuredTool` subclasses
192
+ - `StructuredTool.from_function(name=...)` · `Tool(name=...)`
193
+ - `bind_tools([...])` · `create_react_agent(llm, tools)` · `create_agent(llm, tools)`
194
+ - `AgentExecutor(tools=[...])` · direct Anthropic/OpenAI API `messages.create(tools=[...])`
195
+
190
196
  **Rules:**
191
197
 
192
198
  | Rule | Severity | Trigger |
@@ -249,7 +255,7 @@ With `ANTHROPIC_API_KEY` set, generates a plain English security summary.
249
255
 
250
256
  Builds a call graph from Python agent source and audits trust boundaries. Detects injection propagation across agent boundaries, unbounded spawning, and code-execution agents accepting unverified delegations.
251
257
 
252
- Supports **LangChain / LangGraph**, **AutoGen**, and **CrewAI**.
258
+ Supports **LangChain / LangGraph**, **AutoGen**, **CrewAI**, and **MCP client → server connections**.
253
259
 
254
260
  ```bash
255
261
  sentinel a2a ./agents/
@@ -258,6 +264,12 @@ sentinel a2a . --fail-on HIGH
258
264
  sentinel a2a . --format json
259
265
  ```
260
266
 
267
+ **Detected patterns:**
268
+ - LangGraph `StateGraph.add_node` / `add_edge` / `add_conditional_edges`
269
+ - AutoGen `initiate_chat`, `GroupChat`, `GroupChatManager`
270
+ - CrewAI `Crew(agents=[...], process=Process.hierarchical)`
271
+ - MCP client connections: `sse_client(url)`, `streamablehttp_client(url)` — surfaces agent → MCP server edges with URL resolution from constants
272
+
261
273
  **Rules:**
262
274
 
263
275
  | Rule | Severity | What it catches |
@@ -864,6 +864,76 @@ def a2a(
864
864
  sys.exit(1)
865
865
 
866
866
 
867
+ # ── sentinel host ─────────────────────────────────────────────────────────────
868
+
869
+ @main.command(name="host")
870
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text",
871
+ help="Output format.")
872
+ @click.option("--fail-on", type=click.Choice(["CRITICAL", "HIGH", "MEDIUM", "LOW"]),
873
+ default=None, help="Exit with code 1 if findings at or above this severity exist.")
874
+ @click.option("--ignore-rule", "ignore_rules", multiple=True, metavar="RULE_ID",
875
+ help="Suppress a finding by rule ID. Repeatable. Also reads .sentinelignore.")
876
+ def host(
877
+ fmt: str,
878
+ fail_on: str | None,
879
+ ignore_rules: tuple[str, ...],
880
+ ) -> None:
881
+ """Audit your local AI security posture.
882
+
883
+ Checks Claude Code and Desktop configurations, MCP server permissions,
884
+ shell credential exposure, macOS privacy permissions (Full Disk Access,
885
+ Screen Recording, Accessibility), system security (SIP, FileVault,
886
+ Gatekeeper), and AI processes exposed on the network.
887
+
888
+ No network calls — all checks are local and read-only.
889
+
890
+ \b
891
+ Examples:
892
+ sentinel host
893
+ sentinel host --format json
894
+ sentinel host --fail-on HIGH
895
+ sentinel host --ignore-rule HOST_LARGE_MEMORY
896
+ """
897
+ from agentsentinel_cli.host_scanner import scan_host
898
+ from agentsentinel_cli.host_rules import run_host_rules, host_posture_score
899
+ from agentsentinel_cli.host_report import print_host_result, as_host_json
900
+ from agentsentinel_cli import suppress as _suppress
901
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
902
+
903
+ _ctx_holder: list = []
904
+
905
+ with Progress(
906
+ SpinnerColumn(),
907
+ TextColumn("[dim]{task.description}[/dim]"),
908
+ TimeElapsedColumn(),
909
+ console=console,
910
+ transient=True,
911
+ ) as progress:
912
+ progress.add_task("Scanning host AI security posture…", total=None)
913
+ _ctx_holder.append(scan_host())
914
+
915
+ ctx = _ctx_holder[0]
916
+ findings = run_host_rules(ctx)
917
+
918
+ sup_rules = _suppress.merge(_suppress.load_ignore_file(Path.cwd()), ignore_rules)
919
+ findings, suppressed = _suppress.apply(findings, sup_rules)
920
+ score = host_posture_score(findings)
921
+
922
+ if fmt == "json":
923
+ click.echo(as_host_json(ctx, findings, score))
924
+ else:
925
+ print_host_result(ctx, findings, score)
926
+ msg = _suppress.notice(suppressed)
927
+ if msg:
928
+ console.print(f" {msg}\n")
929
+
930
+ if fail_on:
931
+ _rank = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
932
+ threshold = _rank.get(fail_on, 0)
933
+ if any(_rank.get(f.severity, 0) >= threshold for f in findings):
934
+ sys.exit(1)
935
+
936
+
867
937
  def _parse_ports(ports_str: str) -> list[int]:
868
938
  """Parse '8000-9001' or '8000,8080,9000' into a list of ints."""
869
939
  ports: list[int] = []
@@ -0,0 +1,275 @@
1
+ """Rich terminal and JSON output for host AI security posture scans."""
2
+
3
+ import json
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich import box
9
+ from rich.text import Text
10
+
11
+ from agentsentinel_cli.host_rules import HostFinding, host_posture_score
12
+ from agentsentinel_cli.host_scanner import HostContext
13
+
14
+ console = Console()
15
+
16
+ _SEVERITY_COLOR = {
17
+ "CRITICAL": "bold red",
18
+ "HIGH": "bold orange1",
19
+ "MEDIUM": "bold yellow",
20
+ "LOW": "bold cyan",
21
+ }
22
+ _STATUS_COLOR = {
23
+ "TRUSTED": "bold green",
24
+ "WATCH": "bold yellow",
25
+ "ALERT": "bold orange1",
26
+ "CRITICAL": "bold red",
27
+ }
28
+ _CATEGORY_LABEL = {
29
+ "config": "Configuration",
30
+ "data_exposure": "Data Exposure",
31
+ "permissions": "Permissions",
32
+ "system": "System Security",
33
+ "network": "Network Exposure",
34
+ }
35
+ _CATEGORY_ORDER = ["config", "data_exposure", "permissions", "system", "network"]
36
+
37
+
38
+ def _status_label(score: int) -> str:
39
+ if score >= 80:
40
+ return "TRUSTED"
41
+ if score >= 60:
42
+ return "WATCH"
43
+ if score >= 40:
44
+ return "ALERT"
45
+ return "CRITICAL"
46
+
47
+
48
+ def _bool_tag(val: bool | None, true_label: str, false_label: str) -> str:
49
+ if val is True:
50
+ return f"[green]{true_label}[/green]"
51
+ if val is False:
52
+ return f"[red]{false_label}[/red]"
53
+ return "[dim]unknown[/dim]"
54
+
55
+
56
+ def print_host_result(ctx: HostContext, findings: list[HostFinding], score: int) -> None:
57
+ """Render the full host posture report to the terminal."""
58
+ status = _status_label(score)
59
+ status_color = _STATUS_COLOR[status]
60
+
61
+ console.print()
62
+ console.print(Panel.fit(
63
+ "[bold white]AgentSentinel — Host AI Security Posture[/bold white]\n"
64
+ "[dim]Local AI tools · macOS privacy permissions · system security[/dim]",
65
+ border_style="bright_blue",
66
+ padding=(0, 2),
67
+ ))
68
+
69
+ # ── Discovery summary ─────────────────────────────────────────────────────
70
+ console.print()
71
+
72
+ if ctx.claude_code:
73
+ n_allowed = len(ctx.claude_code.allowed_tools)
74
+ n_hooks = len(ctx.claude_code.hooks)
75
+ n_mcp = len(ctx.claude_code.mcp_servers)
76
+ console.print(
77
+ f" [bold white]Claude Code[/bold white] [dim]{ctx.claude_code.path}[/dim]\n"
78
+ f" [dim] {n_allowed} allowed tool(s) · "
79
+ f"{n_mcp} MCP server(s) · {n_hooks} hook(s)[/dim]"
80
+ )
81
+ if ctx.claude_code.allowed_tools:
82
+ tools_str = ", ".join(ctx.claude_code.allowed_tools[:8])
83
+ console.print(f" [dim] allowedTools: {tools_str}[/dim]")
84
+ else:
85
+ console.print(" [dim]Claude Code settings not found (~/.claude/settings.json)[/dim]")
86
+
87
+ console.print()
88
+ if ctx.claude_desktop:
89
+ n_mcp = len(ctx.claude_desktop.mcp_servers)
90
+ console.print(
91
+ f" [bold white]Claude Desktop[/bold white] [dim]{ctx.claude_desktop.path}[/dim]\n"
92
+ f" [dim] {n_mcp} MCP server(s)[/dim]"
93
+ )
94
+ else:
95
+ console.print(" [dim]Claude Desktop config not found[/dim]")
96
+
97
+ # MCP servers table
98
+ all_servers = []
99
+ if ctx.claude_code:
100
+ all_servers.extend((s, "Claude Code") for s in ctx.claude_code.mcp_servers)
101
+ if ctx.claude_desktop:
102
+ all_servers.extend((s, "Desktop") for s in ctx.claude_desktop.mcp_servers)
103
+
104
+ if all_servers:
105
+ console.print()
106
+ tbl = Table(box=box.SIMPLE, show_header=True, header_style="dim", padding=(0, 1))
107
+ tbl.add_column("MCP Server", style="bold white", min_width=18)
108
+ tbl.add_column("Source", style="dim", width=12)
109
+ tbl.add_column("Network", width=8)
110
+ tbl.add_column("FS Paths", style="dim", max_width=44)
111
+
112
+ for srv, src in all_servers:
113
+ net = Text("yes", style="yellow") if srv.has_network_access else Text("no", style="dim green")
114
+ paths = ", ".join(srv.filesystem_paths[:2]) if srv.filesystem_paths else "—"
115
+ tbl.add_row(srv.name, src, net, paths)
116
+ console.print(tbl)
117
+
118
+ # System security status
119
+ console.print(
120
+ f" [bold white]macOS Security[/bold white]\n"
121
+ f" [dim] SIP: [/dim]{_bool_tag(ctx.sip_enabled, '✓ Enabled', '✗ Disabled')}\n"
122
+ f" [dim] FileVault: [/dim]{_bool_tag(ctx.filevault_enabled, '✓ On', '✗ Off')}\n"
123
+ f" [dim] Gatekeeper: [/dim]{_bool_tag(ctx.gatekeeper_enabled,'✓ Enabled', '✗ Disabled')}"
124
+ )
125
+
126
+ # TCC permissions (granted only)
127
+ granted_tcc = [p for p in ctx.tcc_permissions if p.granted]
128
+ if granted_tcc:
129
+ console.print()
130
+ console.print(" [bold white]App Privacy Permissions (TCC)[/bold white]")
131
+ for p in granted_tcc:
132
+ service_label = p.service.replace("_", " ").title()
133
+ console.print(f" [dim] {p.app_name:28} {service_label}[/dim]")
134
+
135
+ # Memory files
136
+ if ctx.memory_file_count > 0:
137
+ mb = ctx.memory_total_bytes / (1024 * 1024)
138
+ console.print(
139
+ f"\n [dim]Memory (~/.claude/projects/): "
140
+ f"{ctx.memory_file_count} files, {mb:.1f} MB[/dim]"
141
+ )
142
+
143
+ # Shell key findings
144
+ if ctx.shell_key_findings:
145
+ console.print()
146
+ console.print(" [bold yellow]⚠ AI API keys detected in shell config files[/bold yellow]")
147
+ for key_type, file_path, redacted in ctx.shell_key_findings[:4]:
148
+ console.print(f" [dim] {key_type} in {file_path}: {redacted}[/dim]")
149
+ if len(ctx.shell_key_findings) > 4:
150
+ console.print(f" [dim] … and {len(ctx.shell_key_findings) - 4} more[/dim]")
151
+
152
+ # Scan errors / info notes
153
+ if ctx.scan_errors:
154
+ console.print()
155
+ for err in ctx.scan_errors:
156
+ console.print(f" [dim yellow]ℹ {err}[/dim yellow]")
157
+
158
+ # ── Findings ──────────────────────────────────────────────────────────────
159
+ console.print()
160
+ if findings:
161
+ cats: dict[str, list[HostFinding]] = {}
162
+ for f in findings:
163
+ cats.setdefault(f.category, []).append(f)
164
+
165
+ for cat in _CATEGORY_ORDER:
166
+ if cat not in cats:
167
+ continue
168
+ label = _CATEGORY_LABEL.get(cat, cat.title())
169
+ console.rule(f"[dim]{label}[/dim]", style="dim")
170
+ for f in cats[cat]:
171
+ color = _SEVERITY_COLOR.get(f.severity, "white")
172
+ console.print(
173
+ f"\n [{color}]● {f.severity:<8}[/{color}] [bold white]{f.rule_id}[/bold white]"
174
+ )
175
+ console.print(f" [dim] {f.message}[/dim]")
176
+ if f.detail:
177
+ for line in f.detail.split("\n"):
178
+ console.print(f" [dim] {line.strip()}[/dim]")
179
+ if f.remediation:
180
+ console.print(f" [dim cyan] → {f.remediation}[/dim cyan]")
181
+ console.print()
182
+ else:
183
+ console.print(" [green]✓ No security findings[/green]\n")
184
+
185
+ # ── Footer ────────────────────────────────────────────────────────────────
186
+ bar_filled = int(score / 5)
187
+ bar = "█" * bar_filled + "░" * (20 - bar_filled)
188
+ console.print(
189
+ f" Posture Score [{status_color}]{score:>3}/100[/{status_color}] "
190
+ f"[dim]{bar}[/dim] [{status_color}]{status}[/{status_color}]"
191
+ )
192
+
193
+ n_critical = sum(1 for f in findings if f.severity == "CRITICAL")
194
+ n_high = sum(1 for f in findings if f.severity == "HIGH")
195
+ total = len(findings)
196
+
197
+ console.print()
198
+ console.rule(style="bright_blue")
199
+ parts = [f"[bold white]{total}[/bold white] finding{'s' if total != 1 else ''}"]
200
+ if n_critical:
201
+ parts.append(f"[bold red]{n_critical} CRITICAL[/bold red]")
202
+ if n_high:
203
+ parts.append(f"[bold orange1]{n_high} HIGH[/bold orange1]")
204
+ console.print(" " + " · ".join(parts))
205
+ console.print()
206
+
207
+
208
+ def as_host_json(ctx: HostContext, findings: list[HostFinding], score: int) -> str:
209
+ """Serialize host posture results as JSON."""
210
+ all_mcp: list[dict] = []
211
+ if ctx.claude_code:
212
+ for s in ctx.claude_code.mcp_servers:
213
+ all_mcp.append({
214
+ "name": s.name, "source": "claude_code",
215
+ "has_network_access": s.has_network_access,
216
+ "filesystem_paths": s.filesystem_paths,
217
+ "env_keys": s.env_keys,
218
+ })
219
+ if ctx.claude_desktop:
220
+ for s in ctx.claude_desktop.mcp_servers:
221
+ all_mcp.append({
222
+ "name": s.name, "source": "claude_desktop",
223
+ "has_network_access": s.has_network_access,
224
+ "filesystem_paths": s.filesystem_paths,
225
+ "env_keys": s.env_keys,
226
+ })
227
+
228
+ return json.dumps({
229
+ "scan_type": "host",
230
+ "claude_code": {
231
+ "found": ctx.claude_code is not None,
232
+ "allowed_tools": ctx.claude_code.allowed_tools if ctx.claude_code else [],
233
+ "disallowed_tools": ctx.claude_code.disallowed_tools if ctx.claude_code else [],
234
+ "hook_count": len(ctx.claude_code.hooks) if ctx.claude_code else 0,
235
+ },
236
+ "claude_desktop": {
237
+ "found": ctx.claude_desktop is not None,
238
+ },
239
+ "mcp_servers": all_mcp,
240
+ "memory": {
241
+ "file_count": ctx.memory_file_count,
242
+ "total_bytes": ctx.memory_total_bytes,
243
+ },
244
+ "shell_key_findings": [
245
+ {"key_type": k, "file": f, "redacted": s}
246
+ for k, f, s in ctx.shell_key_findings
247
+ ],
248
+ "tcc_permissions": [
249
+ {"app": p.app_name, "bundle_id": p.bundle_id, "service": p.service, "granted": p.granted}
250
+ for p in ctx.tcc_permissions if p.granted
251
+ ],
252
+ "system_security": {
253
+ "sip_enabled": ctx.sip_enabled,
254
+ "filevault_enabled": ctx.filevault_enabled,
255
+ "gatekeeper_enabled": ctx.gatekeeper_enabled,
256
+ },
257
+ "exposed_processes": [
258
+ {"pid": p.pid, "name": p.name, "address": p.address, "port": p.port}
259
+ for p in ctx.exposed_processes
260
+ ],
261
+ "findings": [
262
+ {
263
+ "severity": f.severity,
264
+ "rule_id": f.rule_id,
265
+ "category": f.category,
266
+ "message": f.message,
267
+ "detail": f.detail,
268
+ "remediation": f.remediation,
269
+ }
270
+ for f in findings
271
+ ],
272
+ "posture_score": score,
273
+ "status": _status_label(score),
274
+ "scan_errors": ctx.scan_errors,
275
+ }, indent=2)