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.
- permdiff/__init__.py +26 -0
- permdiff/_proc.py +69 -0
- permdiff/api.py +203 -0
- permdiff/cli/__init__.py +1 -0
- permdiff/cli/_render.py +115 -0
- permdiff/cli/check.py +50 -0
- permdiff/cli/convert.py +62 -0
- permdiff/cli/demo.py +90 -0
- permdiff/cli/diff.py +91 -0
- permdiff/cli/init.py +43 -0
- permdiff/cli/main.py +85 -0
- permdiff/cli/render.py +80 -0
- permdiff/cli/schema.py +14 -0
- permdiff/cli/settings.py +316 -0
- permdiff/cli/setup.py +27 -0
- permdiff/config/__init__.py +17 -0
- permdiff/config/load.py +144 -0
- permdiff/config/model.py +67 -0
- permdiff/config/write.py +52 -0
- permdiff/demo/__init__.py +15 -0
- permdiff/demo/corpus.jsonl +200 -0
- permdiff/demo/engine.py +85 -0
- permdiff/demo/policy_base/agent.cedar +19 -0
- permdiff/demo/policy_base/agent.rego +23 -0
- permdiff/demo/policy_base/rules.py +22 -0
- permdiff/demo/policy_base/schema.cedarschema +11 -0
- permdiff/demo/policy_head/agent.cedar +30 -0
- permdiff/demo/policy_head/agent.rego +47 -0
- permdiff/demo/policy_head/rules.py +31 -0
- permdiff/demo/policy_head/schema.cedarschema +11 -0
- permdiff/errors.py +39 -0
- permdiff/evaluators/__init__.py +7 -0
- permdiff/evaluators/base.py +61 -0
- permdiff/evaluators/cedar/__init__.py +24 -0
- permdiff/evaluators/cedar/evaluator.py +160 -0
- permdiff/evaluators/cedar/loader.py +127 -0
- permdiff/evaluators/cedar/request.py +137 -0
- permdiff/evaluators/opa/__init__.py +16 -0
- permdiff/evaluators/opa/binary.py +195 -0
- permdiff/evaluators/opa/capabilities.py +76 -0
- permdiff/evaluators/opa/evaluator.py +283 -0
- permdiff/evaluators/opa/mapping.py +86 -0
- permdiff/evaluators/opa/ndcache.py +72 -0
- permdiff/evaluators/opa/shim.py +105 -0
- permdiff/evaluators/python_callable.py +65 -0
- permdiff/evaluators/registry.py +109 -0
- permdiff/importers/__init__.py +7 -0
- permdiff/importers/base.py +40 -0
- permdiff/importers/custody.py +167 -0
- permdiff/importers/filters.py +84 -0
- permdiff/importers/jsonl.py +57 -0
- permdiff/importers/limits.py +30 -0
- permdiff/importers/lines.py +67 -0
- permdiff/importers/otel/__init__.py +85 -0
- permdiff/importers/otel/anyvalue.py +51 -0
- permdiff/importers/otel/fallback.py +85 -0
- permdiff/importers/otel/mapping.py +150 -0
- permdiff/importers/otel/spans.py +84 -0
- permdiff/importers/registry.py +113 -0
- permdiff/models/__init__.py +39 -0
- permdiff/models/base.py +11 -0
- permdiff/models/decision.py +98 -0
- permdiff/models/limits.py +33 -0
- permdiff/models/report.py +57 -0
- permdiff/models/toolcall.py +91 -0
- permdiff/models/transition.py +48 -0
- permdiff/policy/__init__.py +23 -0
- permdiff/policy/git.py +83 -0
- permdiff/policy/source.py +126 -0
- permdiff/py.typed +0 -0
- permdiff/redact/__init__.py +7 -0
- permdiff/redact/redactor.py +206 -0
- permdiff/replay/__init__.py +7 -0
- permdiff/replay/runner.py +114 -0
- permdiff/report/__init__.py +34 -0
- permdiff/report/exit_codes.py +61 -0
- permdiff/report/grouping.py +143 -0
- permdiff/report/json_.py +86 -0
- permdiff/report/markdown.py +134 -0
- permdiff/report/sarif.py +172 -0
- permdiff/report/summary.py +77 -0
- permdiff/report/terminal.py +149 -0
- permdiff/report/view.py +88 -0
- permdiff/schemas/__init__.py +47 -0
- permdiff/schemas/decision.json +75 -0
- permdiff/schemas/report.json +733 -0
- permdiff/schemas/toolcall.json +261 -0
- permdiff-0.1.0.dist-info/METADATA +196 -0
- permdiff-0.1.0.dist-info/RECORD +92 -0
- permdiff-0.1.0.dist-info/WHEEL +4 -0
- permdiff-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
permdiff/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface."""
|
permdiff/cli/_render.py
ADDED
|
@@ -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)
|
permdiff/cli/convert.py
ADDED
|
@@ -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)
|