Skip to content

Commit 6d6ada9

Browse files
authored
Merge pull request #32 from PredicateSystems/post_verify_p1
P1: post-execution verification - cooperative
2 parents f51ff31 + 6f736d5 commit 6d6ada9

8 files changed

Lines changed: 1429 additions & 1 deletion

File tree

.flake8

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[flake8]
2+
max-line-length = 100
3+
extend-ignore = E203, W503, E501
4+
exclude =
5+
.git,
6+
__pycache__,
7+
venv,
8+
.venv,
9+
build,
10+
dist,
11+
.eggs,
12+
*.egg-info,
13+
max-complexity = 15

predicate_authority/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,36 @@
5555
run_sidecar,
5656
)
5757
from predicate_authority.telemetry import OpenTelemetryTraceEmitter
58+
from predicate_authority.verify import (
59+
ActualOperation,
60+
AuthorizedOperation,
61+
MandateDetails,
62+
RecordVerificationRequest,
63+
RecordVerificationResponse,
64+
VerificationFailureReason,
65+
Verifier,
66+
VerifyRequest,
67+
VerifyResult,
68+
actions_match,
69+
normalize_resource,
70+
resources_match,
71+
)
5872

5973
__all__ = [
74+
# Verification module
75+
"ActualOperation",
76+
"AuthorizedOperation",
77+
"MandateDetails",
78+
"RecordVerificationRequest",
79+
"RecordVerificationResponse",
80+
"VerificationFailureReason",
81+
"Verifier",
82+
"VerifyRequest",
83+
"VerifyResult",
84+
"actions_match",
85+
"normalize_resource",
86+
"resources_match",
87+
# Authorization
6088
"ActionExecutionResult",
6189
"ActionGuard",
6290
"AuthorityClient",

predicate_authority/pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"predicate-contracts>=0.1.0,<0.5.0",
2020
"pyyaml>=6.0",
2121
"cryptography>=42.0.0",
22+
"httpx>=0.25.0",
2223
]
2324

2425
[project.optional-dependencies]
@@ -32,8 +33,9 @@ predicate-download-sidecar = "predicate_authority.sidecar_binary:_cli_download"
3233
Documentation = "https://www.PredicateSystems.ai/docs"
3334

3435
[tool.setuptools]
35-
packages = ["predicate_authority", "predicate_authority.integrations"]
36+
packages = ["predicate_authority", "predicate_authority.integrations", "predicate_authority.verify"]
3637

