maskflow-cli 0.9.0__tar.gz → 0.10.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 (84) hide show
  1. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/PKG-INFO +3 -1
  2. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/pyproject.toml +11 -1
  3. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/app.py +2 -0
  4. maskflow_cli-0.10.0/src/maskflow_cli/audit_render.py +208 -0
  5. maskflow_cli-0.10.0/src/maskflow_cli/commands/audit_cmd.py +233 -0
  6. maskflow_cli-0.10.0/tests/audit/test_audit_extra_is_optional.py +69 -0
  7. maskflow_cli-0.10.0/tests/audit/test_cli_audit.py +342 -0
  8. maskflow_cli-0.10.0/tests/scan/__init__.py +0 -0
  9. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_agent.py +1 -1
  10. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/.dockerignore +0 -0
  11. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/.gitignore +0 -0
  12. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/Dockerfile +0 -0
  13. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/README.md +0 -0
  14. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/README.md +0 -0
  15. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/generate_sample.py +0 -0
  16. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/examples/sample-llm-traffic.jsonl +0 -0
  17. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/README.md +0 -0
  18. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/_entry.py +0 -0
  19. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/packaging/maskflow.spec +0 -0
  20. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/__init__.py +0 -0
  21. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/bench_render.py +0 -0
  22. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/__init__.py +0 -0
  23. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/agent_cmd.py +0 -0
  24. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/bench_cmd.py +0 -0
  25. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/config_cmd.py +0 -0
  26. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/doctor_cmd.py +0 -0
  27. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/commands/explain_cmd.py +0 -0
  28. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/doctor.py +0 -0
  29. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/doctor_render.py +0 -0
  30. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/evidence.py +0 -0
  31. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/explain.py +0 -0
  32. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/explain_render.py +0 -0
  33. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/render.py +0 -0
  34. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/__init__.py +0 -0
  35. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/aggregate.py +0 -0
  36. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/checkpoint.py +0 -0
  37. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/cmd.py +0 -0
  38. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/errors.py +0 -0
  39. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/fieldsel.py +0 -0
  40. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/pipeline.py +0 -0
  41. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/__init__.py +0 -0
  42. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/assets.py +0 -0
  43. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/build.py +0 -0
  44. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/csv_out.py +0 -0
  45. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/html.py +0 -0
  46. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/json_out.py +0 -0
  47. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/report/summary.py +0 -0
  48. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/severity.py +0 -0
  49. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/__init__.py +0 -0
  50. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_api_common.py +0 -0
  51. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_files.py +0 -0
  52. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_http.py +0 -0
  53. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/_meta.py +0 -0
  54. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/base.py +0 -0
  55. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/csv.py +0 -0
  56. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/dir.py +0 -0
  57. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/helicone.py +0 -0
  58. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/jsonl.py +0 -0
  59. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/langfuse.py +0 -0
  60. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/langsmith.py +0 -0
  61. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/postgres.py +0 -0
  62. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/sources/s3.py +0 -0
  63. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/spec.py +0 -0
  64. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/src/maskflow_cli/scan/worker.py +0 -0
  65. {maskflow_cli-0.9.0/tests/scan → maskflow_cli-0.10.0/tests/audit}/__init__.py +0 -0
  66. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/conftest.py +0 -0
  67. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/partial.toml +0 -0
  68. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/typo.toml +0 -0
  69. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/fixtures/valid.toml +0 -0
  70. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/_fuzz_corpus.py +0 -0
  71. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/conftest.py +0 -0
  72. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_aggregate_and_severity.py +0 -0
  73. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_example_file.py +0 -0
  74. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_fieldsel.py +0 -0
  75. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_pipeline_and_report.py +0 -0
  76. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_report_no_pii_leak.py +0 -0
  77. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_sources_api.py +0 -0
  78. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/scan/test_sources_files.py +0 -0
  79. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_bench.py +0 -0
  80. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_doctor.py +0 -0
  81. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_explain.py +0 -0
  82. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_pack_registration.py +0 -0
  83. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_show.py +0 -0
  84. {maskflow_cli-0.9.0 → maskflow_cli-0.10.0}/tests/test_cli_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: maskflow-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Command-line interface for MaskFlow: .maskflowrc config validation and inspection
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -13,6 +13,8 @@ Requires-Dist: tomli-w>=1.0
13
13
  Requires-Dist: typer>=0.12
14
14
  Provides-Extra: agent
15
15
  Requires-Dist: maskflow-agent<0.2,>=0.1.0; extra == 'agent'
16
+ Provides-Extra: audit
17
+ Requires-Dist: maskflow-audit<0.2,>=0.1.0; extra == 'audit'
16
18
  Provides-Extra: bench
17
19
  Requires-Dist: maskflow-bench<0.2,>=0.1.0; extra == 'bench'
