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

View file

@ -59,5 +59,5 @@ Why: bare `file://` URLs only render the first as clickable across multiple line
Example: Example:
- [Brief](file:///Users/ktg/.../brief.html) - [Brief](file:///absolute/path/to/brief.html)
- [Research summary](file:///Users/ktg/.../research/summary.md) - [Research summary](file:///absolute/path/to/research/summary.md)

View file

@ -16,26 +16,79 @@ submits, never sends, never stores a credential.
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![License](https://img.shields.io/badge/license-MIT-lightgrey) ![License](https://img.shields.io/badge/license-MIT-lightgrey)
**Status:** pre-release. Nothing is built yet beyond the manifest; the build **Status:** pre-release, milestone 1 of six. The candidate profile, the
brief in `docs/build-brief.md` is the contract, and milestones ship in order scoring script, two skills and the host tool server are built; case folders,
(M1–M6). ingestion, correspondence, drafting and learning are not. The build brief in
`docs/build-brief.md` is the contract, and milestones ship in order (M1–M6).
## Install ## Install
Not installable yet. When released, the plugin is pinned by tag in the Read this first if you are not the author. The ingestion guard this plugin
`ktg-plugin-marketplace` catalog: depends on is installed from a **self-hosted** Forgejo host,
`git.fromaitochitta.com`, which a third party may not be able to reach. This
repository is published as a working reference, not as a one-command install:
if you cannot reach that host, everything here is readable and most of it
runs, but the guarded ingestion path cannot be built. Saying so plainly is
better than an install block whose first step fails for everyone but its
author.
The plugin runs on two surfaces, and they install differently.
**Claude Code** — pinned by tag in the `ktg-plugin-marketplace` catalog:
```bash ```bash
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
claude plugin install jobbsok@ktg-plugin-marketplace claude plugin install jobbsok@ktg-plugin-marketplace
``` ```
**Claude Cowork** — there is no marketplace command. Build the archive from
this repository's explicit include list, then upload it through
*Customize → Plugins*:
```bash
bash scripts/package_plugin.sh # writes jobbsok.plugin
```
Never build that archive with a recursive zip of the repository root: it would
ship `.git`, the virtualenv, the local-only `STATE.md` and everything under
`.claude/`. The script exists so that cannot happen by accident.
**Then, once, on either surface** — build the Python environment the scripts
and the host tool server run on:
```bash
bash scripts/bootstrap.sh # add --med-xlsx for the spreadsheet export
```
In an installed copy this builds the environment under `$CLAUDE_PLUGIN_DATA`,
which survives a plugin update; the packaged archive deliberately excludes the
virtualenv, so the bootstrap is the supported route there. Without it the
`jobbsok-tools` server has no interpreter to run on and refuses to start
rather than serving on whatever `python3` the PATH offers. `JOBBSOK_PYTHON`
overrides the interpreter choice.
**Finally, the workspace** — the plugin never guesses at a location:
```bash
export JOBBSOK_WORKSPACE=~/jobbsok-workspace # or pass --workspace
```
The `kandidatprofil` skill scaffolds the tree on first run. The workspace is
yours, lives outside this repository, and is never committed.
## Non-goals ## Non-goals
Submitting applications. Sending email. Any write to Finn.no or LinkedIn. Submitting applications. Sending email. Any write to Finn.no or LinkedIn.
Credential storage. Background jobs. Multi-user support. Recruiter-side Credential storage. Background jobs. Multi-user support. Recruiter-side
features. Bulk listing harvest. features. Bulk listing harvest.
## Security
The trust model, the boundaries and the risks this design accepts are in
[SECURITY.md](SECURITY.md). The short version: read-only against external
sites, no credential handling, all data local, and every listing or email body
passes the ingestion guard before it becomes a file.
## Changelog ## Changelog
See [CHANGELOG.md](CHANGELOG.md). See [CHANGELOG.md](CHANGELOG.md).

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.

View file

@ -325,7 +325,7 @@ question. It was read off a measurement, not off a settings screen.
### O3. Cowork's built-in browser reads Finn, authenticated ### O3. Cowork's built-in browser reads Finn, authenticated
Operator measurement, 2026-09-04. Cowork's built-in browser (shipped Operator measurement, 2026-09-04. Cowork's built-in browser (shipped
2026-08-26) rendered `www.finn.no/job/ad/474745458` in full -- employer, title, 2026-08-26) rendered a Finn job ad (`finn.no/job/ad/<id>`) in full -- employer, title,
`Soknadsfrist`, `Mulighet for hjemmekontor`, `Arbeidsspraak`, `Sektor`: the `Soknadsfrist`, `Mulighet for hjemmekontor`, `Arbeidsspraak`, `Sektor`: the
exact fields the M3 extraction wants, at a clean snapshot-able URL. The exact fields the M3 extraction wants, at a clean snapshot-able URL. The
operator was logged in to Finn, having signed in by hand inside that browser. operator was logged in to Finn, having signed in by hand inside that browser.

View file

@ -0,0 +1,195 @@
"""What this repository publishes, and what it must never publish (plan Step 14).
The remote is PUBLIC. That single fact decides both halves of this file.
The first half is presentation: a README first screen that says what the plugin
is, an install block that covers both surfaces it actually runs on -- Claude
Code and Claude Cowork, which install it in completely different ways -- and a
`SECURITY.md` that states the trust model instead of implying it.
The second half is the one that matters more, and it is deliberately wider than
the two files this step writes. The scan reads **every tracked file**, because
the leak this repository is exposed to does not live in the README. It lives in
`docs/cowork-probe.md`, which is committed here and is appended to by six later
steps with measured facts from the operator's own machine and browser (risk
H1). A two-file scan would never look at it. The first run of this scan found
a real job-ad identifier there, published, which is the whole argument for
scanning wide compressed into one finding.
Three deliberate scopings, stated rather than smuggled, because a rule that
cannot be stated is a rule that will be quietly widened later:
1. **An absolute home path means a literal one.** `/Users/<name>/` and
`/home/<name>/` are refused; `$HOME/Library/...` is not, because that is the
correct way to write the same path and `scripts/cowork_probe_check.sh` has
to name a macOS directory to read a log out of it.
2. **Infrastructure hosts are allowed by name.** The Forgejo host this
repository lives on and the badge service in the README are addresses the
published repository cannot function without. Every other host in a URL that
points at a resource must be under `.example` (RFC 2606).
3. **`tests/test_fixture_hygiene.py` is exempt from the URL rule**, and only
from that one. It seeds a synthetic job-board URL as a canary to prove its
own scan can find, and a scanner cannot scan another scanner's canary
without one of the two being wrong.
The scan reports its denominator on every run, and proves it can find before it
reports that it found nothing. "Found nothing" is a measurement; without a
denominator and a positive control it is indistinguishable from a scan whose
patterns were wrong.
Style note: this file follows tests/test_fixture_hygiene.py.
"""
import os
import re
import subprocess
import warnings
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
README = os.path.join(REPO, "README.md")
SECURITY = os.path.join(REPO, "SECURITY.md")
class OffentligFlateSkannet(UserWarning):
"""Carries the denominator into the run's output, where a reader sees it."""
#: A literal home directory with a user name in it. Written as a pattern rather
#: than a literal so this file does not match itself.
HJEMMESTI = re.compile(r"/(?:Users|home)/[A-Za-z0-9._-]+/")
#: A URL pointing at a resource -- with a path, so a bare domain named in prose
#: is not a finding. Both spellings the corpus uses are covered.
ADRESSE = re.compile(r"(?:https?://|\bwww\.)([A-Za-z0-9.-]+\.[A-Za-z]{2,})(/[^\s`'\")\]]*)")
#: Hosts the published repository cannot function without.
INFRASTRUKTUR = ("git.fromaitochitta.com", "img.shields.io")
#: Exempt from the URL rule only, and for the reason in this module's docstring.
URL_UNNTAK = ("tests/test_fixture_hygiene.py",)
#: The invariants that are live at M1. M3 and M6 extend the list as their
#: surfaces land; these four are true from the first commit.
IKKE_FORHANDLINGSBART = (
"read-only",
"credential",
"local",
"guard",
)
def sporede_filer():
ut = subprocess.run(
["git", "-C", REPO, "ls-files"], capture_output=True, text=True
)
assert ut.returncode == 0, ut.stderr
return [p for p in ut.stdout.split("\n") if p]
def skann(rot, filer):
"""Return (findings, files read, bytes read) for ``filer`` under ``rot``."""
funn = []
lest = 0
bytes_lest = 0
for relativ in filer:
full = os.path.join(rot, relativ)
if not os.path.isfile(full):
continue
try:
with open(full, "r", encoding="utf-8") as handle:
tekst = handle.read()
except UnicodeDecodeError:
continue
lest += 1
bytes_lest += len(tekst.encode("utf-8"))
for treff in HJEMMESTI.finditer(tekst):
funn.append((relativ, "absolutt hjemmesti", treff.group(0)))
if relativ in URL_UNNTAK:
continue
for vert, _sti in ADRESSE.findall(tekst):
if vert in INFRASTRUKTUR:
continue
if vert.endswith(".example"):
continue
funn.append((relativ, "adresse utenfor .example", vert))
return funn, lest, bytes_lest
def test_the_readme_install_block_covers_both_surfaces():
with open(README, "r", encoding="utf-8") as handle:
tekst = handle.read()
assert "## Install" in tekst, "the README has no install section"
installer = tekst.split("## Install", 1)[1]
installer = installer.split("\n## ", 1)[0]
assert "Claude Code" in installer, (
"the install block does not name Claude Code, the development surface"
)
assert "Cowork" in installer, (
"the install block does not name Cowork, which installs by uploading an "
"archive and not by a marketplace command -- a reader following the "
"Claude Code lines there gets nowhere"
)
assert "scripts/package_plugin.sh" in installer, (
"the Cowork route needs the packaging command; never a recursive zip "
"of the repository root"
)
assert "scripts/bootstrap.sh" in installer, (
"without the bootstrap there is no environment for the host MCP server "
"to run on in an installed copy"
)
# The honesty clause: the guard is installed from a self-hosted host a
# third party may not reach. A public install block that fails at its first
# step for everyone but its author has to say so.
assert "git.fromaitochitta.com" in tekst
assert "## Non-goals" in tekst, "the README states no non-goals"
def test_security_md_states_the_trust_model():
assert os.path.isfile(SECURITY), "SECURITY.md does not exist"
with open(SECURITY, "r", encoding="utf-8") as handle:
tekst = handle.read()
lav = tekst.lower()
for begrep in IKKE_FORHANDLINGSBART:
assert begrep in lav, (
"SECURITY.md never mentions %r; it is one of the four invariants "
"that are live at M1" % begrep
)
# The trust boundary is the write, not the read. Saying only "we use a
# guard" leaves the reader guessing where it sits.
assert "guard_ingest.py" in tekst
assert "M3" in tekst or "M6" in tekst, (
"SECURITY.md must say which surfaces are not live yet, or it reads as "
"a description of a finished system"
)
def test_no_tracked_file_carries_a_home_path_or_a_real_address(tmp_path):
# Prove the scan can find before believing that it found nothing.
kanari = tmp_path / "kanari.md"
kanari.write_text(
"Se /Users/enperson/hemmelig/mappe/ og https://ekte-jobbportal.no/annonse/12345\n",
encoding="utf-8",
)
kanarifunn, kanarilest, _ = skann(str(tmp_path), ["kanari.md"])
assert kanarilest == 1
assert len(kanarifunn) == 2, (
"the scan cannot find what it exists to find; it reported %r"
% (kanarifunn,)
)
filer = sporede_filer()
funn, lest, bytes_lest = skann(REPO, filer)
warnings.warn(
"public surface scan: %d tracked files, %d bytes read; %d finding(s)"
% (lest, bytes_lest, len(funn)),
OffentligFlateSkannet,
)
assert lest > 0, "the scan read no files; git ls-files returned %d paths" % len(filer)
assert funn == [], (
"tracked files carry paths or addresses that must not be published:\n%s"
% "\n".join(" %s: %s -- %s" % rad for rad in funn)
)