Skip to content

Repository files navigation

SentinelForge

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.

Python 3.12 Typed Tests MCP No torch


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.

Contents

Quick start

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 :8000

Open 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 dev

The 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.5b

Then set DEFAULT_PROVIDER=ollama.

Working on the frontend
cd apps/web && npm install && npm run dev   # :5173, proxies /api to :8000

In production FastAPI serves the built bundle, so there is one origin and no CORS.

Features

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 from ran_passed alone

Approval that can't be skipped

  • apply_patch requires a token bound to sha256 of 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 tool field 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

How it works

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
Loading

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_server

The task loop

analyse → 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.

Configuration

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

API

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

Development

make check    # ruff + mypy --strict + 1006 tests + retrieval eval
make eval     # retrieval scores against a committed floor
make test     # pytest only

make 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-8M weights over the five documents and small repository in sample_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.yaml sits one question below each metric, so a regression fails make check instead 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

Limitations

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

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages