kotonoha 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README-ja.md +32 -5
- package/README.md +56 -6
- package/package.json +5 -3
- package/skills/japanese-prose/NOTICE.md +7 -1
- package/skills/japanese-prose/SKILL.md +44 -15
- package/skills/japanese-prose/references/review-workflow.md +57 -0
- package/skills/japanese-prose/references/scoring.md +6 -0
- package/skills/japanese-prose/scripts/core.py +6 -2
- package/skills/japanese-prose/scripts/invariants.py +200 -0
- package/skills/japanese-prose/scripts/lint.py +68 -3
- package/skills/japanese-prose/scripts/test_engine.py +168 -0
- package/skills/obsidian-vault/SKILL.md +106 -0
- package/skills/obsidian-vault/references/execution-contract.json +479 -0
- package/skills/obsidian-vault/references/workflow.md +151 -0
- package/skills/obsidian-vault/scripts/test_vault.py +493 -0
- package/skills/obsidian-vault/scripts/test_vault.py.lock +143 -0
- package/skills/obsidian-vault/scripts/vault.py +1316 -0
- package/skills/obsidian-vault/scripts/vault.py.lock +143 -0
- package/skills/tech-writer/SKILL.md +16 -7
- package/skills/tech-writer/assets/templates/obsidian.md +37 -0
- package/skills/tech-writer/references/doctypes/obsidian.md +106 -0
- package/skills/tech-writer/references/japanese-prose-optimization.md +14 -6
|
@@ -0,0 +1,1316 @@
|
|
|
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
|
+
"""Diagnose authorized Markdown notes and apply explicitly approved single-note plans."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from datetime import date
|
|
14
|
+
import difflib
|
|
15
|
+
import fcntl
|
|
16
|
+
import hashlib
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
import posixpath
|
|
23
|
+
import re
|
|
24
|
+
import stat
|
|
25
|
+
import sys
|
|
26
|
+
from urllib.parse import unquote_to_bytes
|
|
27
|
+
|
|
28
|
+
from jsonschema import Draft202012Validator, ValidationError
|
|
29
|
+
from markdown_it import MarkdownIt
|
|
30
|
+
from markdown_it.helpers import parseLinkDestination, parseLinkLabel, parseLinkTitle
|
|
31
|
+
from markdown_it.rules_inline.state_inline import StateInline
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
36
|
+
CONTRACT_PATH = SKILL / "references" / "execution-contract.json"
|
|
37
|
+
MANAGEMENT = {".git", ".obsidian"}
|
|
38
|
+
MARKDOWN = MarkdownIt("commonmark").enable("table")
|
|
39
|
+
CHECKS = (
|
|
40
|
+
"actors", "negation", "conditions", "modality", "scope", "causality",
|
|
41
|
+
"quantity_bindings", "terminology", "structure",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Failure(Exception):
|
|
46
|
+
def __init__(self, code, path=None, *, status="not_performed", exit_code=2):
|
|
47
|
+
super().__init__(code)
|
|
48
|
+
self.code, self.path = code, path
|
|
49
|
+
self.status, self.exit_code = status, exit_code
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def digest(data):
|
|
53
|
+
return hashlib.sha256(data).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canonical(data):
|
|
57
|
+
return json.dumps(data, ensure_ascii=False, sort_keys=True,
|
|
58
|
+
separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def strict_json(data):
|
|
62
|
+
def pairs(items):
|
|
63
|
+
result = {}
|
|
64
|
+
for key, value in items:
|
|
65
|
+
if key in result:
|
|
66
|
+
raise ValueError("Duplicate JSON key")
|
|
67
|
+
result[key] = value
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def invalid_constant(value):
|
|
71
|
+
raise ValueError("Non-finite JSON number")
|
|
72
|
+
|
|
73
|
+
return json.loads(data, object_pairs_hook=pairs, parse_constant=invalid_constant)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
CONTRACT = strict_json(CONTRACT_PATH.read_bytes())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate(value, definition):
|
|
80
|
+
Draft202012Validator({
|
|
81
|
+
"$ref": f"#/$defs/{definition}", "$defs": CONTRACT["$defs"],
|
|
82
|
+
}).validate(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def issue(code, path=None):
|
|
86
|
+
return {"code": code, "path": path, "message": code}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def relative(path, *, dot=False):
|
|
90
|
+
if not isinstance(path, str) or not path or "\0" in path or path.startswith("/"):
|
|
91
|
+
raise Failure("invalid_path")
|
|
92
|
+
parts = path.split("/")
|
|
93
|
+
if ".." in parts:
|
|
94
|
+
raise Failure("invalid_path", path)
|
|
95
|
+
normalized = posixpath.normpath(path)
|
|
96
|
+
if normalized == "." and not dot:
|
|
97
|
+
raise Failure("invalid_path", path)
|
|
98
|
+
if MANAGEMENT.intersection(parts):
|
|
99
|
+
raise Failure("excluded_management_directory", path)
|
|
100
|
+
return normalized
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def directory(path):
|
|
104
|
+
path = os.fspath(path)
|
|
105
|
+
if not os.path.isabs(path) or "\0" in path or ".." in path.split("/"):
|
|
106
|
+
raise Failure("invalid_path")
|
|
107
|
+
descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
|
108
|
+
try:
|
|
109
|
+
for part in Path(path).parts[1:]:
|
|
110
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
111
|
+
dir_fd=descriptor)
|
|
112
|
+
os.close(descriptor)
|
|
113
|
+
descriptor = child
|
|
114
|
+
return descriptor
|
|
115
|
+
except (OSError, Failure):
|
|
116
|
+
os.close(descriptor)
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Vault:
|
|
121
|
+
def __init__(self, request):
|
|
122
|
+
self.root = os.path.normpath(request["vault_root"])
|
|
123
|
+
self.scopes = [
|
|
124
|
+
(scope["kind"], relative(scope["path"], dot=scope["kind"] == "directory"))
|
|
125
|
+
for scope in request["read_scopes"]
|
|
126
|
+
]
|
|
127
|
+
try:
|
|
128
|
+
self.fd = directory(self.root)
|
|
129
|
+
except OSError as error:
|
|
130
|
+
raise Failure("invalid_request", status="failed", exit_code=1) from error
|
|
131
|
+
|
|
132
|
+
def close(self):
|
|
133
|
+
os.close(self.fd)
|
|
134
|
+
|
|
135
|
+
def allowed(self, path):
|
|
136
|
+
return any(
|
|
137
|
+
path == scope or kind == "directory" and (
|
|
138
|
+
scope == "." or path.startswith(scope + "/")
|
|
139
|
+
) for kind, scope in self.scopes
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@contextmanager
|
|
143
|
+
def parent(self, path):
|
|
144
|
+
path = relative(path)
|
|
145
|
+
descriptor = os.dup(self.fd)
|
|
146
|
+
try:
|
|
147
|
+
for part in path.split("/")[:-1]:
|
|
148
|
+
try:
|
|
149
|
+
child = os.open(
|
|
150
|
+
part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
151
|
+
dir_fd=descriptor,
|
|
152
|
+
)
|
|
153
|
+
except OSError as error:
|
|
154
|
+
if error.errno in (20, 40):
|
|
155
|
+
raise Failure("symlink_not_followed", path) from error
|
|
156
|
+
raise
|
|
157
|
+
os.close(descriptor)
|
|
158
|
+
descriptor = child
|
|
159
|
+
yield descriptor, path.split("/")[-1]
|
|
160
|
+
finally:
|
|
161
|
+
os.close(descriptor)
|
|
162
|
+
|
|
163
|
+
def read(self, path, maximum=None):
|
|
164
|
+
path = relative(path)
|
|
165
|
+
if not self.allowed(path):
|
|
166
|
+
raise Failure("outside_scope", path)
|
|
167
|
+
if not path.endswith(".md"):
|
|
168
|
+
raise Failure("unsupported_format", path)
|
|
169
|
+
with self.parent(path) as (parent, name):
|
|
170
|
+
try:
|
|
171
|
+
descriptor = os.open(
|
|
172
|
+
name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent,
|
|
173
|
+
)
|
|
174
|
+
except OSError as error:
|
|
175
|
+
if error.errno == 40:
|
|
176
|
+
raise Failure("symlink_not_followed", path) from error
|
|
177
|
+
raise
|
|
178
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
179
|
+
metadata = os.fstat(stream.fileno())
|
|
180
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
181
|
+
raise Failure("unsupported_format", path)
|
|
182
|
+
if maximum is not None and metadata.st_size > maximum:
|
|
183
|
+
raise Failure("limit_reached", path)
|
|
184
|
+
data = stream.read() if maximum is None else stream.read(maximum + 1)
|
|
185
|
+
if maximum is not None and len(data) > maximum:
|
|
186
|
+
raise Failure("limit_reached", path)
|
|
187
|
+
return data, metadata
|
|
188
|
+
|
|
189
|
+
def enumerate(self):
|
|
190
|
+
paths, skipped, issues = set(), set(), []
|
|
191
|
+
enumeration_complete = True
|
|
192
|
+
|
|
193
|
+
def walk(descriptor, prefix):
|
|
194
|
+
nonlocal enumeration_complete
|
|
195
|
+
try:
|
|
196
|
+
names = sorted(os.listdir(descriptor))
|
|
197
|
+
except OSError:
|
|
198
|
+
skipped.add(prefix)
|
|
199
|
+
issues.append(issue("read_failed", prefix))
|
|
200
|
+
enumeration_complete = False
|
|
201
|
+
return
|
|
202
|
+
for name in names:
|
|
203
|
+
path = name if prefix == "." else prefix + "/" + name
|
|
204
|
+
if name in MANAGEMENT:
|
|
205
|
+
issues.append(issue("excluded_management_directory", path))
|
|
206
|
+
continue
|
|
207
|
+
try:
|
|
208
|
+
metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
|
209
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
210
|
+
skipped.add(path)
|
|
211
|
+
issues.append(issue("symlink_not_followed", path))
|
|
212
|
+
enumeration_complete = False
|
|
213
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
214
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
215
|
+
dir_fd=descriptor)
|
|
216
|
+
try:
|
|
217
|
+
walk(child, path)
|
|
218
|
+
finally:
|
|
219
|
+
os.close(child)
|
|
220
|
+
elif name.endswith(".md"):
|
|
221
|
+
if stat.S_ISREG(metadata.st_mode):
|
|
222
|
+
paths.add(path)
|
|
223
|
+
else:
|
|
224
|
+
skipped.add(path)
|
|
225
|
+
issues.append(issue("unsupported_format", path))
|
|
226
|
+
except OSError:
|
|
227
|
+
skipped.add(path)
|
|
228
|
+
issues.append(issue("read_failed", path))
|
|
229
|
+
enumeration_complete = False
|
|
230
|
+
|
|
231
|
+
for kind, path in self.scopes:
|
|
232
|
+
if kind == "file":
|
|
233
|
+
if not path.endswith(".md"):
|
|
234
|
+
raise Failure("unsupported_format", path)
|
|
235
|
+
paths.add(path)
|
|
236
|
+
continue
|
|
237
|
+
try:
|
|
238
|
+
if path == ".":
|
|
239
|
+
walk(self.fd, ".")
|
|
240
|
+
else:
|
|
241
|
+
with self.parent(path) as (parent, name):
|
|
242
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
243
|
+
dir_fd=parent)
|
|
244
|
+
try:
|
|
245
|
+
walk(child, path)
|
|
246
|
+
finally:
|
|
247
|
+
os.close(child)
|
|
248
|
+
except (OSError, Failure):
|
|
249
|
+
skipped.add(path)
|
|
250
|
+
issues.append(issue("read_failed", path))
|
|
251
|
+
enumeration_complete = False
|
|
252
|
+
return sorted(paths), skipped, issues, enumeration_complete
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def yaml_properties(frontmatter):
|
|
256
|
+
for event in yaml.parse(frontmatter):
|
|
257
|
+
if getattr(event, "anchor", None) or isinstance(event, yaml.AliasEvent):
|
|
258
|
+
raise ValueError("YAML anchors unsupported")
|
|
259
|
+
tag = getattr(event, "tag", None)
|
|
260
|
+
if tag and not tag.startswith("tag:yaml.org,2002:"):
|
|
261
|
+
raise ValueError("Custom YAML tag unsupported")
|
|
262
|
+
node = yaml.compose(frontmatter, Loader=yaml.SafeLoader)
|
|
263
|
+
if node is None:
|
|
264
|
+
return {}
|
|
265
|
+
if not isinstance(node, yaml.MappingNode):
|
|
266
|
+
raise ValueError("Properties must be a mapping")
|
|
267
|
+
keys = [key.value for key, _ in node.value if isinstance(key, yaml.ScalarNode)]
|
|
268
|
+
if len(keys) != len(node.value) or len(set(keys)) != len(keys):
|
|
269
|
+
raise ValueError("Duplicate or complex YAML key")
|
|
270
|
+
values = yaml.safe_load(frontmatter)
|
|
271
|
+
|
|
272
|
+
def scalar(value):
|
|
273
|
+
return (value is None or isinstance(value, (str, bool, int, date))
|
|
274
|
+
or isinstance(value, float) and math.isfinite(value))
|
|
275
|
+
|
|
276
|
+
for key, value in values.items():
|
|
277
|
+
if not isinstance(key, str) or not key:
|
|
278
|
+
raise ValueError("Non-string property key")
|
|
279
|
+
if not scalar(value) and not (
|
|
280
|
+
isinstance(value, list) and all(scalar(item) for item in value)
|
|
281
|
+
):
|
|
282
|
+
raise ValueError("Nested property unsupported")
|
|
283
|
+
if key in {"aliases", "tags", "cssclasses"} and not (
|
|
284
|
+
isinstance(value, list) and all(isinstance(item, str) for item in value)
|
|
285
|
+
):
|
|
286
|
+
raise ValueError("Expected string list")
|
|
287
|
+
return values
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def masked_body(body):
|
|
291
|
+
chars = list(body)
|
|
292
|
+
fatal = False
|
|
293
|
+
comments = []
|
|
294
|
+
|
|
295
|
+
def hide(start, end, *, inline=False):
|
|
296
|
+
for index in range(start, end):
|
|
297
|
+
if chars[index] not in "\r\n":
|
|
298
|
+
chars[index] = "x" if inline else " "
|
|
299
|
+
|
|
300
|
+
def blocks(text):
|
|
301
|
+
lines = text.splitlines(keepends=True)
|
|
302
|
+
offsets = [0]
|
|
303
|
+
for line in lines:
|
|
304
|
+
offsets.append(offsets[-1] + len(line))
|
|
305
|
+
found, containers, tables = {}, [], []
|
|
306
|
+
for token in MARKDOWN.parse(text):
|
|
307
|
+
if token.type == "inline" and token.map:
|
|
308
|
+
containers.append((offsets[token.map[0]], offsets[token.map[1]]))
|
|
309
|
+
if token.type == "table_open" and token.map:
|
|
310
|
+
tables.append((offsets[token.map[0]], offsets[token.map[1]]))
|
|
311
|
+
if token.type in {"fence", "code_block"} and token.map:
|
|
312
|
+
start, end = token.map
|
|
313
|
+
found[offsets[start]] = (offsets[end], token, lines[start:end])
|
|
314
|
+
return found, containers, tables
|
|
315
|
+
|
|
316
|
+
code, containers, tables = blocks(body)
|
|
317
|
+
index = 0
|
|
318
|
+
while index < len(body):
|
|
319
|
+
if index in code:
|
|
320
|
+
end, token, lines = code[index]
|
|
321
|
+
if token.type == "fence" and not (
|
|
322
|
+
len(lines) > 1 and re.match(
|
|
323
|
+
r"^[ >]*" + re.escape(token.markup[0]) + "{"
|
|
324
|
+
+ str(len(token.markup)) + r",}\s*$", lines[-1],
|
|
325
|
+
)
|
|
326
|
+
):
|
|
327
|
+
fatal = True
|
|
328
|
+
hide(index, end)
|
|
329
|
+
index = end
|
|
330
|
+
elif body[index] == "\\":
|
|
331
|
+
index += 2
|
|
332
|
+
elif body.startswith("%%", index):
|
|
333
|
+
close = body.find("%%", index + 2)
|
|
334
|
+
end = len(body) if close < 0 else close + 2
|
|
335
|
+
fatal |= close < 0
|
|
336
|
+
comments.append(body[index:end])
|
|
337
|
+
hide(index, end, inline=True)
|
|
338
|
+
index = end
|
|
339
|
+
elif body[index] == "`":
|
|
340
|
+
opening = re.match(r"`+", body[index:]).group()
|
|
341
|
+
containing = [end for start, end in containers if start <= index < end]
|
|
342
|
+
newline = body.find("\n", index)
|
|
343
|
+
bound = min(containing) if containing else len(body) if newline < 0 else newline
|
|
344
|
+
bound = min([bound] + [start for start in code if start > index])
|
|
345
|
+
if any(start <= index < end for start, end in tables):
|
|
346
|
+
bound = min(bound, len(body) if newline < 0 else newline)
|
|
347
|
+
for position in range(index + len(opening), bound):
|
|
348
|
+
if body[position] == "|":
|
|
349
|
+
previous = position - 1
|
|
350
|
+
while previous >= 0 and body[previous] == "\\":
|
|
351
|
+
previous -= 1
|
|
352
|
+
if (position - previous - 1) % 2 == 0:
|
|
353
|
+
bound = position
|
|
354
|
+
break
|
|
355
|
+
close = re.search(r"(?<!`)" + re.escape(opening) + r"(?!`)",
|
|
356
|
+
body[index + len(opening):bound])
|
|
357
|
+
if close:
|
|
358
|
+
end = index + len(opening) + close.end()
|
|
359
|
+
hide(index, end, inline=True)
|
|
360
|
+
index = end
|
|
361
|
+
else:
|
|
362
|
+
index += len(opening)
|
|
363
|
+
else:
|
|
364
|
+
index += 1
|
|
365
|
+
for start, (end, token, lines) in blocks("".join(chars))[0].items():
|
|
366
|
+
if token.type == "fence" and not (
|
|
367
|
+
len(lines) > 1 and re.match(
|
|
368
|
+
r"^[ >]*" + re.escape(token.markup[0]) + "{"
|
|
369
|
+
+ str(len(token.markup)) + r",}\s*$", lines[-1],
|
|
370
|
+
)
|
|
371
|
+
):
|
|
372
|
+
fatal = True
|
|
373
|
+
hide(start, end)
|
|
374
|
+
return "".join(chars), fatal, comments
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def extract_links(body, masked, line_offset):
|
|
378
|
+
tokens = MARKDOWN.parse(masked)
|
|
379
|
+
blocks = []
|
|
380
|
+
for token in tokens:
|
|
381
|
+
if token.map:
|
|
382
|
+
blocks.append(tuple(token.map))
|
|
383
|
+
links, external, all_raw = [], 0, []
|
|
384
|
+
index = 0
|
|
385
|
+
state = StateInline(body, MARKDOWN, {}, [])
|
|
386
|
+
while index < len(masked):
|
|
387
|
+
if masked[index] == "\\":
|
|
388
|
+
index += 2
|
|
389
|
+
continue
|
|
390
|
+
start = index
|
|
391
|
+
image = masked.startswith("![", start)
|
|
392
|
+
bracket = start + 1 if image else start
|
|
393
|
+
syntax = None
|
|
394
|
+
destination = ""
|
|
395
|
+
if masked.startswith("[[", bracket):
|
|
396
|
+
close = masked.find("]]", bracket + 2)
|
|
397
|
+
newline = masked.find("\n", bracket)
|
|
398
|
+
if close >= 0 and (newline < 0 or close < newline):
|
|
399
|
+
end = close + 2
|
|
400
|
+
syntax = "wiki"
|
|
401
|
+
destination = body[bracket + 2:close].replace("\\|", "|").split("|", 1)[0]
|
|
402
|
+
if "[[" in destination:
|
|
403
|
+
syntax = "unsupported"
|
|
404
|
+
else:
|
|
405
|
+
end = len(masked) if newline < 0 else newline
|
|
406
|
+
syntax = "unsupported"
|
|
407
|
+
elif masked.startswith("[", bracket):
|
|
408
|
+
close = parseLinkLabel(state, bracket, False)
|
|
409
|
+
if close >= 0 and masked.startswith("(", close + 1):
|
|
410
|
+
position = close + 2
|
|
411
|
+
while position < len(body) and body[position].isspace():
|
|
412
|
+
position += 1
|
|
413
|
+
parsed = parseLinkDestination(body, position, len(body))
|
|
414
|
+
if parsed.ok:
|
|
415
|
+
destination = parsed.str
|
|
416
|
+
position = parsed.pos
|
|
417
|
+
while position < len(body) and body[position].isspace():
|
|
418
|
+
position += 1
|
|
419
|
+
if position < len(body) and body[position] in "\"'(":
|
|
420
|
+
title = parseLinkTitle(body, position, len(body))
|
|
421
|
+
if title.ok:
|
|
422
|
+
position = title.pos
|
|
423
|
+
while position < len(body) and body[position].isspace():
|
|
424
|
+
position += 1
|
|
425
|
+
if position < len(body) and body[position] == ")":
|
|
426
|
+
end = position + 1
|
|
427
|
+
syntax = "unsupported" if image else "markdown"
|
|
428
|
+
if syntax is None:
|
|
429
|
+
newline = masked.find("\n", bracket)
|
|
430
|
+
end = len(masked) if newline < 0 else newline
|
|
431
|
+
syntax = "unsupported"
|
|
432
|
+
if syntax is None:
|
|
433
|
+
url = re.match(r"https?://[^\s<>]+", masked[index:])
|
|
434
|
+
if url:
|
|
435
|
+
external += 1
|
|
436
|
+
index += len(url.group())
|
|
437
|
+
else:
|
|
438
|
+
index += 1
|
|
439
|
+
continue
|
|
440
|
+
all_raw.append(body[start:end])
|
|
441
|
+
if re.match(r"^(?:https?|mailto|ftp):", destination, re.I):
|
|
442
|
+
external += 1
|
|
443
|
+
index = end
|
|
444
|
+
continue
|
|
445
|
+
line = body.count("\n", 0, start)
|
|
446
|
+
containing = [(a, b) for a, b in blocks if a <= line < b]
|
|
447
|
+
block_line = min((a for a, _ in containing), default=line)
|
|
448
|
+
links.append({
|
|
449
|
+
"raw": body[start:end], "syntax": syntax, "destination": destination,
|
|
450
|
+
"block_start_line": line_offset + block_line + 1,
|
|
451
|
+
"occurrence": len(links) + 1,
|
|
452
|
+
})
|
|
453
|
+
index = end
|
|
454
|
+
return links, external, all_raw
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def parse_note(path, data):
|
|
458
|
+
text = data.decode("utf-8")
|
|
459
|
+
lines = text.splitlines(keepends=True)
|
|
460
|
+
properties, offset, fatal = {}, 0, False
|
|
461
|
+
if lines and lines[0].strip() == "---":
|
|
462
|
+
close = next((i for i in range(1, len(lines))
|
|
463
|
+
if lines[i].strip() in {"---", "..."}), None)
|
|
464
|
+
if close is None:
|
|
465
|
+
fatal = True
|
|
466
|
+
else:
|
|
467
|
+
offset = close + 1
|
|
468
|
+
try:
|
|
469
|
+
properties = yaml_properties("".join(lines[1:close]))
|
|
470
|
+
except (ValueError, yaml.YAMLError):
|
|
471
|
+
fatal = True
|
|
472
|
+
body = "".join(lines[offset:])
|
|
473
|
+
masked, incomplete, comments = masked_body(body)
|
|
474
|
+
fatal |= incomplete
|
|
475
|
+
headings, all_headings, unsupported = [], [], False
|
|
476
|
+
tokens = MARKDOWN.parse(masked)
|
|
477
|
+
for index, token in enumerate(tokens):
|
|
478
|
+
if token.type == "heading_open":
|
|
479
|
+
inline = tokens[index + 1]
|
|
480
|
+
start, end = token.map
|
|
481
|
+
source_lines = body.splitlines()
|
|
482
|
+
raw = source_lines[start]
|
|
483
|
+
marker = re.match(r"^[ \t>]*(?:(?:[-+*]|\d+[.)])[ \t]+)?#{1,6}(?:[ \t]+|$)", raw)
|
|
484
|
+
if marker:
|
|
485
|
+
raw = re.sub(r"[ \t]+#+[ \t]*$", "", raw[marker.end():]).strip(" \t")
|
|
486
|
+
else:
|
|
487
|
+
raw = "\n".join(line.lstrip(" >") for line in source_lines[start:end - 1])
|
|
488
|
+
all_headings.append(raw)
|
|
489
|
+
children = MARKDOWN.parseInline(raw)[0].children or []
|
|
490
|
+
plain = (len(children) == 1 and children[0].type == "text"
|
|
491
|
+
and children[0].content == raw
|
|
492
|
+
and not any(marker in raw for marker in ("[[", "]]", "==", "~~", "%%")))
|
|
493
|
+
if plain:
|
|
494
|
+
headings.append(raw)
|
|
495
|
+
else:
|
|
496
|
+
unsupported = True
|
|
497
|
+
links, external, all_raw = extract_links(body, masked, offset) if not fatal else ([], 0, [])
|
|
498
|
+
return {
|
|
499
|
+
"path": path, "data": data, "sha256": digest(data), "properties": properties,
|
|
500
|
+
"headings": headings, "all_headings": all_headings, "unsupported_heading": unsupported,
|
|
501
|
+
"links": links, "external": external, "fatal": fatal, "comments": comments,
|
|
502
|
+
"all_link_raw": all_raw,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def resolve_link(source, link, notes, vault, coverage):
|
|
507
|
+
raw, syntax = link["destination"], link["syntax"]
|
|
508
|
+
candidates = []
|
|
509
|
+
|
|
510
|
+
def result(classification, reason):
|
|
511
|
+
return {key: link[key] for key in ("raw", "block_start_line", "occurrence")} | {
|
|
512
|
+
"source_path": source, "classification": classification,
|
|
513
|
+
"reason": reason, "candidates": candidates,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if syntax == "unsupported" or "^" in raw or not raw:
|
|
517
|
+
return result("indeterminate", "unsupported_syntax")
|
|
518
|
+
path, separator, heading = raw.partition("#")
|
|
519
|
+
if syntax == "markdown":
|
|
520
|
+
extension = PurePosixPath(path).suffix
|
|
521
|
+
if path.startswith(("/", "\\")) or (
|
|
522
|
+
extension and "%" not in extension and extension != ".md"
|
|
523
|
+
):
|
|
524
|
+
return result("indeterminate", "unsupported_syntax")
|
|
525
|
+
try:
|
|
526
|
+
if re.search(r"%(?![a-fA-F0-9]{2})", path + heading):
|
|
527
|
+
raise ValueError("Invalid encoding")
|
|
528
|
+
path = unquote_to_bytes(path).decode("utf-8")
|
|
529
|
+
heading = unquote_to_bytes(heading).decode("utf-8")
|
|
530
|
+
except (ValueError, UnicodeError):
|
|
531
|
+
return result("indeterminate", "invalid_encoding")
|
|
532
|
+
if not path.endswith(".md") or path.startswith("/") or re.match(r"^[a-zA-Z]+:", path):
|
|
533
|
+
return result("indeterminate", "unsupported_syntax")
|
|
534
|
+
target = posixpath.normpath(posixpath.join(posixpath.dirname(source), path))
|
|
535
|
+
bare = False
|
|
536
|
+
else:
|
|
537
|
+
if path.startswith("/") or re.search(r"\.(?:png|jpe?g|gif|pdf|webp|svg|mp[34]|canvas|base)$", path, re.I):
|
|
538
|
+
return result("indeterminate", "unsupported_syntax")
|
|
539
|
+
bare = bool(path) and "/" not in path
|
|
540
|
+
target = source if not path and separator else path if path.endswith(".md") else path + ".md"
|
|
541
|
+
if target == ".." or target.startswith("../") or "\0" in target:
|
|
542
|
+
return result("indeterminate", "outside_scope")
|
|
543
|
+
try:
|
|
544
|
+
target = relative(target)
|
|
545
|
+
except Failure:
|
|
546
|
+
return result("indeterminate", "outside_scope")
|
|
547
|
+
if bare:
|
|
548
|
+
candidates = sorted(name for name in notes if PurePosixPath(name).name == target)
|
|
549
|
+
if len(candidates) > 1:
|
|
550
|
+
return result("ambiguous", "multiple_notes")
|
|
551
|
+
if not (coverage["whole_vault"] and coverage["enumeration_complete"] and coverage["reads_complete"]):
|
|
552
|
+
return result("indeterminate", "scope_incomplete")
|
|
553
|
+
else:
|
|
554
|
+
if not vault.allowed(target):
|
|
555
|
+
return result("indeterminate", "outside_scope")
|
|
556
|
+
if any(target == name or target.startswith(name + "/") for name in coverage["unprocessed"]):
|
|
557
|
+
return result("indeterminate", "scope_incomplete")
|
|
558
|
+
if target in notes:
|
|
559
|
+
candidates = [target]
|
|
560
|
+
if not candidates:
|
|
561
|
+
return result("unresolved", "note_missing")
|
|
562
|
+
note = notes[candidates[0]]
|
|
563
|
+
if note["fatal"]:
|
|
564
|
+
return result("indeterminate", "target_parse_incomplete")
|
|
565
|
+
if not separator:
|
|
566
|
+
return result("resolved", "note_found")
|
|
567
|
+
count = note["headings"].count(heading)
|
|
568
|
+
if count > 1:
|
|
569
|
+
return result("ambiguous", "multiple_headings")
|
|
570
|
+
if count == 1:
|
|
571
|
+
return result("resolved", "heading_found")
|
|
572
|
+
if note["unsupported_heading"]:
|
|
573
|
+
return result("indeterminate", "unsupported_heading")
|
|
574
|
+
return result("unresolved", "heading_missing")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def property_type(value):
|
|
578
|
+
if value is None:
|
|
579
|
+
return "null"
|
|
580
|
+
if isinstance(value, bool):
|
|
581
|
+
return "boolean"
|
|
582
|
+
if isinstance(value, (int, float)):
|
|
583
|
+
return "number"
|
|
584
|
+
if isinstance(value, date):
|
|
585
|
+
return "date"
|
|
586
|
+
return "list" if isinstance(value, list) else "string"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def observations(notes):
|
|
590
|
+
groups = defaultdict(set)
|
|
591
|
+
for path, note in notes.items():
|
|
592
|
+
groups["name", PurePosixPath(path).stem].add(path)
|
|
593
|
+
groups["folder", str(PurePosixPath(path).parent)].add(path)
|
|
594
|
+
if note["fatal"]:
|
|
595
|
+
continue
|
|
596
|
+
for name, value in note["properties"].items():
|
|
597
|
+
groups["property_name", name].add(path)
|
|
598
|
+
groups["property_type", name + ":" + property_type(value)].add(path)
|
|
599
|
+
for tag in note["properties"].get("tags", []):
|
|
600
|
+
if tag:
|
|
601
|
+
groups["tag", tag].add(path)
|
|
602
|
+
return [
|
|
603
|
+
{"kind": kind, "value": value, "paths": sorted(paths),
|
|
604
|
+
"count": len(paths), "label": "observation"}
|
|
605
|
+
for (kind, value), paths in sorted(groups.items())
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def note_record(note):
|
|
610
|
+
return {"path": note["path"], "sha256": note["sha256"],
|
|
611
|
+
"bytes": len(note["data"]), "heading_texts": note["all_headings"]}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def inspect(request, vault, report):
|
|
615
|
+
paths, skipped, issues, complete = vault.enumerate()
|
|
616
|
+
coverage = report["coverage"]
|
|
617
|
+
coverage["enumeration_complete"] = complete
|
|
618
|
+
coverage["reads_complete"] = not skipped
|
|
619
|
+
report["issues"].extend(issues)
|
|
620
|
+
notes, total = {}, 0
|
|
621
|
+
limits = request["limits"]
|
|
622
|
+
for index, path in enumerate(paths):
|
|
623
|
+
if limits["max_files"] is not None and index >= limits["max_files"]:
|
|
624
|
+
skipped.add(path)
|
|
625
|
+
report["issues"].append(issue("limit_reached", path))
|
|
626
|
+
continue
|
|
627
|
+
maximum = limits["max_file_bytes"]
|
|
628
|
+
if limits["max_total_bytes"] is not None:
|
|
629
|
+
remaining = limits["max_total_bytes"] - total
|
|
630
|
+
maximum = remaining if maximum is None else min(maximum, remaining)
|
|
631
|
+
try:
|
|
632
|
+
data, _ = vault.read(path, maximum)
|
|
633
|
+
total += len(data)
|
|
634
|
+
note = parse_note(path, data)
|
|
635
|
+
notes[path] = note
|
|
636
|
+
coverage["processed"].append(path)
|
|
637
|
+
if note["fatal"]:
|
|
638
|
+
coverage["parses_complete"] = False
|
|
639
|
+
report["issues"].extend([
|
|
640
|
+
issue("source_parse_incomplete", path), issue("unsupported_format", path),
|
|
641
|
+
])
|
|
642
|
+
except (OSError, UnicodeError, Failure) as error:
|
|
643
|
+
skipped.add(path)
|
|
644
|
+
code = error.code if isinstance(error, Failure) else "unsupported_format" if isinstance(error, UnicodeError) else "read_failed"
|
|
645
|
+
report["issues"].append(issue(code, path))
|
|
646
|
+
coverage["unprocessed"] = sorted(skipped)
|
|
647
|
+
coverage["reads_complete"] &= not skipped
|
|
648
|
+
links = [
|
|
649
|
+
resolve_link(path, link, notes, vault, coverage)
|
|
650
|
+
for path, note in sorted(notes.items()) for link in note["links"]
|
|
651
|
+
]
|
|
652
|
+
for link in links:
|
|
653
|
+
if link["reason"] == "target_parse_incomplete":
|
|
654
|
+
report["issues"].append(issue("target_parse_incomplete", link["source_path"]))
|
|
655
|
+
declarations = []
|
|
656
|
+
for path in request["declared_rules"]:
|
|
657
|
+
path = relative(path)
|
|
658
|
+
if not vault.allowed(path):
|
|
659
|
+
raise Failure("outside_scope", path)
|
|
660
|
+
if path not in notes:
|
|
661
|
+
raise Failure("read_failed", path, status="failed", exit_code=1)
|
|
662
|
+
declarations.append(note_record(notes[path]))
|
|
663
|
+
report["data"] = {
|
|
664
|
+
"notes": [note_record(note) for _, note in sorted(notes.items())],
|
|
665
|
+
"observations": observations(notes), "declarations": declarations,
|
|
666
|
+
"links": links, "external_url_count": sum(note["external"] for note in notes.values()),
|
|
667
|
+
}
|
|
668
|
+
if skipped or not complete or not coverage["parses_complete"] or any(
|
|
669
|
+
link["classification"] == "indeterminate" for link in links
|
|
670
|
+
):
|
|
671
|
+
report.update(status="partial", exit_code=3)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def load_gate():
|
|
675
|
+
path = SKILL.parent / "japanese-prose" / "scripts" / "invariants.py"
|
|
676
|
+
if not path.is_file():
|
|
677
|
+
raise Failure("dependency_missing", status="not_performed", exit_code=4)
|
|
678
|
+
spec = importlib.util.spec_from_file_location("kotonoha_invariants", path)
|
|
679
|
+
module = importlib.util.module_from_spec(spec)
|
|
680
|
+
spec.loader.exec_module(module)
|
|
681
|
+
if not callable(getattr(module, "check_bytes", None)) or tuple(module.SEMANTIC_CHECKS) != CHECKS:
|
|
682
|
+
raise Failure("dependency_missing", status="not_performed", exit_code=4)
|
|
683
|
+
return module
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def implementation_hash():
|
|
687
|
+
paths = [
|
|
688
|
+
Path(__file__), Path(__file__).with_suffix(".py.lock"),
|
|
689
|
+
SKILL.parent / "japanese-prose" / "scripts" / "invariants.py",
|
|
690
|
+
]
|
|
691
|
+
try:
|
|
692
|
+
return digest(canonical({path.name: digest(path.read_bytes()) for path in paths}))
|
|
693
|
+
except FileNotFoundError as error:
|
|
694
|
+
raise Failure("dependency_missing", status="not_performed", exit_code=4) from error
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def protected_snapshot(note):
|
|
698
|
+
text = note["data"].decode("utf-8")
|
|
699
|
+
return {
|
|
700
|
+
"headings": note["all_headings"],
|
|
701
|
+
"wikilinks": re.findall(r"!?\[\[[^\n]*?\]\]", text),
|
|
702
|
+
"links": note["all_link_raw"],
|
|
703
|
+
"markdown_links": re.findall(r"!?\[[^\]\n]*\]\([^\n]*?\)", text),
|
|
704
|
+
"callouts": re.findall(r"^[ >]*\[![^\]\n]+\][+-]?", text, re.M),
|
|
705
|
+
"comments": note["comments"],
|
|
706
|
+
"definitions": re.findall(r"^[ ]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]+)*", text, re.M),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def edit_gate(before, after, terms, review):
|
|
711
|
+
initial, final = parse_note("before.md", before), parse_note("after.md", after)
|
|
712
|
+
if initial["fatal"] or final["fatal"]:
|
|
713
|
+
raise Failure("unsupported_format")
|
|
714
|
+
result = load_gate().check_bytes(before, after, terms, review)
|
|
715
|
+
if protected_snapshot(initial) != protected_snapshot(final):
|
|
716
|
+
raise Failure("gate_blocked")
|
|
717
|
+
if result["status"] == "blocked":
|
|
718
|
+
raise Failure("gate_blocked")
|
|
719
|
+
if result["status"] != "passed":
|
|
720
|
+
raise Failure("semantic_review_required")
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def workspace_path(request, path):
|
|
724
|
+
workspace = request["workspace"]
|
|
725
|
+
if not workspace:
|
|
726
|
+
raise Failure("consent_missing")
|
|
727
|
+
workspace = os.path.normpath(workspace)
|
|
728
|
+
root = os.path.normpath(request["vault_root"])
|
|
729
|
+
if not os.path.isabs(workspace) or os.path.commonpath([workspace, root]) == root:
|
|
730
|
+
raise Failure("invalid_path")
|
|
731
|
+
path = os.path.normpath(os.fspath(path))
|
|
732
|
+
if not os.path.isabs(path) or os.path.commonpath([workspace, path]) != workspace:
|
|
733
|
+
raise Failure("outside_scope")
|
|
734
|
+
return workspace, path
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def workspace_read(request, path):
|
|
738
|
+
workspace, path = workspace_path(request, path)
|
|
739
|
+
descriptor = directory(workspace)
|
|
740
|
+
try:
|
|
741
|
+
relative_path = os.path.relpath(path, workspace)
|
|
742
|
+
parts = relative(relative_path).split("/")
|
|
743
|
+
for part in parts[:-1]:
|
|
744
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
745
|
+
dir_fd=descriptor)
|
|
746
|
+
os.close(descriptor)
|
|
747
|
+
descriptor = child
|
|
748
|
+
file_descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
749
|
+
dir_fd=descriptor)
|
|
750
|
+
with os.fdopen(file_descriptor, "rb") as stream:
|
|
751
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
752
|
+
raise Failure("unsupported_format")
|
|
753
|
+
return stream.read()
|
|
754
|
+
finally:
|
|
755
|
+
os.close(descriptor)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def write_private(path, data, *, reuse=False):
|
|
759
|
+
try:
|
|
760
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
761
|
+
except FileExistsError:
|
|
762
|
+
if reuse:
|
|
763
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
764
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
765
|
+
metadata = os.fstat(stream.fileno())
|
|
766
|
+
if (stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.geteuid()
|
|
767
|
+
and stat.S_IMODE(metadata.st_mode) == 0o600
|
|
768
|
+
and stream.read() == data):
|
|
769
|
+
return
|
|
770
|
+
raise Failure("target_exists") from None
|
|
771
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
772
|
+
stream.write(data)
|
|
773
|
+
stream.flush()
|
|
774
|
+
os.fsync(stream.fileno())
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def replace_private(path, data):
|
|
778
|
+
temporary = path.with_name("." + path.name + ".tmp")
|
|
779
|
+
write_private(temporary, data)
|
|
780
|
+
os.replace(temporary, path)
|
|
781
|
+
descriptor = directory(path.parent)
|
|
782
|
+
try:
|
|
783
|
+
os.fsync(descriptor)
|
|
784
|
+
finally:
|
|
785
|
+
os.close(descriptor)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def identity(metadata):
|
|
789
|
+
return {"device": metadata.st_dev, "inode": metadata.st_ino}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def operation_id(plan, parent_metadata):
|
|
793
|
+
return digest(canonical({
|
|
794
|
+
key: plan[key] for key in (
|
|
795
|
+
"request_sha256", "vault_identity", "target", "before_sha256",
|
|
796
|
+
"after_sha256", "review_sha256", "implementation_sha256",
|
|
797
|
+
)
|
|
798
|
+
} | {"target_parent_identity": identity(parent_metadata)}))
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def review_data(data):
|
|
802
|
+
review = strict_json(data)
|
|
803
|
+
if (not isinstance(review, dict)
|
|
804
|
+
or set(review) != {"before_sha256", "after_sha256", "reviewer", "checks"}
|
|
805
|
+
or not isinstance(review["checks"], dict) or set(review["checks"]) != set(CHECKS)):
|
|
806
|
+
raise Failure("semantic_review_required")
|
|
807
|
+
return review
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def assert_target(target):
|
|
811
|
+
target = relative(target)
|
|
812
|
+
if not target.endswith(".md"):
|
|
813
|
+
raise Failure("invalid_path", target)
|
|
814
|
+
return target
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def metadata_supported(metadata, descriptor=None):
|
|
818
|
+
if (metadata.st_uid != os.geteuid() or metadata.st_nlink != 1
|
|
819
|
+
or metadata.st_mode & 0o7000
|
|
820
|
+
or metadata.st_gid not in {os.getegid(), *os.getgroups()}):
|
|
821
|
+
raise Failure("capability_missing", exit_code=4)
|
|
822
|
+
if descriptor is not None:
|
|
823
|
+
try:
|
|
824
|
+
attributes = os.listxattr(descriptor)
|
|
825
|
+
except OSError as error:
|
|
826
|
+
raise Failure("capability_missing", exit_code=4) from error
|
|
827
|
+
if attributes:
|
|
828
|
+
raise Failure("capability_missing", exit_code=4)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def write_capabilities():
|
|
832
|
+
if (not all(hasattr(os, name) for name in ("replace", "link", "fsync", "listxattr"))
|
|
833
|
+
or os.open not in os.supports_dir_fd or os.link not in os.supports_dir_fd):
|
|
834
|
+
raise Failure("capability_missing", exit_code=4)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def maximum_note_bytes(request):
|
|
838
|
+
limits = [value for value in (
|
|
839
|
+
request["limits"]["max_file_bytes"], request["limits"]["max_total_bytes"],
|
|
840
|
+
) if value is not None]
|
|
841
|
+
return min(limits) if limits else None
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def diff_bytes(before, candidate, target):
|
|
845
|
+
lines = difflib.unified_diff(
|
|
846
|
+
(before or b"").decode("utf-8").splitlines(keepends=True),
|
|
847
|
+
candidate.decode("utf-8").splitlines(keepends=True),
|
|
848
|
+
fromfile=target if before is not None else "/dev/null", tofile=target,
|
|
849
|
+
)
|
|
850
|
+
return "".join(
|
|
851
|
+
line if line.endswith(("\n", "\r")) else line + "\n\\n"
|
|
852
|
+
for line in lines
|
|
853
|
+
).encode("utf-8")
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def local_filesystem(descriptor):
|
|
857
|
+
try:
|
|
858
|
+
location = os.readlink(f"/proc/self/fd/{descriptor}")
|
|
859
|
+
matches = []
|
|
860
|
+
for line in Path("/proc/self/mountinfo").read_text().splitlines():
|
|
861
|
+
fields = line.split()
|
|
862
|
+
mount = re.sub(
|
|
863
|
+
r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), fields[4],
|
|
864
|
+
)
|
|
865
|
+
if mount == "/" or location == mount or location.startswith(mount + "/"):
|
|
866
|
+
matches.append((len(mount), fields[fields.index("-") + 1]))
|
|
867
|
+
filesystem = max(matches)[1] if matches else None
|
|
868
|
+
except (OSError, ValueError, IndexError) as error:
|
|
869
|
+
raise Failure("capability_missing", exit_code=4) from error
|
|
870
|
+
if filesystem not in {"ext2", "ext3", "ext4", "xfs", "btrfs", "tmpfs", "overlay", "zfs", "f2fs"}:
|
|
871
|
+
raise Failure("capability_missing", exit_code=4)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def plan(request, vault, report, args):
|
|
875
|
+
try:
|
|
876
|
+
validate(request, "PlanRequest")
|
|
877
|
+
except ValidationError as error:
|
|
878
|
+
raise Failure("invalid_request") from error
|
|
879
|
+
target = assert_target(request["write_target"])
|
|
880
|
+
if not request["consent"]["workspace_copies"]:
|
|
881
|
+
raise Failure("consent_missing")
|
|
882
|
+
workspace, _ = workspace_path(request, request["workspace"])
|
|
883
|
+
descriptor = directory(workspace)
|
|
884
|
+
os.close(descriptor)
|
|
885
|
+
candidate = workspace_read(request, args.candidate)
|
|
886
|
+
candidate.decode("utf-8")
|
|
887
|
+
review_bytes = None
|
|
888
|
+
before = None
|
|
889
|
+
with vault.parent(target) as (parent, name):
|
|
890
|
+
existing = None
|
|
891
|
+
try:
|
|
892
|
+
existing = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
893
|
+
except FileNotFoundError:
|
|
894
|
+
pass
|
|
895
|
+
if request["intent"] == "proofread":
|
|
896
|
+
before, metadata = vault.read(target, maximum_note_bytes(request))
|
|
897
|
+
if digest(before) != request["base_sha256"]:
|
|
898
|
+
raise Failure("target_changed", target)
|
|
899
|
+
if before == candidate:
|
|
900
|
+
report["data"] = {"plan_path": None, "plan_sha256": None,
|
|
901
|
+
"operation_id": None, "gate_status": "passed",
|
|
902
|
+
"no_changes": True}
|
|
903
|
+
return
|
|
904
|
+
metadata_supported(metadata)
|
|
905
|
+
with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
|
|
906
|
+
metadata_supported(os.fstat(stream.fileno()), stream.fileno())
|
|
907
|
+
elif existing is not None:
|
|
908
|
+
raise Failure("target_exists", target)
|
|
909
|
+
write_capabilities()
|
|
910
|
+
local_filesystem(parent)
|
|
911
|
+
maximum = maximum_note_bytes(request)
|
|
912
|
+
if maximum is not None and len(candidate) > maximum:
|
|
913
|
+
raise Failure("limit_reached", target)
|
|
914
|
+
if not (SKILL.parent / "tech-writer" / "SKILL.md").is_file():
|
|
915
|
+
raise Failure("dependency_missing", exit_code=4)
|
|
916
|
+
load_gate()
|
|
917
|
+
review_bytes = workspace_read(request, args.review) if args.review else None
|
|
918
|
+
review = review_data(review_bytes) if review_bytes is not None else None
|
|
919
|
+
if before is not None:
|
|
920
|
+
edit_gate(before, candidate, request["protected_terms"], review)
|
|
921
|
+
else:
|
|
922
|
+
if parse_note(target, candidate)["fatal"]:
|
|
923
|
+
raise Failure("unsupported_format", target)
|
|
924
|
+
if request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8")):
|
|
925
|
+
raise Failure("gate_blocked", target)
|
|
926
|
+
planned = {
|
|
927
|
+
"schema_version": 1, "request_sha256": report["request_sha256"],
|
|
928
|
+
"contract_sha256": digest(CONTRACT_PATH.read_bytes()),
|
|
929
|
+
"implementation_sha256": implementation_hash(),
|
|
930
|
+
"vault_identity": identity(os.fstat(vault.fd)), "target": target,
|
|
931
|
+
"kind": "edit" if before is not None else "create",
|
|
932
|
+
"before_sha256": digest(before) if before is not None else None,
|
|
933
|
+
"after_sha256": digest(candidate),
|
|
934
|
+
"review_sha256": digest(review_bytes) if review_bytes is not None else None,
|
|
935
|
+
"state": "validated", "protected_terms": request["protected_terms"],
|
|
936
|
+
}
|
|
937
|
+
planned["operation_id"] = operation_id(planned, os.fstat(parent))
|
|
938
|
+
operation = Path(workspace) / planned["operation_id"]
|
|
939
|
+
try:
|
|
940
|
+
operation.mkdir(mode=0o700)
|
|
941
|
+
except FileExistsError:
|
|
942
|
+
pass
|
|
943
|
+
descriptor = directory(operation)
|
|
944
|
+
try:
|
|
945
|
+
metadata = os.fstat(descriptor)
|
|
946
|
+
if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
|
|
947
|
+
raise Failure("capability_missing", exit_code=4)
|
|
948
|
+
finally:
|
|
949
|
+
os.close(descriptor)
|
|
950
|
+
planned["stage_target"] = posixpath.join(
|
|
951
|
+
posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
|
|
952
|
+
)
|
|
953
|
+
diff = diff_bytes(before, candidate, target)
|
|
954
|
+
planned["diff_sha256"] = digest(diff)
|
|
955
|
+
contents = {"candidate": candidate, "diff": diff}
|
|
956
|
+
if before is not None:
|
|
957
|
+
contents["before"] = before
|
|
958
|
+
if review_bytes is not None:
|
|
959
|
+
contents["review"] = review_bytes
|
|
960
|
+
filenames = {"candidate": "candidate.md", "before": "before.md",
|
|
961
|
+
"diff": "diff.patch", "review": "semantic-review.json"}
|
|
962
|
+
planned["artifacts"] = {
|
|
963
|
+
key: str(operation / filename) if key in contents else None
|
|
964
|
+
for key, filename in filenames.items()
|
|
965
|
+
}
|
|
966
|
+
for key, data in contents.items():
|
|
967
|
+
write_private(operation / filenames[key], data, reuse=True)
|
|
968
|
+
validate(planned, "Plan")
|
|
969
|
+
plan_bytes = canonical(planned)
|
|
970
|
+
write_private(operation / "plan.json", plan_bytes, reuse=True)
|
|
971
|
+
report.update(operation_state="validated", data={
|
|
972
|
+
"plan_path": str(operation / "plan.json"), "plan_sha256": digest(plan_bytes),
|
|
973
|
+
"operation_id": planned["operation_id"],
|
|
974
|
+
"gate_status": "passed" if before is not None else "not_applicable",
|
|
975
|
+
"no_changes": False,
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def exclusive_create(parent, stage_name, name):
|
|
980
|
+
os.link(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def apply(request, vault, report, args):
|
|
984
|
+
if not request["consent"]["workspace_copies"]:
|
|
985
|
+
raise Failure("consent_missing")
|
|
986
|
+
plan_bytes = workspace_read(request, args.plan)
|
|
987
|
+
if digest(plan_bytes) != args.approve_plan_sha256:
|
|
988
|
+
raise Failure("plan_not_approved")
|
|
989
|
+
planned = strict_json(plan_bytes)
|
|
990
|
+
validate(planned, "Plan")
|
|
991
|
+
if planned["state"] != "validated":
|
|
992
|
+
raise Failure("gate_blocked")
|
|
993
|
+
if planned["request_sha256"] != report["request_sha256"]:
|
|
994
|
+
raise Failure("request_changed")
|
|
995
|
+
if planned["contract_sha256"] != digest(CONTRACT_PATH.read_bytes()):
|
|
996
|
+
raise Failure("implementation_changed")
|
|
997
|
+
if planned["implementation_sha256"] != implementation_hash():
|
|
998
|
+
raise Failure("implementation_changed")
|
|
999
|
+
if planned["vault_identity"] != identity(os.fstat(vault.fd)):
|
|
1000
|
+
raise Failure("parent_changed")
|
|
1001
|
+
if planned["protected_terms"] != request["protected_terms"]:
|
|
1002
|
+
raise Failure("request_changed")
|
|
1003
|
+
target = assert_target(planned["target"])
|
|
1004
|
+
if (target != request["write_target"]
|
|
1005
|
+
or planned["kind"] != ("edit" if request["intent"] == "proofread" else "create")):
|
|
1006
|
+
raise Failure("request_changed", target)
|
|
1007
|
+
if planned["kind"] == "edit" and request["base_sha256"] != planned["before_sha256"]:
|
|
1008
|
+
raise Failure("request_changed", target)
|
|
1009
|
+
workspace, _ = workspace_path(request, args.plan)
|
|
1010
|
+
operation = Path(workspace) / planned["operation_id"]
|
|
1011
|
+
if os.path.normpath(args.plan) != str(operation / "plan.json"):
|
|
1012
|
+
raise Failure("invalid_path")
|
|
1013
|
+
expected_artifacts = {
|
|
1014
|
+
"candidate": operation / "candidate.md", "before": operation / "before.md",
|
|
1015
|
+
"review": operation / "semantic-review.json", "diff": operation / "diff.patch",
|
|
1016
|
+
}
|
|
1017
|
+
loaded = {}
|
|
1018
|
+
for key, path in planned["artifacts"].items():
|
|
1019
|
+
if path is not None:
|
|
1020
|
+
if path != str(expected_artifacts[key]):
|
|
1021
|
+
raise Failure("invalid_path")
|
|
1022
|
+
loaded[key] = workspace_read(request, path)
|
|
1023
|
+
candidate = loaded["candidate"]
|
|
1024
|
+
before = loaded.get("before")
|
|
1025
|
+
review_bytes = loaded.get("review")
|
|
1026
|
+
if digest(candidate) != planned["after_sha256"]:
|
|
1027
|
+
raise Failure("candidate_changed")
|
|
1028
|
+
if digest(loaded["diff"]) != planned["diff_sha256"]:
|
|
1029
|
+
raise Failure("candidate_changed")
|
|
1030
|
+
if (digest(before) if before is not None else None) != planned["before_sha256"]:
|
|
1031
|
+
raise Failure("target_changed")
|
|
1032
|
+
if (digest(review_bytes) if review_bytes is not None else None) != planned["review_sha256"]:
|
|
1033
|
+
raise Failure("semantic_review_required")
|
|
1034
|
+
if before is not None:
|
|
1035
|
+
if not vault.allowed(target):
|
|
1036
|
+
raise Failure("outside_scope", target)
|
|
1037
|
+
edit_gate(before, candidate, request["protected_terms"],
|
|
1038
|
+
review_data(review_bytes) if review_bytes is not None else None)
|
|
1039
|
+
elif (parse_note(target, candidate)["fatal"] or
|
|
1040
|
+
request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8"))):
|
|
1041
|
+
raise Failure("gate_blocked", target)
|
|
1042
|
+
stage = posixpath.join(
|
|
1043
|
+
posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
|
|
1044
|
+
)
|
|
1045
|
+
if planned["stage_target"] != stage:
|
|
1046
|
+
raise Failure("invalid_path", stage)
|
|
1047
|
+
report["operation_state"] = "approved"
|
|
1048
|
+
report["data"] = {
|
|
1049
|
+
"target": target, "after_sha256": None, "operation_id": planned["operation_id"],
|
|
1050
|
+
"changed": False, "verified": False,
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
def read_at(parent, name):
|
|
1054
|
+
try:
|
|
1055
|
+
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
1056
|
+
except OSError as error:
|
|
1057
|
+
if error.errno == 40:
|
|
1058
|
+
raise Failure("symlink_not_followed", target) from error
|
|
1059
|
+
raise
|
|
1060
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
1061
|
+
metadata = os.fstat(stream.fileno())
|
|
1062
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
1063
|
+
raise Failure("unsupported_format", target)
|
|
1064
|
+
maximum = maximum_note_bytes(request)
|
|
1065
|
+
if maximum is not None and metadata.st_size > maximum:
|
|
1066
|
+
raise Failure("limit_reached", target)
|
|
1067
|
+
data = stream.read() if maximum is None else stream.read(maximum + 1)
|
|
1068
|
+
if maximum is not None and len(data) > maximum:
|
|
1069
|
+
raise Failure("limit_reached", target)
|
|
1070
|
+
return data, metadata
|
|
1071
|
+
|
|
1072
|
+
with vault.parent(target) as (parent, name):
|
|
1073
|
+
local_filesystem(parent)
|
|
1074
|
+
if operation_id(planned, os.fstat(parent)) != planned["operation_id"]:
|
|
1075
|
+
raise Failure("parent_changed", target)
|
|
1076
|
+
expected_receipt = {
|
|
1077
|
+
"schema_version": 1, "operation_id": planned["operation_id"],
|
|
1078
|
+
"plan_sha256": digest(plan_bytes), "request_sha256": report["request_sha256"],
|
|
1079
|
+
"target": target, "after_sha256": digest(candidate), "verified": True,
|
|
1080
|
+
}
|
|
1081
|
+
receipt_path = operation / "receipt.json"
|
|
1082
|
+
try:
|
|
1083
|
+
receipt_bytes = workspace_read(request, receipt_path)
|
|
1084
|
+
except FileNotFoundError:
|
|
1085
|
+
receipt_bytes = None
|
|
1086
|
+
if receipt_bytes is not None:
|
|
1087
|
+
try:
|
|
1088
|
+
receipt = strict_json(receipt_bytes)
|
|
1089
|
+
validate(receipt, "Receipt")
|
|
1090
|
+
except (ValueError, ValidationError) as error:
|
|
1091
|
+
raise Failure("commit_outcome_uncertain", target) from error
|
|
1092
|
+
if receipt != expected_receipt:
|
|
1093
|
+
raise Failure("commit_outcome_uncertain", target)
|
|
1094
|
+
if not vault.allowed(target):
|
|
1095
|
+
raise Failure("outside_scope", target)
|
|
1096
|
+
try:
|
|
1097
|
+
current, _ = read_at(parent, name)
|
|
1098
|
+
except FileNotFoundError as error:
|
|
1099
|
+
raise Failure("target_changed", target) from error
|
|
1100
|
+
if digest(current) != planned["after_sha256"]:
|
|
1101
|
+
raise Failure("target_changed", target)
|
|
1102
|
+
report.update(operation_state="saved", write_status="saved")
|
|
1103
|
+
report["data"].update(after_sha256=digest(current), changed=False, verified=True)
|
|
1104
|
+
report["issues"].append(issue("already_applied", target))
|
|
1105
|
+
return
|
|
1106
|
+
result_path = operation / "result.json"
|
|
1107
|
+
try:
|
|
1108
|
+
previous = strict_json(workspace_read(request, result_path))
|
|
1109
|
+
except FileNotFoundError:
|
|
1110
|
+
previous = None
|
|
1111
|
+
if previous is not None:
|
|
1112
|
+
validate(previous, "Report")
|
|
1113
|
+
if previous["write_status"] in {"unknown", "saved"}:
|
|
1114
|
+
raise Failure("commit_outcome_uncertain", target)
|
|
1115
|
+
try:
|
|
1116
|
+
current_metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
1117
|
+
except FileNotFoundError:
|
|
1118
|
+
current_metadata = None
|
|
1119
|
+
metadata = None
|
|
1120
|
+
if planned["kind"] == "create" and current_metadata is not None:
|
|
1121
|
+
if vault.allowed(target) and stat.S_ISREG(current_metadata.st_mode):
|
|
1122
|
+
current, _ = read_at(parent, name)
|
|
1123
|
+
if digest(current) == planned["after_sha256"]:
|
|
1124
|
+
raise Failure("commit_outcome_uncertain", target)
|
|
1125
|
+
raise Failure("target_exists", target)
|
|
1126
|
+
if planned["kind"] == "edit":
|
|
1127
|
+
if current_metadata is None:
|
|
1128
|
+
raise Failure("target_changed", target)
|
|
1129
|
+
current, metadata = vault.read(target, maximum_note_bytes(request))
|
|
1130
|
+
if digest(current) == planned["after_sha256"]:
|
|
1131
|
+
raise Failure("commit_outcome_uncertain", target)
|
|
1132
|
+
if digest(current) != planned["before_sha256"]:
|
|
1133
|
+
raise Failure("target_changed", target)
|
|
1134
|
+
with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
|
|
1135
|
+
metadata_supported(os.fstat(stream.fileno()), stream.fileno())
|
|
1136
|
+
try:
|
|
1137
|
+
os.stat(PurePosixPath(stage).name, dir_fd=parent, follow_symlinks=False)
|
|
1138
|
+
except FileNotFoundError:
|
|
1139
|
+
pass
|
|
1140
|
+
else:
|
|
1141
|
+
raise Failure("target_exists", stage)
|
|
1142
|
+
pending = dict(report, status="failed", exit_code=1, write_status="unknown",
|
|
1143
|
+
issues=[issue("commit_outcome_uncertain", target)])
|
|
1144
|
+
replace_private(result_path, canonical(pending))
|
|
1145
|
+
committed, own_stage = False, False
|
|
1146
|
+
stage_name = PurePosixPath(stage).name
|
|
1147
|
+
try:
|
|
1148
|
+
try:
|
|
1149
|
+
descriptor = os.open(
|
|
1150
|
+
stage_name, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
1151
|
+
0o666 if planned["kind"] == "create" else 0o600, dir_fd=parent,
|
|
1152
|
+
)
|
|
1153
|
+
except FileExistsError as error:
|
|
1154
|
+
raise Failure("target_exists", stage) from error
|
|
1155
|
+
own_stage = True
|
|
1156
|
+
with os.fdopen(descriptor, "w+b") as stream:
|
|
1157
|
+
stream.write(candidate)
|
|
1158
|
+
stream.flush()
|
|
1159
|
+
if metadata is not None:
|
|
1160
|
+
os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
|
|
1161
|
+
os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
|
|
1162
|
+
os.fsync(stream.fileno())
|
|
1163
|
+
current_root = directory(vault.root)
|
|
1164
|
+
try:
|
|
1165
|
+
if identity(os.fstat(current_root)) != planned["vault_identity"]:
|
|
1166
|
+
raise Failure("parent_changed", target)
|
|
1167
|
+
finally:
|
|
1168
|
+
os.close(current_root)
|
|
1169
|
+
with vault.parent(target) as (current_parent, _):
|
|
1170
|
+
if identity(os.fstat(current_parent)) != identity(os.fstat(parent)):
|
|
1171
|
+
raise Failure("parent_changed", target)
|
|
1172
|
+
if before is not None:
|
|
1173
|
+
current, current_metadata = read_at(parent, name)
|
|
1174
|
+
if digest(current) != planned["before_sha256"]:
|
|
1175
|
+
raise Failure("target_changed", target)
|
|
1176
|
+
if (identity(current_metadata) != identity(metadata)
|
|
1177
|
+
or current_metadata.st_mode != metadata.st_mode
|
|
1178
|
+
or current_metadata.st_gid != metadata.st_gid):
|
|
1179
|
+
raise Failure("target_changed", target)
|
|
1180
|
+
with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as check:
|
|
1181
|
+
metadata_supported(os.fstat(check.fileno()), check.fileno())
|
|
1182
|
+
os.replace(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
1183
|
+
committed, own_stage = True, False
|
|
1184
|
+
else:
|
|
1185
|
+
try:
|
|
1186
|
+
exclusive_create(parent, stage_name, name)
|
|
1187
|
+
except FileExistsError as error:
|
|
1188
|
+
raise Failure("target_exists", target) from error
|
|
1189
|
+
committed = True
|
|
1190
|
+
os.fsync(parent)
|
|
1191
|
+
verified, _ = read_at(parent, name)
|
|
1192
|
+
if digest(verified) != planned["after_sha256"]:
|
|
1193
|
+
raise OSError("Post-commit verification failed")
|
|
1194
|
+
if own_stage:
|
|
1195
|
+
os.unlink(stage_name, dir_fd=parent)
|
|
1196
|
+
own_stage = False
|
|
1197
|
+
os.fsync(parent)
|
|
1198
|
+
replace_private(receipt_path, canonical(expected_receipt))
|
|
1199
|
+
report.update(operation_state="saved", write_status="saved")
|
|
1200
|
+
report["data"].update(
|
|
1201
|
+
after_sha256=digest(verified), changed=True, verified=True,
|
|
1202
|
+
)
|
|
1203
|
+
report["issues"].append(issue("saved", target))
|
|
1204
|
+
replace_private(result_path, canonical(report))
|
|
1205
|
+
except (OSError, Failure) as error:
|
|
1206
|
+
report["issues"] = [item for item in report["issues"] if item["code"] != "saved"]
|
|
1207
|
+
if own_stage:
|
|
1208
|
+
try:
|
|
1209
|
+
os.unlink(stage_name, dir_fd=parent)
|
|
1210
|
+
except OSError as cleanup_error:
|
|
1211
|
+
report["issues"].append(issue("runtime_error", stage))
|
|
1212
|
+
if not committed:
|
|
1213
|
+
report.update(status="failed", exit_code=1, operation_state="blocked")
|
|
1214
|
+
report["issues"].append(issue("write_failed_before_commit", target))
|
|
1215
|
+
raise Failure("write_failed_before_commit", target, status="failed", exit_code=1) from cleanup_error
|
|
1216
|
+
report.update(
|
|
1217
|
+
status="failed" if committed or not isinstance(error, Failure) else error.status,
|
|
1218
|
+
exit_code=1 if committed or not isinstance(error, Failure) else error.exit_code,
|
|
1219
|
+
operation_state="blocked", write_status="unknown" if committed else "unsaved",
|
|
1220
|
+
)
|
|
1221
|
+
report["data"].update(changed=None if committed else False, verified=False,
|
|
1222
|
+
after_sha256=None)
|
|
1223
|
+
code = "commit_outcome_uncertain" if committed else error.code if isinstance(error, Failure) else "write_failed_before_commit"
|
|
1224
|
+
report["issues"].append(issue(code, target))
|
|
1225
|
+
try:
|
|
1226
|
+
replace_private(result_path, canonical(report))
|
|
1227
|
+
except (OSError, Failure):
|
|
1228
|
+
report["issues"].append(issue("runtime_error", str(result_path)))
|
|
1229
|
+
return
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
class Parser(argparse.ArgumentParser):
|
|
1233
|
+
def error(self, message):
|
|
1234
|
+
raise Failure("invalid_request", status="failed", exit_code=1)
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def arguments(argv):
|
|
1238
|
+
parser = Parser(description=__doc__)
|
|
1239
|
+
commands = parser.add_subparsers(dest="command", required=True, parser_class=Parser)
|
|
1240
|
+
for name, command in CONTRACT["x-commands"].items():
|
|
1241
|
+
child = commands.add_parser(name)
|
|
1242
|
+
for flag in command["required"]:
|
|
1243
|
+
child.add_argument(flag, required=True)
|
|
1244
|
+
for flag in command["optional"]:
|
|
1245
|
+
child.add_argument(flag)
|
|
1246
|
+
return parser.parse_args(argv)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def run(argv):
|
|
1250
|
+
command = argv[0] if argv and argv[0] in CONTRACT["x-commands"] else "inspect"
|
|
1251
|
+
report = {
|
|
1252
|
+
"schema_version": 1, "command": command, "status": "completed", "exit_code": 0,
|
|
1253
|
+
"write_status": "unsaved" if command in {"plan", "apply"} else "not_requested",
|
|
1254
|
+
"operation_state": "scoped", "request_sha256": None,
|
|
1255
|
+
"coverage": {"whole_vault": False, "enumeration_complete": True,
|
|
1256
|
+
"reads_complete": True, "parses_complete": True,
|
|
1257
|
+
"processed": [], "unprocessed": []},
|
|
1258
|
+
"issues": [], "data": None,
|
|
1259
|
+
}
|
|
1260
|
+
vault = None
|
|
1261
|
+
try:
|
|
1262
|
+
args = arguments(argv)
|
|
1263
|
+
request_bytes = Path(args.request).read_bytes()
|
|
1264
|
+
report["request_sha256"] = digest(request_bytes)
|
|
1265
|
+
request = strict_json(request_bytes)
|
|
1266
|
+
validate(request, "Request")
|
|
1267
|
+
if not request["consent"]["model_context"]:
|
|
1268
|
+
raise Failure("consent_missing")
|
|
1269
|
+
if sys.platform != "linux" or not hasattr(os, "O_NOFOLLOW"):
|
|
1270
|
+
raise Failure("capability_missing", exit_code=4)
|
|
1271
|
+
vault = Vault(request)
|
|
1272
|
+
report["coverage"]["whole_vault"] = ("directory", ".") in vault.scopes
|
|
1273
|
+
if command == "inspect":
|
|
1274
|
+
inspect(request, vault, report)
|
|
1275
|
+
elif command == "read":
|
|
1276
|
+
data, _ = vault.read(args.path, maximum_note_bytes(request))
|
|
1277
|
+
report["coverage"]["processed"] = [relative(args.path)]
|
|
1278
|
+
report["data"] = {"path": relative(args.path), "sha256": digest(data),
|
|
1279
|
+
"bytes": len(data), "content": data.decode("utf-8")}
|
|
1280
|
+
else:
|
|
1281
|
+
if command == "apply":
|
|
1282
|
+
write_capabilities()
|
|
1283
|
+
fcntl.flock(vault.fd, fcntl.LOCK_EX)
|
|
1284
|
+
try:
|
|
1285
|
+
if command == "plan":
|
|
1286
|
+
plan(request, vault, report, args)
|
|
1287
|
+
else:
|
|
1288
|
+
apply(request, vault, report, args)
|
|
1289
|
+
finally:
|
|
1290
|
+
fcntl.flock(vault.fd, fcntl.LOCK_UN)
|
|
1291
|
+
except Failure as error:
|
|
1292
|
+
report.update(status=error.status, exit_code=error.exit_code,
|
|
1293
|
+
operation_state="blocked")
|
|
1294
|
+
report["issues"].append(issue(error.code, error.path))
|
|
1295
|
+
except (ValueError, UnicodeError, ValidationError, OSError) as error:
|
|
1296
|
+
code = ("unsupported_format" if isinstance(error, UnicodeError)
|
|
1297
|
+
else "read_failed" if command == "read" and isinstance(error, OSError)
|
|
1298
|
+
else "invalid_request" if isinstance(error, (ValueError, ValidationError))
|
|
1299
|
+
else "runtime_error")
|
|
1300
|
+
report.update(status="failed", exit_code=1, operation_state="blocked")
|
|
1301
|
+
report["issues"].append(issue(code))
|
|
1302
|
+
finally:
|
|
1303
|
+
if vault is not None:
|
|
1304
|
+
vault.close()
|
|
1305
|
+
validate(report, "Report")
|
|
1306
|
+
return report
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def main():
|
|
1310
|
+
report = run(sys.argv[1:])
|
|
1311
|
+
print(json.dumps(report, ensure_ascii=False, allow_nan=False))
|
|
1312
|
+
return report["exit_code"]
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
if __name__ == "__main__":
|
|
1316
|
+
sys.exit(main())
|