worldstate-check 0.1.1__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.
- worldstate_check/__init__.py +3 -0
- worldstate_check/__main__.py +3 -0
- worldstate_check/checks/__init__.py +15 -0
- worldstate_check/checks/base.py +38 -0
- worldstate_check/checks/command.py +85 -0
- worldstate_check/checks/file.py +87 -0
- worldstate_check/checks/http.py +66 -0
- worldstate_check/checks/json_check.py +39 -0
- worldstate_check/checks/metric.py +114 -0
- worldstate_check/checks/tcp.py +29 -0
- worldstate_check/cli.py +124 -0
- worldstate_check/demo.py +50 -0
- worldstate_check/engine.py +70 -0
- worldstate_check/errors.py +10 -0
- worldstate_check/loader.py +328 -0
- worldstate_check/models.py +80 -0
- worldstate_check/report.py +67 -0
- worldstate_check/util.py +237 -0
- worldstate_check-0.1.1.dist-info/METADATA +310 -0
- worldstate_check-0.1.1.dist-info/RECORD +24 -0
- worldstate_check-0.1.1.dist-info/WHEEL +5 -0
- worldstate_check-0.1.1.dist-info/entry_points.txt +2 -0
- worldstate_check-0.1.1.dist-info/licenses/LICENSE +21 -0
- worldstate_check-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .command import run_command_check
|
|
2
|
+
from .file import run_file_check
|
|
3
|
+
from .http import run_http_check
|
|
4
|
+
from .json_check import run_json_check
|
|
5
|
+
from .metric import run_metric_check
|
|
6
|
+
from .tcp import run_tcp_check
|
|
7
|
+
|
|
8
|
+
CHECK_RUNNERS = {
|
|
9
|
+
"file": run_file_check,
|
|
10
|
+
"json": run_json_check,
|
|
11
|
+
"metric": run_metric_check,
|
|
12
|
+
"http": run_http_check,
|
|
13
|
+
"tcp": run_tcp_check,
|
|
14
|
+
"command": run_command_check,
|
|
15
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from worldstate_check.models import CheckResult, CheckStatus, VerificationContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def timed_result(
|
|
10
|
+
check: dict[str, Any],
|
|
11
|
+
fn: Callable[[], tuple[CheckStatus, str, Any, Any, dict[str, Any], str | None]],
|
|
12
|
+
) -> CheckResult:
|
|
13
|
+
started = time.perf_counter()
|
|
14
|
+
status, summary, expected, observed, evidence, error = fn()
|
|
15
|
+
return CheckResult(
|
|
16
|
+
check_id=check["id"],
|
|
17
|
+
check_type=check["type"],
|
|
18
|
+
required=check.get("required", True),
|
|
19
|
+
status=status,
|
|
20
|
+
summary=summary,
|
|
21
|
+
expected=expected,
|
|
22
|
+
observed=observed,
|
|
23
|
+
evidence=evidence,
|
|
24
|
+
error=error,
|
|
25
|
+
duration_ms=(time.perf_counter() - started) * 1000,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unknown(check: dict[str, Any], message: str, *, evidence: dict[str, Any] | None = None) -> CheckResult:
|
|
30
|
+
return CheckResult(
|
|
31
|
+
check_id=check["id"],
|
|
32
|
+
check_type=check["type"],
|
|
33
|
+
required=check.get("required", True),
|
|
34
|
+
status=CheckStatus.UNKNOWN,
|
|
35
|
+
summary=message,
|
|
36
|
+
evidence=evidence or {},
|
|
37
|
+
error=message,
|
|
38
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from worldstate_check.errors import PathBoundaryError
|
|
8
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
9
|
+
from worldstate_check.util import resolve_path
|
|
10
|
+
|
|
11
|
+
from .base import timed_result, unknown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_command_check(check: dict[str, Any], ctx: VerificationContext):
|
|
15
|
+
if not ctx.allow_command:
|
|
16
|
+
return unknown(check, "command checks are disabled; pass --allow-command for a trusted specification")
|
|
17
|
+
try:
|
|
18
|
+
cwd = resolve_path(ctx.root, check.get("cwd", "."), ctx.allow_outside_root)
|
|
19
|
+
except PathBoundaryError as exc:
|
|
20
|
+
return unknown(check, str(exc))
|
|
21
|
+
|
|
22
|
+
def evaluate():
|
|
23
|
+
max_bytes = check.get("max_output_bytes", 65_536)
|
|
24
|
+
timeout = float(check.get("timeout_seconds", 10.0))
|
|
25
|
+
env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "")}
|
|
26
|
+
try:
|
|
27
|
+
proc = subprocess.run(
|
|
28
|
+
check["argv"],
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
env=env,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=False,
|
|
33
|
+
shell=False,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
return CheckStatus.FAIL, "verification command timed out", {"timeout_seconds": timeout}, None, {"cwd": str(cwd)}, None
|
|
39
|
+
except (OSError, ValueError) as exc:
|
|
40
|
+
return CheckStatus.UNKNOWN, "verification command could not be started", None, None, {"cwd": str(cwd)}, str(exc)
|
|
41
|
+
|
|
42
|
+
stdout_bytes = proc.stdout[: max_bytes + 1]
|
|
43
|
+
stderr_bytes = proc.stderr[: max_bytes + 1]
|
|
44
|
+
if len(stdout_bytes) > max_bytes or len(stderr_bytes) > max_bytes:
|
|
45
|
+
return (
|
|
46
|
+
CheckStatus.UNKNOWN,
|
|
47
|
+
"verification command output exceeds configured evidence limit",
|
|
48
|
+
None,
|
|
49
|
+
{"exit_code": proc.returncode},
|
|
50
|
+
{"cwd": str(cwd), "max_output_bytes": max_bytes},
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
54
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
55
|
+
expected: dict[str, Any] = {}
|
|
56
|
+
observed: dict[str, Any] = {"exit_code": proc.returncode}
|
|
57
|
+
failures: list[str] = []
|
|
58
|
+
if "exit_code" in check:
|
|
59
|
+
expected["exit_code"] = check["exit_code"]
|
|
60
|
+
if proc.returncode != check["exit_code"]:
|
|
61
|
+
failures.append(f"exit_code={proc.returncode}")
|
|
62
|
+
if "stdout_contains" in check:
|
|
63
|
+
expected["stdout_contains"] = check["stdout_contains"]
|
|
64
|
+
matched = check["stdout_contains"] in stdout
|
|
65
|
+
observed["stdout_contains"] = matched
|
|
66
|
+
if not matched:
|
|
67
|
+
failures.append("required stdout text missing")
|
|
68
|
+
if "stderr_contains" in check:
|
|
69
|
+
expected["stderr_contains"] = check["stderr_contains"]
|
|
70
|
+
matched = check["stderr_contains"] in stderr
|
|
71
|
+
observed["stderr_contains"] = matched
|
|
72
|
+
if not matched:
|
|
73
|
+
failures.append("required stderr text missing")
|
|
74
|
+
|
|
75
|
+
evidence = {
|
|
76
|
+
"argv": check["argv"],
|
|
77
|
+
"cwd": str(cwd),
|
|
78
|
+
"stdout_excerpt": stdout[-2000:],
|
|
79
|
+
"stderr_excerpt": stderr[-2000:],
|
|
80
|
+
}
|
|
81
|
+
if failures:
|
|
82
|
+
return CheckStatus.FAIL, "; ".join(failures), expected, observed, evidence, None
|
|
83
|
+
return CheckStatus.PASS, "command postcondition satisfied", expected, observed, evidence, None
|
|
84
|
+
|
|
85
|
+
return timed_result(check, evaluate)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from worldstate_check.errors import PathBoundaryError
|
|
7
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
8
|
+
from worldstate_check.util import resolve_path
|
|
9
|
+
|
|
10
|
+
from .base import timed_result, unknown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_file_check(check: dict[str, Any], ctx: VerificationContext):
|
|
14
|
+
try:
|
|
15
|
+
path = resolve_path(ctx.root, check["path"], ctx.allow_outside_root)
|
|
16
|
+
except PathBoundaryError as exc:
|
|
17
|
+
return unknown(check, str(exc))
|
|
18
|
+
|
|
19
|
+
def evaluate():
|
|
20
|
+
exists = path.exists()
|
|
21
|
+
expected: dict[str, Any] = {}
|
|
22
|
+
observed: dict[str, Any] = {"exists": exists}
|
|
23
|
+
evidence: dict[str, Any] = {"path": str(path)}
|
|
24
|
+
failures: list[str] = []
|
|
25
|
+
|
|
26
|
+
if "exists" in check:
|
|
27
|
+
expected["exists"] = check["exists"]
|
|
28
|
+
if exists != check["exists"]:
|
|
29
|
+
failures.append(f"exists={exists}")
|
|
30
|
+
|
|
31
|
+
content: str | None = None
|
|
32
|
+
if any(k in check for k in {"contains", "not_contains"}):
|
|
33
|
+
if not exists or not path.is_file():
|
|
34
|
+
failures.append("file content unavailable")
|
|
35
|
+
else:
|
|
36
|
+
max_bytes = check.get("max_read_bytes", 1_048_576)
|
|
37
|
+
size = path.stat().st_size
|
|
38
|
+
evidence["size_bytes"] = size
|
|
39
|
+
if size > max_bytes:
|
|
40
|
+
return (
|
|
41
|
+
CheckStatus.UNKNOWN,
|
|
42
|
+
f"file exceeds max_read_bytes ({size} > {max_bytes})",
|
|
43
|
+
expected,
|
|
44
|
+
observed,
|
|
45
|
+
evidence,
|
|
46
|
+
"file too large for configured text assertion",
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
content = path.read_text(encoding="utf-8")
|
|
50
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
51
|
+
return CheckStatus.UNKNOWN, "could not read file as UTF-8", expected, observed, evidence, str(exc)
|
|
52
|
+
|
|
53
|
+
if "contains" in check:
|
|
54
|
+
expected["contains"] = check["contains"]
|
|
55
|
+
matched = content is not None and check["contains"] in content
|
|
56
|
+
observed["contains"] = matched
|
|
57
|
+
if not matched:
|
|
58
|
+
failures.append("required text missing")
|
|
59
|
+
|
|
60
|
+
if "not_contains" in check:
|
|
61
|
+
expected["not_contains"] = check["not_contains"]
|
|
62
|
+
matched = content is not None and check["not_contains"] not in content
|
|
63
|
+
observed["not_contains"] = matched
|
|
64
|
+
if not matched:
|
|
65
|
+
failures.append("forbidden text present")
|
|
66
|
+
|
|
67
|
+
if "sha256" in check:
|
|
68
|
+
expected["sha256"] = check["sha256"].lower()
|
|
69
|
+
if not exists or not path.is_file():
|
|
70
|
+
failures.append("sha256 unavailable")
|
|
71
|
+
else:
|
|
72
|
+
digest = hashlib.sha256()
|
|
73
|
+
try:
|
|
74
|
+
with path.open("rb") as handle:
|
|
75
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
76
|
+
digest.update(chunk)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return CheckStatus.UNKNOWN, "could not hash file", expected, observed, evidence, str(exc)
|
|
79
|
+
observed["sha256"] = digest.hexdigest()
|
|
80
|
+
if observed["sha256"] != expected["sha256"]:
|
|
81
|
+
failures.append("sha256 mismatch")
|
|
82
|
+
|
|
83
|
+
if failures:
|
|
84
|
+
return CheckStatus.FAIL, "; ".join(failures), expected, observed, evidence, None
|
|
85
|
+
return CheckStatus.PASS, "file postcondition satisfied", expected, observed, evidence, None
|
|
86
|
+
|
|
87
|
+
return timed_result(check, evaluate)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from worldstate_check.errors import SpecError
|
|
9
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
10
|
+
from worldstate_check.util import compare_value, extract_dotted
|
|
11
|
+
|
|
12
|
+
from .base import timed_result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_http_check(check: dict[str, Any], ctx: VerificationContext):
|
|
16
|
+
del ctx
|
|
17
|
+
|
|
18
|
+
def evaluate():
|
|
19
|
+
timeout = float(check.get("timeout_seconds", 3.0))
|
|
20
|
+
request = urllib.request.Request(check["url"], method="GET", headers={"User-Agent": "worldstate-check/0.1"})
|
|
21
|
+
evidence: dict[str, Any] = {"url": check["url"]}
|
|
22
|
+
try:
|
|
23
|
+
response = urllib.request.urlopen(request, timeout=timeout)
|
|
24
|
+
status = response.status
|
|
25
|
+
body_bytes = response.read(1_048_577)
|
|
26
|
+
except urllib.error.HTTPError as exc:
|
|
27
|
+
status = exc.code
|
|
28
|
+
body_bytes = exc.read(1_048_577)
|
|
29
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
30
|
+
return CheckStatus.FAIL, "HTTP endpoint could not be reached", None, None, evidence, str(exc)
|
|
31
|
+
|
|
32
|
+
if len(body_bytes) > 1_048_576:
|
|
33
|
+
return CheckStatus.UNKNOWN, "HTTP response exceeds 1 MiB evidence limit", None, status, evidence, None
|
|
34
|
+
body = body_bytes.decode("utf-8", errors="replace")
|
|
35
|
+
observed: dict[str, Any] = {"status": status}
|
|
36
|
+
expected: dict[str, Any] = {}
|
|
37
|
+
failures: list[str] = []
|
|
38
|
+
|
|
39
|
+
if "status" in check:
|
|
40
|
+
expected["status"] = check["status"]
|
|
41
|
+
if status != check["status"]:
|
|
42
|
+
failures.append(f"status={status}")
|
|
43
|
+
if "text_contains" in check:
|
|
44
|
+
expected["text_contains"] = check["text_contains"]
|
|
45
|
+
matched = check["text_contains"] in body
|
|
46
|
+
observed["text_contains"] = matched
|
|
47
|
+
if not matched:
|
|
48
|
+
failures.append("required response text missing")
|
|
49
|
+
if "json_field" in check:
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(body)
|
|
52
|
+
value = extract_dotted(payload, check["json_field"])
|
|
53
|
+
matched, expected_value = compare_value(value, check["operator"], check)
|
|
54
|
+
except (json.JSONDecodeError, KeyError, ValueError, SpecError) as exc:
|
|
55
|
+
return CheckStatus.UNKNOWN, "HTTP JSON assertion could not be evaluated", expected, observed, evidence, str(exc)
|
|
56
|
+
expected["json"] = {"field": check["json_field"], "value": expected_value, "operator": check["operator"]}
|
|
57
|
+
observed["json"] = {"field": check["json_field"], "value": value}
|
|
58
|
+
if not matched:
|
|
59
|
+
failures.append("JSON response postcondition not satisfied")
|
|
60
|
+
|
|
61
|
+
evidence["body_bytes"] = len(body_bytes)
|
|
62
|
+
if failures:
|
|
63
|
+
return CheckStatus.FAIL, "; ".join(failures), expected, observed, evidence, None
|
|
64
|
+
return CheckStatus.PASS, "HTTP postcondition satisfied", expected, observed, evidence, None
|
|
65
|
+
|
|
66
|
+
return timed_result(check, evaluate)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from worldstate_check.errors import PathBoundaryError, SpecError
|
|
6
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
7
|
+
from worldstate_check.util import compare_value, evidence_path, extract_dotted, read_text_limited, resolve_path, strict_json_loads
|
|
8
|
+
|
|
9
|
+
from .base import timed_result, unknown
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_json_check(check: dict[str, Any], ctx: VerificationContext):
|
|
13
|
+
try:
|
|
14
|
+
path = resolve_path(ctx.root, check["path"], ctx.allow_outside_root)
|
|
15
|
+
except PathBoundaryError as exc:
|
|
16
|
+
return unknown(check, str(exc))
|
|
17
|
+
|
|
18
|
+
def evaluate():
|
|
19
|
+
evidence = {"path": evidence_path(path, ctx.root), "field": check["field"]}
|
|
20
|
+
try:
|
|
21
|
+
raw = read_text_limited(path, int(check.get("max_read_bytes", 1_048_576)))
|
|
22
|
+
data = strict_json_loads(raw)
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
return CheckStatus.FAIL, "JSON file does not exist", None, None, evidence, None
|
|
25
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
26
|
+
return CheckStatus.UNKNOWN, "could not parse JSON evidence", None, None, evidence, str(exc)
|
|
27
|
+
try:
|
|
28
|
+
observed = extract_dotted(data, check["field"])
|
|
29
|
+
except KeyError:
|
|
30
|
+
return CheckStatus.FAIL, f"JSON field not found: {check['field']}", None, None, evidence, None
|
|
31
|
+
try:
|
|
32
|
+
matched, expected = compare_value(observed, check["operator"], check)
|
|
33
|
+
except (ValueError, SpecError) as exc:
|
|
34
|
+
return CheckStatus.UNKNOWN, "JSON comparison could not be evaluated", None, observed, evidence, str(exc)
|
|
35
|
+
if matched:
|
|
36
|
+
return CheckStatus.PASS, "JSON postcondition satisfied", expected, observed, evidence, None
|
|
37
|
+
return CheckStatus.FAIL, "JSON postcondition not satisfied", expected, observed, evidence, None
|
|
38
|
+
|
|
39
|
+
return timed_result(check, evaluate)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from worldstate_check.errors import PathBoundaryError, SpecError
|
|
11
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
12
|
+
from worldstate_check.util import (
|
|
13
|
+
compare_value,
|
|
14
|
+
evidence_path,
|
|
15
|
+
extract_dotted,
|
|
16
|
+
freshness_age_seconds,
|
|
17
|
+
read_text_limited,
|
|
18
|
+
resolve_path,
|
|
19
|
+
strict_json_loads,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .base import timed_result, unknown
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_TELEMETRY_BYTES = 16 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_metric_check(check: dict[str, Any], ctx: VerificationContext):
|
|
28
|
+
source = check["source"]
|
|
29
|
+
try:
|
|
30
|
+
path = resolve_path(ctx.root, source["path"], ctx.allow_outside_root)
|
|
31
|
+
except PathBoundaryError as exc:
|
|
32
|
+
return unknown(check, str(exc))
|
|
33
|
+
|
|
34
|
+
def evaluate():
|
|
35
|
+
evidence: dict[str, Any] = {"path": evidence_path(path, ctx.root), "source_type": source["type"]}
|
|
36
|
+
try:
|
|
37
|
+
observed, timestamp_value = _read_source(path, source)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return CheckStatus.FAIL, "telemetry source does not exist", None, None, evidence, None
|
|
40
|
+
except (OSError, UnicodeDecodeError, csv.Error, KeyError, ValueError) as exc:
|
|
41
|
+
return CheckStatus.UNKNOWN, "could not read telemetry evidence", None, None, evidence, str(exc)
|
|
42
|
+
|
|
43
|
+
if "max_age_seconds" in source:
|
|
44
|
+
try:
|
|
45
|
+
age = freshness_age_seconds(timestamp_value, datetime.now(timezone.utc))
|
|
46
|
+
except (ValueError, OSError, OverflowError) as exc:
|
|
47
|
+
return CheckStatus.UNKNOWN, "could not evaluate telemetry freshness", None, observed, evidence, str(exc)
|
|
48
|
+
evidence["age_seconds"] = round(age, 3)
|
|
49
|
+
evidence["max_age_seconds"] = source["max_age_seconds"]
|
|
50
|
+
if age > float(source["max_age_seconds"]):
|
|
51
|
+
return (
|
|
52
|
+
CheckStatus.FAIL,
|
|
53
|
+
"telemetry is stale",
|
|
54
|
+
{"max_age_seconds": source["max_age_seconds"]},
|
|
55
|
+
{"age_seconds": round(age, 3), "value": observed},
|
|
56
|
+
evidence,
|
|
57
|
+
None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
matched, expected = compare_value(observed, check["operator"], check)
|
|
62
|
+
except (ValueError, SpecError) as exc:
|
|
63
|
+
return CheckStatus.UNKNOWN, "metric comparison could not be evaluated", None, observed, evidence, str(exc)
|
|
64
|
+
if matched:
|
|
65
|
+
return CheckStatus.PASS, "metric postcondition satisfied", expected, observed, evidence, None
|
|
66
|
+
return CheckStatus.FAIL, "metric postcondition not satisfied", expected, observed, evidence, None
|
|
67
|
+
|
|
68
|
+
return timed_result(check, evaluate)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_source(path: Path, source: dict[str, Any]) -> tuple[Any, Any]:
|
|
72
|
+
raw = read_text_limited(path, int(source.get("max_read_bytes", DEFAULT_MAX_TELEMETRY_BYTES)))
|
|
73
|
+
if source["type"] == "json":
|
|
74
|
+
data = strict_json_loads(raw)
|
|
75
|
+
observed = extract_dotted(data, source["field"])
|
|
76
|
+
timestamp = extract_dotted(data, source["timestamp_field"]) if "max_age_seconds" in source else None
|
|
77
|
+
return observed, timestamp
|
|
78
|
+
|
|
79
|
+
reader = csv.DictReader(io.StringIO(raw.lstrip("\ufeff")))
|
|
80
|
+
if reader.fieldnames is None:
|
|
81
|
+
raise ValueError("CSV telemetry source has no header")
|
|
82
|
+
if len(reader.fieldnames) != len(set(reader.fieldnames)):
|
|
83
|
+
raise ValueError("CSV telemetry source has duplicate column names")
|
|
84
|
+
row: dict[str, str | None] | None = None
|
|
85
|
+
for row in reader:
|
|
86
|
+
if None in row:
|
|
87
|
+
raise ValueError("CSV telemetry row has more values than the header")
|
|
88
|
+
if row is None:
|
|
89
|
+
raise ValueError("CSV telemetry source has no data rows")
|
|
90
|
+
if source["column"] not in row:
|
|
91
|
+
raise KeyError(source["column"])
|
|
92
|
+
observed = _coerce_scalar(row[source["column"]])
|
|
93
|
+
timestamp = row[source["timestamp_column"]] if "max_age_seconds" in source else None
|
|
94
|
+
return observed, timestamp
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _coerce_scalar(value: str | None) -> Any:
|
|
98
|
+
if value is None:
|
|
99
|
+
raise ValueError("CSV telemetry value is missing")
|
|
100
|
+
stripped = value.strip()
|
|
101
|
+
lowered = stripped.lower()
|
|
102
|
+
if lowered == "true":
|
|
103
|
+
return True
|
|
104
|
+
if lowered == "false":
|
|
105
|
+
return False
|
|
106
|
+
try:
|
|
107
|
+
if any(c in stripped for c in ".eE"):
|
|
108
|
+
value_float = float(stripped)
|
|
109
|
+
if not math.isfinite(value_float):
|
|
110
|
+
return stripped
|
|
111
|
+
return value_float
|
|
112
|
+
return int(stripped)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return stripped
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from worldstate_check.models import CheckStatus, VerificationContext
|
|
7
|
+
|
|
8
|
+
from .base import timed_result, unknown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_tcp_check(check: dict[str, Any], ctx: VerificationContext):
|
|
12
|
+
if not ctx.allow_network:
|
|
13
|
+
return unknown(check, "network checks are disabled; pass --allow-network for a trusted specification")
|
|
14
|
+
|
|
15
|
+
def evaluate():
|
|
16
|
+
expected = check.get("reachable", True)
|
|
17
|
+
timeout = float(check.get("timeout_seconds", 2.0))
|
|
18
|
+
evidence = {"host": check["host"], "port": check["port"]}
|
|
19
|
+
try:
|
|
20
|
+
with socket.create_connection((check["host"], check["port"]), timeout=timeout):
|
|
21
|
+
reachable = True
|
|
22
|
+
except OSError as exc:
|
|
23
|
+
reachable = False
|
|
24
|
+
evidence["connection_error"] = exc.__class__.__name__
|
|
25
|
+
status = CheckStatus.PASS if reachable == expected else CheckStatus.FAIL
|
|
26
|
+
summary = "TCP postcondition satisfied" if status is CheckStatus.PASS else "TCP postcondition not satisfied"
|
|
27
|
+
return status, summary, expected, reachable, evidence, None
|
|
28
|
+
|
|
29
|
+
return timed_result(check, evaluate)
|
worldstate_check/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .demo import run_demo
|
|
10
|
+
from .engine import verify
|
|
11
|
+
from .errors import SpecError
|
|
12
|
+
from .loader import load_spec
|
|
13
|
+
from .models import VerificationContext, Verdict
|
|
14
|
+
from .report import render_text, report_payload, verify_json_report, write_json_report
|
|
15
|
+
|
|
16
|
+
EXIT_BY_VERDICT = {
|
|
17
|
+
Verdict.VERIFIED: 0,
|
|
18
|
+
Verdict.NOT_VERIFIED: 1,
|
|
19
|
+
Verdict.UNCERTAIN: 2,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="worldstate-check",
|
|
26
|
+
description="Verify observed postconditions after an AI agent or autonomous system acts.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
verify_parser = sub.add_parser("verify", help="evaluate a verification specification")
|
|
32
|
+
verify_parser.add_argument("spec", type=Path)
|
|
33
|
+
verify_parser.add_argument("--root", type=Path, help="verification root; defaults to the specification directory")
|
|
34
|
+
verify_parser.add_argument("--allow-outside-root", action="store_true", help="allow file paths outside the verification root")
|
|
35
|
+
verify_parser.add_argument("--allow-command", action="store_true", help="allow command checks in a trusted specification")
|
|
36
|
+
verify_parser.add_argument("--allow-network", action="store_true", help="allow HTTP and TCP checks in a trusted specification")
|
|
37
|
+
verify_parser.add_argument("--wait", type=float, default=0.0, metavar="SECONDS", help="retry until verified or the timeout expires")
|
|
38
|
+
verify_parser.add_argument("--poll-interval", type=float, default=0.5, metavar="SECONDS")
|
|
39
|
+
verify_parser.add_argument("--report", type=Path, help="write a JSON evidence report")
|
|
40
|
+
verify_parser.add_argument("--json", action="store_true", help="print the JSON report instead of text")
|
|
41
|
+
|
|
42
|
+
validate_parser = sub.add_parser("validate", help="validate a specification without evaluating it")
|
|
43
|
+
validate_parser.add_argument("spec", type=Path)
|
|
44
|
+
|
|
45
|
+
report_parser = sub.add_parser("verify-report", help="verify the SHA-256 digest of a saved evidence report")
|
|
46
|
+
report_parser.add_argument("report", type=Path)
|
|
47
|
+
|
|
48
|
+
init_parser = sub.add_parser("init", help="write a minimal starter specification")
|
|
49
|
+
init_parser.add_argument("path", type=Path, nargs="?", default=Path("worldstate.yaml"))
|
|
50
|
+
|
|
51
|
+
demo_parser = sub.add_parser("demo", help="run a deterministic synthetic example")
|
|
52
|
+
demo_parser.add_argument("--scenario", choices=["verified", "partial"], default="partial")
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = build_parser()
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
try:
|
|
60
|
+
if args.command == "validate":
|
|
61
|
+
load_spec(args.spec)
|
|
62
|
+
print(f"VALID: {args.spec}")
|
|
63
|
+
return 0
|
|
64
|
+
if args.command == "verify-report":
|
|
65
|
+
try:
|
|
66
|
+
valid = verify_json_report(args.report)
|
|
67
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
68
|
+
print(f"ERROR: could not read report: {exc}", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
print(f"{'VALID' if valid else 'INVALID'}: {args.report}")
|
|
71
|
+
return 0 if valid else 1
|
|
72
|
+
if args.command == "init":
|
|
73
|
+
return _init_spec(args.path)
|
|
74
|
+
if args.command == "demo":
|
|
75
|
+
return EXIT_BY_VERDICT[run_demo(args.scenario)]
|
|
76
|
+
if args.command == "verify":
|
|
77
|
+
return _verify_command(args)
|
|
78
|
+
except SpecError as exc:
|
|
79
|
+
print(f"SPEC ERROR: {exc}", file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
82
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
return 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _verify_command(args: argparse.Namespace) -> int:
|
|
88
|
+
spec_path = args.spec.resolve()
|
|
89
|
+
spec = load_spec(spec_path)
|
|
90
|
+
root = (args.root.resolve() if args.root else spec_path.parent)
|
|
91
|
+
ctx = VerificationContext(
|
|
92
|
+
spec_path=spec_path,
|
|
93
|
+
root=root,
|
|
94
|
+
allow_outside_root=args.allow_outside_root,
|
|
95
|
+
allow_command=args.allow_command,
|
|
96
|
+
allow_network=args.allow_network,
|
|
97
|
+
)
|
|
98
|
+
report = verify(spec, ctx, wait_seconds=args.wait, poll_interval=args.poll_interval)
|
|
99
|
+
if args.report:
|
|
100
|
+
write_json_report(report, args.report)
|
|
101
|
+
if args.json:
|
|
102
|
+
print(json.dumps(report_payload(report), indent=2, sort_keys=True))
|
|
103
|
+
else:
|
|
104
|
+
print(render_text(report))
|
|
105
|
+
if args.report:
|
|
106
|
+
print(f"Report: {args.report.resolve()}")
|
|
107
|
+
return EXIT_BY_VERDICT[report.verdict]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _init_spec(path: Path) -> int:
|
|
111
|
+
if path.exists():
|
|
112
|
+
print(f"ERROR: refusing to overwrite existing file: {path}", file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path.write_text(
|
|
116
|
+
"""version: 1\ntask: verify-outcome\nchecks:\n - id: result-file\n type: file\n path: result.json\n exists: true\n\n - id: result-state\n type: json\n path: result.json\n field: status\n operator: eq\n value: complete\n""",
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
print(f"Created {path}")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
raise SystemExit(main())
|
worldstate_check/demo.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .engine import verify
|
|
8
|
+
from .models import VerificationContext, Verdict
|
|
9
|
+
from .report import render_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_demo(scenario: str = "partial") -> Verdict:
|
|
13
|
+
if scenario not in {"verified", "partial"}:
|
|
14
|
+
raise ValueError("scenario must be verified or partial")
|
|
15
|
+
with tempfile.TemporaryDirectory(prefix="worldstate-check-") as raw:
|
|
16
|
+
root = Path(raw)
|
|
17
|
+
(root / "command-ack.txt").write_text("SAFE_MODE_ACK\n", encoding="utf-8")
|
|
18
|
+
attitude = 1.8 if scenario == "verified" else 14.7
|
|
19
|
+
telemetry = {
|
|
20
|
+
"mode": "SAFE",
|
|
21
|
+
"payload": {"power": "off"},
|
|
22
|
+
"attitude": {"error_deg": attitude},
|
|
23
|
+
"battery": {"soc": 71.0},
|
|
24
|
+
}
|
|
25
|
+
(root / "telemetry.json").write_text(json.dumps(telemetry), encoding="utf-8")
|
|
26
|
+
spec = {
|
|
27
|
+
"version": 1,
|
|
28
|
+
"task": "enter-safe-mode",
|
|
29
|
+
"checks": [
|
|
30
|
+
{"id": "command-ack", "type": "file", "path": "command-ack.txt", "contains": "SAFE_MODE_ACK"},
|
|
31
|
+
{"id": "payload-power", "type": "json", "path": "telemetry.json", "field": "payload.power", "operator": "eq", "value": "off"},
|
|
32
|
+
{
|
|
33
|
+
"id": "attitude-error",
|
|
34
|
+
"type": "metric",
|
|
35
|
+
"source": {"type": "json", "path": "telemetry.json", "field": "attitude.error_deg"},
|
|
36
|
+
"operator": "lte",
|
|
37
|
+
"value": 3.0,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "battery-soc",
|
|
41
|
+
"type": "metric",
|
|
42
|
+
"source": {"type": "json", "path": "telemetry.json", "field": "battery.soc"},
|
|
43
|
+
"operator": "gte",
|
|
44
|
+
"value": 25.0,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
report = verify(spec, VerificationContext(spec_path=root / "demo.yaml", root=root))
|
|
49
|
+
print(render_text(report))
|
|
50
|
+
return report.verdict
|