@ictechgy/context-guard 0.5.1 → 0.7.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 +21 -0
- package/README.ko.md +13 -1
- package/README.md +13 -1
- package/docs/distribution.md +2 -2
- package/package.json +2 -1
- package/packaging/homebrew/context-guard.rb.template +5 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +11 -1
- package/plugins/context-guard/README.md +13 -2
- package/plugins/context-guard/bin/context-guard-artifact +1 -1
- package/plugins/context-guard/bin/context-guard-cost +327 -0
- package/plugins/context-guard/bin/context-guard-pack +626 -3
- package/plugins/context-guard/bin/context-guard-setup +89 -12
- package/plugins/context-guard/bin/context-guard-task-memory +635 -0
- package/plugins/context-guard/lib/context_guard_commands.py +21 -1
- package/plugins/context-guard/lib/context_pack_git_boundary.py +19 -0
- package/plugins/context-guard/lib/context_pack_identity.py +115 -0
- package/plugins/context-guard/lib/context_pack_receipts.py +9 -0
- package/plugins/context-guard/lib/context_pack_rendering.py +12 -0
- package/plugins/context-guard/lib/context_pack_scanning.py +10 -0
- package/plugins/context-guard/lib/context_pack_selection.py +6 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Revision-bound, provider-free persistent task memory."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import selectors
|
|
15
|
+
import signal
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Iterator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SCHEMA = "contextguard.task-memory.v1"
|
|
24
|
+
DEFAULT_STORE = ".context-guard/task-memory"
|
|
25
|
+
DEFAULT_TTL = 7 * 24 * 60 * 60
|
|
26
|
+
DEFAULT_MAX_ENTRY_BYTES = 1_000_000
|
|
27
|
+
DEFAULT_MAX_TOTAL_BYTES = 10_000_000
|
|
28
|
+
DEFAULT_MAX_ENTRIES = 100
|
|
29
|
+
MAX_EXACT_BYTES = 1_000_000
|
|
30
|
+
MAX_SOURCE_BYTES = 10_000_000
|
|
31
|
+
MAX_REVISION_BYTES = 10_000_000
|
|
32
|
+
MAX_REVISION_FILES = 4_096
|
|
33
|
+
MAX_GIT_STDOUT_BYTES = 2_000_000
|
|
34
|
+
MAX_GIT_STDERR_BYTES = 128_000
|
|
35
|
+
MAX_GIT_SECONDS = 10
|
|
36
|
+
MAX_METADATA_BYTES = 128_000
|
|
37
|
+
HANDLE_RE = re.compile(r"^contextguard-memory:([a-f0-9]{32})$")
|
|
38
|
+
SECRET_RE = re.compile(
|
|
39
|
+
rb"(?i)(Bearer\s+\S+|Basic\s+\S+|gh[pousr]_[A-Za-z0-9_]{20,}|"
|
|
40
|
+
rb"github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|"
|
|
41
|
+
rb"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}|sk-[A-Za-z0-9][A-Za-z0-9_-]{20,}|"
|
|
42
|
+
rb"AIza[0-9A-Za-z_-]{20,}|(?:api[_-]?key|token|secret|password|passwd|pwd)\s*[:=]\s*\S+)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MemoryError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sha256(data: bytes) -> str:
|
|
51
|
+
return hashlib.sha256(data).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def canonical(value: Any) -> bytes:
|
|
55
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def regular_private(path: Path, *, label: str) -> os.stat_result:
|
|
59
|
+
try:
|
|
60
|
+
info = os.lstat(path)
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise MemoryError(f"cannot inspect {label}") from exc
|
|
63
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o600:
|
|
64
|
+
raise MemoryError(f"unsafe {label}")
|
|
65
|
+
return info
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def private_directory(path: Path, *, label: str) -> os.stat_result:
|
|
69
|
+
try:
|
|
70
|
+
info = os.lstat(path)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise MemoryError(f"cannot inspect {label}") from exc
|
|
73
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700:
|
|
74
|
+
raise MemoryError(f"unsafe {label}")
|
|
75
|
+
return info
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def secure_read(path: Path, *, maximum: int, label: str) -> bytes:
|
|
79
|
+
info = regular_private(path, label=label)
|
|
80
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
81
|
+
try:
|
|
82
|
+
fd = os.open(path, flags)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise MemoryError(f"cannot open {label}") from exc
|
|
85
|
+
try:
|
|
86
|
+
before = os.fstat(fd)
|
|
87
|
+
if not stat.S_ISREG(before.st_mode) or before.st_uid != os.geteuid() or before.st_nlink != 1 or stat.S_IMODE(before.st_mode) != 0o600:
|
|
88
|
+
raise MemoryError(f"unsafe {label}")
|
|
89
|
+
data = os.read(fd, maximum + 1)
|
|
90
|
+
after = os.fstat(fd)
|
|
91
|
+
if len(data) > maximum or (info.st_dev, info.st_ino, info.st_size) != (before.st_dev, before.st_ino, before.st_size) or (before.st_dev, before.st_ino, before.st_size) != (after.st_dev, after.st_ino, after.st_size):
|
|
92
|
+
raise MemoryError(f"invalid {label}")
|
|
93
|
+
return data
|
|
94
|
+
finally:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def atomic_write(directory: Path, name: str, data: bytes) -> None:
|
|
99
|
+
temp = f".{name}.{secrets.token_hex(8)}.tmp"
|
|
100
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
101
|
+
dir_fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0))
|
|
102
|
+
try:
|
|
103
|
+
fd = os.open(temp, flags, 0o600, dir_fd=dir_fd)
|
|
104
|
+
except Exception:
|
|
105
|
+
os.close(dir_fd)
|
|
106
|
+
raise
|
|
107
|
+
try:
|
|
108
|
+
offset = 0
|
|
109
|
+
while offset < len(data):
|
|
110
|
+
offset += os.write(fd, data[offset:])
|
|
111
|
+
os.fsync(fd)
|
|
112
|
+
os.fchmod(fd, 0o600)
|
|
113
|
+
finally:
|
|
114
|
+
os.close(fd)
|
|
115
|
+
try:
|
|
116
|
+
os.replace(temp, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
117
|
+
os.fsync(dir_fd)
|
|
118
|
+
except Exception:
|
|
119
|
+
try:
|
|
120
|
+
os.unlink(temp, dir_fd=dir_fd)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
pass
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
os.close(dir_fd)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def source_read(path: Path) -> tuple[bytes, os.stat_result]:
|
|
129
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
130
|
+
try:
|
|
131
|
+
fd = os.open(path, flags)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
raise MemoryError("cannot securely open source") from exc
|
|
134
|
+
try:
|
|
135
|
+
before = os.fstat(fd)
|
|
136
|
+
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
|
|
137
|
+
raise MemoryError("source must be a single-link regular file")
|
|
138
|
+
if before.st_size > MAX_SOURCE_BYTES:
|
|
139
|
+
raise MemoryError("source exceeds bounded identity limit")
|
|
140
|
+
chunks: list[bytes] = []
|
|
141
|
+
observed = 0
|
|
142
|
+
while True:
|
|
143
|
+
chunk = os.read(fd, 64 * 1024)
|
|
144
|
+
if not chunk:
|
|
145
|
+
break
|
|
146
|
+
chunks.append(chunk)
|
|
147
|
+
observed += len(chunk)
|
|
148
|
+
if observed > MAX_SOURCE_BYTES:
|
|
149
|
+
raise MemoryError("source exceeds bounded identity limit")
|
|
150
|
+
after = os.fstat(fd)
|
|
151
|
+
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns):
|
|
152
|
+
raise MemoryError("source changed while reading")
|
|
153
|
+
return b"".join(chunks), after
|
|
154
|
+
finally:
|
|
155
|
+
os.close(fd)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_root(raw: str) -> Path:
|
|
159
|
+
supplied = Path(raw).absolute()
|
|
160
|
+
try:
|
|
161
|
+
if supplied.is_symlink():
|
|
162
|
+
raise MemoryError("project root must not be a symlink")
|
|
163
|
+
root = supplied.resolve(strict=True)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
raise MemoryError("invalid project root") from exc
|
|
166
|
+
info = os.lstat(root)
|
|
167
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid():
|
|
168
|
+
raise MemoryError("project root is not a directory")
|
|
169
|
+
return root
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def resolve_store(root: Path, raw: str) -> Path:
|
|
173
|
+
candidate = Path(raw)
|
|
174
|
+
if candidate.is_absolute():
|
|
175
|
+
try:
|
|
176
|
+
candidate = candidate.parent.resolve(strict=True) / candidate.name
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
raise MemoryError("store parent is unavailable") from exc
|
|
179
|
+
else:
|
|
180
|
+
candidate = root / candidate
|
|
181
|
+
candidate = Path(os.path.abspath(candidate))
|
|
182
|
+
try:
|
|
183
|
+
candidate.relative_to(root)
|
|
184
|
+
except ValueError as exc:
|
|
185
|
+
raise MemoryError("store must be inside project root") from exc
|
|
186
|
+
current = root
|
|
187
|
+
for part in candidate.relative_to(root).parts:
|
|
188
|
+
current = current / part
|
|
189
|
+
try:
|
|
190
|
+
if stat.S_ISLNK(os.lstat(current).st_mode):
|
|
191
|
+
raise MemoryError("store contains a symlink component")
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
continue
|
|
194
|
+
return candidate
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ensure_store(store: Path) -> None:
|
|
198
|
+
store.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
199
|
+
records = store / "records"
|
|
200
|
+
records.mkdir(mode=0o700, exist_ok=True)
|
|
201
|
+
private_directory(store, label="memory store")
|
|
202
|
+
private_directory(records, label="record directory")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class locked_store:
|
|
206
|
+
def __init__(self, store: Path) -> None:
|
|
207
|
+
self.store = store
|
|
208
|
+
self.fd = -1
|
|
209
|
+
|
|
210
|
+
def __enter__(self) -> "locked_store":
|
|
211
|
+
ensure_store(self.store)
|
|
212
|
+
path = self.store / "lock"
|
|
213
|
+
self.fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
214
|
+
os.fchmod(self.fd, 0o600)
|
|
215
|
+
info = os.fstat(self.fd)
|
|
216
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_nlink != 1:
|
|
217
|
+
raise MemoryError("unsafe memory lock")
|
|
218
|
+
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def __exit__(self, *_args: object) -> None:
|
|
222
|
+
if self.fd >= 0:
|
|
223
|
+
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
224
|
+
os.close(self.fd)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load_key(store: Path) -> bytes:
|
|
228
|
+
path = store / "key"
|
|
229
|
+
if not path.exists():
|
|
230
|
+
atomic_write(store, "key", secrets.token_bytes(32))
|
|
231
|
+
key = secure_read(path, maximum=64, label="authentication key")
|
|
232
|
+
if len(key) != 32:
|
|
233
|
+
raise MemoryError("invalid authentication key")
|
|
234
|
+
return key
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def git(root: Path, *args: str) -> bytes:
|
|
238
|
+
executable = "/usr/bin/git"
|
|
239
|
+
if not Path(executable).is_file():
|
|
240
|
+
raise MemoryError("trusted Git executable unavailable")
|
|
241
|
+
environment = {
|
|
242
|
+
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1",
|
|
243
|
+
"GIT_NO_LAZY_FETCH": "1", "GIT_NO_REPLACE_OBJECTS": "1",
|
|
244
|
+
"GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "/usr/bin/false",
|
|
245
|
+
"SSH_ASKPASS": "/usr/bin/false", "LANG": "C", "LC_ALL": "C",
|
|
246
|
+
"PATH": "/usr/bin:/bin",
|
|
247
|
+
}
|
|
248
|
+
command = [
|
|
249
|
+
executable,
|
|
250
|
+
"-c",
|
|
251
|
+
"core.fsmonitor=false",
|
|
252
|
+
"-c",
|
|
253
|
+
"core.hooksPath=/dev/null",
|
|
254
|
+
*args,
|
|
255
|
+
]
|
|
256
|
+
try:
|
|
257
|
+
proc = subprocess.Popen(
|
|
258
|
+
command,
|
|
259
|
+
cwd=root,
|
|
260
|
+
env=environment,
|
|
261
|
+
stdin=subprocess.DEVNULL,
|
|
262
|
+
stdout=subprocess.PIPE,
|
|
263
|
+
stderr=subprocess.PIPE,
|
|
264
|
+
start_new_session=True,
|
|
265
|
+
)
|
|
266
|
+
except OSError:
|
|
267
|
+
raise MemoryError("project must be a readable Git worktree")
|
|
268
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
269
|
+
selector = selectors.DefaultSelector()
|
|
270
|
+
stdout_buffer = bytearray()
|
|
271
|
+
stderr_buffer = bytearray()
|
|
272
|
+
streams = {
|
|
273
|
+
proc.stdout.fileno(): (proc.stdout, stdout_buffer, MAX_GIT_STDOUT_BYTES),
|
|
274
|
+
proc.stderr.fileno(): (proc.stderr, stderr_buffer, MAX_GIT_STDERR_BYTES),
|
|
275
|
+
}
|
|
276
|
+
for descriptor in streams:
|
|
277
|
+
os.set_blocking(descriptor, False)
|
|
278
|
+
selector.register(descriptor, selectors.EVENT_READ)
|
|
279
|
+
deadline = time.monotonic() + MAX_GIT_SECONDS
|
|
280
|
+
try:
|
|
281
|
+
while selector.get_map():
|
|
282
|
+
remaining = deadline - time.monotonic()
|
|
283
|
+
if remaining <= 0:
|
|
284
|
+
raise TimeoutError
|
|
285
|
+
for key, _events in selector.select(min(0.25, remaining)):
|
|
286
|
+
stream, buffer, maximum = streams[key.fd]
|
|
287
|
+
chunk = os.read(key.fd, 64 * 1024)
|
|
288
|
+
if not chunk:
|
|
289
|
+
selector.unregister(key.fd)
|
|
290
|
+
continue
|
|
291
|
+
buffer.extend(chunk)
|
|
292
|
+
if len(buffer) > maximum:
|
|
293
|
+
raise OverflowError
|
|
294
|
+
remaining = deadline - time.monotonic()
|
|
295
|
+
if remaining <= 0:
|
|
296
|
+
raise TimeoutError
|
|
297
|
+
return_code = proc.wait(timeout=remaining)
|
|
298
|
+
except (OSError, OverflowError, TimeoutError, subprocess.TimeoutExpired):
|
|
299
|
+
try:
|
|
300
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
301
|
+
except ProcessLookupError:
|
|
302
|
+
pass
|
|
303
|
+
proc.wait()
|
|
304
|
+
raise MemoryError("project Git metadata exceeds the bounded identity limit") from None
|
|
305
|
+
finally:
|
|
306
|
+
selector.close()
|
|
307
|
+
proc.stdout.close()
|
|
308
|
+
proc.stderr.close()
|
|
309
|
+
if return_code != 0:
|
|
310
|
+
raise MemoryError("project must be a readable Git worktree")
|
|
311
|
+
return bytes(stdout_buffer)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def project_identity(root: Path) -> dict[str, Any]:
|
|
315
|
+
info = os.stat(root)
|
|
316
|
+
return {"physical_root_sha256": sha256(os.fsencode(str(root))), "device": info.st_dev, "inode": info.st_ino}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def staged_paths(raw: bytes) -> set[bytes]:
|
|
320
|
+
fields = raw.split(b"\0")
|
|
321
|
+
if not fields or fields[-1] or (len(fields) - 1) % 2:
|
|
322
|
+
raise MemoryError("staged identity metadata is malformed")
|
|
323
|
+
paths: set[bytes] = set()
|
|
324
|
+
for index in range(0, len(fields) - 1, 2):
|
|
325
|
+
header = fields[index]
|
|
326
|
+
path = fields[index + 1]
|
|
327
|
+
if not header.startswith(b":") or not path or path in paths:
|
|
328
|
+
raise MemoryError("staged identity metadata is malformed")
|
|
329
|
+
paths.add(path)
|
|
330
|
+
return paths
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def worktree_rows(root: Path, names: list[bytes], store_rel: str) -> list[dict[str, str]]:
|
|
334
|
+
rows: list[dict[str, str]] = []
|
|
335
|
+
revision_bytes = 0
|
|
336
|
+
for raw in sorted(set(names)):
|
|
337
|
+
if not raw:
|
|
338
|
+
continue
|
|
339
|
+
rel = os.fsdecode(raw)
|
|
340
|
+
if store_rel and (rel == store_rel or rel.startswith(store_rel + "/")):
|
|
341
|
+
continue
|
|
342
|
+
path = root / rel
|
|
343
|
+
try:
|
|
344
|
+
metadata = os.lstat(path)
|
|
345
|
+
except OSError:
|
|
346
|
+
rows.append({"path_sha256": sha256(raw), "content_sha256": "unsafe"})
|
|
347
|
+
continue
|
|
348
|
+
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
|
349
|
+
rows.append({"path_sha256": sha256(raw), "content_sha256": "unsafe"})
|
|
350
|
+
continue
|
|
351
|
+
data, _metadata = source_read(path)
|
|
352
|
+
revision_bytes += len(data)
|
|
353
|
+
if revision_bytes > MAX_REVISION_BYTES:
|
|
354
|
+
raise MemoryError("worktree identity exceeds bounded byte limit")
|
|
355
|
+
rows.append({"path_sha256": sha256(raw), "content_sha256": sha256(data)})
|
|
356
|
+
return rows
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def revision_identity(root: Path, store: Path) -> dict[str, str]:
|
|
360
|
+
head = git(root, "rev-parse", "HEAD").decode("ascii").strip()
|
|
361
|
+
staged_arguments = (
|
|
362
|
+
"diff", "--cached", "--raw", "-z", "--no-renames", "--no-ext-diff", "--no-textconv",
|
|
363
|
+
)
|
|
364
|
+
names_arguments = ("ls-files", "-m", "-o", "--exclude-standard", "-z")
|
|
365
|
+
staged = git(root, *staged_arguments)
|
|
366
|
+
staged_names = staged_paths(staged)
|
|
367
|
+
names_raw = git(root, *names_arguments)
|
|
368
|
+
names = names_raw.split(b"\0")
|
|
369
|
+
try:
|
|
370
|
+
store_rel = store.relative_to(root).as_posix()
|
|
371
|
+
except ValueError:
|
|
372
|
+
store_rel = ""
|
|
373
|
+
identity_names = {
|
|
374
|
+
raw
|
|
375
|
+
for raw in (*staged_names, *names)
|
|
376
|
+
if raw
|
|
377
|
+
and not (
|
|
378
|
+
store_rel
|
|
379
|
+
and (
|
|
380
|
+
os.fsdecode(raw) == store_rel
|
|
381
|
+
or os.fsdecode(raw).startswith(store_rel + "/")
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
if len(identity_names) > MAX_REVISION_FILES:
|
|
386
|
+
raise MemoryError("worktree identity exceeds bounded file limit")
|
|
387
|
+
rows = worktree_rows(root, names, store_rel)
|
|
388
|
+
if (
|
|
389
|
+
git(root, "rev-parse", "HEAD").decode("ascii").strip() != head
|
|
390
|
+
or git(root, *staged_arguments) != staged
|
|
391
|
+
or git(root, *names_arguments) != names_raw
|
|
392
|
+
):
|
|
393
|
+
raise MemoryError("project revision changed during identity capture")
|
|
394
|
+
confirmed_rows = worktree_rows(root, names, store_rel)
|
|
395
|
+
if (
|
|
396
|
+
confirmed_rows != rows
|
|
397
|
+
or git(root, "rev-parse", "HEAD").decode("ascii").strip() != head
|
|
398
|
+
or git(root, *staged_arguments) != staged
|
|
399
|
+
or git(root, *names_arguments) != names_raw
|
|
400
|
+
):
|
|
401
|
+
raise MemoryError("project revision changed during identity capture")
|
|
402
|
+
return {"head": head, "worktree_sha256": sha256(canonical({"staged": sha256(staged), "files": rows}))}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def source_identities(root: Path, sources: list[str]) -> list[dict[str, Any]]:
|
|
406
|
+
identities: list[dict[str, Any]] = []
|
|
407
|
+
for raw in sources:
|
|
408
|
+
relative = Path(raw)
|
|
409
|
+
if relative.is_absolute() or ".." in relative.parts or "\\" in raw or "\x00" in raw:
|
|
410
|
+
raise MemoryError("source escapes project root")
|
|
411
|
+
candidate = root / relative
|
|
412
|
+
current = root
|
|
413
|
+
for part in relative.parts:
|
|
414
|
+
current = current / part
|
|
415
|
+
if os.path.islink(current):
|
|
416
|
+
raise MemoryError("source contains a symlink")
|
|
417
|
+
data, _info = source_read(candidate)
|
|
418
|
+
if SECRET_RE.search(data):
|
|
419
|
+
raise MemoryError("secret-bearing source refused")
|
|
420
|
+
identities.append({"relative_path_sha256": sha256(os.fsencode(relative.as_posix())), "bytes": len(data), "sha256": sha256(data)})
|
|
421
|
+
return identities
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def signed(metadata: dict[str, Any], key: bytes) -> dict[str, Any]:
|
|
425
|
+
result = dict(metadata)
|
|
426
|
+
result["mac"] = hmac.new(key, canonical(metadata), hashlib.sha256).hexdigest()
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def record_paths(store: Path, record_id: str) -> tuple[Path, Path]:
|
|
431
|
+
return store / "records" / f"{record_id}.json", store / "records" / f"{record_id}.data"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def metadata_rows(store: Path) -> Iterator[tuple[Path, dict[str, Any]]]:
|
|
435
|
+
for path in (store / "records").glob("*.json"):
|
|
436
|
+
try:
|
|
437
|
+
raw = secure_read(path, maximum=MAX_METADATA_BYTES, label="record metadata")
|
|
438
|
+
value = json.loads(raw)
|
|
439
|
+
if isinstance(value, dict):
|
|
440
|
+
yield path, value
|
|
441
|
+
except (MemoryError, json.JSONDecodeError, UnicodeDecodeError):
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def remove_record(store: Path, record_id: str) -> None:
|
|
446
|
+
meta, data = record_paths(store, record_id)
|
|
447
|
+
for path in (meta, data):
|
|
448
|
+
try:
|
|
449
|
+
path.unlink()
|
|
450
|
+
except FileNotFoundError:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cleanup_records(store: Path, key: bytes, now: int) -> int:
|
|
455
|
+
removed = 0
|
|
456
|
+
records = store / "records"
|
|
457
|
+
for temp in records.glob(".*.tmp"):
|
|
458
|
+
try:
|
|
459
|
+
temp.unlink()
|
|
460
|
+
removed += 1
|
|
461
|
+
except OSError:
|
|
462
|
+
pass
|
|
463
|
+
for path in list(records.glob("*.json")):
|
|
464
|
+
record_id = path.stem
|
|
465
|
+
try:
|
|
466
|
+
raw = secure_read(path, maximum=MAX_METADATA_BYTES, label="record metadata")
|
|
467
|
+
meta = json.loads(raw)
|
|
468
|
+
mac = meta.pop("mac")
|
|
469
|
+
valid = hmac.compare_digest(str(mac), hmac.new(key, canonical(meta), hashlib.sha256).hexdigest())
|
|
470
|
+
expired = int(meta["expires_at"]) < now
|
|
471
|
+
data_path = records / f"{record_id}.data"
|
|
472
|
+
maximum = min(int(meta["quota"]["max_entry_bytes"]), MAX_EXACT_BYTES)
|
|
473
|
+
content = secure_read(data_path, maximum=maximum, label="record content")
|
|
474
|
+
digest_valid = meta["content"] == {"bytes": len(content), "sha256": sha256(content)}
|
|
475
|
+
if not valid or expired or not digest_valid or SECRET_RE.search(content):
|
|
476
|
+
raise MemoryError("invalid record")
|
|
477
|
+
except Exception:
|
|
478
|
+
remove_record(store, record_id)
|
|
479
|
+
removed += 1
|
|
480
|
+
for data_path in list(records.glob("*.data")):
|
|
481
|
+
if not data_path.with_suffix(".json").exists():
|
|
482
|
+
try:
|
|
483
|
+
data_path.unlink()
|
|
484
|
+
removed += 1
|
|
485
|
+
except OSError:
|
|
486
|
+
pass
|
|
487
|
+
return removed
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def put_command(args: argparse.Namespace, root: Path, store: Path) -> int:
|
|
491
|
+
content = sys.stdin.buffer.read(args.max_entry_bytes + 1)
|
|
492
|
+
if len(content) > args.max_entry_bytes:
|
|
493
|
+
raise MemoryError("entry quota exceeded")
|
|
494
|
+
if SECRET_RE.search(content):
|
|
495
|
+
raise MemoryError("secret-bearing content refused")
|
|
496
|
+
now = int(time.time())
|
|
497
|
+
with locked_store(store):
|
|
498
|
+
key = load_key(store)
|
|
499
|
+
cleanup_records(store, key, now)
|
|
500
|
+
sources = source_identities(root, args.source)
|
|
501
|
+
project = project_identity(root)
|
|
502
|
+
revision = revision_identity(root, store)
|
|
503
|
+
task_sha = sha256(args.task.encode("utf-8"))
|
|
504
|
+
nonce = secrets.token_bytes(16)
|
|
505
|
+
record_id = hmac.new(key, nonce + canonical(project) + bytes.fromhex(task_sha), hashlib.sha256).hexdigest()[:32]
|
|
506
|
+
metadata: dict[str, Any] = {
|
|
507
|
+
"schema": SCHEMA, "record_id": record_id, "created_at": now,
|
|
508
|
+
"expires_at": now + args.ttl_seconds, "last_accessed_at": now,
|
|
509
|
+
"project": project, "revision": revision, "task_sha256": task_sha,
|
|
510
|
+
"sources": sources, "content": {"bytes": len(content), "sha256": sha256(content)},
|
|
511
|
+
"quota": {"max_entry_bytes": args.max_entry_bytes, "max_total_bytes": args.max_total_bytes, "max_entries": args.max_entries},
|
|
512
|
+
}
|
|
513
|
+
existing = sorted(metadata_rows(store), key=lambda item: int(item[1].get("last_accessed_at", 0)))
|
|
514
|
+
total = sum(int(item[1].get("content", {}).get("bytes", 0)) for item in existing)
|
|
515
|
+
while existing and (len(existing) >= args.max_entries or total + len(content) > args.max_total_bytes):
|
|
516
|
+
old_path, old = existing.pop(0)
|
|
517
|
+
total -= int(old.get("content", {}).get("bytes", 0))
|
|
518
|
+
remove_record(store, old_path.stem)
|
|
519
|
+
if len(existing) >= args.max_entries or total + len(content) > args.max_total_bytes:
|
|
520
|
+
raise MemoryError("store quota exceeded")
|
|
521
|
+
meta_path, data_path = record_paths(store, record_id)
|
|
522
|
+
atomic_write(data_path.parent, data_path.name, content)
|
|
523
|
+
try:
|
|
524
|
+
atomic_write(meta_path.parent, meta_path.name, canonical(signed(metadata, key)))
|
|
525
|
+
except Exception:
|
|
526
|
+
data_path.unlink(missing_ok=True)
|
|
527
|
+
raise
|
|
528
|
+
receipt = {"schema": SCHEMA, "handle": f"contextguard-memory:{record_id}", "expires_at": metadata["expires_at"], "bytes": len(content), "reexpand_command": f"context-guard task-memory get contextguard-memory:{record_id} --task <task> --source <source> --max-bytes {min(len(content), MAX_EXACT_BYTES)}", "claim_boundary": "local provider-free memory; no token/cost savings guarantee"}
|
|
529
|
+
print(json.dumps(receipt, sort_keys=True) if args.json else receipt["handle"])
|
|
530
|
+
return 0
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def get_command(args: argparse.Namespace, root: Path, store: Path) -> int:
|
|
534
|
+
match = HANDLE_RE.fullmatch(args.handle)
|
|
535
|
+
if not match:
|
|
536
|
+
raise MemoryError("invalid public handle")
|
|
537
|
+
record_id = match.group(1)
|
|
538
|
+
now = int(time.time())
|
|
539
|
+
with locked_store(store):
|
|
540
|
+
key = load_key(store)
|
|
541
|
+
meta_path, data_path = record_paths(store, record_id)
|
|
542
|
+
raw_meta = secure_read(meta_path, maximum=MAX_METADATA_BYTES, label="record metadata")
|
|
543
|
+
try:
|
|
544
|
+
signed_meta = json.loads(raw_meta)
|
|
545
|
+
mac = signed_meta.pop("mac")
|
|
546
|
+
except (json.JSONDecodeError, KeyError, AttributeError) as exc:
|
|
547
|
+
raise MemoryError("invalid record metadata") from exc
|
|
548
|
+
expected = hmac.new(key, canonical(signed_meta), hashlib.sha256).hexdigest()
|
|
549
|
+
if not hmac.compare_digest(str(mac), expected):
|
|
550
|
+
raise MemoryError("record authentication failed")
|
|
551
|
+
if signed_meta.get("schema") != SCHEMA or signed_meta.get("record_id") != record_id:
|
|
552
|
+
raise MemoryError("record schema mismatch")
|
|
553
|
+
content_bytes = int(signed_meta["content"]["bytes"])
|
|
554
|
+
if content_bytes > args.max_bytes or args.max_bytes > MAX_EXACT_BYTES:
|
|
555
|
+
raise MemoryError("bounded recovery limit exceeded")
|
|
556
|
+
content = secure_read(data_path, maximum=args.max_bytes, label="record content")
|
|
557
|
+
valid = (
|
|
558
|
+
int(signed_meta["expires_at"]) >= now
|
|
559
|
+
and signed_meta["project"] == project_identity(root)
|
|
560
|
+
and signed_meta["revision"] == revision_identity(root, store)
|
|
561
|
+
and signed_meta["task_sha256"] == sha256(args.task.encode("utf-8"))
|
|
562
|
+
and signed_meta["sources"] == source_identities(root, args.source)
|
|
563
|
+
and signed_meta["content"] == {"bytes": len(content), "sha256": sha256(content)}
|
|
564
|
+
and SECRET_RE.search(content) is None
|
|
565
|
+
)
|
|
566
|
+
if not valid:
|
|
567
|
+
raise MemoryError("record binding invalidated")
|
|
568
|
+
sys.stdout.buffer.write(content)
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cleanup_command(args: argparse.Namespace, _root: Path, store: Path) -> int:
|
|
573
|
+
now = int(time.time())
|
|
574
|
+
with locked_store(store):
|
|
575
|
+
removed = cleanup_records(store, load_key(store), now)
|
|
576
|
+
result = {"schema": SCHEMA, "removed": removed}
|
|
577
|
+
print(json.dumps(result, sort_keys=True) if args.json else f"removed={removed}")
|
|
578
|
+
return 0
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def parser() -> argparse.ArgumentParser:
|
|
582
|
+
result = argparse.ArgumentParser(description="Revision-bound persistent task memory")
|
|
583
|
+
result.add_argument("--root", default=".")
|
|
584
|
+
result.add_argument("--store", default=DEFAULT_STORE)
|
|
585
|
+
commands = result.add_subparsers(required=True)
|
|
586
|
+
put = commands.add_parser("put")
|
|
587
|
+
put.add_argument("--task", required=True)
|
|
588
|
+
put.add_argument("--source", action="append", required=True)
|
|
589
|
+
put.add_argument("--ttl-seconds", type=int, default=DEFAULT_TTL)
|
|
590
|
+
put.add_argument("--max-entry-bytes", type=int, default=DEFAULT_MAX_ENTRY_BYTES)
|
|
591
|
+
put.add_argument("--max-total-bytes", type=int, default=DEFAULT_MAX_TOTAL_BYTES)
|
|
592
|
+
put.add_argument("--max-entries", type=int, default=DEFAULT_MAX_ENTRIES)
|
|
593
|
+
put.add_argument("--json", action="store_true")
|
|
594
|
+
put.set_defaults(func=put_command)
|
|
595
|
+
get = commands.add_parser("get")
|
|
596
|
+
get.add_argument("handle")
|
|
597
|
+
get.add_argument("--task", required=True)
|
|
598
|
+
get.add_argument("--source", action="append", required=True)
|
|
599
|
+
get.add_argument("--max-bytes", type=int, required=True)
|
|
600
|
+
get.set_defaults(func=get_command)
|
|
601
|
+
cleanup = commands.add_parser("cleanup")
|
|
602
|
+
cleanup.add_argument("--json", action="store_true")
|
|
603
|
+
cleanup.set_defaults(func=cleanup_command)
|
|
604
|
+
return result
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def validate_args(args: argparse.Namespace) -> None:
|
|
608
|
+
for name in ("ttl_seconds", "max_entry_bytes", "max_total_bytes", "max_entries", "max_bytes"):
|
|
609
|
+
value = getattr(args, name, None)
|
|
610
|
+
if value is not None and value <= 0:
|
|
611
|
+
raise MemoryError(f"{name.replace('_', '-')} must be positive")
|
|
612
|
+
if getattr(args, "ttl_seconds", 1) > 30 * 24 * 60 * 60:
|
|
613
|
+
raise MemoryError("ttl exceeds maximum")
|
|
614
|
+
if getattr(args, "max_entry_bytes", 1) > MAX_EXACT_BYTES:
|
|
615
|
+
raise MemoryError("entry limit exceeds exact recovery maximum")
|
|
616
|
+
if getattr(args, "max_total_bytes", 1) > DEFAULT_MAX_TOTAL_BYTES:
|
|
617
|
+
raise MemoryError("total quota exceeds maximum")
|
|
618
|
+
if getattr(args, "max_entries", 1) > DEFAULT_MAX_ENTRIES:
|
|
619
|
+
raise MemoryError("entry count exceeds maximum")
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def main() -> int:
|
|
623
|
+
args = parser().parse_args()
|
|
624
|
+
try:
|
|
625
|
+
validate_args(args)
|
|
626
|
+
root = resolve_root(args.root)
|
|
627
|
+
store = resolve_store(root, args.store)
|
|
628
|
+
return int(args.func(args, root, store))
|
|
629
|
+
except (MemoryError, OSError, ValueError, KeyError, TypeError) as exc:
|
|
630
|
+
print(f"context-guard-task-memory: {exc}", file=sys.stderr)
|
|
631
|
+
return 1
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
if __name__ == "__main__":
|
|
635
|
+
raise SystemExit(main())
|