permdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. permdiff/__init__.py +26 -0
  2. permdiff/_proc.py +69 -0
  3. permdiff/api.py +203 -0
  4. permdiff/cli/__init__.py +1 -0
  5. permdiff/cli/_render.py +115 -0
  6. permdiff/cli/check.py +50 -0
  7. permdiff/cli/convert.py +62 -0
  8. permdiff/cli/demo.py +90 -0
  9. permdiff/cli/diff.py +91 -0
  10. permdiff/cli/init.py +43 -0
  11. permdiff/cli/main.py +85 -0
  12. permdiff/cli/render.py +80 -0
  13. permdiff/cli/schema.py +14 -0
  14. permdiff/cli/settings.py +316 -0
  15. permdiff/cli/setup.py +27 -0
  16. permdiff/config/__init__.py +17 -0
  17. permdiff/config/load.py +144 -0
  18. permdiff/config/model.py +67 -0
  19. permdiff/config/write.py +52 -0
  20. permdiff/demo/__init__.py +15 -0
  21. permdiff/demo/corpus.jsonl +200 -0
  22. permdiff/demo/engine.py +85 -0
  23. permdiff/demo/policy_base/agent.cedar +19 -0
  24. permdiff/demo/policy_base/agent.rego +23 -0
  25. permdiff/demo/policy_base/rules.py +22 -0
  26. permdiff/demo/policy_base/schema.cedarschema +11 -0
  27. permdiff/demo/policy_head/agent.cedar +30 -0
  28. permdiff/demo/policy_head/agent.rego +47 -0
  29. permdiff/demo/policy_head/rules.py +31 -0
  30. permdiff/demo/policy_head/schema.cedarschema +11 -0
  31. permdiff/errors.py +39 -0
  32. permdiff/evaluators/__init__.py +7 -0
  33. permdiff/evaluators/base.py +61 -0
  34. permdiff/evaluators/cedar/__init__.py +24 -0
  35. permdiff/evaluators/cedar/evaluator.py +160 -0
  36. permdiff/evaluators/cedar/loader.py +127 -0
  37. permdiff/evaluators/cedar/request.py +137 -0
  38. permdiff/evaluators/opa/__init__.py +16 -0
  39. permdiff/evaluators/opa/binary.py +195 -0
  40. permdiff/evaluators/opa/capabilities.py +76 -0
  41. permdiff/evaluators/opa/evaluator.py +283 -0
  42. permdiff/evaluators/opa/mapping.py +86 -0
  43. permdiff/evaluators/opa/ndcache.py +72 -0
  44. permdiff/evaluators/opa/shim.py +105 -0
  45. permdiff/evaluators/python_callable.py +65 -0
  46. permdiff/evaluators/registry.py +109 -0
  47. permdiff/importers/__init__.py +7 -0
  48. permdiff/importers/base.py +40 -0
  49. permdiff/importers/custody.py +167 -0
  50. permdiff/importers/filters.py +84 -0
  51. permdiff/importers/jsonl.py +57 -0
  52. permdiff/importers/limits.py +30 -0
  53. permdiff/importers/lines.py +67 -0
  54. permdiff/importers/otel/__init__.py +85 -0
  55. permdiff/importers/otel/anyvalue.py +51 -0
  56. permdiff/importers/otel/fallback.py +85 -0
  57. permdiff/importers/otel/mapping.py +150 -0
  58. permdiff/importers/otel/spans.py +84 -0
  59. permdiff/importers/registry.py +113 -0
  60. permdiff/models/__init__.py +39 -0
  61. permdiff/models/base.py +11 -0
  62. permdiff/models/decision.py +98 -0
  63. permdiff/models/limits.py +33 -0
  64. permdiff/models/report.py +57 -0
  65. permdiff/models/toolcall.py +91 -0
  66. permdiff/models/transition.py +48 -0
  67. permdiff/policy/__init__.py +23 -0
  68. permdiff/policy/git.py +83 -0
  69. permdiff/policy/source.py +126 -0
  70. permdiff/py.typed +0 -0
  71. permdiff/redact/__init__.py +7 -0
  72. permdiff/redact/redactor.py +206 -0
  73. permdiff/replay/__init__.py +7 -0
  74. permdiff/replay/runner.py +114 -0
  75. permdiff/report/__init__.py +34 -0
  76. permdiff/report/exit_codes.py +61 -0
  77. permdiff/report/grouping.py +143 -0
  78. permdiff/report/json_.py +86 -0
  79. permdiff/report/markdown.py +134 -0
  80. permdiff/report/sarif.py +172 -0
  81. permdiff/report/summary.py +77 -0
  82. permdiff/report/terminal.py +149 -0
  83. permdiff/report/view.py +88 -0
  84. permdiff/schemas/__init__.py +47 -0
  85. permdiff/schemas/decision.json +75 -0
  86. permdiff/schemas/report.json +733 -0
  87. permdiff/schemas/toolcall.json +261 -0
  88. permdiff-0.1.0.dist-info/METADATA +196 -0
  89. permdiff-0.1.0.dist-info/RECORD +92 -0
  90. permdiff-0.1.0.dist-info/WHEEL +4 -0
  91. permdiff-0.1.0.dist-info/entry_points.txt +2 -0
  92. permdiff-0.1.0.dist-info/licenses/LICENSE +202 -0
