agent-safe-runner 0.4.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.
- agent_safe_runner/__init__.py +3 -0
- agent_safe_runner/__main__.py +5 -0
- agent_safe_runner/audit.py +126 -0
- agent_safe_runner/cli.py +156 -0
- agent_safe_runner/core.py +531 -0
- agent_safe_runner/errors.py +32 -0
- agent_safe_runner/mcp_server.py +170 -0
- agent_safe_runner/policy.py +121 -0
- agent_safe_runner/redaction.py +56 -0
- agent_safe_runner-0.4.0.dist-info/METADATA +273 -0
- agent_safe_runner-0.4.0.dist-info/RECORD +14 -0
- agent_safe_runner-0.4.0.dist-info/WHEEL +4 -0
- agent_safe_runner-0.4.0.dist-info/entry_points.txt +2 -0
- agent_safe_runner-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import AuditIntegrityError
|
|
13
|
+
from .redaction import redact
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def _file_lock(path: Path):
|
|
18
|
+
"""Use a one-byte advisory lock on Windows and flock on POSIX."""
|
|
19
|
+
lock_path = Path(f"{path}.lock")
|
|
20
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
with lock_path.open("a+b") as handle:
|
|
22
|
+
handle.seek(0, os.SEEK_END)
|
|
23
|
+
if handle.tell() == 0:
|
|
24
|
+
handle.write(b"0")
|
|
25
|
+
handle.flush()
|
|
26
|
+
handle.seek(0)
|
|
27
|
+
if os.name == "nt":
|
|
28
|
+
import msvcrt
|
|
29
|
+
|
|
30
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
31
|
+
try:
|
|
32
|
+
yield
|
|
33
|
+
finally:
|
|
34
|
+
handle.seek(0)
|
|
35
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
36
|
+
else:
|
|
37
|
+
import fcntl
|
|
38
|
+
|
|
39
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuditLog:
|
|
47
|
+
"""Append-only JSONL audit log with a hash chain for accidental tamper detection."""
|
|
48
|
+
|
|
49
|
+
_registry_lock = threading.Lock()
|
|
50
|
+
_path_locks: dict[str, threading.Lock] = {}
|
|
51
|
+
|
|
52
|
+
def __init__(self, path: str | Path):
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
lock_key = str(self.path.resolve(strict=False)).casefold()
|
|
55
|
+
with self._registry_lock:
|
|
56
|
+
self._lock = self._path_locks.setdefault(lock_key, threading.Lock())
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _digest(record: dict[str, Any]) -> str:
|
|
60
|
+
encoded = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
61
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
62
|
+
|
|
63
|
+
def _tail(self) -> tuple[int, str | None]:
|
|
64
|
+
if not self.path.exists():
|
|
65
|
+
return 0, None
|
|
66
|
+
last = None
|
|
67
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
68
|
+
for line in handle:
|
|
69
|
+
if line.strip():
|
|
70
|
+
last = line
|
|
71
|
+
if last is None:
|
|
72
|
+
return 0, None
|
|
73
|
+
try:
|
|
74
|
+
record = json.loads(last)
|
|
75
|
+
return int(record["seq"]), str(record["hash"])
|
|
76
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
77
|
+
raise AuditIntegrityError("cannot append to an invalid audit log") from exc
|
|
78
|
+
|
|
79
|
+
def append(self, event: str, **data: object) -> dict[str, Any]:
|
|
80
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
with self._lock:
|
|
82
|
+
with _file_lock(self.path):
|
|
83
|
+
sequence, previous_hash = self._tail()
|
|
84
|
+
record: dict[str, Any] = {
|
|
85
|
+
"seq": sequence + 1,
|
|
86
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
87
|
+
"event": event,
|
|
88
|
+
"prev_hash": previous_hash,
|
|
89
|
+
"data": redact(data),
|
|
90
|
+
}
|
|
91
|
+
record["hash"] = self._digest(record)
|
|
92
|
+
descriptor = os.open(self.path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
93
|
+
try:
|
|
94
|
+
payload = (json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
|
95
|
+
os.write(descriptor, payload)
|
|
96
|
+
os.fsync(descriptor)
|
|
97
|
+
finally:
|
|
98
|
+
os.close(descriptor)
|
|
99
|
+
return record
|
|
100
|
+
|
|
101
|
+
def verify(self) -> dict[str, int | bool]:
|
|
102
|
+
expected_sequence = 1
|
|
103
|
+
previous_hash = None
|
|
104
|
+
entries = 0
|
|
105
|
+
if not self.path.exists():
|
|
106
|
+
return {"valid": True, "entries": 0}
|
|
107
|
+
with self._lock:
|
|
108
|
+
with _file_lock(self.path):
|
|
109
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
110
|
+
for line_number, line in enumerate(handle, start=1):
|
|
111
|
+
if not line.strip():
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
record = json.loads(line)
|
|
115
|
+
actual_hash = record.pop("hash")
|
|
116
|
+
except (KeyError, json.JSONDecodeError) as exc:
|
|
117
|
+
raise AuditIntegrityError(f"invalid audit entry at line {line_number}") from exc
|
|
118
|
+
if record.get("seq") != expected_sequence or record.get("prev_hash") != previous_hash:
|
|
119
|
+
raise AuditIntegrityError(f"broken audit chain at line {line_number}")
|
|
120
|
+
calculated_hash = self._digest(record)
|
|
121
|
+
if actual_hash != calculated_hash:
|
|
122
|
+
raise AuditIntegrityError(f"audit hash mismatch at line {line_number}")
|
|
123
|
+
previous_hash = actual_hash
|
|
124
|
+
expected_sequence += 1
|
|
125
|
+
entries += 1
|
|
126
|
+
return {"valid": True, "entries": entries}
|
agent_safe_runner/cli.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Sequence
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .audit import AuditLog
|
|
11
|
+
from .core import APPROVAL_STATUSES, JobStore
|
|
12
|
+
from .errors import RunnerError
|
|
13
|
+
from .policy import Policy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
STATUSES = ("queued", "running", "retry_wait", "succeeded", "failed", "cancelled", "dead_letter")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _emit(payload: Any, *, error: bool = False) -> None:
|
|
20
|
+
stream = sys.stderr if error else sys.stdout
|
|
21
|
+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=stream)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _command(parts: Sequence[str]) -> tuple[str, ...]:
|
|
25
|
+
values = list(parts)
|
|
26
|
+
if values and values[0] == "--":
|
|
27
|
+
values.pop(0)
|
|
28
|
+
if not values:
|
|
29
|
+
raise ValueError("a command is required after --")
|
|
30
|
+
return tuple(values)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="agent-safe", description="Local-first, policy-gated job runner")
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
36
|
+
parser.add_argument("--db", default="agent-safe.sqlite3", help="SQLite queue path")
|
|
37
|
+
parser.add_argument("--audit", default="audit.jsonl", help="JSONL audit path")
|
|
38
|
+
parser.add_argument("--policy", default="agent-safe-policy.json", help="Policy JSON path")
|
|
39
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
40
|
+
|
|
41
|
+
init_policy = sub.add_parser("init-policy", help="Write a conservative sample policy")
|
|
42
|
+
init_policy.add_argument("path", nargs="?", default="agent-safe-policy.json")
|
|
43
|
+
|
|
44
|
+
submit = sub.add_parser("submit", help="Queue a command without executing it")
|
|
45
|
+
submit.add_argument("--key")
|
|
46
|
+
submit.add_argument("--cwd")
|
|
47
|
+
submit.add_argument("--timeout", type=int, default=60)
|
|
48
|
+
submit.add_argument("--max-attempts", type=int, default=1)
|
|
49
|
+
submit.add_argument("command", nargs=argparse.REMAINDER, help="Use: submit [options] -- program arg...")
|
|
50
|
+
|
|
51
|
+
listing = sub.add_parser("list", help="List jobs")
|
|
52
|
+
listing.add_argument("--status", action="append", choices=STATUSES, default=[])
|
|
53
|
+
listing.add_argument("--limit", type=int, default=100)
|
|
54
|
+
listing.add_argument("--approval", choices=APPROVAL_STATUSES)
|
|
55
|
+
|
|
56
|
+
inbox = sub.add_parser("inbox", help="List pending jobs awaiting an operator decision")
|
|
57
|
+
inbox.add_argument("--limit", type=int, default=100)
|
|
58
|
+
|
|
59
|
+
assess = sub.add_parser("assess", help="Report policy allowance without approving or executing")
|
|
60
|
+
assess.add_argument("job_id")
|
|
61
|
+
|
|
62
|
+
approve = sub.add_parser("approve", help="Approve a pending job that also passes policy")
|
|
63
|
+
approve.add_argument("job_id")
|
|
64
|
+
approve.add_argument("--by", required=True, help="Operator label, not authentication")
|
|
65
|
+
approve.add_argument("--reason")
|
|
66
|
+
|
|
67
|
+
deny = sub.add_parser("deny", help="Deny and cancel a pending job")
|
|
68
|
+
deny.add_argument("job_id")
|
|
69
|
+
deny.add_argument("--by", required=True, help="Operator label, not authentication")
|
|
70
|
+
deny.add_argument("--reason", required=True)
|
|
71
|
+
|
|
72
|
+
show = sub.add_parser("show", help="Show one job")
|
|
73
|
+
show.add_argument("job_id")
|
|
74
|
+
|
|
75
|
+
run = sub.add_parser("run", help="Dry-run a job; --execute requires approval and policy allowance")
|
|
76
|
+
run.add_argument("job_id")
|
|
77
|
+
run.add_argument("--execute", action="store_true")
|
|
78
|
+
run.add_argument("--worker", default="direct")
|
|
79
|
+
|
|
80
|
+
work = sub.add_parser("work", help="Process at most one available job")
|
|
81
|
+
work.add_argument("--once", action="store_true", required=True)
|
|
82
|
+
work.add_argument("--execute", action="store_true")
|
|
83
|
+
work.add_argument("--worker", default="worker")
|
|
84
|
+
|
|
85
|
+
cancel = sub.add_parser("cancel", help="Cancel a queued job")
|
|
86
|
+
cancel.add_argument("job_id")
|
|
87
|
+
|
|
88
|
+
retry = sub.add_parser("retry", help="Reset failed, cancelled, or dead-letter jobs to pending approval")
|
|
89
|
+
retry.add_argument("job_id")
|
|
90
|
+
|
|
91
|
+
sub.add_parser("audit-verify", help="Verify the JSONL audit hash chain")
|
|
92
|
+
sub.add_parser("mcp", help="Serve proposal/read-only MCP over stdio; requires absolute global paths and [mcp]")
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
97
|
+
parser = build_parser()
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
try:
|
|
100
|
+
if args.action == "init-policy":
|
|
101
|
+
destination = Path(args.path)
|
|
102
|
+
if destination.exists():
|
|
103
|
+
raise ValueError(f"policy already exists: {destination}")
|
|
104
|
+
destination.write_text(json.dumps(Policy.sample(), indent=2) + "\n", encoding="utf-8")
|
|
105
|
+
_emit({"status": "created", "path": str(destination.resolve())})
|
|
106
|
+
return 0
|
|
107
|
+
if args.action == "audit-verify":
|
|
108
|
+
_emit(AuditLog(args.audit).verify())
|
|
109
|
+
return 0
|
|
110
|
+
if args.action == "mcp":
|
|
111
|
+
from .mcp_server import serve
|
|
112
|
+
|
|
113
|
+
serve(args.db, args.audit, args.policy)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
policy = Policy.from_file(args.policy)
|
|
117
|
+
with JobStore(args.db, args.audit, policy=policy) as store:
|
|
118
|
+
if args.action == "submit":
|
|
119
|
+
job = store.submit(
|
|
120
|
+
_command(args.command),
|
|
121
|
+
args.key,
|
|
122
|
+
cwd=args.cwd,
|
|
123
|
+
timeout=args.timeout,
|
|
124
|
+
max_attempts=args.max_attempts,
|
|
125
|
+
)
|
|
126
|
+
_emit(job.to_dict())
|
|
127
|
+
elif args.action == "list":
|
|
128
|
+
_emit([job.to_dict() for job in store.list(tuple(args.status), args.limit, approval=args.approval)])
|
|
129
|
+
elif args.action == "inbox":
|
|
130
|
+
_emit([job.to_dict() for job in store.inbox(args.limit)])
|
|
131
|
+
elif args.action == "assess":
|
|
132
|
+
_emit(store.assess(args.job_id))
|
|
133
|
+
elif args.action == "approve":
|
|
134
|
+
_emit(store.approve(args.job_id, by=args.by, reason=args.reason).to_dict())
|
|
135
|
+
elif args.action == "deny":
|
|
136
|
+
_emit(store.deny(args.job_id, by=args.by, reason=args.reason).to_dict())
|
|
137
|
+
elif args.action == "show":
|
|
138
|
+
_emit(store.get(args.job_id).to_dict())
|
|
139
|
+
elif args.action == "run":
|
|
140
|
+
_emit(store.run(args.job_id, execute=args.execute, worker_id=args.worker).to_dict())
|
|
141
|
+
elif args.action == "work":
|
|
142
|
+
job = store.work_once(execute=args.execute, worker_id=args.worker)
|
|
143
|
+
_emit({"status": "idle"} if job is None else job.to_dict())
|
|
144
|
+
elif args.action == "cancel":
|
|
145
|
+
_emit(store.cancel(args.job_id).to_dict())
|
|
146
|
+
elif args.action == "retry":
|
|
147
|
+
_emit(store.retry(args.job_id).to_dict())
|
|
148
|
+
return 0
|
|
149
|
+
except (RunnerError, ValueError, OSError) as exc:
|
|
150
|
+
code = getattr(exc, "code", "invalid_input")
|
|
151
|
+
_emit({"error": {"code": code, "message": str(exc)}}, error=True)
|
|
152
|
+
return 2
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|