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.
@@ -0,0 +1,3 @@
1
+ """Safe, auditable local job execution for AI-agent workflows."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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}
@@ -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())