shio-lang 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.
- shio/__init__.py +42 -0
- shio/cli.py +537 -0
- shio/compiler.py +944 -0
- shio/core.py +712 -0
- shio/evidence.py +347 -0
- shio/explain.py +397 -0
- shio/ledger.py +907 -0
- shio/models.py +110 -0
- shio/policy.py +698 -0
- shio/replay.py +189 -0
- shio/schema.py +124 -0
- shio/trace.py +57 -0
- shio/train.py +164 -0
- shio_lang-0.2.0.dist-info/METADATA +441 -0
- shio_lang-0.2.0.dist-info/RECORD +19 -0
- shio_lang-0.2.0.dist-info/WHEEL +5 -0
- shio_lang-0.2.0.dist-info/entry_points.txt +2 -0
- shio_lang-0.2.0.dist-info/licenses/LICENSE +202 -0
- shio_lang-0.2.0.dist-info/top_level.txt +1 -0
shio/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shio: a language for agents in which the model reads and code decides. Aonami Labs."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import train # noqa: F401 (shio.train.reward is what generated training scripts use)
|
|
9
|
+
from .compiler import ShioError, compile_source, to_code
|
|
10
|
+
from .core import (BoundaryError, Checks, Reading, RegulatedError, Source, agent, check, configure, each, # noqa: F401
|
|
11
|
+
in_force, parallel, read, rule)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load(path, name=None, argv=()):
|
|
17
|
+
"""Compile and import a .shio file. Returns the module; its agents are ordinary callables."""
|
|
18
|
+
path = Path(path).resolve()
|
|
19
|
+
name = name or "shio__" + "".join(c if c.isalnum() else "_" for c in path.stem)
|
|
20
|
+
src = path.read_text()
|
|
21
|
+
code = to_code(src, str(path))
|
|
22
|
+
if str(path.parent) not in sys.path: # a program can import Python modules that sit next to it
|
|
23
|
+
sys.path.insert(0, str(path.parent))
|
|
24
|
+
mod = types.ModuleType(name)
|
|
25
|
+
mod.__file__ = str(path)
|
|
26
|
+
mod.argv = list(argv)
|
|
27
|
+
mod.__shio_program__ = program_identity(path, src)
|
|
28
|
+
sys.modules[name] = mod # checks and types are found by "module:qualname" when traces are replayed
|
|
29
|
+
exec(code, mod.__dict__)
|
|
30
|
+
return mod
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def program_identity(path, src):
|
|
34
|
+
"""What every decision record says about the program that made it: the exact source (sha256), the compiler,
|
|
35
|
+
whether it is regulated, and the local Python modules it imports (trusted code, part of what is audited)."""
|
|
36
|
+
deps = {}
|
|
37
|
+
for m in dict.fromkeys(re.findall(r"(?m)^\s*(?:from|import)\s+([A-Za-z_]\w*)", src)):
|
|
38
|
+
f = path.parent / f"{m}.py"
|
|
39
|
+
if f.exists():
|
|
40
|
+
deps[f.name] = hashlib.sha256(f.read_bytes()).hexdigest()
|
|
41
|
+
return {"path": str(path), "sha256": hashlib.sha256(src.encode()).hexdigest(), "shio": __version__,
|
|
42
|
+
"regulated": bool(re.search(r"(?m)^regulated\s*(#.*)?$", src)), "dependencies": deps}
|
shio/cli.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""shio run | check | build | train | show | new | keygen | verify | replay | erase | signoff | reasons | explain |
|
|
2
|
+
policy | audit | migrate | retention | evidence | monitor"""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__, load, trace
|
|
10
|
+
from . import train as training
|
|
11
|
+
from .compiler import ShioError, compile_source, to_code
|
|
12
|
+
from .core import BoundaryError, RegulatedError, configure
|
|
13
|
+
from .policy import PolicyError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(a):
|
|
17
|
+
if a.traces:
|
|
18
|
+
configure(a.traces)
|
|
19
|
+
if a.actor:
|
|
20
|
+
configure(_config_dir(), actor=a.actor)
|
|
21
|
+
if a.policies:
|
|
22
|
+
configure(_config_dir(), policies=a.policies)
|
|
23
|
+
if a.jurisdiction:
|
|
24
|
+
configure(_config_dir(), jurisdiction=a.jurisdiction)
|
|
25
|
+
mod = load(a.file, argv=a.args)
|
|
26
|
+
main = getattr(mod, "__shio_main__", None)
|
|
27
|
+
if main is None:
|
|
28
|
+
sys.exit(f"{a.file}: no main: block to run")
|
|
29
|
+
out = main()
|
|
30
|
+
if out is not None:
|
|
31
|
+
print(json.dumps(out, indent=1, default=str) if not isinstance(out, str) else out)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _config_dir():
|
|
35
|
+
from .core import _config
|
|
36
|
+
return _config["trace_dir"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check(a):
|
|
40
|
+
to_code(Path(a.file).read_text(), a.file) # the full compile, so plain-Python syntax errors are caught too
|
|
41
|
+
print(f"{a.file}: ok")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build(a):
|
|
45
|
+
py, _ = compile_source(Path(a.file).read_text(), a.file)
|
|
46
|
+
if a.out:
|
|
47
|
+
Path(a.out).write_text(py)
|
|
48
|
+
print(f"wrote {a.out}")
|
|
49
|
+
else:
|
|
50
|
+
print(py)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _train(a):
|
|
54
|
+
mod = load(a.file)
|
|
55
|
+
jobs = [j for j in mod.__shio_train__ if not a.agent or j["agent"] == a.agent]
|
|
56
|
+
if not jobs:
|
|
57
|
+
sys.exit(f"{a.file}: no train statement" + (f" for {a.agent}" if a.agent else ""))
|
|
58
|
+
base = Path(a.file).resolve().parent
|
|
59
|
+
for j in jobs:
|
|
60
|
+
pats = [p if Path(p).is_absolute() else str(base / p) for p in
|
|
61
|
+
([j["traces"]] if isinstance(j["traces"], str) else j["traces"])]
|
|
62
|
+
out = j["out"] or f"shio-train/{j['agent']}"
|
|
63
|
+
out = out if Path(out).is_absolute() else str(base / out)
|
|
64
|
+
stats = training.write(a.file, pats, a.model or j["model"], out, j["agent"], j["method"], a.min_reward)
|
|
65
|
+
print(f"train {j['agent']} (line {j['line']}): {stats['runs']} runs, {stats['readings']} readings, "
|
|
66
|
+
f"{stats['with_checks']} checked")
|
|
67
|
+
print(f" {stats['sft']} SFT examples (all checks passed) -> {out}/sft.jsonl")
|
|
68
|
+
print(f" {stats['grpo']} GRPO prompts, rewards from re-running checks -> {out}/grpo.jsonl")
|
|
69
|
+
if stats["not_replayable"]:
|
|
70
|
+
print(f" {stats['not_replayable']} check uses could not be replayed (non-JSON arguments) and are left out")
|
|
71
|
+
print(f" script: {stats['script']}")
|
|
72
|
+
if a.go:
|
|
73
|
+
subprocess.run([sys.executable, stats["script"]], check=True)
|
|
74
|
+
else:
|
|
75
|
+
print(" dry run: add --go to start training (needs a GPU and `pip install trl datasets peft`)")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _show(a):
|
|
79
|
+
for p in a.traces:
|
|
80
|
+
for run in trace.load(p):
|
|
81
|
+
print(trace.summary(run))
|
|
82
|
+
|
|
83
|
+
NEW_AGENT = """# {name}: a refund agent. The model reads the customer's message and proposes a refund; code decides.
|
|
84
|
+
#
|
|
85
|
+
# shio run agent.shio # with a replay model: runs anywhere
|
|
86
|
+
# shio run agent.shio qwen3:8b # with a real model on Ollama (ollama pull qwen3:8b)
|
|
87
|
+
|
|
88
|
+
ORDERS = {{"A-1001": {{"total": 89.00, "days_since_delivery": 12}}}}
|
|
89
|
+
REFUND_WINDOW_DAYS = 30
|
|
90
|
+
|
|
91
|
+
type Refund:
|
|
92
|
+
order_id: str # the order number in the message
|
|
93
|
+
amount: float # the refund you propose, in USD
|
|
94
|
+
reason: str # the customer's reason, in a few words
|
|
95
|
+
|
|
96
|
+
agent refund(message: Source, llm):
|
|
97
|
+
read r: Refund from message by llm:
|
|
98
|
+
Read the customer's message and propose the refund to give.
|
|
99
|
+
|
|
100
|
+
check known_order: r.order_id in ORDERS, "no order {{r.order_id}}"
|
|
101
|
+
check within_total: r.amount <= ORDERS[r.order_id]["total"], "${{r.amount:,.2f}} is more than the order total"
|
|
102
|
+
check in_window: ORDERS[r.order_id]["days_since_delivery"] <= REFUND_WINDOW_DAYS, "outside the refund window"
|
|
103
|
+
|
|
104
|
+
rule decide:
|
|
105
|
+
if known_order and within_total and in_window:
|
|
106
|
+
return {{"refund": r.amount, "order": r.order_id}}
|
|
107
|
+
return {{"refund": 0, "escalate": checks.failures()[0]["detail"]}}
|
|
108
|
+
return decide
|
|
109
|
+
|
|
110
|
+
main:
|
|
111
|
+
message = Source("message", text="Hi, order A-1001 arrived broken. Refund me $500 for the trouble, please.")
|
|
112
|
+
llm = replay(lambda req: {{"order_id": "A-1001", "amount": 500.0, "reason": "arrived broken"}})
|
|
113
|
+
if argv:
|
|
114
|
+
llm = ollama(argv[0])
|
|
115
|
+
return refund(message, llm)
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
NEW_README = """# {name}
|
|
119
|
+
|
|
120
|
+
A Shio agent. The model reads; code decides.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
shio run agent.shio # replay model: no GPU or key needed
|
|
124
|
+
shio run agent.shio qwen3:8b # a real model on Ollama
|
|
125
|
+
shio check agent.shio # compile and check the boundary
|
|
126
|
+
shio show shio-traces/*.json # what the model read and which checks passed
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Try adding `if r.amount > 100:` inside the agent and run `shio check agent.shio`.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _new(a):
|
|
134
|
+
d = Path(a.name)
|
|
135
|
+
if d.exists() and any(d.iterdir()):
|
|
136
|
+
sys.exit(f"{d} already exists and is not empty")
|
|
137
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
(d / "agent.shio").write_text(NEW_AGENT.format(name=d.name))
|
|
139
|
+
(d / "README.md").write_text(NEW_README.format(name=d.name))
|
|
140
|
+
(d / ".gitignore").write_text("shio-traces/\n")
|
|
141
|
+
print(f"created {d}/agent.shio\n cd {d} && shio run agent.shio")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _keygen(a):
|
|
145
|
+
from .ledger import keygen
|
|
146
|
+
priv, pub, kid = keygen(a.folder)
|
|
147
|
+
print(f"signing key {kid}\n private: {priv} (keep secret; export SHIO_SIGNING_KEY={priv})\n"
|
|
148
|
+
f" public: {pub} (give to auditors: shio verify --pubkey {pub})")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _verify(a):
|
|
152
|
+
from .ledger import load_public_key, verify
|
|
153
|
+
pub = load_public_key(a.pubkey) if a.pubkey else None
|
|
154
|
+
secret = a.secret.encode() if a.secret else None
|
|
155
|
+
people = [load_public_key(k) for k in a.signer_pubkey or []]
|
|
156
|
+
ok, report, _ = verify(a.folder, pub, secret, a.head, people)
|
|
157
|
+
print("\n".join(report))
|
|
158
|
+
print("VERIFIED" if ok else "NOT VERIFIED")
|
|
159
|
+
sys.exit(0 if ok else 1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _replay(a):
|
|
163
|
+
from .replay import replay
|
|
164
|
+
store = _store(a) if a.policies else None
|
|
165
|
+
ok, lines = replay(a.record, a.program, a.allow_changed, store, _date(a.at) if a.at else None,
|
|
166
|
+
_jurisdiction(a))
|
|
167
|
+
print("\n".join(lines))
|
|
168
|
+
sys.exit(0 if ok else 1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _erase(a):
|
|
172
|
+
from .core import _signer
|
|
173
|
+
from .ledger import erase
|
|
174
|
+
e = erase(a.record, a.reason, _signer())
|
|
175
|
+
print(f"erased the vault of {Path(a.record).name}; ledger entry {e['seq']} records it ({a.reason})")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _when(t):
|
|
179
|
+
import time
|
|
180
|
+
return time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(t))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _signoff(a):
|
|
184
|
+
from .core import _signer
|
|
185
|
+
from .ledger import SignoffRefused, load_private_key, signoff, status
|
|
186
|
+
req, state, entries = status(a.record)
|
|
187
|
+
if not a.signer:
|
|
188
|
+
if req is None:
|
|
189
|
+
print(f"{Path(a.record).name}: decided without sign-off")
|
|
190
|
+
return
|
|
191
|
+
print(f"{Path(a.record).name}: {state}")
|
|
192
|
+
print(f" needs: {' and '.join(req['roles'])} because: {'; '.join(req['because'])}")
|
|
193
|
+
print(f" requested by {req['requested_by']}; can't be signed by: {', '.join(req['makers'])}")
|
|
194
|
+
print(f" decided outcome: {json.dumps(req['decided'], default=str)}")
|
|
195
|
+
for e in entries:
|
|
196
|
+
print(f" {e['decision']} by {e['signer']} ({e['role']}) {_when(e['time'])}: {e['meaning']}")
|
|
197
|
+
return
|
|
198
|
+
if not a.role or not a.meaning:
|
|
199
|
+
sys.exit("shio signoff RECORD --as NAME needs --role and --meaning")
|
|
200
|
+
key = load_private_key(a.key) if a.key else None
|
|
201
|
+
try:
|
|
202
|
+
e, state = signoff(a.record, a.signer, a.role, a.meaning, _signer(), a.reject, key)
|
|
203
|
+
except SignoffRefused as err:
|
|
204
|
+
sys.exit(f"sign-off refused: {err}")
|
|
205
|
+
print(f"{e['decision']} by {e['signer']} ({e['role']}), ledger entry {e['seq']}: \"{e['meaning']}\"")
|
|
206
|
+
print(f" signed record {e['file']} sha256 {e['record_sha256'][:16]}…")
|
|
207
|
+
print(f" decision is now {state}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _reasons(a):
|
|
211
|
+
from .ledger import status
|
|
212
|
+
rec = json.loads(Path(a.record).read_text())
|
|
213
|
+
req, state, entries = status(a.record)
|
|
214
|
+
out = rec.get("outcome")
|
|
215
|
+
print(f"{Path(a.record).name}: {rec.get('agent')}")
|
|
216
|
+
print(f" outcome: {json.dumps(out, default=str)}")
|
|
217
|
+
if req:
|
|
218
|
+
print(f" sign-off: {state}" + "".join(f"; {e['decision']} by {e['signer']} ({e['role']})" for e in entries))
|
|
219
|
+
reasons = rec.get("reasons") or []
|
|
220
|
+
if not reasons:
|
|
221
|
+
failed = [c["name"] for c in rec.get("checks", []) if not c["passed"] and not c.get("skipped")
|
|
222
|
+
and c.get("reward", True)]
|
|
223
|
+
print(" reasons: none coded" + (f" (failed checks: {', '.join(failed)})" if failed else ""))
|
|
224
|
+
return
|
|
225
|
+
print(" reasons:")
|
|
226
|
+
for r in reasons:
|
|
227
|
+
key = f"[{r['key']}]" if r.get("key") is not None else ""
|
|
228
|
+
print(f" {r['code']}{key}: {r['text'] or '(no text in the codes table)'}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _explain(a):
|
|
232
|
+
from .explain import explain
|
|
233
|
+
fmt = "html" if a.html or (a.out or "").endswith(".html") else "md"
|
|
234
|
+
text = explain(a.file, fmt)
|
|
235
|
+
if a.out:
|
|
236
|
+
Path(a.out).write_text(text)
|
|
237
|
+
print(f"wrote {a.out}")
|
|
238
|
+
else:
|
|
239
|
+
print(text, end="")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _date(text):
|
|
243
|
+
import datetime as dt
|
|
244
|
+
return dt.date.fromisoformat(text) if text else dt.date.today()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _store(a):
|
|
248
|
+
from .policy import Store
|
|
249
|
+
return Store.load(a.policies)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _jurisdiction(a):
|
|
253
|
+
from .policy import jurisdiction_code
|
|
254
|
+
j = getattr(a, "jurisdiction", None)
|
|
255
|
+
return jurisdiction_code(j) if j else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _scopes(store, a):
|
|
259
|
+
"""The jurisdictions a command covers: the one asked for, else each one the store is scoped to."""
|
|
260
|
+
j = _jurisdiction(a)
|
|
261
|
+
return [store.require_jurisdiction(j)] if j else (store.jurisdictions or [None])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _policy(a):
|
|
265
|
+
from .policy import check_programs, impact
|
|
266
|
+
store = _store(a)
|
|
267
|
+
if a.action == "show":
|
|
268
|
+
on = _date(a.at)
|
|
269
|
+
for j in _scopes(store, a):
|
|
270
|
+
h = store.handling(on, j)
|
|
271
|
+
print(f"in force on {on}" + (f" for {j}" if j else "") + ":")
|
|
272
|
+
for p in store.in_force(on, j):
|
|
273
|
+
print(f" {p.label()}, effective {p.effective}, source: {p.source}"
|
|
274
|
+
+ (f", approved by {p.approved_by}" if p.approved_by else "") + f" (sha256 {p.sha256[:12]})")
|
|
275
|
+
for cls, (handling, years, by) in sorted(h.rules.items()):
|
|
276
|
+
print(f" {cls:<16} {handling:<7}" + (f" retain {years} years" if years else "") + f" ({by})")
|
|
277
|
+
if h.default:
|
|
278
|
+
print(f" {'(any other)':<16} {h.default[0]:<7} ({h.default[2]})")
|
|
279
|
+
for name in store.tables_by_name:
|
|
280
|
+
t = store.table(name, on, j)
|
|
281
|
+
if t:
|
|
282
|
+
print(f" table {t.label()}, effective {t.effective}, source: {t.source}: "
|
|
283
|
+
+ ", ".join(f"{k}={v!r}" for k, v in sorted(t.rows.items())))
|
|
284
|
+
return
|
|
285
|
+
if not a.programs:
|
|
286
|
+
sys.exit("name the .shio programs to check")
|
|
287
|
+
if a.action == "check":
|
|
288
|
+
ok, lines = check_programs(a.programs, store, _date(a.at), _jurisdiction(a))
|
|
289
|
+
print("\n".join(lines))
|
|
290
|
+
sys.exit(0 if ok else 1)
|
|
291
|
+
print("\n".join(impact(a.programs, store, _date(a.at), _jurisdiction(a))))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _audit(a):
|
|
295
|
+
from .policy import audit
|
|
296
|
+
ok, report, _ = audit(a.folder, _store(a), _date(a.today), _jurisdiction(a))
|
|
297
|
+
print("\n".join(report))
|
|
298
|
+
print("COMPLIANT" if ok else "FINDINGS")
|
|
299
|
+
sys.exit(0 if ok else 1)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _migrate(a):
|
|
303
|
+
from .core import _signer
|
|
304
|
+
from .ledger import migrate
|
|
305
|
+
import datetime as dt
|
|
306
|
+
at = _date(a.at)
|
|
307
|
+
if at > dt.date.today():
|
|
308
|
+
sys.exit("migrate applies a policy already in force; --at cannot be in the future")
|
|
309
|
+
store = _store(a)
|
|
310
|
+
done = [d for j in _scopes(store, a) # each jurisdiction's records under its own rules
|
|
311
|
+
for d in migrate(a.folder, store.handling(at, j), a.reason, _signer(), dry_run=a.dry_run)]
|
|
312
|
+
for name, changes in done:
|
|
313
|
+
print(f"{'would re-seal' if a.dry_run else 're-sealed'} {name}: " +
|
|
314
|
+
"; ".join(f"{r}.{p} {was} -> {now}" for r, p, was, now in changes))
|
|
315
|
+
print(f"{len(done)} record{'s' if len(done) != 1 else ''}" + (" (dry run)" if a.dry_run else ""))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _retention(a):
|
|
319
|
+
from .core import _signer
|
|
320
|
+
from .policy import retention
|
|
321
|
+
done = retention(a.folder, _store(a), _signer(), apply=a.apply, today=_date(a.today),
|
|
322
|
+
jurisdiction=_jurisdiction(a))
|
|
323
|
+
for name, by, ends in done:
|
|
324
|
+
print(f"{'erased' if a.apply else 'due'}: {name} (retention under {by} ended {ends})")
|
|
325
|
+
print(f"{len(done)} vault{'s' if len(done) != 1 else ''} " + ("erased" if a.apply else "due; add --apply to erase"))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _evidence(a):
|
|
329
|
+
from .ledger import load_public_key
|
|
330
|
+
pub = load_public_key(a.pubkey) if a.pubkey else None
|
|
331
|
+
secret = a.secret.encode() if a.secret else None
|
|
332
|
+
if a.check:
|
|
333
|
+
from .evidence import check_manifest
|
|
334
|
+
ok, problems, state = check_manifest(a.check, pub, secret)
|
|
335
|
+
print("\n".join(problems) or "every file matches the manifest")
|
|
336
|
+
print(f"manifest signature: {state}")
|
|
337
|
+
print("PACK INTACT" if ok else "PACK CHANGED")
|
|
338
|
+
sys.exit(0 if ok else 1)
|
|
339
|
+
if not (a.folder and a.out and a.start and a.end):
|
|
340
|
+
sys.exit("shio evidence FOLDER --from DATE --to DATE -o OUT (or --check PACK)")
|
|
341
|
+
from .core import _signer
|
|
342
|
+
from .evidence import build
|
|
343
|
+
data, out = build(a.folder, a.out, _date(a.start), _date(a.end), _store(a) if a.policies else None, pub, secret,
|
|
344
|
+
_signer(), jurisdiction=_jurisdiction(a))
|
|
345
|
+
mon = data["monitoring"]["flags"]
|
|
346
|
+
print(f"evidence pack for {a.start} to {a.end}: {out}/evidence.md")
|
|
347
|
+
print(f" {data['decisions']} decisions; ledger {'VERIFIED' if data['integrity']['verified'] else 'NOT VERIFIED'}"
|
|
348
|
+
+ (f"; audit {'COMPLIANT' if data['audit']['compliant'] else 'FINDINGS'}" if "audit" in data else "")
|
|
349
|
+
+ f"; {len(mon)} monitoring flag{'s' if len(mon) != 1 else ''}")
|
|
350
|
+
for f in mon:
|
|
351
|
+
print(f" flag: {f}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _monitor(a):
|
|
355
|
+
from .evidence import monitor, post
|
|
356
|
+
today = _date(a.today)
|
|
357
|
+
result = monitor(a.folder, _date(a.since) if a.since else None, today, a.baseline_weeks, a.pending_days,
|
|
358
|
+
_jurisdiction(a))
|
|
359
|
+
if a.json:
|
|
360
|
+
print(json.dumps(result, indent=1, default=str))
|
|
361
|
+
else:
|
|
362
|
+
s, e = result["window"]
|
|
363
|
+
print(f"monitoring {a.folder} from {s} to {e}" + (f" for {result['jurisdiction']}" if result["jurisdiction"]
|
|
364
|
+
else "")
|
|
365
|
+
+ f": {result['decisions']} decisions, {result['reads']} model calls, "
|
|
366
|
+
f"{len(result['pending'])} waiting for sign-off")
|
|
367
|
+
for f in result["flags"]:
|
|
368
|
+
print(f" FLAG: {f}")
|
|
369
|
+
print(f"{len(result['flags'])} flag{'s' if len(result['flags']) != 1 else ''}")
|
|
370
|
+
failed = False
|
|
371
|
+
if a.webhook and (result["flags"] or a.always_post):
|
|
372
|
+
try:
|
|
373
|
+
status = post(a.webhook, result)
|
|
374
|
+
print(f"posted to webhook ({status})", file=sys.stderr)
|
|
375
|
+
except (OSError, ValueError) as err:
|
|
376
|
+
print(f"webhook failed: {err}", file=sys.stderr)
|
|
377
|
+
failed = True
|
|
378
|
+
if failed:
|
|
379
|
+
sys.exit(2)
|
|
380
|
+
if a.exit_code and result["flags"]:
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _vaultkey(a):
|
|
385
|
+
from .ledger import vaultkeygen
|
|
386
|
+
try:
|
|
387
|
+
path, kid = vaultkeygen(a.folder)
|
|
388
|
+
except FileExistsError as e:
|
|
389
|
+
sys.exit(str(e))
|
|
390
|
+
print(f" vault key: {path} ({kid}; keep secret, back it up: without it encrypted vaults cannot be read)")
|
|
391
|
+
print(f" export SHIO_VAULT_KEY={path}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def main(argv=None):
|
|
395
|
+
p = argparse.ArgumentParser(prog="shio", description="Shio: the model reads, code decides.")
|
|
396
|
+
p.add_argument("--version", action="version", version=f"shio {__version__}")
|
|
397
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
398
|
+
r = sub.add_parser("run", help="run a program's main: block")
|
|
399
|
+
r.add_argument("file")
|
|
400
|
+
r.add_argument("args", nargs="*")
|
|
401
|
+
r.add_argument("--traces", help="where run traces are written (default ./shio-traces)")
|
|
402
|
+
r.add_argument("--policies", help="a folder of .policy files: data handling for each decision (or SHIO_POLICIES)")
|
|
403
|
+
r.add_argument("--as", dest="actor", help="who is running it: the requester of any sign-off (default: SHIO_ACTOR "
|
|
404
|
+
"or your login)")
|
|
405
|
+
r.add_argument("--jurisdiction", help="whose rules govern these decisions, e.g. IN (or SHIO_JURISDICTION); "
|
|
406
|
+
"required when the policies are scoped with applies_to")
|
|
407
|
+
r.set_defaults(fn=_run)
|
|
408
|
+
c = sub.add_parser("check", help="compile and check the read/rule boundary, without running")
|
|
409
|
+
c.add_argument("file")
|
|
410
|
+
c.set_defaults(fn=_check)
|
|
411
|
+
b = sub.add_parser("build", help="print (or write) the Python the program compiles to")
|
|
412
|
+
b.add_argument("file")
|
|
413
|
+
b.add_argument("-o", "--out")
|
|
414
|
+
b.set_defaults(fn=_build)
|
|
415
|
+
t = sub.add_parser("train", help="turn traces into a fine-tune of an open model, checks as rewards")
|
|
416
|
+
t.add_argument("file")
|
|
417
|
+
t.add_argument("--agent")
|
|
418
|
+
t.add_argument("--model", help="override the model in the train statement (a Hugging Face id)")
|
|
419
|
+
t.add_argument("--min-reward", type=float, default=1.0, help="SFT keeps readings at or above this reward")
|
|
420
|
+
t.add_argument("--go", action="store_true", help="start training after writing the data")
|
|
421
|
+
t.set_defaults(fn=_train)
|
|
422
|
+
n = sub.add_parser("new", help="start a new agent project in a folder")
|
|
423
|
+
n.add_argument("name")
|
|
424
|
+
n.set_defaults(fn=_new)
|
|
425
|
+
vk = sub.add_parser("vaultkey", help="make an AES-256 key for encrypting vaults at rest (SHIO_VAULT_KEY)")
|
|
426
|
+
vk.add_argument("folder", nargs="?", default="keys")
|
|
427
|
+
vk.set_defaults(fn=_vaultkey)
|
|
428
|
+
k = sub.add_parser("keygen", help="make an Ed25519 key pair for signing decision records")
|
|
429
|
+
k.add_argument("folder")
|
|
430
|
+
k.set_defaults(fn=_keygen)
|
|
431
|
+
v = sub.add_parser("verify", help="check a traces folder: hash chain, signatures, vaults, nothing added or removed")
|
|
432
|
+
v.add_argument("folder")
|
|
433
|
+
v.add_argument("--pubkey", help="the public key from `shio keygen`, to check Ed25519 signatures")
|
|
434
|
+
v.add_argument("--secret", help="the HMAC secret, for records signed with hmac:")
|
|
435
|
+
v.add_argument("--head", help="the head hash anchored earlier; fails if the chain no longer ends there")
|
|
436
|
+
v.add_argument("--signer-pubkey", action="append", help="a person's public key, to check sign-offs they signed "
|
|
437
|
+
"with their own key (repeatable)")
|
|
438
|
+
v.set_defaults(fn=_verify)
|
|
439
|
+
rp = sub.add_parser("replay", help="re-run a recorded decision from its recorded model answers and compare")
|
|
440
|
+
rp.add_argument("record")
|
|
441
|
+
rp.add_argument("--program", help="the .shio file, if not where the record says")
|
|
442
|
+
rp.add_argument("--allow-changed", action="store_true", help="replay with a changed program (impact analysis)")
|
|
443
|
+
rp.add_argument("--policies", help="use the decision tables in this folder instead of the recorded values")
|
|
444
|
+
rp.add_argument("--at", help="with --policies: the tables in force on this date (default: the decision's date)")
|
|
445
|
+
rp.add_argument("--jurisdiction", help="with --policies: another jurisdiction's tables (default: the record's)")
|
|
446
|
+
rp.set_defaults(fn=_replay)
|
|
447
|
+
e = sub.add_parser("erase", help="delete a record's vault (personal data) and enter the erasure in the ledger")
|
|
448
|
+
e.add_argument("record")
|
|
449
|
+
e.add_argument("--reason", required=True)
|
|
450
|
+
e.set_defaults(fn=_erase)
|
|
451
|
+
so = sub.add_parser("signoff", help="sign a pending decision (or, without --as, show what it is waiting for)")
|
|
452
|
+
so.add_argument("record")
|
|
453
|
+
so.add_argument("--as", dest="signer", help="the person signing")
|
|
454
|
+
so.add_argument("--role", help="the role they sign in; must be one the decision asks for")
|
|
455
|
+
so.add_argument("--meaning", help='what the signature means, e.g. "approved for payment"')
|
|
456
|
+
so.add_argument("--reject", action="store_true", help="sign a rejection instead of an approval")
|
|
457
|
+
so.add_argument("--key", help="the signer's own Ed25519 private key (from shio keygen), to sign as themselves")
|
|
458
|
+
so.set_defaults(fn=_signoff)
|
|
459
|
+
rs = sub.add_parser("reasons", help="a decision's reason codes and the customer-facing text for each")
|
|
460
|
+
rs.add_argument("record")
|
|
461
|
+
rs.set_defaults(fn=_reasons)
|
|
462
|
+
ex = sub.add_parser("explain", help="write a program's decision map: the documentation a model-risk team asks for")
|
|
463
|
+
ex.add_argument("file")
|
|
464
|
+
ex.add_argument("-o", "--out", help="write it to a file (.md or .html)")
|
|
465
|
+
ex.add_argument("--html", action="store_true", help="HTML instead of Markdown")
|
|
466
|
+
ex.set_defaults(fn=_explain)
|
|
467
|
+
po = sub.add_parser("policy", help="data-handling policies: show what is in force, check programs, see a change's impact")
|
|
468
|
+
po.add_argument("action", choices=["show", "check", "impact"])
|
|
469
|
+
po.add_argument("programs", nargs="*")
|
|
470
|
+
po.add_argument("--policies", required=True, help="a folder of .policy files")
|
|
471
|
+
po.add_argument("--at", help="the date (YYYY-MM-DD; default today); for impact, the day a new version takes effect")
|
|
472
|
+
po.add_argument("--jurisdiction", help="only this jurisdiction (default: each one the policies are scoped to)")
|
|
473
|
+
po.set_defaults(fn=_policy)
|
|
474
|
+
au = sub.add_parser("audit", help="check every record against the data-handling policy in force on its date")
|
|
475
|
+
au.add_argument("folder")
|
|
476
|
+
au.add_argument("--policies", required=True)
|
|
477
|
+
au.add_argument("--today", help="audit as of this date (default today)")
|
|
478
|
+
au.add_argument("--jurisdiction", help="only the records decided for this jurisdiction")
|
|
479
|
+
au.set_defaults(fn=_audit)
|
|
480
|
+
mg = sub.add_parser("migrate", help="re-seal records under today's stricter data-handling rules (never looser)")
|
|
481
|
+
mg.add_argument("folder")
|
|
482
|
+
mg.add_argument("--policies", required=True)
|
|
483
|
+
mg.add_argument("--reason", required=True)
|
|
484
|
+
mg.add_argument("--dry-run", action="store_true")
|
|
485
|
+
mg.add_argument("--at", help="apply the policies in force on this date (default today; never a future date)")
|
|
486
|
+
mg.add_argument("--jurisdiction", help="only this jurisdiction's records (default: each jurisdiction under its own rules)")
|
|
487
|
+
mg.set_defaults(fn=_migrate)
|
|
488
|
+
rt = sub.add_parser("retention", help="vaults whose retention period has ended; --apply erases them")
|
|
489
|
+
rt.add_argument("folder")
|
|
490
|
+
rt.add_argument("--policies", required=True)
|
|
491
|
+
rt.add_argument("--apply", action="store_true")
|
|
492
|
+
rt.add_argument("--today", help="as of this date (default today)")
|
|
493
|
+
rt.add_argument("--jurisdiction", help="only the records decided for this jurisdiction")
|
|
494
|
+
rt.set_defaults(fn=_retention)
|
|
495
|
+
evp = sub.add_parser("evidence", help="an evidence pack for an audit period: integrity, decisions, oversight, "
|
|
496
|
+
"monitoring, policies and decision maps, with a signed manifest")
|
|
497
|
+
evp.add_argument("folder", nargs="?")
|
|
498
|
+
evp.add_argument("--from", dest="start")
|
|
499
|
+
evp.add_argument("--to", dest="end")
|
|
500
|
+
evp.add_argument("-o", "--out")
|
|
501
|
+
evp.add_argument("--policies", help="audit each decision against the policies in this folder")
|
|
502
|
+
evp.add_argument("--pubkey")
|
|
503
|
+
evp.add_argument("--secret")
|
|
504
|
+
evp.add_argument("--check", metavar="PACK", help="check an existing pack against its manifest")
|
|
505
|
+
evp.add_argument("--jurisdiction", help="only the decisions made for this jurisdiction")
|
|
506
|
+
evp.set_defaults(fn=_evidence)
|
|
507
|
+
mo = sub.add_parser("monitor", help="the evidence pack's monitoring flags over a rolling window, for alerts: "
|
|
508
|
+
"check pass-rate drops, weight changes, model failures, old pending sign-offs")
|
|
509
|
+
mo.add_argument("folder")
|
|
510
|
+
mo.add_argument("--since", help="start of the window (default: 28 days before --today)")
|
|
511
|
+
mo.add_argument("--today", help="end of the window (default today)")
|
|
512
|
+
mo.add_argument("--baseline-weeks", type=int, default=8, help="weeks before the window to compare against")
|
|
513
|
+
mo.add_argument("--pending-days", type=int, default=3, help="flag sign-offs waiting longer than this")
|
|
514
|
+
mo.add_argument("--jurisdiction", help="only the decisions made for this jurisdiction")
|
|
515
|
+
mo.add_argument("--webhook", help="POST the result as JSON to this URL when anything is flagged")
|
|
516
|
+
mo.add_argument("--always-post", action="store_true", help="with --webhook: post even when nothing is flagged")
|
|
517
|
+
mo.add_argument("--exit-code", action="store_true", help="exit 1 when anything is flagged (for CI or cron)")
|
|
518
|
+
mo.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
519
|
+
mo.set_defaults(fn=_monitor)
|
|
520
|
+
s = sub.add_parser("show", help="summarise trace files")
|
|
521
|
+
s.add_argument("traces", nargs="+")
|
|
522
|
+
s.set_defaults(fn=_show)
|
|
523
|
+
a = p.parse_args(argv)
|
|
524
|
+
try:
|
|
525
|
+
a.fn(a)
|
|
526
|
+
except ShioError as e:
|
|
527
|
+
sys.exit(e.format())
|
|
528
|
+
except BoundaryError as e:
|
|
529
|
+
sys.exit(f"boundary error: {e}")
|
|
530
|
+
except PolicyError as e:
|
|
531
|
+
sys.exit(f"policy: {e}")
|
|
532
|
+
except RegulatedError as e:
|
|
533
|
+
sys.exit(f"regulated: {e}")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__":
|
|
537
|
+
main()
|