@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.
@@ -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())