sopvm 0.2.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.
- sopvm/__init__.py +92 -0
- sopvm/capability/__init__.py +12 -0
- sopvm/capability/policy.py +94 -0
- sopvm/capability/token.py +159 -0
- sopvm/checker/__init__.py +5 -0
- sopvm/checker/check.py +176 -0
- sopvm/cli/__init__.py +1 -0
- sopvm/cli/main.py +177 -0
- sopvm/compiler/__init__.py +8 -0
- sopvm/compiler/errors.py +16 -0
- sopvm/compiler/lower.py +61 -0
- sopvm/compiler/page.py +53 -0
- sopvm/compiler/pipeline.py +37 -0
- sopvm/integrations/__init__.py +1 -0
- sopvm/integrations/langgraph/__init__.py +1 -0
- sopvm/integrations/langgraph/node.py +83 -0
- sopvm/ir/__init__.py +5 -0
- sopvm/ir/model.py +94 -0
- sopvm/ir/versions.py +7 -0
- sopvm/parser/__init__.py +15 -0
- sopvm/parser/ast.py +62 -0
- sopvm/parser/errors.py +53 -0
- sopvm/parser/parse.py +151 -0
- sopvm/plugins/__init__.py +13 -0
- sopvm/plugins/base.py +53 -0
- sopvm/plugins/registry.py +43 -0
- sopvm/plugins/sandbox.py +63 -0
- sopvm/runtime/__init__.py +18 -0
- sopvm/runtime/events.py +30 -0
- sopvm/runtime/executor.py +288 -0
- sopvm/runtime/gate.py +75 -0
- sopvm/runtime/state.py +27 -0
- sopvm/runtime/violations.py +29 -0
- sopvm/schemas/capability.schema.json +25 -0
- sopvm/schemas/sop.schema.json +78 -0
- sopvm/telemetry/__init__.py +12 -0
- sopvm/telemetry/events.py +89 -0
- sopvm/telemetry/sink.py +58 -0
- sopvm-0.2.0.dist-info/METADATA +115 -0
- sopvm-0.2.0.dist-info/RECORD +42 -0
- sopvm-0.2.0.dist-info/WHEEL +4 -0
- sopvm-0.2.0.dist-info/entry_points.txt +2 -0
sopvm/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""SOPVM — Public API (Milestone 13).
|
|
2
|
+
|
|
3
|
+
Per INTERFACES.md §8:
|
|
4
|
+
|
|
5
|
+
sopvm.compile(sop_path: str, policy_path: str) -> CompiledProgram
|
|
6
|
+
sopvm.check(compiled: CompiledProgram) -> CheckResult
|
|
7
|
+
sopvm.Runtime(compiled: CompiledProgram, providers: list[ToolProvider])
|
|
8
|
+
.run() -> RunResult
|
|
9
|
+
|
|
10
|
+
Anything not in this list is internal and may change without a version bump.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from sopvm.compiler.pipeline import compile_sop
|
|
20
|
+
from sopvm.ir.model import CompiledProgram
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sopvm.checker.check import CheckResult
|
|
24
|
+
from sopvm.plugins.base import ToolProvider
|
|
25
|
+
from sopvm.runtime.executor import RunResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Public API surface ------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def compile(sop_path: str, policy_path: str) -> CompiledProgram:
|
|
31
|
+
"""Compile a SOP YAML into a ``CompiledProgram``.
|
|
32
|
+
|
|
33
|
+
This is the primary entry point for the compiler pipeline.
|
|
34
|
+
"""
|
|
35
|
+
return compile_sop(sop_path, policy_path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(compiled: CompiledProgram) -> CheckResult:
|
|
39
|
+
"""Check a compiled program against its embedded policy.
|
|
40
|
+
|
|
41
|
+
Returns a ``CheckResult`` with ``passed`` (bool) and ``violations``.
|
|
42
|
+
"""
|
|
43
|
+
from sopvm.checker.check import check as _check
|
|
44
|
+
from sopvm.capability.policy import load_policy
|
|
45
|
+
policy = load_policy(compiled.policy_ref) # type: ignore[attr-defined]
|
|
46
|
+
return _check(compiled, policy)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Runtime:
|
|
50
|
+
"""Runtime for executing a compiled SOP program.
|
|
51
|
+
|
|
52
|
+
Per INTERFACES.md §8::
|
|
53
|
+
|
|
54
|
+
sopvm.Runtime(compiled, providers).run() -> RunResult
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
compiled: CompiledProgram,
|
|
60
|
+
providers: list[ToolProvider] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
from sopvm.plugins.registry import ProviderRegistry
|
|
63
|
+
|
|
64
|
+
self._compiled = compiled
|
|
65
|
+
self._registry = ProviderRegistry()
|
|
66
|
+
if providers:
|
|
67
|
+
for p in providers:
|
|
68
|
+
self._registry.register(p)
|
|
69
|
+
|
|
70
|
+
def run(self) -> RunResult:
|
|
71
|
+
"""Execute the compiled SOP and return a ``RunResult``."""
|
|
72
|
+
from sopvm.runtime.executor import Executor
|
|
73
|
+
from sopvm.runtime.state import StepState
|
|
74
|
+
|
|
75
|
+
class _SimpleHandler:
|
|
76
|
+
def execute(self, node, request_tool=None):
|
|
77
|
+
return StepState.DONE
|
|
78
|
+
|
|
79
|
+
executor = Executor(
|
|
80
|
+
program=self._compiled,
|
|
81
|
+
handler=_SimpleHandler(),
|
|
82
|
+
registry=self._registry,
|
|
83
|
+
)
|
|
84
|
+
return executor.run()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"CompiledProgram",
|
|
89
|
+
"Runtime",
|
|
90
|
+
"check",
|
|
91
|
+
"compile",
|
|
92
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Capability token package (Milestone 4)."""
|
|
2
|
+
|
|
3
|
+
from .policy import Policy, load_policy
|
|
4
|
+
from .token import CapabilityGrammarError, CapabilityToken, parse_capability
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CapabilityGrammarError",
|
|
8
|
+
"CapabilityToken",
|
|
9
|
+
"Policy",
|
|
10
|
+
"load_policy",
|
|
11
|
+
"parse_capability",
|
|
12
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Policy loader — parses YAML policy files per INTERFACES.md §3.
|
|
2
|
+
|
|
3
|
+
Policy format:
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
policy_version: "0.1"
|
|
7
|
+
allowed_capabilities:
|
|
8
|
+
- "db:read(orders)"
|
|
9
|
+
- "payments:refund(max_amount<=100.00)"
|
|
10
|
+
- "notify:email"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Each entry in ``allowed_capabilities`` is a capability string parsed
|
|
14
|
+
into a ``CapabilityToken``. Comparator params (``<=``, ``>=``, etc.)
|
|
15
|
+
express ceilings that the static checker evaluates against SOP requests.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from .token import CapabilityToken, CapabilityGrammarError, parse_capability
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PolicyError(Exception):
|
|
29
|
+
"""Raised when a policy file is invalid or missing required fields."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str) -> None:
|
|
32
|
+
self.message = message
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Policy:
|
|
38
|
+
"""A parsed capability policy.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
policy_version: Policy schema version.
|
|
42
|
+
allowed_capabilities: List of ceiling capability tokens.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
policy_version: str
|
|
46
|
+
allowed_capabilities: tuple[CapabilityToken, ...] = field(default_factory=tuple)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_policy(path: str | Path) -> Policy:
|
|
50
|
+
"""Load and parse a YAML policy file.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path: Filesystem path to the policy YAML file.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A validated ``Policy``.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
PolicyError: If the file is missing required fields or contains
|
|
60
|
+
malformed capability strings.
|
|
61
|
+
"""
|
|
62
|
+
path = Path(path)
|
|
63
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
64
|
+
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
raise PolicyError(
|
|
67
|
+
f"policy file must be a YAML mapping, got {type(raw).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if "policy_version" not in raw:
|
|
71
|
+
raise PolicyError("policy file missing required field: policy_version")
|
|
72
|
+
|
|
73
|
+
if "allowed_capabilities" not in raw:
|
|
74
|
+
raise PolicyError("policy file missing required field: allowed_capabilities")
|
|
75
|
+
|
|
76
|
+
caps_raw = raw["allowed_capabilities"]
|
|
77
|
+
if not isinstance(caps_raw, list) or len(caps_raw) == 0:
|
|
78
|
+
raise PolicyError("allowed_capabilities must be a non-empty list")
|
|
79
|
+
|
|
80
|
+
tokens: list[CapabilityToken] = []
|
|
81
|
+
for entry in caps_raw:
|
|
82
|
+
if not isinstance(entry, str):
|
|
83
|
+
raise PolicyError(
|
|
84
|
+
f"allowed_capabilities entries must be strings, got: {type(entry).__name__}"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
tokens.append(parse_capability(entry))
|
|
88
|
+
except CapabilityGrammarError as e:
|
|
89
|
+
raise PolicyError(f"malformed capability in policy: {e}") from e
|
|
90
|
+
|
|
91
|
+
return Policy(
|
|
92
|
+
policy_version=raw["policy_version"],
|
|
93
|
+
allowed_capabilities=tuple(tokens),
|
|
94
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Capability token data model and parser.
|
|
2
|
+
|
|
3
|
+
Grammar: ``namespace:action(param=value, ...)``
|
|
4
|
+
|
|
5
|
+
Policy entries may use comparator params (``max_amount<=100.00``) to
|
|
6
|
+
express ceilings. SOP-declared requests use concrete values only
|
|
7
|
+
(``max_amount=100.00``). The parser accepts both forms; the semantic
|
|
8
|
+
distinction is enforced by the checker, not the parser.
|
|
9
|
+
|
|
10
|
+
Param forms:
|
|
11
|
+
- ``ns:action(key<=value)`` — comparator (policy ceiling)
|
|
12
|
+
- ``ns:action(key=value)`` — named assignment
|
|
13
|
+
- ``ns:action(resource)`` — bare resource identifier
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
# Matches: namespace:action or namespace:action(params)
|
|
22
|
+
_CAP_RE = re.compile(
|
|
23
|
+
r"^(?P<ns>[a-zA-Z_][a-zA-Z0-9_]*)"
|
|
24
|
+
r":(?P<action>[a-zA-Z_][a-zA-Z0-9_]*)"
|
|
25
|
+
r"(?:\((?P<params>.*)\))?$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Comparator: key<=value, key>=value, key<value, key>value, key==value
|
|
29
|
+
_PARAM_COMPARATOR_RE = re.compile(
|
|
30
|
+
r"^\s*(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)"
|
|
31
|
+
r"\s*(?P<op><=|>=|<|>|==)"
|
|
32
|
+
r"\s*(?P<value>.+?)\s*$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Assignment: key=value
|
|
36
|
+
_PARAM_ASSIGN_RE = re.compile(
|
|
37
|
+
r"^\s*(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)"
|
|
38
|
+
r"\s*=\s*"
|
|
39
|
+
r"(?P<value>.+?)\s*$"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Bare resource identifier: just a word (e.g. "orders", "/tmp/out")
|
|
43
|
+
_PARAM_BARE_RE = re.compile(
|
|
44
|
+
r"^\s*(?P<key>.+?)\s*$"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CapabilityGrammarError(Exception):
|
|
49
|
+
"""Raised when a capability string is malformed."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, raw: str | None = None) -> None:
|
|
52
|
+
self.raw = raw
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class CapabilityToken:
|
|
58
|
+
"""A parsed capability token.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
namespace: The capability namespace (e.g. ``db``, ``payments``).
|
|
62
|
+
action: The action within the namespace (e.g. ``read``, ``refund``).
|
|
63
|
+
params: Parsed parameters. Values may be:
|
|
64
|
+
- ``True`` for bare resource identifiers (e.g. ``db:read(orders)``)
|
|
65
|
+
- ``str`` or ``float`` for assignment params (e.g. ``channel=slack``)
|
|
66
|
+
- ``{"op": str, "value": float|str}`` for comparator params
|
|
67
|
+
raw: The original unparsed string.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
namespace: str
|
|
71
|
+
action: str
|
|
72
|
+
params: dict[str, str | float | bool | dict[str, str | float]]
|
|
73
|
+
raw: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_capability(s: str) -> CapabilityToken:
|
|
77
|
+
"""Parse a capability string into a ``CapabilityToken``.
|
|
78
|
+
|
|
79
|
+
Accepts both concrete requests (``ns:action(k=v)``) and ceiling
|
|
80
|
+
expressions with comparators (``ns:action(k<=v)``).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
s: The capability string to parse.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A ``CapabilityToken``.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
CapabilityGrammarError: If the string doesn't match the grammar.
|
|
90
|
+
"""
|
|
91
|
+
m = _CAP_RE.match(s)
|
|
92
|
+
if not m:
|
|
93
|
+
raise CapabilityGrammarError(
|
|
94
|
+
f"malformed capability string: {s!r}", raw=s
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
ns = m.group("ns")
|
|
98
|
+
action = m.group("action")
|
|
99
|
+
params_str = m.group("params")
|
|
100
|
+
|
|
101
|
+
params: dict[str, str | float | bool | dict[str, str | float]] = {}
|
|
102
|
+
if params_str and params_str.strip():
|
|
103
|
+
for param_part in _split_params(params_str):
|
|
104
|
+
# Try comparator first: key<=value
|
|
105
|
+
pm = _PARAM_COMPARATOR_RE.match(param_part)
|
|
106
|
+
if pm:
|
|
107
|
+
params[pm.group("key")] = {
|
|
108
|
+
"op": pm.group("op"),
|
|
109
|
+
"value": _coerce_value(pm.group("value")),
|
|
110
|
+
}
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Try assignment: key=value
|
|
114
|
+
pm = _PARAM_ASSIGN_RE.match(param_part)
|
|
115
|
+
if pm:
|
|
116
|
+
params[pm.group("key")] = _coerce_value(pm.group("value"))
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Bare resource identifier
|
|
120
|
+
key = param_part.strip()
|
|
121
|
+
if key:
|
|
122
|
+
params[key] = True
|
|
123
|
+
else:
|
|
124
|
+
raise CapabilityGrammarError(
|
|
125
|
+
f"empty parameter in capability {s!r}",
|
|
126
|
+
raw=s,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return CapabilityToken(namespace=ns, action=action, params=params, raw=s)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _split_params(params_str: str) -> list[str]:
|
|
133
|
+
"""Split a parameter string on commas, respecting parentheses."""
|
|
134
|
+
parts = []
|
|
135
|
+
depth = 0
|
|
136
|
+
current: list[str] = []
|
|
137
|
+
for ch in params_str:
|
|
138
|
+
if ch == "(":
|
|
139
|
+
depth += 1
|
|
140
|
+
current.append(ch)
|
|
141
|
+
elif ch == ")":
|
|
142
|
+
depth -= 1
|
|
143
|
+
current.append(ch)
|
|
144
|
+
elif ch == "," and depth == 0:
|
|
145
|
+
parts.append("".join(current))
|
|
146
|
+
current = []
|
|
147
|
+
else:
|
|
148
|
+
current.append(ch)
|
|
149
|
+
if current:
|
|
150
|
+
parts.append("".join(current))
|
|
151
|
+
return parts
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _coerce_value(raw: str) -> str | float:
|
|
155
|
+
"""Try to parse a value as a float, otherwise keep as string."""
|
|
156
|
+
try:
|
|
157
|
+
return float(raw)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return raw
|
sopvm/checker/check.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Static capability checker (Milestone 4).
|
|
2
|
+
|
|
3
|
+
Deterministic, side-effect-free: checks a compiled IR against a policy
|
|
4
|
+
without any I/O beyond loading the input files. This is the project's
|
|
5
|
+
headline claim — pure static analysis, usable as a pre-commit hook.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from sopvm.capability.policy import Policy
|
|
13
|
+
from sopvm.capability.token import CapabilityToken, parse_capability
|
|
14
|
+
from sopvm.ir.model import CompiledProgram
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Violation:
|
|
19
|
+
"""A single capability policy violation.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
step_id: The step that requested the violating capability.
|
|
23
|
+
requested: The raw capability string that was requested.
|
|
24
|
+
reason: Human-readable explanation of why it was denied.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
step_id: str
|
|
28
|
+
requested: str
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CheckResult:
|
|
34
|
+
"""Result of a static capability check.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
passed: True if no violations were found.
|
|
38
|
+
violations: List of violations (empty if passed).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
passed: bool
|
|
42
|
+
violations: tuple[Violation, ...] = field(default_factory=tuple)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def satisfies(requested: CapabilityToken, allowed: CapabilityToken) -> bool:
|
|
46
|
+
"""Check if a requested capability satisfies a policy ceiling.
|
|
47
|
+
|
|
48
|
+
Rules:
|
|
49
|
+
- namespace and action must match exactly.
|
|
50
|
+
- For each param in ``allowed`` that has a comparator (``<=``, ``>=``,
|
|
51
|
+
``<``, ``>``, ``==``), the corresponding param in ``requested`` must
|
|
52
|
+
satisfy it numerically.
|
|
53
|
+
- Params in ``allowed`` without comparators must match exactly.
|
|
54
|
+
- Params in ``requested`` not mentioned in ``allowed`` are unconstrained
|
|
55
|
+
(allowed is a ceiling, not an exact-match contract).
|
|
56
|
+
|
|
57
|
+
Design note: this means a request can carry extra params beyond what
|
|
58
|
+
the policy mentions, and still pass. This is intentional — the policy
|
|
59
|
+
is a ceiling on constrained dimensions, not a whitelist of all params.
|
|
60
|
+
This choice is flagged for discussion in the PR description.
|
|
61
|
+
"""
|
|
62
|
+
if requested.namespace != allowed.namespace:
|
|
63
|
+
return False
|
|
64
|
+
if requested.action != allowed.action:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
for key, allowed_val in allowed.params.items():
|
|
68
|
+
requested_val = requested.params.get(key)
|
|
69
|
+
if requested_val is None:
|
|
70
|
+
# Param in allowed but not in requested — violation.
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
if isinstance(allowed_val, dict):
|
|
74
|
+
# Comparator param in policy ceiling.
|
|
75
|
+
op = allowed_val["op"]
|
|
76
|
+
ceiling = allowed_val["value"]
|
|
77
|
+
if not _compare(requested_val, op, ceiling):
|
|
78
|
+
return False
|
|
79
|
+
elif allowed_val is True:
|
|
80
|
+
# Bare param in allowed — requested must also have bare param.
|
|
81
|
+
if requested_val is not True:
|
|
82
|
+
return False
|
|
83
|
+
else:
|
|
84
|
+
# Named param — exact match.
|
|
85
|
+
if requested_val != allowed_val:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compare(
|
|
92
|
+
requested: object,
|
|
93
|
+
op: object,
|
|
94
|
+
ceiling: object,
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Evaluate a single comparator constraint."""
|
|
97
|
+
req = _to_float(requested)
|
|
98
|
+
ceil = _to_float(ceiling)
|
|
99
|
+
if req is None or ceil is None:
|
|
100
|
+
# Non-numeric values: only == and != are meaningful.
|
|
101
|
+
if op == "==":
|
|
102
|
+
return requested == ceiling
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
if op == "<=":
|
|
106
|
+
return req <= ceil
|
|
107
|
+
elif op == ">=":
|
|
108
|
+
return req >= ceil
|
|
109
|
+
elif op == "<":
|
|
110
|
+
return req < ceil
|
|
111
|
+
elif op == ">":
|
|
112
|
+
return req > ceil
|
|
113
|
+
elif op == "==":
|
|
114
|
+
return req == ceil
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _to_float(v: object) -> float | None:
|
|
119
|
+
if isinstance(v, (int, float)):
|
|
120
|
+
return float(v)
|
|
121
|
+
try:
|
|
122
|
+
return float(v) # type: ignore[arg-type]
|
|
123
|
+
except (ValueError, TypeError):
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check(program: CompiledProgram, policy: Policy) -> CheckResult:
|
|
128
|
+
"""Static check: does every declared capability satisfy the policy?
|
|
129
|
+
|
|
130
|
+
For every ``IrNode``, for every capability in ``capabilities_declared``,
|
|
131
|
+
check it against every entry in ``policy.allowed_capabilities``. If
|
|
132
|
+
none satisfy, record a violation.
|
|
133
|
+
|
|
134
|
+
Malformed capability strings are caught and reported as violations
|
|
135
|
+
rather than raising exceptions — the checker must not crash on bad input.
|
|
136
|
+
|
|
137
|
+
This function is fully deterministic and side-effect-free.
|
|
138
|
+
"""
|
|
139
|
+
from sopvm.capability.token import CapabilityGrammarError
|
|
140
|
+
|
|
141
|
+
violations: list[Violation] = []
|
|
142
|
+
|
|
143
|
+
for step_id, node in program.nodes.items():
|
|
144
|
+
for cap_str in node.capabilities_declared:
|
|
145
|
+
try:
|
|
146
|
+
token = parse_capability(cap_str)
|
|
147
|
+
except CapabilityGrammarError:
|
|
148
|
+
violations.append(Violation(
|
|
149
|
+
step_id=step_id,
|
|
150
|
+
requested=cap_str,
|
|
151
|
+
reason=f"malformed capability string {cap_str!r}",
|
|
152
|
+
))
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if not any(satisfies(token, allowed) for allowed in policy.allowed_capabilities):
|
|
156
|
+
# Find the ceiling for the reason message.
|
|
157
|
+
matching = [
|
|
158
|
+
a for a in policy.allowed_capabilities
|
|
159
|
+
if a.namespace == token.namespace and a.action == token.action
|
|
160
|
+
]
|
|
161
|
+
if matching:
|
|
162
|
+
ceiling_str = matching[0].raw
|
|
163
|
+
reason = f"exceeds policy ceiling {ceiling_str}"
|
|
164
|
+
else:
|
|
165
|
+
reason = f"capability {cap_str!r} not mentioned in policy"
|
|
166
|
+
|
|
167
|
+
violations.append(Violation(
|
|
168
|
+
step_id=step_id,
|
|
169
|
+
requested=cap_str,
|
|
170
|
+
reason=reason,
|
|
171
|
+
))
|
|
172
|
+
|
|
173
|
+
return CheckResult(
|
|
174
|
+
passed=len(violations) == 0,
|
|
175
|
+
violations=tuple(violations),
|
|
176
|
+
)
|
sopvm/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package (Milestone 11)."""
|