superlocalmemory 3.4.18 → 3.4.21
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 +35 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/retrieval/reranker.py +4 -2
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.4.21 — LLD-06 §3
|
|
4
|
+
|
|
5
|
+
"""AST-extract binary hook entry.
|
|
6
|
+
|
|
7
|
+
LLD reference: ``.backup/active-brain/lld/LLD-06-windows-binary-and-legacy-migration.md``
|
|
8
|
+
Section 3 (Binary Entry — no code duplication).
|
|
9
|
+
|
|
10
|
+
This generator reads the two canonical source modules
|
|
11
|
+
(``src/superlocalmemory/core/topic_signature.py`` and
|
|
12
|
+
``src/superlocalmemory/core/context_cache.py``), extracts the functions
|
|
13
|
+
the hot-path binary needs (``compute_topic_signature`` and the small
|
|
14
|
+
read-only slice of ``read_entry_fast``'s logic), and emits a single
|
|
15
|
+
self-contained file that:
|
|
16
|
+
|
|
17
|
+
* imports stdlib only (json, sys, os, sqlite3, hashlib, hmac, re,
|
|
18
|
+
time, unicodedata)
|
|
19
|
+
* renames the public functions with a ``_`` prefix
|
|
20
|
+
* ships the ``main()`` contract from LLD-06 §3.2
|
|
21
|
+
* writes ``# source_sha256: <hex>`` so drift is detectable
|
|
22
|
+
|
|
23
|
+
The output is deterministic: same inputs → byte-identical bytes.
|
|
24
|
+
Generated content is meant to be consumed by PyInstaller at release
|
|
25
|
+
time (or called during tests to assert the contract). It is NOT
|
|
26
|
+
checked into the repo — callers invoke ``emit_entry`` to write it.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import ast
|
|
31
|
+
import hashlib
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
# stdlib modules the emitted file is allowed to import. Any other import
|
|
36
|
+
# in the final file is a bug — the static-check step rejects it.
|
|
37
|
+
ALLOWED_STDLIB_IMPORTS: frozenset[str] = frozenset({
|
|
38
|
+
"__future__",
|
|
39
|
+
"json", "sys", "os", "sqlite3", "hashlib", "hmac",
|
|
40
|
+
"re", "time", "unicodedata",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
# Function names we extract by AST from each source module.
|
|
44
|
+
_TOPIC_FN_NAME = "compute_topic_signature"
|
|
45
|
+
_READER_FN_NAME = "read_entry_fast" # referenced for parity docs only
|
|
46
|
+
|
|
47
|
+
# Deny-list: the emitted file must not reference these packages.
|
|
48
|
+
_NONSTDLIB_DENY: tuple[str, ...] = (
|
|
49
|
+
"torch", "sentence_transformers", "fastapi", "uvicorn", "numpy",
|
|
50
|
+
"scipy", "lightgbm", "onnxruntime", "transformers", "httpx",
|
|
51
|
+
"mcp", "pydantic", "lark", "superlocalmemory",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
BANNER_AUTOGEN = "# AUTO-GENERATED BY scripts/build_entry.py — DO NOT EDIT"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class GenResult:
|
|
59
|
+
"""Outcome of running the generator."""
|
|
60
|
+
|
|
61
|
+
text: str
|
|
62
|
+
source_sha256: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# AST helpers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _load_source(path: Path) -> str:
|
|
70
|
+
return path.read_text(encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _sha256_concat(paths: list[Path]) -> str:
|
|
74
|
+
h = hashlib.sha256()
|
|
75
|
+
for p in paths:
|
|
76
|
+
h.update(p.read_bytes())
|
|
77
|
+
return h.hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_function(source: str, name: str) -> ast.FunctionDef:
|
|
81
|
+
"""Return the top-level ``def name(...)`` node from ``source``."""
|
|
82
|
+
tree = ast.parse(source)
|
|
83
|
+
for node in tree.body:
|
|
84
|
+
if isinstance(node, ast.FunctionDef) and node.name == name:
|
|
85
|
+
return node
|
|
86
|
+
raise LookupError(f"function {name!r} not found in source")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_module_constants(
|
|
90
|
+
source: str, names: set[str]
|
|
91
|
+
) -> list[ast.stmt]:
|
|
92
|
+
"""Extract top-level assignments where a target matches ``names``.
|
|
93
|
+
|
|
94
|
+
Captures plain assignments (``X = ...``) and annotated assignments
|
|
95
|
+
(``X: T = ...``). Returns the statements in source order.
|
|
96
|
+
"""
|
|
97
|
+
tree = ast.parse(source)
|
|
98
|
+
out: list[ast.stmt] = []
|
|
99
|
+
for node in tree.body:
|
|
100
|
+
if isinstance(node, ast.Assign):
|
|
101
|
+
for target in node.targets:
|
|
102
|
+
if isinstance(target, ast.Name) and target.id in names:
|
|
103
|
+
out.append(node)
|
|
104
|
+
break
|
|
105
|
+
elif isinstance(node, ast.AnnAssign):
|
|
106
|
+
if isinstance(node.target, ast.Name) and node.target.id in names:
|
|
107
|
+
out.append(node)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Emitted main()
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
_MAIN_SRC = '''
|
|
116
|
+
|
|
117
|
+
_TTL_SECONDS = 120
|
|
118
|
+
_HMAC_MATERIAL = b"active_brain_cache"
|
|
119
|
+
_HMAC_HEX_LEN = 32
|
|
120
|
+
_MAX_STDIN_BYTES = 65536
|
|
121
|
+
_MAX_PROMPT_CHARS = 8192
|
|
122
|
+
_MAX_SESSION_CHARS = 128
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _install_token_for(home_dir):
|
|
126
|
+
token_path = os.path.join(home_dir, ".install_token")
|
|
127
|
+
if not os.path.isfile(token_path):
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
with open(token_path, "r", encoding="utf-8") as fh:
|
|
131
|
+
token = fh.read().strip()
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
return token or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _binding_hmac(token):
|
|
138
|
+
return hmac.new(
|
|
139
|
+
token.encode("utf-8"),
|
|
140
|
+
_HMAC_MATERIAL,
|
|
141
|
+
hashlib.sha256,
|
|
142
|
+
).hexdigest()[:_HMAC_HEX_LEN]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read_entry_fast(session_id, topic_sig, db_path):
|
|
146
|
+
try:
|
|
147
|
+
home_dir = os.path.dirname(db_path)
|
|
148
|
+
token = _install_token_for(home_dir)
|
|
149
|
+
if token is None:
|
|
150
|
+
return None
|
|
151
|
+
conn = sqlite3.connect(
|
|
152
|
+
"file:" + db_path + "?mode=ro", uri=True, timeout=0.5,
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
row = conn.execute(
|
|
156
|
+
"SELECT value FROM slm_meta WHERE key='install_token_hmac'",
|
|
157
|
+
).fetchone()
|
|
158
|
+
if row is None:
|
|
159
|
+
return None
|
|
160
|
+
if not hmac.compare_digest(row[0], _binding_hmac(token)):
|
|
161
|
+
return None
|
|
162
|
+
now = int(time.time())
|
|
163
|
+
row = conn.execute(
|
|
164
|
+
"SELECT content FROM context_entries "
|
|
165
|
+
"WHERE session_id=? AND topic_sig=? "
|
|
166
|
+
" AND computed_at > ?",
|
|
167
|
+
(session_id, topic_sig, now - _TTL_SECONDS),
|
|
168
|
+
).fetchone()
|
|
169
|
+
finally:
|
|
170
|
+
try:
|
|
171
|
+
conn.close()
|
|
172
|
+
except sqlite3.Error:
|
|
173
|
+
pass
|
|
174
|
+
if row is None:
|
|
175
|
+
return None
|
|
176
|
+
return {"content": row[0]}
|
|
177
|
+
except Exception:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
try:
|
|
183
|
+
data = sys.stdin.read(_MAX_STDIN_BYTES)
|
|
184
|
+
payload = json.loads(data) if data else {}
|
|
185
|
+
except (ValueError, UnicodeDecodeError):
|
|
186
|
+
sys.stdout.write("{}")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
session_id = str(payload.get("session_id", ""))[:_MAX_SESSION_CHARS]
|
|
190
|
+
prompt = str(payload.get("prompt", ""))[:_MAX_PROMPT_CHARS]
|
|
191
|
+
if not session_id or not prompt:
|
|
192
|
+
sys.stdout.write("{}")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
db_path = os.environ.get("SLM_CACHE_DB") or os.path.join(
|
|
196
|
+
os.path.expanduser("~"),
|
|
197
|
+
".superlocalmemory",
|
|
198
|
+
"active_brain_cache.db",
|
|
199
|
+
)
|
|
200
|
+
if not os.path.isfile(db_path):
|
|
201
|
+
sys.stdout.write("{}")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
sig = _compute_topic_signature(prompt)
|
|
206
|
+
entry = _read_entry_fast(session_id, sig, db_path)
|
|
207
|
+
if entry is None:
|
|
208
|
+
sys.stdout.write("{}")
|
|
209
|
+
else:
|
|
210
|
+
out = {
|
|
211
|
+
"hookSpecificOutput": {
|
|
212
|
+
"hookEventName": "UserPromptSubmit",
|
|
213
|
+
"additionalContext": entry["content"],
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
sys.stdout.write(json.dumps(out, separators=(",", ":")))
|
|
217
|
+
except Exception:
|
|
218
|
+
sys.stdout.write("{}")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main())
|
|
224
|
+
'''
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Generator
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def generate(
|
|
233
|
+
topic_signature_src: Path,
|
|
234
|
+
context_cache_src: Path,
|
|
235
|
+
) -> GenResult:
|
|
236
|
+
"""Produce the emitted hook binary entry as a string.
|
|
237
|
+
|
|
238
|
+
Deterministic: given the same input source files, returns identical
|
|
239
|
+
bytes. The ``source_sha256`` banner captures the concatenated SHA
|
|
240
|
+
so silent drift is rejected by the static-check step.
|
|
241
|
+
"""
|
|
242
|
+
topic_src = _load_source(topic_signature_src)
|
|
243
|
+
cache_src = _load_source(context_cache_src)
|
|
244
|
+
|
|
245
|
+
fn_topic = _extract_function(topic_src, _TOPIC_FN_NAME)
|
|
246
|
+
# Inline helpers/constants referenced by compute_topic_signature.
|
|
247
|
+
topic_consts = _extract_module_constants(
|
|
248
|
+
topic_src,
|
|
249
|
+
{"_STOPWORDS", "MAX_SIG_INPUT_CHARS", "_SIG_LEN",
|
|
250
|
+
"_CAMEL_PASCAL", "_URL", "_PATH", "_QUOTED_DOUBLE",
|
|
251
|
+
"_QUOTED_SINGLE", "_WORD"},
|
|
252
|
+
)
|
|
253
|
+
fn_canon = _extract_function(topic_src, "_canon")
|
|
254
|
+
|
|
255
|
+
# Rename compute_topic_signature → _compute_topic_signature, keep
|
|
256
|
+
# call sites inside the function body consistent.
|
|
257
|
+
fn_topic_renamed = _clone_and_rename(fn_topic, "_compute_topic_signature")
|
|
258
|
+
|
|
259
|
+
consts_src = _unparse_many(topic_consts)
|
|
260
|
+
fn_canon_src = ast.unparse(fn_canon)
|
|
261
|
+
fn_topic_src = ast.unparse(fn_topic_renamed)
|
|
262
|
+
|
|
263
|
+
sha = _sha256_concat([topic_signature_src, context_cache_src])
|
|
264
|
+
|
|
265
|
+
pieces: list[str] = [
|
|
266
|
+
BANNER_AUTOGEN,
|
|
267
|
+
f"# source_sha256: {sha}",
|
|
268
|
+
"# Extracted from:",
|
|
269
|
+
f"# - {topic_signature_src.name} (compute_topic_signature + helpers)",
|
|
270
|
+
f"# - {context_cache_src.name} (read_entry_fast logic)",
|
|
271
|
+
"",
|
|
272
|
+
"from __future__ import annotations",
|
|
273
|
+
"",
|
|
274
|
+
"import hashlib",
|
|
275
|
+
"import hmac",
|
|
276
|
+
"import json",
|
|
277
|
+
"import os",
|
|
278
|
+
"import re",
|
|
279
|
+
"import sqlite3",
|
|
280
|
+
"import sys",
|
|
281
|
+
"import time",
|
|
282
|
+
"import unicodedata",
|
|
283
|
+
"",
|
|
284
|
+
"# --- extracted constants -----------------------------------------------",
|
|
285
|
+
consts_src,
|
|
286
|
+
"",
|
|
287
|
+
"# --- extracted helpers -------------------------------------------------",
|
|
288
|
+
fn_canon_src,
|
|
289
|
+
"",
|
|
290
|
+
"# --- extracted topic signature ----------------------------------------",
|
|
291
|
+
fn_topic_src,
|
|
292
|
+
"",
|
|
293
|
+
"# --- reader + main() (LLD-06 §3.2) ------------------------------------",
|
|
294
|
+
_MAIN_SRC.strip("\n"),
|
|
295
|
+
"",
|
|
296
|
+
]
|
|
297
|
+
text = "\n".join(pieces) + "\n"
|
|
298
|
+
return GenResult(text=text, source_sha256=sha)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _clone_and_rename(fn: ast.FunctionDef, new_name: str) -> ast.FunctionDef:
|
|
302
|
+
new_fn = ast.parse(ast.unparse(fn)).body[0]
|
|
303
|
+
assert isinstance(new_fn, ast.FunctionDef)
|
|
304
|
+
new_fn.name = new_name
|
|
305
|
+
return new_fn
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _unparse_many(nodes: list[ast.stmt]) -> str:
|
|
309
|
+
return "\n".join(ast.unparse(n) for n in nodes)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Static checks on the emitted file
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class GeneratorError(RuntimeError):
|
|
318
|
+
"""Raised when the emitted file fails a static check."""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def static_check(text: str) -> None:
|
|
322
|
+
"""Validate the emitted file. Raises :class:`GeneratorError` on fail.
|
|
323
|
+
|
|
324
|
+
Checks:
|
|
325
|
+
1. ``ast.parse`` succeeds.
|
|
326
|
+
2. Every import is from ``ALLOWED_STDLIB_IMPORTS``; no package on
|
|
327
|
+
the non-stdlib deny-list appears at all.
|
|
328
|
+
3. No ``open(...)`` call in write mode anywhere (``w``, ``wb``,
|
|
329
|
+
``a``, ``a+``, ``w+``, ``x``, ``x+``).
|
|
330
|
+
4. No socket / urllib / http / requests imports (no network).
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
tree = ast.parse(text)
|
|
334
|
+
except SyntaxError as exc:
|
|
335
|
+
raise GeneratorError(f"syntax error in emitted file: {exc}") from exc
|
|
336
|
+
|
|
337
|
+
_check_imports(tree)
|
|
338
|
+
_check_no_write_open(tree)
|
|
339
|
+
_check_no_network(tree)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_imports(tree: ast.AST) -> None:
|
|
343
|
+
for node in ast.walk(tree):
|
|
344
|
+
if isinstance(node, ast.Import):
|
|
345
|
+
for alias in node.names:
|
|
346
|
+
root = alias.name.split(".")[0]
|
|
347
|
+
_reject_if_denied(root)
|
|
348
|
+
if root not in ALLOWED_STDLIB_IMPORTS:
|
|
349
|
+
raise GeneratorError(
|
|
350
|
+
f"non-stdlib import: {alias.name!r}",
|
|
351
|
+
)
|
|
352
|
+
elif isinstance(node, ast.ImportFrom):
|
|
353
|
+
module = (node.module or "").split(".")[0]
|
|
354
|
+
_reject_if_denied(module)
|
|
355
|
+
if module and module not in ALLOWED_STDLIB_IMPORTS:
|
|
356
|
+
raise GeneratorError(
|
|
357
|
+
f"non-stdlib import: from {node.module!r}",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _reject_if_denied(name: str) -> None:
|
|
362
|
+
if name in _NONSTDLIB_DENY:
|
|
363
|
+
raise GeneratorError(
|
|
364
|
+
f"forbidden non-stdlib package referenced: {name!r}",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _check_no_write_open(tree: ast.AST) -> None:
|
|
369
|
+
write_mode_chars = {"w", "a", "x", "+"}
|
|
370
|
+
for node in ast.walk(tree):
|
|
371
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
|
|
372
|
+
and node.func.id == "open":
|
|
373
|
+
mode_arg = _open_mode(node)
|
|
374
|
+
if mode_arg is None:
|
|
375
|
+
continue # default mode 'r' — safe
|
|
376
|
+
for ch in mode_arg:
|
|
377
|
+
if ch in write_mode_chars:
|
|
378
|
+
raise GeneratorError(
|
|
379
|
+
f"write-mode open() not allowed: mode={mode_arg!r}",
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _open_mode(call: ast.Call) -> str | None:
|
|
384
|
+
# Positional: open(path, mode)
|
|
385
|
+
if len(call.args) >= 2:
|
|
386
|
+
arg = call.args[1]
|
|
387
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
388
|
+
return arg.value
|
|
389
|
+
for kw in call.keywords:
|
|
390
|
+
if kw.arg == "mode" and isinstance(kw.value, ast.Constant) \
|
|
391
|
+
and isinstance(kw.value.value, str):
|
|
392
|
+
return kw.value.value
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _check_no_network(tree: ast.AST) -> None:
|
|
397
|
+
net_roots = {"socket", "urllib", "http", "requests", "httpx",
|
|
398
|
+
"ftplib", "smtplib", "telnetlib"}
|
|
399
|
+
for node in ast.walk(tree):
|
|
400
|
+
if isinstance(node, ast.Import):
|
|
401
|
+
for alias in node.names:
|
|
402
|
+
root = alias.name.split(".")[0]
|
|
403
|
+
if root in net_roots:
|
|
404
|
+
raise GeneratorError(
|
|
405
|
+
f"network import not allowed: {alias.name!r}",
|
|
406
|
+
)
|
|
407
|
+
elif isinstance(node, ast.ImportFrom):
|
|
408
|
+
root = (node.module or "").split(".")[0]
|
|
409
|
+
if root in net_roots:
|
|
410
|
+
raise GeneratorError(
|
|
411
|
+
f"network import not allowed: from {node.module!r}",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Public CLI-ish entry (invoked from CI build script)
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def emit_entry(
|
|
421
|
+
topic_signature_src: Path,
|
|
422
|
+
context_cache_src: Path,
|
|
423
|
+
dest: Path,
|
|
424
|
+
) -> GenResult:
|
|
425
|
+
"""Generate, static-check, then write the emitted file to ``dest``."""
|
|
426
|
+
result = generate(topic_signature_src, context_cache_src)
|
|
427
|
+
static_check(result.text)
|
|
428
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
dest.write_text(result.text, encoding="utf-8")
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def default_source_paths(repo_root: Path) -> tuple[Path, Path]:
|
|
434
|
+
base = repo_root / "src" / "superlocalmemory" / "core"
|
|
435
|
+
return base / "topic_signature.py", base / "context_cache.py"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if __name__ == "__main__": # pragma: no cover — invoked from CI
|
|
439
|
+
import argparse
|
|
440
|
+
|
|
441
|
+
ap = argparse.ArgumentParser(description="AST-extract slm-hook entry")
|
|
442
|
+
ap.add_argument("--repo-root", type=Path, default=Path.cwd())
|
|
443
|
+
ap.add_argument(
|
|
444
|
+
"--dest", type=Path,
|
|
445
|
+
default=None,
|
|
446
|
+
help="output path (default: build/hook_binary_entry.py)",
|
|
447
|
+
)
|
|
448
|
+
ns = ap.parse_args()
|
|
449
|
+
topic, cache = default_source_paths(ns.repo_root)
|
|
450
|
+
dest = ns.dest or (ns.repo_root / "build" / "hook_binary_entry.py")
|
|
451
|
+
res = emit_entry(topic, cache, dest)
|
|
452
|
+
print(f"wrote {dest} (source_sha256={res.source_sha256[:12]}...)")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Stage-5b contract gate — enforces LLD-00 integration contracts at CI time.
|
|
3
|
+
#
|
|
4
|
+
# Per IMPLEMENTATION-MANIFEST-v3.4.21-FINAL.md §P0.1 and LLD-00 §13.
|
|
5
|
+
# Scans src/ for retired patterns and contract violations. Exit 0 = clean,
|
|
6
|
+
# exit 1 = violation found (specific failure printed to stdout).
|
|
7
|
+
#
|
|
8
|
+
# Uses POSIX grep for portability (see MANIFEST-DEVIATION entry for P0.1).
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
cd "$(git rev-parse --show-toplevel)"
|
|
12
|
+
SCAN_DIR="${SCAN_DIR:-src}"
|
|
13
|
+
|
|
14
|
+
FAIL=0
|
|
15
|
+
check() {
|
|
16
|
+
local pattern="$1" msg="$2"
|
|
17
|
+
[ -d "$SCAN_DIR" ] || return 0
|
|
18
|
+
local matches
|
|
19
|
+
matches="$(grep -rEn --include='*.py' --include='*.sql' \
|
|
20
|
+
--include='*.js' --include='*.ts' --include='*.sh' --include='*.toml' \
|
|
21
|
+
"$pattern" "$SCAN_DIR" 2>/dev/null || true)"
|
|
22
|
+
if [ -n "$matches" ]; then
|
|
23
|
+
echo "STAGE5B GATE FAILED: $msg"
|
|
24
|
+
echo " pattern: $pattern"
|
|
25
|
+
echo "$matches" | sed 's/^/ /'
|
|
26
|
+
FAIL=1
|
|
27
|
+
fi
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# LLD-00 §1.2 — pending_observations retired, use pending_outcomes (LLD-08 §M007).
|
|
31
|
+
check "pending_observations" \
|
|
32
|
+
"LLD-00 §1.2 — pending_observations retired, use pending_outcomes"
|
|
33
|
+
|
|
34
|
+
# LLD-00 §2 — finalize_outcome takes outcome_id only (kwarg), not query_id.
|
|
35
|
+
check "finalize_outcome\(query_id" \
|
|
36
|
+
"LLD-00 §2 — wrong finalize_outcome signature"
|
|
37
|
+
|
|
38
|
+
# LLD-00 §3 — hook must validate HMAC marker, not substring-scan fact_id.
|
|
39
|
+
check " fid in response_text" \
|
|
40
|
+
"LLD-00 §3 — bare substring scan, use HMAC validator"
|
|
41
|
+
|
|
42
|
+
# MASTER-PLAN D2 + LLD-00 §5 — no Opus in any SLM-initiated LLM call.
|
|
43
|
+
check "claude-opus-4" \
|
|
44
|
+
"LLD-00 + MASTER-PLAN D2 — no Opus in SLM-initiated LLM calls"
|
|
45
|
+
|
|
46
|
+
# LLD-00 §1.1 + SEC-C-05 — action_outcomes writes must go through canonical API.
|
|
47
|
+
check "action_outcomes.*INSERT.*VALUES" \
|
|
48
|
+
"SEC-C-05 — every action_outcomes INSERT must include profile_id"
|
|
49
|
+
|
|
50
|
+
exit $FAIL
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SuperLocalMemory postinstall — validation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from scripts/postinstall-interactive.js in Stage 9 W4
|
|
5
|
+
* (H-ARC-03) to keep the main installer under the 800-LOC cap while
|
|
6
|
+
* preserving every check byte-for-byte.
|
|
7
|
+
*
|
|
8
|
+
* Exports:
|
|
9
|
+
* validateReplyFileSchema(obj) — H-09 schema gate
|
|
10
|
+
* validateHomePath(homeArg, home, opt) — H-10 + H-SEC-03 gate
|
|
11
|
+
* DENY_PREFIXES_POSIX — shared list (tests may read)
|
|
12
|
+
*
|
|
13
|
+
* Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
14
|
+
* Licensed under AGPL-3.0-or-later.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------------
|
|
25
|
+
// H-09 — Reply-file schema validation
|
|
26
|
+
// ------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function validateReplyFileSchema(obj) {
|
|
29
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
30
|
+
return { ok: false, error: 'reply-file must decode to a JSON object' };
|
|
31
|
+
}
|
|
32
|
+
const schema = {
|
|
33
|
+
profile: { type: 'string', enum: ['minimal', 'light', 'balanced', 'power', 'custom'] },
|
|
34
|
+
home: { type: 'string' },
|
|
35
|
+
accept_default: { type: 'boolean' },
|
|
36
|
+
no_benchmark: { type: 'boolean' },
|
|
37
|
+
ram_ceiling_mb: { type: 'number', integer: true, min: 1 },
|
|
38
|
+
hot_path_hooks: { type: 'string' },
|
|
39
|
+
reranker: { type: 'string' },
|
|
40
|
+
context_injection_tokens: { type: 'number', integer: true, min: 0 },
|
|
41
|
+
skill_evolution_enabled: { type: 'boolean' },
|
|
42
|
+
evolution_llm: { type: 'string', enum: ['haiku', 'sonnet', 'ollama', 'skip'] },
|
|
43
|
+
online_retrain_cadence: { type: 'string' },
|
|
44
|
+
consolidation_cadence: { type: 'string' },
|
|
45
|
+
inline_entity_detection: { type: 'boolean' },
|
|
46
|
+
telemetry: { type: 'string' },
|
|
47
|
+
};
|
|
48
|
+
for (const key of Object.keys(obj)) {
|
|
49
|
+
if (!Object.prototype.hasOwnProperty.call(schema, key)) {
|
|
50
|
+
return { ok: false, error: 'unexpected key in reply-file: "' + key + '"' };
|
|
51
|
+
}
|
|
52
|
+
const rule = schema[key];
|
|
53
|
+
const val = obj[key];
|
|
54
|
+
if (rule.type === 'string') {
|
|
55
|
+
if (typeof val !== 'string') {
|
|
56
|
+
return { ok: false, error: 'reply-file key "' + key + '" must be a string' };
|
|
57
|
+
}
|
|
58
|
+
if (rule.enum && !rule.enum.includes(val)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: 'reply-file key "' + key + '" must be one of: ' + rule.enum.join('|'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
} else if (rule.type === 'boolean') {
|
|
65
|
+
if (typeof val !== 'boolean') {
|
|
66
|
+
return { ok: false, error: 'reply-file key "' + key + '" must be a boolean' };
|
|
67
|
+
}
|
|
68
|
+
} else if (rule.type === 'number') {
|
|
69
|
+
if (typeof val !== 'number' || Number.isNaN(val) || !Number.isFinite(val)) {
|
|
70
|
+
return { ok: false, error: 'reply-file key "' + key + '" must be a number' };
|
|
71
|
+
}
|
|
72
|
+
if (rule.integer && !Number.isInteger(val)) {
|
|
73
|
+
return { ok: false, error: 'reply-file key "' + key + '" must be an integer' };
|
|
74
|
+
}
|
|
75
|
+
if (rule.min !== undefined && val < rule.min) {
|
|
76
|
+
return { ok: false, error: 'reply-file key "' + key + '" must be >= ' + rule.min };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------------
|
|
85
|
+
// H-10 / H-SEC-03 — --home path validation
|
|
86
|
+
// ------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const DENY_PREFIXES_POSIX = [
|
|
89
|
+
'/etc', '/usr', '/bin', '/sbin', '/boot', '/proc', '/sys', '/dev',
|
|
90
|
+
'/var/log', '/var/lib', '/var/spool', '/root',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function _violatesDenyList(resolvedPath) {
|
|
94
|
+
if (process.platform === 'win32') return null;
|
|
95
|
+
for (const prefix of DENY_PREFIXES_POSIX) {
|
|
96
|
+
if (resolvedPath === prefix ||
|
|
97
|
+
resolvedPath.startsWith(prefix + path.sep)) {
|
|
98
|
+
return prefix;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateHomePath(homeArg, userHomeDir, outsideOptIn) {
|
|
105
|
+
if (typeof homeArg !== 'string' || homeArg === '') {
|
|
106
|
+
return { ok: false, error: '--home must be a non-empty string' };
|
|
107
|
+
}
|
|
108
|
+
if (!path.isAbsolute(homeArg)) {
|
|
109
|
+
return { ok: false, error: '--home must be an absolute path (rule: not-absolute)' };
|
|
110
|
+
}
|
|
111
|
+
const segments = homeArg.split(path.sep);
|
|
112
|
+
if (segments.includes('..')) {
|
|
113
|
+
return { ok: false, error: '--home must not contain ".." segments (rule: dotdot-segment)' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// S9-W2 H-SEC-03: resolve symlinks BEFORE the insideHome check.
|
|
117
|
+
let resolved = path.resolve(homeArg);
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(resolved)) {
|
|
120
|
+
resolved = fs.realpathSync.native
|
|
121
|
+
? fs.realpathSync.native(resolved)
|
|
122
|
+
: fs.realpathSync(resolved);
|
|
123
|
+
} else {
|
|
124
|
+
let anc = path.dirname(resolved);
|
|
125
|
+
const tail = [path.basename(resolved)];
|
|
126
|
+
while (anc !== path.dirname(anc) && !fs.existsSync(anc)) {
|
|
127
|
+
tail.unshift(path.basename(anc));
|
|
128
|
+
anc = path.dirname(anc);
|
|
129
|
+
}
|
|
130
|
+
if (fs.existsSync(anc)) {
|
|
131
|
+
const realAnc = fs.realpathSync.native
|
|
132
|
+
? fs.realpathSync.native(anc)
|
|
133
|
+
: fs.realpathSync(anc);
|
|
134
|
+
resolved = path.join(realAnc, ...tail);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// realpathSync failure → keep lexical resolve; downstream checks apply.
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const resolvedHome = path.resolve(userHomeDir || os.homedir());
|
|
142
|
+
const insideHome =
|
|
143
|
+
resolved === resolvedHome || resolved.startsWith(resolvedHome + path.sep);
|
|
144
|
+
if (!insideHome && !outsideOptIn) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error:
|
|
148
|
+
'--home resolves outside $HOME (' +
|
|
149
|
+
resolvedHome +
|
|
150
|
+
'); pass --home-outside-home to override (rule: outside-home)',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const forbidden = _violatesDenyList(resolved);
|
|
155
|
+
if (forbidden) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error:
|
|
159
|
+
'--home resolves to a system directory (' +
|
|
160
|
+
forbidden +
|
|
161
|
+
'); refusing (rule: deny-prefix)',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const st = fs.lstatSync(resolved);
|
|
167
|
+
if (st.isSymbolicLink()) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: '--home resolves to a symlink; refusing (rule: symlink)',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!st.isDirectory()) {
|
|
174
|
+
return { ok: false, error: '--home exists but is not a directory (rule: not-a-directory)' };
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {
|
|
177
|
+
// Path does not yet exist — OK, caller will mkdirSync.
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, resolved };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
validateReplyFileSchema,
|
|
185
|
+
validateHomePath,
|
|
186
|
+
DENY_PREFIXES_POSIX,
|
|
187
|
+
};
|