data-olympus 0.3.0__py3-none-any.whl
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.
- data_olympus/__init__.py +14 -0
- data_olympus/_bin/_kb_detect_workspace.sh +57 -0
- data_olympus/_bin/_kb_enforce.py +619 -0
- data_olympus/_bin/_kb_fallback.py +361 -0
- data_olympus/_bin/kb +957 -0
- data_olympus/_bin/kb-enforce-hook +337 -0
- data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
- data_olympus/audit_log.py +275 -0
- data_olympus/audit_trailers.py +42 -0
- data_olympus/auth.py +139 -0
- data_olympus/cli/__init__.py +1 -0
- data_olympus/cli/import_cmd.py +115 -0
- data_olympus/cli/indexgen.py +60 -0
- data_olympus/cli/main.py +151 -0
- data_olympus/cli/report_cmd.py +181 -0
- data_olympus/config.py +261 -0
- data_olympus/cooccurrence.py +393 -0
- data_olympus/dedup.py +57 -0
- data_olympus/durable.py +51 -0
- data_olympus/embeddings.py +317 -0
- data_olympus/enforce_policy.py +297 -0
- data_olympus/format/__init__.py +16 -0
- data_olympus/format/document.py +39 -0
- data_olympus/format/frontmatter.py +35 -0
- data_olympus/format/lint.py +92 -0
- data_olympus/format/validate.py +71 -0
- data_olympus/git_ops.py +397 -0
- data_olympus/health.py +114 -0
- data_olympus/importer/__init__.py +13 -0
- data_olympus/importer/adr.py +286 -0
- data_olympus/importer/flat.py +170 -0
- data_olympus/importer/model.py +106 -0
- data_olympus/importer/okf.py +192 -0
- data_olympus/importer/run.py +416 -0
- data_olympus/importer/stamp.py +227 -0
- data_olympus/index.py +1745 -0
- data_olympus/markdown_parse.py +103 -0
- data_olympus/models.py +480 -0
- data_olympus/onboarding.py +131 -0
- data_olympus/onboarding_inflight.py +137 -0
- data_olympus/onboarding_playbook.py +99 -0
- data_olympus/pending.py +533 -0
- data_olympus/principals.py +168 -0
- data_olympus/prompts.py +35 -0
- data_olympus/push_queue.py +261 -0
- data_olympus/query_expansion.py +200 -0
- data_olympus/rate_limit.py +81 -0
- data_olympus/refresh.py +329 -0
- data_olympus/report.py +133 -0
- data_olympus/rest_api.py +845 -0
- data_olympus/safe_id.py +36 -0
- data_olympus/search_gate.py +64 -0
- data_olympus/search_shortcut.py +146 -0
- data_olympus/server.py +1115 -0
- data_olympus/session_metrics.py +303 -0
- data_olympus/setup_wizard.py +751 -0
- data_olympus/thin_pointer.py +20 -0
- data_olympus/tools_audit.py +41 -0
- data_olympus/tools_enforce.py +198 -0
- data_olympus/tools_onboarding.py +585 -0
- data_olympus/tools_read.py +230 -0
- data_olympus/tools_write.py +878 -0
- data_olympus/trigram.py +126 -0
- data_olympus/viewer/__init__.py +1 -0
- data_olympus/viewer/generator.py +375 -0
- data_olympus/worktrees.py +147 -0
- data_olympus/write_gate.py +382 -0
- data_olympus-0.3.0.dist-info/METADATA +97 -0
- data_olympus-0.3.0.dist-info/RECORD +73 -0
- data_olympus-0.3.0.dist-info/WHEEL +4 -0
- data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
- data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
- data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Append-only JSONL audit log of all writes, with a tamper-evident hash chain.
|
|
2
|
+
|
|
3
|
+
Each appended event carries an ``event_id``, the ``prev_hash`` of the previous
|
|
4
|
+
chained event, and its own ``hash`` over the canonical event body (which includes
|
|
5
|
+
``prev_hash``). Any later edit/deletion/reordering of an event breaks the chain
|
|
6
|
+
and is detected by :meth:`verify`. With ``KB_AUDIT_HMAC_KEY`` set the digest is a
|
|
7
|
+
keyed HMAC-SHA256, so an attacker who can write the log file still cannot forge a
|
|
8
|
+
valid chain without the key. Legacy lines without a ``hash`` are tolerated: the
|
|
9
|
+
chain validates the hashed lines in order and ignores unhashed ones, so enabling
|
|
10
|
+
the feature on an existing log does not retroactively flag it.
|
|
11
|
+
|
|
12
|
+
Rotation (WP3a). When the live file grows past ``max_bytes`` a fresh append
|
|
13
|
+
rotates it: the live file is renamed to ``<stem>-<UTC-timestamp><suffix>`` and a
|
|
14
|
+
new live file is started. The hash chain carries across the boundary because
|
|
15
|
+
``_last_hash`` is retained in memory across the rename, so the first event of the
|
|
16
|
+
new file links to the last hash of the rotated one. :meth:`verify` replays every
|
|
17
|
+
rotated segment in chronological order followed by the live file, so an intact
|
|
18
|
+
chain validates across rotations and any break at the boundary is caught. Reads
|
|
19
|
+
(:meth:`iter_filtered`) default to the live file only (cheap, matches the pre-
|
|
20
|
+
rotation behaviour) but can walk rotated segments newest-first when
|
|
21
|
+
``include_rotated=True`` so a ``since``-filtered query still sees history that has
|
|
22
|
+
rotated out of the live file.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import glob
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
|
|
39
|
+
GENESIS = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuditLog:
|
|
43
|
+
def __init__(
|
|
44
|
+
self, *, log_path: str, hmac_key: str = "", max_bytes: int = 0,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._path = log_path
|
|
47
|
+
self._hmac_key = hmac_key or ""
|
|
48
|
+
# Size-based rotation threshold in bytes. 0 (the default) disables
|
|
49
|
+
# rotation entirely, so an operator who never sets KB_AUDIT_MAX_BYTES keeps
|
|
50
|
+
# the single-file behaviour and existing logs are untouched.
|
|
51
|
+
self._max_bytes = max_bytes if max_bytes and max_bytes > 0 else 0
|
|
52
|
+
# append() is a read-modify-write on _last_hash plus a file write. Once
|
|
53
|
+
# handlers are offloaded to the anyio threadpool, appends can run
|
|
54
|
+
# concurrently; without this lock two threads read the same _last_hash
|
|
55
|
+
# and produce sibling events that break the hash chain. Reads (verify /
|
|
56
|
+
# iter_filtered) take the same lock so they never observe a torn append.
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._last_hash = self._load_last_hash()
|
|
59
|
+
|
|
60
|
+
# --- hashing helpers ---------------------------------------------------
|
|
61
|
+
def _digest(self, payload: str) -> str:
|
|
62
|
+
if self._hmac_key:
|
|
63
|
+
return hmac.new(
|
|
64
|
+
self._hmac_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
|
65
|
+
).hexdigest()
|
|
66
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _canonical(event: dict[str, Any]) -> str:
|
|
70
|
+
"""Deterministic serialization of the event body excluding its own hash."""
|
|
71
|
+
body = {k: v for k, v in event.items() if k != "hash"}
|
|
72
|
+
return json.dumps(body, sort_keys=True, ensure_ascii=False)
|
|
73
|
+
|
|
74
|
+
def _load_last_hash(self) -> str:
|
|
75
|
+
"""The last chained hash across ALL segments (rotated + live).
|
|
76
|
+
|
|
77
|
+
Reads rotated segments in chronological order then the live file so a
|
|
78
|
+
process that restarts after a rotation still chains its first append to
|
|
79
|
+
the true tail of the chain rather than to the (now-rotated-out) live
|
|
80
|
+
file's older content.
|
|
81
|
+
"""
|
|
82
|
+
last = GENESIS
|
|
83
|
+
for path in [*self._rotated_paths(), self._path]:
|
|
84
|
+
if not os.path.exists(path):
|
|
85
|
+
continue
|
|
86
|
+
with open(path, encoding="utf-8") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
ev = json.loads(line)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
if isinstance(ev, dict) and ev.get("hash"):
|
|
96
|
+
last = ev["hash"]
|
|
97
|
+
return last
|
|
98
|
+
|
|
99
|
+
# --- rotation helpers --------------------------------------------------
|
|
100
|
+
def _rotated_paths(self) -> list[str]:
|
|
101
|
+
"""Rotated segment paths, oldest-first.
|
|
102
|
+
|
|
103
|
+
Rotated files are named ``<stem>-<UTC-timestamp><suffix>`` next to the
|
|
104
|
+
live file. Sorting the glob lexically orders them chronologically because
|
|
105
|
+
the timestamp is fixed-width ``YYYYmmddTHHMMSSffffff``. The live file
|
|
106
|
+
itself is excluded (its name has no timestamp segment)."""
|
|
107
|
+
stem, suffix = os.path.splitext(self._path)
|
|
108
|
+
pattern = f"{glob.escape(stem)}-*{suffix}"
|
|
109
|
+
return sorted(p for p in glob.glob(pattern) if p != self._path)
|
|
110
|
+
|
|
111
|
+
def _rotated_path_for(self, when: float) -> str:
|
|
112
|
+
stem, suffix = os.path.splitext(self._path)
|
|
113
|
+
ts = time.strftime("%Y%m%dT%H%M%S", time.gmtime(when))
|
|
114
|
+
micros = int((when % 1) * 1_000_000)
|
|
115
|
+
candidate = f"{stem}-{ts}{micros:06d}{suffix}"
|
|
116
|
+
# Extremely unlikely, but never clobber an existing rotated segment.
|
|
117
|
+
n = 1
|
|
118
|
+
while os.path.exists(candidate):
|
|
119
|
+
candidate = f"{stem}-{ts}{micros:06d}-{n}{suffix}"
|
|
120
|
+
n += 1
|
|
121
|
+
return candidate
|
|
122
|
+
|
|
123
|
+
def _maybe_rotate_locked(self) -> None:
|
|
124
|
+
"""Rotate the live file when it exceeds the size threshold. Caller holds
|
|
125
|
+
the lock. The in-memory ``_last_hash`` is deliberately NOT reset, so the
|
|
126
|
+
first event written to the fresh live file links to the last hash of the
|
|
127
|
+
rotated segment and the chain is continuous across the boundary."""
|
|
128
|
+
if self._max_bytes <= 0 or not os.path.exists(self._path):
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
size = os.path.getsize(self._path)
|
|
132
|
+
except OSError: # pragma: no cover - defensive
|
|
133
|
+
return
|
|
134
|
+
if size < self._max_bytes:
|
|
135
|
+
return
|
|
136
|
+
os.rename(self._path, self._rotated_path_for(time.time()))
|
|
137
|
+
|
|
138
|
+
# --- public API --------------------------------------------------------
|
|
139
|
+
def append(self, event: dict[str, Any]) -> None:
|
|
140
|
+
"""Append a single JSON object as one line, chained to the previous event.
|
|
141
|
+
|
|
142
|
+
The parent directory is created lazily on first append so that
|
|
143
|
+
constructing an AuditLog with an unwritable default path (e.g. macOS
|
|
144
|
+
tests that never trigger a write) does not crash startup. When rotation
|
|
145
|
+
is enabled and the live file has passed the size threshold, it is rotated
|
|
146
|
+
BEFORE this event is written so the new event starts the fresh segment.
|
|
147
|
+
"""
|
|
148
|
+
os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._maybe_rotate_locked()
|
|
151
|
+
chained = dict(event)
|
|
152
|
+
chained["event_id"] = uuid.uuid4().hex
|
|
153
|
+
chained["prev_hash"] = self._last_hash
|
|
154
|
+
chained["hash"] = self._digest(self._canonical(chained))
|
|
155
|
+
line = json.dumps(chained, ensure_ascii=False)
|
|
156
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
157
|
+
f.write(line + "\n")
|
|
158
|
+
self._last_hash = chained["hash"]
|
|
159
|
+
|
|
160
|
+
def verify(self) -> tuple[bool, int]:
|
|
161
|
+
"""Recompute the hash chain over the whole log (all rotated segments +
|
|
162
|
+
the live file, in chronological order).
|
|
163
|
+
|
|
164
|
+
Returns ``(True, -1)`` when intact (or empty / all-legacy), else
|
|
165
|
+
``(False, line_index)`` for the 0-based index of the first offending line
|
|
166
|
+
counted across the concatenation of all segments (rotated oldest-first,
|
|
167
|
+
then live). So a single-file log verifies exactly as before, and a
|
|
168
|
+
rotated log verifies across the boundary: the first event of a new segment
|
|
169
|
+
must carry the last hash of the previous segment as its ``prev_hash``.
|
|
170
|
+
|
|
171
|
+
Unhashed legacy lines are tolerated ONLY as a prefix before the first
|
|
172
|
+
hashed event (the pre-chaining migration window). Once the chain has
|
|
173
|
+
started, any unhashed JSON line is treated as tampering and breaks
|
|
174
|
+
verification, so an attacker cannot append a legacy-shaped record to forge
|
|
175
|
+
an event while keeping verify green.
|
|
176
|
+
"""
|
|
177
|
+
# Snapshot every segment under the lock (short hold), then verify in
|
|
178
|
+
# memory so a concurrent append cannot make us read a torn final line as
|
|
179
|
+
# tampering.
|
|
180
|
+
with self._lock:
|
|
181
|
+
segments: list[list[str]] = []
|
|
182
|
+
for path in [*self._rotated_paths(), self._path]:
|
|
183
|
+
if not os.path.exists(path):
|
|
184
|
+
continue
|
|
185
|
+
with open(path, encoding="utf-8") as f:
|
|
186
|
+
segments.append(f.readlines())
|
|
187
|
+
prev = GENESIS
|
|
188
|
+
seen_hashed = False
|
|
189
|
+
global_index = 0
|
|
190
|
+
for raw_lines in segments:
|
|
191
|
+
for raw in raw_lines:
|
|
192
|
+
i = global_index
|
|
193
|
+
global_index += 1
|
|
194
|
+
raw = raw.strip()
|
|
195
|
+
if not raw:
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
ev = json.loads(raw)
|
|
199
|
+
except json.JSONDecodeError:
|
|
200
|
+
return (False, i)
|
|
201
|
+
if not (isinstance(ev, dict) and ev.get("hash")):
|
|
202
|
+
if seen_hashed:
|
|
203
|
+
return (False, i) # unhashed line after the chain started
|
|
204
|
+
continue # legacy prefix, tolerated
|
|
205
|
+
if ev.get("prev_hash") != prev:
|
|
206
|
+
return (False, i)
|
|
207
|
+
if self._digest(self._canonical(ev)) != ev["hash"]:
|
|
208
|
+
return (False, i)
|
|
209
|
+
prev = ev["hash"]
|
|
210
|
+
seen_hashed = True
|
|
211
|
+
return (True, -1)
|
|
212
|
+
|
|
213
|
+
def iter_filtered(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
since: float | None = None,
|
|
217
|
+
agent: str | None = None,
|
|
218
|
+
status: str | None = None,
|
|
219
|
+
include_rotated: bool = False,
|
|
220
|
+
max_scan_events: int | None = None,
|
|
221
|
+
) -> Iterator[dict[str, Any]]:
|
|
222
|
+
"""Yield events matching the filters, most-recent first.
|
|
223
|
+
|
|
224
|
+
By default only the live file is read (cheap; matches the pre-rotation
|
|
225
|
+
behaviour). When ``include_rotated`` is True the rotated segments are also
|
|
226
|
+
walked, newest-first, AFTER the live file so results stay strictly most-
|
|
227
|
+
recent-first across the rotation boundary. Callers that pass a ``since``
|
|
228
|
+
filter should set ``include_rotated=True`` to see history that has already
|
|
229
|
+
rotated out of the live file.
|
|
230
|
+
|
|
231
|
+
``max_scan_events`` bounds the reverse scan: once that many raw events
|
|
232
|
+
have been examined (across all scanned segments) iteration stops even if
|
|
233
|
+
more match. This caps the memory/time cost of a query against a large
|
|
234
|
+
rotated history; callers that only need "the most recent N" pass their N
|
|
235
|
+
so the scan does not walk the entire archive.
|
|
236
|
+
"""
|
|
237
|
+
scanned = 0
|
|
238
|
+
# Live file first (newest events live here), then rotated newest-first.
|
|
239
|
+
paths = [self._path]
|
|
240
|
+
if include_rotated:
|
|
241
|
+
paths.extend(reversed(self._rotated_paths()))
|
|
242
|
+
for path in paths:
|
|
243
|
+
if not os.path.exists(path):
|
|
244
|
+
continue
|
|
245
|
+
# Read under the lock so we never see a torn append, then yield from
|
|
246
|
+
# the in-memory snapshot (holding the lock across yields would block
|
|
247
|
+
# appends for as long as the caller iterates).
|
|
248
|
+
with self._lock, open(path, encoding="utf-8") as f:
|
|
249
|
+
lines = f.readlines()
|
|
250
|
+
for line in reversed(lines):
|
|
251
|
+
if max_scan_events is not None and scanned >= max_scan_events:
|
|
252
|
+
return
|
|
253
|
+
line = line.strip()
|
|
254
|
+
if not line:
|
|
255
|
+
continue
|
|
256
|
+
scanned += 1
|
|
257
|
+
try:
|
|
258
|
+
ev = json.loads(line)
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
continue
|
|
261
|
+
if since is not None and float(ev.get("ts", 0)) < since:
|
|
262
|
+
# Events are appended in time order, so within a segment a
|
|
263
|
+
# reverse scan that reaches an older-than-``since`` event has
|
|
264
|
+
# no newer matches left below it; and every rotated segment is
|
|
265
|
+
# strictly older than the live file we scan first. So the first
|
|
266
|
+
# sub-floor event ends the whole scan. (A clock that ran
|
|
267
|
+
# backwards mid-run could in theory hide a straggler; the audit
|
|
268
|
+
# ts is server wall-clock and this is a best-effort recency
|
|
269
|
+
# query, so the bound is worth the rare miss.)
|
|
270
|
+
return
|
|
271
|
+
if agent is not None and ev.get("agent_identity") != agent:
|
|
272
|
+
continue
|
|
273
|
+
if status is not None and ev.get("status") != status:
|
|
274
|
+
continue
|
|
275
|
+
yield ev
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Build commit messages with 7 audit trailers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_commit_message(
|
|
8
|
+
*,
|
|
9
|
+
subject: str,
|
|
10
|
+
source_session: str,
|
|
11
|
+
agent_identity: str,
|
|
12
|
+
confidence_original: float,
|
|
13
|
+
operator_confirmed: bool,
|
|
14
|
+
proposal_type: Literal["memory", "edit"],
|
|
15
|
+
target_tier: str,
|
|
16
|
+
target_path: str,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Return a commit message with subject + blank line + 7 trailers.
|
|
19
|
+
|
|
20
|
+
Raises ValueError if any trailer value contains a newline (which would
|
|
21
|
+
break git's trailer parsing).
|
|
22
|
+
"""
|
|
23
|
+
for label, value in [
|
|
24
|
+
("subject", subject),
|
|
25
|
+
("source_session", source_session),
|
|
26
|
+
("agent_identity", agent_identity),
|
|
27
|
+
("target_tier", target_tier),
|
|
28
|
+
("target_path", target_path),
|
|
29
|
+
]:
|
|
30
|
+
if "\n" in value or "\r" in value:
|
|
31
|
+
raise ValueError(f"trailer value for {label!r} contains newline")
|
|
32
|
+
|
|
33
|
+
trailers = [
|
|
34
|
+
f"KB-Source-Session: {source_session}",
|
|
35
|
+
f"KB-Agent-Identity: {agent_identity}",
|
|
36
|
+
f"KB-Confidence: {confidence_original:.2f}",
|
|
37
|
+
f"KB-Operator-Confirmed: {'yes' if operator_confirmed else 'no'}",
|
|
38
|
+
f"KB-Proposal-Type: {proposal_type}",
|
|
39
|
+
f"KB-Target-Tier: {target_tier}",
|
|
40
|
+
f"KB-Target-Path: {target_path}",
|
|
41
|
+
]
|
|
42
|
+
return subject + "\n\n" + "\n".join(trailers) + "\n"
|
data_olympus/auth.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Structural rule (always applies, not configurable) + policy blocklist
|
|
2
|
+
(operator-configurable, empty by default).
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Structural rule rejects: empty, NUL, absolute, traversal, non-md, non-indexed,
|
|
6
|
+
structurally-excluded. Normalization happens BEFORE prefix match.
|
|
7
|
+
- Policy blocklist consists of tier names (e.g. {"T1"}) and fnmatch globs
|
|
8
|
+
(e.g. ["decisions/ADR-008-*.md"]).
|
|
9
|
+
|
|
10
|
+
By default both blocklists are empty (allow-by-default modulo the structural rule).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
|
|
18
|
+
# Generic, deployment-neutral default. A deployment with extra top-level
|
|
19
|
+
# directories supplies its own writable set via KB_INDEXED_PREFIXES (a
|
|
20
|
+
# comma-separated list), which replaces this default wholesale.
|
|
21
|
+
DEFAULT_INDEXED_PREFIXES: tuple[str, ...] = (
|
|
22
|
+
"universal/", "tech-stacks/", "projects/",
|
|
23
|
+
"decisions/", "workflows/", "memory/",
|
|
24
|
+
"tooling/", "templates/",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def indexed_prefixes() -> tuple[str, ...]:
|
|
29
|
+
"""Active writable prefixes: KB_INDEXED_PREFIXES if set, else default."""
|
|
30
|
+
raw = os.environ.get("KB_INDEXED_PREFIXES", "").strip()
|
|
31
|
+
if not raw:
|
|
32
|
+
return DEFAULT_INDEXED_PREFIXES
|
|
33
|
+
return tuple(s.strip() for s in raw.split(",") if s.strip())
|
|
34
|
+
|
|
35
|
+
STRUCTURALLY_EXCLUDED: frozenset[str] = frozenset({
|
|
36
|
+
".git", ".worktrees", ".ruff_cache", "__pycache__",
|
|
37
|
+
"to-delete", "archive", "_archive", "tools",
|
|
38
|
+
".pytest_cache", ".mypy_cache", "node_modules",
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_target_path(raw: str) -> str | None:
|
|
43
|
+
"""Return the canonical relative POSIX path, or None if structurally invalid.
|
|
44
|
+
|
|
45
|
+
The returned string is the SINGLE authoritative form of the path: backslashes
|
|
46
|
+
are folded to ``/`` and the segments are re-joined with POSIX separators.
|
|
47
|
+
Every downstream operation (classification, blocklist match, filesystem join,
|
|
48
|
+
``git add``) MUST use this canonical value, never the raw input. If callers
|
|
49
|
+
validated with the canonical form but then joined/wrote/git-added the raw
|
|
50
|
+
string, a value like ``decisions\\x.md`` would pass validation as
|
|
51
|
+
``decisions/x.md`` on Linux yet land a literal root-level file named
|
|
52
|
+
``decisions\\x.md`` that is outside every indexed prefix and invisible to
|
|
53
|
+
``KB_WRITE_BLOCK_PATHS`` globs. Returning the canonical form and using it
|
|
54
|
+
everywhere keeps the policy decision and the filesystem effect in lockstep.
|
|
55
|
+
|
|
56
|
+
Rejections: empty/whitespace-only, NUL or any other control character
|
|
57
|
+
(``\\n``, ``\\r``, ``\\t``, etc. would let a payload smuggle newlines into a
|
|
58
|
+
path), absolute paths, Windows drive letters, ``.``/``..`` traversal, and any
|
|
59
|
+
structurally-excluded segment.
|
|
60
|
+
"""
|
|
61
|
+
if not raw or not raw.strip():
|
|
62
|
+
return None
|
|
63
|
+
# Reject control characters (NUL, newline, CR, tab, and the rest of the
|
|
64
|
+
# C0/C1 range) before any normalization: a newline in a path is never a
|
|
65
|
+
# legitimate target and would otherwise smuggle through classification and
|
|
66
|
+
# audit records.
|
|
67
|
+
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
|
|
68
|
+
return None
|
|
69
|
+
if raw.startswith("/") or (len(raw) >= 2 and raw[1] == ":"):
|
|
70
|
+
return None
|
|
71
|
+
parts = raw.replace("\\", "/").split("/")
|
|
72
|
+
if any(p == "" or p == "." or p == ".." for p in parts):
|
|
73
|
+
return None
|
|
74
|
+
if any(p in STRUCTURALLY_EXCLUDED for p in parts):
|
|
75
|
+
return None
|
|
76
|
+
return str(PurePosixPath(*parts))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Back-compat alias for the previous private name; both point at the same guard.
|
|
80
|
+
_normalize_target_path = normalize_target_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_writable_path(target_path: str) -> bool:
|
|
84
|
+
"""Structural rule. Independent of policy blocklist.
|
|
85
|
+
|
|
86
|
+
Callers that go on to write MUST first resolve the canonical form with
|
|
87
|
+
:func:`normalize_target_path` and operate on that; this predicate only
|
|
88
|
+
answers whether the canonical form is a writable indexed ``.md`` path.
|
|
89
|
+
"""
|
|
90
|
+
canonical = normalize_target_path(target_path)
|
|
91
|
+
if canonical is None:
|
|
92
|
+
return False
|
|
93
|
+
if not canonical.endswith(".md"):
|
|
94
|
+
return False
|
|
95
|
+
return any(canonical.startswith(p) for p in indexed_prefixes())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def safe_join_under_root(root: str, target_path: str) -> str | None:
|
|
99
|
+
"""Join ``target_path`` under ``root`` and return the absolute path only when
|
|
100
|
+
it resolves to exactly that lexical location inside ``root``; else return None.
|
|
101
|
+
|
|
102
|
+
Two checks, both required:
|
|
103
|
+
|
|
104
|
+
1. The realpath-resolved location is strictly inside ``root``. This rejects
|
|
105
|
+
traversal and symlinks that point *outside* the worktree (e.g. a malicious
|
|
106
|
+
``memory/inbox`` symlinked to ``/etc``).
|
|
107
|
+
2. The resolved location equals the lexical join of ``root`` and
|
|
108
|
+
``target_path``. This additionally rejects a symlink component that
|
|
109
|
+
redirects to *another in-root path*: without it, an allowed lexical
|
|
110
|
+
``target_path`` (which is what gets classified, blocklist-checked, audited,
|
|
111
|
+
and ``git add``-ed) could land its bytes on a different file, decoupling
|
|
112
|
+
the policy decision from the filesystem effect.
|
|
113
|
+
|
|
114
|
+
The returned value is the *unresolved* join so callers keep writing to the
|
|
115
|
+
in-tree path and ``git add`` the relative ``target_path`` exactly as before.
|
|
116
|
+
This is the single shared containment guard used by every write path (memory
|
|
117
|
+
propose, edit, resolve, bootstrap).
|
|
118
|
+
"""
|
|
119
|
+
root_real = os.path.realpath(root)
|
|
120
|
+
full = os.path.join(root, target_path)
|
|
121
|
+
real = os.path.realpath(full)
|
|
122
|
+
expected = os.path.normpath(os.path.join(root_real, target_path))
|
|
123
|
+
inside = real != root_real and real.startswith(root_real + os.sep)
|
|
124
|
+
if inside and real == expected:
|
|
125
|
+
return full
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PathBlocklist:
|
|
130
|
+
"""Operator-configured per-tier + per-path blocklist. Both default empty."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, tier_blocks: list[str], path_blocks: list[str]) -> None:
|
|
133
|
+
self._tier_blocks = {t.strip() for t in tier_blocks if t.strip()}
|
|
134
|
+
self._path_blocks = [p.strip() for p in path_blocks if p.strip()]
|
|
135
|
+
|
|
136
|
+
def blocks(self, target_path: str, target_tier: str) -> bool:
|
|
137
|
+
if target_tier in self._tier_blocks:
|
|
138
|
+
return True
|
|
139
|
+
return any(fnmatch.fnmatch(target_path, p) for p in self._path_blocks)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""data-olympus local-bundle CLI (lint, index, visualize)."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""`data-olympus import`: migrate an existing rule corpus into a draft bundle.
|
|
2
|
+
|
|
3
|
+
Wires the CLI flags to ``data_olympus.importer.run_import`` and renders the
|
|
4
|
+
import report either human-readably or as JSON (``--json``). Exit codes:
|
|
5
|
+
- 0: import succeeded and the output is lint-clean.
|
|
6
|
+
- 1: import succeeded but the output has lint errors (should not happen on the
|
|
7
|
+
happy path; surfaces a stamping regression instead of silently passing).
|
|
8
|
+
- 2: bad input or a refused re-run (ImportError_).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from data_olympus.importer import ImportError_, run_import
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
from data_olympus.importer.model import ImportReport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _render_human(report: ImportReport) -> str:
|
|
26
|
+
lines: list[str] = []
|
|
27
|
+
lines.append(f"Imported {report.kind} from {report.source}")
|
|
28
|
+
lines.append(f" output: {report.out_dir}")
|
|
29
|
+
lines.append(f" created {len(report.created)} draft(s):")
|
|
30
|
+
for name in report.created:
|
|
31
|
+
lines.append(f" + {name}")
|
|
32
|
+
if report.skipped:
|
|
33
|
+
lines.append(f" skipped {len(report.skipped)} section(s) (too short):")
|
|
34
|
+
for s in report.skipped:
|
|
35
|
+
lines.append(f" - {s.heading}: {s.reason}")
|
|
36
|
+
if report.inferences:
|
|
37
|
+
lines.append(f" inferences ({len(report.inferences)}):")
|
|
38
|
+
for note in report.inferences:
|
|
39
|
+
lines.append(f" * {note}")
|
|
40
|
+
if report.needs_review:
|
|
41
|
+
lines.append(f" needs human review ({len(report.needs_review)}):")
|
|
42
|
+
for note in report.needs_review:
|
|
43
|
+
lines.append(f" ! {note}")
|
|
44
|
+
if report.lint:
|
|
45
|
+
lines.append(f" lint findings ({len(report.lint)}):")
|
|
46
|
+
for f in report.lint:
|
|
47
|
+
lines.append(f" [{f.severity}] {f.path}: {f.field}: {f.message}")
|
|
48
|
+
lines.append(f" lint clean: {report.lint_clean}")
|
|
49
|
+
else:
|
|
50
|
+
lines.append(" lint: clean (no findings)")
|
|
51
|
+
lines.append(" next steps:")
|
|
52
|
+
for step in report.next_steps:
|
|
53
|
+
lines.append(f" - {step}")
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_import(args: argparse.Namespace) -> int:
|
|
58
|
+
try:
|
|
59
|
+
report = run_import(
|
|
60
|
+
source=args.source,
|
|
61
|
+
kind=args.kind,
|
|
62
|
+
tier=args.tier,
|
|
63
|
+
out=args.out,
|
|
64
|
+
category=args.category,
|
|
65
|
+
id_prefix=args.id_prefix,
|
|
66
|
+
force=args.force,
|
|
67
|
+
)
|
|
68
|
+
except ImportError_ as exc:
|
|
69
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
if args.json:
|
|
73
|
+
print(json.dumps(report.to_dict()))
|
|
74
|
+
else:
|
|
75
|
+
print(_render_human(report))
|
|
76
|
+
|
|
77
|
+
return 0 if report.lint_clean else 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def add_import_subparser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
81
|
+
"""Register the ``import`` subcommand on the CLI's subparser action."""
|
|
82
|
+
p = sub.add_parser(
|
|
83
|
+
"import",
|
|
84
|
+
help="migrate an existing rule corpus (CLAUDE.md/AGENTS.md/.cursorrules/ADR/OKF) "
|
|
85
|
+
"into a governed draft bundle",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument("source", help="path to the source file (flat rule file) or directory (ADR/OKF)")
|
|
88
|
+
p.add_argument(
|
|
89
|
+
"--kind",
|
|
90
|
+
required=True,
|
|
91
|
+
choices=["claude-md", "agents-md", "gemini-md", "cursorrules", "adr", "okf"],
|
|
92
|
+
help="source corpus kind",
|
|
93
|
+
)
|
|
94
|
+
p.add_argument(
|
|
95
|
+
"--tier",
|
|
96
|
+
required=True,
|
|
97
|
+
help="governance tier for the imported drafts (T1|T2|T3|T4|meta, or a bare digit)",
|
|
98
|
+
)
|
|
99
|
+
p.add_argument(
|
|
100
|
+
"-o", "--out", default=None,
|
|
101
|
+
help="output bundle directory (default: <source>/imported-<kind>). Refuses to write "
|
|
102
|
+
"into a dir that already holds governed files unless --force is given.",
|
|
103
|
+
)
|
|
104
|
+
p.add_argument("--category", default=None, help="optional category stamped on every draft")
|
|
105
|
+
p.add_argument(
|
|
106
|
+
"--id-prefix", default=None,
|
|
107
|
+
help="id prefix for generated ids (default: per-kind, e.g. CLAUDE/AGENTS/OKF; "
|
|
108
|
+
"ADR imports always use ADR-NNNN)",
|
|
109
|
+
)
|
|
110
|
+
p.add_argument(
|
|
111
|
+
"--force", action="store_true",
|
|
112
|
+
help="overwrite an output dir that was already used as an import target",
|
|
113
|
+
)
|
|
114
|
+
p.add_argument("--json", action="store_true", help="emit the import report as JSON")
|
|
115
|
+
p.set_defaults(func=_cmd_import)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Regenerate index.md files for progressive disclosure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from data_olympus.format import Document
|
|
8
|
+
from data_olympus.format.validate import RESERVED as _RESERVED
|
|
9
|
+
|
|
10
|
+
_SKIP = {".git", "__pycache__", ".venv", ".pytest_cache", ".ruff_cache", "node_modules"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _dirs_with_markdown(root: Path) -> list[Path]:
|
|
14
|
+
dirs: set[Path] = set()
|
|
15
|
+
for md in root.rglob("*.md"):
|
|
16
|
+
if any(part in _SKIP for part in md.parts):
|
|
17
|
+
continue
|
|
18
|
+
if md.name in _RESERVED:
|
|
19
|
+
continue
|
|
20
|
+
dirs.add(md.parent)
|
|
21
|
+
return sorted(dirs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _entry(doc_path: Path) -> tuple[str, str, str]:
|
|
25
|
+
"""Return (title, relative_url, description) for a concept file."""
|
|
26
|
+
doc = Document.load(doc_path)
|
|
27
|
+
title = doc.frontmatter.get("title") or doc_path.stem
|
|
28
|
+
desc = doc.frontmatter.get("description") or ""
|
|
29
|
+
return str(title), doc_path.name, str(desc)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def regenerate_indexes(root: Path) -> list[Path]:
|
|
33
|
+
"""Write an index.md into every directory that holds concept docs.
|
|
34
|
+
Returns the list of index.md paths written."""
|
|
35
|
+
root = Path(root)
|
|
36
|
+
written: list[Path] = []
|
|
37
|
+
for d in _dirs_with_markdown(root):
|
|
38
|
+
concepts = sorted(p for p in d.glob("*.md") if p.name not in _RESERVED)
|
|
39
|
+
subdirs = sorted(
|
|
40
|
+
sub
|
|
41
|
+
for sub in d.iterdir()
|
|
42
|
+
if sub.is_dir() and sub.name not in _SKIP and any(sub.rglob("*.md"))
|
|
43
|
+
)
|
|
44
|
+
lines: list[str] = [f"# {d.name or root.name}", ""]
|
|
45
|
+
if concepts:
|
|
46
|
+
lines.append("# Concepts")
|
|
47
|
+
for c in concepts:
|
|
48
|
+
title, url, desc = _entry(c)
|
|
49
|
+
suffix = f" - {desc}" if desc else ""
|
|
50
|
+
lines.append(f"* [{title}]({url}){suffix}")
|
|
51
|
+
lines.append("")
|
|
52
|
+
if subdirs:
|
|
53
|
+
lines.append("# Subdirectories")
|
|
54
|
+
for sub in subdirs:
|
|
55
|
+
lines.append(f"* [{sub.name}/]({sub.name}/)")
|
|
56
|
+
lines.append("")
|
|
57
|
+
idx_path = d / "index.md"
|
|
58
|
+
idx_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
59
|
+
written.append(idx_path)
|
|
60
|
+
return written
|