Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,10 @@ Please do **not** open a public issue for security vulnerabilities. We aim to ac

## Supported Versions

Only the current major release line is supported. All minor and patch versions within it receive security fixes.

| Version | Supported |
|---------|-----------|
| 6.x (current) | Yes, all minor and patch releases |
| < 6 | No |

When a vulnerability is found, the fix is shipped as a new release. There are no backports to older major versions. Fixes are not applied in place to existing binaries. **If you run tracedecay in production automation (CI pipelines, scheduled agents, server-side MCP deployments), keep it updated to the latest release** so any future fix reaches you immediately via `tracedecay upgrade`.
Security fixes ship in the latest published release. Pre-release builds are
supported by updating to the latest pre-release; older prereleases and historical
major versions do not receive backports. Consult the repository Releases page
for the current version rather than assuming a pinned major line remains current.

## Security Model

Expand All @@ -33,9 +29,15 @@ tracedecay builds a **local** code graph stored in the active project store. Rep
- Cross-session memory: durable facts, named entities, code-area notes, decisions, and feedback events in the holographic fact store. Those rows are local-only project data.
- A response cache for mode-aware source reads (`read_cache` table): the rendered output served to the agent, stored as a BLOB keyed by file path, mode, and arguments. For full/line-range reads this rendered output contains source text. Rows are freshness-gated by file mtime and swept after a period of inactivity.

Aside from the `read_cache`, the graph itself does **not** persist raw source code. It stores structural metadata only. The active project store is local-only. There is no cloud sync, remote database, or server-side storage.
Sealed code-index artifacts also persist sanitized source chunks for retrieval.
Sanitization can remove detected secrets; it does not remove proprietary source
or guarantee that all sensitive content is recognized. Treat index artifacts,
source-read responses, session messages, and durable facts as private data.
Local operation is the default. Explicitly configured remote sources and remote
authorities can exchange policy-authorized content with authenticated peers;
these deployments require reviewing their configuration and peer trust.

The user-level `~/.tracedecay/global.db` tracks indexed projects, aggregate token-savings counts, and cost accounting data parsed from Claude Code session transcripts. Cursor transcript search is stored in the active project's session store (`.tracedecay/sessions.db` for repo-local projects), which contains ingested Cursor user/assistant message text plus transcript paths and metadata for that project. Both stores remain local-only and are not synced to a remote service.
The user-level `~/.tracedecay/global.db` tracks indexed projects, aggregate token-savings counts, and cost accounting data parsed from Claude Code session transcripts. Cursor transcript search is stored in the active project's session store (`.tracedecay/sessions.db` for repo-local projects), which contains ingested Cursor user/assistant message text plus transcript paths and metadata for that project. These stores are local by default; configured remote authority operations follow their explicit admission and disclosure policy.

### Network access

Expand All @@ -57,11 +59,12 @@ explicitly local listeners:
These controls limit network reachability; they do not isolate TraceDecay from
other processes running as the same operating-system user.

Outbound connections are limited to:
Outbound capabilities include:

| Destination | Purpose | Auth | Failure mode |
|-------------|---------|------|-------------|
| `api.github.com` | Check for releases, discover the pull request for a checkout whose `origin` is on GitHub, and read its reviews and checks | The local GitHub login (`GH_TOKEN`, `gh auth token`, or the git credential helper) when present, anonymous otherwise; optional read-only credential from the OS keyring for configured private sources | Public checks are best effort; an anonymous read GitHub refuses is reported as `denied_no_credential`; configured private access fails closed when credentials or permissions cannot be verified |
| Configured remote sources and authorities | Policy-authorized retrieval and remote operations | Configured peer authentication and disclosure policy | Admission or authorization failures are reported; review each peer before enabling |
| `github.com` | Download binary during `tracedecay upgrade` | None (public releases) | Error shown to user |
| `huggingface.co` and Hugging Face artifact hosts | Download missing, revision-pinned semantic-model artifacts when semantic auto-download is enabled | None | Semantic retrieval reports model acquisition state or failure; exact, lexical, and graph retrieval remain available |
| `tracedecay-counter.enzinol.workers.dev` | Aggregate token-savings counter | None | Silently ignored |
Expand Down Expand Up @@ -132,7 +135,13 @@ recursive-write primitive.
- Replaces the running binary in place via `self-replace`
- Re-registers agent integrations on the next launch when the version bump is minor or major

