docs(m1): add security model and complete the readme first screen

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 06:56:23 +02:00
commit a9153e043e
5 changed files with 338 additions and 8 deletions

82
SECURITY.md Normal file
View file

@ -0,0 +1,82 @@
# Security model
This plugin runs on the operator's own machine, reads job listings written by
strangers, and holds a job search that is nobody else's business. The design
follows from those three facts. This document states what the plugin will not
do, where the boundaries actually sit, and which risks the design accepts
rather than solves.
It is written for M1. Sections marked *not live yet* describe a surface that
lands at M3 or M6; they are here because the boundary is designed now and
building against an undocumented boundary is how one gets moved.
## The four non-negotiables
**Read-only against external sites.** The plugin never submits a form, never
sends a message, never applies for anything. It attaches to a browser the
operator already opened and reads what is on the screen. At M3 that is
enforced by a deny list and a `PreToolUse` hook, not by convention alone
(*not live yet*).
**No credential handling.** The plugin stores no password, no token and no
cookie. The browser it reads through is authenticated because the operator
signed in by hand, in their own browser session. Mail access, if it is ever
configured, is read-only IMAP whose credentials come from environment
variables the plugin never writes and never logs.
**All data is local.** The workspace lives outside this repository, is never
committed, and is never transmitted anywhere. There is no telemetry, no
analytics, no crash reporting and no phone-home. Nothing runs on a schedule:
the plugin does something when the operator asks it to and at no other time.
**Untrusted content stays untrusted.** A job listing and an email body are
input written by someone else, and both routinely contain text shaped like an
instruction. Every such body passes `scripts/guard_ingest.py`, which wraps a
pinned release of the ingestion guard, **before it is written to the
workspace**. The trust boundary is the write, not the read: content that has
not passed the guard may be looked at and reasoned about, but it does not
become a file. (*The guard pipeline lands at M3.*)
## Where the boundaries sit
| Boundary | Enforced by | Live at |
| --- | --- | --- |
| Workspace containment | `jobbsok_lib.paths.safe_join`, which resolves symlinks before comparing | M1 |
| Explicit workspace, never a guessed one | `jobbsok_lib.paths.workspace_root`; every MCP tool requires it | M1 |
| Interpreter floor | `scripts/jobbsok_tools_launch.sh` refuses anything below Python 3.10 | M1 |
| Build identity | `BUILD_STAMP`, so a cached upload cannot pass for a fresh one | M1 |
| Ingestion | `scripts/guard_ingest.py` at the point of the write | M3 |
| Browser read-only | deny list plus a `PreToolUse` hook | M3 |
| Profile mutation | proposed as a diff, applied only by a human | M6 |
## Accepted risks
**The host MCP server is unsandboxed.** `jobbsok-tools` runs as the operator,
with the operator's file system. That is what makes it useful and it is not
mitigated away. What is mitigated is the path: every tool takes an explicit
workspace, and every path is resolved and checked against that root before it
is opened, so a caller-supplied path cannot walk out of the workspace by
traversal or through a symlink.
**Decision logs are append-only and are never pruned automatically.**
`beslutninger.jsonl` accumulates. A correction is a new entry, never an edit,
because a log that can be rewritten is not evidence of anything. Retention is
proposed to the operator at M6 and applied only on approval.
**The ingestion guard is installed from a self-hosted host.** If that host is
unreachable, the guarded ingestion path cannot be built. The pin is an exact
tag and is never substituted for a different one, and the source is never
vendored into this repository.
**This repository has a public remote.** Everything committed here is
published. `tests/test_public_surface.py` scans every tracked file on every
run for absolute home paths and for addresses outside `.example`, and
`tests/test_fixture_hygiene.py` scans the fixture corpus for operator content.
Both report the number of files they read, because a scan that says it found
nothing without saying what it looked at has said nothing.
## Reporting
This is a solo-maintained, fork-and-own repository. Issues are welcome as
signals; pull requests are not accepted. For anything sensitive, open an issue
saying only that you have something to report, without the details.