3738
[tool.setuptools.package-dir]
3839
"predicate_authority" = "."
3940
"predicate_authority.integrations" = "integrations"
41+
"predicate_authority.verify" = "verify"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Post-execution verification module.
3+
4+
This module provides verification capability to compare actual operations
5+
against what was authorized via a mandate, detecting unauthorized deviations.
6+
7+
Example:
8+
>>> from predicate_authority.verify import Verifier
9+
>>> verifier = Verifier(base_url="http://127.0.0.1:8787")
10+
>>> result = verifier.verify(
11+
... mandate_id=decision.mandate_id,
12+
... actual={
13+
... "action": "fs.read",
14+
... "resource": "/src/index.ts",
15+
... },
16+
... )
17+
>>> if not result.verified:
18+
... print(f"Operation mismatch: {result.reason}")
19+
"""
20+
21+
from predicate_authority.verify.comparators import (
22+
actions_match,
23+
normalize_resource,
24+
resources_match,
25+
)
26+
from predicate_authority.verify.types import ( # Evidence types (discriminated union); Core types
27+
ActualOperation,
28+
AuthorizedOperation,
29+
BrowserEvidence,
30+
CliEvidence,
31+
DbEvidence,
32+
ExecutionEvidence,
33+
FileEvidence,
34+
GenericEvidence,
35+
HttpEvidence,
36+
MandateDetails,
37+
RecordVerificationRequest,
38+
RecordVerificationResponse,
39+
VerificationFailureReason,
40+
VerifyRequest,
41+
VerifyResult,
42+
get_evidence_type,
43+
)
44+
from predicate_authority.verify.verifier import Verifier
45+
46+
__all__ = [
47+
# Evidence types (discriminated union)
48+
"BrowserEvidence",
49+
"CliEvidence",
50+
"DbEvidence",
51+
"ExecutionEvidence",
52+
"FileEvidence",
53+
"GenericEvidence",
54+
"HttpEvidence",
55+
"get_evidence_type",
56+
# Core types
57+
"ActualOperation",
58+
"AuthorizedOperation",
59+
"MandateDetails",
60+
"RecordVerificationRequest",
61+
"RecordVerificationResponse",
62+
"VerificationFailureReason",
63+
"VerifyRequest",
64+
"VerifyResult",
65+
# Comparators
66+
"actions_match",
67+
"normalize_resource",
68+
"resources_match",
69+
# Verifier
70+
"Verifier",
71+
]
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Resource comparison functions for post-execution verification.
3+
4+
These functions compare authorized resources against actual resources,
5+
handling path normalization and glob pattern matching.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
from fnmatch import fnmatch
12+
13+
from predicate_contracts import normalize_path
14+
15+
16+
def normalize_resource(resource: str) -> str:
17+
"""
18+
Normalize a resource path for comparison.
19+
20+
Applies the following transformations:
21+
- Expands ~ to home directory
22+
- Collapses multiple slashes
23+
- Removes ./ segments
24+
- Removes trailing slashes
25+
- Resolves . and ..
26+
27+
Args:
28+
resource: Resource path to normalize
29+
30+
Returns:
31+
Normalized path
32+
"""
33+
# Use existing normalize_path for filesystem paths
34+
if resource.startswith("/") or resource.startswith("~") or resource.startswith("."):
35+
normalized = normalize_path(resource)
36+
# normalize_path doesn't strip trailing slashes, so we do it here
37+
if len(normalized) > 1 and normalized.endswith("/"):
38+
normalized = normalized[:-1]
39+
return normalized
40+
41+
# For URLs, handle protocol specially
42+
url_match = re.match(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)", resource)
43+
if url_match:
44+
protocol = url_match.group(1) # e.g., "https://"
45+
rest = resource[len(protocol) :]
46+
47+
# Normalize the rest (collapse slashes, remove ./, remove trailing /)
48+
normalized = re.sub(r"/+", "/", rest) # Collapse multiple slashes
49+
normalized = re.sub(r"/\./", "/", normalized) # Remove ./
50+
normalized = re.sub(r"/$", "", normalized) # Remove trailing slash
51+
52+
return protocol + normalized
53+
54+
# For other non-path resources, do basic cleanup
55+
normalized = re.sub(r"/+", "/", resource) # Collapse multiple slashes
56+
normalized = re.sub(r"/\./", "/", normalized) # Remove ./
57+
normalized = re.sub(r"/$", "", normalized) # Remove trailing slash
58+
return normalized
59+
60+
61+
def resources_match(
62+
authorized: str,
63+
actual: str,
64+
*,
65+
allow_glob: bool = True,
66+
) -> bool:
67+
"""
68+
Check if an actual resource matches an authorized resource.
69+
70+
Handles:
71+
- Path normalization (~ expansion, . and .., etc.)
72+
- Optional glob pattern matching (* wildcards)
73+
74+
Args:
75+
authorized: Resource from the mandate (may contain glob patterns)
76+
actual: Resource that was actually accessed
77+
allow_glob: Enable glob pattern matching for authorized resource
78+
79+
Returns:
80+
True if resources match
81+
"""
82+
# Normalize both resources
83+
normalized_auth = normalize_resource(authorized)
84+
normalized_actual = normalize_resource(actual)
85+
86+
# Exact match after normalization
87+
if normalized_auth == normalized_actual:
88+
return True
89+
90+
# Glob pattern match (if enabled and authorized resource contains wildcards)
91+
if allow_glob and "*" in authorized:
92+
return fnmatch(normalized_actual, authorized)
93+
94+
return False
95+
96+
97+
def actions_match(authorized: str, actual: str) -> bool:
98+
"""
99+
Check if an actual action matches an authorized action.
100+
101+
Actions are compared case-sensitively after trimming whitespace.
102+
Supports glob patterns in the authorized action.
103+
104+
Args:
105+
authorized: Action from the mandate (may contain glob patterns)
106+
actual: Action that was actually performed
107+
108+
Returns:
109+
True if actions match
110+
"""
111+
normalized_auth = authorized.strip()
112+
normalized_actual = actual.strip()
113+
114+
# Exact match
115+
if normalized_auth == normalized_actual:
116+
return True
117+
118+
# Glob pattern match (e.g., "fs.*" matches "fs.read")
119+
if "*" in authorized:
120+
return fnmatch(normalized_actual, authorized)
121+
122+
return False

0 commit comments

Comments
 (0)