Skip to content

Commit 06ce489

Browse files
Paul Kyleclaude
andcommitted
fix: CLI/API parity, path validation, scrub residues
- Sync CLI client (_api.py) with live API shapes: search uses limit/category, diff uses days/paths, triggers hit /triggers - Route blame/timeline/rollback/push through HTTP instead of bypassing to git_tools directly - Add _resolve_memory_path() guard to git_tools.py — rejects null bytes and path traversal on blame, timeline, rollback - Validate absolute paths in migrate/openclaw endpoint - Scrub 5060 VRAM from session_end.py example - Scrub governor/gradient agent names from PRD.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf3272a commit 06ce489

File tree

8 files changed

+82
-57
lines changed

8 files changed

+82
-57
lines changed

PRD.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ All of these store things. None of them *remember*.
5454

5555
**Secondary:** The human (Paul) who browses, edits, and reviews memory files directly; receives daily digests and weekly reviews.
5656

57-
**Tertiary:** Other agents (governor, gradient, emission) that read from shared memory.
57+
**Tertiary:** Other AI agents that read from shared memory (multi-agent setups).
5858

5959
---
6060

@@ -694,7 +694,7 @@ Prompts are not disposable. They're the most durable artifact in the system —
694694
| **1: Core Memory** | Two-phase injection + core memory files + retire MEMORY.md | Week 2 |
695695
| **2: Consolidation** | Weekly cron + entity linking + insights extraction | Weeks 3-4 |
696696
| **3: Migration** | Backfill from Mem0 (2,632) + QC MCP (14K) selectively | Week 4+ |
697-
| **4: Multi-Agent + MCP** | Orchestrator/gradient/emission read access + MCP server for external tools | Future |
697+
| **4: Multi-Agent + MCP** | Multi-agent read access + MCP server for external tools | Future |
698698

699699
---
700700

@@ -715,6 +715,6 @@ Prompts are not disposable. They're the most durable artifact in the system —
715715

716716
**After 3 months:**
717717

718-
- [ ] Multiple agents share Palinode (read access for governor/gradient)
718+
- [ ] Multiple agents share Palinode (read access for all agent profiles)
719719
- [ ] Palinode has survived at least one infrastructure failure without data loss
720720
- [ ] Paul trusts the system enough to stop manually curating MEMORY.md

palinode/api/server.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,18 +1010,19 @@ def migrate_openclaw_api(req: MigrateOpenClawRequest) -> dict:
10101010
if "\x00" in path:
10111011
raise HTTPException(status_code=400, detail="Null bytes are not allowed in path")
10121012

1013-
# Resolve relative paths against memory_dir; reject absolute paths that
1014-
# point outside it (absolute paths that are already safe are accepted).
1015-
if not os.path.isabs(path):
1016-
base = _memory_base_dir()
1013+
# Resolve against memory_dir; reject paths that escape it.
1014+
base = _memory_base_dir()
1015+
if os.path.isabs(path):
1016+
resolved_path = os.path.realpath(path)
1017+
else:
10171018
resolved_path = os.path.realpath(os.path.join(base, path))
1018-
try:
1019-
within = os.path.commonpath([base, resolved_path]) == base
1020-
except ValueError:
1021-
within = False
1022-
if not within:
1023-
raise HTTPException(status_code=403, detail="Path traversal rejected")
1024-
path = resolved_path
1019+
try:
1020+
within = os.path.commonpath([base, resolved_path]) == base
1021+
except ValueError:
1022+
within = False
1023+
if not within:
1024+
raise HTTPException(status_code=403, detail="Path traversal rejected")
1025+
path = resolved_path
10251026

10261027
if not os.path.isfile(path):
10271028
raise HTTPException(status_code=404, detail=f"File not found: {path}")

palinode/cli/_api.py

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ def __init__(self):
1010
)
1111
self.client = httpx.Client(base_url=self.base_url, timeout=30.0)
1212

13-
def search(self, query: str, top_k: int = 3, type_filter: str = None):
14-
payload = {"query": query, "top_k": top_k}
15-
if type_filter:
16-
payload["type"] = type_filter
17-
13+
def search(self, query: str, limit: int = 3, category: str = None):
14+
payload: dict = {"query": query, "limit": limit}
15+
if category:
16+
payload["category"] = category
17+
1818
response = self.client.post("/search", json=payload)
1919
response.raise_for_status()
2020
return response.json()
@@ -37,13 +37,11 @@ def get_status(self):
3737
response.raise_for_status()
3838
return response.json()
3939

