permissiondiff 0.1.0__tar.gz → 0.2.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 (26) hide show
  1. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/PKG-INFO +45 -5
  2. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/README.md +44 -4
  3. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/pyproject.toml +2 -2
  4. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/pyproject.toml.orig +2 -2
  5. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/__init__.py +1 -1
  6. permissiondiff-0.2.0/src/permissiondiff/adapters/__init__.py +68 -0
  7. permissiondiff-0.2.0/src/permissiondiff/adapters/http.py +57 -0
  8. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/application.py +81 -14
  9. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/cli.py +71 -5
  10. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/config.py +12 -1
  11. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/engine.py +113 -9
  12. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/errors.py +10 -0
  13. permissiondiff-0.2.0/src/permissiondiff/evaluator.py +238 -0
  14. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/generator.py +26 -29
  15. permissiondiff-0.2.0/src/permissiondiff/gitref.py +70 -0
  16. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/invariants.py +28 -6
  17. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/models.py +69 -8
  18. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/report.py +57 -3
  19. permissiondiff-0.2.0/src/permissiondiff/worker.py +85 -0
  20. permissiondiff-0.1.0/src/permissiondiff/evaluator.py +0 -86
  21. permissiondiff-0.1.0/src/permissiondiff/worker.py +0 -48
  22. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/LICENSE +0 -0
  23. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/__main__.py +0 -0
  24. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/loader.py +0 -0
  25. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/py.typed +0 -0
  26. {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: permissiondiff
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Deterministic differential authorization regression testing
5
5
  Keywords: authorization,ci,differential-testing,property-based-testing,security-testing
6
6
  Author: Abishek Giri
@@ -35,6 +35,7 @@ Description-Content-Type: text/markdown
35
35
  > **Prove your code didn't just hand the wrong person the keys.**
36
36
 
37
37
  [![CI](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml/badge.svg)](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/permissiondiff)](https://pypi.org/project/permissiondiff/)
38
39
  ![Python](https://img.shields.io/badge/python-3.12%E2%80%933.14-blue)
39
40
  ![License](https://img.shields.io/badge/license-Apache%202.0-green)
40
41
  [![Ruff](https://img.shields.io/badge/lint-ruff-000000)](https://github.com/astral-sh/ruff)
@@ -57,7 +58,14 @@ interfaces may evolve before 1.0. It supports Python 3.12, 3.13, and 3.14 and is
57
58
 
58
59
  ## Install
59
60
 
60
- Install the CLI with uv:
61
+ Add the [published package](https://pypi.org/project/permissiondiff/) to an existing uv project:
62
+
63
+ ```bash
64
+ uv add permissiondiff
65
+ uv run permissiondiff --help
66
+ ```
67
+
68
+ For a standalone CLI, install it with uv:
61
69
 
62
70
  ```bash
63
71
  uv tool install permissiondiff
@@ -69,11 +77,9 @@ Or install it in an active virtual environment with pip:
69
77
  python -m pip install permissiondiff
70
78
  ```
71
79
 
72
- To add PermissionDiff to an existing uv project instead, run `uv add permissiondiff`.
73
-
74
80
  ## Five-minute quickstart
75
81
 
76
- After installation:
82
+ After standalone CLI installation (or use `uv run permissiondiff` inside a uv project):
77
83
 
78
84
  ```bash
79
85
  permissiondiff init demo
@@ -157,6 +163,28 @@ invariants:
157
163
 
158
164
  The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
159
165
 
166
+ ## Least-privilege review
167
+
168
+ `mine` reports the authorizer's effective grant surface — each distinct role × action ×
169
+ resource-type × tenant-relation × ownership pattern it allows — and flags **broad** grants (those
170
+ crossing a tenant boundary or reaching a non-owned resource) for tightening:
171
+
172
+ ```bash
173
+ uv run permissiondiff mine --config permissiondiff.yaml
174
+ ```
175
+
176
+ ## Policy engines and agents
177
+
178
+ Wrap an external policy engine as the authorizer with the adapter toolkit in
179
+ `permissiondiff.adapters` (`from_boolean`, `from_decision`, or `http_authorizer` for OPA-style
180
+ endpoints); runnable example adapters for OPA, OpenFGA, Auth0 FGA, Cedar, and SpiceDB live under
181
+ [`examples/adapters/`](https://github.com/abishekgiri/permissiondiff/tree/main/examples/adapters).
182
+ Adapters make read-only decision calls — point them at a non-production policy instance.
183
+
184
+ For agent / on-behalf-of principals, declare `delegated_by: [id, ...]` on a subject. PermissionDiff
185
+ enforces the least-privilege intersection rule: a delegated principal allowed where any delegator
186
+ is denied (on the identical case) is a critical privilege-escalation finding.
187
+
160
188
  ## Baseline and diff workflow
161
189
 
162
190
  Create a baseline on trusted code:
@@ -173,6 +201,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
173
201
  --baseline .permissiondiff/main.json
174
202
  ```
175
203
 
204
+ Or skip the snapshot file entirely and diff against a git ref — PermissionDiff evaluates the
205
+ baseline authorizer as it existed at that ref in a temporary, auto-removed worktree:
206
+
207
+ ```bash
208
+ uv run permissiondiff diff --config permissiondiff.yaml --git-ref main
209
+ ```
210
+
176
211
  Classification is exhaustive:
177
212
 
178
213
  | Baseline | Candidate | Result |
@@ -212,6 +247,11 @@ The repository's thin composite action invokes the same CLI without duplicating
212
247
  baseline: .permissiondiff/main.json
213
248
  ```
214
249
 
250
+ Check out the calling repository before this step and make sure the baseline is present in CI.
251
+ Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
252
+ [published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
253
+ exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
254
+
215
255
  ## Security and limitations
216
256
 
217
257
  **Only point PermissionDiff at decision logic with no live side effects. Never use an authorizer that issues refunds, deletes data, sends messages, or contacts production.**
@@ -3,6 +3,7 @@
3
3
  > **Prove your code didn't just hand the wrong person the keys.**
4
4
 
5
5
  [![CI](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml/badge.svg)](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/permissiondiff)](https://pypi.org/project/permissiondiff/)
6
7
  ![Python](https://img.shields.io/badge/python-3.12%E2%80%933.14-blue)
7
8
  ![License](https://img.shields.io/badge/license-Apache%202.0-green)
8
9
  [![Ruff](https://img.shields.io/badge/lint-ruff-000000)](https://github.com/astral-sh/ruff)
@@ -25,7 +26,14 @@ interfaces may evolve before 1.0. It supports Python 3.12, 3.13, and 3.14 and is
25
26
 
26
27
  ## Install
27
28
 
28
- Install the CLI with uv:
29
+ Add the [published package](https://pypi.org/project/permissiondiff/) to an existing uv project:
30
+
31
+ ```bash
32
+ uv add permissiondiff
33
+ uv run permissiondiff --help
34
+ ```
35
+
36
+ For a standalone CLI, install it with uv:
29
37
 
30
38
  ```bash
31
39
  uv tool install permissiondiff
@@ -37,11 +45,9 @@ Or install it in an active virtual environment with pip:
37
45
  python -m pip install permissiondiff
38
46
  ```
39
47
 
40
- To add PermissionDiff to an existing uv project instead, run `uv add permissiondiff`.
41
-
42
48
  ## Five-minute quickstart
43
49
 
44
- After installation:
50
+ After standalone CLI installation (or use `uv run permissiondiff` inside a uv project):
45
51
 
46
52
  ```bash
47
53
  permissiondiff init demo
@@ -125,6 +131,28 @@ invariants:
125
131
 
126
132
  The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
127
133
 
134
+ ## Least-privilege review
135
+
136
+ `mine` reports the authorizer's effective grant surface — each distinct role × action ×
137
+ resource-type × tenant-relation × ownership pattern it allows — and flags **broad** grants (those
138
+ crossing a tenant boundary or reaching a non-owned resource) for tightening:
139
+
140
+ ```bash
141
+ uv run permissiondiff mine --config permissiondiff.yaml
142
+ ```
143
+
144
+ ## Policy engines and agents
145
+
146
+ Wrap an external policy engine as the authorizer with the adapter toolkit in
147
+ `permissiondiff.adapters` (`from_boolean`, `from_decision`, or `http_authorizer` for OPA-style
148
+ endpoints); runnable example adapters for OPA, OpenFGA, Auth0 FGA, Cedar, and SpiceDB live under
149
+ [`examples/adapters/`](https://github.com/abishekgiri/permissiondiff/tree/main/examples/adapters).
150
+ Adapters make read-only decision calls — point them at a non-production policy instance.
151
+
152
+ For agent / on-behalf-of principals, declare `delegated_by: [id, ...]` on a subject. PermissionDiff
153
+ enforces the least-privilege intersection rule: a delegated principal allowed where any delegator
154
+ is denied (on the identical case) is a critical privilege-escalation finding.
155
+
128
156
  ## Baseline and diff workflow
129
157
 
130
158
  Create a baseline on trusted code:
@@ -141,6 +169,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
141
169
  --baseline .permissiondiff/main.json
142
170
  ```
143
171
 
172
+ Or skip the snapshot file entirely and diff against a git ref — PermissionDiff evaluates the
173
+ baseline authorizer as it existed at that ref in a temporary, auto-removed worktree:
174
+
175
+ ```bash
176
+ uv run permissiondiff diff --config permissiondiff.yaml --git-ref main
177
+ ```
178
+
144
179
  Classification is exhaustive:
145
180
 
146
181
  | Baseline | Candidate | Result |
@@ -180,6 +215,11 @@ The repository's thin composite action invokes the same CLI without duplicating
180
215
  baseline: .permissiondiff/main.json
181
216
  ```
182
217
 
218
+ Check out the calling repository before this step and make sure the baseline is present in CI.
219
+ Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
220
+ [published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
221
+ exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
222
+
183
223
  ## Security and limitations
184
224
 
185
225
  **Only point PermissionDiff at decision logic with no live side effects. Never use an authorizer that issues refunds, deletes data, sends messages, or contacts production.**
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "permissiondiff"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Deterministic differential authorization regression testing"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -57,7 +57,7 @@ dev = [
57
57
  ]
58
58
 
59
59
  [build-system]
60
- requires = ["uv_build>=0.11.1,<0.12.0"]
60
+ requires = ["uv_build>=0.11.1,<0.13.0"]
61
61
  build-backend = "uv_build"
62
62
 
63
63
  [tool.ruff]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "permissiondiff"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Deterministic differential authorization regression testing"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -55,7 +55,7 @@ dev = [
55
55
  ]
56
56
 
57
57
  [build-system]
58
- requires = ["uv_build>=0.11.1,<0.12.0"]
58
+ requires = ["uv_build>=0.11.1,<0.13.0"]
59
59
  build-backend = "uv_build"
60
60
 
61
61
  [tool.ruff]
@@ -20,4 +20,4 @@ __all__ = [
20
20
  "Subject",
21
21
  ]
22
22
 
23
- __version__ = "0.1.0"
23
+ __version__ = "0.2.0"
@@ -0,0 +1,68 @@
1
+ """User-side toolkit for building a PermissionDiff authorizer from an external policy system.
2
+
3
+ Adapters live **outside** the core engine (the core never imports this package). An adapter turns
4
+ an external authorization decision -- a boolean check from a policy-engine SDK (OpenFGA, SpiceDB,
5
+ Cedar, Auth0 FGA, ...) or a JSON response from an HTTP policy endpoint (OPA, ...) -- into the
6
+ ``authorize(subject, action, resource, context) -> Decision`` callable that PermissionDiff
7
+ evaluates.
8
+
9
+ Adapters make **read-only decision calls only**. As with any authorizer, point them at a
10
+ non-production / test policy instance -- never at production data or anything with side effects.
11
+
12
+ Most SDK-based engines reduce to a boolean check, so :func:`from_boolean` is usually all you need;
13
+ :func:`from_decision` covers engines that already return an allow/deny verdict object. See
14
+ ``examples/adapters/`` for concrete per-engine wrappers.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+
21
+ from permissiondiff.adapters.http import http_authorizer
22
+ from permissiondiff.models import (
23
+ Action,
24
+ AuthorizationFunction,
25
+ Context,
26
+ Decision,
27
+ Resource,
28
+ Subject,
29
+ )
30
+
31
+ BooleanCheck = Callable[[Subject, Action, Resource, Context], bool]
32
+
33
+ __all__ = ["BooleanCheck", "from_boolean", "from_decision", "http_authorizer"]
34
+
35
+
36
+ def from_boolean(check: BooleanCheck) -> AuthorizationFunction:
37
+ """Wrap a boolean allow/deny check (the shape of most SDK calls) as an authorizer."""
38
+
39
+ def _authorize(
40
+ subject: Subject, action: Action, resource: Resource, context: Context
41
+ ) -> Decision:
42
+ return Decision.ALLOW if check(subject, action, resource, context) else Decision.DENY
43
+
44
+ return _authorize
45
+
46
+
47
+ def from_decision(
48
+ check: Callable[[Subject, Action, Resource, Context], bool | Decision],
49
+ ) -> AuthorizationFunction:
50
+ """Wrap a check that returns a bool or an actual ``Decision``.
51
+
52
+ Anything that is not a ``Decision`` or a ``bool`` raises ``TypeError`` so a misconfigured
53
+ adapter surfaces as an explicit evaluation error rather than a silent wrong verdict.
54
+ """
55
+
56
+ def _authorize(
57
+ subject: Subject, action: Action, resource: Resource, context: Context
58
+ ) -> Decision:
59
+ result = check(subject, action, resource, context)
60
+ if isinstance(result, Decision):
61
+ return result
62
+ if isinstance(result, bool):
63
+ return Decision.ALLOW if result else Decision.DENY
64
+ raise TypeError(
65
+ f"adapter check returned {type(result).__name__}, expected bool or Decision"
66
+ )
67
+
68
+ return _authorize
@@ -0,0 +1,57 @@
1
+ """Generic HTTP-JSON policy adapter (e.g. Open Policy Agent's data API), standard library only.
2
+
3
+ This is user-side glue that the core engine never imports. It POSTs a JSON request built from the
4
+ case to a policy endpoint and turns the JSON response into a ``Decision``. Point it at a
5
+ non-production policy server; it performs a read-only decision query.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.request
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from permissiondiff.models import (
16
+ Action,
17
+ AuthorizationFunction,
18
+ Context,
19
+ Decision,
20
+ Resource,
21
+ Subject,
22
+ )
23
+
24
+ RequestBuilder = Callable[[Subject, Action, Resource, Context], dict[str, Any]]
25
+ AllowedParser = Callable[[Any], bool]
26
+
27
+
28
+ def http_authorizer(
29
+ endpoint: str,
30
+ *,
31
+ build_request: RequestBuilder,
32
+ parse_allowed: AllowedParser,
33
+ timeout: float = 5.0,
34
+ ) -> AuthorizationFunction:
35
+ """Build an authorizer that queries an HTTP-JSON policy endpoint.
36
+
37
+ ``build_request`` maps a case to the JSON body to POST; ``parse_allowed`` maps the decoded
38
+ JSON response to a boolean allow/deny. Network or parse failures propagate, so PermissionDiff
39
+ records them as explicit evaluation errors rather than guessing a verdict.
40
+ """
41
+
42
+ def _authorize(
43
+ subject: Subject, action: Action, resource: Resource, context: Context
44
+ ) -> Decision:
45
+ body = json.dumps(build_request(subject, action, resource, context)).encode("utf-8")
46
+ # The endpoint is operator-supplied configuration, not attacker-controlled case data.
47
+ request = urllib.request.Request(
48
+ endpoint,
49
+ data=body,
50
+ headers={"Content-Type": "application/json"},
51
+ method="POST",
52
+ )
53
+ with urllib.request.urlopen(request, timeout=timeout) as response:
54
+ payload = json.loads(response.read().decode("utf-8"))
55
+ return Decision.ALLOW if parse_allowed(payload) else Decision.DENY
56
+
57
+ return _authorize
@@ -10,12 +10,20 @@ from permissiondiff.config import PermissionDiffConfig
10
10
  from permissiondiff.engine import (
11
11
  compare_decisions,
12
12
  comparison_findings,
13
+ delegation_findings,
13
14
  invariant_findings,
15
+ mine_grants,
14
16
  )
15
17
  from permissiondiff.evaluator import evaluate_cases
16
- from permissiondiff.generator import generate_cases, shrink_findings
18
+ from permissiondiff.generator import generate_cases
17
19
  from permissiondiff.invariants import build_invariants
18
- from permissiondiff.models import AuthorizationCase, CaseEvaluation, ComparisonResult, Finding
20
+ from permissiondiff.models import (
21
+ AuthorizationCase,
22
+ CaseEvaluation,
23
+ ComparisonResult,
24
+ Finding,
25
+ Grant,
26
+ )
19
27
  from permissiondiff.snapshot import Snapshot, load_snapshot, snapshot_from_evaluations
20
28
 
21
29
 
@@ -27,6 +35,15 @@ class RunResult:
27
35
  findings: tuple[Finding, ...]
28
36
 
29
37
 
38
+ @dataclass(frozen=True, slots=True)
39
+ class MineResult:
40
+ """Effective grant surface plus any cases that could not be evaluated."""
41
+
42
+ cases_evaluated: int
43
+ grants: tuple[Grant, ...]
44
+ errors: tuple[CaseEvaluation, ...]
45
+
46
+
30
47
  def run_test(
31
48
  config: PermissionDiffConfig,
32
49
  *,
@@ -38,13 +55,28 @@ def run_test(
38
55
  cases = generate_cases(config, seed=seed, max_examples=max_examples)
39
56
  evaluations = _evaluate(config, cases, workdir)
40
57
  invariants = build_invariants(config.invariants, workdir=workdir)
41
- findings = shrink_findings(
42
- invariant_findings(evaluations, invariants, minimize=False),
43
- seed=seed,
44
- )
58
+ findings = [
59
+ *invariant_findings(evaluations, invariants),
60
+ *delegation_findings(evaluations),
61
+ ]
45
62
  return RunResult(len(cases), tuple(findings))
46
63
 
47
64
 
65
+ def run_mine(
66
+ config: PermissionDiffConfig,
67
+ *,
68
+ workdir: Path,
69
+ seed: int,
70
+ max_examples: int | None = None,
71
+ ) -> MineResult:
72
+ """Generate cases, evaluate the authorizer, and mine its effective grant surface."""
73
+ cases = generate_cases(config, seed=seed, max_examples=max_examples)
74
+ evaluations = _evaluate(config, cases, workdir)
75
+ grants = mine_grants(evaluations)
76
+ errors = tuple(evaluation for evaluation in evaluations if evaluation.error is not None)
77
+ return MineResult(len(cases), tuple(grants), errors)
78
+
79
+
48
80
  def create_snapshot(
49
81
  config: PermissionDiffConfig,
50
82
  *,
@@ -63,9 +95,42 @@ def run_diff(
63
95
  *,
64
96
  workdir: Path,
65
97
  baseline_path: Path,
98
+ ) -> RunResult:
99
+ """Replay every exact baseline case (from a snapshot file) against the candidate."""
100
+ return _diff_against_snapshot(config, workdir=workdir, snapshot=load_snapshot(baseline_path))
101
+
102
+
103
+ def run_git_diff(
104
+ config: PermissionDiffConfig,
105
+ *,
106
+ workdir: Path,
107
+ ref: str,
108
+ seed: int,
109
+ max_examples: int | None = None,
110
+ ) -> RunResult:
111
+ """Diff the candidate against the baseline authorizer as it existed at a git ref.
112
+
113
+ The ref is checked out into a temporary detached worktree; the candidate config's domain
114
+ generates the corpus, which is evaluated against the baseline authorizer code there. The
115
+ candidate then replays that exact corpus in the working tree.
116
+ """
117
+ from permissiondiff.gitref import baseline_worktree, repo_root
118
+
119
+ relative = workdir.resolve().relative_to(repo_root(workdir))
120
+ with baseline_worktree(workdir, ref) as tree:
121
+ baseline = create_snapshot(
122
+ config, workdir=tree / relative, seed=seed, max_examples=max_examples
123
+ )
124
+ return _diff_against_snapshot(config, workdir=workdir, snapshot=baseline)
125
+
126
+
127
+ def _diff_against_snapshot(
128
+ config: PermissionDiffConfig,
129
+ *,
130
+ workdir: Path,
131
+ snapshot: Snapshot,
66
132
  ) -> RunResult:
67
133
  """Replay every exact baseline case against the candidate authorizer."""
68
- snapshot = load_snapshot(baseline_path)
69
134
  cases = [snapshot_case.case for snapshot_case in snapshot.cases]
70
135
  evaluations = _evaluate(config, cases, workdir)
71
136
  comparisons: list[ComparisonResult] = []
@@ -79,13 +144,14 @@ def run_diff(
79
144
  )
80
145
  )
81
146
  invariants = build_invariants(config.invariants, workdir=workdir)
82
- findings = shrink_findings(
83
- [
84
- *comparison_findings(comparisons, minimize=False),
85
- *invariant_findings(evaluations, invariants, minimize=False),
86
- ],
87
- seed=snapshot.seed,
88
- )
147
+ # Comparison and invariant findings never share an equivalence key (their
148
+ # FindingKind differs), so minimizing each group independently is equivalent
149
+ # to minimizing the combined list, and it preserves equivalent-case counts.
150
+ findings = [
151
+ *comparison_findings(comparisons),
152
+ *invariant_findings(evaluations, invariants),
153
+ *delegation_findings(evaluations),
154
+ ]
89
155
  return RunResult(len(cases), tuple(findings))
90
156
 
91
157
 
@@ -99,4 +165,5 @@ def _evaluate(
99
165
  config.authorizer,
100
166
  workdir=workdir,
101
167
  timeout_seconds=config.execution.timeout_seconds,
168
+ mode=config.execution.worker,
102
169
  )
@@ -9,14 +9,22 @@ import typer
9
9
  from rich.console import Console
10
10
 
11
11
  from permissiondiff import __version__
12
- from permissiondiff.application import create_snapshot, run_diff, run_test
12
+ from permissiondiff.application import (
13
+ create_snapshot,
14
+ run_diff,
15
+ run_git_diff,
16
+ run_mine,
17
+ run_test,
18
+ )
13
19
  from permissiondiff.config import PermissionDiffConfig, load_config
14
- from permissiondiff.errors import PermissionDiffError
20
+ from permissiondiff.errors import ConfigurationError, EvaluationError, PermissionDiffError
15
21
  from permissiondiff.models import Finding
16
22
  from permissiondiff.report import (
17
23
  assign_finding_ids,
18
24
  explain_finding,
19
25
  persist_findings,
26
+ persist_grants,
27
+ render_grants,
20
28
  render_terminal,
21
29
  result_exit_code,
22
30
  )
@@ -107,6 +115,41 @@ def test_command(
107
115
  _unexpected(exc, debug)
108
116
 
109
117
 
118
+ @app.command()
119
+ def mine(
120
+ config: CONFIG_OPTION = Path("permissiondiff.yaml"),
121
+ seed: SEED_OPTION = 42,
122
+ max_examples: Annotated[
123
+ int | None, typer.Option("--max-examples", min=1, help="Override corpus size.")
124
+ ] = None,
125
+ json_output: Annotated[
126
+ Path, typer.Option("--json-output", help="Machine-readable grant report path.")
127
+ ] = Path(".permissiondiff/grants.json"),
128
+ debug: DEBUG_OPTION = False,
129
+ ) -> None:
130
+ """Report the authorizer's effective grant surface for least-privilege review."""
131
+ try:
132
+ loaded = load_config(config)
133
+ result = run_mine(
134
+ loaded, workdir=config.resolve().parent, seed=seed, max_examples=max_examples
135
+ )
136
+ if result.errors:
137
+ # Never present a partial grant surface as complete.
138
+ raise EvaluationError(
139
+ f"{len(result.errors)} case(s) failed evaluation; "
140
+ f"first error: {result.errors[0].error}"
141
+ )
142
+ grants = list(result.grants)
143
+ persist_grants(grants, report_path=json_output, cases_evaluated=result.cases_evaluated)
144
+ render_grants(grants, cases_evaluated=result.cases_evaluated)
145
+ except typer.Exit:
146
+ raise
147
+ except PermissionDiffError as exc:
148
+ _fail(exc, debug)
149
+ except Exception as exc:
150
+ _unexpected(exc, debug)
151
+
152
+
110
153
  @app.command()
111
154
  def snapshot(
112
155
  output: Annotated[Path, typer.Option("--output", "-o", help="Baseline JSON path.")],
@@ -138,8 +181,18 @@ def snapshot(
138
181
 
139
182
  @app.command()
140
183
  def diff(
141
- baseline: Annotated[Path, typer.Option("--baseline", help="Baseline snapshot path.")],
184
+ baseline: Annotated[
185
+ Path | None, typer.Option("--baseline", help="Baseline snapshot path.")
186
+ ] = None,
187
+ git_ref: Annotated[
188
+ str | None,
189
+ typer.Option("--git-ref", help="Baseline git ref to evaluate in a temporary worktree."),
190
+ ] = None,
142
191
  config: CONFIG_OPTION = Path("permissiondiff.yaml"),
192
+ seed: SEED_OPTION = 42,
193
+ max_examples: Annotated[
194
+ int | None, typer.Option("--max-examples", min=1, help="Override corpus size (--git-ref).")
195
+ ] = None,
143
196
  json_output: Annotated[
144
197
  Path, typer.Option("--json-output", help="Machine-readable report path.")
145
198
  ] = Path(".permissiondiff/report.json"),
@@ -148,10 +201,23 @@ def diff(
148
201
  ] = Path(".permissiondiff/failures"),
149
202
  debug: DEBUG_OPTION = False,
150
203
  ) -> None:
151
- """Replay the exact baseline corpus against the candidate authorizer."""
204
+ """Replay the exact baseline corpus against the candidate authorizer.
205
+
206
+ Provide exactly one baseline source: --baseline (a snapshot file) or --git-ref (a git ref
207
+ whose authorizer is evaluated in a temporary worktree).
208
+ """
152
209
  try:
210
+ if (baseline is None) == (git_ref is None):
211
+ raise ConfigurationError("provide exactly one of --baseline or --git-ref")
153
212
  loaded = load_config(config)
154
- result = run_diff(loaded, workdir=config.resolve().parent, baseline_path=baseline)
213
+ workdir = config.resolve().parent
214
+ if git_ref is not None:
215
+ result = run_git_diff(
216
+ loaded, workdir=workdir, ref=git_ref, seed=seed, max_examples=max_examples
217
+ )
218
+ else:
219
+ assert baseline is not None
220
+ result = run_diff(loaded, workdir=workdir, baseline_path=baseline)
155
221
  _finish_run(loaded, result.cases_evaluated, result.findings, failures_dir, json_output)
156
222
  except typer.Exit:
157
223
  raise
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any
6
+ from typing import Any, Literal
7
7
 
8
8
  import yaml
9
9
  from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
@@ -21,6 +21,7 @@ class SubjectConfig(BaseModel):
21
21
  tenant: str | None = None
22
22
  role: str | None = None
23
23
  attributes: dict[str, Any] = Field(default_factory=dict)
24
+ delegated_by: list[str] = Field(default_factory=list)
24
25
 
25
26
  def to_domain(self) -> Subject:
26
27
  """Convert boundary validation data into the core model."""
@@ -29,6 +30,7 @@ class SubjectConfig(BaseModel):
29
30
  tenant=self.tenant,
30
31
  role=self.role,
31
32
  attributes=self.attributes,
33
+ delegated_by=tuple(self.delegated_by),
32
34
  )
33
35
 
34
36
 
@@ -104,6 +106,15 @@ class ExecutionConfig(BaseModel):
104
106
  model_config = ConfigDict(extra="forbid")
105
107
 
106
108
  timeout_seconds: float = Field(default=2.0, gt=0, le=300)
109
+ worker: Literal["persistent", "process_per_case"] = "persistent"
110
+ """How cases reach the authorizer.
111
+
112
+ ``persistent`` (default) imports the authorizer once in a long-lived worker and streams cases
113
+ over a line-delimited protocol, re-spawning on any crash or timeout so a bad case cannot
114
+ corrupt later cases -- far faster on large corpora. ``process_per_case`` spawns a fresh
115
+ interpreter per case (maximum isolation, no shared import state); use it if an authorizer
116
+ relies on process-global state and cannot tolerate a shared import.
117
+ """
107
118
 
108
119
 
109
120
  class FailOnConfig(BaseModel):