jobbsok could not start on stock Windows. `.mcp.json` named `bash` as the
command, and the five entry points behind it reached for grep, sed, find, awk,
zip and unzip. `bootstrap.sh` built the virtualenv, so a Windows adopter could
not even reach an interpreter. Two adopters are waiting and neither is
guaranteed to be on macOS, so this is the install, not a rough edge.
The Python layer was already clean -- no /tmp, no /usr, no os.uname, no home
directory assumption -- so only the shell layer moved. Behaviour is carried
over unchanged; the deliberate exceptions are listed in docs/.
THE ONE OPEN DECISION, AND WHY IT WAS FORCED
How does .mcp.json start an interpreter without a POSIX shell, when it is
called python3 on macOS and python or py on Windows? Measured against the
installed CLI, not assumed:
- The plugin mcpServers stdio schema has NO platform-conditional form. A
config carrying invented windows/darwin/platform keys was accepted and the
keys were silently discarded -- it fails quietly, not loudly.
- ${VAR:-default} IS expanded, in command, args and env.
- ${VAR} without a default is not safe: unset, it is passed through
unexpanded, so the spawn would try to run a program named ${VAR}.
- Windows spawns with shell:false, so a .py path as command is out.
- No single literal works. On this Mac, python and py are not on PATH.
So the default form is the only lever the schema offers:
"${JOBBSOK_LAUNCH_PYTHON:-python3}". macOS and Linux keep working with nothing
set; Windows sets one variable and needs no shell.
A SECOND VARIABLE, NOT A REUSE OF JOBBSOK_PYTHON
JOBBSOK_PYTHON names the interpreter to SERVE on: the launcher treats it as an
explicit operator choice, so it wins over the bootstrapped virtualenv. A
Windows adopter setting it merely to spell `python` would silently bypass that
virtualenv and serve WITHOUT the ingestion guard. JOBBSOK_LAUNCH_PYTHON only
says how to start the launcher. A test asserts the two never collapse into one.
O4 IS LEFT STANDING
The launcher still gates on the interpreter's version rather than on the guard
being importable. That is the shell version's semantics carried over on
purpose: harmless while nothing writes, a defect from M2, and an M2 decision.
VERIFY
- pytest tests/ -> 124 passed, exit 0 (was 112)
- grep -c '"command": "bash"' .mcp.json -> 0
- git ls-files 'scripts/*.sh' -> 0
- README install block names Windows, and neither WSL nor Git Bash
- server started end to end exactly as .mcp.json expands, and answered
initialize and tools/list
- the ported probe checker reproduces the shell version's output and exits 0
NOT MEASURED, AND NOT ASSUMED
Nothing here has ever run on Windows. Whether Cowork on Windows bridges to a
host-side stdio MCP as it does on this Mac is unmeasured -- docs/cowork-probe.md
covered macOS only. docs/cross-platform-port.md says what a Windows probe would
have to measure, and records two findings left deliberately untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4.3 KiB
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.py 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.