fullstop 0.2.1__tar.gz

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.
Files changed (82) hide show
  1. fullstop-0.2.1/LICENSE +21 -0
  2. fullstop-0.2.1/PKG-INFO +445 -0
  3. fullstop-0.2.1/README.md +435 -0
  4. fullstop-0.2.1/fullstop/__init__.py +8 -0
  5. fullstop-0.2.1/fullstop/__main__.py +8 -0
  6. fullstop-0.2.1/fullstop/activity.py +241 -0
  7. fullstop-0.2.1/fullstop/agent.py +463 -0
  8. fullstop-0.2.1/fullstop/cli.py +262 -0
  9. fullstop-0.2.1/fullstop/gate.py +230 -0
  10. fullstop-0.2.1/fullstop/manifest.py +305 -0
  11. fullstop-0.2.1/fullstop/policy.py +184 -0
  12. fullstop-0.2.1/fullstop/protocol.py +212 -0
  13. fullstop-0.2.1/fullstop/provider.py +188 -0
  14. fullstop-0.2.1/fullstop/redact.py +52 -0
  15. fullstop-0.2.1/fullstop/resolve.py +98 -0
  16. fullstop-0.2.1/fullstop/sandbox.py +66 -0
  17. fullstop-0.2.1/fullstop/state.py +92 -0
  18. fullstop-0.2.1/fullstop/tools/__init__.py +41 -0
  19. fullstop-0.2.1/fullstop/tools/base.py +65 -0
  20. fullstop-0.2.1/fullstop/tools/browser.py +25 -0
  21. fullstop-0.2.1/fullstop/tools/file.py +178 -0
  22. fullstop-0.2.1/fullstop/tools/note.py +59 -0
  23. fullstop-0.2.1/fullstop/tools/shell.py +82 -0
  24. fullstop-0.2.1/fullstop/tools/web.py +135 -0
  25. fullstop-0.2.1/fullstop/types.py +63 -0
  26. fullstop-0.2.1/fullstop/ui_approver.py +258 -0
  27. fullstop-0.2.1/fullstop/ui_assets.py +955 -0
  28. fullstop-0.2.1/fullstop/ui_model.py +187 -0
  29. fullstop-0.2.1/fullstop/ui_server.py +310 -0
  30. fullstop-0.2.1/fullstop/ui_strings.py +327 -0
  31. fullstop-0.2.1/fullstop/ui_wizard.py +302 -0
  32. fullstop-0.2.1/fullstop.egg-info/PKG-INFO +445 -0
  33. fullstop-0.2.1/fullstop.egg-info/SOURCES.txt +80 -0
  34. fullstop-0.2.1/fullstop.egg-info/dependency_links.txt +1 -0
  35. fullstop-0.2.1/fullstop.egg-info/top_level.txt +1 -0
  36. fullstop-0.2.1/pyproject.toml +8 -0
  37. fullstop-0.2.1/setup.cfg +4 -0
  38. fullstop-0.2.1/tests/test_activity_log.py +144 -0
  39. fullstop-0.2.1/tests/test_agent_loop.py +278 -0
  40. fullstop-0.2.1/tests/test_cli.py +169 -0
  41. fullstop-0.2.1/tests/test_credentials.py +160 -0
  42. fullstop-0.2.1/tests/test_examples.py +183 -0
  43. fullstop-0.2.1/tests/test_fix_01_crash_resume.py +319 -0
  44. fullstop-0.2.1/tests/test_fix_02_config_tamper.py +215 -0
  45. fullstop-0.2.1/tests/test_fix_03_context_exhaustion.py +120 -0
  46. fullstop-0.2.1/tests/test_fix_04_goal_override.py +97 -0
  47. fullstop-0.2.1/tests/test_fix_05_redirect_chain.py +145 -0
  48. fullstop-0.2.1/tests/test_fix_06_approval_display.py +191 -0
  49. fullstop-0.2.1/tests/test_fix_07_shell_allowlist.py +178 -0
  50. fullstop-0.2.1/tests/test_fix_08_activity_log_rotation.py +98 -0
  51. fullstop-0.2.1/tests/test_fix_09_file_read_cap.py +47 -0
  52. fullstop-0.2.1/tests/test_fix_10_unattended_stop.py +109 -0
  53. fullstop-0.2.1/tests/test_fix_11_redactor_call_time.py +49 -0
  54. fullstop-0.2.1/tests/test_fix_12_note_symlink.py +58 -0
  55. fullstop-0.2.1/tests/test_fix_13_file_list_protected.py +71 -0
  56. fullstop-0.2.1/tests/test_fix_14_gate_classification_label.py +65 -0
  57. fullstop-0.2.1/tests/test_fix_15_observation_delimiters.py +126 -0
  58. fullstop-0.2.1/tests/test_fix_16_log_truncate_wiring.py +65 -0
  59. fullstop-0.2.1/tests/test_fix_17_19_readme_honesty.py +85 -0
  60. fullstop-0.2.1/tests/test_fix_20_live_user_seed.py +84 -0
  61. fullstop-0.2.1/tests/test_fix_21_fence_not_line_anchored.py +59 -0
  62. fullstop-0.2.1/tests/test_fix_22_resume_seed_guard.py +127 -0
  63. fullstop-0.2.1/tests/test_fix_23_parser_stray_marker_cliffs.py +89 -0
  64. fullstop-0.2.1/tests/test_imports.py +80 -0
  65. fullstop-0.2.1/tests/test_manifest_policy.py +332 -0
  66. fullstop-0.2.1/tests/test_policy_gate.py +357 -0
  67. fullstop-0.2.1/tests/test_protocol.py +137 -0
  68. fullstop-0.2.1/tests/test_provider.py +190 -0
  69. fullstop-0.2.1/tests/test_r2_01_replay_dedup_keys.py +311 -0
  70. fullstop-0.2.1/tests/test_r2_02_provider_context_body.py +253 -0
  71. fullstop-0.2.1/tests/test_r2_03_log_oversize_wedge.py +163 -0
  72. fullstop-0.2.1/tests/test_r2_04_readme_allow_config_change.py +99 -0
  73. fullstop-0.2.1/tests/test_r2_05_error_observation_frame.py +173 -0
  74. fullstop-0.2.1/tests/test_sandbox.py +184 -0
  75. fullstop-0.2.1/tests/test_state.py +80 -0
  76. fullstop-0.2.1/tests/test_tools.py +362 -0
  77. fullstop-0.2.1/tests/test_ui_approver.py +339 -0
  78. fullstop-0.2.1/tests/test_ui_assets.py +49 -0
  79. fullstop-0.2.1/tests/test_ui_cli.py +332 -0
  80. fullstop-0.2.1/tests/test_ui_model.py +212 -0
  81. fullstop-0.2.1/tests/test_ui_server.py +307 -0
  82. fullstop-0.2.1/tests/test_ui_wizard.py +214 -0