**macOS / Linux:** Release artifacts are not cryptographically signed. The integrity guarantee relies on HTTPS transport security and GitHub's release infrastructure.
**Installer provenance:** `install.sh` checks same-release SHA-256 sums and uses
`gh attestation verify` to verify build provenance against the configured release
workflow. Missing verification fails installation unless the operator explicitly
opts into the documented unattested install path. Checksums alone establish
consistency, not independent publisher authenticity. Other distribution/update
paths have their own verification; do not assume installer checks apply to all
paths.

**Windows:** Authenticode code signing via the [SignPath.io Foundation](https://signpath.io/foundation) program is being rolled out so Windows binaries are signed as part of the release workflow (addresses the Smart App Control block reported in #79). Until that lands in a published release, Windows binaries remain unsigned.

Expand All @@ -142,12 +151,12 @@ tracedecay installs **no background daemon, system service, or autostart process

### Unsafe code

The codebase contains minimal `unsafe`, used in two cross-platform places:

- **Memory-mapped monitor ring buffer** (`src/monitor.rs`). `memmap2` maps `~/.tracedecay/monitor.mmap`, the shared buffer the `tracedecay monitor` TUI reads
- **Tree-sitter FFI** (`crates/tracedecay-code-extraction/src/ts_provider.rs`), constructing the bundled WGSL grammar from its raw C entry point

The Windows-elevation `unsafe` documented in earlier versions was removed alongside the daemon in 6.0.0.
Unsafe Rust and FFI exist in several owners, including private filesystem
identity/platform primitives (`crates/tracedecay-private-fs`), tree-sitter grammar
bindings (`crates/tracedecay-code-extraction/src/ts_provider.rs`), and platform
runtime integrations. This is not an exhaustive inventory. Review the relevant
platform implementation and its invariants when changing those boundaries;
an old count of unsafe sites is not a security guarantee.

## Best Practices

Expand Down
44 changes: 24 additions & 20 deletions benchmark_data/run_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import json
import os
import random
import resource
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -114,7 +113,7 @@ def benchmark_token_savior(name: str, root: Path) -> tuple[dict, list[str]]:
indexer = ProjectIndexer(root_str)
index = indexer.index()
out["cold_index_seconds"] = round(time.perf_counter() - t0, 3)
out["cold_index_peak_memory_bytes"] = tracemalloc.get_traced_memory()[1]
out["cold_index_python_allocations_peak_bytes"] = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
out["total_files"] = index.total_files
out["total_lines"] = index.total_lines
Expand Down Expand Up @@ -164,23 +163,27 @@ def avg_ms(fn, items: list[str]) -> float | None:
# ---------------------------------------------------------------------------


def ru_maxrss_bytes() -> int:
"""ru_maxrss is bytes on macOS, KiB on Linux."""
r = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
return r if sys.platform == "darwin" else r * 1024


def run_timed(
cmd: list[str],
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> tuple[float, int, subprocess.CompletedProcess]:
before = ru_maxrss_bytes()
t0 = time.perf_counter()
cp = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True)
dt = time.perf_counter() - t0
after = ru_maxrss_bytes()
return dt, max(0, after - before), cp
) -> tuple[float, int | None, subprocess.CompletedProcess]:
# File-backed output avoids pipe deadlocks while wait4 owns child reaping.
with tempfile.TemporaryFile(mode="w+") as stdout, tempfile.TemporaryFile(mode="w+") as stderr:
t0 = time.perf_counter()
process = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=stdout, stderr=stderr, text=True)
peak_rss = None
if hasattr(os, "wait4"):
_, status, usage = os.wait4(process.pid, 0)
process.returncode = os.waitstatus_to_exitcode(status)
peak_rss = int(usage.ru_maxrss) * (1 if sys.platform == "darwin" else 1024)
else:
process.wait()
elapsed = time.perf_counter() - t0
stdout.seek(0)
stderr.seek(0)
completed = subprocess.CompletedProcess(cmd, process.returncode, stdout.read(), stderr.read())
return elapsed, peak_rss, completed


class TraceDecayMcp:
Expand Down Expand Up @@ -292,7 +295,7 @@ def benchmark_tracedecay(name: str, root: Path, sample_symbols: list[str]) -> di
out["error"] = cp.stderr[-2000:]
return out
out["cold_index_seconds"] = round(dt, 3)
out["cold_index_peak_memory_bytes"] = mem
out["cold_index_client_peak_rss_bytes"] = mem