permdiff/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """permdiff: terraform plan for AI agent permission changes.
2
+
3
+ Replays recorded agent tool calls against an authorization policy at two git
4
+ refs and reports which decisions change.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from permdiff.api import diff, load_traces
10
+ from permdiff.evaluators import Evaluator
11
+ from permdiff.models import Decision, Effect, ErrorKind, Report, ToolCall, Transition
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "Decision",
17
+ "Effect",
18
+ "ErrorKind",
19
+ "Evaluator",
20
+ "Report",
21
+ "ToolCall",
22
+ "Transition",
23
+ "__version__",
24
+ "diff",
25
+ "load_traces",
26
+ ]
permdiff/_proc.py ADDED
@@ -0,0 +1,69 @@
1
+ """Subprocess wrapper. Argument lists only, never a shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from permdiff.errors import ProcError
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProcResult:
19
+ """Outcome of one subprocess run."""
20
+
21
+ argv: tuple[str, ...]
22
+ returncode: int
23
+ stdout: str
24
+ stderr: str
25
+
26
+ @property
27
+ def ok(self) -> bool:
28
+ return self.returncode == 0
29
+
30
+
31
+ def run(
32
+ argv: Sequence[str | os.PathLike[str]],
33
+ *,
34
+ cwd: Path | None = None,
35
+ env: Mapping[str, str] | None = None,
36
+ stdin: str | None = None,
37
+ timeout: float | None = None,
38
+ ) -> ProcResult:
39
+ """Run ``argv`` without a shell and capture text output.
40
+
41
+ Raises ProcError when the executable is missing; a non-zero exit is
42
+ returned, not raised, so callers can attach context.
43
+ """
44
+ args = tuple(os.fspath(a) for a in argv)
45
+ log.debug("run: %s (cwd=%s)", " ".join(args), cwd)
46
+ try:
47
+ completed = subprocess.run( # noqa: S603 # argument list, no shell
48
+ args,
49
+ cwd=cwd,
50
+ env=dict(env) if env is not None else None,
51
+ input=stdin,
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ timeout=timeout,
56
+ )
57
+ except FileNotFoundError as exc:
58
+ msg = f"executable not found: {args[0]}"
59
+ raise ProcError(msg) from exc
60
+ except subprocess.TimeoutExpired as exc:
61
+ msg = f"timed out after {timeout}s: {args[0]}"
62
+ raise ProcError(msg) from exc
63
+ log.debug("exit %d: %s", completed.returncode, args[0])
64
+ return ProcResult(
65
+ argv=args,
66
+ returncode=completed.returncode,
67
+ stdout=completed.stdout,
68
+ stderr=completed.stderr,
69
+ )
permdiff/api.py ADDED
@@ -0,0 +1,203 @@
1
+ """Public Python API (FR-27): ``load_traces`` and ``diff``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import logging
7
+ import time
8
+ from collections.abc import Mapping, Sequence
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from permdiff.errors import TraceImportError
14
+ from permdiff.evaluators import registry as engines
15
+ from permdiff.importers import registry as importers
16
+ from permdiff.importers.base import ImportResult, ImportStats
17
+ from permdiff.models import Counts, Report, ReportHeader, ToolCall
18
+ from permdiff.models.limits import DEFAULT_MAX_RECORDS
19
+ from permdiff.policy import PolicySource, source_for
20
+ from permdiff.redact import new_salt
21
+ from permdiff.replay import replay
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ AUTO_FORMAT = "auto"
26
+
27
+
28
+ def _expand(patterns: Sequence[str | Path]) -> list[Path]:
29
+ paths: list[Path] = []
30
+ for pattern in patterns:
31
+ text = str(pattern)
32
+ matches = sorted(glob.glob(text)) if glob.has_magic(text) else [text] # noqa: PTH207
33
+ if not matches:
34
+ msg = f"--traces {text!r} matched no files"
35
+ raise TraceImportError(msg)
36
+ paths.extend(Path(m) for m in matches)
37
+ if not paths:
38
+ msg = "no trace files given; pass --traces FILE|GLOB"
39
+ raise TraceImportError(msg)
40
+ return paths
41
+
42
+
43
+ def load_traces(
44
+ paths: Sequence[str | Path],
45
+ *,
46
+ fmt: str = AUTO_FORMAT,
47
+ strict: bool = False,
48
+ max_records: int = DEFAULT_MAX_RECORDS,
49
+ importer_options: Mapping[str, Any] | None = None,
50
+ ) -> ImportResult:
51
+ """Read every file (globs expanded) into canonical calls, in path order.
52
+
53
+ ``fmt`` is an importer name or ``auto`` to sniff each file (FR-5). ``importer_options``
54
+ reach importers that accept them (for example ``principal_from`` for OTel).
55
+ """
56
+ options = dict(importer_options or {})
57
+ calls: list[ToolCall] = []
58
+ read = skipped = 0
59
+ locators: list[str] = []
60
+ started = time.perf_counter()
61
+ for path in _expand(paths):
62
+ importer = (
63
+ importers.detect(path, **options)
64
+ if fmt == AUTO_FORMAT
65
+ else importers.get_for(path, fmt, **options)
66
+ )
67
+ result = importer.read(path, strict=strict, max_records=max_records)
68
+ calls.extend(result.calls)
69
+ if len(calls) > max_records:
70
+ msg = (
71
+ f"{path}: corpus exceeds max_records={max_records} across --traces files; "
72
+ "raise the cap or split the run"
73
+ )
74
+ raise TraceImportError(msg)
75
+ read += result.stats.read
76
+ skipped += result.stats.skipped
77
+ locators.extend(result.stats.skipped_locators)
78
+ log.info(
79
+ "imported %d calls (%d skipped) in %.2fs", read, skipped, time.perf_counter() - started
80
+ )
81
+ stats = ImportStats(read=read, skipped=skipped, skipped_locators=tuple(locators))
82
+ return ImportResult(calls=tuple(calls), stats=stats)
83
+
84
+
85
+ def diff(
86
+ *,
87
+ traces: Sequence[ToolCall],
88
+ window: tuple[datetime, datetime] | None = None,
89
+ base: str,
90
+ head: str,
91
+ policy: str,
92
+ engine: str,
93
+ repo: Path = Path(),
94
+ engine_options: Mapping[str, Any] | None = None,
95
+ salt: bytes | None = None,
96
+ verify_deterministic: bool = False,
97
+ keep_temp: bool = False,
98
+ import_stats: ImportStats | None = None,
99
+ filtered: int = 0,
100
+ allow_widening: tuple[str, str] | None = None,
101
+ ) -> Report:
102
+ """Replay ``traces`` against ``policy`` at ``base`` and ``head``; return an unredacted Report.
103
+
104
+ Apply a ``Redactor`` built with the same ``salt`` before rendering.
105
+ """
106
+ return diff_sources(
107
+ traces=traces,
108
+ base=source_for(repo, base, policy),
109
+ head=source_for(repo, head, policy),
110
+ policy_path=policy,
111
+ engine=engine,
112
+ window=window,
113
+ engine_options=engine_options,
114
+ salt=salt,
115
+ verify_deterministic=verify_deterministic,
116
+ keep_temp=keep_temp,
117
+ import_stats=import_stats,
118
+ filtered=filtered,
119
+ allow_widening=allow_widening,
120
+ )
121
+
122
+
123
+ def diff_sources(
124
+ *,
125
+ traces: Sequence[ToolCall],
126
+ window: tuple[datetime, datetime] | None = None,
127
+ base: PolicySource,
128
+ head: PolicySource,
129
+ policy_path: str,
130
+ engine: str,
131
+ engine_options: Mapping[str, Any] | None = None,
132
+ salt: bytes | None = None,
133
+ verify_deterministic: bool = False,
134
+ keep_temp: bool = False,
135
+ import_stats: ImportStats | None = None,
136
+ filtered: int = 0,
137
+ allow_widening: tuple[str, str] | None = None,
138
+ ) -> Report:
139
+ """``diff`` over explicit policy sources (git refs, the worktree, or plain directories)."""
140
+ evaluator = engines.resolve(engine, **dict(engine_options or {}))
141
+ run_salt = salt if salt is not None else new_salt()
142
+ started = time.perf_counter()
143
+ with (
144
+ base.materialize(keep=keep_temp) as base_policy,
145
+ head.materialize(keep=keep_temp) as head_policy,
146
+ ):
147
+ log.info("materialized policies in %.2fs", time.perf_counter() - started)
148
+ started = time.perf_counter()
149
+ result = replay(
150
+ traces,
151
+ evaluator,
152
+ base_policy,
153
+ head_policy,
154
+ verify_deterministic=verify_deterministic,
155
+ )
156
+ log.info(
157
+ "evaluated %d calls at both refs in %.2fs", len(traces), time.perf_counter() - started
158
+ )
159
+ header = ReportHeader(
160
+ base_label=base_policy.label,
161
+ base_sha=base_policy.sha,
162
+ head_label=head_policy.label,
163
+ head_sha=head_policy.sha,
164
+ is_worktree=head_policy.is_worktree,
165
+ policy_path=policy_path,
166
+ policy_files=_policy_files(head_policy.path),
167
+ engine=getattr(evaluator, "label", evaluator.name),
168
+ window=window if window is not None else _window(traces),
169
+ salt=run_salt.hex(),
170
+ undefined_policy=getattr(evaluator, "undefined_policy", None),
171
+ generated_at=datetime.now(tz=UTC),
172
+ )
173
+ counts = _merge_counts(result.counts, import_stats, filtered)
174
+ return Report(
175
+ header=header, transitions=result.transitions, counts=counts, allow_widening=allow_widening
176
+ )
177
+
178
+
179
+ def _policy_files(root: Path, limit: int = 200) -> tuple[str, ...]:
180
+ """Relative paths of regular files under ``root`` (or ``root`` itself), sorted, capped."""
181
+ if root.is_file():
182
+ return (root.name,)
183
+ files = sorted(
184
+ p.relative_to(root).as_posix()
185
+ for p in root.rglob("*")
186
+ if p.is_file() and not p.name.startswith(".")
187
+ )
188
+ return tuple(files[:limit])
189
+
190
+
191
+ def _window(traces: Sequence[ToolCall]) -> tuple[datetime, datetime] | None:
192
+ if not traces:
193
+ return None
194
+ stamps = [t.timestamp for t in traces]
195
+ return min(stamps), max(stamps)
196
+
197
+
198
+ def _merge_counts(counts: Counts, stats: ImportStats | None, filtered: int) -> Counts:
199
+ imported = stats.read if stats else counts.evaluated + filtered
200
+ skipped = stats.skipped if stats else 0
201
+ return counts.model_copy(
202
+ update={"imported": imported, "skipped": skipped, "filtered": filtered}
203
+ )
@@ -0,0 +1 @@
1
+ """Command-line interface."""
@@ -0,0 +1,115 @@
1
+ """Shared tail of ``diff`` and ``demo``: redact once, render, exit through the gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from permdiff.errors import ConfigError
13
+ from permdiff.models import Report
14
+ from permdiff.redact import RedactLevel, Redactor
15
+ from permdiff.report import (
16
+ FailOn,
17
+ ReportView,
18
+ build_view,
19
+ gate,
20
+ render_json,
21
+ render_markdown,
22
+ render_sarif,
23
+ render_terminal,
24
+ )
25
+ from permdiff.report.grouping import DEFAULT_GROUP_BY, DEFAULT_MAX_GROUPS, DEFAULT_SAMPLES
26
+
27
+ FORMATS = ("terminal", "markdown", "json", "sarif")
28
+ PRINCIPAL_SHOWN_IN = frozenset({"terminal"})
29
+ """Formats that print principal ids verbatim (overview §3.6); every other format hashes them."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class OutputOptions:
34
+ fail_on: FailOn
35
+ redact: RedactLevel
36
+ show_args: frozenset[str]
37
+ fmt: str = "terminal"
38
+ samples: int = DEFAULT_SAMPLES
39
+ group_by: tuple[str, ...] = DEFAULT_GROUP_BY
40
+ max_groups: int = DEFAULT_MAX_GROUPS
41
+ show_attribution: bool = False
42
+ quiet: bool = False
43
+ no_color: bool = False
44
+ output: Path | None = None
45
+ pr_comment: bool = False
46
+ include_decisions: bool = False
47
+
48
+ @property
49
+ def show_principal(self) -> bool:
50
+ return self.fmt in PRINCIPAL_SHOWN_IN
51
+
52
+ def validated(self) -> OutputOptions:
53
+ """``--pr-comment`` forces markdown and refuses ``--redact none`` (AC-17.3)."""
54
+ if not self.pr_comment:
55
+ return self
56
+ if self.redact is RedactLevel.NONE:
57
+ msg = "--pr-comment refuses --redact none: PR comments must never carry raw trace data"
58
+ raise ConfigError(msg)
59
+ return OutputOptions(**{**self.__dict__, "fmt": "markdown"})
60
+
61
+
62
+ def parse_show_args(text: str) -> frozenset[str]:
63
+ return frozenset(k.strip() for k in text.split(",") if k.strip())
64
+
65
+
66
+ def use_color(no_color: bool) -> bool:
67
+ return not no_color and not os.environ.get("NO_COLOR") and sys.stdout.isatty()
68
+
69
+
70
+ def emit_and_exit(ctx: click.Context, report: Report, opts: OutputOptions) -> None:
71
+ """Redact with the report's own salt, print, and exit with the gate code."""
72
+ redactor = Redactor(
73
+ level=opts.redact,
74
+ salt=bytes.fromhex(report.header.salt),
75
+ show_args=opts.show_args,
76
+ show_principal=opts.show_principal,
77
+ )
78
+ exit_code = gate(report, opts.fail_on)
79
+ view = build_view(
80
+ report,
81
+ redactor=redactor,
82
+ by=opts.group_by,
83
+ samples=opts.samples,
84
+ max_groups=opts.max_groups,
85
+ show_attribution=opts.show_attribution,
86
+ include_decisions=opts.include_decisions and opts.fmt == "json",
87
+ )
88
+ text = render(view, opts, exit_code=exit_code)
89
+ if opts.output is not None:
90
+ opts.output.write_text(text, encoding="utf-8")
91
+ click.echo(f"wrote {opts.fmt} report to {opts.output}", err=True)
92
+ else:
93
+ click.echo(text, nl=False)
94
+ ctx.exit(exit_code)
95
+
96
+
97
+ def render(view: ReportView, opts: OutputOptions, *, exit_code: int) -> str:
98
+ if opts.fmt == "markdown":
99
+ return render_markdown(view, exit_code=exit_code, fail_on=opts.fail_on)
100
+ if opts.fmt == "sarif":
101
+ return render_sarif(view, exit_code=exit_code, fail_on=opts.fail_on)
102
+ if opts.fmt == "json":
103
+ return render_json(
104
+ view,
105
+ exit_code=exit_code,
106
+ fail_on=opts.fail_on,
107
+ include_decisions=opts.include_decisions,
108
+ )
109
+ return render_terminal(
110
+ view,
111
+ exit_code=exit_code,
112
+ fail_on=opts.fail_on,
113
+ quiet=opts.quiet,
114
+ color=use_color(opts.no_color) and opts.output is None,
115
+ )
permdiff/cli/check.py ADDED
@@ -0,0 +1,50 @@
1
+ """``permdiff check``: validate traces and compile both refs without diffing (FR-25)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from permdiff.cli.settings import (
11
+ engine_options,
12
+ load_filtered_traces,
13
+ resolve_config,
14
+ selection_flags,
15
+ )
16
+ from permdiff.errors import EXIT_OK, EXIT_TOOL_ERROR
17
+ from permdiff.evaluators import registry
18
+ from permdiff.policy import source_for
19
+
20
+
21
+ @click.command("check")
22
+ @selection_flags
23
+ @click.pass_context
24
+ def check_cmd(ctx: click.Context, /, **kwargs: Any) -> None:
25
+ """Parse the traces and compile the policy at both refs; exit 1 on any failure."""
26
+ repo: Path = kwargs.pop("repo")
27
+ opa_bin: Path | None = kwargs.pop("opa_bin")
28
+ config = resolve_config(ctx, repo, kwargs)
29
+ imported, selected = load_filtered_traces(config, kwargs)
30
+ click.echo(
31
+ f"traces: {imported.stats.read:,} calls ({imported.stats.skipped:,} skipped, "
32
+ f"{selected.filtered:,} filtered out)"
33
+ )
34
+ options = engine_options(config)
35
+ if opa_bin is not None:
36
+ options["opa_bin"] = opa_bin
37
+ evaluator = registry.resolve(config.policy.engine, **options)
38
+ failed = False
39
+ for ref in (config.policy.base, config.policy.head):
40
+ with source_for(repo, ref, config.policy.path).materialize() as materialized:
41
+ prepared = evaluator.prepare(materialized.path, label=materialized.label)
42
+ error = getattr(prepared, "compile_error", None)
43
+ prepared.close()
44
+ sha = f" ({materialized.sha[:12]})" if materialized.sha else ""
45
+ if error:
46
+ failed = True
47
+ click.echo(f"{materialized.label}{sha}: FAIL {error}")
48
+ else:
49
+ click.echo(f"{materialized.label}{sha}: ok")
50
+ ctx.exit(EXIT_TOOL_ERROR if failed else EXIT_OK)
@@ -0,0 +1,62 @@
1
+ """``permdiff convert``: write any supported trace format as canonical permdiff JSONL (FR-7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from permdiff import api
11
+ from permdiff.errors import TraceImportError
12
+ from permdiff.importers.jsonl import FORMAT_NAME
13
+ from permdiff.models import Source, ToolCall
14
+ from permdiff.models.limits import DEFAULT_MAX_RECORDS
15
+
16
+
17
+ def to_jsonl_line(call: ToolCall) -> str:
18
+ """Canonical JSONL: the record keeps its original source so provenance survives."""
19
+ source = call.source or Source(format=FORMAT_NAME, locator="")
20
+ return call.model_copy(update={"source": source}).model_dump_json(exclude_none=False) + "\n"
21
+
22
+
23
+ @click.command("convert")
24
+ @click.argument("files", nargs=-1, required=True)
25
+ @click.option("--from", "fmt", default=api.AUTO_FORMAT, show_default=True, help="Importer name.")
26
+ @click.option(
27
+ "-o",
28
+ "--output",
29
+ type=click.Path(path_type=Path, dir_okay=False),
30
+ default=None,
31
+ help="Output JSONL (default stdout).",
32
+ )
33
+ @click.option("--strict", is_flag=True, help="Abort on the first malformed record.")
34
+ @click.option("--max-records", type=int, default=DEFAULT_MAX_RECORDS, show_default=True)
35
+ @click.option("--principal-from", default=None, help="OTel: attribute path for the principal.")
36
+ def convert_cmd(
37
+ files: tuple[str, ...],
38
+ fmt: str,
39
+ output: Path | None,
40
+ strict: bool,
41
+ max_records: int,
42
+ principal_from: str | None,
43
+ ) -> None:
44
+ """Convert Custody or OTel traces (or any importer's input) to permdiff JSONL."""
45
+ options = {"principal_from": principal_from} if principal_from else {}
46
+ result = api.load_traces(
47
+ files, fmt=fmt, strict=strict, max_records=max_records, importer_options=options
48
+ )
49
+ text = "".join(to_jsonl_line(c) for c in result.calls)
50
+ if output is not None:
51
+ try:
52
+ output.write_text(text, encoding="utf-8")
53
+ except OSError as exc:
54
+ msg = f"cannot write {output}: {exc.strerror or exc}"
55
+ raise TraceImportError(msg) from exc
56
+ else:
57
+ sys.stdout.write(text)
58
+ click.echo(
59
+ f"converted {result.stats.read:,} calls ({result.stats.skipped:,} skipped)"
60
+ + (f" to {output}" if output is not None else ""),
61
+ err=True,
62
+ )
permdiff/cli/demo.py ADDED
@@ -0,0 +1,90 @@
1
+ """``permdiff demo``: run the bundled example offline (FR-24)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from permdiff import api, demo
10
+ from permdiff.cli._render import OutputOptions, emit_and_exit, parse_show_args
11
+ from permdiff.cli.diff import parse_salt
12
+ from permdiff.cli.settings import output_flags
13
+ from permdiff.config.model import ReportConfig
14
+ from permdiff.errors import EngineError
15
+ from permdiff.policy import DirectorySource
16
+ from permdiff.redact import RedactLevel
17
+ from permdiff.report import FailOn
18
+ from permdiff.report.grouping import validate_group_by
19
+
20
+ ENGINES = ("auto", "opa", "python", "cedar")
21
+ FALLBACK_NOTE = (
22
+ "note: opa is not installed, using the Python engine; run `permdiff setup opa` "
23
+ "then `permdiff demo --engine opa` for the Rego version"
24
+ )
25
+
26
+
27
+ def demo_output_options(kwargs: dict[str, Any]) -> OutputOptions:
28
+ """Shaping flags with the config defaults; the demo reads no permdiff.toml."""
29
+ defaults = ReportConfig()
30
+
31
+ def pick(flag: str, default: Any) -> Any:
32
+ value = kwargs.pop(flag)
33
+ return default if value is None else value
34
+
35
+ return OutputOptions(
36
+ fmt=pick("fmt_out", defaults.format),
37
+ fail_on=FailOn(pick("fail_on", defaults.fail_on)),
38
+ redact=RedactLevel(pick("redact", defaults.redact)),
39
+ show_args=parse_show_args(pick("show_args", "")),
40
+ samples=pick("samples", defaults.samples),
41
+ group_by=validate_group_by(pick("group_by", ",".join(defaults.group_by)).split(",")),
42
+ max_groups=pick("max_groups", defaults.max_groups),
43
+ show_attribution=bool(pick("show_attribution", defaults.show_attribution)),
44
+ quiet=kwargs.pop("quiet"),
45
+ no_color=kwargs.pop("no_color"),
46
+ output=kwargs.pop("output"),
47
+ pr_comment=kwargs.pop("pr_comment"),
48
+ include_decisions=kwargs.pop("include_decisions"),
49
+ ).validated()
50
+
51
+
52
+ def choose_engine(requested: str) -> tuple[str, dict[str, Any]]:
53
+ """``auto`` picks OPA when the pinned binary is already present, else Python."""
54
+ from permdiff.evaluators.opa.binary import resolve_binary # noqa: PLC0415 # keep start fast
55
+
56
+ if requested == "python":
57
+ return demo.ENGINE_SPEC, {}
58
+ if requested == "cedar":
59
+ return "cedar", {"resource": demo.CEDAR_RESOURCE}
60
+ try:
61
+ opa_bin = resolve_binary(download_missing=(requested == "opa"))
62
+ except EngineError:
63
+ if requested == "opa":
64
+ raise
65
+ click.echo(FALLBACK_NOTE, err=True)
66
+ return demo.ENGINE_SPEC, {}
67
+ return "opa", {"decision": demo.OPA_DECISION, "opa_bin": opa_bin}
68
+
69
+
70
+ @click.command("demo")
71
+ @click.option("--engine", type=click.Choice(ENGINES), default="auto", show_default=True)
72
+ @output_flags
73
+ @click.pass_context
74
+ def demo_cmd(ctx: click.Context, /, **kwargs: Any) -> None:
75
+ """Diff two bundled policy versions over a 200-call synthetic corpus. Needs nothing else."""
76
+ opts = demo_output_options(kwargs)
77
+ salt = parse_salt(kwargs.pop("salt_hex"))
78
+ engine, engine_options = choose_engine(kwargs.pop("engine"))
79
+ imported = api.load_traces([demo.CORPUS], fmt="jsonl")
80
+ report = api.diff_sources(
81
+ traces=imported.calls,
82
+ base=DirectorySource(demo.POLICY_BASE, demo.BASE_LABEL),
83
+ head=DirectorySource(demo.POLICY_HEAD, demo.HEAD_LABEL),
84
+ policy_path="demo/policy",
85
+ engine=engine,
86
+ engine_options=engine_options,
87
+ salt=salt,
88
+ import_stats=imported.stats,
89
+ )
90
+ emit_and_exit(ctx, report, opts)