18
20
  Provides-Extra: dev
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maskflow-cli"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Command-line interface for MaskFlow: .maskflowrc config validation and inspection"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -86,6 +86,15 @@ postgres = [
86
86
  bench = [
87
87
  "maskflow-bench>=0.1.0,<0.2",
88
88
  ]
89
+ # `maskflow audit` -- static discovery of LLM egress paths plus (from M2) the
90
+ # canary harness that proves which of them leak. Same policy as [bench] and
91
+ # [evidence] above -- opt-in so a bare `pip install maskflow-cli` (and the
92
+ # standalone PyInstaller binary) doesn't carry an AST analyser, a PyYAML
93
+ # dependency, and eventually an interceptor; audit_cmd.py imports it lazily
94
+ # and every other command runs without it.
95
+ audit = [
96
+ "maskflow-audit>=0.1.0,<0.2",
97
+ ]
89
98
  # `maskflow agent install|uninstall|doctor` -- passthrough to maskflow-agent's
90
99
  # own console script (Claude Code PII guard). Lazy-imported by agent_cmd.py.
91
100
  agent = [
@@ -94,6 +103,7 @@ agent = [
94
103
 
95
104
  [tool.uv.sources]
96
105
  maskflow-agent = { workspace = true }
106
+ maskflow-audit = { workspace = true }
97
107
  maskflow-core = { workspace = true }
98
108
  maskflow-pack-intl = { workspace = true }
99
109
  maskflow-pack-india = { workspace = true }
@@ -12,6 +12,7 @@ import maskflow_pack_india # noqa: F401 -- import side effect registers pack-in
12
12
  import typer
13
13
 
14
14
  from .commands.agent_cmd import agent
15
+ from .commands.audit_cmd import audit
15
16
  from .commands.bench_cmd import bench
16
17
  from .commands.config_cmd import app as config_app
17
18
  from .commands.doctor_cmd import doctor
@@ -24,6 +25,7 @@ app.command("doctor")(doctor)
24
25
  app.command("explain")(explain)
25
26
  app.command("scan")(scan)
26
27
  app.command("bench")(bench)
28
+ app.command("audit")(audit)
27
29
  app.command(
28
30
  "agent",
29
31
  context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
@@ -0,0 +1,208 @@
1
+ """Rich rendering for `maskflow audit`. Kept separate from
2
+ commands/audit_cmd.py so the command body stays thin, same split as
3
+ doctor.py/doctor_render.py and bench_render.py.
4
+
5
+ Only type-checked against maskflow-audit (TYPE_CHECKING guard below,
6
+ `from __future__ import annotations` keeps every annotation an unevaluated
7
+ string at runtime) -- this module never constructs an AuditSummary itself,
8
+ only duck-types over one handed in by audit_cmd.py, so it never needs the
9
+ optional [audit] extra installed just to be imported.
10
+
11
+ Nothing here reads a value: an AuditSummary carries identifiers and counts
12
+ only (see maskflow_audit.report.summary's docstring), so this renderer
13
+ cannot print PII even by accident."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ from rich.console import Console
20
+
21
+ if TYPE_CHECKING:
22
+ from maskflow_audit.baseline.diff import CiVerdict
23
+ from maskflow_audit.fix.loop import FixReport
24
+ from maskflow_audit.report.summary import AuditSummary
25
+
26
+ # Same rationale as doctor_render.py's: a fixed width so output doesn't
27
+ # wrap unpredictably under a non-tty (CI logs, CliRunner in tests).
28
+ CONSOLE_WIDTH = 100
29
+
30
+ #: Longest source list shown per flow before it is summarised. The full
31
+ #: list is always in audit.json; the table is a triage view, and a cell
32
+ #: that folds character-by-character (which is what happens when five
33
+ #: column names meet a long fixture path at a fixed console width) is
34
+ #: worse than no cell at all.
35
+ MAX_SOURCES_SHOWN = 2
36
+
37
+
38
+ #: How a status reads in the terminal, and the colour it reads in.
39
+ _STATUS_STYLE: dict[str, tuple[str, str]] = {
40
+ "unmasked": ("UNMASKED", "bold red"),
41
+ "unknown": ("unknown", "yellow"),
42
+ "masked_via_mask_call": ("masked: mask()", "green"),
43
+ "masked_via_gateway_base_url": ("masked: gateway", "green"),
44
+ "masked_via_integration": ("masked: integration", "green"),
45
+ }
46
+
47
+ #: A proven leak overrides the static status entirely -- it is the one
48
+ #: line in the output that is evidence rather than inference.
49
+ _LEAK_STYLE = ("LEAK PROVEN", "bold white on red")
50
+
51
+ #: What a flow's canary verdict adds to its detail line. `not_exercised`
52
+ #: is spelled out because it is the one a reader is most likely to
53
+ #: misread as safe.
54
+ _CANARY_NOTE: dict[str, str] = {
55
+ "escaped": "canary escaped",
56
+ "contained": "canary contained",
57
+ "not_exercised": "not exercised by the run",
58
+ "not_run": "",
59
+ }
60
+
61
+
62
+ def _sources(flow: object) -> str:
63
+ """A compact source summary: at most MAX_SOURCES_SHOWN, then a count."""
64
+ sources = flow.sources # type: ignore[attr-defined]
65
+ shown = [f"{s.kind}:{s.detail}" if s.detail else s.kind for s in sources]
66
+ if not shown:
67
+ return ""
68
+ if len(shown) <= MAX_SOURCES_SHOWN:
69
+ return ", ".join(shown)
70
+ return ", ".join(shown[:MAX_SOURCES_SHOWN]) + f" (+{len(shown) - MAX_SOURCES_SHOWN})"
71
+
72
+
73
+ def make_console(*, stderr: bool = True) -> Console:
74
+ """Human output goes to stderr so `--out -` stays pipeable."""
75
+ return Console(width=CONSOLE_WIDTH, stderr=stderr, highlight=False, soft_wrap=False)
76
+
77
+
78
+ def render_summary(console: Console, summary: AuditSummary) -> None:
79
+ """The headline, the flow table, and the honest caveat."""
80
+ coverage = summary.coverage
81
+ console.print()
82
+ console.print(f"[bold]{summary.headline}[/bold]")
83
+
84
+ if coverage.total_flows == 0:
85
+ console.print(
86
+ "[yellow]No LLM egress paths found.[/yellow] That means none of the sinks in "
87
+ "the registry appear in this tree -- not that none exist. See the limits note "
88
+ "in the report."
89
+ )
90
+ return
91
+
92
+ parts = []
93
+ if coverage.confirmed_leaks:
94
+ # Listed first and loudest: it is the only number here that is
95
+ # evidence rather than inference.
96
+ parts.append(f"[bold white on red] {coverage.confirmed_leaks} PROVEN LEAK(S) [/]")
97
+ if coverage.unmasked:
98
+ parts.append(f"[bold red]{coverage.unmasked} unmasked[/bold red]")
99
+ if coverage.unknown:
100
+ parts.append(f"[yellow]{coverage.unknown} unknown[/yellow]")
101
+ if coverage.masked:
102
+ parts.append(f"[green]{coverage.masked} masked[/green]")
103
+ console.print(" · ".join(parts))
104
+ console.print()
105
+
106
+ # Deliberately not a rich Table. At a fixed console width a table
107
+ # redistributes space between columns, so one long repo-relative path
108
+ # starves its neighbours until a cell folds one character per line --
109
+ # which is what a real audit of a real tree produces. A two-line block
110
+ # per flow stays readable at any width and never truncates a path.
111
+ for flow in summary.flows:
112
+ if flow.canary_status == "escaped":
113
+ label, style = _LEAK_STYLE
114
+ else:
115
+ label, style = _STATUS_STYLE.get(flow.masking_status, (flow.masking_status, ""))
116
+ badge = f"[{style}]{label:<20}[/{style}]" if style else f"{label:<20}"
117
+ console.print(
118
+ f"{badge} {flow.file}:{flow.line} [dim]{flow.sink_id} · {flow.flow_id}[/dim]"
119
+ )
120
+ sources = _sources(flow)
121
+ detail = f"from {sources}" if sources else "source not traced"
122
+ note = _CANARY_NOTE.get(flow.canary_status, "")
123
+ suffix = f" · {note}" if note else ""
124
+ console.print(f"{'':<20} [dim]{detail} · conf: {flow.confidence}{suffix}[/dim]")
125
+ for escape in flow.canary_escapes[:1]:
126
+ entities = ", ".join(sorted({e.entity_type for e in flow.canary_escapes}))
127
+ console.print(
128
+ f"{'':<20} [red]canary {entities} reached {escape.host}{escape.path}"
129
+ f" ({escape.form})[/red]"
130
+ )
131
+ for frame in escape.app_stack[-3:]:
132
+ console.print(f"{'':<22} [dim]{frame}[/dim]")
133
+
134
+ run = summary.canary_run
135
+ if not summary.scope.canaries_run:
136
+ console.print(
137
+ "[dim]Canaries were not run, so every finding above is predicted, not "
138
+ "proven. Re-run with --prove to confirm which paths actually leak.[/dim]"
139
+ )
140
+ elif run is not None:
141
+ console.print(
142
+ f"[dim]Canary run: seed {run.seed} · {run.requests_intercepted} request(s) "
143
+ f"intercepted (none sent) · {run.escapes_observed} escape(s) observed.[/dim]"
144
+ )
145
+ if run.unattributed_escapes:
146
+ # Discovery missed a sink. This is the most useful bug report
147
+ # the tool can produce about itself, so it is asked for.
148
+ console.print(
149
+ f"[yellow]{run.unattributed_escapes} escape(s) matched no discovered "
150
+ "flow -- a canary got out through a path static analysis did not find. "
151
+ "Please report it.[/yellow]"
152
+ )
153
+ if run.note:
154
+ console.print(f"[yellow]{run.note}[/yellow]")
155
+ for warning in run.warnings:
156
+ console.print(f"[yellow]--allow-env override: {warning}[/yellow]")
157
+ if summary.skipped_files:
158
+ console.print(
159
+ f"[dim]{len(summary.skipped_files)} file(s) skipped (unparseable or "
160
+ f"unreadable); see skipped_files in the report.[/dim]"
161
+ )
162
+
163
+
164
+ def render_ci(console: Console, verdict: CiVerdict, summary: AuditSummary) -> None:
165
+ """The `--ci` gate's output: short, and PR-friendly.
166
+
167
+ GitHub Actions workflow commands (`::error::`, `::notice::`) so a
168
+ failure annotates the run rather than hiding in a log, matching the
169
+ scan action's behaviour.
170
+ """
171
+ if verdict.skipped:
172
+ console.print(f"[yellow]{verdict.summary_line()}[/yellow]")
173
+ console.print(f"::notice::{verdict.summary_line()}")
174
+ return
175
+
176
+ if verdict.ok:
177
+ console.print(f"[green]{verdict.summary_line()}[/green]")
178
+ console.print(f"::notice::{summary.headline}")
179
+ return
180
+
181
+ console.print(f"[bold red]{verdict.summary_line()}[/bold red]\n")
182
+ for problem in verdict.problems:
183
+ console.print(f" [red]•[/red] {problem}")
184
+ console.print(f"\n::error::{verdict.summary_line()}")
185
+ console.print(
186
+ "[dim]If these paths are intentional, re-run `maskflow audit --baseline` "
187
+ "and commit the updated baseline.[/dim]"
188
+ )
189
+
190
+
191
+ def render_fix(console: Console, report: FixReport) -> None:
192
+ """What the fix loop did, or why it did nothing."""
193
+ if not report.model_available:
194
+ console.print(f"\n[yellow]{report.note}[/yellow]")
195
+ return
196
+
197
+ console.print()
198
+ for result in report.fixed:
199
+ gates = result.attempts[-1].gates
200
+ console.print(f"[green]FIXED[/green] {result.file}:{result.line} ({result.flow_id})")
201
+ if gates is not None:
202
+ console.print(f"{'':<6}[dim]{gates.summary()}[/dim]")
203
+ for result in report.unfixed:
204
+ console.print(f"[yellow]NOT FIXED[/yellow] {result.file}:{result.line}")
205
+ console.print(f"{'':<10}[dim]{result.reason}[/dim]")
206
+ if report.fixed:
207
+ console.print("\n[dim]Suggested PR body:[/dim]")
208
+ console.print(report.pr_body())
@@ -0,0 +1,233 @@
1
+ """`maskflow audit <path>` -- find every place a codebase sends text to an
2
+ LLM, prove which of those paths leak, and fix them.
3
+
4
+ `maskflow scan` asks what PII already reached a provider, from logs. This
5
+ asks the forward question, from source: where can text leave, and is it
6
+ guarded? With `--prove` it stops guessing -- it seeds synthetic identities,
7
+ runs the project, intercepts every provider-bound request before it leaves
8
+ the process, and reports which canary reached which host from which line.
9
+
10
+ `maskflow-audit` is an **optional** dependency (`pip install
11
+ 'maskflow-cli[audit]'`) -- imported lazily here, same policy as
12
+ `maskflow_cli.evidence` and `bench_cmd.py`, so a bare CLI install (and the
13
+ standalone PyInstaller binary) doesn't require it and every other command
14
+ runs without it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import typer
23
+
24
+ from ..audit_render import make_console, render_ci, render_fix, render_summary
25
+
26
+ _MISSING = (
27
+ "`maskflow audit` needs the audit extra -- install it with: pip install 'maskflow-cli[audit]'"
28
+ )
29
+
30
+ _EVIDENCE_MISSING = (
31
+ "--evidence needs the evidence extra too -- install it with: "
32
+ "pip install 'maskflow-cli[audit,evidence]'"
33
+ )
34
+
35
+ # Ruff B008: ruff allows a bare typer.Argument()/Option() in a default, but
36
+ # not a nested call inside one (the Path(".") below), so the argument and
37
+ # the list/Path-defaulted options are module-level singletons -- same
38
+ # pattern as scan/cmd.py's and explain_cmd.py's.
39
+ _PATH_ARGUMENT = typer.Argument(Path("."), help="Directory or file to audit.")
40
+ _OUT_OPTION = typer.Option(
41
+ None, "--out", "-o", help="Write the report here ('-' for stdout). Default: audit.<format>."
42
+ )
43
+ _FORMAT_OPTION = typer.Option("json", "--format", help="json | html.")
44
+ _EXCLUDE_OPTION = typer.Option(
45
+ [], "--exclude", help="Directory name to skip, repeatable. Adds to the defaults."
46
+ )
47
+ _PROVE_OPTION = typer.Option(
48
+ False,
49
+ "--prove",
50
+ help="Seed canaries, run the project, and confirm which paths actually leak.",
51
+ )
52
+ _FLOWS_OPTION = typer.Option(
53
+ None,
54
+ "--flows",
55
+ help="Command --prove should run (default: python -m pytest -q).",
56
+ )
57
+ _SEED_OPTION = typer.Option(
58
+ None, "--seed", help="Canary seed, for reproducing a run. Default: fresh each time."
59
+ )
60
+ _IDENTITIES_OPTION = typer.Option(
61
+ 1, "--identities", min=1, help="How many synthetic people to seed."
62
+ )
63
+ _ALLOW_ENV_OPTION = typer.Option(
64
+ False,
65
+ "--allow-env",
66
+ help="Seed even though the environment looks like a real deployment.",
67
+ )
68
+ _BASELINE_OPTION = typer.Option(
69
+ False, "--baseline", help="Write maskflow-audit.baseline.json and exit."
70
+ )
71
+ _CI_OPTION = typer.Option(
72
+ False, "--ci", help="Fail the build on a new unmasked path or a new confirmed leak."
73
+ )
74
+ _BASELINE_FILE_OPTION = typer.Option(
75
+ None, "--baseline-file", help="Baseline path (default: maskflow-audit.baseline.json)."
76
+ )
77
+ _FIX_OPTION = typer.Option(False, "--fix", help="Agentic fix loop. Needs your own model key.")
78
+ _HANDOFF_OPTION = typer.Option(
79
+ False,
80
+ "--handoff",
81
+ help="Write maskflow-audit-tasks.md for your own coding agent. No key needed.",
82
+ )
83
+ _EVIDENCE_OPTION = typer.Option(
84
+ False, "--evidence", help="Emit metadata-only evidence events for this run."
85
+ )
86
+ _QUIET_OPTION = typer.Option(False, "--quiet", help="Suppress the terminal summary.")
87
+
88
+ _FORMATS = ("json", "html")
89
+
90
+
91
+ def _die(message: str, code: int = 1) -> None:
92
+ typer.echo(f"error: {message}", err=True)
93
+ raise typer.Exit(code=code)
94
+
95
+
96
+ def audit( # noqa: PLR0913 -- a CLI surface, each option is deliberate
97
+ path: Path = _PATH_ARGUMENT,
98
+ out: Path | None = _OUT_OPTION,
99
+ fmt: str = _FORMAT_OPTION,
100
+ exclude: list[str] = _EXCLUDE_OPTION,
101
+ prove: bool = _PROVE_OPTION,
102
+ flows: str | None = _FLOWS_OPTION,
103
+ seed: int | None = _SEED_OPTION,
104
+ identities: int = _IDENTITIES_OPTION,
105
+ allow_env: bool = _ALLOW_ENV_OPTION,
106
+ baseline: bool = _BASELINE_OPTION,
107
+ ci: bool = _CI_OPTION,
108
+ baseline_file: Path | None = _BASELINE_FILE_OPTION,
109
+ fix: bool = _FIX_OPTION,
110
+ handoff: bool = _HANDOFF_OPTION,
111
+ evidence: bool = _EVIDENCE_OPTION,
112
+ quiet: bool = _QUIET_OPTION,
113
+ ) -> None:
114
+ """Audits a source tree for unmasked LLM egress paths.
115
+
116
+ Writes a machine-readable report (audit.json) and prints a summary.
117
+ Your code is parsed, never executed; with --prove the project's own
118
+ outbound requests are intercepted before they reach a provider, so
119
+ nothing is ever transmitted. See docs/audit.md for the full reference."""
120
+ try:
121
+ from maskflow_audit.api import run_audit
122
+ from maskflow_audit.baseline import BASELINE_FILENAME, BaselineError, diff_against
123
+ from maskflow_audit.baseline import write_baseline as write_baseline_file
124
+ from maskflow_audit.canary.manifest import SelfCheckFailure
125
+ from maskflow_audit.discovery.walk import DEFAULT_EXCLUDES, discover
126
+ from maskflow_audit.fix import run_fix_loop, write_handoff
127
+ from maskflow_audit.fix.handoff import HANDOFF_FILENAME
128
+ from maskflow_audit.prove import parse_command, run_canary_proof
129
+ from maskflow_audit.report import render
130
+ from maskflow_audit.seeding.safety import ProductionLooksLikely
131
+ from maskflow_audit.sinks.loader import RegistryError, default_registry
132
+ except ModuleNotFoundError as exc:
133
+ typer.echo(_MISSING, err=True)
134
+ raise typer.Exit(code=1) from exc
135
+
136
+ if fmt not in _FORMATS:
137
+ _die(f"--format must be one of {', '.join(_FORMATS)} (got {fmt!r})")
138
+ if not path.exists():
139
+ _die(f"{path}: no such file or directory", code=2)
140
+ if fix and handoff:
141
+ _die("--fix and --handoff are alternatives; pass one or the other")
142
+
143
+ excludes = DEFAULT_EXCLUDES | frozenset(exclude)
144
+ console = make_console()
145
+
146
+ try:
147
+ registry = default_registry()
148
+ except RegistryError as exc:
149
+ # A malformed shipped registry is a packaging bug, not user error.
150
+ _die(str(exc), code=1)
151
+ return
152
+
153
+ proof = None
154
+ if prove:
155
+ discovered, _, _ = discover(path, registry, exclude=excludes)
156
+ try:
157
+ proof = run_canary_proof(
158
+ path,
159
+ discovered,
160
+ command=parse_command(flows),
161
+ seed=seed,
162
+ identities=identities,
163
+ allow_env=allow_env,
164
+ )
165
+ except ProductionLooksLikely as exc:
166
+ _die(str(exc), code=2)
167
+ return
168
+ except SelfCheckFailure as exc:
169
+ # A canary MaskFlow would not detect proves nothing, so this is
170
+ # fatal rather than a warning.
171
+ _die(str(exc), code=1)
172
+ return
173
+
174
+ summary = run_audit(path, sinks=registry, exclude=excludes, prove=proof)
175
+
176
+ baseline_path = baseline_file or (Path.cwd() / BASELINE_FILENAME)
177
+
178
+ if baseline:
179
+ write_baseline_file(baseline_path, summary)
180
+ if not quiet:
181
+ console.print(
182
+ f"wrote {baseline_path} -- commit it, then gate your builds with "
183
+ "`maskflow audit --ci`"
184
+ )
185
+ return
186
+
187
+ if ci:
188
+ try:
189
+ verdict = diff_against(summary, baseline_path)
190
+ except BaselineError as exc:
191
+ _die(str(exc), code=1)
192
+ return
193
+ render_ci(console, verdict, summary)
194
+ raise typer.Exit(code=verdict.exit_code)
195
+
196
+ rendered = render(summary, fmt)
197
+ destination = out if out is not None else Path(f"audit.{fmt}")
198
+ if str(destination) == "-":
199
+ sys.stdout.write(rendered)
200
+ else:
201
+ destination.write_text(rendered, encoding="utf-8")
202
+
203
+ if handoff:
204
+ handoff_path = Path.cwd() / HANDOFF_FILENAME
205
+ tasks = write_handoff(handoff_path, summary)
206
+ if not quiet:
207
+ console.print(f"wrote {handoff_path} ({tasks} task(s))")
208
+
209
+ if fix:
210
+ # No model is wired in by default: the audit never carries a
211
+ # provider dependency or a key. `model=None` reports that plainly
212
+ # and points at --handoff, which needs neither.
213
+ report = run_fix_loop(path, summary, model=None)
214
+ render_fix(console, report)
215
+
216
+ if evidence:
217
+ try:
218
+ from maskflow_audit.evidence import EvidenceExtraMissing, emit_audit_evidence
219
+
220
+ count = emit_audit_evidence(summary, enabled=True)
221
+ except ModuleNotFoundError as exc:
222
+ typer.echo(_EVIDENCE_MISSING, err=True)
223
+ raise typer.Exit(code=1) from exc
224
+ except EvidenceExtraMissing as exc:
225
+ typer.echo(_EVIDENCE_MISSING, err=True)
226
+ raise typer.Exit(code=1) from exc
227
+ if not quiet:
228
+ console.print(f"emitted {count} metadata-only evidence event(s)")
229
+
230
+ if not quiet:
231
+ render_summary(console, summary)
232
+ if str(destination) != "-":
233
+ console.print(f"\nwrote {destination}")
@@ -0,0 +1,69 @@
1
+ """`maskflow-audit` must stay an optional dependency.
2
+
3
+ A bare `pip install maskflow-cli`, and the standalone PyInstaller binary,
4
+ must keep working with every other command intact -- the binary's spec
5
+ collects every `maskflow_cli` submodule, so a module-level
6
+ `import maskflow_audit` anywhere in the CLI would either bundle the audit
7
+ package into the binary or break its build.
8
+
9
+ Checked by AST inspection rather than by uninstalling the package, so the
10
+ guarantee holds in a dev environment where the extra IS installed (which
11
+ is every environment this suite runs in).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import pathlib
18
+
19
+ import maskflow_cli.audit_render as audit_render_mod
20
+ import maskflow_cli.commands.audit_cmd as audit_cmd_mod
21
+ from maskflow_cli.app import app
22
+ from typer.testing import CliRunner
23
+
24
+ runner = CliRunner()
25
+
26
+ _MODULES = (audit_cmd_mod, audit_render_mod)
27
+
28
+
29
+ def _toplevel_imports(module: object) -> set[str]:
30
+ """Root package names imported at module level (not inside a function).
31
+
32
+ A TYPE_CHECKING block counts as module level textually but never runs,
33
+ so it is skipped -- that is exactly how audit_render types itself
34
+ against maskflow-audit without importing it.
35
+ """
36
+ source = pathlib.Path(module.__file__).read_text(encoding="utf-8") # type: ignore[attr-defined]
37
+ tree = ast.parse(source)
38
+ roots: set[str] = set()
39
+ for node in tree.body:
40
+ if isinstance(node, ast.Import):
41
+ roots.update(alias.name.split(".")[0] for alias in node.names)
42
+ elif isinstance(node, ast.ImportFrom) and node.module and not node.level:
43
+ roots.add(node.module.split(".")[0])
44
+ return roots
45
+
46
+
47
+ def test_no_cli_module_imports_maskflow_audit_at_module_level() -> None:
48
+ for module in _MODULES:
49
+ assert "maskflow_audit" not in _toplevel_imports(module), (
50
+ f"{module.__name__} imports maskflow_audit eagerly; it must be "
51
+ "lazy-imported inside the command body"
52
+ )
53
+
54
+
55
+ def test_the_guard_can_actually_see_imports() -> None:
56
+ """Non-vacuity: the helper must find the imports that ARE there."""
57
+ assert "typer" in _toplevel_imports(audit_cmd_mod)
58
+ assert "rich" in _toplevel_imports(audit_render_mod)
59
+
60
+
61
+ def test_the_missing_extra_message_names_the_install_command() -> None:
62
+ # A user who hits this needs the exact command, not a concept.
63
+ assert "pip install 'maskflow-cli[audit]'" in audit_cmd_mod._MISSING
64
+
65
+
66
+ def test_every_other_command_still_works() -> None:
67
+ # The binary ships without the extra; `doctor` must not care.
68
+ result = runner.invoke(app, ["doctor"], env={"HOME": "/nonexistent-home"})
69
+ assert result.exit_code == 0, result.output
@@ -0,0 +1,342 @@
1
+ """`maskflow audit` through the real root app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from maskflow_cli.app import app
10
+ from typer.testing import CliRunner
11
+
12
+ runner = CliRunner()
13
+
14
+ _APP = (
15
+ "from openai import OpenAI\n"
16
+ "client = OpenAI()\n"
17
+ "def summarize(cur):\n"
18
+ " row = cur.fetchone()\n"
19
+ " return client.chat.completions.create(messages=row['aadhaar'])\n"
20
+ )
21
+
22
+
23
+ @pytest.fixture
24
+ def project(tmp_path: Path) -> Path:
25
+ (tmp_path / "app.py").write_text(_APP, encoding="utf-8")
26
+ return tmp_path
27
+
28
+
29
+ def test_writes_a_report_and_exits_zero(project: Path, tmp_path: Path) -> None:
30
+ out = tmp_path / "audit.json"
31
+ result = runner.invoke(app, ["audit", str(project), "--out", str(out)])
32
+ assert result.exit_code == 0, result.output
33
+ data = json.loads(out.read_text(encoding="utf-8"))
34
+ assert data["coverage"]["total_flows"] == 1
35
+ assert data["flows"][0]["masking_status"] == "unmasked"
36
+
37
+
38
+ def _flat(output: str) -> str:
39
+ """Rich wraps at a fixed width, so collapse whitespace before matching
40
+ a phrase -- otherwise the assertion pins the wrap points instead."""
41
+ return " ".join(output.split())
42
+
43
+
44
+ def test_prints_the_headline_and_the_unproven_caveat(project: Path, tmp_path: Path) -> None:
45
+ result = runner.invoke(app, ["audit", str(project), "--out", str(tmp_path / "a.json")])
46
+ assert result.exit_code == 0, result.output
47
+ assert "0 of 1 LLM egress paths masked" in result.output
48
+ # The report must never let a reader think a static finding was proven.
49
+ assert "predicted, not proven" in _flat(result.output)
50
+
51
+
52
+ def test_quiet_suppresses_the_summary(project: Path, tmp_path: Path) -> None:
53
+ out = tmp_path / "a.json"
54
+ result = runner.invoke(app, ["audit", str(project), "--out", str(out), "--quiet"])
55
+ assert result.exit_code == 0, result.output
56
+ assert "LLM egress paths" not in result.output
57
+ assert out.is_file()
58
+
59
+
60
+ def test_out_dash_writes_to_stdout(project: Path) -> None:
61
+ result = runner.invoke(app, ["audit", str(project), "--out", "-", "--quiet"])
62
+ assert result.exit_code == 0, result.output
63
+ assert json.loads(result.stdout)["schema_version"] == "1"
64
+
65
+
66
+ def test_default_output_path_is_audit_json(
67
+ project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
68
+ ) -> None:
69
+ workdir = tmp_path / "cwd"
70
+ workdir.mkdir()
71
+ monkeypatch.chdir(workdir)
72
+ result = runner.invoke(app, ["audit", str(project), "--quiet"])
73
+ assert result.exit_code == 0, result.output
74
+ assert (workdir / "audit.json").is_file()
75
+
76
+
77
+ def test_a_missing_path_exits_two(tmp_path: Path) -> None:
78
+ result = runner.invoke(app, ["audit", str(tmp_path / "nope")])
79
+ assert result.exit_code == 2, result.output
80
+ assert "no such file or directory" in result.output
81
+
82
+
83
+ def test_an_unknown_format_exits_one(project: Path) -> None:
84
+ result = runner.invoke(app, ["audit", str(project), "--format", "pdf"])
85
+ assert result.exit_code == 1, result.output
86
+ assert "--format must be one of" in result.output
87
+
88
+
89
+ def test_exclude_is_repeatable(tmp_path: Path) -> None:
90
+ (tmp_path / "app.py").write_text(_APP, encoding="utf-8")
91
+ for name in ("vendor", "examples"):
92
+ (tmp_path / name).mkdir()
93
+ (tmp_path / name / "x.py").write_text(_APP, encoding="utf-8")
94
+
95
+ out = tmp_path / "a.json"
96
+ result = runner.invoke(
97
+ app,
98
+ [
99
+ "audit",
100
+ str(tmp_path),
101
+ "--exclude",
102
+ "vendor",
103
+ "--exclude",
104
+ "examples",
105
+ "--out",
106
+ str(out),
107
+ "--quiet",
108
+ ],
109
+ )
110
+ assert result.exit_code == 0, result.output
111
+ files = [f["file"] for f in json.loads(out.read_text(encoding="utf-8"))["flows"]]
112
+ assert files == ["app.py"]
113
+
114
+
115
+ def test_a_clean_tree_reports_zero_without_failing(tmp_path: Path) -> None:
116
+ (tmp_path / "plain.py").write_text("def f():\n return 1\n", encoding="utf-8")
117
+ result = runner.invoke(app, ["audit", str(tmp_path), "--out", str(tmp_path / "a.json")])
118
+ assert result.exit_code == 0, result.output
119
+ # A clean report must not read as a clean bill of health.
120
+ assert "not that none exist" in _flat(result.output)
121
+
122
+
123
+ def test_audit_is_registered_on_the_root_app() -> None:
124
+ result = runner.invoke(app, ["--help"])
125
+ assert result.exit_code == 0, result.output
126
+ assert "audit" in result.output
127
+
128
+
129
+ class TestNewSurfaces:
130
+ """--prove / --baseline / --ci / --handoff / --format html."""
131
+
132
+ _LEAKY = (
133
+ "import os, sqlite3, httpx\n"
134
+ "DB = os.path.join(os.path.dirname(__file__), 'app.db')\n"
135
+ "client = httpx.Client(base_url='https://api.openai.com')\n"
136
+ "def summarize():\n"
137
+ " row = sqlite3.connect(DB).execute("
138
+ "'SELECT customer_name, aadhaar FROM tickets').fetchone()\n"
139
+ " return client.post('/v1/chat/completions',\n"
140
+ " json={'messages': [{'role': 'user', 'content': f'{row[0]} {row[1]}'}]})\n"
141
+ )
142
+
143
+ @pytest.fixture
144
+ def leaky_project(self, tmp_path: Path) -> Path:
145
+ import sqlite3
146
+
147
+ (tmp_path / "app.py").write_text(self._LEAKY, encoding="utf-8")
148
+ (tmp_path / "run_flows.py").write_text("import app\napp.summarize()\n", encoding="utf-8")
149
+ connection = sqlite3.connect(tmp_path / "app.db")
150
+ connection.execute(
151
+ "CREATE TABLE tickets (id INTEGER PRIMARY KEY, customer_name TEXT, aadhaar TEXT)"
152
+ )
153
+ connection.commit()
154
+ connection.close()
155
+ return tmp_path
156
+
157
+ def test_html_is_self_contained(self, project: Path, tmp_path: Path) -> None:
158
+ out = tmp_path / "r.html"
159
+ result = runner.invoke(
160
+ app, ["audit", str(project), "--format", "html", "--out", str(out), "--quiet"]
161
+ )
162
+ assert result.exit_code == 0, result.output
163
+ html = out.read_text(encoding="utf-8")
164
+ assert html.startswith("<!doctype html>")
165
+ for needle in ("http://", "https://fonts", "cdn.", "<script src", "<link "):
166
+ assert needle not in html, needle
167
+ assert "DPDP Rule 6" in html
168
+ assert "<!-- DPDP_RULE6_APPENDIX -->" in html
169
+ assert "No raw PII value" in html
170
+
171
+ def test_prove_confirms_the_leak_with_a_call_stack(
172
+ self, leaky_project: Path, tmp_path: Path
173
+ ) -> None:
174
+ out = tmp_path / "a.json"
175
+ result = runner.invoke(
176
+ app,
177
+ [
178
+ "audit",
179
+ str(leaky_project),
180
+ "--prove",
181
+ "--flows",
182
+ "python run_flows.py",
183
+ "--seed",
184
+ "7",
185
+ "--out",
186
+ str(out),
187
+ ],
188
+ )
189
+ assert result.exit_code == 0, result.output
190
+ data = json.loads(out.read_text(encoding="utf-8"))
191
+ assert data["coverage"]["confirmed_leaks"] == 1
192
+ flow = data["flows"][0]
193
+ assert flow["canary_status"] == "escaped"
194
+ assert flow["confidence"] == "confirmed"
195
+ escapes = flow["canary_escapes"]
196
+ assert escapes and escapes[0]["host"] == "api.openai.com"
197
+ assert escapes[0]["app_stack"], "the call stack is the evidence; it must be present"
198
+ assert "LEAK PROVEN" in result.output
199
+
200
+ def test_prove_writes_no_canary_value_into_the_report(
201
+ self, leaky_project: Path, tmp_path: Path
202
+ ) -> None:
203
+ """The report is a document a DPO retains; it must carry no value."""
204
+ out = tmp_path / "a.json"
205
+ result = runner.invoke(
206
+ app,
207
+ [
208
+ "audit",
209
+ str(leaky_project),
210
+ "--prove",
211
+ "--flows",
212
+ "python run_flows.py",
213
+ "--seed",
214
+ "7",
215
+ "--quiet",
216
+ "--out",
217
+ str(out),
218
+ ],
219
+ )
220
+ assert result.exit_code == 0, result.output
221
+ from maskflow_audit.canary.manifest import build_manifest
222
+
223
+ rendered = out.read_text(encoding="utf-8")
224
+ for value in build_manifest(seed=7).values():
225
+ assert value not in rendered
226
+
227
+ def test_prove_restores_the_tree(self, leaky_project: Path, tmp_path: Path) -> None:
228
+ """An audit that left canary rows behind would be a bug."""
229
+ import sqlite3
230
+
231
+ result = runner.invoke(
232
+ app,
233
+ [
234
+ "audit",
235
+ str(leaky_project),
236
+ "--prove",
237
+ "--flows",
238
+ "python run_flows.py",
239
+ "--quiet",
240
+ "--out",
241
+ str(tmp_path / "a.json"),
242
+ ],
243
+ )
244
+ assert result.exit_code == 0, result.output
245
+ connection = sqlite3.connect(leaky_project / "app.db")
246
+ assert connection.execute("SELECT COUNT(*) FROM tickets").fetchone()[0] == 0
247
+
248
+ def test_baseline_then_ci_passes_unchanged(self, project: Path, tmp_path: Path) -> None:
249
+ base = tmp_path / "base.json"
250
+ written = runner.invoke(
251
+ app, ["audit", str(project), "--baseline", "--baseline-file", str(base), "--quiet"]
252
+ )
253
+ assert written.exit_code == 0, written.output
254
+ assert base.is_file()
255
+
256
+ gate = runner.invoke(app, ["audit", str(project), "--ci", "--baseline-file", str(base)])
257
+ assert gate.exit_code == 0, gate.output
258
+ assert "no new unmasked" in gate.output
259
+
260
+ def test_ci_fails_on_a_newly_added_unmasked_call(self, project: Path, tmp_path: Path) -> None:
261
+ base = tmp_path / "base.json"
262
+ runner.invoke(
263
+ app, ["audit", str(project), "--baseline", "--baseline-file", str(base), "--quiet"]
264
+ )
265
+ (project / "app.py").write_text(
266
+ _APP + "def added(t):\n client.chat.completions.create(messages=t)\n",
267
+ encoding="utf-8",
268
+ )
269
+ gate = runner.invoke(app, ["audit", str(project), "--ci", "--baseline-file", str(base)])
270
+ assert gate.exit_code == 1, gate.output
271
+ assert "NEW unguarded egress path" in gate.output
272
+ assert "::error::" in gate.output
273
+
274
+ def test_ci_skips_without_a_baseline(self, project: Path, tmp_path: Path) -> None:
275
+ gate = runner.invoke(
276
+ app, ["audit", str(project), "--ci", "--baseline-file", str(tmp_path / "none.json")]
277
+ )
278
+ assert gate.exit_code == 0, gate.output
279
+ assert "skipped" in gate.output
280
+
281
+ def test_handoff_writes_a_task_file(
282
+ self, project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
283
+ ) -> None:
284
+ workdir = tmp_path / "cwd"
285
+ workdir.mkdir()
286
+ monkeypatch.chdir(workdir)
287
+ result = runner.invoke(
288
+ app, ["audit", str(project), "--handoff", "--quiet", "--out", str(tmp_path / "a.json")]
289
+ )
290
+ assert result.exit_code == 0, result.output
291
+ tasks = (workdir / "maskflow-audit-tasks.md").read_text(encoding="utf-8")
292
+ assert "## Task 1" in tasks
293
+ assert "Never edit a test" in tasks
294
+
295
+ def test_fix_without_a_model_points_at_handoff(self, project: Path, tmp_path: Path) -> None:
296
+ result = runner.invoke(
297
+ app, ["audit", str(project), "--fix", "--out", str(tmp_path / "a.json")]
298
+ )
299
+ assert result.exit_code == 0, result.output
300
+ assert "--handoff" in _flat(result.output)
301
+
302
+ def test_fix_and_handoff_are_mutually_exclusive(self, project: Path) -> None:
303
+ result = runner.invoke(app, ["audit", str(project), "--fix", "--handoff"])
304
+ assert result.exit_code == 1, result.output
305
+ assert "alternatives" in result.output
306
+
307
+ def test_allow_env_is_required_in_a_production_looking_env(
308
+ self, leaky_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
309
+ ) -> None:
310
+ """Seeding production would be a data incident caused by the tool
311
+ that exists to prevent data incidents."""
312
+ monkeypatch.setenv("APP_ENV", "production")
313
+ refused = runner.invoke(
314
+ app,
315
+ [
316
+ "audit",
317
+ str(leaky_project),
318
+ "--prove",
319
+ "--flows",
320
+ "python run_flows.py",
321
+ "--out",
322
+ str(tmp_path / "a.json"),
323
+ ],
324
+ )
325
+ assert refused.exit_code == 2, refused.output
326
+ assert "refusing to seed" in refused.output
327
+
328
+ allowed = runner.invoke(
329
+ app,
330
+ [
331
+ "audit",
332
+ str(leaky_project),
333
+ "--prove",
334
+ "--flows",
335
+ "python run_flows.py",
336
+ "--allow-env",
337
+ "--quiet",
338
+ "--out",
339
+ str(tmp_path / "b.json"),
340
+ ],
341
+ )
342
+ assert allowed.exit_code == 0, allowed.output
File without changes
@@ -10,6 +10,6 @@ def test_agent_passthrough_reaches_maskflow_agent() -> None:
10
10
 
11
11
  def test_agent_install_writes_hooks(tmp_path, monkeypatch) -> None:
12
12
  monkeypatch.chdir(tmp_path)
13
- result = CliRunner().invoke(app, ["agent", "install", "--scope", "local"])
13
+ result = CliRunner().invoke(app, ["agent", "install", "--scope", "local", "--no-warm-up"])
14
14
  assert result.exit_code == 0, result.output
15
15
  assert (tmp_path / ".claude" / "settings.local.json").is_file()
File without changes
File without changes
File without changes