kotonoha 0.4.1 → 0.5.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 +50 -1
- package/README-ja.md +108 -18
- package/README.md +155 -23
- package/bin/kotonoha.js +64 -12
- package/package.json +7 -3
- package/skills/obsidian-bases/SKILL.md +140 -0
- package/skills/obsidian-bases/assets/templates/cards.base +8 -0
- package/skills/obsidian-bases/assets/templates/kanban.base +7 -0
- package/skills/obsidian-bases/assets/templates/list.base +8 -0
- package/skills/obsidian-bases/assets/templates/table.base +10 -0
- package/skills/obsidian-bases/references/execution-contract.json +246 -0
- package/skills/obsidian-bases/references/workflow.md +159 -0
- package/skills/obsidian-bases/scripts/check.cjs +690 -0
- package/skills/obsidian-bases/scripts/package-lock.json +104 -0
- package/skills/obsidian-bases/scripts/package.json +18 -0
- package/skills/obsidian-bases/scripts/verify.cjs +454 -0
- package/skills/obsidian-canvas/SKILL.md +134 -0
- package/skills/obsidian-canvas/assets/templates/comparison.canvas +11 -0
- package/skills/obsidian-canvas/assets/templates/mind-map.canvas +11 -0
- package/skills/obsidian-canvas/assets/templates/note-map.canvas +10 -0
- package/skills/obsidian-canvas/assets/templates/workflow.canvas +11 -0
- package/skills/obsidian-canvas/references/execution-contract.json +404 -0
- package/skills/obsidian-canvas/references/workflow.md +189 -0
- package/skills/obsidian-canvas/scripts/canvas.py +1479 -0
- package/skills/obsidian-canvas/scripts/canvas.py.lock +143 -0
- package/skills/obsidian-canvas/scripts/test_canvas.py +2047 -0
- package/skills/obsidian-canvas/scripts/test_canvas.py.lock +143 -0
- package/skills/obsidian-vault/SKILL.md +149 -93
- package/skills/obsidian-vault/references/workflow.md +61 -4
- package/skills/obsidian-vault/scripts/test_vault.py +76 -0
- package/skills/obsidian-vault/scripts/vault.py +118 -71
- package/skills/tech-writer/SKILL.md +26 -36
- package/skills/tech-writer/references/japanese-prose-optimization.md +3 -1
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = "==3.12.*"
|
|
4
|
+
# dependencies = ["markdown-it-py>=3,<5", "PyYAML>=6,<7", "jsonschema>=4,<5"]
|
|
5
|
+
# ///
|
|
6
|
+
"""Scope-bound Canvas diagnostics and explicitly approved single-file transactions."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
import copy
|
|
11
|
+
import errno
|
|
12
|
+
import fcntl
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import ipaddress
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import posixpath
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import sys
|
|
24
|
+
from urllib.parse import unquote, urlsplit
|
|
25
|
+
|
|
26
|
+
from jsonschema import Draft202012Validator, ValidationError
|
|
27
|
+
|
|
28
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
29
|
+
CONTROL_MAX = 1048576
|
|
30
|
+
SAFE = 9007199254740991
|
|
31
|
+
CHECKS = ("actors", "negation", "conditions", "modality", "scope", "causality",
|
|
32
|
+
"quantity_bindings", "terminology", "structure")
|
|
33
|
+
PREFIXES = {"evidence": "[根拠あり] ", "proposal": "[提案] ", "unverified": "[未確認] "}
|
|
34
|
+
COMMON = {"id", "type", "x", "y", "width", "height", "color"}
|
|
35
|
+
EXTRA = {"text": {"text"}, "file": {"file", "subpath"}, "link": {"url"},
|
|
36
|
+
"group": {"label", "background", "backgroundStyle"}}
|
|
37
|
+
EDGE = {"id", "fromNode", "toNode", "fromSide", "toSide", "fromEnd", "toEnd", "color", "label"}
|
|
38
|
+
SEMANTIC = {"text", "file", "subpath", "url", "label", "background",
|
|
39
|
+
"fromNode", "toNode", "fromEnd", "toEnd"}
|
|
40
|
+
VIRTUAL = {"nodes_presence", "edges_presence", "nodes_order", "edges_order"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Failure(Exception):
|
|
44
|
+
def __init__(self, code, exit_code=2, *, path=None):
|
|
45
|
+
self.code, self.exit_code = code, exit_code
|
|
46
|
+
self.path = path
|
|
47
|
+
super().__init__(code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Number(str):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def digest(data):
|
|
55
|
+
return hashlib.sha256(data).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canonical(value):
|
|
59
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
60
|
+
separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def strict_json(data, *, numbers=False):
|
|
64
|
+
def pairs(items):
|
|
65
|
+
result = {}
|
|
66
|
+
for key, value in items:
|
|
67
|
+
if key in result:
|
|
68
|
+
raise ValueError("duplicate_key")
|
|
69
|
+
result[key] = value
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def constant(_):
|
|
73
|
+
raise ValueError("nonfinite_number")
|
|
74
|
+
|
|
75
|
+
def finite(raw):
|
|
76
|
+
value = float(raw)
|
|
77
|
+
if not math.isfinite(value):
|
|
78
|
+
raise ValueError("nonfinite_number")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
text = data.decode("utf-8")
|
|
82
|
+
if text.startswith("\ufeff"):
|
|
83
|
+
raise ValueError("utf8_bom")
|
|
84
|
+
result = json.loads(text, object_pairs_hook=pairs, parse_constant=constant,
|
|
85
|
+
**({"parse_int": Number, "parse_float": Number} if numbers else {"parse_float": finite}))
|
|
86
|
+
if not numbers:
|
|
87
|
+
pending = [result]
|
|
88
|
+
while pending:
|
|
89
|
+
item = pending.pop()
|
|
90
|
+
if isinstance(item, str) and any(0xD800 <= ord(c) <= 0xDFFF for c in item):
|
|
91
|
+
raise ValueError("unicode_scalar")
|
|
92
|
+
if isinstance(item, dict):
|
|
93
|
+
pending.extend(item.keys())
|
|
94
|
+
pending.extend(item.values())
|
|
95
|
+
elif isinstance(item, list):
|
|
96
|
+
pending.extend(item)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
CONTRACT_BYTES = (SKILL / "references/execution-contract.json").read_bytes()
|
|
101
|
+
CONTRACT = strict_json(CONTRACT_BYTES)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def validate(value, definition):
|
|
105
|
+
Draft202012Validator({"$ref": f"#/$defs/{definition}",
|
|
106
|
+
"$defs": CONTRACT["$defs"]}).validate(value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def issue(code, path=None, entity="document", id=None, field=None, related_ids=None,
|
|
110
|
+
category=None):
|
|
111
|
+
if category is None:
|
|
112
|
+
category = next(k for k, v in CONTRACT["x-diagnostic-codes"].items() if code in v)
|
|
113
|
+
return dict(path=path, category=category, code=code, entity=entity, id=id,
|
|
114
|
+
field=field, related_ids=related_ids or [])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_module(path, name):
|
|
118
|
+
try:
|
|
119
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
120
|
+
module = importlib.util.module_from_spec(spec)
|
|
121
|
+
spec.loader.exec_module(module)
|
|
122
|
+
return module
|
|
123
|
+
except (OSError, ImportError, AttributeError):
|
|
124
|
+
raise Failure("dependency_missing", 4) from None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def backend():
|
|
128
|
+
module = load_module(SKILL.parent / "obsidian-vault/scripts/vault.py", "canvas_storage")
|
|
129
|
+
if (getattr(module, "STORAGE_API_VERSION", None) != 1
|
|
130
|
+
or not all(callable(getattr(getattr(module, "Vault", None), name, None)) for name in
|
|
131
|
+
("read_authorized", "stat_authorized", "enumerate_authorized"))
|
|
132
|
+
or not all(callable(getattr(module, name, None)) for name in (
|
|
133
|
+
"commit_bytes", "directory", "identity", "metadata_supported", "write_capabilities",
|
|
134
|
+
"local_filesystem", "diff_bytes", "parse_note", "masked_body", "extract_links"))):
|
|
135
|
+
raise Failure("dependency_missing", 4)
|
|
136
|
+
return module
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def gate():
|
|
140
|
+
module = load_module(SKILL.parent / "japanese-prose/scripts/invariants.py", "canvas_gate")
|
|
141
|
+
if (tuple(getattr(module, "SEMANTIC_CHECKS", ())) != CHECKS
|
|
142
|
+
or not callable(getattr(module, "check_bytes", None))):
|
|
143
|
+
raise Failure("dependency_missing", 4)
|
|
144
|
+
return module
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def integer(number):
|
|
148
|
+
"""Classify the decimal lexeme without exponent-sized allocations or float rounding."""
|
|
149
|
+
if isinstance(number, bool) or not isinstance(number, (Number, int, float)):
|
|
150
|
+
return None, "invalid_type"
|
|
151
|
+
raw = str(number).lower()
|
|
152
|
+
match = re.fullmatch(r"(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?", raw)
|
|
153
|
+
if not match:
|
|
154
|
+
return None, "invalid_type"
|
|
155
|
+
sign, whole, fraction, exponent = match.groups()
|
|
156
|
+
fraction = fraction or ""
|
|
157
|
+
digits = (whole + fraction).lstrip("0")
|
|
158
|
+
if not digits:
|
|
159
|
+
return 0, None
|
|
160
|
+
exp = (exponent or "0").lstrip("+")
|
|
161
|
+
magnitude = exp.lstrip("-").lstrip("0") or "0"
|
|
162
|
+
# Only a shift within the input's length can produce a small integer.
|
|
163
|
+
if len(magnitude) > len(str(len(raw) + 32)):
|
|
164
|
+
return None, "invalid_type" if exp.startswith("-") else "integer_range"
|
|
165
|
+
shift = int(exp) - len(fraction)
|
|
166
|
+
if shift < 0:
|
|
167
|
+
if -shift > len(digits) or any(c != "0" for c in digits[shift:]):
|
|
168
|
+
return None, "invalid_type"
|
|
169
|
+
digits = digits[:shift]
|
|
170
|
+
elif len(digits) + shift > 16:
|
|
171
|
+
return None, "integer_range"
|
|
172
|
+
else:
|
|
173
|
+
digits += "0" * shift
|
|
174
|
+
digits = digits.lstrip("0") or "0"
|
|
175
|
+
if len(digits) > 16 or len(digits) == 16 and digits > str(SAFE):
|
|
176
|
+
return None, "integer_range"
|
|
177
|
+
return (-1 if sign else 1) * int(digits), None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def request_json(data):
|
|
181
|
+
holder = [strict_json(data, numbers=True)]
|
|
182
|
+
pending = [(holder, 0)]
|
|
183
|
+
while pending:
|
|
184
|
+
parent, key = pending.pop()
|
|
185
|
+
value = parent[key]
|
|
186
|
+
if isinstance(value, Number):
|
|
187
|
+
value, error = integer(value)
|
|
188
|
+
if error:
|
|
189
|
+
raise ValueError("invalid_input")
|
|
190
|
+
parent[key] = value
|
|
191
|
+
elif isinstance(value, str):
|
|
192
|
+
if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
|
|
193
|
+
raise ValueError("invalid_input")
|
|
194
|
+
elif isinstance(value, dict):
|
|
195
|
+
if any(any(0xD800 <= ord(c) <= 0xDFFF for c in field) for field in value):
|
|
196
|
+
raise ValueError("invalid_input")
|
|
197
|
+
pending.extend((value, field) for field in value)
|
|
198
|
+
elif isinstance(value, list):
|
|
199
|
+
pending.extend((value, index) for index in range(len(value)))
|
|
200
|
+
return holder[0]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def forbidden(path, *, parents=False):
|
|
204
|
+
if (not isinstance(path, str) or not path or path.startswith(("/", "\\"))
|
|
205
|
+
or "\\" in path or any(ord(c) < 32 or ord(c) == 127 for c in path)
|
|
206
|
+
or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", path)):
|
|
207
|
+
return True
|
|
208
|
+
parts = path.split("/")
|
|
209
|
+
return (any(p.lower() in {".git", ".obsidian"} for p in parts)
|
|
210
|
+
or not parents and ".." in parts)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def safe_path(path, *, dot=False):
|
|
214
|
+
if forbidden(path):
|
|
215
|
+
raise Failure("scope_refused")
|
|
216
|
+
normalized = posixpath.normpath(path)
|
|
217
|
+
if normalized == "." and not dot:
|
|
218
|
+
raise Failure("scope_refused")
|
|
219
|
+
return normalized
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def valid_url(raw):
|
|
223
|
+
try:
|
|
224
|
+
url = urlsplit(raw)
|
|
225
|
+
host = url.hostname
|
|
226
|
+
if (url.scheme.lower() not in {"http", "https"} or not host
|
|
227
|
+
or "\\" in raw or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in raw)):
|
|
228
|
+
return False
|
|
229
|
+
_ = url.port
|
|
230
|
+
try:
|
|
231
|
+
ipaddress.ip_address(host)
|
|
232
|
+
except ValueError:
|
|
233
|
+
ascii_host = host.encode("idna").decode("ascii").removesuffix(".")
|
|
234
|
+
labels = ascii_host.split(".")
|
|
235
|
+
if (len(ascii_host) > 253 or len(labels) == 4 and all(label.isdigit() for label in labels)
|
|
236
|
+
or not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
237
|
+
for label in labels)):
|
|
238
|
+
return False
|
|
239
|
+
return True
|
|
240
|
+
except (ValueError, UnicodeError):
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def text_links(storage, text):
|
|
245
|
+
masked, _, _ = storage.masked_body(text)
|
|
246
|
+
internal, _, all_raw = storage.extract_links(text, masked, 0)
|
|
247
|
+
remaining = iter(internal)
|
|
248
|
+
next_internal = next(remaining, None)
|
|
249
|
+
records, occupied, position = [], [], 0
|
|
250
|
+
for raw in all_raw:
|
|
251
|
+
start = text.find(raw, position)
|
|
252
|
+
while start >= 0 and masked[start:start + 1] != text[start:start + 1]:
|
|
253
|
+
start = text.find(raw, start + 1)
|
|
254
|
+
position = start + len(raw)
|
|
255
|
+
occupied.append((start, position))
|
|
256
|
+
if next_internal is not None and next_internal["raw"] == raw:
|
|
257
|
+
records.append((start, next_internal))
|
|
258
|
+
next_internal = next(remaining, None)
|
|
259
|
+
continue
|
|
260
|
+
wiki = re.fullmatch(r"!?\[\[(.*?)\]\]", raw)
|
|
261
|
+
if wiki:
|
|
262
|
+
destination = wiki[1].replace("\\|", "|").split("|", 1)[0]
|
|
263
|
+
else:
|
|
264
|
+
children = storage.MARKDOWN.parseInline(raw)[0].children or []
|
|
265
|
+
target = next((token for token in children if token.type in {"link_open", "image"}), None)
|
|
266
|
+
destination = target.attrGet("href" if target.type == "link_open" else "src") if target else ""
|
|
267
|
+
records.append((start, {"raw": raw, "syntax": "external", "destination": destination}))
|
|
268
|
+
for match in re.finditer(r"https?://[^\s<>]+", masked, re.I):
|
|
269
|
+
if not any(start <= match.start() < end for start, end in occupied):
|
|
270
|
+
records.append((match.start(), {"raw": text[match.start():match.end()],
|
|
271
|
+
"syntax": "external", "destination": text[match.start():match.end()]}))
|
|
272
|
+
return [link for _, link in sorted(records, key=lambda item: item[0])]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def contains(group, node):
|
|
276
|
+
return (group["width"] > 0 and group["height"] > 0
|
|
277
|
+
and node["width"] > 0 and node["height"] > 0
|
|
278
|
+
and group["x"] <= node["x"] and group["y"] <= node["y"]
|
|
279
|
+
and group["x"] + group["width"] >= node["x"] + node["width"]
|
|
280
|
+
and group["y"] + group["height"] >= node["y"] + node["height"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def intersects(a, b):
|
|
284
|
+
return (min(a["width"], a["height"], b["width"], b["height"]) > 0
|
|
285
|
+
and a["x"] < b["x"] + b["width"] and b["x"] < a["x"] + a["width"]
|
|
286
|
+
and a["y"] < b["y"] + b["height"] and b["y"] < a["y"] + a["height"])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def prefix(value):
|
|
290
|
+
if not isinstance(value, str):
|
|
291
|
+
return None
|
|
292
|
+
for category, marker in PREFIXES.items():
|
|
293
|
+
if value.startswith(marker):
|
|
294
|
+
rest = value[len(marker):]
|
|
295
|
+
if not rest.startswith(" ") and not any(rest.startswith(other.rstrip()) for other in PREFIXES.values()):
|
|
296
|
+
return category
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def effective(document):
|
|
301
|
+
nodes = document.get("nodes", [])
|
|
302
|
+
by_id = {n["id"]: n for n in nodes}
|
|
303
|
+
node_positions = {n["id"]: i for i, n in enumerate(nodes)}
|
|
304
|
+
groups = [(i, n) for i, n in enumerate(nodes) if n["type"] == "group"]
|
|
305
|
+
result, carriers = {}, {}
|
|
306
|
+
for entity, objects in (("node", nodes), ("edge", document.get("edges", []))):
|
|
307
|
+
for index, obj in enumerate(objects):
|
|
308
|
+
key = entity, obj["id"]
|
|
309
|
+
field = "text" if obj.get("type") == "text" else "label"
|
|
310
|
+
own = prefix(obj.get(field))
|
|
311
|
+
if own:
|
|
312
|
+
result[key], carriers[key] = own, {(entity, obj["id"], field)}
|
|
313
|
+
continue
|
|
314
|
+
subjects = [obj] if entity == "node" else [by_id[obj["fromNode"]], by_id[obj["toNode"]]]
|
|
315
|
+
choices = [(i, g) for i, g in groups
|
|
316
|
+
if (entity == "edge" or i < index)
|
|
317
|
+
and all(i < node_positions[n["id"]] for n in subjects)
|
|
318
|
+
and all(contains(g, n) and g is not n for n in subjects)]
|
|
319
|
+
inner = [(i, g) for i, g in choices
|
|
320
|
+
if not any(g is not h and contains(g, h) and not contains(h, g)
|
|
321
|
+
for _, h in choices)]
|
|
322
|
+
categories = {prefix(g.get("label")) for _, g in inner}
|
|
323
|
+
result[key] = next(iter(categories)) if len(categories) == 1 else (
|
|
324
|
+
"ambiguous" if categories else None)
|
|
325
|
+
carriers[key] = {("node", g["id"], "label") for _, g in inner}
|
|
326
|
+
return result, carriers
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_document(data, path):
|
|
330
|
+
issues = []
|
|
331
|
+
diagnostics = dict(structure="supported", references=[], issues=issues,
|
|
332
|
+
render_check="not_performed")
|
|
333
|
+
try:
|
|
334
|
+
document = strict_json(data, numbers=True)
|
|
335
|
+
except UnicodeDecodeError:
|
|
336
|
+
issues.append(issue("utf8_decode", path))
|
|
337
|
+
diagnostics["structure"] = "unsupported"
|
|
338
|
+
return None, diagnostics
|
|
339
|
+
except (ValueError, RecursionError) as error:
|
|
340
|
+
code = str(error) if str(error) in {"duplicate_key", "nonfinite_number", "utf8_bom"} else "json_syntax"
|
|
341
|
+
issues.append(issue(code, path))
|
|
342
|
+
diagnostics["structure"] = "unsupported" if code == "utf8_bom" else "invalid"
|
|
343
|
+
return None, diagnostics
|
|
344
|
+
|
|
345
|
+
def add(code, entity="document", obj=None, field=None, related=None):
|
|
346
|
+
identifier = obj.get("id") if isinstance(obj, dict) else None
|
|
347
|
+
if not isinstance(identifier, str) or isinstance(identifier, Number):
|
|
348
|
+
identifier = None
|
|
349
|
+
if identifier is not None and any(0xD800 <= ord(c) <= 0xDFFF for c in identifier):
|
|
350
|
+
identifier = None
|
|
351
|
+
if field is not None and any(0xD800 <= ord(c) <= 0xDFFF for c in field):
|
|
352
|
+
field = None
|
|
353
|
+
issues.append(issue(code, path, entity, identifier, field, related))
|
|
354
|
+
|
|
355
|
+
def scalar(value):
|
|
356
|
+
pending = [value]
|
|
357
|
+
while pending:
|
|
358
|
+
item = pending.pop()
|
|
359
|
+
if isinstance(item, str) and any(0xD800 <= ord(c) <= 0xDFFF for c in item):
|
|
360
|
+
return True
|
|
361
|
+
if isinstance(item, list):
|
|
362
|
+
pending.extend(item)
|
|
363
|
+
elif isinstance(item, dict):
|
|
364
|
+
pending.extend(item.keys())
|
|
365
|
+
pending.extend(item.values())
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
if not isinstance(document, dict):
|
|
369
|
+
add("invalid_type")
|
|
370
|
+
else:
|
|
371
|
+
for name in sorted(document):
|
|
372
|
+
if name not in {"nodes", "edges"}:
|
|
373
|
+
add("unknown_attribute", field=name)
|
|
374
|
+
for collection, entity in (("nodes", "node"), ("edges", "edge")):
|
|
375
|
+
objects = document.get(collection, [])
|
|
376
|
+
if not isinstance(objects, list):
|
|
377
|
+
add("invalid_type", field=collection)
|
|
378
|
+
continue
|
|
379
|
+
seen = set()
|
|
380
|
+
for obj in objects:
|
|
381
|
+
if not isinstance(obj, dict):
|
|
382
|
+
add("invalid_type", entity)
|
|
383
|
+
continue
|
|
384
|
+
raw_kind = obj.get("type")
|
|
385
|
+
kind = raw_kind if isinstance(raw_kind, str) and not isinstance(raw_kind, Number) else None
|
|
386
|
+
unknown_kind = entity == "node" and kind is not None and kind not in EXTRA
|
|
387
|
+
known = COMMON | EXTRA.get(kind, set()) if entity == "node" else EDGE
|
|
388
|
+
required = ({"id", "type", "x", "y", "width", "height"}
|
|
389
|
+
| ({"text"} if kind == "text" else {"file"} if kind == "file"
|
|
390
|
+
else {"url"} if kind == "link" else set())
|
|
391
|
+
if entity == "node" else {"id", "fromNode", "toNode"})
|
|
392
|
+
for field in sorted(required - obj.keys()):
|
|
393
|
+
add("missing_required", entity, obj, field)
|
|
394
|
+
for field in sorted(obj):
|
|
395
|
+
value = obj[field]
|
|
396
|
+
if scalar(value) or scalar(field):
|
|
397
|
+
add("unicode_scalar", entity, obj, field)
|
|
398
|
+
if field not in known:
|
|
399
|
+
if not unknown_kind:
|
|
400
|
+
add("unknown_attribute", entity, obj, field)
|
|
401
|
+
continue
|
|
402
|
+
if field in {"x", "y", "width", "height"}:
|
|
403
|
+
value, error = integer(value)
|
|
404
|
+
if error:
|
|
405
|
+
add(error, entity, obj, field)
|
|
406
|
+
else:
|
|
407
|
+
obj[field] = value
|
|
408
|
+
elif not isinstance(value, str) or isinstance(value, Number):
|
|
409
|
+
add("invalid_type", entity, obj, field)
|
|
410
|
+
elif field == "type" and unknown_kind:
|
|
411
|
+
add("unknown_node_type", entity, obj, field)
|
|
412
|
+
elif field == "color" and not re.fullmatch(r"(?:[1-6]|#[0-9A-Fa-f]{6})", value):
|
|
413
|
+
add("unsupported_color", entity, obj, field)
|
|
414
|
+
elif (field in {"fromSide", "toSide"} and value not in {"top", "bottom", "left", "right"}
|
|
415
|
+
or field in {"fromEnd", "toEnd"} and value not in {"none", "arrow"}
|
|
416
|
+
or field == "backgroundStyle" and value not in {"cover", "ratio", "repeat"}
|
|
417
|
+
or field == "subpath" and not value.startswith("#")):
|
|
418
|
+
add("invalid_enum", entity, obj, field)
|
|
419
|
+
identifier = obj.get("id")
|
|
420
|
+
if isinstance(identifier, str):
|
|
421
|
+
if identifier in seen:
|
|
422
|
+
add("duplicate_id", entity, obj, "id")
|
|
423
|
+
seen.add(identifier)
|
|
424
|
+
nodes = document.get("nodes", [])
|
|
425
|
+
edges = document.get("edges", [])
|
|
426
|
+
if isinstance(nodes, list) and isinstance(edges, list):
|
|
427
|
+
ids = {n.get("id") for n in nodes if isinstance(n, dict) and isinstance(n.get("id"), str)}
|
|
428
|
+
for edge in edges:
|
|
429
|
+
if isinstance(edge, dict):
|
|
430
|
+
for field in ("fromNode", "toNode"):
|
|
431
|
+
if isinstance(edge.get(field), str) and edge[field] not in ids:
|
|
432
|
+
add("missing_endpoint", "edge", edge, field, [edge[field]])
|
|
433
|
+
if scalar(document) and not any(i["code"] == "unicode_scalar" for i in issues):
|
|
434
|
+
add("unicode_scalar")
|
|
435
|
+
diagnostics["structure"] = (
|
|
436
|
+
"unsupported" if any(i["code"] == "unicode_scalar" for i in issues)
|
|
437
|
+
else "invalid" if any(i["category"] == "format" for i in issues)
|
|
438
|
+
else "unsupported" if issues else "supported")
|
|
439
|
+
if diagnostics["structure"] == "supported":
|
|
440
|
+
nodes = document.get("nodes", [])
|
|
441
|
+
for index, node in enumerate(nodes):
|
|
442
|
+
for field in ("height", "width"):
|
|
443
|
+
if node[field] <= 0:
|
|
444
|
+
add("nonpositive_size", "node", node, field)
|
|
445
|
+
if node["type"] != "group":
|
|
446
|
+
for earlier in nodes[:index]:
|
|
447
|
+
if earlier["type"] != "group" and intersects(earlier, node):
|
|
448
|
+
add("overlap", "node", node, related=[earlier["id"]])
|
|
449
|
+
categories, _ = effective(document)
|
|
450
|
+
for (entity, identifier), category in categories.items():
|
|
451
|
+
if category == "ambiguous":
|
|
452
|
+
issues.append(issue("ambiguous_marking", path, entity, identifier))
|
|
453
|
+
if isinstance(document, dict):
|
|
454
|
+
positions = {("document", None): -1}
|
|
455
|
+
for offset, collection in ((0, "nodes"), (1000000000, "edges")):
|
|
456
|
+
objects = document.get(collection, [])
|
|
457
|
+
if isinstance(objects, list):
|
|
458
|
+
for index, obj in enumerate(objects):
|
|
459
|
+
if isinstance(obj, dict):
|
|
460
|
+
identifier = obj.get("id")
|
|
461
|
+
if isinstance(identifier, str):
|
|
462
|
+
positions.setdefault((collection[:-1], identifier), offset + index)
|
|
463
|
+
issues.sort(key=lambda i: (positions.get((i["entity"], i["id"]), -1),
|
|
464
|
+
i["field"] or "", i["code"], i["related_ids"]))
|
|
465
|
+
return document, diagnostics
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class Session:
|
|
469
|
+
def __init__(self, request, storage):
|
|
470
|
+
self.request, self.storage = request, storage
|
|
471
|
+
self.vault = storage.Vault(request)
|
|
472
|
+
self.versions, self.paths, self.total = set(), set(), 0
|
|
473
|
+
self.exhausted = False
|
|
474
|
+
self.notes_cache = {}
|
|
475
|
+
self.version_sizes = {}
|
|
476
|
+
|
|
477
|
+
def reserve(self, path, data):
|
|
478
|
+
key = path, digest(data)
|
|
479
|
+
if key in self.versions:
|
|
480
|
+
return
|
|
481
|
+
limits = self.request["limits"]
|
|
482
|
+
if ((limits["max_file_bytes"] is not None and len(data) > limits["max_file_bytes"])
|
|
483
|
+
or limits["max_files"] is not None and path not in self.paths and len(self.paths) >= limits["max_files"]
|
|
484
|
+
or limits["max_total_bytes"] is not None and self.total + len(data) > limits["max_total_bytes"]):
|
|
485
|
+
self.exhausted = True
|
|
486
|
+
raise Failure("limit_exceeded")
|
|
487
|
+
self.versions.add(key)
|
|
488
|
+
self.paths.add(path)
|
|
489
|
+
self.total += len(data)
|
|
490
|
+
|
|
491
|
+
def read(self, path):
|
|
492
|
+
safe_path(path)
|
|
493
|
+
if not self.vault.allowed(path):
|
|
494
|
+
raise Failure("scope_refused")
|
|
495
|
+
limits = self.request["limits"]
|
|
496
|
+
maximum = limits["max_file_bytes"]
|
|
497
|
+
# Reserve-size checks precede reading new bodies. Previously seen versions
|
|
498
|
+
# may be re-read without consuming their budget a second time.
|
|
499
|
+
try:
|
|
500
|
+
metadata = self.vault.stat_authorized(path)
|
|
501
|
+
except self.storage.Failure as error:
|
|
502
|
+
if error.code in {"symlink_not_followed", "outside_scope", "invalid_path", "unsupported_format"}:
|
|
503
|
+
raise Failure("scope_refused") from None
|
|
504
|
+
raise
|
|
505
|
+
known_sizes = [size for (name, _), size in self.version_sizes.items() if name == path]
|
|
506
|
+
new_size = metadata.st_size not in known_sizes
|
|
507
|
+
if new_size and (
|
|
508
|
+
limits["max_files"] is not None and path not in self.paths and len(self.paths) >= limits["max_files"]
|
|
509
|
+
or limits["max_total_bytes"] is not None and self.total + metadata.st_size > limits["max_total_bytes"]):
|
|
510
|
+
self.exhausted = True
|
|
511
|
+
raise Failure("limit_exceeded")
|
|
512
|
+
if limits["max_total_bytes"] is not None:
|
|
513
|
+
available = max(limits["max_total_bytes"] - self.total, max(known_sizes, default=0))
|
|
514
|
+
maximum = min(maximum, available) if maximum is not None else available
|
|
515
|
+
try:
|
|
516
|
+
data, metadata = self.vault.read_authorized(path, maximum)
|
|
517
|
+
except self.storage.Failure as error:
|
|
518
|
+
if error.code == "limit_reached":
|
|
519
|
+
self.exhausted = True
|
|
520
|
+
raise Failure("limit_exceeded") from None
|
|
521
|
+
if error.code in {"symlink_not_followed", "outside_scope", "invalid_path", "unsupported_format"}:
|
|
522
|
+
raise Failure("scope_refused") from None
|
|
523
|
+
raise
|
|
524
|
+
self.reserve(path, data)
|
|
525
|
+
self.version_sizes[(path, digest(data))] = len(data)
|
|
526
|
+
return data, metadata
|
|
527
|
+
|
|
528
|
+
def note(self, path):
|
|
529
|
+
if path not in self.notes_cache:
|
|
530
|
+
data, _ = self.read(path)
|
|
531
|
+
self.notes_cache[path] = self.storage.parse_note(path, data)
|
|
532
|
+
return self.notes_cache[path]
|
|
533
|
+
|
|
534
|
+
def resolve(self, path, fragment):
|
|
535
|
+
if forbidden(path):
|
|
536
|
+
return "forbidden_path", []
|
|
537
|
+
path = posixpath.normpath(path)
|
|
538
|
+
if not self.vault.allowed(path):
|
|
539
|
+
return "outside_scope", []
|
|
540
|
+
try:
|
|
541
|
+
metadata = self.vault.stat_authorized(path)
|
|
542
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
543
|
+
return "not_regular_file", []
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
return "missing_file", []
|
|
546
|
+
except (OSError, self.storage.Failure):
|
|
547
|
+
return "read_failed", []
|
|
548
|
+
candidates = [path]
|
|
549
|
+
note = None
|
|
550
|
+
if path.endswith(".md"):
|
|
551
|
+
try:
|
|
552
|
+
note = self.note(path)
|
|
553
|
+
except Failure:
|
|
554
|
+
return "limit_exceeded", candidates
|
|
555
|
+
except (OSError, ValueError, self.storage.Failure):
|
|
556
|
+
return "target_parse_incomplete", candidates
|
|
557
|
+
if note["fatal"]:
|
|
558
|
+
return "target_parse_incomplete", candidates
|
|
559
|
+
if fragment is None:
|
|
560
|
+
return "file_exists", candidates
|
|
561
|
+
if note is None or not fragment.startswith("#") or fragment.startswith("#^"):
|
|
562
|
+
return "unsupported_subpath", candidates
|
|
563
|
+
heading = fragment[1:]
|
|
564
|
+
if note["headings"].count(heading) > 1:
|
|
565
|
+
return "ambiguous_heading", candidates
|
|
566
|
+
if heading in note["headings"]:
|
|
567
|
+
return "heading_exists", candidates
|
|
568
|
+
if note["unsupported_heading"]:
|
|
569
|
+
return "unsupported_subpath", candidates
|
|
570
|
+
return "missing_heading", candidates
|
|
571
|
+
|
|
572
|
+
def text_reference(self, canvas, link):
|
|
573
|
+
raw, syntax = link["destination"], link["syntax"]
|
|
574
|
+
path, separator, heading = raw.partition("#")
|
|
575
|
+
fragment = "#" + heading if separator else None
|
|
576
|
+
if syntax == "external" or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", raw):
|
|
577
|
+
return ("external_url" if valid_url(raw) else "forbidden_url"), [], fragment
|
|
578
|
+
if syntax == "unsupported" or not path:
|
|
579
|
+
return "unsupported_reference", [], fragment
|
|
580
|
+
if forbidden(path, parents=syntax == "markdown"):
|
|
581
|
+
return "forbidden_path", [], fragment
|
|
582
|
+
if syntax == "markdown":
|
|
583
|
+
try:
|
|
584
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", path + heading):
|
|
585
|
+
raise ValueError()
|
|
586
|
+
path, heading = unquote(path, errors="strict"), unquote(heading, errors="strict")
|
|
587
|
+
except (ValueError, UnicodeError):
|
|
588
|
+
return "unsupported_reference", [], fragment
|
|
589
|
+
fragment = "#" + heading if separator else None
|
|
590
|
+
if forbidden(path, parents=True):
|
|
591
|
+
return "forbidden_path", [], fragment
|
|
592
|
+
path = posixpath.normpath(posixpath.join(posixpath.dirname(canvas), path))
|
|
593
|
+
if forbidden(path):
|
|
594
|
+
return "forbidden_path", [], fragment
|
|
595
|
+
if not path.endswith(".md"):
|
|
596
|
+
return "unsupported_reference", [], fragment
|
|
597
|
+
else:
|
|
598
|
+
if Path(path).suffix and not path.endswith(".md"):
|
|
599
|
+
return "unsupported_reference", [], fragment
|
|
600
|
+
path = path if path.endswith(".md") else path + ".md"
|
|
601
|
+
if "/" not in path:
|
|
602
|
+
names, _, _, complete = self.vault.enumerate_authorized((".md",))
|
|
603
|
+
candidates = sorted(n for n in names if posixpath.basename(n) == path)
|
|
604
|
+
if len(candidates) > 1:
|
|
605
|
+
return "multiple_notes", candidates, fragment
|
|
606
|
+
whole = ("directory", ".") in self.vault.scopes
|
|
607
|
+
if not whole or not complete:
|
|
608
|
+
return "incomplete_search", candidates, fragment
|
|
609
|
+
if not candidates:
|
|
610
|
+
return "missing_file", [], fragment
|
|
611
|
+
path = candidates[0]
|
|
612
|
+
reason, candidates = self.resolve(path, fragment)
|
|
613
|
+
return reason, candidates, fragment
|
|
614
|
+
|
|
615
|
+
def diagnose(self, data, path):
|
|
616
|
+
document, diagnostics = parse_document(data, path)
|
|
617
|
+
if (diagnostics["structure"] == "invalid" or document is None
|
|
618
|
+
or any(i["code"] == "unicode_scalar" for i in diagnostics["issues"])):
|
|
619
|
+
return document, diagnostics
|
|
620
|
+
for entity, collection in (("node", "nodes"), ("edge", "edges")):
|
|
621
|
+
objects = document.get(collection, [])
|
|
622
|
+
if not isinstance(objects, list):
|
|
623
|
+
continue
|
|
624
|
+
for obj in objects:
|
|
625
|
+
if not isinstance(obj, dict) or not isinstance(obj.get("id"), str):
|
|
626
|
+
continue
|
|
627
|
+
for field in ("file", "background", "url", "text", "label"):
|
|
628
|
+
if not isinstance(obj.get(field), str):
|
|
629
|
+
continue
|
|
630
|
+
if field in {"text", "label"}:
|
|
631
|
+
links = text_links(self.storage, obj[field])
|
|
632
|
+
records = [(link["raw"], link, None) for link in links]
|
|
633
|
+
else:
|
|
634
|
+
records = [(obj[field], None, obj.get("subpath") if field == "file" else None)]
|
|
635
|
+
for occurrence, (raw, link, fragment) in enumerate(records, 1):
|
|
636
|
+
policy = False
|
|
637
|
+
if diagnostics["structure"] != "supported":
|
|
638
|
+
if link:
|
|
639
|
+
_, separator, heading = link["destination"].partition("#")
|
|
640
|
+
fragment = "#" + heading if separator else None
|
|
641
|
+
reason, candidates = "source_parse_incomplete", []
|
|
642
|
+
elif link:
|
|
643
|
+
reason, candidates, fragment = self.text_reference(path, link)
|
|
644
|
+
elif field == "url":
|
|
645
|
+
valid = valid_url(raw)
|
|
646
|
+
reason, candidates = ("external_url" if valid else "forbidden_url"), []
|
|
647
|
+
policy = not valid
|
|
648
|
+
else:
|
|
649
|
+
reason, candidates = self.resolve(raw, fragment)
|
|
650
|
+
policy = reason == "forbidden_path"
|
|
651
|
+
diagnostics["references"].append(dict(
|
|
652
|
+
canvas=path, entity=entity, id=obj["id"], field=field,
|
|
653
|
+
occurrence=occurrence, raw=raw, fragment=fragment,
|
|
654
|
+
classification=CONTRACT["x-reference-reasons"][reason]["classification"],
|
|
655
|
+
reason=reason, candidates=candidates, policy_forbidden=policy))
|
|
656
|
+
return document, diagnostics
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def partial(diagnostics):
|
|
660
|
+
return (diagnostics["structure"] != "supported"
|
|
661
|
+
or any(CONTRACT["x-reference-reasons"][r["reason"]]["partial"]
|
|
662
|
+
for r in diagnostics["references"]))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def context(request, command):
|
|
666
|
+
validate(request, "Request")
|
|
667
|
+
if (not os.path.isabs(request["vault_root"]) or ".." in request["vault_root"].split("/")
|
|
668
|
+
or request["workspace"] is not None and (
|
|
669
|
+
not os.path.isabs(request["workspace"]) or ".." in request["workspace"].split("/"))):
|
|
670
|
+
raise Failure("invalid_input", 1)
|
|
671
|
+
if ((command in {"inspect", "read"}) != (request["intent"] == "inspect")
|
|
672
|
+
or command == "layout" and (request["intent"] not in {"create", "edit"} or request["layout"] is None)
|
|
673
|
+
or command != "layout" and request["layout"] is not None
|
|
674
|
+
or command in {"layout", "plan", "apply"} and request["workspace"] is None):
|
|
675
|
+
raise Failure("invalid_input", 1)
|
|
676
|
+
for scope in request["read_scopes"]:
|
|
677
|
+
safe_path(scope["path"], dot=scope["kind"] == "directory")
|
|
678
|
+
if request["write_target"] is not None:
|
|
679
|
+
safe_path(request["write_target"])
|
|
680
|
+
if not request["write_target"].endswith(".canvas"):
|
|
681
|
+
raise Failure("invalid_input", 1)
|
|
682
|
+
seen, set_fields = {}, {}
|
|
683
|
+
for mutation in request["mutations"]:
|
|
684
|
+
entity, identifier, action, fields = (mutation[k] for k in ("entity", "id", "action", "fields"))
|
|
685
|
+
if entity == "document":
|
|
686
|
+
valid = identifier is None and action == "set" and bool(fields) and set(fields) <= VIRTUAL
|
|
687
|
+
else:
|
|
688
|
+
allowed = (COMMON | set().union(*EXTRA.values()) if entity == "node" else EDGE) - {"id", "type"}
|
|
689
|
+
valid = (identifier is not None and (action == "set" and bool(fields) and set(fields) <= allowed
|
|
690
|
+
or action in {"add", "remove"} and not fields))
|
|
691
|
+
key = entity, identifier
|
|
692
|
+
previous = seen.setdefault(key, [])
|
|
693
|
+
disjoint_set = (action == "set" and all(a == "set" for a in previous)
|
|
694
|
+
and not (set_fields.get(key, set()) & set(fields)))
|
|
695
|
+
retype_pair = entity == "node" and previous == ["remove"] and action == "add"
|
|
696
|
+
if not valid or previous and not (retype_pair or disjoint_set):
|
|
697
|
+
raise Failure("invalid_input", 1)
|
|
698
|
+
previous.append(action)
|
|
699
|
+
if action == "set":
|
|
700
|
+
set_fields.setdefault(key, set()).update(fields)
|
|
701
|
+
bindings = [(m["entity"], m["id"]) for m in request["markings"]]
|
|
702
|
+
if len(bindings) != len(set(bindings)) or any(not t.strip() for t in request["protected_terms"]):
|
|
703
|
+
raise Failure("invalid_input", 1)
|
|
704
|
+
if not request["consent"]["model_context"] or (
|
|
705
|
+
command in {"layout", "plan", "apply"} and not request["consent"]["workspace_copies"]):
|
|
706
|
+
raise Failure("consent_required")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def private_metadata(metadata, mode, directory=False):
|
|
710
|
+
if (metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != mode
|
|
711
|
+
or not (stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode))):
|
|
712
|
+
raise Failure("scope_refused")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@contextmanager
|
|
716
|
+
def private_parent(request, path):
|
|
717
|
+
workspace = request["workspace"]
|
|
718
|
+
path = os.fspath(path)
|
|
719
|
+
root = os.path.normpath(request["vault_root"])
|
|
720
|
+
if (not workspace or not os.path.isabs(workspace) or not os.path.isabs(path)
|
|
721
|
+
or ".." in path.split("/") or ".." in workspace.split("/")
|
|
722
|
+
or os.path.commonpath([root, workspace]) in {root, os.path.normpath(workspace)}
|
|
723
|
+
or os.path.commonpath([workspace, path]) != workspace or path == workspace):
|
|
724
|
+
raise Failure("scope_refused")
|
|
725
|
+
storage = backend()
|
|
726
|
+
descriptor = storage.directory(workspace)
|
|
727
|
+
try:
|
|
728
|
+
private_metadata(os.fstat(descriptor), 0o700, True)
|
|
729
|
+
parts = os.path.relpath(path, workspace).split("/")
|
|
730
|
+
for part in parts[:-1]:
|
|
731
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
732
|
+
os.close(descriptor)
|
|
733
|
+
descriptor = child
|
|
734
|
+
private_metadata(os.fstat(descriptor), 0o700, True)
|
|
735
|
+
yield descriptor, parts[-1]
|
|
736
|
+
except OSError as error:
|
|
737
|
+
if error.errno in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
|
|
738
|
+
raise Failure("scope_refused") from None
|
|
739
|
+
raise
|
|
740
|
+
finally:
|
|
741
|
+
os.close(descriptor)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def private_read(request, path, maximum=CONTROL_MAX):
|
|
745
|
+
with private_parent(request, path) as (parent, name):
|
|
746
|
+
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
747
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
748
|
+
metadata = os.fstat(stream.fileno())
|
|
749
|
+
private_metadata(metadata, 0o600)
|
|
750
|
+
if maximum is not None and metadata.st_size > maximum:
|
|
751
|
+
raise Failure("limit_exceeded")
|
|
752
|
+
data = stream.read() if maximum is None else stream.read(maximum + 1)
|
|
753
|
+
if maximum is not None and len(data) > maximum:
|
|
754
|
+
raise Failure("limit_exceeded")
|
|
755
|
+
return data
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def private_write(request, path, data, *, replace=False):
|
|
759
|
+
with private_parent(request, path) as (parent, name):
|
|
760
|
+
stage = "." + name + ".tmp" if replace else name
|
|
761
|
+
try:
|
|
762
|
+
descriptor = os.open(stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
763
|
+
0o600, dir_fd=parent)
|
|
764
|
+
except FileExistsError:
|
|
765
|
+
raise Failure("workspace_conflict", path=str(Path(path).with_name(stage))) from None
|
|
766
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
767
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
768
|
+
stream.write(data)
|
|
769
|
+
stream.flush()
|
|
770
|
+
os.fsync(stream.fileno())
|
|
771
|
+
if replace:
|
|
772
|
+
os.replace(stage, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
773
|
+
os.fsync(parent)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def private_mkdir(request, path):
|
|
777
|
+
with private_parent(request, path) as (parent, name):
|
|
778
|
+
os.mkdir(name, 0o700, dir_fd=parent)
|
|
779
|
+
os.chmod(name, 0o700, dir_fd=parent, follow_symlinks=False)
|
|
780
|
+
os.fsync(parent)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def changed_delta(request, before, after):
|
|
784
|
+
mutations = request["mutations"]
|
|
785
|
+
permissions = {}
|
|
786
|
+
for mutation in mutations:
|
|
787
|
+
permissions.setdefault((mutation["entity"], mutation["id"], mutation["action"]), set()).update(mutation["fields"])
|
|
788
|
+
proofread = request["intent"] == "proofread"
|
|
789
|
+
refusal = "protected_change" if proofread else "unrequested_change"
|
|
790
|
+
delta, strings = [], []
|
|
791
|
+
removed_nodes = {n["id"] for n in before.get("nodes", [])} - {n["id"] for n in after.get("nodes", [])}
|
|
792
|
+
if request["intent"] == "create":
|
|
793
|
+
return [], []
|
|
794
|
+
if proofread:
|
|
795
|
+
for mutation in mutations:
|
|
796
|
+
entity = mutation["entity"]
|
|
797
|
+
objects = {o["id"]: o for o in before.get(entity + "s", [])} if entity != "document" else {}
|
|
798
|
+
obj = objects.get(mutation["id"])
|
|
799
|
+
if (obj is None or mutation["action"] != "set"
|
|
800
|
+
or any(not (f == "text" and obj.get("type") == "text"
|
|
801
|
+
or f == "label" and (entity == "edge" or obj.get("type") == "group"))
|
|
802
|
+
or not isinstance(obj.get(f), str) for f in mutation["fields"])):
|
|
803
|
+
raise Failure("protected_change")
|
|
804
|
+
for mutation in mutations:
|
|
805
|
+
if mutation["entity"] != "document":
|
|
806
|
+
collection = mutation["entity"] + "s"
|
|
807
|
+
old = {o["id"]: o for o in before.get(collection, [])}
|
|
808
|
+
new = {o["id"]: o for o in after.get(collection, [])}
|
|
809
|
+
if mutation["action"] in {"set", "remove"} and mutation["id"] not in old:
|
|
810
|
+
raise Failure("invalid_input", 1)
|
|
811
|
+
if mutation["action"] == "add" and mutation["id"] in old:
|
|
812
|
+
if not ((mutation["entity"], mutation["id"], "remove") in permissions
|
|
813
|
+
and mutation["id"] in new and old[mutation["id"]].get("type") != new[mutation["id"]].get("type")):
|
|
814
|
+
raise Failure("invalid_input", 1)
|
|
815
|
+
def change(entity, identifier, action, field=None, dependency=False):
|
|
816
|
+
delta.append(dict(entity=entity, id=identifier, action=action, field=field, dependency=dependency))
|
|
817
|
+
for collection in ("nodes", "edges"):
|
|
818
|
+
old, new = before.get(collection, []), after.get(collection, [])
|
|
819
|
+
entity = collection[:-1]
|
|
820
|
+
old_ids, new_ids = [o["id"] for o in old], [o["id"] for o in new]
|
|
821
|
+
if (collection in before) != (collection in after):
|
|
822
|
+
field = collection + "_presence"
|
|
823
|
+
dependency = (collection not in before and bool(new)
|
|
824
|
+
and all((entity, i, "add") in permissions for i in new_ids))
|
|
825
|
+
if not dependency and field not in permissions.get(("document", None, "set"), set()):
|
|
826
|
+
raise Failure(refusal)
|
|
827
|
+
change("document", None, "set", field, dependency)
|
|
828
|
+
if [i for i in old_ids if i in new_ids] != [i for i in new_ids if i in old_ids]:
|
|
829
|
+
field = collection + "_order"
|
|
830
|
+
if field not in permissions.get(("document", None, "set"), set()):
|
|
831
|
+
raise Failure(refusal)
|
|
832
|
+
change("document", None, "set", field)
|
|
833
|
+
for collection in ("nodes", "edges"):
|
|
834
|
+
entity = collection[:-1]
|
|
835
|
+
old = {o["id"]: o for o in before.get(collection, [])}
|
|
836
|
+
new = {o["id"]: o for o in after.get(collection, [])}
|
|
837
|
+
for identifier, obj in old.items():
|
|
838
|
+
if identifier not in new:
|
|
839
|
+
dependency = entity == "edge" and bool({obj["fromNode"], obj["toNode"]} & removed_nodes)
|
|
840
|
+
if proofread or (entity, identifier, "remove") not in permissions and not dependency:
|
|
841
|
+
raise Failure(refusal)
|
|
842
|
+
change(entity, identifier, "remove", dependency=dependency)
|
|
843
|
+
continue
|
|
844
|
+
final = new[identifier]
|
|
845
|
+
if entity == "node" and obj["type"] != final["type"]:
|
|
846
|
+
if proofread or not all((entity, identifier, a) in permissions for a in ("remove", "add")):
|
|
847
|
+
raise Failure(refusal)
|
|
848
|
+
change(entity, identifier, "remove")
|
|
849
|
+
change(entity, identifier, "add")
|
|
850
|
+
continue
|
|
851
|
+
for field in sorted(obj.keys() | final.keys()):
|
|
852
|
+
if (field in obj) == (field in final) and obj.get(field) == final.get(field):
|
|
853
|
+
continue
|
|
854
|
+
if field not in permissions.get((entity, identifier, "set"), set()):
|
|
855
|
+
raise Failure(refusal)
|
|
856
|
+
if proofread:
|
|
857
|
+
valid = (field == "text" and obj.get("type") == "text"
|
|
858
|
+
or field == "label" and (entity == "edge" or obj.get("type") == "group"))
|
|
859
|
+
if not valid or not isinstance(obj.get(field), str) or not isinstance(final.get(field), str):
|
|
860
|
+
raise Failure("protected_change")
|
|
861
|
+
strings.append((entity, identifier, field, obj[field], final[field]))
|
|
862
|
+
change(entity, identifier, "set", field)
|
|
863
|
+
for identifier in new:
|
|
864
|
+
if identifier not in old:
|
|
865
|
+
if proofread or (entity, identifier, "add") not in permissions:
|
|
866
|
+
raise Failure(refusal)
|
|
867
|
+
change(entity, identifier, "add")
|
|
868
|
+
if proofread and not strings:
|
|
869
|
+
raise Failure("protected_change")
|
|
870
|
+
return delta, strings
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def marking_gate(request, before, after, delta):
|
|
874
|
+
old, _ = effective(before)
|
|
875
|
+
current, carriers = effective(after)
|
|
876
|
+
proofread = request["intent"] == "proofread"
|
|
877
|
+
needed = set(current) if request["intent"] == "create" else {
|
|
878
|
+
(d["entity"], d["id"]) for d in delta
|
|
879
|
+
if d["action"] == "add" or d["action"] == "set" and d["field"] in SEMANTIC}
|
|
880
|
+
if proofread:
|
|
881
|
+
needed = set()
|
|
882
|
+
for key in old.keys() & current.keys():
|
|
883
|
+
if old[key] != current[key]:
|
|
884
|
+
if proofread:
|
|
885
|
+
raise Failure("protected_change")
|
|
886
|
+
needed.add(key)
|
|
887
|
+
delta.append(dict(entity=key[0], id=key[1], action="set",
|
|
888
|
+
field="effective_category", dependency=True))
|
|
889
|
+
if proofread:
|
|
890
|
+
for collection in ("nodes", "edges"):
|
|
891
|
+
final = {o["id"]: o for o in after.get(collection, [])}
|
|
892
|
+
for obj in before.get(collection, []):
|
|
893
|
+
field = "text" if obj.get("type") == "text" else "label"
|
|
894
|
+
if prefix(obj.get(field)) != prefix(final[obj["id"]].get(field)):
|
|
895
|
+
raise Failure("protected_change")
|
|
896
|
+
bindings = {(m["entity"], m["id"]): m for m in request["markings"]}
|
|
897
|
+
if any(key not in current for key in bindings):
|
|
898
|
+
raise Failure("invalid_input", 1)
|
|
899
|
+
for key in needed | bindings.keys():
|
|
900
|
+
binding = bindings.get(key)
|
|
901
|
+
if (binding is None or current[key] not in PREFIXES
|
|
902
|
+
or binding["category"] != current[key]
|
|
903
|
+
or (binding["carrier_entity"], binding["carrier_id"], binding["carrier_field"]) not in carriers[key]):
|
|
904
|
+
raise Failure("marking_required")
|
|
905
|
+
positions = {("document", None): -1}
|
|
906
|
+
for offset, collection in ((0, "nodes"), (1000000000, "edges")):
|
|
907
|
+
order = list(dict.fromkeys(o["id"] for o in before.get(collection, []) + after.get(collection, [])))
|
|
908
|
+
positions.update({(collection[:-1], identifier): offset + i for i, identifier in enumerate(order)})
|
|
909
|
+
# Stable sorting retains the adjacent remove/add pair.
|
|
910
|
+
delta.sort(key=lambda d: (positions[(d["entity"], d["id"])],
|
|
911
|
+
d["field"] or ""))
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def review_gate(request, before, candidate, review_bytes, strings, storage):
|
|
915
|
+
try:
|
|
916
|
+
review = strict_json(review_bytes)
|
|
917
|
+
validate(review, "Review")
|
|
918
|
+
except (ValueError, ValidationError, UnicodeError, RecursionError):
|
|
919
|
+
raise Failure("invalid_input", 1) from None
|
|
920
|
+
if (review["before_sha256"] != (digest(before) if before is not None else None)
|
|
921
|
+
or review["after_sha256"] != digest(candidate)):
|
|
922
|
+
raise Failure("stale_review")
|
|
923
|
+
if not all(review["diagram_checks"].values()):
|
|
924
|
+
raise Failure("review_rejected")
|
|
925
|
+
if request["intent"] != "proofread":
|
|
926
|
+
if review["semantic_checks"] is not None:
|
|
927
|
+
raise Failure("invalid_input", 1)
|
|
928
|
+
return
|
|
929
|
+
mechanism = gate()
|
|
930
|
+
records = review["semantic_checks"]
|
|
931
|
+
if not isinstance(records, list) or len(records) != len(strings):
|
|
932
|
+
raise Failure("review_rejected")
|
|
933
|
+
indexed = {(r["entity"], r["id"], r["field"]): r for r in records}
|
|
934
|
+
if len(indexed) != len(records) or set(indexed) != {s[:3] for s in strings}:
|
|
935
|
+
raise Failure("review_rejected")
|
|
936
|
+
for entity, identifier, field, original, edited in strings:
|
|
937
|
+
record = indexed[(entity, identifier, field)]
|
|
938
|
+
initial, final = original.encode("utf-8"), edited.encode("utf-8")
|
|
939
|
+
if (record["before_sha256"] != digest(initial) or record["after_sha256"] != digest(final)
|
|
940
|
+
or not all(record["checks"].values())):
|
|
941
|
+
raise Failure("review_rejected")
|
|
942
|
+
converted = {k: record[k] for k in ("before_sha256", "after_sha256", "checks")}
|
|
943
|
+
converted["reviewer"] = review["reviewer"]
|
|
944
|
+
outcome = mechanism.check_bytes(initial, final, request["protected_terms"], converted)
|
|
945
|
+
# Parse as a body, not as a note: text cards have no frontmatter.
|
|
946
|
+
def syntax(text):
|
|
947
|
+
masked, incomplete, comments = storage.masked_body(text)
|
|
948
|
+
_, _, all_raw = storage.extract_links(text, masked, 0)
|
|
949
|
+
lines = text.splitlines(keepends=True)
|
|
950
|
+
headings = ["".join(lines[token.map[0]:token.map[1]])
|
|
951
|
+
for token in storage.MARKDOWN.parse(masked)
|
|
952
|
+
if token.type == "heading_open" and token.map]
|
|
953
|
+
return dict(
|
|
954
|
+
incomplete=incomplete, comments=comments, links=all_raw, headings=headings,
|
|
955
|
+
callouts=re.findall(r"^[ >]*\[![^\]\n]+\][+-]?", text, re.M),
|
|
956
|
+
definitions=re.findall(r"^[ ]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]+)*", text, re.M),
|
|
957
|
+
block_ids=re.findall(r"(?<!\S)\^[A-Za-z0-9-]+(?=\s|$)", masked),
|
|
958
|
+
tags=re.findall(r"(?<![\w\\])#[\w/-]+", masked))
|
|
959
|
+
if outcome["status"] != "passed" or syntax(original) != syntax(edited):
|
|
960
|
+
raise Failure("protected_change")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def implementation_hash(proofread):
|
|
964
|
+
paths = [Path(__file__), Path(__file__).with_suffix(".py.lock"), SKILL / "SKILL.md",
|
|
965
|
+
SKILL.parent / "obsidian-vault/scripts/vault.py",
|
|
966
|
+
SKILL.parent / "obsidian-vault/scripts/vault.py.lock"]
|
|
967
|
+
if proofread:
|
|
968
|
+
gate()
|
|
969
|
+
paths.append(SKILL.parent / "japanese-prose/scripts/invariants.py")
|
|
970
|
+
try:
|
|
971
|
+
return digest(canonical({"proofread_gate": proofread,
|
|
972
|
+
"files": {str(p.relative_to(SKILL.parent)): digest(p.read_bytes()) for p in paths}}))
|
|
973
|
+
except OSError:
|
|
974
|
+
raise Failure("dependency_missing", 4) from None
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def storage_check(session, parent, target, metadata):
|
|
978
|
+
storage = session.storage
|
|
979
|
+
try:
|
|
980
|
+
storage.write_capabilities()
|
|
981
|
+
storage.local_filesystem(parent)
|
|
982
|
+
if os.path.realpath(f"/proc/self/fd/{parent}") != str(Path(session.vault.root) / posixpath.dirname(target)):
|
|
983
|
+
raise Failure("storage_capability", 4)
|
|
984
|
+
if metadata is not None:
|
|
985
|
+
with os.fdopen(os.open(posixpath.basename(target), os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
|
|
986
|
+
storage.metadata_supported(metadata, stream.fileno())
|
|
987
|
+
if os.readlink(f"/proc/self/fd/{stream.fileno()}") != str(Path(session.vault.root) / target):
|
|
988
|
+
raise Failure("storage_capability", 4)
|
|
989
|
+
except storage.Failure:
|
|
990
|
+
raise Failure("storage_capability", 4) from None
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def validate_change(session, before, candidate, review_bytes):
|
|
994
|
+
request, storage = session.request, session.storage
|
|
995
|
+
target = request["write_target"]
|
|
996
|
+
if before is None:
|
|
997
|
+
initial, old_diag = {}, None
|
|
998
|
+
else:
|
|
999
|
+
initial, old_diag = session.diagnose(before, target)
|
|
1000
|
+
if old_diag["structure"] != "supported":
|
|
1001
|
+
raise Failure("source_not_editable")
|
|
1002
|
+
final, diagnostics = session.diagnose(candidate, target)
|
|
1003
|
+
if diagnostics["structure"] != "supported":
|
|
1004
|
+
raise Failure("candidate_not_editable")
|
|
1005
|
+
if any(r["policy_forbidden"] for d in (old_diag, diagnostics) if d for r in d["references"]):
|
|
1006
|
+
raise Failure("forbidden_reference")
|
|
1007
|
+
if before is not None and not request["allow_formatting"]:
|
|
1008
|
+
raise Failure("formatting_consent_required")
|
|
1009
|
+
delta, strings = changed_delta(request, initial, final)
|
|
1010
|
+
marking_gate(request, initial, final, delta)
|
|
1011
|
+
if review_bytes is None:
|
|
1012
|
+
raise Failure("review_required")
|
|
1013
|
+
review_gate(request, before, candidate, review_bytes, strings, storage)
|
|
1014
|
+
if session.exhausted:
|
|
1015
|
+
raise Failure("limit_exceeded")
|
|
1016
|
+
return delta, diagnostics
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def operation_hash(plan):
|
|
1020
|
+
return digest(canonical({k: v for k, v in plan.items()
|
|
1021
|
+
if k not in {"operation_id", "artifacts", "stage_target", "state"}}))
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
ARTIFACT_NAMES = {"before": "before.canvas", "candidate": "candidate.canvas",
|
|
1025
|
+
"review": "semantic-review.json", "diff": "diff.patch",
|
|
1026
|
+
"delta": "delta.json", "diagnostics": "diagnostics.json"}
|
|
1027
|
+
HASH_NAMES = {"before": "before_sha256", "candidate": "after_sha256",
|
|
1028
|
+
"review": "review_sha256", "diff": "diff_sha256",
|
|
1029
|
+
"delta": "delta_sha256", "diagnostics": "diagnostics_sha256"}
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def target_read(session):
|
|
1033
|
+
target = safe_path(session.request["write_target"])
|
|
1034
|
+
if not session.vault.allowed(target):
|
|
1035
|
+
raise Failure("scope_refused")
|
|
1036
|
+
try:
|
|
1037
|
+
return session.read(target)
|
|
1038
|
+
except FileNotFoundError:
|
|
1039
|
+
return None, None
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def plan_command(session, request_bytes, args, report):
|
|
1043
|
+
request, storage = session.request, session.storage
|
|
1044
|
+
with session.vault.parent(request["write_target"]) as (parent, _):
|
|
1045
|
+
before, metadata = target_read(session)
|
|
1046
|
+
if request["intent"] == "create":
|
|
1047
|
+
if before is not None:
|
|
1048
|
+
raise Failure("target_exists")
|
|
1049
|
+
elif before is None or digest(before) != request["base_sha256"]:
|
|
1050
|
+
raise Failure("target_changed")
|
|
1051
|
+
candidate = private_read(request, args["--candidate"], request["limits"]["max_file_bytes"])
|
|
1052
|
+
if before == candidate:
|
|
1053
|
+
report["data"] = dict(plan_path=None, plan_sha256=None, operation_id=None, no_changes=True)
|
|
1054
|
+
return
|
|
1055
|
+
review = private_read(request, args["--review"]) if "--review" in args else None
|
|
1056
|
+
delta, diagnostics = validate_change(session, before, candidate, review)
|
|
1057
|
+
storage_check(session, parent, request["write_target"], metadata)
|
|
1058
|
+
diff = storage.diff_bytes(before, candidate, request["write_target"])
|
|
1059
|
+
maximum = request["limits"]["max_file_bytes"]
|
|
1060
|
+
if maximum is not None and len(diff) > maximum:
|
|
1061
|
+
raise Failure("limit_exceeded")
|
|
1062
|
+
payloads = dict(before=before, candidate=candidate, review=review, diff=diff,
|
|
1063
|
+
delta=canonical(delta), diagnostics=canonical(diagnostics))
|
|
1064
|
+
for key in ("review", "delta", "diagnostics"):
|
|
1065
|
+
if len(payloads[key]) > CONTROL_MAX:
|
|
1066
|
+
raise Failure("limit_exceeded")
|
|
1067
|
+
plan = dict(schema_version=1, kind=request["intent"], request_sha256=digest(request_bytes),
|
|
1068
|
+
contract_sha256=digest(CONTRACT_BYTES),
|
|
1069
|
+
implementation_sha256=implementation_hash(request["intent"] == "proofread"),
|
|
1070
|
+
vault_identity=storage.identity(os.fstat(session.vault.fd)),
|
|
1071
|
+
parent_identity=storage.identity(os.fstat(parent)), target=request["write_target"],
|
|
1072
|
+
**{HASH_NAMES[k]: digest(v) if v is not None else None for k, v in payloads.items()})
|
|
1073
|
+
operation = operation_hash(plan)
|
|
1074
|
+
directory = Path(request["workspace"]) / operation
|
|
1075
|
+
plan.update(operation_id=operation, state="validated",
|
|
1076
|
+
stage_target=posixpath.join(posixpath.dirname(plan["target"]), f".kotonoha-{operation}.tmp"),
|
|
1077
|
+
artifacts={k: str(directory / name) if payloads[k] is not None else None
|
|
1078
|
+
for k, name in ARTIFACT_NAMES.items()})
|
|
1079
|
+
validate(plan, "Plan")
|
|
1080
|
+
encoded = canonical(plan)
|
|
1081
|
+
if len(encoded) > CONTROL_MAX:
|
|
1082
|
+
raise Failure("limit_exceeded")
|
|
1083
|
+
path = directory / "plan.json"
|
|
1084
|
+
try:
|
|
1085
|
+
private_mkdir(request, directory)
|
|
1086
|
+
except FileExistsError:
|
|
1087
|
+
try:
|
|
1088
|
+
if private_read(request, path) != encoded:
|
|
1089
|
+
raise Failure("workspace_conflict")
|
|
1090
|
+
for key, value in payloads.items():
|
|
1091
|
+
if value is not None and private_read(request, plan["artifacts"][key], maximum if key in {"candidate", "before", "diff"} else CONTROL_MAX) != value:
|
|
1092
|
+
raise Failure("workspace_conflict")
|
|
1093
|
+
for name in ("result.json", "receipt.json"):
|
|
1094
|
+
try:
|
|
1095
|
+
private_read(request, directory / name)
|
|
1096
|
+
except FileNotFoundError:
|
|
1097
|
+
continue
|
|
1098
|
+
raise Failure("workspace_conflict")
|
|
1099
|
+
if set(os.listdir(directory)) != {"plan.json"} | {ARTIFACT_NAMES[k] for k, v in payloads.items() if v is not None}:
|
|
1100
|
+
raise Failure("workspace_conflict")
|
|
1101
|
+
with session.vault.parent(plan["stage_target"]) as (stage_parent, stage_name):
|
|
1102
|
+
try:
|
|
1103
|
+
os.stat(stage_name, dir_fd=stage_parent, follow_symlinks=False)
|
|
1104
|
+
except FileNotFoundError:
|
|
1105
|
+
pass
|
|
1106
|
+
else:
|
|
1107
|
+
raise Failure("workspace_conflict")
|
|
1108
|
+
except (OSError, storage.Failure):
|
|
1109
|
+
raise Failure("workspace_conflict") from None
|
|
1110
|
+
else:
|
|
1111
|
+
for key, value in payloads.items():
|
|
1112
|
+
if value is not None:
|
|
1113
|
+
private_write(request, plan["artifacts"][key], value)
|
|
1114
|
+
private_write(request, path, encoded)
|
|
1115
|
+
report["data"] = dict(plan_path=str(path), plan_sha256=digest(encoded),
|
|
1116
|
+
operation_id=operation, no_changes=False)
|
|
1117
|
+
report["operation_state"] = "validated"
|
|
1118
|
+
if partial(diagnostics):
|
|
1119
|
+
report.update(status="partial", exit_code=3)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def replay_decision(result, receipt, target, *, create=False, stage=False):
|
|
1123
|
+
if result == "corrupt":
|
|
1124
|
+
return "replay_mismatch"
|
|
1125
|
+
if receipt == "valid":
|
|
1126
|
+
return "already_applied" if target == "after" else "replay_mismatch"
|
|
1127
|
+
if result == "saved":
|
|
1128
|
+
return "replay_mismatch"
|
|
1129
|
+
if result in {"pending", "unknown"}:
|
|
1130
|
+
return "prior_outcome_unknown"
|
|
1131
|
+
if receipt == "invalid":
|
|
1132
|
+
return "replay_mismatch"
|
|
1133
|
+
if target != "before":
|
|
1134
|
+
return "target_exists" if create else "target_changed"
|
|
1135
|
+
return "workspace_conflict" if stage else "fresh"
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def bound_record(session, path, definition, expected):
|
|
1139
|
+
try:
|
|
1140
|
+
value = strict_json(private_read(session.request, path))
|
|
1141
|
+
validate(value, definition)
|
|
1142
|
+
if any(value.get(k) != v for k, v in expected.items()):
|
|
1143
|
+
return "invalid", None
|
|
1144
|
+
return "valid", value
|
|
1145
|
+
except FileNotFoundError:
|
|
1146
|
+
return "absent", None
|
|
1147
|
+
except (ValueError, ValidationError, OSError, Failure):
|
|
1148
|
+
return "invalid", None
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def apply_command(session, request_bytes, args, report):
|
|
1152
|
+
request, storage = session.request, session.storage
|
|
1153
|
+
encoded = private_read(request, args["--plan"])
|
|
1154
|
+
if digest(encoded) != args["--approve-plan-sha256"]:
|
|
1155
|
+
raise Failure("approval_mismatch")
|
|
1156
|
+
try:
|
|
1157
|
+
plan = strict_json(encoded)
|
|
1158
|
+
validate(plan, "Plan")
|
|
1159
|
+
except (ValueError, ValidationError):
|
|
1160
|
+
raise Failure("plan_mismatch") from None
|
|
1161
|
+
directory = Path(request["workspace"]) / plan["operation_id"]
|
|
1162
|
+
expected_artifacts = {k: str(directory / name) if k != "before" or plan["before_sha256"] is not None else None
|
|
1163
|
+
for k, name in ARTIFACT_NAMES.items()}
|
|
1164
|
+
stage_target = posixpath.join(posixpath.dirname(request["write_target"]), f".kotonoha-{plan['operation_id']}.tmp")
|
|
1165
|
+
if (str(directory / "plan.json") != args["--plan"] or plan["artifacts"] != expected_artifacts
|
|
1166
|
+
or plan["request_sha256"] != digest(request_bytes) or plan["contract_sha256"] != digest(CONTRACT_BYTES)
|
|
1167
|
+
or plan["implementation_sha256"] != implementation_hash(request["intent"] == "proofread")
|
|
1168
|
+
or plan["kind"] != request["intent"] or plan["target"] != request["write_target"]
|
|
1169
|
+
or plan["stage_target"] != stage_target or operation_hash(plan) != plan["operation_id"]
|
|
1170
|
+
or plan["before_sha256"] != request["base_sha256"]):
|
|
1171
|
+
raise Failure("plan_mismatch")
|
|
1172
|
+
payloads = {}
|
|
1173
|
+
for key, path in expected_artifacts.items():
|
|
1174
|
+
maximum = request["limits"]["max_file_bytes"] if key in {"before", "candidate", "diff"} else CONTROL_MAX
|
|
1175
|
+
payloads[key] = private_read(request, path, maximum) if path is not None else None
|
|
1176
|
+
if (digest(payloads[key]) if payloads[key] is not None else None) != plan[HASH_NAMES[key]]:
|
|
1177
|
+
raise Failure("plan_mismatch")
|
|
1178
|
+
for key, definition in (("delta", "Delta"), ("diagnostics", "Diagnostics")):
|
|
1179
|
+
try:
|
|
1180
|
+
validate(strict_json(payloads[key]), definition)
|
|
1181
|
+
except (ValueError, ValidationError):
|
|
1182
|
+
raise Failure("plan_mismatch") from None
|
|
1183
|
+
if storage.diff_bytes(payloads["before"], payloads["candidate"], plan["target"]) != payloads["diff"]:
|
|
1184
|
+
raise Failure("plan_mismatch")
|
|
1185
|
+
common = dict(schema_version=1, operation_id=plan["operation_id"], request_sha256=plan["request_sha256"],
|
|
1186
|
+
plan_sha256=digest(encoded), target=plan["target"], after_sha256=plan["after_sha256"])
|
|
1187
|
+
result_status, result = bound_record(session, directory / "result.json", "OperationResult",
|
|
1188
|
+
common | {"before_sha256": plan["before_sha256"]})
|
|
1189
|
+
receipt_status, _ = bound_record(session, directory / "receipt.json", "Receipt", common | {"verified": True})
|
|
1190
|
+
with session.vault.parent(plan["target"]) as (parent, name):
|
|
1191
|
+
if (storage.identity(os.fstat(session.vault.fd)) != plan["vault_identity"]
|
|
1192
|
+
or storage.identity(os.fstat(parent)) != plan["parent_identity"]):
|
|
1193
|
+
raise Failure("plan_mismatch")
|
|
1194
|
+
current, metadata = target_read(session)
|
|
1195
|
+
state = ("after" if current is not None and digest(current) == plan["after_sha256"]
|
|
1196
|
+
else "before" if current == payloads["before"] else "other")
|
|
1197
|
+
stage_name = posixpath.basename(stage_target)
|
|
1198
|
+
try:
|
|
1199
|
+
os.stat(stage_name, dir_fd=parent, follow_symlinks=False)
|
|
1200
|
+
stage = True
|
|
1201
|
+
except FileNotFoundError:
|
|
1202
|
+
stage = False
|
|
1203
|
+
decision = replay_decision("corrupt" if result_status == "invalid" else result["phase"] if result else "absent",
|
|
1204
|
+
receipt_status, state, create=request["intent"] == "create", stage=stage)
|
|
1205
|
+
if decision not in {"fresh", "already_applied"}:
|
|
1206
|
+
raise Failure(decision)
|
|
1207
|
+
if payloads["before"] is not None:
|
|
1208
|
+
session.reserve(plan["target"], payloads["before"])
|
|
1209
|
+
session.reserve(plan["target"], payloads["candidate"])
|
|
1210
|
+
delta, diagnostics = validate_change(session, payloads["before"], payloads["candidate"], payloads["review"])
|
|
1211
|
+
if canonical(delta) != payloads["delta"]:
|
|
1212
|
+
raise Failure("plan_mismatch")
|
|
1213
|
+
data = dict(target=plan["target"], operation_id=plan["operation_id"], after_sha256=plan["after_sha256"],
|
|
1214
|
+
changed=decision == "fresh", verified=True, diagnostics=diagnostics,
|
|
1215
|
+
approved_diagnostics_sha256=plan["diagnostics_sha256"])
|
|
1216
|
+
if decision == "fresh":
|
|
1217
|
+
storage_check(session, parent, plan["target"], metadata)
|
|
1218
|
+
pending = common | dict(before_sha256=plan["before_sha256"], phase="pending", verified=False)
|
|
1219
|
+
private_write(request, directory / "result.json", canonical(pending))
|
|
1220
|
+
progress = {"committed": False, "own_stage": False, "cleanup_failed": False}
|
|
1221
|
+
|
|
1222
|
+
def read_at(descriptor, filename):
|
|
1223
|
+
fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor)
|
|
1224
|
+
with os.fdopen(fd, "rb") as stream:
|
|
1225
|
+
info = os.fstat(stream.fileno())
|
|
1226
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1227
|
+
raise Failure("scope_refused")
|
|
1228
|
+
maximum = request["limits"]["max_file_bytes"]
|
|
1229
|
+
if maximum is not None and info.st_size > maximum:
|
|
1230
|
+
raise Failure("limit_exceeded")
|
|
1231
|
+
value = stream.read() if maximum is None else stream.read(maximum + 1)
|
|
1232
|
+
if maximum is not None and len(value) > maximum:
|
|
1233
|
+
raise Failure("limit_exceeded")
|
|
1234
|
+
session.reserve(plan["target"], value)
|
|
1235
|
+
return value, info
|
|
1236
|
+
|
|
1237
|
+
try:
|
|
1238
|
+
storage.commit_bytes(session.vault, parent, name, stage_name, payloads["before"],
|
|
1239
|
+
payloads["candidate"], metadata, plan["vault_identity"], progress,
|
|
1240
|
+
read_at=read_at, target=plan["target"])
|
|
1241
|
+
private_write(request, directory / "receipt.json", canonical(common | {"verified": True}))
|
|
1242
|
+
private_write(request, directory / "result.json",
|
|
1243
|
+
canonical(pending | {"phase": "saved", "verified": True}), replace=True)
|
|
1244
|
+
except Exception as error:
|
|
1245
|
+
if progress["committed"]:
|
|
1246
|
+
report["data"] = data | {"after_sha256": None, "changed": None, "verified": False}
|
|
1247
|
+
report["write_status"] = "unknown"
|
|
1248
|
+
raise Failure("runtime_error", 1,
|
|
1249
|
+
path=stage_target if progress["cleanup_failed"] else None) from error
|
|
1250
|
+
if progress["cleanup_failed"]:
|
|
1251
|
+
# Keep the pending record and the exact known stage path.
|
|
1252
|
+
# Neither retry nor cleanup may guess ownership after failure.
|
|
1253
|
+
raise Failure("runtime_error", 1, path=stage_target) from error
|
|
1254
|
+
if isinstance(error, Failure):
|
|
1255
|
+
raise
|
|
1256
|
+
if isinstance(error, storage.Failure):
|
|
1257
|
+
if error.code == "target_exists":
|
|
1258
|
+
try:
|
|
1259
|
+
os.stat(stage_name, dir_fd=parent, follow_symlinks=False)
|
|
1260
|
+
except FileNotFoundError:
|
|
1261
|
+
raise Failure("target_exists") from None
|
|
1262
|
+
raise Failure("workspace_conflict", path=stage_target) from None
|
|
1263
|
+
if error.code == "target_changed":
|
|
1264
|
+
raise Failure("target_changed") from None
|
|
1265
|
+
if error.code == "parent_changed":
|
|
1266
|
+
raise Failure("plan_mismatch") from None
|
|
1267
|
+
if error.exit_code == 4:
|
|
1268
|
+
raise Failure("storage_capability", 4) from None
|
|
1269
|
+
raise Failure("runtime_error", 1) from error
|
|
1270
|
+
report.update(data=data, write_status="saved", operation_state="saved")
|
|
1271
|
+
report["processed"] = [plan["target"]]
|
|
1272
|
+
if partial(diagnostics):
|
|
1273
|
+
report.update(status="partial", exit_code=3)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def layout_document(document, layout):
|
|
1277
|
+
final = copy.deepcopy(document)
|
|
1278
|
+
nodes = {n["id"]: n for n in final.get("nodes", [])}
|
|
1279
|
+
selected = []
|
|
1280
|
+
for identifier in layout["node_ids"]:
|
|
1281
|
+
node = nodes.get(identifier)
|
|
1282
|
+
if node is None or node["type"] == "group" or min(node["width"], node["height"]) <= 0:
|
|
1283
|
+
raise Failure("layout_refused")
|
|
1284
|
+
selected.append(node)
|
|
1285
|
+
groups = [n for n in nodes.values() if n["type"] == "group"]
|
|
1286
|
+
if any(intersects(n, g) for n in selected for g in groups):
|
|
1287
|
+
raise Failure("layout_refused")
|
|
1288
|
+
width, height = max(n["width"] for n in selected), max(n["height"] for n in selected)
|
|
1289
|
+
for index, node in enumerate(selected):
|
|
1290
|
+
mode, columns = layout["mode"], layout["columns"]
|
|
1291
|
+
column = index if mode == "horizontal" else 0 if mode == "vertical" else index % columns
|
|
1292
|
+
row = index if mode == "vertical" else 0 if mode == "horizontal" else index // columns
|
|
1293
|
+
node["x"] = layout["origin_x"] + column * (width + layout["gap"])
|
|
1294
|
+
node["y"] = layout["origin_y"] + row * (height + layout["gap"])
|
|
1295
|
+
if any(abs(node[k]) > SAFE for k in ("x", "y")) or any(intersects(node, g) for g in groups):
|
|
1296
|
+
raise Failure("layout_refused")
|
|
1297
|
+
return final
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def layout_command(session, request_bytes, args, report):
|
|
1301
|
+
request = session.request
|
|
1302
|
+
candidate = private_read(request, args["--candidate"], request["limits"]["max_file_bytes"])
|
|
1303
|
+
document, diagnostics = parse_document(candidate, request["write_target"])
|
|
1304
|
+
if diagnostics["structure"] != "supported":
|
|
1305
|
+
raise Failure("candidate_not_editable")
|
|
1306
|
+
final = layout_document(document, request["layout"])
|
|
1307
|
+
encoded = (json.dumps(final, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
1308
|
+
maximum = request["limits"]["max_file_bytes"]
|
|
1309
|
+
if maximum is not None and len(encoded) > maximum:
|
|
1310
|
+
raise Failure("limit_exceeded")
|
|
1311
|
+
directory = Path(request["workspace"]) / ("layout-" + digest(request_bytes + candidate))
|
|
1312
|
+
try:
|
|
1313
|
+
private_mkdir(request, directory)
|
|
1314
|
+
path = directory / "candidate.canvas"
|
|
1315
|
+
private_write(request, path, encoded)
|
|
1316
|
+
except FileExistsError:
|
|
1317
|
+
raise Failure("workspace_conflict") from None
|
|
1318
|
+
_, diagnostics = parse_document(encoded, request["write_target"])
|
|
1319
|
+
report.update(operation_state="proposed", data=dict(
|
|
1320
|
+
candidate_path=str(path), candidate_sha256=digest(encoded), changed=encoded != candidate,
|
|
1321
|
+
issues=diagnostics["issues"], reference_check="not_performed", render_check="not_performed"))
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def inspect_command(session, args, report):
|
|
1325
|
+
command = report["command"]
|
|
1326
|
+
if "--path" in args:
|
|
1327
|
+
path = safe_path(args["--path"])
|
|
1328
|
+
if not path.endswith((".canvas", ".md") if command == "read" else (".canvas",)):
|
|
1329
|
+
raise Failure("scope_refused")
|
|
1330
|
+
if not session.vault.allowed(path):
|
|
1331
|
+
raise Failure("scope_refused")
|
|
1332
|
+
paths = [path]
|
|
1333
|
+
else:
|
|
1334
|
+
paths, skipped, _, complete = session.vault.enumerate_authorized((".canvas",))
|
|
1335
|
+
report["unprocessed"] = sorted(skipped)
|
|
1336
|
+
if not complete:
|
|
1337
|
+
report.update(status="partial", exit_code=3)
|
|
1338
|
+
documents = []
|
|
1339
|
+
for path in paths:
|
|
1340
|
+
try:
|
|
1341
|
+
data, _ = session.read(path)
|
|
1342
|
+
except Failure as error:
|
|
1343
|
+
if error.code == "scope_refused":
|
|
1344
|
+
raise
|
|
1345
|
+
if command == "read":
|
|
1346
|
+
raise
|
|
1347
|
+
report["issues"].append(issue(error.code, path, category="runtime"))
|
|
1348
|
+
report["unprocessed"].append(path)
|
|
1349
|
+
report.update(status="partial", exit_code=3)
|
|
1350
|
+
continue
|
|
1351
|
+
except (OSError, session.storage.Failure):
|
|
1352
|
+
if command == "read":
|
|
1353
|
+
raise Failure("read_failed", 1) from None
|
|
1354
|
+
report["issues"].append(issue("read_failed", path, category="runtime"))
|
|
1355
|
+
report["unprocessed"].append(path)
|
|
1356
|
+
report.update(status="partial", exit_code=3)
|
|
1357
|
+
continue
|
|
1358
|
+
diagnostics = session.diagnose(data, path)[1] if path.endswith(".canvas") else None
|
|
1359
|
+
if diagnostics and partial(diagnostics):
|
|
1360
|
+
report.update(status="partial", exit_code=3)
|
|
1361
|
+
report["processed"].append(path)
|
|
1362
|
+
if command == "read":
|
|
1363
|
+
try:
|
|
1364
|
+
content = data.decode("utf-8")
|
|
1365
|
+
content.encode("utf-8")
|
|
1366
|
+
except UnicodeError:
|
|
1367
|
+
content = None
|
|
1368
|
+
report.update(status="partial", exit_code=3)
|
|
1369
|
+
report["data"] = dict(path=path, sha256=digest(data), bytes=len(data),
|
|
1370
|
+
content=content, diagnostics=diagnostics)
|
|
1371
|
+
else:
|
|
1372
|
+
documents.append(dict(path=path, sha256=digest(data), bytes=len(data), diagnostics=diagnostics))
|
|
1373
|
+
if command == "inspect":
|
|
1374
|
+
report["data"] = {"documents": documents}
|
|
1375
|
+
if session.exhausted:
|
|
1376
|
+
report.update(status="partial", exit_code=3)
|
|
1377
|
+
if not any(i["code"] == "limit_exceeded" for i in report["issues"]):
|
|
1378
|
+
report["issues"].append(issue("limit_exceeded", category="runtime"))
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def arguments(argv):
|
|
1382
|
+
if not argv or argv[0] not in CONTRACT["x-commands"]:
|
|
1383
|
+
raise Failure("invalid_input", 1)
|
|
1384
|
+
command = argv[0]
|
|
1385
|
+
metadata = CONTRACT["x-commands"][command]
|
|
1386
|
+
allowed = metadata["required"] + metadata["optional"]
|
|
1387
|
+
result = {}
|
|
1388
|
+
for index in range(1, len(argv), 2):
|
|
1389
|
+
if (index + 1 >= len(argv) or argv[index] not in allowed or argv[index] in result
|
|
1390
|
+
or argv[index + 1].startswith("--")):
|
|
1391
|
+
raise Failure("invalid_input", 1)
|
|
1392
|
+
result[argv[index]] = argv[index + 1]
|
|
1393
|
+
if not all(flag in result for flag in metadata["required"]):
|
|
1394
|
+
raise Failure("invalid_input", 1)
|
|
1395
|
+
return command, result
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def request_read(path):
|
|
1399
|
+
# Requests are control inputs, not Vault payloads; all parents still refuse symlinks.
|
|
1400
|
+
path = os.path.abspath(path)
|
|
1401
|
+
descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
|
1402
|
+
try:
|
|
1403
|
+
parts = Path(path).parts[1:]
|
|
1404
|
+
for part in parts[:-1]:
|
|
1405
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
1406
|
+
os.close(descriptor)
|
|
1407
|
+
descriptor = child
|
|
1408
|
+
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor)
|
|
1409
|
+
with os.fdopen(fd, "rb") as stream:
|
|
1410
|
+
metadata = os.fstat(stream.fileno())
|
|
1411
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > CONTROL_MAX:
|
|
1412
|
+
raise Failure("invalid_input", 1)
|
|
1413
|
+
data = stream.read(CONTROL_MAX + 1)
|
|
1414
|
+
if len(data) > CONTROL_MAX:
|
|
1415
|
+
raise Failure("invalid_input", 1)
|
|
1416
|
+
return data
|
|
1417
|
+
finally:
|
|
1418
|
+
os.close(descriptor)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def run(argv):
|
|
1422
|
+
command = argv[0] if argv and argv[0] in CONTRACT["x-commands"] else "inspect"
|
|
1423
|
+
report = dict(schema_version=1, command=command, status="completed", exit_code=0,
|
|
1424
|
+
write_status="unsaved" if command in {"plan", "apply"} else "not_requested",
|
|
1425
|
+
operation_state="scoped", request_sha256=None, processed=[], unprocessed=[], issues=[], data=None)
|
|
1426
|
+
session = None
|
|
1427
|
+
try:
|
|
1428
|
+
command, args = arguments(argv)
|
|
1429
|
+
try:
|
|
1430
|
+
data = request_read(args["--request"])
|
|
1431
|
+
except OSError:
|
|
1432
|
+
raise Failure("invalid_input", 1) from None
|
|
1433
|
+
report["request_sha256"] = digest(data)
|
|
1434
|
+
try:
|
|
1435
|
+
request = request_json(data)
|
|
1436
|
+
context(request, command)
|
|
1437
|
+
except (ValueError, ValidationError, UnicodeError, RecursionError):
|
|
1438
|
+
raise Failure("invalid_input", 1) from None
|
|
1439
|
+
storage = backend()
|
|
1440
|
+
if request["intent"] == "proofread":
|
|
1441
|
+
gate()
|
|
1442
|
+
session = Session(request, storage)
|
|
1443
|
+
if command in {"plan", "apply"}:
|
|
1444
|
+
fcntl.flock(session.vault.fd, fcntl.LOCK_EX)
|
|
1445
|
+
(plan_command if command == "plan" else apply_command)(session, data, args, report)
|
|
1446
|
+
elif command == "layout":
|
|
1447
|
+
layout_command(session, data, args, report)
|
|
1448
|
+
else:
|
|
1449
|
+
inspect_command(session, args, report)
|
|
1450
|
+
except Failure as error:
|
|
1451
|
+
report.update(status="failed" if error.exit_code == 1 else "not_performed",
|
|
1452
|
+
exit_code=error.exit_code, operation_state="blocked")
|
|
1453
|
+
report["issues"].append(issue(error.code, error.path))
|
|
1454
|
+
except Exception as error:
|
|
1455
|
+
if session and isinstance(error, session.storage.Failure):
|
|
1456
|
+
code = "storage_capability" if error.exit_code == 4 else "scope_refused"
|
|
1457
|
+
exit_code = 4 if error.exit_code == 4 else 2
|
|
1458
|
+
else:
|
|
1459
|
+
code, exit_code = "runtime_error", 1
|
|
1460
|
+
report.update(status="failed" if exit_code == 1 else "not_performed",
|
|
1461
|
+
exit_code=exit_code, operation_state="blocked")
|
|
1462
|
+
report["issues"].append(issue(code))
|
|
1463
|
+
finally:
|
|
1464
|
+
if session:
|
|
1465
|
+
session.vault.close()
|
|
1466
|
+
report["processed"] = sorted(set(report["processed"]))
|
|
1467
|
+
report["unprocessed"] = sorted(set(report["unprocessed"]))
|
|
1468
|
+
validate(report, "Report")
|
|
1469
|
+
return report
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def main():
|
|
1473
|
+
report = run(sys.argv[1:])
|
|
1474
|
+
print(canonical(report).decode("utf-8"))
|
|
1475
|
+
return report["exit_code"]
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
if __name__ == "__main__":
|
|
1479
|
+
sys.exit(main())
|