log(f"[tk] {name}: sync (warm) ...")
dt, _, cp = run_timed([TRACEDECAY_BIN, "sync"], cwd=str(root), env=env)
Expand Down Expand Up @@ -432,9 +435,9 @@ def generate_report(results: list[dict], naive_sizes: dict[str, int]) -> str:
"query timing so the find_symbol column is directly comparable.",
"",
"**Memory notes.** token-savior's peak memory is measured with `tracemalloc`",
"(Python heap only). tracedecay runs as a subprocess, so its peak is the",
"`ru_maxrss` delta from `getrusage(RUSAGE_CHILDREN)` (resident set size).",
"These are *not* identical units, treat them as order-of-magnitude.",
"(Python allocations only). TraceDecay reports the specific CLI child process",
"peak RSS from `wait4`, where supported. This excludes a separate daemon.",
"These measure different things and are reported in separate rows; no memory ratio is computed.",
"",
"**Query timing.** token-savior is called in-process (pure Python dict",
"lookups). tracedecay is driven over MCP via `tracedecay serve --timings`",
Expand All @@ -459,7 +462,8 @@ def generate_report(results: list[dict], naive_sizes: dict[str, int]) -> str:
"|--------|--------------|-----------|",
f"| Cold index time | {fmt_sec(ts.get('cold_index_seconds'))} | {fmt_sec(tk.get('cold_index_seconds'))} |",
f"| Warm reindex time | {fmt_sec(ts.get('warm_index_seconds'))} | {fmt_sec(tk.get('warm_index_seconds'))} |",
f"| Peak memory (cold) | {fmt_bytes(ts.get('cold_index_peak_memory_bytes'))} | {fmt_bytes(tk.get('cold_index_peak_memory_bytes'))} |",
f"| Python allocations peak (cold) | {fmt_bytes(ts.get('cold_index_python_allocations_peak_bytes'))} | — |",
f"| CLI process peak RSS (cold) | — | {fmt_bytes(tk.get('cold_index_client_peak_rss_bytes'))} |",
f"| Cache / DB size | {fmt_bytes(ts.get('cache_size_bytes'))} | {fmt_bytes(tk.get('cache_size_bytes'))} |",
f"| Files indexed | {fmt_int(ts.get('total_files'))} | {fmt_int(tk.get('file_count'))} |",
f"| Symbols / nodes | {fmt_int(ts.get('symbol_table_size'))} | {fmt_int(tk.get('node_count'))} |",
Expand Down
13 changes: 7 additions & 6 deletions crates/tracedecay-automation-runtime/src/automation/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,8 @@ fn build_job_prompt(
enum PreRunCommandError {
#[error("command timed out after {JOB_COMMAND_TIMEOUT_SECS}s")]
TimedOut,
#[error("command output exceeded the capture byte limit")]
OutputTooLarge,
#[error("failed to spawn command: {0}")]
Spawn(std::io::Error),
#[error("command exited with {status}: {stderr}")]
Expand All @@ -979,7 +981,7 @@ impl PreRunCommandError {
match self {
Self::TimedOut => AgentTaskFailureClass::Timeout,
Self::Spawn(_) => AgentTaskFailureClass::Unavailable,
Self::Exited { .. } => AgentTaskFailureClass::Permanent,
Self::Exited { .. } | Self::OutputTooLarge => AgentTaskFailureClass::Permanent,
}
}
}
Expand All @@ -992,9 +994,8 @@ impl From<GitCommandError> for PreRunCommandError {
| GitCommandError::Wait(source)
| GitCommandError::WriteInput(source)
| GitCommandError::ReadOutput { source, .. } => Self::Spawn(source),
error @ (GitCommandError::Cancelled | GitCommandError::OutputLimitExceeded { .. }) => {
Self::Spawn(std::io::Error::other(error))
}
GitCommandError::OutputLimitExceeded { .. } => Self::OutputTooLarge,
error @ GitCommandError::Cancelled => Self::Spawn(std::io::Error::other(error)),
}
}
}
Expand Down Expand Up @@ -1031,8 +1032,8 @@ async fn run_pre_run_command(
let bounds = GitCommandBounds {
deadline: Instant::now() + timeout,
cancel: Some(cancel.clone()),
max_stdout_bytes: usize::MAX,
max_stderr_bytes: usize::MAX,
max_stdout_bytes: JOB_COMMAND_OUTPUT_CAP_CHARS * 4,
max_stderr_bytes: JOB_COMMAND_OUTPUT_CAP_CHARS * 4,
};
// Scheduler shutdown drops the owning future. Cancelling makes the
// supervisor kill the command, so it cannot keep the daemon cgroup alive.
Expand Down
Loading
Loading