Skip to content

Commit 80aaa2b

Browse files
Paul Kylecodexclaude
committed
fix: security hardening, executor no-ops, palinode start, 42 tests
Independent review by OpenAI Codex: - Path traversal: null byte, symlink escape, commonpath checks (API + CLI) - Executor: no-op detection, fact text normalization, stats only on real changes - datetime.utcnow() → datetime.now(UTC) across all modules - palinode start: rewritten with multiprocessing - MCP: fixed elif/else syntax bug in palinode_lint handler - Tests: 42 passing (symlink, null-byte, no-op, missing-field, CLI start) - Docs: tool count and Python version aligned Co-Authored-By: OpenAI Codex <noreply@openai.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4134ada commit 80aaa2b

15 files changed

Lines changed: 297 additions & 110 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ If every service crashes, `cat` still works. No external database. No cloud depe
2424

2525
Most agent memory systems are opaque databases you can't inspect, flat files that don't scale, or graph stores that require infrastructure. Palinode is **memory with provenance** — the only system where you can `git blame` every fact your agent knows.
2626

27-
- **Git blame/diff/rollback as agent tools** — not just git-compatible files, but `palinode_diff`, `palinode_blame`, and `palinode_rollback` as first-class MCP tools your agent can call. [DiffMem](https://github.com/search?q=diffmem) and Git-Context-Controller are PoCs; Palinode ships 15 MCP tools including 5 git operations.
27+
- **Git blame/diff/rollback as agent tools** — not just git-compatible files, but `palinode_diff`, `palinode_blame`, and `palinode_rollback` as first-class MCP tools your agent can call. [DiffMem](https://github.com/search?q=diffmem) and Git-Context-Controller are PoCs; Palinode ships 17 MCP tools including 5 git operations.
2828

2929
- **Operation-based compaction with a deterministic executor** — the LLM outputs structured ops (KEEP/UPDATE/MERGE/SUPERSEDE/ARCHIVE), a deterministic executor applies them. The LLM never touches your files directly. [All-Mem](https://arxiv.org/search/?query=all-mem+memory) does something similar on graph nodes; Palinode does it on plain markdown with git commits.
3030

@@ -70,7 +70,7 @@ Most agent memory systems are opaque databases you can't inspect, flat files tha
7070

7171
### Integration ✅
7272
- **OpenClaw plugin** — lifecycle hooks for inject, extract, and capture
73-
- **MCP server**15 tools for Claude Code and any MCP client
73+
- **MCP server**17 tools for Claude Code and any MCP client
7474
- **FastAPI server** — HTTP API for programmatic access
7575
- **CLI** — command-line search, stats, reindex
7676

@@ -132,7 +132,7 @@ graph TD
132132

133133
## Requirements
134134

135-
- **Python 3.12+**
135+
- **Python 3.11+**
136136
- **Ollama** with `bge-m3` model (for embeddings — `ollama pull bge-m3`)
137137
- **Git** (for memory versioning)
138138
- A directory for your memory files (local, or a private git repo)
@@ -148,7 +148,7 @@ Optional:
148148
| Embeddings | BGE-M3 via Ollama |
149149
| Consolidation LLM | [OLMo 3.1 32B AWQ](https://huggingface.co/allenai/OLMo-3.1-32B-AWQ) via vLLM |
150150
| Hardware | RTX 5090 32GB (consolidation), any CPU (embeddings + API) |
151-
| Python | 3.12 |
151+
| Python | 3.11+ |
152152
| OS | Ubuntu 22.04 (Linux), macOS 14+ (development) |
153153

154154
Other models should work — the consolidation prompt is model-agnostic. Smaller models (8B) may produce less reliable JSON for compaction operations; use `json-repair` (included) as a safety net.
@@ -531,7 +531,7 @@ Palinode is informed by research and ideas from several projects in the agent me
531531

532532
### Architecture Inspiration
533533

534-
- **[LLM Knowledge Bases](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** (Andrej Karpathy, April 2026) — The "compile, don't retrieve" pattern: LLM incrementally builds a structured markdown wiki from raw sources. Palinode implements this with git provenance, deterministic compaction, and 15 MCP tools.
534+
- **[LLM Knowledge Bases](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** (Andrej Karpathy, April 2026) — The "compile, don't retrieve" pattern: LLM incrementally builds a structured markdown wiki from raw sources. Palinode implements this with git provenance, deterministic compaction, and 17 MCP tools.
535535

536536
- **[OpenClaw](https://github.com/openclaw/openclaw)** — The plugin SDK, lifecycle hooks, and `MEMORY.md` pattern that Palinode extends and replaces. Palinode started as a better memory system for OpenClaw agents.
537537

docs/INSTALL-CLAUDE-CODE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Installing Palinode with Claude Code
22

3-
Palinode gives Claude Code persistent memory via MCP — 14 tools for searching, saving, and managing memories across sessions. The `palinode-session` skill auto-captures milestones and decisions during coding, so your memory stays fresh without manual effort.
3+
Palinode gives Claude Code persistent memory via MCP — 17 tools for searching, saving, and managing memories across sessions. The `palinode-session` skill auto-captures milestones and decisions during coding, so your memory stays fresh without manual effort.
44

55
## Prerequisites
66

77
- Claude Code installed (`npm install -g @anthropic-ai/claude-code`)
88
- Palinode API running (see below)
9-
- Python 3.12+
9+
- Python 3.11+
1010

1111
---
1212

palinode/api/server.py

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import httpx
1717
import subprocess
1818
import glob
19-
from datetime import datetime
19+
from datetime import UTC, datetime
2020
from typing import Any
2121

2222
from fastapi import FastAPI, HTTPException
@@ -34,7 +34,7 @@ class JsonlFormatter(logging.Formatter):
3434
"""Logging Formatter dictating a JSONL chronological schema format."""
3535
def format(self, record: logging.LogRecord) -> str:
3636
return json.dumps({
37-
"timestamp": datetime.utcnow().isoformat() + "Z",
37+
"timestamp": _utc_now().isoformat().replace("+00:00", "Z"),
3838
"level": record.levelname,
3939
"name": record.name,
4040
"message": record.getMessage()
@@ -54,6 +54,34 @@ def format(self, record: logging.LogRecord) -> str:
5454

5555
# ── Auto-summary helpers ──────────────────────────────────────────────────────
5656

57+
58+
def _utc_now() -> datetime:
59+
"""Return a timezone-aware UTC timestamp."""
60+
return datetime.now(UTC)
61+
62+
63+
def _memory_base_dir() -> str:
64+
"""Return the canonical memory root."""
65+
return os.path.realpath(getattr(config, "memory_dir", config.palinode_dir))
66+
67+
68+
def _resolve_memory_path(file_path: str) -> tuple[str, str]:
69+
"""Resolve a relative memory path without allowing traversal outside memory_dir."""
70+
if "\x00" in file_path:
71+
raise HTTPException(status_code=400, detail="Null bytes are not allowed in paths")
72+
if os.path.isabs(file_path):
73+
raise HTTPException(status_code=403, detail="Absolute paths are not allowed")
74+
75+
base_dir = _memory_base_dir()
76+
resolved = os.path.realpath(os.path.join(base_dir, file_path))
77+
try:
78+
within_root = os.path.commonpath([base_dir, resolved]) == base_dir
79+
except ValueError as exc:
80+
raise HTTPException(status_code=403, detail="Path traversal rejected") from exc
81+
if not within_root:
82+
raise HTTPException(status_code=403, detail="Path traversal rejected")
83+
return base_dir, resolved
84+
5785
def _generate_summary(content: str) -> str:
5886
"""Invokes Ollama to produce a single-sentence logical summary of file memory.
5987
@@ -163,12 +191,17 @@ def list_api(category: str | None = None, core_only: bool = False) -> list[dict[
163191
from palinode.core import parser
164192

165193
results = []
166-
base_dir = getattr(config, 'memory_dir', config.palinode_dir)
194+
base_dir = _memory_base_dir()
167195
search_pattern = os.path.join(base_dir, "**/*.md")
168196

169197
skip_dirs = {"daily", "archive", "inbox", "logs"}
170198

171199
for filepath in glob.glob(search_pattern, recursive=True):
200+
try:
201+
if os.path.commonpath([base_dir, os.path.realpath(filepath)]) != base_dir:
202+
continue
203+
except ValueError:
204+
continue
172205
rel_path = os.path.relpath(filepath, base_dir)
173206
parts = rel_path.split(os.sep)
174207

@@ -207,19 +240,20 @@ def list_api(category: str | None = None, core_only: bool = False) -> list[dict[
207240
def read_api(file_path: str, meta: bool = False) -> dict[str, Any]:
208241
from palinode.core import parser
209242

210-
base_dir = getattr(config, 'memory_dir', config.palinode_dir)
211-
243+
candidates = [file_path]
212244
if not file_path.endswith(".md"):
213-
test_path = os.path.join(base_dir, file_path)
214-
if not os.path.exists(test_path):
215-
file_path += ".md"
216-
217-
resolved = os.path.realpath(os.path.join(base_dir, file_path))
218-
if not resolved.startswith(os.path.realpath(base_dir)):
219-
raise HTTPException(status_code=403, detail="Path traversal rejected")
220-
221-
if not os.path.exists(resolved):
222-
raise HTTPException(status_code=404, detail="File not found")
245+
candidates.append(f"{file_path}.md")
246+
247+
resolved = ""
248+
for candidate in candidates:
249+
_, resolved_candidate = _resolve_memory_path(candidate)
250+
if os.path.exists(resolved_candidate):
251+
file_path = candidate
252+
resolved = resolved_candidate
253+
break
254+
255+
if not resolved:
256+
raise HTTPException(status_code=404, detail="File not found")
223257

224258
try:
225259
with open(resolved, "r") as f:
@@ -617,11 +651,7 @@ def history_api(file_path: str, limit: int = 10) -> dict[str, Any]:
617651
Shows when and how a memory was created, updated, or superseded.
618652
"""
619653
import subprocess
620-
base_dir = os.path.abspath(getattr(config, 'memory_dir', config.palinode_dir))
621-
full_path = os.path.abspath(os.path.join(base_dir, file_path))
622-
623-
if not full_path.startswith(base_dir):
624-
raise HTTPException(status_code=400, detail="Invalid path (path traversal detected)")
654+
base_dir, full_path = _resolve_memory_path(file_path)
625655

626656
if not os.path.exists(full_path):
627657
raise HTTPException(status_code=404, detail="File not found")

palinode/cli/__init__.py

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import click
2-
import asyncio
32
from palinode.core.config import config
43
from palinode.cli.search import search
54
from palinode.cli.save import save
@@ -58,38 +57,38 @@ def main():
5857
@click.option("--api/--no-api", default=True, help="Run API server")
5958
def start(watcher, api):
6059
"""Start Palinode services in the foreground."""
61-
from palinode.api.server import APIServer
62-
from palinode.indexer.sqlite import SQLiteIndexer
63-
from palinode.ingest.watcher import Watcher
64-
from rich.live import Live
65-
from rich.panel import Panel
60+
from multiprocessing import Process
6661
from rich.console import Console
62+
from palinode.api.server import main as api_main
63+
from palinode.indexer.watcher import main as watcher_main
6764

6865
console = Console()
69-
70-
async def run_services():
71-
tasks = []
72-
if api:
73-
console.print("[green]Starting API server...[/green]")
74-
api_service = APIServer(config)
75-
tasks.append(api_service.start())
76-
77-
if watcher:
78-
console.print("[green]Starting watcher...[/green]")
79-
indexer = SQLiteIndexer(config)
80-
watcher_service = Watcher(config, indexer)
81-
tasks.append(watcher_service.start())
82-
83-
if not tasks:
84-
console.print("[yellow]No services specified to start.[/yellow]")
85-
return
86-
87-
await asyncio.gather(*tasks)
66+
processes = []
67+
68+
if api:
69+
console.print("[green]Starting API server...[/green]")
70+
processes.append(Process(target=api_main, daemon=False))
71+
72+
if watcher:
73+
console.print("[green]Starting watcher...[/green]")
74+
processes.append(Process(target=watcher_main, daemon=False))
75+
76+
if not processes:
77+
console.print("[yellow]No services specified to start.[/yellow]")
78+
return
8879

8980
try:
90-
asyncio.run(run_services())
81+
for process in processes:
82+
process.start()
83+
for process in processes:
84+
process.join()
9185
except KeyboardInterrupt:
9286
console.print("\n[yellow]Stopping services...[/yellow]")
87+
for process in processes:
88+
if process.is_alive():
89+
process.terminate()
90+
for process in processes:
91+
process.join(timeout=5)
9392

9493
@main.command()
9594
@click.option("--watcher/--no-watcher", default=True, help="Stop memory watcher")

palinode/cli/read.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@
66
from palinode.cli._format import print_result, get_default_format, OutputFormat
77

88

9+
def _resolve_memory_path(file_path: str) -> tuple[str, str]:
10+
"""Resolve a relative memory path without allowing traversal outside memory_dir."""
11+
if "\x00" in file_path:
12+
raise click.ClickException("Null bytes are not allowed in paths")
13+
if os.path.isabs(file_path):
14+
raise click.ClickException("Absolute paths are not allowed")
15+
16+
base_dir = os.path.realpath(config.memory_dir)
17+
resolved = os.path.realpath(os.path.join(base_dir, file_path))
18+
try:
19+
within_root = os.path.commonpath([base_dir, resolved]) == base_dir
20+
except ValueError as exc:
21+
raise click.ClickException("Invalid path") from exc
22+
if not within_root:
23+
raise click.ClickException("Path traversal rejected")
24+
return base_dir, resolved
25+
26+
927
@click.command()
1028
@click.argument("file_path")
1129
@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default=None, help="Output format")
@@ -21,21 +39,13 @@ def read(file_path, fmt, meta):
2139
2240
palinode read projects/palinode-status.md --meta --format json
2341
"""
24-
# Resolve path relative to memory_dir
25-
if os.path.isabs(file_path):
26-
full_path = file_path
27-
else:
28-
full_path = os.path.join(config.memory_dir, file_path)
42+
_, full_path = _resolve_memory_path(file_path)
2943

3044
if not os.path.exists(full_path):
31-
# Try with .md extension
32-
if not full_path.endswith(".md"):
33-
full_path_md = full_path + ".md"
34-
if os.path.exists(full_path_md):
35-
full_path = full_path_md
36-
else:
37-
raise click.ClickException(f"File not found: {file_path}")
38-
else:
45+
if not file_path.endswith(".md"):
46+
file_path = f"{file_path}.md"
47+
_, full_path = _resolve_memory_path(file_path)
48+
if not os.path.exists(full_path):
3949
raise click.ClickException(f"File not found: {file_path}")
4050

4151
with open(full_path, "r") as f:

0 commit comments

Comments
 (0)