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 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
@@ -0,0 +1,5 @@
1
+ """Checker package (Milestone 4)."""
2
+
3
+ from .check import CheckResult, Violation, check, satisfies
4
+
5
+ __all__ = ["CheckResult", "Violation", "check", "satisfies"]
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)."""