proofstep-trajectory 0.1.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.
- proofstep_trajectory-0.1.0/.gitignore +57 -0
- proofstep_trajectory-0.1.0/PKG-INFO +48 -0
- proofstep_trajectory-0.1.0/README.md +25 -0
- proofstep_trajectory-0.1.0/pyproject.toml +39 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/__init__.py +53 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/engine.py +71 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/evaluator.py +93 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/events.py +150 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/matchers.py +453 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/normalize.py +318 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/parser.py +204 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/predicates.py +219 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/py.typed +0 -0
- proofstep_trajectory-0.1.0/src/proofstep_trajectory/schema.py +262 -0
- proofstep_trajectory-0.1.0/tests/builders.py +120 -0
- proofstep_trajectory-0.1.0/tests/conftest.py +11 -0
- proofstep_trajectory-0.1.0/tests/test_matchers.py +352 -0
- proofstep_trajectory-0.1.0/tests/test_normalize.py +249 -0
- proofstep_trajectory-0.1.0/tests/test_parser.py +207 -0
- proofstep_trajectory-0.1.0/tests/test_reference_policy.py +169 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
__pycache__/
|
|
3
|
+
.coverage
|
|
4
|
+
.coverage.*
|
|
5
|
+
.docker-data/
|
|
6
|
+
.DS_Store
|
|
7
|
+
.e2e-api.log
|
|
8
|
+
.env
|
|
9
|
+
.env.*
|
|
10
|
+
.env.prod
|
|
11
|
+
# Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
|
|
12
|
+
# negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
|
|
13
|
+
# line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
|
|
14
|
+
# `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
|
|
15
|
+
# anyone who cloned the repository. It was caught by CI running the same step.
|
|
16
|
+
!.env.example
|
|
17
|
+
!.env.prod.example
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.idea/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.next/
|
|
22
|
+
.proofstep/
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.ruff_cache/
|
|
25
|
+
.turbo/
|
|
26
|
+
.venv/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
*.key
|
|
30
|
+
*.pem
|
|
31
|
+
*.proofstep.local.yaml
|
|
32
|
+
*.py[cod]
|
|
33
|
+
*.swp
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
# Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
|
|
36
|
+
# Docker volumes
|
|
37
|
+
# Editors / OS
|
|
38
|
+
# is a backup with no access control and no retention.
|
|
39
|
+
# Node
|
|
40
|
+
# Proofstep local state
|
|
41
|
+
# Python
|
|
42
|
+
# Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
|
|
43
|
+
# Secrets and local config — never commit these
|
|
44
|
+
# The e2e stack's server log, written next to the repo so a CI failure can print it.
|
|
45
|
+
# to cover all of them rather than the default filename only.
|
|
46
|
+
~/.proofstep/
|
|
47
|
+
backups/
|
|
48
|
+
build/
|
|
49
|
+
coverage.xml
|
|
50
|
+
credentials.json
|
|
51
|
+
dist/
|
|
52
|
+
htmlcov/
|
|
53
|
+
node_modules/
|
|
54
|
+
out/
|
|
55
|
+
proofstep-*.json
|
|
56
|
+
secrets/
|
|
57
|
+
venv/
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proofstep-trajectory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Proofstep trajectory policy engine — policy-as-code for tool-using agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
|
|
6
|
+
Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/IlaKhan17/proofstep
|
|
8
|
+
Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: proofstep-types
|
|
20
|
+
Requires-Dist: pydantic>=2.9
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# proofstep-trajectory
|
|
25
|
+
|
|
26
|
+
**Trajectory policy engine** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
27
|
+
agents that knows the difference between a regression and a bad day.
|
|
28
|
+
|
|
29
|
+
Evaluates what an agent *did* — the ordered sequence of tool calls, their arguments, and the state
|
|
30
|
+
they left behind — against policies written as reviewable YAML.
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
rules:
|
|
34
|
+
- id: no-send-before-approval
|
|
35
|
+
kind: forbidden_before
|
|
36
|
+
severity: block
|
|
37
|
+
action: gmail.send
|
|
38
|
+
before: approval_received
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
An agent can produce a flawless email and still have sent it before approval. No output evaluator
|
|
42
|
+
can detect that; this is what does.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
47
|
+
|
|
48
|
+
Apache-2.0.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# proofstep-trajectory
|
|
2
|
+
|
|
3
|
+
**Trajectory policy engine** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
4
|
+
agents that knows the difference between a regression and a bad day.
|
|
5
|
+
|
|
6
|
+
Evaluates what an agent *did* — the ordered sequence of tool calls, their arguments, and the state
|
|
7
|
+
they left behind — against policies written as reviewable YAML.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
rules:
|
|
11
|
+
- id: no-send-before-approval
|
|
12
|
+
kind: forbidden_before
|
|
13
|
+
severity: block
|
|
14
|
+
action: gmail.send
|
|
15
|
+
before: approval_received
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
An agent can produce a flawless email and still have sent it before approval. No output evaluator
|
|
19
|
+
can detect that; this is what does.
|
|
20
|
+
|
|
21
|
+
## Documentation
|
|
22
|
+
|
|
23
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
24
|
+
|
|
25
|
+
Apache-2.0.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "proofstep-trajectory"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Proofstep trajectory policy engine — policy-as-code for tool-using agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 4 - Beta",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
dependencies = [
|
|
20
|
+
"proofstep-types",
|
|
21
|
+
"pydantic>=2.9",
|
|
22
|
+
"pyyaml>=6.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/IlaKhan17/proofstep"
|
|
27
|
+
Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
|
|
28
|
+
Repository = "https://github.com/IlaKhan17/proofstep"
|
|
29
|
+
Issues = "https://github.com/IlaKhan17/proofstep/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/proofstep_trajectory"]
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
proofstep-types = { workspace = true }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Proofstep trajectory policy engine (pure library — no I/O).
|
|
2
|
+
|
|
3
|
+
Policy-as-code for tool-using agents: `(policy, trace) -> list[PolicyFailure]`.
|
|
4
|
+
|
|
5
|
+
Output evaluation asks whether the answer was good. This asks whether the agent
|
|
6
|
+
behaved legitimately on the way there — an agent can produce a flawless email and
|
|
7
|
+
still have sent it before approval, which no output evaluator can detect.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from importlib import metadata as _metadata
|
|
11
|
+
|
|
12
|
+
from proofstep_trajectory.engine import evaluate_policy
|
|
13
|
+
from proofstep_trajectory.events import (
|
|
14
|
+
EventRef,
|
|
15
|
+
PolicyFailure,
|
|
16
|
+
PolicyResult,
|
|
17
|
+
TrajectoryEvent,
|
|
18
|
+
)
|
|
19
|
+
from proofstep_trajectory.normalize import Normalized, args_hash, normalize
|
|
20
|
+
from proofstep_trajectory.parser import (
|
|
21
|
+
LoadedPolicy,
|
|
22
|
+
PolicyError,
|
|
23
|
+
check_actions,
|
|
24
|
+
load_policy,
|
|
25
|
+
load_policy_file,
|
|
26
|
+
)
|
|
27
|
+
from proofstep_trajectory.predicates import PredicateError, compile_predicate
|
|
28
|
+
from proofstep_trajectory.schema import Include, Policy
|
|
29
|
+
|
|
30
|
+
# Read from the installed distribution rather than written here twice. A hand-maintained
|
|
31
|
+
# copy drifts the first time a release bumps one and not the other — which it already did,
|
|
32
|
+
# reporting 0.1.0.dev0 from a 0.1.0 wheel.
|
|
33
|
+
__version__ = _metadata.version("proofstep-trajectory")
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"EventRef",
|
|
37
|
+
"Include",
|
|
38
|
+
"LoadedPolicy",
|
|
39
|
+
"Normalized",
|
|
40
|
+
"Policy",
|
|
41
|
+
"PolicyError",
|
|
42
|
+
"PolicyFailure",
|
|
43
|
+
"PolicyResult",
|
|
44
|
+
"PredicateError",
|
|
45
|
+
"TrajectoryEvent",
|
|
46
|
+
"args_hash",
|
|
47
|
+
"check_actions",
|
|
48
|
+
"compile_predicate",
|
|
49
|
+
"evaluate_policy",
|
|
50
|
+
"load_policy",
|
|
51
|
+
"load_policy_file",
|
|
52
|
+
"normalize",
|
|
53
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Top-level evaluation: (policy, trace) -> PolicyResult."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from proofstep_trajectory.events import PolicyFailure, PolicyResult
|
|
9
|
+
from proofstep_trajectory.matchers import MATCHERS, REQUIRES_COMPLETE
|
|
10
|
+
from proofstep_trajectory.normalize import Normalized, normalize
|
|
11
|
+
from proofstep_trajectory.parser import LoadedPolicy, check_actions
|
|
12
|
+
from proofstep_trajectory.predicates import PredicateError, evaluate
|
|
13
|
+
from proofstep_types import Trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def evaluate_policy(loaded: LoadedPolicy, trace: Trace) -> PolicyResult:
|
|
17
|
+
"""Evaluate every rule in the policy against one trace."""
|
|
18
|
+
policy = loaded.policy
|
|
19
|
+
norm = normalize(trace, policy)
|
|
20
|
+
|
|
21
|
+
failures: list[PolicyFailure] = []
|
|
22
|
+
inconclusive: list[str] = []
|
|
23
|
+
warnings = [*norm.warnings, *check_actions(loaded, norm.actions)]
|
|
24
|
+
|
|
25
|
+
for rule in policy.rules:
|
|
26
|
+
# Absence of evidence is not evidence of absence: a rule that asserts
|
|
27
|
+
# something *did* happen cannot be judged on a trajectory that lost spans.
|
|
28
|
+
# Rules that observe a forbidden action still run, because seeing it is
|
|
29
|
+
# valid evidence no matter what is missing.
|
|
30
|
+
if norm.incomplete and rule.kind in REQUIRES_COMPLETE:
|
|
31
|
+
inconclusive.append(rule.id)
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
if rule.when is not None and rule.kind != "conditional":
|
|
35
|
+
if not _guard(rule, norm, warnings):
|
|
36
|
+
continue
|
|
37
|
+
elif rule.kind == "conditional" and not _guard(rule, norm, warnings):
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
for failure in MATCHERS[rule.kind](rule, norm):
|
|
41
|
+
failures.append(
|
|
42
|
+
failure if failure.policy_line else _with_line(failure, loaded.line_for(rule.id))
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return PolicyResult(
|
|
46
|
+
policy_name=policy.name,
|
|
47
|
+
failures=failures,
|
|
48
|
+
events=norm.events,
|
|
49
|
+
incomplete=norm.incomplete,
|
|
50
|
+
inconclusive_rules=inconclusive,
|
|
51
|
+
warnings=warnings,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _guard(rule: Any, norm: Normalized, warnings: list[str]) -> bool:
|
|
56
|
+
"""Evaluate a rule's `when` predicate. A broken predicate must not pass silently."""
|
|
57
|
+
namespace = {
|
|
58
|
+
"metadata": norm.state,
|
|
59
|
+
"state": norm.state,
|
|
60
|
+
"actions": dict.fromkeys(norm.actions, True),
|
|
61
|
+
"counts": {action: norm.counts(action) for action in norm.actions},
|
|
62
|
+
}
|
|
63
|
+
try:
|
|
64
|
+
return evaluate(rule.when, namespace)
|
|
65
|
+
except PredicateError as exc:
|
|
66
|
+
warnings.append(f"rule {rule.id!r}: `when` could not be evaluated ({exc}); rule skipped")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _with_line(failure: PolicyFailure, line: int | None) -> PolicyFailure:
|
|
71
|
+
return replace(failure, policy_line=line)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Adapter exposing a policy as an evaluator.
|
|
2
|
+
|
|
3
|
+
Deliberately placed here rather than in `proofstep-core`. The two packages are
|
|
4
|
+
siblings in the layering contract and neither may import the other; this works
|
|
5
|
+
because `Evaluator` is a *structural* protocol, so an object with the right shape
|
|
6
|
+
satisfies it without any import. The only shared dependency is `proofstep-types`.
|
|
7
|
+
|
|
8
|
+
That is the payoff of protocol-based extension points: the integration costs nothing
|
|
9
|
+
architecturally.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from proofstep_trajectory.engine import evaluate_policy
|
|
18
|
+
from proofstep_trajectory.parser import LoadedPolicy, load_policy_file
|
|
19
|
+
from proofstep_types import Score, Severity
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrajectoryEvaluator:
|
|
23
|
+
"""Scores an example by evaluating its captured trajectory against a policy.
|
|
24
|
+
|
|
25
|
+
Emits 1.0 when the policy holds and 0.0 when a blocking rule fails. An
|
|
26
|
+
incomplete trajectory produces an *errored* score rather than a zero: the run
|
|
27
|
+
failed to observe enough to judge, which is a different fact from observing a
|
|
28
|
+
violation, and gating on the difference is the whole point.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
version = 1
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
policy: LoadedPolicy | str | Path,
|
|
36
|
+
*,
|
|
37
|
+
name: str | None = None,
|
|
38
|
+
fail_on_warnings: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.loaded = policy if isinstance(policy, LoadedPolicy) else load_policy_file(policy)
|
|
41
|
+
self.name = name or self.loaded.policy.name.replace("-", "_")
|
|
42
|
+
self.fail_on_warnings = fail_on_warnings
|
|
43
|
+
self.requires_trace = True
|
|
44
|
+
|
|
45
|
+
async def evaluate(self, ctx: Any) -> Score:
|
|
46
|
+
trace = getattr(ctx, "trace", None)
|
|
47
|
+
if trace is None:
|
|
48
|
+
return Score.failure(
|
|
49
|
+
self.name,
|
|
50
|
+
f"policy {self.loaded.policy.name!r} needs a captured trajectory but the "
|
|
51
|
+
"task produced no trace; is the task instrumented with the SDK?",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
result = evaluate_policy(self.loaded, trace)
|
|
55
|
+
|
|
56
|
+
if result.inconclusive_rules:
|
|
57
|
+
return Score.failure(
|
|
58
|
+
self.name,
|
|
59
|
+
f"trajectory is incomplete, so {len(result.inconclusive_rules)} rule(s) "
|
|
60
|
+
f"could not be judged: {', '.join(result.inconclusive_rules)}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
failing = [
|
|
64
|
+
f
|
|
65
|
+
for f in result.failures
|
|
66
|
+
if f.severity is Severity.BLOCK or (self.fail_on_warnings and f.blocking is False)
|
|
67
|
+
]
|
|
68
|
+
passed = not failing
|
|
69
|
+
|
|
70
|
+
return Score(
|
|
71
|
+
metric=self.name,
|
|
72
|
+
value=1.0 if passed else 0.0,
|
|
73
|
+
passed=passed,
|
|
74
|
+
reasoning=result.format(policy_path=self.loaded.path) if failing else None,
|
|
75
|
+
raw={
|
|
76
|
+
"policy": self.loaded.policy.name,
|
|
77
|
+
"policy_hash": self.loaded.content_hash,
|
|
78
|
+
"event_count": len(result.events),
|
|
79
|
+
"failures": [
|
|
80
|
+
{
|
|
81
|
+
"rule_id": f.rule_id,
|
|
82
|
+
"rule_kind": f.rule_kind,
|
|
83
|
+
"severity": f.severity.value,
|
|
84
|
+
"message": f.message,
|
|
85
|
+
"span_id": f.offending_span_id,
|
|
86
|
+
"event_index": f.offending_event_index,
|
|
87
|
+
"policy_line": f.policy_line,
|
|
88
|
+
}
|
|
89
|
+
for f in result.failures
|
|
90
|
+
],
|
|
91
|
+
"warnings": result.warnings,
|
|
92
|
+
},
|
|
93
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The normalized trajectory event, and the result of evaluating a policy.
|
|
2
|
+
|
|
3
|
+
Rules never touch spans. They operate on this flat, ordered event list, so a
|
|
4
|
+
normalization fix corrects every rule at once instead of twelve times.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from proofstep_types import Severity, Status
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class TrajectoryEvent:
|
|
18
|
+
index: int
|
|
19
|
+
action: str
|
|
20
|
+
span_id: str
|
|
21
|
+
parent_span_id: str | None
|
|
22
|
+
depth: int
|
|
23
|
+
started_at: datetime
|
|
24
|
+
ended_at: datetime | None
|
|
25
|
+
status: Status
|
|
26
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
args_hash: str = ""
|
|
28
|
+
attempt: int = 1
|
|
29
|
+
is_retry: bool = False
|
|
30
|
+
parallel_group: str | None = None
|
|
31
|
+
result_summary: Any = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def failed(self) -> bool:
|
|
35
|
+
return self.status in (Status.ERROR, Status.TIMEOUT)
|
|
36
|
+
|
|
37
|
+
def key(self, parts: list[str]) -> tuple[Any, ...]:
|
|
38
|
+
"""Composite identity used by unique_action and no_loop."""
|
|
39
|
+
values: list[Any] = []
|
|
40
|
+
for part in parts:
|
|
41
|
+
if part == "action":
|
|
42
|
+
values.append(self.action)
|
|
43
|
+
elif part == "args_hash":
|
|
44
|
+
values.append(self.args_hash)
|
|
45
|
+
elif part.startswith("args."):
|
|
46
|
+
values.append(_dig(self.args, part[5:]))
|
|
47
|
+
else:
|
|
48
|
+
values.append(getattr(self, part, None))
|
|
49
|
+
return tuple(values)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dig(source: dict[str, Any], path: str) -> Any:
|
|
53
|
+
current: Any = source
|
|
54
|
+
for part in path.split("."):
|
|
55
|
+
if not isinstance(current, dict) or part not in current:
|
|
56
|
+
return None
|
|
57
|
+
current = current[part]
|
|
58
|
+
return current
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class EventRef:
|
|
63
|
+
"""A pointer back to a span, so every finding is clickable."""
|
|
64
|
+
|
|
65
|
+
index: int
|
|
66
|
+
span_id: str
|
|
67
|
+
action: str
|
|
68
|
+
at: datetime
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class PolicyFailure:
|
|
73
|
+
rule_id: str
|
|
74
|
+
rule_kind: str
|
|
75
|
+
severity: Severity
|
|
76
|
+
message: str
|
|
77
|
+
offending_span_id: str | None = None
|
|
78
|
+
offending_event_index: int | None = None
|
|
79
|
+
offending_action: str = ""
|
|
80
|
+
expected: Any = None
|
|
81
|
+
actual: Any = None
|
|
82
|
+
evidence: list[EventRef] = field(default_factory=list)
|
|
83
|
+
policy_line: int | None = None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def blocking(self) -> bool:
|
|
87
|
+
return self.severity is Severity.BLOCK
|
|
88
|
+
|
|
89
|
+
def format(self, *, policy_path: str | None = None) -> str:
|
|
90
|
+
"""Human-readable rendering.
|
|
91
|
+
|
|
92
|
+
Message quality is a feature. Every failure states what happened, where,
|
|
93
|
+
what was expected, and how to go look — "policy violation" is banned.
|
|
94
|
+
"""
|
|
95
|
+
mark = "✗" if self.blocking else "⚠"
|
|
96
|
+
lines = [f"{mark} {self.rule_id} [{self.severity.value}]", f" {self.message}"]
|
|
97
|
+
if self.offending_span_id:
|
|
98
|
+
label = f"{self.offending_action} " if self.offending_action else ""
|
|
99
|
+
where = f" offending : {label}span {self.offending_span_id}"
|
|
100
|
+
if self.offending_event_index is not None:
|
|
101
|
+
where += f" (event #{self.offending_event_index})"
|
|
102
|
+
lines.append(where)
|
|
103
|
+
if self.expected is not None:
|
|
104
|
+
lines.append(f" expected : {self.expected}")
|
|
105
|
+
if self.actual is not None:
|
|
106
|
+
lines.append(f" observed : {self.actual}")
|
|
107
|
+
if policy_path:
|
|
108
|
+
suffix = f":{self.policy_line}" if self.policy_line else ""
|
|
109
|
+
lines.append(f" policy : {policy_path}{suffix}")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class PolicyResult:
|
|
115
|
+
policy_name: str
|
|
116
|
+
failures: list[PolicyFailure] = field(default_factory=list)
|
|
117
|
+
events: list[TrajectoryEvent] = field(default_factory=list)
|
|
118
|
+
incomplete: bool = False
|
|
119
|
+
inconclusive_rules: list[str] = field(default_factory=list)
|
|
120
|
+
warnings: list[str] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def passed(self) -> bool:
|
|
124
|
+
"""Inconclusive is not passed.
|
|
125
|
+
|
|
126
|
+
A trajectory that lost spans cannot support an assertion about what did not
|
|
127
|
+
happen, and reporting that as compliance would be a false negative in the
|
|
128
|
+
direction that matters.
|
|
129
|
+
"""
|
|
130
|
+
return not any(f.blocking for f in self.failures) and not self.inconclusive_rules
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def score(self) -> float:
|
|
134
|
+
return 1.0 if self.passed else 0.0
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def blocking_failures(self) -> list[PolicyFailure]:
|
|
138
|
+
return [f for f in self.failures if f.blocking]
|
|
139
|
+
|
|
140
|
+
def format(self, *, policy_path: str | None = None) -> str:
|
|
141
|
+
if not self.failures and not self.inconclusive_rules:
|
|
142
|
+
return f"✓ {self.policy_name}: {len(self.events)} events, no violations"
|
|
143
|
+
blocks = [f.format(policy_path=policy_path) for f in self.failures]
|
|
144
|
+
for rule_id in self.inconclusive_rules:
|
|
145
|
+
blocks.append(
|
|
146
|
+
f"? {rule_id} [inconclusive]\n"
|
|
147
|
+
" The trajectory is incomplete (spans were dropped or left open), so "
|
|
148
|
+
"this rule cannot assert that something did not happen."
|
|
149
|
+
)
|
|
150
|
+
return "\n\n".join(blocks)
|