A privacy-first AI developer platform.
Ask questions about your documents and code, get a reviewable patch, run it in a sandbox — approve nothing you haven't read.
Point it at a document set and a repository. Ask a question and get an answer with citations you can click through to the exact page, section, or line. Or give it a coding task, and get back a plan, the evidence it used, a unified diff, and real test output from a disposable copy of your repo.
Nothing is written to your repository until you press Approve.
Everything runs locally by default. Embeddings are computed on your machine, the index is a local
SQLite file, and with DEFAULT_PROVIDER=ollama or deterministic no bytes leave the host.
git clone https://github.com/kajal-jotwani/SENTINELFORGE.git
cd SENTINELFORGE
make install # Python 3.12 via uv
OFFLINE=0 make dev # fetches embedding weights once, then serves :8000Open http://localhost:8000. A corpus is already indexed — five documents and a small Python project — so every screen works immediately with nothing to upload.
Staying fully offline
Download the weights ahead of time, then run with no network at all:
uv run python -c "from model2vec import StaticModel; \
StaticModel.from_pretrained('minishlab/potion-base-8M').save_pretrained('.models/potion-base-8M')"
EMBEDDING_MODEL_PATH=.models/potion-base-8M make devThe container image bakes the weights in, so a deployed instance fetches nothing.
Docker, with a local LLM
docker compose up --build
docker compose exec ollama ollama pull qwen2.5-coder:1.5bThen set DEFAULT_PROVIDER=ollama.
Working on the frontend
cd apps/web && npm install && npm run dev # :5173, proxies /api to :8000In production FastAPI serves the built bundle, so there is one origin and no CORS.
Retrieval you can verify
- PDF, Markdown, plain text, JSON, Python, JavaScript and TypeScript
- Every citation names a file and an exact span —
guide.pdf p.3,analyze.py:41-45,Handbook > Security - Click a citation to expand the text that span points at
- Content-hash deduplication, incremental re-indexing, and deletion
A sandbox that says what it actually did
- Disposable workspace — your repository is copied, never opened for writing
- CPU, memory, process, file-size and output limits, enforced before
exec - Network off by default; command and module allowlists; argv only, never a shell
- Execution status is
not_run · ran_failed · ran_passed · timeout · killed, and "tests passed" renders fromran_passedalone
Approval that can't be skipped
apply_patchrequires a token bound tosha256of one exact diff- Single-use, expiring, and consumed in the same transaction as the write
- Approving a patch against a task that didn't propose it is refused before a token exists
Untrusted documents stay untrusted
- Invisible-character sanitisation, scored detection, quarantine, and fencing
- The planner's
toolfield is an enum, so a document has no channel to add an action - Quarantined sources stay visible in the UI and their exclusion is stated in the answer
graph LR
UI[React SPA] -->|HTTPS + SSE| API[FastAPI]
API -.->|MCP over stdio| MCP[MCP server<br/>12 tools]
MCP --> RAG[retrieval]
MCP --> AGENT[agent loop]
MCP --> SB[sandbox]
RAG --> DB[(SQLite + numpy)]
SB --> WS[/tmp workspace copy/]
style MCP fill:#6f42c1,color:#fff
style SB fill:#b02a37,color:#fff
The dotted line is a real process boundary. services/api spawns python -m packages.mcp_server and
talks to it over stdio — it imports no domain package, and a test walks the AST of every module under
services/api/** to keep it that way. The System screen lists the tools the client enumerated from the
live session, so it's evidence rather than a claim.
The server runs standalone too, so MCP Inspector or Claude Desktop can connect:
uv run python -m packages.mcp_serveranalyse → plan → retrieve → propose_patch → sandbox_exec → self_critique → final_report
Each step is typed, persisted, and streamed to the browser as it happens. The patch is applied to the workspace copy to test it — that needs no approval, because it's disposable. Only writing to your real repository is gated.
Copy .env.example to .env. Every variable is read in one place, packages/core/config.py.
| Provider | Requires | Data processing |
|---|---|---|
deterministic |
nothing | local |
ollama |
local Ollama + a pulled model | local |
groq |
GROQ_API_KEY, OFFLINE=0 |
external |
anthropic |
a paid key, OFFLINE=0 |
external |
deterministic is not a stub — it produces a real plan, derives a patch from the AST, and runs it in the
sandbox. A rate limit can't break the workflow.
OFFLINE=1 is enforced twice: Settings refuses to construct with an external provider, and the provider
registry refuses again in case a Settings was built unvalidated. OLLAMA_BASE_URL is checked against a
loopback allowlist, so "local" can't be switched off by one environment variable. The header badge shows
LOCAL or EXTERNAL on every screen — and unknown in amber when it can't tell, rather than
assuming.
Sandbox limits
| Variable | Default | Controls |
|---|---|---|
SANDBOX_WALL_TIMEOUT_S |
30 | wall clock, then killpg |
SANDBOX_CPU_SECONDS |
20 | RLIMIT_CPU |
SANDBOX_MEMORY_MB |
192 | RLIMIT_AS |
SANDBOX_MAX_PROCESSES |
64 | RLIMIT_NPROC — the fork-bomb control |
SANDBOX_MAX_OUTPUT_BYTES |
262144 | streaming cap, then kill |
SANDBOX_UID / SANDBOX_GID |
65534 | privilege drop; RLIMIT_NPROC does not bind for root |
OpenAPI at /docs, generated from the code. The web client's types are generated from the same schema,
and a test fails the build if the committed schema isn't the one the code produces.
POST /api/ingest · GET /api/documents · DELETE /api/documents/{id} |
corpus |
POST /api/ask |
cited answers |
POST /api/tasks · GET /api/tasks/{id} |
coding tasks |
POST /api/tasks/{id}/approve · /reject |
the approval gate |
POST /api/sandbox/run |
direct execution |
GET /api/health · GET /api/system · GET /api/audit |
operations |
make check # ruff + mypy --strict + 1006 tests + retrieval eval
make eval # retrieval scores against a committed floor
make test # pytest onlymake check is the single gate, and it's what CI runs.
Retrieval evaluation. Ten questions with expected sources, scored on every run so a regression fails the build instead of turning up mid-demo:
recall@1 1.000 recall@3 1.000 recall@5 1.000 MRR 1.000
Measured against real
potion-base-8Mweights over the five documents and small repository insample_data/. Read it for what it is: at this corpus size it is a sanity gate, not a benchmark, and a perfect score says these ten questions are answerable — not that retrieval is solved. What it does buy is a real floor:eval/questions.yamlsits one question below each metric, so a regression failsmake checkinstead of turning up mid-demo.Five of the ten share no vocabulary with the passage that answers them — "is it acceptable to build a command string and run it?" has to reach a paragraph that says "argument list, never through a shell" — which is the part BM25 alone fails and the reason retrieval is hybrid.
Adversarial tests. 36 of the 1006, one per row of the threat table in
systemdesign.md §8.3 — path traversal, symlink escape, fork bomb, memory bomb, CPU
spin, output flood, network egress, secret exfiltration, shell metacharacters, approval replay, token
rebinding, injection quarantine, and fabricated pass results.
packages/core settings, JSON logging with request ids, the error envelope
packages/rag parsers, chunking, embeddings, SQLite + numpy store, retrieval, injection defence
packages/sandbox policy, workspace, subprocess backend, netblock, secrets, approvals, audit
packages/agent the seven-step loop, patching, provider registry
packages/mcp_server tool registry, 12 tools, 1 resource, 1 prompt, stdio entrypoint
services/api FastAPI: an MCP client, and nothing else
apps/web React 19 + Vite + TypeScript + Tailwind
eval 10 questions, the runner, the committed floor
Nothing here should be a discovery.
The deployed sandbox is OS-level, not kernel-namespace. A free PaaS instance has no container runtime,
so isolation is rlimits, process groups, an environment allowlist, path containment, and an injected
socket block. Code that reaches execution — pytest running the repository's own tests, the intended case
— runs as the server's uid, can read and write outside the workspace, and can reach the network through
ctypes, which patching the socket module doesn't cover. The workspace copy protects your repository
from the patch; it does not sandbox a hostile test file. What stays load-bearing is what decides what
gets to run at all: the command allowlist, an allowlist on python -m modules (timeit -s executes
arbitrary source, so gating -c alone gates the spelling, not the capability), read containment on
cat/ls, and an approval gate on anything that can express arbitrary code. The Docker backend is
designed in §8.1 and not built.
That is why the deployed default is deterministic, and it is a security decision rather than a
cost one. With a provider configured the agent's patch is written by the model, so the code
pytest imports during the pre-approval run is model-authored. Against the isolation above, a model
that wrote a hostile file would reach whatever the server's uid reaches — and a patch runs before
anyone approves it, because running is what produces the evidence to approve on. The deployment
therefore ships the AST transform, whose output is a guard clause or nothing. Set
DEFAULT_PROVIDER=groq|ollama to get model-written diffs locally, where the blast radius is yours.
Every model reply is still checked before it becomes a diff — it must parse, differ, and drop no
existing definition — but those checks catch a careless model, not a hostile one, and the report
labels which engine wrote each patch so the two are never confused.
RLIMIT_NPROC does not apply to root. Measured: as root, a limit of 10 permitted 40 forks; as
nobody, the same limit blocked at 9. The container runs unprivileged so the control binds, and
system_status reports whether it actually does — a limit that silently doesn't apply is worse than no
limit.
Static embeddings, not a transformer. potion-base-8M has no attention and no context sensitivity. It was chosen because 512 MB of RAM and no torch is a hard constraint; BM25 fusion covers part of the gap. The eval above is the measured version of that trade rather than an assertion about it.
Code is chunked by line windows snapped to top-level definitions, not by AST. Line ranges are exact, which is what citations need. A tree-sitter split would give better boundaries.
Nothing survives a restart. The free tier has no persistent disk. SQLite lives in /tmp, the index is
rebuilt from sample_data/ at every cold start, and task reports are in memory, bounded to the last 50.
Rate limiting is in-process, so behind a proxy that doesn't forward a real client IP, every caller
shares one bucket. Access control is one optional shared token (API_TOKEN), off by default. There
are no users and no roles.
Not built: multi-tenancy, durable storage, horizontal scale, token-level streaming.
Design of record: systemdesign.md · AI tooling disclosure: AI_USAGE.md