synartesis 0.7.0 → 0.8.0
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 +32 -0
- package/README.md +9 -1
- 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,38 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.8.0 — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`expect: absent`, and `move_file` becomes undoable.** A pre-read normally
|
|
10
|
+
captures what a call is about to replace, so a read that finds nothing means
|
|
11
|
+
there is nothing to put back. For a few calls that is exactly backwards:
|
|
12
|
+
moving a file onto a free path is undone by moving it back, and it is finding
|
|
13
|
+
*something* there that puts the call beyond undo, because one inverse cannot
|
|
14
|
+
both move your file back and restore what it landed on.
|
|
15
|
+
|
|
16
|
+
Both halves were measured before anything was written. Declared plainly
|
|
17
|
+
`reversible`, the safe move came back `partial` with the file still moved —
|
|
18
|
+
the trivially reversible case was the one that could not be undone — and the
|
|
19
|
+
dangerous one came back `rolled_back` with the overwritten file gone, which
|
|
20
|
+
is the confident wrong undo this exists to prevent. `expect: absent` on the
|
|
21
|
+
snapshot swaps the two: finding nothing is reversible and the inverse runs;
|
|
22
|
+
finding something is held for a person and recorded with **no inverse**, so
|
|
23
|
+
undo says it cannot be undone rather than putting half of it back.
|
|
24
|
+
|
|
25
|
+
The shipped filesystem policy uses it, so moving a file to a fresh path is
|
|
26
|
+
now an ordinary undoable action instead of an approval prompt. A policy that
|
|
27
|
+
declares it on a non-`reversible` rule, on a `verify` read, or with an
|
|
28
|
+
inverse reading `$snapshot.` — which can never resolve, since nothing is
|
|
29
|
+
captured — is refused at load.
|
|
30
|
+
|
|
31
|
+
Worth recording: this server's own description of `move_file` says "If the
|
|
32
|
+
destination exists, the operation will fail." It does not; it renames over
|
|
33
|
+
the top, because that is what `rename(2)` does. Taking the documentation at
|
|
34
|
+
its word would have made the rule unconditionally reversible and the
|
|
35
|
+
overwrite both unrecoverable and unremarked.
|
|
36
|
+
|
|
5
37
|
## 0.7.0 — 2026-09-16
|
|
6
38
|
|
|
7
39
|
A pass over everything, after an audit of the desktop app, the core and the
|
package/README.md
CHANGED
|
@@ -156,7 +156,15 @@ Every tool gets one of four classifications, written down in a manifest:
|
|
|
156
156
|
|
|
157
157
|
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
158
158
|
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
159
|
-
worth avoiding most.
|
|
159
|
+
worth avoiding most. `synartesis check` names them, so you meet that decision
|
|
160
|
+
before your agent does.
|
|
161
|
+
|
|
162
|
+
A few calls are reversible only when nothing is in the way — moving a file onto
|
|
163
|
+
a free path is undone by moving it back, moving it onto an existing file
|
|
164
|
+
destroys what was there. For those, `expect: absent` on the pre-read swaps the
|
|
165
|
+
two: finding nothing is the reversible case, finding something is held for a
|
|
166
|
+
person and recorded with no inverse, so undo says it cannot be undone rather
|
|
167
|
+
than putting half of it back and calling that success.
|
|
160
168
|
|
|
161
169
|
## Has anybody touched it since?
|
|
162
170
|
|
|
@@ -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"
|