synartesis 0.7.0 → 0.8.1
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.
- package/CHANGELOG.md +63 -0
- package/README.md +181 -9
- package/SECURITY.md +2 -2
- package/dist/{chunk-FXSKWMNG.js → chunk-O3QDPYEL.js} +22 -4
- package/dist/cli.js +1 -1
- package/dist/proxy.js +34 -19
- package/manifests/filesystem.yaml +39 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.8.1 — 2026-09-19
|
|
6
|
+
|
|
7
|
+
Documentation only. No code changed, and the published package behaves exactly
|
|
8
|
+
as 0.8.0 did.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **The README is laid out as a page rather than a document.** Somebody
|
|
13
|
+
arriving from a link wants to know within one screen what this is, that it
|
|
14
|
+
works, and which of the four ways in is theirs. None of that was visible
|
|
15
|
+
without scrolling past four screens of prose. The top is now a centred hero,
|
|
16
|
+
a badge row and a contents bar, then **Choose your path** — four cards, one
|
|
17
|
+
per way in, each carrying the command that starts it — and a short list of
|
|
18
|
+
first results that take the least time.
|
|
19
|
+
|
|
20
|
+
- **Two things came out of hiding, in the README and on the site.** The
|
|
21
|
+
interactive screen was a footnote inside Install despite being the interface
|
|
22
|
+
most people will use, and there was nothing anywhere about writing a policy
|
|
23
|
+
for a server that does not ship with one — which is the case for every server
|
|
24
|
+
but four, and the contribution most worth asking for. Both are now sections
|
|
25
|
+
of their own: `synartesis.online/install.html#write-a-policy` covers `init`
|
|
26
|
+
drafting fail-closed and `check` refusing a policy that names a tool the
|
|
27
|
+
server no longer has.
|
|
28
|
+
|
|
29
|
+
- **A contributing section, and the star asked for out loud.** The README
|
|
30
|
+
explained the tool thoroughly and then stopped, with no issue link and
|
|
31
|
+
nothing a reader who agreed with it could do next.
|
|
32
|
+
|
|
33
|
+
- `SECURITY.md` said the 0.6.x line was the supported one, two minors after it
|
|
34
|
+
stopped being.
|
|
35
|
+
|
|
36
|
+
## 0.8.0 — 2026-09-16
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`expect: absent`, and `move_file` becomes undoable.** A pre-read normally
|
|
41
|
+
captures what a call is about to replace, so a read that finds nothing means
|
|
42
|
+
there is nothing to put back. For a few calls that is exactly backwards:
|
|
43
|
+
moving a file onto a free path is undone by moving it back, and it is finding
|
|
44
|
+
*something* there that puts the call beyond undo, because one inverse cannot
|
|
45
|
+
both move your file back and restore what it landed on.
|
|
46
|
+
|
|
47
|
+
Both halves were measured before anything was written. Declared plainly
|
|
48
|
+
`reversible`, the safe move came back `partial` with the file still moved —
|
|
49
|
+
the trivially reversible case was the one that could not be undone — and the
|
|
50
|
+
dangerous one came back `rolled_back` with the overwritten file gone, which
|
|
51
|
+
is the confident wrong undo this exists to prevent. `expect: absent` on the
|
|
52
|
+
snapshot swaps the two: finding nothing is reversible and the inverse runs;
|
|
53
|
+
finding something is held for a person and recorded with **no inverse**, so
|
|
54
|
+
undo says it cannot be undone rather than putting half of it back.
|
|
55
|
+
|
|
56
|
+
The shipped filesystem policy uses it, so moving a file to a fresh path is
|
|
57
|
+
now an ordinary undoable action instead of an approval prompt. A policy that
|
|
58
|
+
declares it on a non-`reversible` rule, on a `verify` read, or with an
|
|
59
|
+
inverse reading `$snapshot.` — which can never resolve, since nothing is
|
|
60
|
+
captured — is refused at load.
|
|
61
|
+
|
|
62
|
+
Worth recording: this server's own description of `move_file` says "If the
|
|
63
|
+
destination exists, the operation will fail." It does not; it renames over
|
|
64
|
+
the top, because that is what `rename(2)` does. Taking the documentation at
|
|
65
|
+
its word would have made the rule unconditionally reversible and the
|
|
66
|
+
overwrite both unrecoverable and unremarked.
|
|
67
|
+
|
|
5
68
|
## 0.7.0 — 2026-09-16
|
|
6
69
|
|
|
7
70
|
A pass over everything, after an audit of the desktop app, the core and the
|
package/README.md
CHANGED
|
@@ -1,27 +1,79 @@
|
|
|
1
1
|
<!-- Absolute URLs, not relative paths: this README is also the npm package
|
|
2
2
|
page, and npm does not resolve relative image paths against the repo. -->
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
3
5
|
[](https://synartesis.online)
|
|
4
6
|
|
|
7
|
+
**Give AI agents a way back.**
|
|
8
|
+
Synartesis sits between your MCP client and the servers it talks to, records
|
|
9
|
+
every tool call with the state that call replaced, and can put that state back.
|
|
10
|
+
What cannot be put back, it refuses to let an agent do unsupervised.
|
|
11
|
+
|
|
12
|
+
### [Install it, then read the five minutes ahead of you →](docs/synartesis-user-guide.md#the-five-minutes-ahead-of-you)
|
|
13
|
+
|
|
5
14
|
[](https://github.com/ArhaanDev24/Synartesis/actions/workflows/check.yml)
|
|
6
15
|
[](https://www.npmjs.com/package/synartesis)
|
|
7
16
|
[](https://www.npmjs.com/package/synartesis)
|
|
8
17
|
[](https://nodejs.org)
|
|
9
18
|
[](LICENSE)
|
|
19
|
+
[](https://synartesis.online)
|
|
20
|
+
[](https://github.com/ArhaanDev24/Synartesis)
|
|
21
|
+
|
|
22
|
+
[Choose your path](#choose-your-path) · [See it in action](#see-it-in-action) ·
|
|
23
|
+
[Install](#install) · [The screen](#the-screen) ·
|
|
24
|
+
[The desktop window](#the-desktop-window) ·
|
|
25
|
+
[The four classes](#what-it-can-and-cannot-do) ·
|
|
26
|
+
[Your own server](#writing-a-policy-for-your-own-server) ·
|
|
27
|
+
[Commands](#commands) · [What it does not do](#what-it-does-not-do) ·
|
|
28
|
+
[Trust](#trust) · [Contributing](#contributing)
|
|
29
|
+
|
|
30
|
+
</div>
|
|
10
31
|
|
|
11
32
|
An agent with write access to a real system runs twenty steps, misreads step
|
|
12
33
|
seven, and applies the rest to the wrong records. Today your options are to
|
|
13
34
|
reverse it by hand from the transcript, restore a backup and lose every
|
|
14
35
|
legitimate change made in the same window, or accept the damage.
|
|
15
36
|
|
|
16
|
-
Synartesis sits between your MCP client and the servers it talks to. It records
|
|
17
|
-
every tool call with the state that call replaced, and it can put that state
|
|
18
|
-
back. What cannot be put back, it refuses to let an agent do unsupervised.
|
|
19
|
-
|
|
20
37
|
It is not a sandbox: the container your agent runs in is disposable, but the
|
|
21
38
|
CRM row it updated over the network is not. It is not a tracing tool: a trace
|
|
22
39
|
tells you `update_customer` ran forty times, not what the values were before.
|
|
23
40
|
|
|
24
|
-
|
|
41
|
+
**If this is a problem you have, [a star](https://github.com/ArhaanDev24/Synartesis) helps other people find it.**
|
|
42
|
+
It is a young project, and that is most of how anybody learns it exists.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Choose your path
|
|
47
|
+
|
|
48
|
+
<table>
|
|
49
|
+
<tr>
|
|
50
|
+
<td width="50%"><a href="#install"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-install.png" alt="Cover what you already have — one command, every client. Finds what Claude Code, Claude Desktop, Cursor and Codex already list and points every entry at the proxy. $ synartesis install"></a></td>
|
|
51
|
+
<td width="50%"><a href="#the-screen"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-screen.png" alt="The screen — everything, on the arrow keys. What your agents have done, what is held for approval, and the undo for any of it. $ synartesis"></a></td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<td width="50%"><a href="#the-desktop-window"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-desktop.png" alt="The desktop window — the same engine, with a conversation. Talk to any model. Every tool it calls goes out through the proxy on its way. $ synartesis desktop"></a></td>
|
|
55
|
+
<td width="50%"><a href="#writing-a-policy-for-your-own-server"><img src="https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/brand/synartesis-card-policy.png" alt="Your own server — four ship with a policy, hundreds do not. Introspect a server, draft a manifest from it, then correct it where you know better. $ synartesis init crm -- ./crm-mcp"></a></td>
|
|
56
|
+
</tr>
|
|
57
|
+
</table>
|
|
58
|
+
|
|
59
|
+
**Your first result**, in the order they take the least time:
|
|
60
|
+
|
|
61
|
+
- **Undo a real write:** [run the filesystem demo](demo/filesystem-demo.sh) — it
|
|
62
|
+
makes a file, has an agent overwrite it, and puts it back, in about a minute
|
|
63
|
+
and without touching anything of yours.
|
|
64
|
+
- **Cover the agents on this machine:** [`synartesis install`](#install), then
|
|
65
|
+
`synartesis status` to see what is now covered and what is not.
|
|
66
|
+
- **Watch one happen:** [`synartesis watch`](#commands) prints each call as it
|
|
67
|
+
goes out, and lets you answer an approval where you are standing.
|
|
68
|
+
- **Ask what an agent already did:** [`synartesis show <id> --live`](#has-anybody-touched-it-since)
|
|
69
|
+
reads every resource a session touched, as it is now, and says which still match.
|
|
70
|
+
|
|
71
|
+
Nothing above sends anything anywhere. The journal is a SQLite file on your
|
|
72
|
+
machine, and the proxy talks only to the servers your policy names.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## See it in action
|
|
25
77
|
|
|
26
78
|
Both shots are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
|
|
27
79
|
pasted rather than typeset. An agent overwrote a file and tried to move another.
|
|
@@ -41,6 +93,9 @@ It stops at the record that moved and exits non-zero. Anything already put back
|
|
|
41
93
|
stays put back, and it prints the three ways on: leave it, restore the resource
|
|
42
94
|
and `--replan`, or `--force` to overwrite deliberately.
|
|
43
95
|
|
|
96
|
+
[The desktop window](#the-desktop-window) · [What it can and cannot do](#what-it-can-and-cannot-do) · [What it does not do](#what-it-does-not-do) · [User guide](docs/synartesis-user-guide.md)
|
|
97
|
+
|
|
98
|
+
---
|
|
44
99
|
## Install
|
|
45
100
|
|
|
46
101
|
```bash
|
|
@@ -67,6 +122,12 @@ installed.**
|
|
|
67
122
|
Needs Node 22 or newer. npm ships a prebuilt SQLite binding, so no toolchain is
|
|
68
123
|
required unless you build from a clone.
|
|
69
124
|
|
|
125
|
+
[User guide](docs/synartesis-user-guide.md#install-it) · [Claude Desktop](docs/synartesis-user-guide.md#claude-desktop) · [Claude Code](docs/synartesis-user-guide.md#claude-code) · [Any other client](docs/synartesis-user-guide.md#any-other-mcp-client)
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## The screen
|
|
130
|
+
|
|
70
131
|
Then just:
|
|
71
132
|
|
|
72
133
|
```bash
|
|
@@ -74,7 +135,17 @@ synartesis
|
|
|
74
135
|
```
|
|
75
136
|
|
|
76
137
|
One screen: what agents have done, what is held for approval, every AI on the
|
|
77
|
-
machine, and undo — all on the arrow keys.
|
|
138
|
+
machine, and undo — all on the arrow keys. `enter` opens a session, `u` undoes
|
|
139
|
+
it, `p` previews that undo without running it, `l` reads the world as it is now,
|
|
140
|
+
`f` expands every argument, `c` lists every AI on the machine, `g` shows what is
|
|
141
|
+
held. Nothing in it is a second implementation: it is the same journal and the
|
|
142
|
+
same planner the [commands](#commands) below use, so a session undone in the
|
|
143
|
+
screen and one undone in a script end the same way.
|
|
144
|
+
|
|
145
|
+
There is no mode in it that acts without telling you what it is about to do. An
|
|
146
|
+
undo shows you the plan first, and a held call shows you why it is held.
|
|
147
|
+
|
|
148
|
+
---
|
|
78
149
|
|
|
79
150
|
## The desktop window
|
|
80
151
|
|
|
@@ -143,6 +214,8 @@ want. The `release` workflow builds all three platforms on their own machines
|
|
|
143
214
|
and attaches the installers to the release for a tag. Both the window and the terminal share
|
|
144
215
|
one journal, so either can undo what the other did.
|
|
145
216
|
|
|
217
|
+
---
|
|
218
|
+
|
|
146
219
|
## What it can and cannot do
|
|
147
220
|
|
|
148
221
|
Every tool gets one of four classifications, written down in a manifest:
|
|
@@ -156,7 +229,54 @@ Every tool gets one of four classifications, written down in a manifest:
|
|
|
156
229
|
|
|
157
230
|
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
158
231
|
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
159
|
-
worth avoiding most.
|
|
232
|
+
worth avoiding most. `synartesis check` names them, so you meet that decision
|
|
233
|
+
before your agent does.
|
|
234
|
+
|
|
235
|
+
A few calls are reversible only when nothing is in the way — moving a file onto
|
|
236
|
+
a free path is undone by moving it back, moving it onto an existing file
|
|
237
|
+
destroys what was there. For those, `expect: absent` on the pre-read swaps the
|
|
238
|
+
two: finding nothing is the reversible case, finding something is held for a
|
|
239
|
+
person and recorded with no inverse, so undo says it cannot be undone rather
|
|
240
|
+
than putting half of it back and calling that success.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Writing a policy for your own server
|
|
245
|
+
|
|
246
|
+
Four servers ship with a policy. For anything else — a database, a ticketing
|
|
247
|
+
system, the MCP server you wrote last week — start by asking the server what it
|
|
248
|
+
has:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
synartesis init crm -- ./crm-mcp
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
That connects, lists every tool, and writes a manifest drafted from what the
|
|
255
|
+
server says about itself. If it recognises the server as one that already ships
|
|
256
|
+
with a policy, it adopts that one instead — but only when every tool the policy
|
|
257
|
+
calls is actually there, because a policy whose inverses cannot be called is
|
|
258
|
+
worse than a file of TODOs: it looks finished.
|
|
259
|
+
|
|
260
|
+
Drafted, not decided. A tool the server marks read-only is written `readonly`
|
|
261
|
+
with a comment telling you to check, since that hint is a statement of intent
|
|
262
|
+
and not a guarantee; everything else lands `irreversible` and gated. The draft
|
|
263
|
+
is fail-closed on purpose, and you open it up one tool at a time as you work
|
|
264
|
+
out how each of them is undone.
|
|
265
|
+
|
|
266
|
+
Then check it against the running server:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
synartesis check
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
It loads the manifest, connects to every server it names, and says which tools
|
|
273
|
+
your policy covers, which it does not, and where the two disagree. A policy
|
|
274
|
+
that names a tool the server no longer has is an error, not a warning.
|
|
275
|
+
|
|
276
|
+
[Writing a manifest](docs/synartesis-user-guide.md#open-the-file) · [The four classes](docs/synartesis-user-guide.md#the-four-classes) · [Checking it](docs/synartesis-user-guide.md#check-that-it-worked) · [Pinning a tool's shape](#when-the-server-changes-underneath-you)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
---
|
|
160
280
|
|
|
161
281
|
## Has anybody touched it since?
|
|
162
282
|
|
|
@@ -179,6 +299,8 @@ answers. `l` in the screen does the same.
|
|
|
179
299
|
If you decide the recorded value is the one worth keeping, `undo --force` prints
|
|
180
300
|
every line it would write over and stops; `--force --yes` goes ahead.
|
|
181
301
|
|
|
302
|
+
---
|
|
303
|
+
|
|
182
304
|
## What each policy has actually been tested against
|
|
183
305
|
|
|
184
306
|
A policy that has met a real server and one written from its documentation are
|
|
@@ -204,6 +326,8 @@ wrote yourself: the tool has no business grading your work. Nothing is inferred
|
|
|
204
326
|
from silence, and all three states are printed, because if silence meant "fine"
|
|
205
327
|
then an ungraded policy and a known-untested one would look identical.
|
|
206
328
|
|
|
329
|
+
---
|
|
330
|
+
|
|
207
331
|
## Undoing something that was never read first
|
|
208
332
|
|
|
209
333
|
Most undo rides on a pre-read: the value before the write is captured, and
|
|
@@ -237,6 +361,8 @@ It is consulted only where there is no read already, so it can never displace a
|
|
|
237
361
|
working pre-read with a differently shaped one — which would make the post-state
|
|
238
362
|
and the snapshot incomparable and every later comparison meaningless.
|
|
239
363
|
|
|
364
|
+
---
|
|
365
|
+
|
|
240
366
|
## When the server changes underneath you
|
|
241
367
|
|
|
242
368
|
A policy is a claim about what a tool does, and a tool's name is a weak place to
|
|
@@ -276,6 +402,8 @@ reads as protected and is not. Tools that no policy matches need no pin: they ar
|
|
|
276
402
|
already fail-closed as irreversible and gated, so there is no classification for a
|
|
277
403
|
schema change to corrupt.
|
|
278
404
|
|
|
405
|
+
---
|
|
406
|
+
|
|
279
407
|
## Commands
|
|
280
408
|
|
|
281
409
|
`synartesis desktop` opens [the window](#the-desktop-window), and says where to
|
|
@@ -301,8 +429,8 @@ get it if it is not installed.
|
|
|
301
429
|
| `prune` | Delete sessions older than 30 days and reclaim the space |
|
|
302
430
|
| `close [id]` | End a session a killed proxy left open |
|
|
303
431
|
|
|
304
|
-
|
|
305
|
-
|
|
432
|
+
Every one of those can also be done from [the screen](#the-screen), on the
|
|
433
|
+
arrow keys.
|
|
306
434
|
|
|
307
435
|
`--manifest` and `--journal` are found rather than typed, from the current
|
|
308
436
|
directory upwards the way a version control tool finds its root, then from
|
|
@@ -312,6 +440,8 @@ and `gates`. Exit codes: `0` succeeded, `1` halted or refused, `2` bad usage.
|
|
|
312
440
|
**Full walkthrough, writing a manifest, and serving over HTTP for clients that
|
|
313
441
|
cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
|
|
314
442
|
|
|
443
|
+
---
|
|
444
|
+
|
|
315
445
|
## What it does not do
|
|
316
446
|
|
|
317
447
|
- **It cannot un-send what has been seen.** An email that has been read, a
|
|
@@ -353,6 +483,8 @@ means an agent stopping mid-task on a call nobody expected. `synartesis check`
|
|
|
353
483
|
names every such tool, and the proxy warns about them at startup, so you can
|
|
354
484
|
write a policy before meeting one rather than after.
|
|
355
485
|
|
|
486
|
+
---
|
|
487
|
+
|
|
356
488
|
## Trust
|
|
357
489
|
|
|
358
490
|
A manifest names commands and Synartesis runs them. Treat one you did not write
|
|
@@ -378,6 +510,8 @@ or of the CLI mid-undo loses nothing; only the machine losing power can cost the
|
|
|
378
510
|
tail of the write-ahead log. `SYNARTESIS_SYNC=full` asks for an fsync per commit
|
|
379
511
|
instead — worth it where fsync is cheap, and measurably not where it is not.
|
|
380
512
|
|
|
513
|
+
---
|
|
514
|
+
|
|
381
515
|
## Development
|
|
382
516
|
|
|
383
517
|
```bash
|
|
@@ -397,6 +531,44 @@ is expected to work, the code has no platform-specific paths outside
|
|
|
397
531
|
`src/locate.ts` and `src/install/clients.ts`, and nobody has proved it. If you
|
|
398
532
|
run Windows and something is wrong there, that is worth an issue.
|
|
399
533
|
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Contributing
|
|
537
|
+
|
|
538
|
+
The most useful thing you can send is a policy. Four servers ship with one;
|
|
539
|
+
there are hundreds that do not. If you run one — a database, a ticketing
|
|
540
|
+
system, your own — `synartesis init <name> -- <command>` drafts a
|
|
541
|
+
manifest by introspecting it, and a pull request adding that draft under
|
|
542
|
+
[`manifests/`](manifests) makes the tool cover a server it could not cover
|
|
543
|
+
before. Say in the description whether you ran it against the real thing; that
|
|
544
|
+
is what `provenance:` records, and an honest `documented` is worth more than an
|
|
545
|
+
optimistic `live`.
|
|
546
|
+
|
|
547
|
+
Also wanted, in rough order of how much they help:
|
|
548
|
+
|
|
549
|
+
- **Windows.** The installer is built and never tested — see
|
|
550
|
+
[Development](#development). A report either way is worth an issue.
|
|
551
|
+
- **A case where undo got it wrong.** The failure this project cares about most
|
|
552
|
+
is a confident wrong answer. If undo told you it reverted something and it had
|
|
553
|
+
not, that is the bug report to open.
|
|
554
|
+
- **A server whose tools do not fit the four classes.** The model has held so
|
|
555
|
+
far, and the first case it cannot express is worth knowing about.
|
|
556
|
+
|
|
557
|
+
[`CONTRIBUTING.md`](CONTRIBUTING.md) has the setup, and one command runs the
|
|
558
|
+
same gate a pull request has to pass:
|
|
559
|
+
|
|
560
|
+
```bash
|
|
561
|
+
pnpm check
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Bugs and questions go in [issues](https://github.com/ArhaanDev24/Synartesis/issues).
|
|
565
|
+
Security reports do not — [`SECURITY.md`](SECURITY.md) says where those go
|
|
566
|
+
instead. And if it saved you an afternoon, **[star the
|
|
567
|
+
repo](https://github.com/ArhaanDev24/Synartesis)**; it costs you a click and is
|
|
568
|
+
most of how a project this size gets found.
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
400
572
|
## Licence
|
|
401
573
|
|
|
402
574
|
MIT. See [LICENSE](LICENSE).
|
package/SECURITY.md
CHANGED
|
@@ -1155,7 +1155,11 @@ var templateValue = z2.lazy(
|
|
|
1155
1155
|
var callTemplate = z2.strictObject({
|
|
1156
1156
|
tool: z2.string().min(1),
|
|
1157
1157
|
args: z2.record(z2.string(), templateValue).default({}),
|
|
1158
|
-
absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional()
|
|
1158
|
+
absent_when: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).optional(),
|
|
1159
|
+
// Only `absent` for now. `present` would mean "refuse unless something is
|
|
1160
|
+
// already here", which is a different feature nobody has asked for, and a
|
|
1161
|
+
// value with no meaning behind it is worse than one that is missing.
|
|
1162
|
+
expect: z2.literal("absent").optional()
|
|
1159
1163
|
});
|
|
1160
1164
|
var toolPolicy = z2.strictObject({
|
|
1161
1165
|
match: z2.string().min(1),
|
|
@@ -1304,12 +1308,25 @@ function validate(source, manifest) {
|
|
|
1304
1308
|
if (policy.class === "readonly" && policy.snapshot !== void 0) {
|
|
1305
1309
|
source.fail([...path, "snapshot"], "a readonly tool must not declare a snapshot");
|
|
1306
1310
|
}
|
|
1311
|
+
const expectsAbsent = policy.snapshot?.expect === "absent";
|
|
1312
|
+
if (policy.verify?.expect !== void 0) {
|
|
1313
|
+
source.fail(
|
|
1314
|
+
[...path, "verify", "expect"],
|
|
1315
|
+
"expect belongs on a snapshot; a verify read runs after the call, when there is nothing left to expect"
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
if (expectsAbsent && policy.class !== "reversible") {
|
|
1319
|
+
source.fail(
|
|
1320
|
+
[...path, "snapshot", "expect"],
|
|
1321
|
+
`expect: absent says this call is reversible exactly when the read finds nothing, which only means something for a reversible tool, not a ${policy.class} one`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1307
1324
|
if (policy.snapshot !== void 0) {
|
|
1308
1325
|
checkCall(source, [...path, "snapshot"], policy.snapshot, servers, ["$."]);
|
|
1309
1326
|
}
|
|
1310
1327
|
if (policy.inverse !== void 0) {
|
|
1311
1328
|
const allowed = ["$.", "$result."];
|
|
1312
|
-
if (policy.snapshot !== void 0) {
|
|
1329
|
+
if (policy.snapshot !== void 0 && !expectsAbsent) {
|
|
1313
1330
|
allowed.push("$snapshot.");
|
|
1314
1331
|
}
|
|
1315
1332
|
checkCall(source, [...path, "inverse"], policy.inverse, servers, allowed);
|
|
@@ -1329,7 +1346,8 @@ function withGate(policy) {
|
|
|
1329
1346
|
args: call.args,
|
|
1330
1347
|
...call.absent_when === void 0 ? {} : {
|
|
1331
1348
|
absentWhen: typeof call.absent_when === "string" ? [call.absent_when] : [...call.absent_when]
|
|
1332
|
-
}
|
|
1349
|
+
},
|
|
1350
|
+
...call.expect === void 0 ? {} : { expect: call.expect }
|
|
1333
1351
|
});
|
|
1334
1352
|
const gate = policy.gate ?? (policy.class === "irreversible" ? "always" : "never");
|
|
1335
1353
|
return {
|
|
@@ -1993,4 +2011,4 @@ export {
|
|
|
1993
2011
|
observeState,
|
|
1994
2012
|
connectStdioUpstream
|
|
1995
2013
|
};
|
|
1996
|
-
//# sourceMappingURL=chunk-
|
|
2014
|
+
//# sourceMappingURL=chunk-O3QDPYEL.js.map
|
package/dist/cli.js
CHANGED
package/dist/proxy.js
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
verifyAgainstServers,
|
|
26
26
|
warnUntested,
|
|
27
27
|
withIdempotencyKey
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-O3QDPYEL.js";
|
|
29
29
|
import {
|
|
30
30
|
SnapshotError,
|
|
31
31
|
UpstreamError,
|
|
@@ -709,18 +709,39 @@ function createProxyServer(options) {
|
|
|
709
709
|
let snapshot;
|
|
710
710
|
let verify;
|
|
711
711
|
let probe;
|
|
712
|
-
let
|
|
712
|
+
let noWayBack;
|
|
713
|
+
let foundNothing = false;
|
|
714
|
+
const expectsAbsent = policy.snapshot?.expect === "absent";
|
|
713
715
|
if (policy.snapshot !== void 0) {
|
|
714
716
|
try {
|
|
715
717
|
verify = planRead(policy.snapshot, { args });
|
|
716
718
|
probe = verify;
|
|
717
719
|
snapshot = await runRead(router, verify, extra.signal);
|
|
718
|
-
|
|
720
|
+
if (expectsAbsent) {
|
|
721
|
+
noWayBack = {
|
|
722
|
+
asked: `something is already there, so this cannot be undone \u2014 putting back what this call moves would leave nothing where the old contents were`,
|
|
723
|
+
recorded: `the pre-read expected nothing and found something, so this call overwrote it and no single inverse can put both back`
|
|
724
|
+
};
|
|
725
|
+
verify = void 0;
|
|
726
|
+
} else {
|
|
727
|
+
journal.attachSnapshot(pending.actionId, snapshot);
|
|
728
|
+
}
|
|
719
729
|
} catch (error) {
|
|
720
730
|
const reason = describe(error);
|
|
721
731
|
if (error instanceof SnapshotError && error.absent) {
|
|
722
|
-
|
|
723
|
-
|
|
732
|
+
foundNothing = true;
|
|
733
|
+
if (expectsAbsent) {
|
|
734
|
+
log?.debug(
|
|
735
|
+
{ seq: pending.seq, tool: route.tool },
|
|
736
|
+
"pre-read found nothing, which is what makes this reversible"
|
|
737
|
+
);
|
|
738
|
+
} else {
|
|
739
|
+
noWayBack = {
|
|
740
|
+
asked: `nothing was captured to restore, so this cannot be undone \u2014 the read said: ${reason}`,
|
|
741
|
+
recorded: `no prior state existed, so there is nothing to restore: ${reason}`
|
|
742
|
+
};
|
|
743
|
+
verify = void 0;
|
|
744
|
+
}
|
|
724
745
|
} else {
|
|
725
746
|
journal.markFailed(pending.actionId, reason);
|
|
726
747
|
log?.error(
|
|
@@ -734,8 +755,8 @@ function createProxyServer(options) {
|
|
|
734
755
|
}
|
|
735
756
|
}
|
|
736
757
|
}
|
|
737
|
-
const priorState = probe === void 0 ||
|
|
738
|
-
if (
|
|
758
|
+
const priorState = probe === void 0 || foundNothing ? { present: false } : { present: true, value: snapshot };
|
|
759
|
+
if (noWayBack !== void 0 && !askedAlready) {
|
|
739
760
|
const standing = journal.findApproval({
|
|
740
761
|
server: route.upstream.name,
|
|
741
762
|
tool: route.tool,
|
|
@@ -743,18 +764,14 @@ function createProxyServer(options) {
|
|
|
743
764
|
notBefore: new Date(Date.now() - APPROVAL_WINDOW_MS).toISOString()
|
|
744
765
|
});
|
|
745
766
|
if (standing === void 0) {
|
|
746
|
-
await decide(
|
|
747
|
-
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
748
|
-
);
|
|
767
|
+
await decide(noWayBack.asked);
|
|
749
768
|
} else if (journal.adoptApproval(pending.actionId, standing)) {
|
|
750
769
|
log?.info(
|
|
751
770
|
{ action: pending.actionId, by: standing.approvedBy, from: standing.runId },
|
|
752
771
|
"proceeding on a standing approval"
|
|
753
772
|
);
|
|
754
773
|
} else {
|
|
755
|
-
await decide(
|
|
756
|
-
`nothing was captured to restore, so this cannot be undone \u2014 the read said: ${missingPriorState}`
|
|
757
|
-
);
|
|
774
|
+
await decide(noWayBack.asked);
|
|
758
775
|
}
|
|
759
776
|
}
|
|
760
777
|
const forwarded = {
|
|
@@ -800,13 +817,11 @@ function createProxyServer(options) {
|
|
|
800
817
|
if (inferred !== void 0) {
|
|
801
818
|
warnings.push(inferred);
|
|
802
819
|
}
|
|
803
|
-
if (
|
|
804
|
-
warnings.push(
|
|
805
|
-
`no prior state existed, so there is nothing to restore: ${missingPriorState}`
|
|
806
|
-
);
|
|
820
|
+
if (noWayBack !== void 0) {
|
|
821
|
+
warnings.push(noWayBack.recorded);
|
|
807
822
|
}
|
|
808
823
|
let inverse;
|
|
809
|
-
if (policy.inverse !== void 0 &&
|
|
824
|
+
if (policy.inverse !== void 0 && noWayBack === void 0) {
|
|
810
825
|
try {
|
|
811
826
|
inverse = planInverse(policy.inverse, context);
|
|
812
827
|
} catch (error) {
|
|
@@ -857,7 +872,7 @@ function createProxyServer(options) {
|
|
|
857
872
|
const recovered = recoverInverse(
|
|
858
873
|
policy,
|
|
859
874
|
{ args, snapshot },
|
|
860
|
-
|
|
875
|
+
noWayBack !== void 0
|
|
861
876
|
);
|
|
862
877
|
journal.markApplied(pending.actionId, {
|
|
863
878
|
result: void 0,
|
|
@@ -88,16 +88,46 @@ tools:
|
|
|
88
88
|
path: "$.path"
|
|
89
89
|
content: "$snapshot.content"
|
|
90
90
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
91
|
+
# Reversible exactly when the destination did not exist, which is what
|
|
92
|
+
# `expect: absent` says.
|
|
93
|
+
#
|
|
94
|
+
# Moving onto a path where nothing is, is undone by moving the file back:
|
|
95
|
+
# the state this replaces is absence, and moving it off restores absence
|
|
96
|
+
# exactly. Moving onto a file that does exist overwrites it, and one inverse
|
|
97
|
+
# cannot both move the file back and restore what it landed on -- undo would
|
|
98
|
+
# report success over a file it had destroyed.
|
|
99
|
+
#
|
|
100
|
+
# Both cases were gated before, because the machinery read "the pre-read
|
|
101
|
+
# found nothing" as "there is nothing to put back" -- true of a write, and
|
|
102
|
+
# precisely backwards here, where finding nothing is the safe case. Measured
|
|
103
|
+
# both ways against the real server: as a plain reversible rule the safe move
|
|
104
|
+
# came back `partial` with the file still moved, and the dangerous one came
|
|
105
|
+
# back `rolled_back` with the overwritten file gone.
|
|
106
|
+
#
|
|
107
|
+
# And the pre-read is load-bearing, not belt and braces. This server's own
|
|
108
|
+
# description of move_file says "If the destination exists, the operation
|
|
109
|
+
# will fail" -- it does not. It renames over the top, silently, because
|
|
110
|
+
# that is what rename(2) does on POSIX. Taking the description at its word
|
|
111
|
+
# would have made this rule unconditionally reversible and the overwrite
|
|
112
|
+
# unrecoverable and unremarked. tests/expect-absent.test.ts pins the real
|
|
113
|
+
# behaviour so a version that starts matching its documentation is a
|
|
114
|
+
# failing test rather than a surprise.
|
|
98
115
|
- match: "fs.move_file"
|
|
99
|
-
class:
|
|
100
|
-
|
|
116
|
+
class: reversible
|
|
117
|
+
snapshot:
|
|
118
|
+
tool: "fs.read_text_file"
|
|
119
|
+
args:
|
|
120
|
+
path: "$.destination"
|
|
121
|
+
# Same reasoning as the two writes above: anything that is not absence
|
|
122
|
+
# is a failed read, not an empty destination, and a destination that
|
|
123
|
+
# exists but cannot be read must not be treated as a free space.
|
|
124
|
+
absent_when: ["ENOENT", "no such file"]
|
|
125
|
+
expect: absent
|
|
126
|
+
inverse:
|
|
127
|
+
tool: "fs.move_file"
|
|
128
|
+
args:
|
|
129
|
+
source: "$.destination"
|
|
130
|
+
destination: "$.source"
|
|
101
131
|
|
|
102
132
|
# No rmdir exists on this server, so a directory once created stays.
|
|
103
133
|
- match: "fs.create_directory"
|