Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503, E501
exclude =
.git,
__pycache__,
venv,
.venv,
build,
dist,
.eggs,
*.egg-info,
max-complexity = 15
28 changes: 28 additions & 0 deletions predicate_authority/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,36 @@
run_sidecar,
)
from predicate_authority.telemetry import OpenTelemetryTraceEmitter
from predicate_authority.verify import (
ActualOperation,
AuthorizedOperation,
MandateDetails,
RecordVerificationRequest,
RecordVerificationResponse,
VerificationFailureReason,
Verifier,
VerifyRequest,
VerifyResult,
actions_match,
normalize_resource,
resources_match,
)

__all__ = [
# Verification module
"ActualOperation",
"AuthorizedOperation",
"MandateDetails",
"RecordVerificationRequest",
"RecordVerificationResponse",
"VerificationFailureReason",
"Verifier",
"VerifyRequest",
"VerifyResult",
"actions_match",
"normalize_resource",
"resources_match",
# Authorization
"ActionExecutionResult",
"ActionGuard",
"AuthorityClient",
Expand Down
4 changes: 3 additions & 1 deletion predicate_authority/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"predicate-contracts>=0.1.0,<0.5.0",
"pyyaml>=6.0",
"cryptography>=42.0.0",
"httpx>=0.25.0",
]

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

[tool.setuptools]
packages = ["predicate_authority", "predicate_authority.integrations"]
packages = ["predicate_authority", "predicate_authority.integrations", "predicate_authority.verify"]

[tool.setuptools.package-dir]
"predicate_authority" = "."
"predicate_authority.integrations" = "integrations"
"predicate_authority.verify" = "verify"
71 changes: 71 additions & 0 deletions predicate_authority/verify/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Post-execution verification module.

This module provides verification capability to compare actual operations
against what was authorized via a mandate, detecting unauthorized deviations.

Example:
>>> from predicate_authority.verify import Verifier
>>> verifier = Verifier(base_url="http://127.0.0.1:8787")
>>> result = verifier.verify(
... mandate_id=decision.mandate_id,
... actual={
... "action": "fs.read",
... "resource": "/src/index.ts",
... },
... )
>>> if not result.verified:
... print(f"Operation mismatch: {result.reason}")
"""

from predicate_authority.verify.comparators import (
actions_match,
normalize_resource,
resources_match,
)
from predicate_authority.verify.types import ( # Evidence types (discriminated union); Core types
ActualOperation,
AuthorizedOperation,
BrowserEvidence,
CliEvidence,
DbEvidence,
ExecutionEvidence,
FileEvidence,
GenericEvidence,
HttpEvidence,
MandateDetails,
RecordVerificationRequest,
RecordVerificationResponse,
VerificationFailureReason,
VerifyRequest,
VerifyResult,
get_evidence_type,
)
from predicate_authority.verify.verifier import Verifier

__all__ = [
# Evidence types (discriminated union)
"BrowserEvidence",
"CliEvidence",
"DbEvidence",
"ExecutionEvidence",
"FileEvidence",
"GenericEvidence",
"HttpEvidence",
"get_evidence_type",
# Core types
"ActualOperation",
"AuthorizedOperation",
"MandateDetails",
"RecordVerificationRequest",
"RecordVerificationResponse",
"VerificationFailureReason",
"VerifyRequest",
"VerifyResult",
# Comparators
"actions_match",
"normalize_resource",
"resources_match",
# Verifier
"Verifier",
]
122 changes: 122 additions & 0 deletions predicate_authority/verify/comparators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""
Resource comparison functions for post-execution verification.

These functions compare authorized resources against actual resources,
handling path normalization and glob pattern matching.
"""

from __future__ import annotations

import re
from fnmatch import fnmatch

from predicate_contracts import normalize_path


def normalize_resource(resource: str) -> str:
"""
Normalize a resource path for comparison.

Applies the following transformations:
- Expands ~ to home directory
- Collapses multiple slashes
- Removes ./ segments
- Removes trailing slashes
- Resolves . and ..

Args:
resource: Resource path to normalize

Returns:
Normalized path
"""
# Use existing normalize_path for filesystem paths
if resource.startswith("/") or resource.startswith("~") or resource.startswith("."):
normalized = normalize_path(resource)
# normalize_path doesn't strip trailing slashes, so we do it here
if len(normalized) > 1 and normalized.endswith("/"):
normalized = normalized[:-1]
return normalized

# For URLs, handle protocol specially
url_match = re.match(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)", resource)
if url_match:
protocol = url_match.group(1) # e.g., "https://"
rest = resource[len(protocol) :]

# Normalize the rest (collapse slashes, remove ./, remove trailing /)
normalized = re.sub(r"/+", "/", rest) # Collapse multiple slashes
normalized = re.sub(r"/\./", "/", normalized) # Remove ./
normalized = re.sub(r"/$", "", normalized) # Remove trailing slash

return protocol + normalized

# For other non-path resources, do basic cleanup
normalized = re.sub(r"/+", "/", resource) # Collapse multiple slashes
normalized = re.sub(r"/\./", "/", normalized) # Remove ./
normalized = re.sub(r"/$", "", normalized) # Remove trailing slash
return normalized


def resources_match(
authorized: str,
actual: str,
*,
allow_glob: bool = True,
) -> bool:
"""
Check if an actual resource matches an authorized resource.

Handles:
- Path normalization (~ expansion, . and .., etc.)
- Optional glob pattern matching (* wildcards)

Args:
authorized: Resource from the mandate (may contain glob patterns)
actual: Resource that was actually accessed
allow_glob: Enable glob pattern matching for authorized resource

Returns:
True if resources match
"""
# Normalize both resources
normalized_auth = normalize_resource(authorized)
normalized_actual = normalize_resource(actual)

# Exact match after normalization
if normalized_auth == normalized_actual:
return True

# Glob pattern match (if enabled and authorized resource contains wildcards)
if allow_glob and "*" in authorized:
return fnmatch(normalized_actual, authorized)

return False


def actions_match(authorized: str, actual: str) -> bool:
"""
Check if an actual action matches an authorized action.

Actions are compared case-sensitively after trimming whitespace.
Supports glob patterns in the authorized action.

Args:
authorized: Action from the mandate (may contain glob patterns)
actual: Action that was actually performed

Returns:
True if actions match
"""
normalized_auth = authorized.strip()
normalized_actual = actual.strip()

# Exact match
if normalized_auth == normalized_actual:
return True

# Glob pattern match (e.g., "fs.*" matches "fs.read")
if "*" in authorized:
return fnmatch(normalized_actual, authorized)

return False
Loading