docs(m1): add security model and complete the readme first screen
This commit is contained in:
parent
0e3295f52b
commit
a9153e043e
5 changed files with 338 additions and 8 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
63
README.md
63
README.md
|
|
@ -16,26 +16,79 @@ submits, never sends, never stores a credential.
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
**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
82
SECURITY.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
195
tests/test_public_surface.py
Normal file
195
tests/test_public_surface.py
Normal 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)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue