fullstop 0.2.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.
- fullstop/__init__.py +8 -0
- fullstop/__main__.py +8 -0
- fullstop/activity.py +241 -0
- fullstop/agent.py +463 -0
- fullstop/cli.py +262 -0
- fullstop/gate.py +230 -0
- fullstop/manifest.py +305 -0
- fullstop/policy.py +184 -0
- fullstop/protocol.py +212 -0
- fullstop/provider.py +188 -0
- fullstop/redact.py +52 -0
- fullstop/resolve.py +98 -0
- fullstop/sandbox.py +66 -0
- fullstop/state.py +92 -0
- fullstop/tools/__init__.py +41 -0
- fullstop/tools/base.py +65 -0
- fullstop/tools/browser.py +25 -0
- fullstop/tools/file.py +178 -0
- fullstop/tools/note.py +59 -0
- fullstop/tools/shell.py +82 -0
- fullstop/tools/web.py +135 -0
- fullstop/types.py +63 -0
- fullstop/ui_approver.py +258 -0
- fullstop/ui_assets.py +955 -0
- fullstop/ui_model.py +187 -0
- fullstop/ui_server.py +310 -0
- fullstop/ui_strings.py +327 -0
- fullstop/ui_wizard.py +302 -0
- fullstop-0.2.1.dist-info/METADATA +445 -0
- fullstop-0.2.1.dist-info/RECORD +33 -0
- fullstop-0.2.1.dist-info/WHEEL +5 -0
- fullstop-0.2.1.dist-info/licenses/LICENSE +21 -0
- fullstop-0.2.1.dist-info/top_level.txt +1 -0
fullstop/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""fullstop — a zero-dependency Python runtime for always-on agents.
|
|
2
|
+
|
|
3
|
+
Final name (owner ruling 2026-09-30; verified free on PyPI that day). This
|
|
4
|
+
package deliberately exports nothing but its version; import submodules
|
|
5
|
+
directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__: str = "0.2.1"
|
fullstop/__main__.py
ADDED
fullstop/activity.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Append-only, tamper-evident JSONL activity log.
|
|
2
|
+
|
|
3
|
+
Every line carries a chained hash: sha256(f"{prev}:{canonical_json(line_without_hash)}").
|
|
4
|
+
String values are scrubbed and truncated AT WRITE TIME, so credentials never
|
|
5
|
+
reach disk even in model replies.
|
|
6
|
+
|
|
7
|
+
v0.1.1 (FIXLIST item 8): appends are O(1) — they chain from the cached
|
|
8
|
+
last-line hash and never re-read the whole file (a bounded tail-window read
|
|
9
|
+
still validates the last line on every append, so an externally corrupted or
|
|
10
|
+
swapped tail is still refused). When the live segment grows past
|
|
11
|
+
``max_segment_bytes`` it rotates into a timestamped segment in the same
|
|
12
|
+
directory; the chain and the global ``seq`` CONTINUE across segments, and
|
|
13
|
+
``verify()``/``entries()``/``tail()`` walk all segments in order.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterator
|
|
23
|
+
|
|
24
|
+
from .redact import Redactor, truncate
|
|
25
|
+
|
|
26
|
+
_RESERVED = {"seq", "ts", "event", "prev", "hash"}
|
|
27
|
+
# How far back from EOF the bounded tail read may look for the last line.
|
|
28
|
+
_TAIL_WINDOW_BYTES = 1 << 20
|
|
29
|
+
_SEGMENT_RE = re.compile(r"^activity-\d{8}T\d{6,12}Z-(\d+)\.jsonl$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _canonical(obj: Any) -> str:
|
|
33
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ActivityLogError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ActivityLog:
|
|
41
|
+
def __init__(self, path: Path, redactor: Redactor | None = None,
|
|
42
|
+
truncate_chars: int = 2000,
|
|
43
|
+
max_segment_bytes: int = 1_048_576) -> None:
|
|
44
|
+
self._path = Path(path)
|
|
45
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self._redactor = redactor
|
|
47
|
+
self._truncate_chars = truncate_chars
|
|
48
|
+
self._max_segment_bytes = max(1024, int(max_segment_bytes))
|
|
49
|
+
# Chain head cache: {"hash": ..., "seq": ...} of the last appended
|
|
50
|
+
# (or loaded) entry. O(1) appends chain from this, never from a
|
|
51
|
+
# full-file re-read.
|
|
52
|
+
self._tail: dict[str, Any] | None = None
|
|
53
|
+
self._tail_loaded = False
|
|
54
|
+
|
|
55
|
+
# -- scrubbing -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def _scrub(self, value: Any) -> Any:
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
if self._redactor is not None:
|
|
60
|
+
value = self._redactor.scrub(value)
|
|
61
|
+
return truncate(value, self._truncate_chars)
|
|
62
|
+
if isinstance(value, list):
|
|
63
|
+
return [self._scrub(v) for v in value]
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
out = {}
|
|
66
|
+
for k, v in value.items():
|
|
67
|
+
key = self._redactor.scrub(str(k)) if self._redactor else str(k)
|
|
68
|
+
out[key] = self._scrub(v)
|
|
69
|
+
return out
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
# -- ordered segments ------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _segment_files(self) -> list[Path]:
|
|
75
|
+
"""All segment files in CHAIN order: rotated segments sorted by the
|
|
76
|
+
seq stamped in their name, then the live file."""
|
|
77
|
+
rotated: list[tuple[int, Path]] = []
|
|
78
|
+
if self._path.parent.exists():
|
|
79
|
+
for p in self._path.parent.glob("activity-*.jsonl"):
|
|
80
|
+
m = _SEGMENT_RE.match(p.name)
|
|
81
|
+
if m:
|
|
82
|
+
rotated.append((int(m.group(1)), p))
|
|
83
|
+
rotated.sort(key=lambda sp: sp[0])
|
|
84
|
+
return [p for _, p in rotated] + [self._path]
|
|
85
|
+
|
|
86
|
+
def _read_last_entry(self) -> dict:
|
|
87
|
+
"""The last chain entry across segments, via a BOUNDED tail read.
|
|
88
|
+
Raises ActivityLogError on a corrupt or unparseable tail; returns
|
|
89
|
+
{} when nothing has been logged yet.
|
|
90
|
+
|
|
91
|
+
v0.1.2 (FIXLIST2 item 3): a line larger than the tail window makes
|
|
92
|
+
the window start MID-LINE, and the fragment it yields is not a
|
|
93
|
+
valid entry — which used to wedge every future append. When the
|
|
94
|
+
window read cannot produce a valid last line, the segment is
|
|
95
|
+
re-read IN FULL (pathological case only; normal appends never hit
|
|
96
|
+
it) so the real last line can be validated. A genuinely corrupt or
|
|
97
|
+
tampered tail still raises — from the full read."""
|
|
98
|
+
for path in reversed(self._segment_files()):
|
|
99
|
+
if not path.exists():
|
|
100
|
+
continue
|
|
101
|
+
size = path.stat().st_size
|
|
102
|
+
if size == 0:
|
|
103
|
+
continue
|
|
104
|
+
with path.open("rb") as fh:
|
|
105
|
+
window = min(size, _TAIL_WINDOW_BYTES)
|
|
106
|
+
fh.seek(-window, os.SEEK_END)
|
|
107
|
+
data = fh.read()
|
|
108
|
+
lines = [ln for ln in
|
|
109
|
+
data.decode("utf-8", errors="replace").split("\n")
|
|
110
|
+
if ln.strip()]
|
|
111
|
+
try:
|
|
112
|
+
obj = self._validate_last_line(lines)
|
|
113
|
+
except ActivityLogError:
|
|
114
|
+
if window >= size:
|
|
115
|
+
raise # the window already saw the whole segment
|
|
116
|
+
full_lines = [ln for ln in
|
|
117
|
+
path.read_text(encoding="utf-8",
|
|
118
|
+
errors="replace").split("\n")
|
|
119
|
+
if ln.strip()]
|
|
120
|
+
return self._validate_last_line(full_lines)
|
|
121
|
+
if obj is not None:
|
|
122
|
+
return obj
|
|
123
|
+
return {}
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _validate_last_line(lines: list[str]) -> dict | None:
|
|
127
|
+
"""Validate ``lines[-1]`` as the chain tail: None when the segment
|
|
128
|
+
has no lines, the entry dict when valid; raises ActivityLogError on
|
|
129
|
+
an unparseable, chain-field-missing, or hash-failing tail."""
|
|
130
|
+
if not lines:
|
|
131
|
+
return None
|
|
132
|
+
line = lines[-1]
|
|
133
|
+
try:
|
|
134
|
+
obj = json.loads(line)
|
|
135
|
+
except json.JSONDecodeError as e:
|
|
136
|
+
raise ActivityLogError(
|
|
137
|
+
f"activity log tail is not valid JSON: {e.msg}") from e
|
|
138
|
+
if (not isinstance(obj, dict) or "seq" not in obj
|
|
139
|
+
or "prev" not in obj or "hash" not in obj):
|
|
140
|
+
raise ActivityLogError(
|
|
141
|
+
"activity log tail is missing chain fields")
|
|
142
|
+
expected = _sha256(
|
|
143
|
+
f'{obj["prev"]}:{_canonical({k: v for k, v in obj.items() if k != "hash"})}')
|
|
144
|
+
if expected != obj["hash"]:
|
|
145
|
+
raise ActivityLogError(
|
|
146
|
+
"activity log tail fails its chained hash (corrupt or "
|
|
147
|
+
"tampered); refusing to extend")
|
|
148
|
+
return obj
|
|
149
|
+
|
|
150
|
+
def _load_tail(self) -> None:
|
|
151
|
+
obj = self._read_last_entry()
|
|
152
|
+
if obj:
|
|
153
|
+
self._tail = {"hash": obj["hash"], "seq": obj["seq"]}
|
|
154
|
+
else:
|
|
155
|
+
self._tail = {"hash": "0", "seq": 0}
|
|
156
|
+
self._tail_loaded = True
|
|
157
|
+
|
|
158
|
+
# -- append --------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def append(self, event: str, **fields: Any) -> str:
|
|
161
|
+
clash = _RESERVED & set(fields)
|
|
162
|
+
if clash:
|
|
163
|
+
raise ValueError(f"reserved field names cannot be logged: {sorted(clash)}")
|
|
164
|
+
if not self._tail_loaded:
|
|
165
|
+
self._load_tail()
|
|
166
|
+
else:
|
|
167
|
+
# Cheap tamper tripwire: re-read ONLY the last line (bounded
|
|
168
|
+
# window) and refuse to extend if it moved under us.
|
|
169
|
+
current = self._read_last_entry()
|
|
170
|
+
if (not current
|
|
171
|
+
or current.get("hash") != self._tail["hash"]
|
|
172
|
+
or current.get("seq") != self._tail["seq"]):
|
|
173
|
+
raise ActivityLogError(
|
|
174
|
+
"activity log tail changed externally since the last "
|
|
175
|
+
"append (corrupt or tampered); refusing to extend")
|
|
176
|
+
entry: dict[str, Any] = {
|
|
177
|
+
"seq": self._tail["seq"] + 1,
|
|
178
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
179
|
+
"event": event,
|
|
180
|
+
}
|
|
181
|
+
for key, value in fields.items():
|
|
182
|
+
entry[key] = self._scrub(value)
|
|
183
|
+
entry["prev"] = self._tail["hash"]
|
|
184
|
+
entry["hash"] = _sha256(f'{entry["prev"]}:{_canonical(entry)}')
|
|
185
|
+
line = _canonical(entry)
|
|
186
|
+
# Size-based rotation: move the full live segment aside and start a
|
|
187
|
+
# fresh one; the chain and seq continue across the boundary.
|
|
188
|
+
if self._path.exists() and self._path.stat().st_size > 0:
|
|
189
|
+
projected = self._path.stat().st_size + len(line) + 1
|
|
190
|
+
if projected > self._max_segment_bytes:
|
|
191
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") + "Z"
|
|
192
|
+
rotated = self._path.with_name(
|
|
193
|
+
f"activity-{stamp}-{self._tail['seq']}.jsonl")
|
|
194
|
+
os.replace(self._path, rotated)
|
|
195
|
+
with self._path.open("a", encoding="utf-8") as fh:
|
|
196
|
+
fh.write(line + "\n")
|
|
197
|
+
self._tail = {"hash": entry["hash"], "seq": entry["seq"]}
|
|
198
|
+
return entry["hash"]
|
|
199
|
+
|
|
200
|
+
# -- reading ----------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def entries(self) -> Iterator[dict]:
|
|
203
|
+
for path in self._segment_files():
|
|
204
|
+
if not path.exists():
|
|
205
|
+
continue
|
|
206
|
+
for line in path.read_text(encoding="utf-8").split("\n"):
|
|
207
|
+
if line.strip():
|
|
208
|
+
yield json.loads(line)
|
|
209
|
+
|
|
210
|
+
def tail(self, n: int) -> list[dict]:
|
|
211
|
+
return list(self.entries())[-n:]
|
|
212
|
+
|
|
213
|
+
def verify(self) -> tuple[bool, int | None]:
|
|
214
|
+
"""Walk the whole chain across ALL segments; returns (ok, first_bad_seq
|
|
215
|
+
or None)."""
|
|
216
|
+
prev = "0"
|
|
217
|
+
expected_seq = 1
|
|
218
|
+
for path in self._segment_files():
|
|
219
|
+
if not path.exists():
|
|
220
|
+
continue
|
|
221
|
+
for line in path.read_text(encoding="utf-8").split("\n"):
|
|
222
|
+
if not line.strip():
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
obj = json.loads(line)
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
return False, expected_seq
|
|
228
|
+
if not isinstance(obj, dict):
|
|
229
|
+
return False, expected_seq
|
|
230
|
+
if obj.get("seq") != expected_seq or obj.get("prev") != prev:
|
|
231
|
+
return False, expected_seq
|
|
232
|
+
body = {k: v for k, v in obj.items() if k != "hash"}
|
|
233
|
+
if _sha256(f'{obj.get("prev")}:{_canonical(body)}') != obj.get("hash"):
|
|
234
|
+
return False, expected_seq
|
|
235
|
+
prev = obj.get("hash")
|
|
236
|
+
expected_seq += 1
|
|
237
|
+
return True, None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _sha256(text: str) -> str:
|
|
241
|
+
return hashlib.sha256(text.encode()).hexdigest()
|