40-
def get_diff(self, file_path: str = None, commits: int = None):
41-
params = {}
42-
if file_path:
43-
params["file"] = file_path
44-
if commits:
45-
params["commits"] = commits
46-
40+
def get_diff(self, days: int = 7, paths: str = None):
41+
params: dict = {"days": days}
42+
if paths:
43+
params["paths"] = paths
44+
4745
response = self.client.get("/diff", params=params)
4846
response.raise_for_status()
4947
return response.json()
@@ -53,23 +51,23 @@ def consolidate(self, dry_run: bool = False, nightly: bool = False):
5351
response.raise_for_status()
5452
return response.json()
5553

56-
def trigger_add(self, description: str, file_path: str, threshold: float = 0.4):
57-
payload = {
54+
def trigger_add(self, description: str, memory_file: str, threshold: float = 0.75):
55+
payload: dict = {
5856
"description": description,
59-
"file": file_path,
60-
"threshold": threshold
57+
"memory_file": memory_file,
58+
"threshold": threshold,
6159
}
62-
response = self.client.post("/trigger/add", json=payload)
60+
response = self.client.post("/triggers", json=payload)
6361
response.raise_for_status()
6462
return response.json()
6563

6664
def trigger_list(self):
67-
response = self.client.get("/trigger/list")
65+
response = self.client.get("/triggers")
6866
response.raise_for_status()
6967
return response.json()
7068

7169
def trigger_remove(self, trigger_id: str):
72-
response = self.client.delete(f"/trigger/remove/{trigger_id}")
70+
response = self.client.delete(f"/triggers/{trigger_id}")
7371
response.raise_for_status()
7472
return response.json()
7573

@@ -121,22 +119,30 @@ def migrate_mem0(self):
121119
return response.json()
122120

123121
def blame(self, file_path: str, search: str = None):
124-
# We need git_tools for these if they are not exposed by API yet
125-
# But for now, let's assume they are or we handle them
126-
from palinode.core import git_tools
127-
return git_tools.blame(file_path, search)
122+
params: dict = {}
123+
if search:
124+
params["search"] = search
125+
response = self.client.get(f"/blame/{file_path}", params=params, timeout=10.0)
126+
response.raise_for_status()
127+
return response.json()
128128

129129
def timeline(self, file_path: str, limit: int = 20):
130-
from palinode.core import git_tools
131-
return git_tools.timeline(file_path, limit)
130+
response = self.client.get(f"/timeline/{file_path}", params={"limit": limit}, timeout=10.0)
131+
response.raise_for_status()
132+
return response.json()
132133

133-
def rollback(self, file_path: str, commit: str, dry_run: bool = True):
134-
from palinode.core import git_tools
135-
return git_tools.rollback(file_path, commit, dry_run=dry_run)
134+
def rollback(self, file_path: str, commit: str = None, dry_run: bool = True):
135+
params: dict = {"file_path": file_path, "dry_run": dry_run}
136+
if commit:
137+
params["commit"] = commit
138+
response = self.client.post("/rollback", params=params, timeout=30.0)
139+
response.raise_for_status()
140+
return response.json()
136141

137142
def push(self):
138-
from palinode.core import git_tools
139-
return git_tools.push()
143+
response = self.client.post("/push", timeout=60.0)
144+
response.raise_for_status()
145+
return response.json()
140146

141147
def health_check(self):
142148
try:

palinode/cli/diff.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from palinode.cli._format import console, print_result, get_default_format, OutputFormat
44

55
@click.command()
6-
@click.argument("file", required=False)
7-
@click.option("--commits", type=int, help="Number of recent commits to show")
6+
@click.option("--days", type=int, default=7, help="Look back N days (default: 7)")
7+
@click.option("--paths", type=str, default=None, help="Comma-separated path filters (e.g. projects/,decisions/)")
88
@click.option("--format", "fmt", type=click.Choice(["json", "text"]), help="Output format")
9-
def diff(file, commits, fmt):
9+
def diff(days, paths, fmt):
1010
"""Show recent changes to memory files."""
1111
try:
12-
data = api_client.get_diff(file_path=file, commits=commits)
12+
data = api_client.get_diff(days=days, paths=paths)
1313

1414
output_fmt = OutputFormat(fmt) if fmt else get_default_format()
1515

palinode/cli/search.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55

66
@click.command()
77
@click.argument("query")
8-
@click.option("--top-k", default=3, help="Number of results (default: 3)")
9-
@click.option("--type", "type_filter", help="Filter by memory type")
8+
@click.option("--limit", default=3, help="Number of results (default: 3)")
9+
@click.option("--category", help="Filter by memory type/category")
1010
@click.option("--format", "fmt", type=click.Choice(["json", "text"]), help="Output format")
1111
@click.option("--score/--no-score", default=False, help="Show relevance scores")
12-
def search(query, top_k, type_filter, fmt, score):
12+
def search(query, limit, category, fmt, score):
1313
"""Search memory by meaning or keyword."""
1414
try:
15-
results = api_client.search(query, top_k=top_k, type_filter=type_filter)
15+
results = api_client.search(query, limit=limit, category=category)
1616

1717
output_fmt = OutputFormat(fmt) if fmt else get_default_format()
1818

palinode/cli/session_end.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def session_end(summary, decision, blocker, project, source, fmt):
2222
2323
palinode session-end "Implemented CLI wrapper with 22 commands"
2424
25-
palinode session-end "Fixed 5060 VRAM budget" -d "Use qwen2.5:14b" -b "Test under load" -p palinode
25+
palinode session-end "Fixed embedding timeout" -d "Increase batch size" -b "Test under load" -p palinode
2626
"""
2727
now = datetime.now(timezone.utc)
2828
today = now.strftime("%Y-%m-%d")

palinode/cli/trigger.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ def trigger():
1010

1111
@trigger.command(name="add")
1212
@click.argument("description")
13-
@click.option("--file", "file_path", required=True, help="Memory file to trigger")
14-
@click.option("--threshold", type=float, default=0.4, help="Similarity threshold (0.0 to 1.0)")
13+
@click.option("--file", "memory_file", required=True, help="Memory file to trigger")
14+
@click.option("--threshold", type=float, default=0.75, help="Similarity threshold (0.0 to 1.0)")
1515
@click.option("--format", "fmt", type=click.Choice(["json", "text"]), help="Output format")
16-
def trigger_add(description, file_path, threshold, fmt):
16+
def trigger_add(description, memory_file, threshold, fmt):
1717
"""Register a new auto-surface trigger."""
1818
try:
19-
result = api_client.trigger_add(description, file_path, threshold)
19+
result = api_client.trigger_add(description, memory_file, threshold)
2020

2121
output_fmt = OutputFormat(fmt) if fmt else get_default_format()
2222
if output_fmt == OutputFormat.JSON:

palinode/core/git_tools.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@
2020
logger = logging.getLogger("palinode.git_tools")
2121

2222

23+
def _resolve_memory_path(file_path: str) -> str:
24+
"""Resolve a relative file_path against memory_dir and reject traversal.
25+
26+
Returns the validated relative path. Raises ValueError if the resolved
27+
path escapes memory_dir.
28+
"""
29+
if "\x00" in file_path:
30+
raise ValueError("Null bytes are not allowed in file paths")
31+
base = os.path.realpath(config.memory_dir)
32+
resolved = os.path.realpath(os.path.join(base, file_path))
33+
if not resolved.startswith(base + os.sep) and resolved != base:
34+
raise ValueError(f"Path traversal rejected: {file_path}")
35+
return file_path
36+
37+
2338
def _utc_now() -> datetime:
2439
"""Return a timezone-aware UTC timestamp."""
2540
return datetime.now(UTC)
@@ -116,6 +131,7 @@ def blame(file_path: str, search: str | None = None) -> str:
116131
Returns:
117132
Formatted blame output with both git dates and origin provenance.
118133
"""
134+
file_path = _resolve_memory_path(file_path)
119135
full_path = os.path.join(config.memory_dir, file_path)
120136
if not os.path.exists(full_path):
121137
return f"File not found: {file_path}"
@@ -189,6 +205,7 @@ def timeline(file_path: str, limit: int = 20) -> str:
189205
Returns:
190206
Formatted timeline with dates, messages, and change summaries.
191207
"""
208+
file_path = _resolve_memory_path(file_path)
192209
if not os.path.exists(os.path.join(config.memory_dir, file_path)):
193210
return f"File not found: {file_path}"
194211

@@ -231,6 +248,7 @@ def rollback(file_path: str, commit: str | None = None, dry_run: bool = False) -
231248
Returns:
232249
Description of what was (or would be) rolled back.
233250
"""
251+
file_path = _resolve_memory_path(file_path)
234252
if not os.path.exists(os.path.join(config.memory_dir, file_path)):
235253
return f"File not found: {file_path}"
236254

0 commit comments

Comments
 (0)