fullstop-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the fullstop contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,445 @@
1
+ Metadata-Version: 2.4
2
+ Name: fullstop
3
+ Version: 0.2.1
4
+ Summary: A zero-dependency Python runtime for always-on agents: identity manifests, a default-deny policy gate with human approval, a fenced tool-call protocol, a hash-chained activity log, and crash-safe checkpoint/resume with replay
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # fullstop
12
+
13
+ **Your agent. Your machine. Full stop.**
14
+
15
+ A self-hosted, model-agnostic, **zero-dependency** Python runtime for always-on
16
+ agents: each agent has a persistent workspace, an identity with scoped
17
+ credentials, a policy gate that reviews every consequential action, and a
18
+ tamper-evident append-only activity log.
19
+
20
+ The free, open-source counterpart to OpenAI's Dots announcement (DevDay 2026,
21
+ Sept 29), built the week it was announced. `fullstop` is the final name
22
+ (verified free on PyPI 2026-09-30); the project was developed under the
23
+ working name `hearth`, which is why early git history says so. No OpenAI
24
+ marks are used in the repo, package, or CLI names.
25
+
26
+ - Runtime: Python 3.11+, **stdlib only** (no third-party imports anywhere —
27
+ proven by `tests/test_imports.py`)
28
+ - `pathlib` everywhere, no shellisms; the suite runs green on Windows (CI
29
+ is configured to add `ubuntu-latest`)
30
+ - Synchronous only; JSON everywhere, no YAML
31
+
32
+ ## What it is, honestly
33
+
34
+ An agent that keeps working toward a goal across sessions: you give it a goal
35
+ and a manifest, it runs a plan → act → observe loop against an
36
+ OpenAI-compatible model API, checkpoints its state as JSON after every step,
37
+ and can resume after a crash — including mid-step. The crash-mid-run resume
38
+ without double execution is demonstrated by
39
+ `tests/test_fix_01_crash_resume.py`, not just guard-stop resumes.
40
+
41
+ It is **not**: a hosted cloud product; a real browser in v0.1 (`web_fetch`
42
+ only — the `browser` tool interface is defined but unimplemented, and saying
43
+ so is enforced by `tests/test_tools.py::BrowserToolTests`); a multi-agent
44
+ orchestrator; a plugin marketplace; streaming output or a desktop GUI (the
45
+ loopback activity view documented below is a local dashboard served to your
46
+ browser, not either); wired to
47
+ any non-OpenAI-compatible provider protocol (Anthropic native etc. — only
48
+ OpenAI-compatible chat-completions endpoints, plus the scripted mock).
49
+
50
+ ## fullstop vs Dots — as announced
51
+
52
+ OpenAI announced Dots and Specialist Dots at DevDay 2026 (Sept 29). The
53
+ comparison below is scoped to the capabilities **as announced** that day —
54
+ not to press speculation — and is dated for exactly that reason: OpenAI may
55
+ have shipped more since. OpenAI and Dots are trademarks of OpenAI; fullstop
56
+ is an independent open-source project with no affiliation. What fullstop
57
+ **has**, **has not**, and **will not** have:
58
+
59
+ | Capability | fullstop v0.1 | Dots (as announced 2026-09-29) |
60
+ | --- | --- | --- |
61
+ | Hosting | self-hosted, your machine | OpenAI cloud |
62
+ | Model | any endpoint that speaks the OpenAI chat-completions API (unit-tested against scripted mock endpoints; you supply the base URL; live endpoints untested — see Limits) | OpenAI models |
63
+ | Always-on loop, checkpoints, resume | has — incl. crash-mid-run with no double execution (`tests/test_fix_01_crash_resume.py`) | has (managed) |
64
+ | Policy gate over every consequential action | has — code between model and tools, default-deny (`tests/test_policy_gate.py`) | has (product-managed review) |
65
+ | Human approval showing the full request | has — at the console (`tests/test_fix_06_approval_display.py`) | has (in-product) |
66
+ | Tamper-evident activity log you can read and verify | has — with rotation (`tests/test_fix_08_activity_log_rotation.py`) | not disclosed |
67
+ | Config integrity anchored in the log | has — `config_loaded` hashes; resume refuses swapped gate rules (`tests/test_fix_02_config_tamper.py`) | not disclosed |
68
+ | Browser automation | **has not** — v0.1 is `web_fetch` only, redirect hops re-validated per hop (`tests/test_fix_05_redirect_chain.py`) | has (built-in) |
69
+ | Multi-agent orchestration / specialist dots | **has not** | has |
70
+ | Streaming output | **has not** | has |
71
+
72
+ fullstop **will not** have — permanent non-goals, not a todo list: cloud
73
+ hosting; a plugin marketplace; non-OpenAI-compatible provider protocols
74
+ (Anthropic native etc.); multi-agent orchestration.
75
+
76
+ fullstop **has not** yet, and may gain later: real browser automation;
77
+ streaming; message-history compaction (context exhaustion stops the run
78
+ honestly — see Limits below).
79
+
80
+ ## Quickstart (keyless, exactly ONE approval prompt)
81
+
82
+ From the repo root:
83
+
84
+ ```bash
85
+ python -m fullstop run --manifest examples/scripted-demo.json
86
+ ```
87
+
88
+ The scripted demo needs **no API key and no network**. It performs, in order:
89
+
90
+ 1. a `note` write to `memory.md` — pre-approved by the demo policy, **no prompt**;
91
+ 2. a pre-approved `notes/intro.md` write + read — **no prompt**;
92
+ 3. a `summary.md` write that matches nothing pre-approved — **THE one prompt**:
93
+ - answer `y` → the write executes and `summary.md` appears;
94
+ - answer `n` (or just Enter) → the write is denied, logged, and **the run
95
+ still completes**;
96
+ - note: on a NON-interactive console (piped stdin) no one can answer, so
97
+ the run pauses in `stopped_approval` instead — resume it from a real
98
+ terminal (`tests/test_fix_10_unattended_stop.py`);
99
+ 4. a glob-disguised escape write `notes/../../escape.txt` — the glob
100
+ `notes/**` pre-approves it at the gate (no prompt), and the **sandbox
101
+ blocks it** — you watch the backstop work without being asked;
102
+ 5. a final summary turn — run completes.
103
+
104
+ Then inspect the evidence:
105
+
106
+ ```bash
107
+ python -m fullstop log --manifest examples/scripted-demo.json --verify
108
+ python -m fullstop status --manifest examples/scripted-demo.json
109
+ ```
110
+
111
+ The quickstart's exact behavior (exactly one prompt, the artifact set, the
112
+ sandbox block, the verified hash chain) is asserted by
113
+ `tests/test_examples.py`, which loads the shipped files from disk.
114
+
115
+ ## The example manifests
116
+
117
+ | Manifest | What it demonstrates | Prompts |
118
+ | --- | --- | --- |
119
+ | `examples/scripted-demo.json` | the full loop: pre-approved writes, one approval, a blocked escape | exactly one |
120
+ | `examples/research-readonly.json` | a read-only specialist: listings are free, `notes/**` and `memory.md` are protected, so both write attempts (`file_write`, `note`) are hard-denied with no human present — the workspace ends untouched | zero |
121
+ | `examples/writer-approval.json` | an approval-gated writer: `drafts/**` writes freely, the `report.md` write and the memory note each ask once (answer `y`, then `n`), and a `secret-*.txt` write is unapprovable at any price | exactly two |
122
+ | `examples/research-assistant.json` | the real-provider template (needs `FULLSTOP_API_KEY` and your model id; validated by load in `tests/test_examples.py`) | — |
123
+
124
+ ```bash
125
+ python -m fullstop run --manifest examples/research-readonly.json
126
+ python -m fullstop run --manifest examples/writer-approval.json
127
+ ```
128
+
129
+ The first completes with zero prompts on any console. The second needs a
130
+ human for its two prompts; where no one can answer, the same attended-only
131
+ rules as the quickstart apply — a genuinely non-tty console pauses in
132
+ `stopped_approval` for a later `resume`. That unattended stop is tested
133
+ generically (`tests/test_fix_10_unattended_stop.py`), not against this
134
+ example — in the suite the writer-approval run is exercised with an
135
+ approver armed. Both runs' exact behavior — prompt counts, decision trail,
136
+ artifact set, verified chain — is asserted by `tests/test_examples.py`.
137
+
138
+ Any run can override its manifest's goal for that one run with `--goal`
139
+ (`python -m fullstop run --manifest examples/research-readonly.json --goal
140
+ "Inventory only"`); the override is applied before the loop — and thus the
141
+ system prompt — is built (`tests/test_fix_04_goal_override.py`).
142
+
143
+ ## Resuming, editing the config, and `--allow-config-change`
144
+
145
+ `fullstop resume` continues from the checkpoint. Two facts govern every
146
+ resume:
147
+
148
+ - **Guard-stopped runs are resumable.** When a run stops at a limit
149
+ (`stopped_max_steps`, `stopped_max_cost`, `stopped_context`,
150
+ `stopped_approval`), the operator edits the manifest — the canonical
151
+ case is to raise `max_steps` (or lift `max_context_chars` / remove a
152
+ deny rule) — and resumes from there
153
+ (`tests/test_agent_loop.py`, `tests/test_fix_10_unattended_stop.py`).
154
+ - **Resume refuses a changed config by default.** Every start and resume
155
+ appends a `config_loaded` event with sha256 hashes of the manifest and
156
+ policy bytes; `resume` compares against the last one and refuses on a
157
+ mismatch, because one approved write must never silently swap the gate's
158
+ rules (`tests/test_fix_02_config_tamper.py`).
159
+
160
+ Editing the manifest to raise `max_steps` changes the manifest bytes, so
161
+ the standard resume-with-edit flow is:
162
+
163
+ ```bash
164
+ python -m fullstop resume --manifest <path.json> --allow-config-change
165
+ ```
166
+
167
+ `--allow-config-change` is the explicit operator override: you assert the
168
+ config change is yours and intentional, and the new `config_loaded` event
169
+ anchors the run to the edited rules from that resume onward.
170
+
171
+ **Failed runs have no supported retry.** Only guard-stopped
172
+ (`stopped_*`) and crash-mid-run (`running`) checkpoints continue;
173
+ `completed`, `failed`, and `script_exhausted` runs are terminal — `resume`
174
+ on them does nothing except re-log and exit (nonzero for `failed`). There
175
+ is no supported retry of a failed run; start a new run (`run`) instead.
176
+
177
+ ## The activity view (loopback dashboard)
178
+
179
+ `python -m fullstop ui --manifest <path.json>` serves a dashboard for that
180
+ manifest's workspace and opens it in a browser (`--no-browser` prints the
181
+ URL instead). `run --ui` and `resume --ui` serve the same dashboard while a
182
+ run executes: the run stays foreground and the dashboard lives on daemon
183
+ threads, polling the checkpoint and log files the loop already writes; the
184
+ server never imports the agent loop (`tests/test_ui_cli.py`).
185
+
186
+ What it shows:
187
+
188
+ - the current run: goal, status, steps, cost, tokens, and the checkpointed
189
+ failure line if any;
190
+ - the activity log as written: narration (model replies), tool calls, gate
191
+ decisions, approvals, sandbox blocks, guard trips, run end
192
+ (`tests/test_ui_model.py`);
193
+ - a history panel of the runs recorded in this workspace, and a verify
194
+ button that re-checks the hash chain and reports the first broken entry
195
+ (`tests/test_ui_server.py`);
196
+ - approval cards. With `--ui`, an APPROVAL_REQUIRED decision renders the
197
+ complete request (the full `render_request` output, never a summary) in
198
+ the browser. Approvals use a decision-file protocol: the pending card is
199
+ written to a rendezvous directory under the OS temp dir, deliberately
200
+ OUTSIDE the workspace sandbox so the agent's own file tools can never
201
+ reach it, let alone forge an approval; each prompt carries a 64-bit
202
+ random id bound into both filenames; a wrong-id or forged decision file
203
+ is ignored; and on timeout (default 300 s, `--approval-timeout`) the run
204
+ fails closed into `stopped_approval`, exactly like an unattended console
205
+ (`tests/test_ui_approver.py`, `tests/test_ui_cli.py`);
206
+ - a new-run wizard: builds ONE manifest file with an inline policy,
207
+ validated by the same strict validators the runtime uses (errors surface
208
+ field-level and verbatim), saves to new files only (an existing path is
209
+ refused and never modified; the no-clobber write is atomic), and carries
210
+ credential env var NAMES only; the written bytes are scrubbed through
211
+ `Redactor.from_env`, so an environment value can never be persisted even
212
+ if pasted into a text field (`tests/test_ui_wizard.py`).
213
+
214
+ Trust boundaries, stated plainly:
215
+
216
+ - the server binds 127.0.0.1 only and there is NO host flag anywhere; a
217
+ `--host` argument is rejected by the CLI parser
218
+ (`tests/test_ui_server.py`);
219
+ - requests whose Host header is not this loopback origin are refused
220
+ (DNS-rebinding guard), and every POST must carry the `X-Fullstop-UI`
221
+ header that cross-site form posts cannot set (CSRF guard)
222
+ (`tests/test_ui_server.py`);
223
+ - the dashboard deliberately carries NO token. This is a deliberate
224
+ contrast with keysmith's token-gated dashboard, not an oversight: any
225
+ process running as the local user can read the page and answer an
226
+ approval card. If that is not acceptable on your machine, do not use
227
+ `--ui`;
228
+ - the only writes the dashboard can perform are the decision file and,
229
+ when explicitly asked through the wizard, one new manifest file; it
230
+ holds no code path that opens checkpoints, manifests, or the log for
231
+ writing (`tests/test_ui_server.py`).
232
+
233
+ ## Manifest schema (strict; unknown keys rejected)
234
+
235
+ ```jsonc
236
+ {
237
+ "identity": {"name": str, "role": str, "home": str}, // home = workspace dir
238
+ "goal": str,
239
+ "policy": "path.json" | { "inline policy object" },
240
+ "credentials": ["ENV_VAR_NAME", ...], // optional; NAMES only
241
+ "provider": {"type": "openai_compat", "base_url": str, "api_key_env": str,
242
+ "model": str, "usd_per_1k_input": num?, "usd_per_1k_output": num?,
243
+ "timeout_s": num?}
244
+ | {"type": "scripted", "script_path": str},
245
+ "limits": {"max_steps": int, "max_cost_usd": num?, "max_tool_calls_per_step": int?,
246
+ "max_context_chars": int?}, // near-limit estimate -> stopped_context
247
+ "log_truncate_chars": int?
248
+ }
249
+ ```
250
+
251
+ `identity.home`, the policy path form, and `provider.script_path` all resolve
252
+ **relative to the manifest file's directory** (validated from any CWD by
253
+ `tests/test_manifest_policy.py`). `max_cost_usd` requires both pricing fields
254
+ (they are illustrative placeholders in `examples/research-assistant.json` —
255
+ set them to your provider's real prices).
256
+
257
+ **Model config note — the markers are load-bearing.** The
258
+ `<<<TOOL_CALL>>>` / `<<<END_TOOL_CALL>>>` fences are the only way a model can
259
+ act. A model that *mentions* a marker in prose (quoted or not) hijacks the
260
+ scan: by quote parity, the next real block surfaces as a loud
261
+ `malformed_call` error (`invalid JSON` / `unterminated`) instead of
262
+ executing, and case-mismatched markers (e.g. `<<<tool_call>>>`) are plain
263
+ prose — the reply completes silently with no call. When choosing or prompting
264
+ a model, tell it to **never echo the markers outside an actual call**
265
+ (`tests/test_fix_23_parser_stray_marker_cliffs.py` pins the exact cliffs).
266
+
267
+ ## Policy schema (default-deny)
268
+
269
+ ```jsonc
270
+ {
271
+ "protected_paths": ["secrets/**", "*.pem", ".env"], // + non-removable .fullstop/**
272
+ "write_preapproved": ["notes/**", "memory.md"],
273
+ "shell": {"allow": ["git", // bare name: ONLY the bare invocation
274
+ {"program": "git", // constrained entry: program
275
+ "args": ["status"]}], // + exact/pattern ("*") arguments
276
+ "deny": ["rm"], "timeout_s": 30},
277
+ "web": {"allow_domains": ["arxiv.org"], "deny_domains": [], "timeout_s": 10,
278
+ "max_bytes": 1048576}
279
+ }
280
+ ```
281
+
282
+ **The note-is-a-write law:** `note` is a WRITE, gated exactly like
283
+ `file_write` against `memory.md`. It is DENIED when that file is protected,
284
+ pre-approved when it is listed in `write_preapproved`, and otherwise
285
+ APPROVAL_REQUIRED. Operators who want frictionless memory put `"memory.md"` in
286
+ `write_preapproved` — of the four shipped example policies, `scripted-demo`
287
+ and `research-assistant` preapprove it, `writer-approval` routes the memory
288
+ note to human approval (it asks once), and `research-readonly` protects
289
+ `memory.md` outright so the note is hard-denied. Proven by
290
+ `tests/test_policy_gate.py`.
291
+
292
+ **The no-arbitrary-arguments law:** an allowlist entry pre-approves a
293
+ program ONLY together with its argument constraints — a bare string entry
294
+ pre-approves just the bare invocation, a `{"program", "args"}` entry
295
+ pre-approves exactly matching argument patterns; anything else routes to
296
+ human approval. Programs are matched as resolved absolute paths (PATH-only,
297
+ `.exe`-pinned on Windows; the workspace cwd is never searched, and
298
+ `.bat`/`.cmd` targets are refused outright). Proven by
299
+ `tests/test_fix_07_shell_allowlist.py`.
300
+
301
+ ## The gate (the differentiator)
302
+
303
+ Every tool call is classified read-only vs consequential and decided by the
304
+ table in `fullstop/gate.py`:
305
+
306
+ | call | decision |
307
+ | --- | --- |
308
+ | `file_read` unprotected / `file_list` of an ordinary directory | ALLOW (read-only; protected entries such as `.fullstop/` are hidden from the listing) |
309
+ | `file_list` of a protected directory | **operator-approvable** |
310
+ | `file_read` protected (incl. `.fullstop/**`) | **operator-approvable** |
311
+ | `file_write` protected / denylisted shell / denylisted domain / `.fullstop` write | **hard DENY — approval never upgrades it** |
312
+ | `file_write` / `note` / `shell` / `web_fetch` / `browser` matching a pre-approval | ALLOW |
313
+ | everything else consequential | APPROVAL_REQUIRED → human prompt |
314
+
315
+ The gate matches the **literal** path spelling (a glob can pre-approve
316
+ `notes/../../escape.txt`); the sandbox chokepoint inside the tool is the
317
+ ordered backstop that kills it. Approvals only unlock APPROVAL_REQUIRED
318
+ outcomes; hard denials stay hard (`tests/test_policy_gate.py`). ALLOW decisions
319
+ carry a per-call token minted with a per-Gate secret; the tool registry
320
+ refuses to execute anything else (`fullstop/tools/base.py`), so a forged
321
+ decision from model output cannot reach a tool.
322
+
323
+ ## Security model — and its limits
324
+
325
+ Model:
326
+
327
+ - **Sandbox**: all file tools pass through `fullstop/sandbox.py` — absolute,
328
+ drive/UNC/ADS/colon, NUL, and `..` spellings are rejected; realpath +
329
+ commonpath containment; symlink aliases are blocked
330
+ (`tests/test_sandbox.py`).
331
+ - **Anti-aliasing**: writes additionally require resolved == literal path, so
332
+ symlinked/short-name/trailing-dot redirects of writes fail closed
333
+ (`tests/test_tools.py`).
334
+ - **Credentials**: manifests carry env var NAMES only; values are read at call
335
+ time, scrubbed (`[REDACTED:NAME]`) at every write layer, and removed from
336
+ child process environments (`tests/test_credentials.py`).
337
+ - **Tamper-evident log**: every line chains
338
+ `sha256(prev:canonical_json(line))`; appending onto a tampered tail fails
339
+ loudly (`tests/test_activity_log.py`). Tamper-evident, **not** tamper-proof —
340
+ an attacker with write access to the file can rebuild the whole chain.
341
+
342
+ Limits, stated plainly ("not yet" — untested or unimplemented):
343
+
344
+ - Shell children inherit the environment **minus the credential names** — full
345
+ environment isolation is not yet implemented.
346
+ - `web_fetch` has no JavaScript and no cookies. Redirects ARE followed, but
347
+ every hop is re-validated against the full domain allow/deny predicate
348
+ before it is requested (proven against a live loopback redirect chain in
349
+ `tests/test_fix_05_redirect_chain.py`); the ordinary live-network path is
350
+ otherwise exercised only by that test.
351
+ - **Context exhaustion**: message history grows without compaction in v0.1.
352
+ When the provider rejects a call for context length, or the estimated
353
+ context reaches `limits.max_context_chars`, the run stops in
354
+ `stopped_context` — surfaced again on resume, honestly, rather than
355
+ looping `failed` → resume → `failed`
356
+ (`tests/test_fix_03_context_exhaustion.py`).
357
+ - **Attended-only approvals**: when a consequential action needs approval
358
+ and no human can answer (no approver configured, or a non-tty console),
359
+ the run pauses in `stopped_approval` instead of spinning to `max_steps`;
360
+ it resumes once a human answers (`tests/test_fix_10_unattended_stop.py`).
361
+ - Live-model behavior (real internet model endpoints) is untested by the
362
+ suite — the suite is keyless by design, and its only network I/O is
363
+ loopback; the provider
364
+ adapter is tested against a fake opener plus a loopback mock HTTP
365
+ endpoint serving realistic error bodies (`tests/test_provider.py`,
366
+ `tests/test_r2_02_provider_context_body.py`).
367
+ - The write-path equality rule intentionally **fails closed** on
368
+ legal-but-unusual Windows spellings whose realpath differs from the literal
369
+ (8.3 short names, trailing dots and spaces). Blocking them is deliberate;
370
+ do not "repair" this.
371
+ - Protected **reads** are operator-approvable and, once approved, execute;
372
+ protected **writes** are unapprovable, full stop.
373
+
374
+ ## Deliberate deviation from the charter
375
+
376
+ `ScriptedModel` ships **in the package** (`fullstop/provider.py`), not only in
377
+ the test suite as CHARTER.md:15 imagined, because the keyless quickstart and
378
+ the smoke script need it. Tests import it from there. This is the only stated
379
+ deviation.
380
+
381
+ ## Development
382
+
383
+ ```bash
384
+ python -m unittest discover -s tests # the whole suite: zero keys, loopback-only tests
385
+ python scripts/smoke.py # DoD#2 smoke (keyless, socketless)
386
+ ```
387
+
388
+ CI (`.github/workflows/ci.yml`) is configured to run the same suite on
389
+ `ubuntu-latest` and `windows-latest` on every push — with no install step,
390
+ because the repo is stdlib-only.
391
+
392
+ ## Claim → demonstrating test
393
+
394
+ | Claim | Test |
395
+ | --- | --- |
396
+ | Zero third-party imports | `tests/test_imports.py` |
397
+ | Sandbox escapes blocked + logged | `tests/test_sandbox.py` |
398
+ | Credentials never logged/written | `tests/test_credentials.py` |
399
+ | Gate not bypassable by model output; approvals execute; hard denials stick | `tests/test_policy_gate.py` |
400
+ | Tamper-evident chained log | `tests/test_activity_log.py` |
401
+ | Strict protocol parsing | `tests/test_protocol.py` |
402
+ | Checkpoints atomic + scrubbed | `tests/test_state.py` |
403
+ | Tool semantics (incl. alias rules) | `tests/test_tools.py` |
404
+ | Provider adapter (key only in header) | `tests/test_provider.py` |
405
+ | Loop: guards, resume, event sequence | `tests/test_agent_loop.py` |
406
+ | CLI exit codes / JSON output | `tests/test_cli.py` |
407
+ | Shipped examples work as documented | `tests/test_examples.py` |
408
+ | Read-only example: zero prompts; both writes hard-denied; workspace untouched | `tests/test_examples.py` |
409
+ | Approval-gated writer example: exactly two prompts; protected write unapprovable | `tests/test_examples.py` |
410
+ | Quickstart: exactly one prompt, sandbox block, verified chain | `tests/test_examples.py` |
411
+ | Manifest/policy validation incl. relative resolution | `tests/test_manifest_policy.py` |
412
+ | Crash-mid-run resume: no double execution, no re-prompt of approved calls | `tests/test_fix_01_crash_resume.py` |
413
+ | Config anchored: in-sandbox config refused; hash mismatch blocks resume | `tests/test_fix_02_config_tamper.py` |
414
+ | Context exhaustion stops in `stopped_context` | `tests/test_fix_03_context_exhaustion.py` |
415
+ | `--goal` override reaches the system prompt | `tests/test_fix_04_goal_override.py` |
416
+ | Redirect hops re-validated per hop | `tests/test_fix_05_redirect_chain.py` |
417
+ | Approver prompts show the complete request | `tests/test_fix_06_approval_display.py` |
418
+ | Shell allowlist constrains arguments; PATH-resolved programs; batch refused | `tests/test_fix_07_shell_allowlist.py` |
419
+ | Log appends never re-read the file; rotation keeps the chain verifiable across segments | `tests/test_fix_08_activity_log_rotation.py` |
420
+ | `file_read` caps the read itself | `tests/test_fix_09_file_read_cap.py` |
421
+ | Unattended approval pauses in `stopped_approval` | `tests/test_fix_10_unattended_stop.py` |
422
+ | Redactor reads credentials at call time | `tests/test_fix_11_redactor_call_time.py` |
423
+ | `note` write protected like `file_write` (symlink-safe target) | `tests/test_fix_12_note_symlink.py` |
424
+ | `file_list` hides protected metadata without approval | `tests/test_fix_13_file_list_protected.py` |
425
+ | Approval-gated `web_fetch` logged as consequential | `tests/test_fix_14_gate_classification_label.py` |
426
+ | Tool output delimited per session (no forged observations) | `tests/test_fix_15_observation_delimiters.py` |
427
+ | `log_truncate_chars` reaches the activity log | `tests/test_fix_16_log_truncate_wiring.py` |
428
+ | README honesty (comparison vs announced Dots capabilities, non-goals, limits) | `tests/test_fix_17_19_readme_honesty.py` |
429
+ | Crash-resume replay-dedup holds for oversized and redacted args (raw-args digest) | `tests/test_r2_01_replay_dedup_keys.py` |
430
+ | Provider error bodies carried (size-capped); context 400 -> `stopped_context` via a mock HTTP endpoint | `tests/test_r2_02_provider_context_body.py` |
431
+ | Oversize log lines never wedge the tail scan; `log_truncate_chars` bounded below the tail window | `tests/test_r2_03_log_oversize_wedge.py` |
432
+ | `--allow-config-change` and the resume-with-edit flow documented | `tests/test_r2_04_readme_allow_config_change.py` |
433
+ | Error observations delimited per session, capped like ok-output | `tests/test_r2_05_error_observation_frame.py` |
434
+ | Resume of a pre-v0.1.2 system-only checkpoint seeds the goal from the persisted goal; no double seed | `tests/test_fix_22_resume_seed_guard.py` |
435
+ | Stray-marker cliffs pinned: bare mention → malformed + later block parses; quoted mention → unterminated; forged block inside an args string inert | `tests/test_fix_23_parser_stray_marker_cliffs.py` |
436
+ | Activity view: loopback-only bind, no host flag, Host allowlist + X-Fullstop-UI POST guards, decision-file-only write surface | `tests/test_ui_server.py` |
437
+ | Browser approvals: decision-file protocol outside the sandbox, full request on the card, forged id ignored, timeout → `stopped_approval` | `tests/test_ui_approver.py` |
438
+ | `run --ui` / `resume --ui` end-to-end over real loopback HTTP; `ui` subcommand serves until stopped | `tests/test_ui_cli.py` |
439
+ | UI read model: snapshot/history views and chain verify over the log | `tests/test_ui_model.py` |
440
+ | Wizard: real strict validators, verbatim field errors, new-file-only atomic save, env values never persisted | `tests/test_ui_wizard.py` |
441
+ | Front-end naming law (no prose in the assets) and inline-script breakout guard | `tests/test_ui_assets.py` |
442
+
443
+ ## License
444
+
445
+ MIT — see `LICENSE`.