Skip to content

Commit 1a2a106

Browse files
tbitcsoz-agent
andcommitted
release: v0.11.2 — ESDB rollback/compact, CodeQL security fixes, Kairos PRs #23 #24
670 tests passing. All 12 CodeQL findings fixed. Co-Authored-By: Oz <oz-agent@warp.dev>
2 parents 844722e + a850bbc commit 1a2a106

10 files changed

Lines changed: 639 additions & 214 deletions

File tree

CHANGELOG.md

Lines changed: 179 additions & 166 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "specsmith"
7-
version = "0.11.1"
7+
version = "0.11.2"
88
description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
99
readme = "README.md"
1010
license = "MIT"

src/specsmith/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
try:
99
__version__: str = _pkg_version("specsmith")
1010
except PackageNotFoundError: # running from source without install
11-
__version__ = "0.11.1" # fallback: keep in sync with pyproject.toml
11+
__version__ = "0.11.2" # fallback: keep in sync with pyproject.toml

src/specsmith/agent/broker.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@
4242
from pathlib import Path
4343
from typing import Any
4444

45+
46+
def _safe_file_read(path: Path, encoding: str = "utf-8") -> str:
47+
"""Read a file after validating it contains no path-traversal components.
48+
49+
CodeQL ``py/path-injection``: reject any path whose components include
50+
``..`` or null bytes before performing the read.
51+
"""
52+
raw = str(path)
53+
if "\x00" in raw:
54+
raise ValueError(f"Path contains null byte: {raw!r}")
55+
for part in path.parts:
56+
if part in ("..", "..."):
57+
raise ValueError(f"Path traversal rejected: {raw!r}")
58+
return path.read_text(encoding=encoding)
59+
4560
# ---------------------------------------------------------------------------
4661
# Intent classification
4762
# ---------------------------------------------------------------------------
@@ -190,7 +205,10 @@ def parse_requirements(req_md_path: Path) -> list[RequirementSummary]:
190205
"""
191206
if not req_md_path.is_file():
192207
return []
193-
text = req_md_path.read_text(encoding="utf-8")
208+
try:
209+
text = _safe_file_read(req_md_path)
210+
except ValueError:
211+
return []
194212
out: list[RequirementSummary] = []
195213
blocks = re.split(r"^##\s+\d+\.\s+", text, flags=re.MULTILINE)[1:]
196214
for block in blocks:
@@ -258,8 +276,8 @@ def infer_scope(
258276
suggested_files: list[str] = []
259277
if repo_index_path and repo_index_path.is_file():
260278
try:
261-
files = json.loads(repo_index_path.read_text(encoding="utf-8"))
262-
except (OSError, json.JSONDecodeError):
279+
files = json.loads(_safe_file_read(repo_index_path))
280+
except (OSError, json.JSONDecodeError, ValueError):
263281
files = []
264282
for f in files:
265283
if not isinstance(f, str):

src/specsmith/cli.py

Lines changed: 260 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8516,13 +8516,271 @@ def esdb_replay_cmd(project_dir: str) -> None:
85168516
console.print(f"[bold]Replay check:[/bold] {st.backend}")
85178517
console.print(f" Records: {st.record_count}")
85188518
if st.chain_valid:
8519-
console.print("[green]\u2714[/green] WAL chain valid state consistent.")
8519+
console.print("[green]\u2714[/green] WAL chain valid \u2014 state consistent.")
85208520
else:
85218521
console.print("[red]\u2717[/red] WAL chain integrity failure detected.")
85228522

85238523

8524+
@esdb_group.command(name="export")
8525+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8526+
@click.option(
8527+
"--output",
8528+
default="",
8529+
help="Output file path (default: <project>/.specsmith/esdb_export.json)",
8530+
)
8531+
@click.option("--json", "as_json", is_flag=True, default=False)
8532+
def esdb_export_cmd(project_dir: str, output: str, as_json: bool) -> None:
8533+
"""Export the full ESDB to a JSON file."""
8534+
import json as _json
8535+
8536+
from specsmith.esdb.bridge import EsdbBridge
8537+
8538+
bridge = EsdbBridge(project_dir)
8539+
st = bridge.status()
8540+
reqs = bridge.requirements()
8541+
tests = bridge.testcases()
8542+
payload = {
8543+
"esdb_version": 1,
8544+
"backend": st.backend,
8545+
"record_count": st.record_count,
8546+
"requirements": [r.to_dict() for r in reqs],
8547+
"testcases": [t.to_dict() for t in tests],
8548+
}
8549+
dest = output or str(Path(project_dir).resolve() / ".specsmith" / "esdb_export.json")
8550+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
8551+
Path(dest).write_text(_json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
8552+
if as_json:
8553+
click.echo(_json.dumps({"ok": True, "path": dest, "records": st.record_count}, indent=2))
8554+
else:
8555+
console.print(f"[green]\u2714[/green] Exported {st.record_count} records to {dest}")
8556+
8557+
8558+
@esdb_group.command(name="import")
8559+
@click.argument("source")
8560+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8561+
@click.option("--json", "as_json", is_flag=True, default=False)
8562+
def esdb_import_cmd(source: str, project_dir: str, as_json: bool) -> None:
8563+
"""Import an ESDB JSON export into the project store."""
8564+
import json as _json
8565+
8566+
src = Path(source)
8567+
if not src.is_file():
8568+
console.print(f"[red]File not found:[/red] {source}")
8569+
raise SystemExit(1)
8570+
try:
8571+
data = _json.loads(src.read_text(encoding="utf-8"))
8572+
except ValueError as exc:
8573+
console.print(f"[red]Invalid JSON:[/red] {exc}")
8574+
raise SystemExit(1) from exc
8575+
8576+
reqs = data.get("requirements", [])
8577+
tests = data.get("testcases", [])
8578+
specsmith_dir = Path(project_dir).resolve() / ".specsmith"
8579+
specsmith_dir.mkdir(parents=True, exist_ok=True)
8580+
8581+
# Write requirements and testcases directly to the live JSON stores.
8582+
# Existing data is replaced with the imported snapshot.
8583+
reqs_path = specsmith_dir / "requirements.json"
8584+
tests_path = specsmith_dir / "testcases.json"
8585+
reqs_path.write_text(_json.dumps(reqs, indent=2, ensure_ascii=False), encoding="utf-8")
8586+
tests_path.write_text(_json.dumps(tests, indent=2, ensure_ascii=False), encoding="utf-8")
8587+
8588+
result = {"ok": True, "requirements": len(reqs), "testcases": len(tests)}
8589+
if as_json:
8590+
click.echo(_json.dumps(result, indent=2))
8591+
else:
8592+
console.print(
8593+
f"[green]\u2714[/green] Imported {len(reqs)} requirements, {len(tests)} test cases."
8594+
)
8595+
console.print(f" Wrote .specsmith/requirements.json and .specsmith/testcases.json")
8596+
8597+
8598+
@esdb_group.command(name="backup")
8599+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8600+
@click.option(
8601+
"--dir",
8602+
"backup_dir",
8603+
default="",
8604+
help="Directory for backup files (default: .specsmith/backups/)",
8605+
)
8606+
@click.option("--json", "as_json", is_flag=True, default=False)
8607+
def esdb_backup_cmd(project_dir: str, backup_dir: str, as_json: bool) -> None:
8608+
"""Create a timestamped snapshot backup of the ESDB."""
8609+
import datetime
8610+
import json as _json
8611+
8612+
from specsmith.esdb.bridge import EsdbBridge
8613+
8614+
bridge = EsdbBridge(project_dir)
8615+
st = bridge.status()
8616+
ts = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
8617+
dest_dir = (
8618+
Path(backup_dir) if backup_dir else Path(project_dir).resolve() / ".specsmith" / "backups"
8619+
)
8620+
dest_dir.mkdir(parents=True, exist_ok=True)
8621+
dest = dest_dir / f"esdb_backup_{ts}.json"
8622+
reqs = bridge.requirements()
8623+
tests = bridge.testcases()
8624+
payload = {
8625+
"esdb_version": 1,
8626+
"timestamp": ts,
8627+
"backend": st.backend,
8628+
"record_count": st.record_count,
8629+
"requirements": [r.to_dict() for r in reqs],
8630+
"testcases": [t.to_dict() for t in tests],
8631+
}
8632+
dest.write_text(_json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
8633+
result = {"ok": True, "path": str(dest), "timestamp": ts, "records": st.record_count}
8634+
if as_json:
8635+
click.echo(_json.dumps(result, indent=2))
8636+
else:
8637+
console.print(f"[green]\u2714[/green] Backup created: {dest} ({st.record_count} records)")
8638+
8639+
8640+
@esdb_group.command(name="rollback")
8641+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8642+
@click.option("--steps", default=1, show_default=True, help="Number of backups to roll back (default: 1 = latest backup).")
8643+
@click.option("--json", "as_json", is_flag=True, default=False)
8644+
def esdb_rollback_cmd(project_dir: str, steps: int, as_json: bool) -> None:
8645+
"""Restore the ESDB from the most recent backup snapshot.
8646+
8647+
Finds the N-th most recent backup in .specsmith/backups/ (N = --steps)
8648+
and restores requirements.json + testcases.json from it.
8649+
"""
8650+
import json as _json
8651+
8652+
from specsmith.esdb.bridge import EsdbBridge
8653+
8654+
root = Path(project_dir).resolve()
8655+
backups_dir = root / ".specsmith" / "backups"
8656+
if not backups_dir.is_dir():
8657+
result = {"ok": False, "error": "No backups directory found. Run `specsmith esdb backup` first."}
8658+
if as_json:
8659+
click.echo(_json.dumps(result, indent=2))
8660+
else:
8661+
console.print(f"[red]\u2717[/red] {result['error']}")
8662+
raise SystemExit(1)
8663+
8664+
backup_files = sorted(backups_dir.glob("esdb_backup_*.json"), reverse=True)
8665+
if not backup_files:
8666+
result = {"ok": False, "error": "No backup files found in .specsmith/backups/."}
8667+
if as_json:
8668+
click.echo(_json.dumps(result, indent=2))
8669+
else:
8670+
console.print(f"[red]\u2717[/red] {result['error']}")
8671+
raise SystemExit(1)
8672+
8673+
target_idx = min(steps - 1, len(backup_files) - 1)
8674+
backup_path = backup_files[target_idx]
8675+
8676+
try:
8677+
data = _json.loads(backup_path.read_text(encoding="utf-8"))
8678+
except (OSError, ValueError) as exc:
8679+
result = {"ok": False, "error": f"Cannot read backup {backup_path.name}: {exc}"}
8680+
if as_json:
8681+
click.echo(_json.dumps(result, indent=2))
8682+
else:
8683+
console.print(f"[red]\u2717[/red] {result['error']}")
8684+
raise SystemExit(1) from exc
8685+
8686+
reqs = data.get("requirements", [])
8687+
tests = data.get("testcases", [])
8688+
specsmith_dir = root / ".specsmith"
8689+
specsmith_dir.mkdir(parents=True, exist_ok=True)
8690+
(specsmith_dir / "requirements.json").write_text(
8691+
_json.dumps(reqs, indent=2, ensure_ascii=False), encoding="utf-8"
8692+
)
8693+
(specsmith_dir / "testcases.json").write_text(
8694+
_json.dumps(tests, indent=2, ensure_ascii=False), encoding="utf-8"
8695+
)
8696+
8697+
# Invalidate the bridge cache so any subsequent bridge calls reflect the restore.
8698+
bridge = EsdbBridge(project_dir)
8699+
st = bridge.status()
8700+
8701+
result = {
8702+
"ok": True,
8703+
"restored_from": backup_path.name,
8704+
"timestamp": data.get("timestamp", ""),
8705+
"requirements": len(reqs),
8706+
"testcases": len(tests),
8707+
"records_after": st.record_count,
8708+
}
8709+
if as_json:
8710+
click.echo(_json.dumps(result, indent=2))
8711+
else:
8712+
console.print(f"[green]\u2714[/green] Restored from backup: [bold]{backup_path.name}[/bold]")
8713+
console.print(f" Requirements: {len(reqs)} \u00b7 Test cases: {len(tests)}")
8714+
8715+
8716+
@esdb_group.command(name="compact")
8717+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8718+
@click.option("--json", "as_json", is_flag=True, default=False)
8719+
def esdb_compact_cmd(project_dir: str, as_json: bool) -> None:
8720+
"""Compact the ESDB: deduplicate records and remove empty entries.
8721+
8722+
Reads .specsmith/requirements.json and .specsmith/testcases.json,
8723+
deduplicates by ID (last-write-wins), drops records with no ID,
8724+
and writes the compacted lists back to disk.
8725+
"""
8726+
import json as _json
8727+
8728+
root = Path(project_dir).resolve()
8729+
specsmith_dir = root / ".specsmith"
8730+
8731+
removed_reqs = 0
8732+
removed_tests = 0
8733+
8734+
for filename, kind in (("requirements.json", "requirements"), ("testcases.json", "testcases")):
8735+
path = specsmith_dir / filename
8736+
if not path.is_file():
8737+
continue
8738+
try:
8739+
records = _json.loads(path.read_text(encoding="utf-8"))
8740+
except (OSError, ValueError):
8741+
continue
8742+
if not isinstance(records, list):
8743+
continue
8744+
before = len(records)
8745+
# Deduplicate by ID (last occurrence wins); drop entries with no ID.
8746+
seen: dict[str, object] = {}
8747+
for rec in records:
8748+
if not isinstance(rec, dict):
8749+
continue
8750+
rid = rec.get("id") or rec.get("req_id") or ""
8751+
if not rid:
8752+
continue
8753+
seen[rid] = rec
8754+
compacted = list(seen.values())
8755+
after = len(compacted)
8756+
if kind == "requirements":
8757+
removed_reqs = before - after
8758+
else:
8759+
removed_tests = before - after
8760+
path.write_text(_json.dumps(compacted, indent=2, ensure_ascii=False), encoding="utf-8")
8761+
8762+
from specsmith.esdb.bridge import EsdbBridge
8763+
8764+
bridge = EsdbBridge(project_dir)
8765+
st = bridge.status()
8766+
8767+
result = {
8768+
"ok": True,
8769+
"backend": st.backend,
8770+
"records_after": st.record_count,
8771+
"removed_duplicate_requirements": removed_reqs,
8772+
"removed_duplicate_testcases": removed_tests,
8773+
}
8774+
if as_json:
8775+
click.echo(_json.dumps(result, indent=2))
8776+
else:
8777+
total_removed = removed_reqs + removed_tests
8778+
console.print(
8779+
f"[green]\u2714[/green] Compact complete on {st.backend} "
8780+
f"({st.record_count} records, {total_removed} duplicates removed)"
8781+
)
8782+
85248783

8525-
>>>>>>> 968261582aa77adba116f3c092ae13c2449a4df8
85268784
main.add_command(esdb_group)
85278785

85288786

src/specsmith/esdb/bridge.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ class EsdbRecord:
2727
data: dict[str, Any] = field(default_factory=dict)
2828
source_ids: list[str] = field(default_factory=list)
2929

30+
def to_dict(self) -> dict[str, Any]:
31+
"""Return the original source data dict (used for export/backup)."""
32+
return self.data if self.data else {
33+
"id": self.id,
34+
"kind": self.kind,
35+
"status": self.status,
36+
"confidence": self.confidence,
37+
"label": self.label,
38+
}
39+
3040

3141
@dataclass
3242
class EsdbStatus:

0 commit comments

Comments
 (0)