agentlatch-security 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_latch/cli.py ADDED
@@ -0,0 +1,500 @@
1
+ """Command-line entry point for AgentLatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.prompt import Confirm, Prompt
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+ from rich_argparse import RawDescriptionRichHelpFormatter
15
+
16
+ from agent_latch import __version__
17
+ from agent_latch.findings import dedupe
18
+ from agent_latch.manifest import ManifestError, find_manifest, scan_manifest
19
+ from agent_latch.owasp import OWASP_AGENTIC_TITLE, category_label, coverage, coverage_status
20
+ from agent_latch.report import json_report, sarif_report, severity_sort_key, text_report
21
+ from agent_latch.rules import (
22
+ DependencyAuditError,
23
+ Finding,
24
+ audit_requirements,
25
+ scan_project,
26
+ )
27
+ from agent_latch.runtime.results import report_runtime_results
28
+ from agent_latch.suppress import IGNORE_FILE, ConfigError, filter_findings, load_ignore_rules
29
+
30
+ _EPILOG = """\
31
+ [bold bright_cyan]EXAMPLES:[/bold bright_cyan]
32
+ [cyan]agent-latch scan[/cyan] scan the current directory
33
+ [cyan]agent-latch scan --config agent-manifest.yaml[/cyan] audit tools and prompts in a manifest
34
+ [cyan]agent-latch scan ./my-agent --fail-on high[/cyan] fail CI on high-severity findings
35
+ [cyan]agent-latch scan . --project-ignores[/cyan] your own repo: apply its .agent-latch-ignore
36
+ [cyan]agent-latch scan . --min-severity medium[/cyan] hide low and info findings
37
+ [cyan]agent-latch scan --format sarif --output results.sarif[/cyan]
38
+ [cyan]agent-latch --interactive[/cyan] guided scan
39
+ [cyan]agent-latch --runtime-result[/cyan] print the runtime gate report
40
+
41
+ [dim]Scans run locally. Findings are heuristics, not a security certification.[/dim]
42
+ """
43
+
44
+
45
+ _SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
46
+
47
+ SEVERITY_STYLES = {
48
+ "critical": "bold white on red",
49
+ "high": "bold red",
50
+ "medium": "yellow",
51
+ "low": "blue",
52
+ "info": "dim",
53
+ "unknown": "dim",
54
+ }
55
+
56
+
57
+ def build_parser() -> argparse.ArgumentParser:
58
+ RawDescriptionRichHelpFormatter.styles.update(
59
+ {"argparse.groups": "bold bright_cyan", "argparse.prog": "bold bright_cyan"}
60
+ )
61
+ RawDescriptionRichHelpFormatter.group_name_formatter = str.upper
62
+ parser = argparse.ArgumentParser(
63
+ prog="agent-latch",
64
+ usage="agent-latch [scan] [path] [options]",
65
+ description=(
66
+ f"[bold bright_cyan]AgentLatch[/bold bright_cyan] [dim]v{__version__}[/dim]\n"
67
+ "Local-first security checks for AI-agent projects."
68
+ ),
69
+ epilog=_EPILOG,
70
+ formatter_class=RawDescriptionRichHelpFormatter,
71
+ )
72
+ parser.add_argument("path", nargs="?", default=".", help="Project directory or file (default: current directory)")
73
+ parser.add_argument("-V", "--version", action="version", version=f"agent-latch {__version__}")
74
+
75
+ checks = parser.add_argument_group("Checks")
76
+ checks.add_argument(
77
+ "--config",
78
+ metavar="MANIFEST",
79
+ help="Agent manifest declaring tools and prompts (default: agent-manifest.yaml in the target, if present)",
80
+ )
81
+ checks.add_argument(
82
+ "--exclude",
83
+ metavar="PATH",
84
+ action="append",
85
+ default=[],
86
+ help="Skip findings under this path or glob, relative to the target (repeatable)",
87
+ )
88
+ checks.add_argument(
89
+ "--ignore-file",
90
+ metavar="FILE",
91
+ help="Ignore file of accepted findings (default: .agent-latch-ignore in the target, if present)",
92
+ )
93
+ checks.add_argument(
94
+ "--project-ignores",
95
+ action="store_true",
96
+ help=(
97
+ "Apply suppressions from the scanned project itself (.agent-latch-ignore, pyproject excludes, "
98
+ "inline ignore comments). Off by default, because the project's author controls them; "
99
+ "use only for code you trust. --exclude and --ignore-file always apply"
100
+ ),
101
+ )
102
+ checks.add_argument(
103
+ "--dependencies",
104
+ action="store_true",
105
+ help="Audit requirements*.txt with pip-audit (contacts advisory service; no package install)",
106
+ )
107
+ checks.add_argument(
108
+ "-i",
109
+ "--interactive",
110
+ action="store_true",
111
+ help="Launch the guided terminal scanner",
112
+ )
113
+
114
+ output = parser.add_argument_group("Output")
115
+ output.add_argument(
116
+ "--runtime-result",
117
+ nargs="?",
118
+ const="runtime-logs/gate-results.csv",
119
+ metavar="CSV",
120
+ help=(
121
+ "Print the runtime gate report from this CSV instead of scanning"
122
+ " (default: runtime-logs/gate-results.csv). Every gate uses the same"
123
+ " columns. The path argument is the project searched for a local tool's file and line"
124
+ ),
125
+ )
126
+ output.add_argument("--format", choices=("text", "json", "sarif"), default="text", help="Report format (default: text)")
127
+ output.add_argument("--output", metavar="FILE", help="Write report to a file instead of stdout")
128
+ output.add_argument(
129
+ "--plain",
130
+ action="store_true",
131
+ help="Plain-text report even in a terminal (the table is used only when stdout is a terminal)",
132
+ )
133
+
134
+ output.add_argument(
135
+ "--min-severity",
136
+ choices=("info", "low", "medium", "high", "critical"),
137
+ default="info",
138
+ help=(
139
+ "Report only findings at or above this severity (default: info, i.e. all). Hidden findings are "
140
+ "counted in the report; unknown-severity findings are always shown"
141
+ ),
142
+ )
143
+
144
+ ci = parser.add_argument_group("CI")
145
+ ci.add_argument(
146
+ "--fail-on",
147
+ choices=("none", "low", "medium", "high", "critical"),
148
+ default="none",
149
+ help="Exit 1 for this severity or higher; unknown-severity findings also fail closed",
150
+ )
151
+ return parser
152
+
153
+
154
+ def _render_coverage(console: Console, findings: list[Finding]) -> None:
155
+ table = Table(
156
+ title=f"{OWASP_AGENTIC_TITLE} · informational mapping",
157
+ title_style="bold",
158
+ show_edge=False,
159
+ )
160
+ table.add_column("ID", style="cyan", no_wrap=True)
161
+ table.add_column("Category")
162
+ table.add_column("Status")
163
+ for entry in coverage(findings):
164
+ if not entry["has_checks"]:
165
+ style = "dim"
166
+ elif entry["finding_count"]:
167
+ style = "bold red"
168
+ else:
169
+ style = "green"
170
+ table.add_row(entry["id"], entry["name"], Text(coverage_status(entry), style=style))
171
+ console.print(table)
172
+
173
+
174
+ def _render_findings(console: Console, findings: list[Finding]) -> None:
175
+ if not findings:
176
+ console.print(
177
+ Panel(
178
+ Text("No findings from the enabled checks. This does not mean the project is secure."),
179
+ title="[green]Scan complete[/green]",
180
+ border_style="green",
181
+ )
182
+ )
183
+ _render_coverage(console, findings)
184
+ return
185
+
186
+ table = Table(title=f"Security findings · {len(findings)}", show_lines=True)
187
+ table.add_column("Severity", no_wrap=True)
188
+ table.add_column("Rule", style="cyan", no_wrap=True)
189
+ table.add_column("Location", style="dim")
190
+ table.add_column("OWASP Agentic")
191
+ table.add_column("Finding")
192
+
193
+ for finding in sorted(findings, key=severity_sort_key):
194
+ style = SEVERITY_STYLES.get(finding.severity, "white")
195
+ detail = Text(finding.title, style="bold")
196
+ detail.append("\n" + finding.message)
197
+ detail.append("\nEvidence: " + finding.evidence, style="dim")
198
+ table.add_row(
199
+ Text(finding.severity.upper(), style=style),
200
+ Text(finding.rule_id),
201
+ Text(f"{finding.path}:{finding.line}:{finding.column}"),
202
+ Text("\n".join(category_label(cid) for cid in finding.owasp) or "—"),
203
+ detail,
204
+ )
205
+ console.print(table)
206
+ _render_coverage(console, findings)
207
+ console.print("[dim]Findings need human review; they are not a security certification.[/dim]")
208
+
209
+
210
+ def _print_fail_summary(blocking: list[Finding], fail_on: str) -> None:
211
+ # stderr keeps machine-readable stdout (json/sarif) intact; color only on a terminal.
212
+ console = Console(stderr=True, highlight=False, soft_wrap=True)
213
+ rule_width = max(len(finding.rule_id) for finding in blocking)
214
+ locations = [f"{finding.path}:{finding.line}" for finding in blocking]
215
+ location_width = max(len(location) for location in locations)
216
+ console.print(
217
+ f"\n[bold red]✗ Failing (exit 1):[/] {len(blocking)} finding(s) at or above "
218
+ f"[bold]--fail-on {fail_on}[/]"
219
+ )
220
+ for finding, location in zip(blocking, locations):
221
+ line = Text(" ")
222
+ line.append(f"{finding.severity.upper():<8}", style=SEVERITY_STYLES.get(finding.severity, "white"))
223
+ line.append(f" {finding.rule_id:<{rule_width}} ")
224
+ line.append(f"{location:<{location_width}}", style="cyan")
225
+ line.append(f" {finding.title}")
226
+ console.print(line)
227
+ console.print(
228
+ "[dim]Fix these, list accepted ones in .agent-latch-ignore (applied with --project-ignores), "
229
+ "or raise --fail-on.[/]"
230
+ )
231
+
232
+
233
+ def filter_min_severity(findings: list[Finding], min_severity: str) -> tuple[list[Finding], int]:
234
+ """Drop findings below min_severity. Unknown severity is kept, so it can still fail closed."""
235
+ threshold = _SEVERITY_RANK[min_severity]
236
+ kept = [
237
+ finding
238
+ for finding in findings
239
+ if finding.severity not in _SEVERITY_RANK or _SEVERITY_RANK[finding.severity] <= threshold
240
+ ]
241
+ return kept, len(findings) - len(kept)
242
+
243
+
244
+ def _render_scan(
245
+ console: Console,
246
+ findings: list[Finding],
247
+ target: Path,
248
+ manifest: Path | None,
249
+ dependency_manifests: int | None,
250
+ suppressed: int = 0,
251
+ min_severity: str = "info",
252
+ hidden: int = 0,
253
+ ) -> None:
254
+ target_message = Text("Target: ", style="bold")
255
+ target_message.append(str(target))
256
+ console.print(target_message)
257
+ if manifest is not None:
258
+ manifest_message = Text("Manifest: ", style="bold")
259
+ manifest_message.append(str(manifest))
260
+ console.print(manifest_message)
261
+ if dependency_manifests is not None:
262
+ if dependency_manifests:
263
+ console.print(
264
+ f"[dim]Dependency audit checked {dependency_manifests} requirements manifest(s).[/dim]"
265
+ )
266
+ else:
267
+ console.print(
268
+ "[yellow]No supported requirements*.txt files found; dependency audit had no coverage.[/yellow]"
269
+ )
270
+ if suppressed:
271
+ console.print(f"[dim]{suppressed} finding(s) suppressed by ignore rules or inline comments.[/dim]")
272
+ if hidden:
273
+ console.print(f"[dim]{hidden} finding(s) below --min-severity {min_severity} not shown.[/dim]")
274
+ _render_findings(console, findings)
275
+
276
+
277
+ def _write_interactive_report(
278
+ console: Console,
279
+ findings: list[Finding],
280
+ target: Path,
281
+ dependency_manifests: int | None,
282
+ ) -> None:
283
+ report_format = Prompt.ask(
284
+ "Export a machine-readable report?",
285
+ choices=["skip", "json", "sarif"],
286
+ default="skip",
287
+ )
288
+ if report_format == "skip":
289
+ return
290
+
291
+ default_name = f"agent-latch-report.{report_format}"
292
+ output_text = Prompt.ask("Report filename", default=default_name)
293
+ output_path = Path(output_text).expanduser()
294
+ if output_path.exists() and not Confirm.ask(
295
+ Text(f"{output_path} already exists. Overwrite it?", style="yellow"), default=False
296
+ ):
297
+ console.print("[yellow]Report export skipped.[/yellow]")
298
+ return
299
+
300
+ report = (
301
+ json_report(findings, str(target), dependency_manifests)
302
+ if report_format == "json"
303
+ else sarif_report(findings, str(target), dependency_manifests)
304
+ )
305
+ output_path.parent.mkdir(parents=True, exist_ok=True)
306
+ output_path.write_text(report + "\n", encoding="utf-8")
307
+ saved_message = Text("Report saved: ", style="green")
308
+ saved_message.append(str(output_path.resolve()))
309
+ console.print(saved_message)
310
+
311
+
312
+ def interactive_main() -> int:
313
+ console = Console()
314
+ console.print(
315
+ Panel.fit(
316
+ "[bold bright_cyan]AgentLatch[/bold bright_cyan]"
317
+ f" [dim]v{__version__}[/dim]\n"
318
+ "[white]Local-first security checks for AI-agent projects[/white]",
319
+ border_style="bright_cyan",
320
+ )
321
+ )
322
+ console.print("[dim]Selected static checks only—not a complete security assessment.[/dim]\n")
323
+
324
+ try:
325
+ target = Path(Prompt.ask("Project folder or file", default=".")).expanduser().resolve()
326
+ if not target.exists():
327
+ console.print(Text(f"Path not found: {target}", style="bold red"))
328
+ return 2
329
+
330
+ project_ignores = Confirm.ask(
331
+ "Apply this project's own ignore file and inline ignore comments? Choose yes only for code you trust.",
332
+ default=False,
333
+ )
334
+ include_dependencies = Confirm.ask(
335
+ "Run optional Python dependency audit? It contacts the advisory service with package names and versions.",
336
+ default=False,
337
+ )
338
+
339
+ manifest = find_manifest(target)
340
+ with console.status("[bold cyan]Scanning source and configuration files…"):
341
+ findings = scan_project(target)
342
+ if manifest is not None:
343
+ findings = dedupe([*scan_manifest(manifest, target), *findings])
344
+
345
+ dependency_manifests: int | None = None
346
+ if include_dependencies:
347
+ with console.status("[bold cyan]Checking dependency advisories…"):
348
+ dependency_findings, dependency_manifests = audit_requirements(target)
349
+ findings.extend(dependency_findings)
350
+
351
+ base = target if target.is_dir() else target.parent
352
+ findings, suppressed = filter_findings(
353
+ findings,
354
+ base,
355
+ load_ignore_rules(target, project_config=project_ignores),
356
+ inline=project_ignores,
357
+ )
358
+
359
+ console.print()
360
+ _render_scan(console, findings, target, manifest, dependency_manifests, suppressed)
361
+ _write_interactive_report(console, findings, target, dependency_manifests)
362
+ return 0
363
+ except (ManifestError, ConfigError) as exc:
364
+ error_message = Text("Configuration error: ", style="bold red")
365
+ error_message.append(str(exc))
366
+ console.print(error_message)
367
+ return 2
368
+ except DependencyAuditError as exc:
369
+ error_message = Text("Dependency audit failed: ", style="bold red")
370
+ error_message.append(str(exc))
371
+ console.print(error_message)
372
+ return 2
373
+ except (KeyboardInterrupt, EOFError):
374
+ console.print("\n[yellow]Scan cancelled.[/yellow]")
375
+ return 130
376
+ except OSError as exc:
377
+ error_message = Text("Could not complete the scan: ", style="bold red")
378
+ error_message.append(str(exc))
379
+ console.print(error_message)
380
+ return 2
381
+
382
+
383
+ def main(argv: list[str] | None = None) -> int:
384
+ argv = sys.argv[1:] if argv is None else argv
385
+ # "scan" is an optional verb; `agent-latch <path>` keeps working. Use ./scan for a folder named scan.
386
+ if argv[:1] == ["scan"]:
387
+ argv = argv[1:]
388
+ args = build_parser().parse_args(argv)
389
+ if args.runtime_result is not None:
390
+ return report_runtime_results(
391
+ Path(args.runtime_result).expanduser(),
392
+ Path(args.path).expanduser().resolve(),
393
+ plain=args.plain,
394
+ min_severity=args.min_severity,
395
+ fail_on=args.fail_on,
396
+ )
397
+ if args.interactive:
398
+ return interactive_main()
399
+
400
+ if args.fail_on != "none" and _SEVERITY_RANK[args.min_severity] < _SEVERITY_RANK[args.fail_on]:
401
+ print(
402
+ f"Error: --min-severity {args.min_severity} would hide {args.fail_on} findings that "
403
+ f"--fail-on {args.fail_on} fails on. Use a --min-severity at or below --fail-on.",
404
+ file=sys.stderr,
405
+ )
406
+ return 2
407
+
408
+ target = Path(args.path).expanduser().resolve()
409
+ if not target.exists():
410
+ print(f"Error: scan target does not exist: {target}", file=sys.stderr)
411
+ return 2
412
+
413
+ if args.config:
414
+ manifest: Path | None = Path(args.config).expanduser()
415
+ if not manifest.is_file():
416
+ print(f"Error: manifest does not exist: {manifest}", file=sys.stderr)
417
+ return 2
418
+ else:
419
+ manifest = find_manifest(target)
420
+
421
+ # Source checks always run; a manifest only adds findings and can never hide them.
422
+ findings = scan_project(target)
423
+ if manifest is not None:
424
+ try:
425
+ findings = dedupe([*scan_manifest(manifest, target), *findings])
426
+ except ManifestError as exc:
427
+ print(f"Manifest error: {exc}", file=sys.stderr)
428
+ return 2
429
+ manifest_text = str(manifest) if manifest is not None else None
430
+
431
+ dependency_manifests: int | None = None
432
+ if args.dependencies:
433
+ try:
434
+ dependency_findings, dependency_manifests = audit_requirements(target)
435
+ except DependencyAuditError as exc:
436
+ print(f"Dependency audit error: {exc}", file=sys.stderr)
437
+ return 2
438
+ findings.extend(dependency_findings)
439
+
440
+ try:
441
+ ignore_file = Path(args.ignore_file).expanduser() if args.ignore_file else None
442
+ ignore_rules = load_ignore_rules(
443
+ target, args.exclude, ignore_file, project_config=args.project_ignores
444
+ )
445
+ except ConfigError as exc:
446
+ print(f"Configuration error: {exc}", file=sys.stderr)
447
+ return 2
448
+ base = target if target.is_dir() else target.parent
449
+ findings, suppressed = filter_findings(findings, base, ignore_rules, inline=args.project_ignores)
450
+ findings, hidden = filter_min_severity(findings, args.min_severity)
451
+ shown_min = args.min_severity if args.min_severity != "info" else None
452
+ if not args.project_ignores and ignore_file is None and (base / IGNORE_FILE).is_file():
453
+ print(
454
+ f"Note: {IGNORE_FILE} in the scanned project was not applied. "
455
+ "Add --project-ignores if you trust this project.",
456
+ file=sys.stderr,
457
+ )
458
+
459
+ if args.format == "json":
460
+ report = json_report(
461
+ findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
462
+ )
463
+ elif args.format == "sarif":
464
+ report = sarif_report(
465
+ findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
466
+ )
467
+ else:
468
+ report = text_report(
469
+ findings, str(target), dependency_manifests, manifest_text, suppressed, shown_min, hidden
470
+ )
471
+
472
+ if args.format == "text" and not args.output and not args.plain and sys.stdout.isatty():
473
+ _render_scan(
474
+ Console(), findings, target, manifest, dependency_manifests, suppressed, args.min_severity, hidden
475
+ )
476
+ elif args.output:
477
+ output = Path(args.output).expanduser()
478
+ output.parent.mkdir(parents=True, exist_ok=True)
479
+ output.write_text(report + "\n", encoding="utf-8")
480
+ print(f"Wrote {args.format} report with {len(findings)} finding(s) to {output}", file=sys.stderr)
481
+ else:
482
+ print(report)
483
+
484
+ if args.fail_on == "none":
485
+ return 0
486
+ severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
487
+ threshold = severity_rank[args.fail_on]
488
+ blocking = [
489
+ finding
490
+ for finding in sorted(findings, key=severity_sort_key)
491
+ if finding.severity == "unknown" or severity_rank.get(finding.severity, 99) <= threshold
492
+ ]
493
+ if not blocking:
494
+ return 0
495
+ _print_fail_summary(blocking, args.fail_on)
496
+ return 1
497
+
498
+
499
+ if __name__ == "__main__":
500
+ raise SystemExit(main())
@@ -0,0 +1,65 @@
1
+ """The Finding value type and helpers shared by every rule module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass, replace
8
+
9
+ # Tests use fake credentials and switch safety features off on purpose. Rules that are noisy there
10
+ # still report test-file findings, at low severity, so a real problem in a test is not hidden.
11
+ _TEST_DIRS = {"test", "tests", "__tests__", "spec", "specs", "testing", "fixtures"}
12
+ _TEST_FILE = re.compile(r"(?i)^(?:test_.*|.*_test\.\w+|.*\.(?:test|spec)\.\w+|conftest\.py)$")
13
+
14
+
15
+ def is_test_path(relative_path: str) -> bool:
16
+ parts = relative_path.split("/")
17
+ return bool(_TEST_FILE.match(parts[-1])) or any(part.lower() in _TEST_DIRS for part in parts[:-1])
18
+
19
+
20
+ # Rules whose findings in test files are reported at low severity (SEC001 handles this itself).
21
+ TEST_DEMOTED_RULES = frozenset({"AGENTLATCH-PY001", "AGENTLATCH-AG004", "AGENTLATCH-AG006", "AGENTLATCH-AG007"})
22
+
23
+
24
+ def demote_test_findings(findings: list[Finding], relative_path: str) -> list[Finding]:
25
+ """In a test file, report TEST_DEMOTED_RULES findings at low severity instead of dropping them."""
26
+ if not is_test_path(relative_path):
27
+ return findings
28
+ return [
29
+ replace(
30
+ finding,
31
+ severity="low",
32
+ confidence="low",
33
+ message=finding.message + " This is a test file, so it is reported at low severity.",
34
+ )
35
+ if finding.rule_id in TEST_DEMOTED_RULES
36
+ else finding
37
+ for finding in findings
38
+ ]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Finding:
43
+ rule_id: str
44
+ title: str
45
+ severity: str
46
+ message: str
47
+ path: str
48
+ line: int
49
+ column: int
50
+ owasp: tuple[str, ...]
51
+ confidence: str = "medium"
52
+ evidence: str = ""
53
+
54
+
55
+ def dedupe(findings: Iterable[Finding]) -> list[Finding]:
56
+ """Keep the first finding per rule and location, e.g. when the manifest and the project walker
57
+ both check the same prompt file."""
58
+ seen: set[tuple[str, str, int, int]] = set()
59
+ kept: list[Finding] = []
60
+ for finding in findings:
61
+ key = (finding.rule_id, finding.path, finding.line, finding.column)
62
+ if key not in seen:
63
+ seen.add(key)
64
+ kept.append(finding)
65
+ return sorted(kept, key=lambda finding: (finding.path, finding.line, finding.rule_id))
@@ -0,0 +1,17 @@
1
+ """Runtime gate plugin namespace.
2
+
3
+ Each gate module exports a unique string ``GATE_ID`` and an
4
+ ``evaluate(event: dict) -> {"findings": list}`` function. The importer supplies each gate with
5
+ the same payload-event JSON object and writes a common result envelope to its CSV output.
6
+
7
+ ``alr04`` ships with the package. It is a monitor-only preview: it reports findings and never
8
+ blocks. The runtime report lists findings only.
9
+
10
+ ``agent-latch --runtime-result`` prints every gate's alerts from ``gate-results.csv``. A finding
11
+ is included when ``is_finding`` is true, or when that key is absent and ``outcome`` is ``FAIL``
12
+ or ``INCONCLUSIVE``. These fields are what the report reads, for this gate and later ones:
13
+ ``rule_id``, ``severity``, ``message``, ``owasp``, ``family`` (the title), ``entity.server``,
14
+ ``entity.tool``, and ``evidence`` entries with ``signal`` and ``field``. Optional ``path``,
15
+ ``line``, and ``column`` are the source location. Without them, the report looks up
16
+ ``entity.tool`` as a function in the project and uses that function's docstring line.
17
+ """