feat(m1): add pinned workspace root and ASCII slug helpers
Plan Step 3. Verify green: `.venv/bin/python -m pytest tests/test_paths.py` ->
8 passed, exit 0. Full suite: 11 passed.
Test first, and RED was observed before a line of paths.py existed
(ModuleNotFoundError on collection).
Two measured risks drive the design, not one.
Risk C6 -- the host-side server runs unsandboxed with caller-supplied paths.
safe_join resolves BOTH operands with os.path.realpath before comparing, so
lexical traversal and symlink escape fall to the same check; a lexical check on
the joined string would have called the symlink case safe. The comparison is
component-aware, so a sibling whose name merely prefixes the root is outside.
Escape raises; it never clamps to an adjacent path.
Risk H8 -- macOS hands back NFD. slug normalises to NFC first, so a decomposed
name and its composed twin produce one slug rather than two directories. The
test asserts that equality on a real NFC/NFD pair, not on a lookalike.
No implicit default workspace: --workspace, else JOBBSOK_WORKSPACE, else a
`workspace:` line in ${CLAUDE_PLUGIN_DATA}/jobbsok.conf, else WorkspaceUnresolved.
Guessing at the operator's home directory is the one behaviour this module must
not have. This tightens build-brief section 5's `~/jobbsok-workspace` default
into an explicit order; the deviation is recorded in the plan's Assumptions.
scaffold is idempotent by only creating what is absent -- an existing
beslutninger.jsonl is never truncated. It is append-only, and a scaffold that
emptied it would destroy the decision log. The test proves this by populating
the workspace and asserting a byte-identical snapshot across a second run.
Collision rule is documented and tested: -2, then -3, counting up.
sak_id carries no `taken` parameter. An earlier draft had one; no step and no
test requires it, so it was unproven code and was removed rather than kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
24768ef66b
commit
1d631b7c94
3 changed files with 401 additions and 0 deletions
6
scripts/jobbsok_lib/__init__.py
Normal file
6
scripts/jobbsok_lib/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Shared library for the jobbsok scripts.
|
||||||
|
|
||||||
|
Deliberately thin. Everything here is imported by scripts that run both from
|
||||||
|
the Claude Code CLI and from the host-side MCP server, so nothing heavy is
|
||||||
|
imported at module load: cold start is a real constraint on the server side.
|
||||||
|
"""
|
||||||
268
scripts/jobbsok_lib/paths.py
Normal file
268
scripts/jobbsok_lib/paths.py
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"""Workspace root resolution, path containment, and name generation.
|
||||||
|
|
||||||
|
Every later script depends on this module, and it exists because of two
|
||||||
|
measured risks.
|
||||||
|
|
||||||
|
Risk C6 -- the host-side MCP server runs unsandboxed on the operator's machine
|
||||||
|
and takes caller-supplied paths. A path that escapes the workspace escapes onto
|
||||||
|
the machine. There are two ways out, lexical traversal and symlink resolution,
|
||||||
|
and a check that only looks at the joined string catches the first and misses
|
||||||
|
the second. `safe_join` resolves with `os.path.realpath` before comparing, so
|
||||||
|
both are refused, and refused loudly: the failure is an exception, never a
|
||||||
|
clamped path that silently writes somewhere adjacent.
|
||||||
|
|
||||||
|
Risk H8 -- macOS returns filenames in NFD, so a name that looks identical to
|
||||||
|
its NFC twin is a different byte string, and two cases that should be one
|
||||||
|
become two directories. `slug` normalises first and then folds to ASCII, so no
|
||||||
|
generated path component carries a non-ASCII byte at all.
|
||||||
|
|
||||||
|
There is no implicit default workspace. `workspace_root` resolves from
|
||||||
|
`--workspace`, else `JOBBSOK_WORKSPACE`, else a `workspace:` line in
|
||||||
|
`${CLAUDE_PLUGIN_DATA}/jobbsok.conf`; if none of the three answers, that is an
|
||||||
|
error. Guessing at the operator's home directory is the one behaviour this
|
||||||
|
module must never have. (This tightens build-brief section 5's
|
||||||
|
`~/jobbsok-workspace` default into an explicit resolution order; the deviation
|
||||||
|
is recorded in the plan's Assumptions.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
#: Directories created by :func:`scaffold`, relative to the workspace root.
|
||||||
|
WORKSPACE_DIRS = (
|
||||||
|
"profil",
|
||||||
|
"profil/cv",
|
||||||
|
"saker",
|
||||||
|
"arkiv",
|
||||||
|
"karantene",
|
||||||
|
"kandidater",
|
||||||
|
".review",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Files created by :func:`scaffold`, relative to the workspace root.
|
||||||
|
WORKSPACE_FILES = (
|
||||||
|
"beslutninger.jsonl",
|
||||||
|
"referanser.md",
|
||||||
|
".gitignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `sak-id` format from build-brief section 5.2: YYYY-MM-<slug>.
|
||||||
|
SAK_ID_RE = re.compile(r"^\d{4}-\d{2}-[a-z0-9-]{1,80}$")
|
||||||
|
|
||||||
|
CONF_FILENAME = "jobbsok.conf"
|
||||||
|
CONF_KEY = "workspace:"
|
||||||
|
|
||||||
|
# Folds applied before Unicode decomposition, because these characters have no
|
||||||
|
# canonical decomposition to strip a mark from: the ash and the o-slash are
|
||||||
|
# distinct letters, not accented vowels. The a-ring does decompose and is
|
||||||
|
# handled by the general pass, but is listed here so the three folds the
|
||||||
|
# contract names are visible in one place.
|
||||||
|
_FOLDS = {
|
||||||
|
"æ": "ae", # ae ligature
|
||||||
|
"Æ": "ae",
|
||||||
|
"ø": "o", # o with stroke
|
||||||
|
"Ø": "o",
|
||||||
|
"å": "a", # a with ring
|
||||||
|
"Å": "a",
|
||||||
|
"ß": "ss", # sharp s, for German employer names
|
||||||
|
"đ": "d", # d with stroke
|
||||||
|
"Đ": "d",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceError(Exception):
|
||||||
|
"""Base class for every refusal in this module."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceUnresolved(WorkspaceError):
|
||||||
|
"""No workspace could be resolved, and guessing one is not an option."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceEscape(WorkspaceError):
|
||||||
|
"""A path resolved outside the pinned workspace root."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSakId(WorkspaceError):
|
||||||
|
"""A sak-id did not match the build-brief section 5.2 format."""
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_root(arg=None, environ=None):
|
||||||
|
"""Resolve the workspace root once, and pin it.
|
||||||
|
|
||||||
|
Resolution order, first hit wins:
|
||||||
|
|
||||||
|
1. ``arg`` -- the caller's ``--workspace`` value.
|
||||||
|
2. ``JOBBSOK_WORKSPACE`` in the environment.
|
||||||
|
3. A single ``workspace: <path>`` line in
|
||||||
|
``${CLAUDE_PLUGIN_DATA}/jobbsok.conf``, written by the scaffold. This
|
||||||
|
exists so the operator does not retype an absolute path in every Cowork
|
||||||
|
invocation.
|
||||||
|
|
||||||
|
Raises :class:`WorkspaceUnresolved` if none of the three answers. The
|
||||||
|
returned path is absolute and symlink-resolved, and it need not exist yet --
|
||||||
|
scaffolding a fresh workspace is a supported first run.
|
||||||
|
"""
|
||||||
|
environ = os.environ if environ is None else environ
|
||||||
|
|
||||||
|
candidate = _clean(arg)
|
||||||
|
if candidate is None:
|
||||||
|
candidate = _clean(environ.get("JOBBSOK_WORKSPACE"))
|
||||||
|
if candidate is None:
|
||||||
|
candidate = _from_conf(environ)
|
||||||
|
if candidate is None:
|
||||||
|
raise WorkspaceUnresolved(
|
||||||
|
"no workspace: pass --workspace, set JOBBSOK_WORKSPACE, or put a "
|
||||||
|
"'workspace: <path>' line in ${CLAUDE_PLUGIN_DATA}/%s. There is no "
|
||||||
|
"default -- guessing at your home directory is not a fallback."
|
||||||
|
% CONF_FILENAME
|
||||||
|
)
|
||||||
|
return os.path.realpath(os.path.expanduser(candidate))
|
||||||
|
|
||||||
|
|
||||||
|
def safe_join(root, *parts):
|
||||||
|
"""Join ``parts`` under ``root`` and refuse anything that leaves it.
|
||||||
|
|
||||||
|
Both operands are resolved with :func:`os.path.realpath` before comparing,
|
||||||
|
so lexical traversal (``../..``) and symlink escape are refused by the same
|
||||||
|
check. The comparison is component-aware: a sibling directory whose name
|
||||||
|
merely starts with the root's name is outside, not inside.
|
||||||
|
"""
|
||||||
|
resolved_root = os.path.realpath(os.path.expanduser(root))
|
||||||
|
candidate = os.path.realpath(os.path.join(resolved_root, *parts))
|
||||||
|
if candidate != resolved_root and not candidate.startswith(resolved_root + os.sep):
|
||||||
|
raise WorkspaceEscape(
|
||||||
|
"path escapes the workspace: %r resolves to %r, which is outside %r"
|
||||||
|
% (os.path.join(*parts) if parts else "", candidate, resolved_root)
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def slug(text, taken=None):
|
||||||
|
"""Fold ``text`` into a lowercase ASCII path component.
|
||||||
|
|
||||||
|
Normalises to NFC first so a decomposed macOS name and its composed twin
|
||||||
|
produce the same slug (risk H8), applies the Norwegian folds -- ``ae`` for
|
||||||
|
the ash, ``o`` for the o-slash, ``a`` for the a-ring -- then strips any
|
||||||
|
remaining combining marks and replaces every run of non-alphanumeric
|
||||||
|
characters with a single hyphen.
|
||||||
|
|
||||||
|
Collision rule: when ``taken`` is supplied and the slug is already in it,
|
||||||
|
a numeric suffix is appended, starting at ``-2`` and counting up
|
||||||
|
(``bergen-kommune``, ``bergen-kommune-2``, ``bergen-kommune-3``). The
|
||||||
|
suffix is part of the name, so it is stable once assigned; callers keep
|
||||||
|
``taken`` and add each returned slug to it.
|
||||||
|
"""
|
||||||
|
text = unicodedata.normalize("NFC", text or "")
|
||||||
|
folded = "".join(_FOLDS.get(char, char) for char in text)
|
||||||
|
# Anything left with an accent loses it here (e -acute becomes e); anything
|
||||||
|
# that survives as non-ASCII is dropped by the ASCII round-trip below.
|
||||||
|
decomposed = unicodedata.normalize("NFD", folded)
|
||||||
|
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||||
|
ascii_only = stripped.encode("ascii", "ignore").decode("ascii")
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")
|
||||||
|
if not base:
|
||||||
|
raise ValueError("slug of %r is empty; a path component cannot be blank" % (text,))
|
||||||
|
|
||||||
|
if taken is None or base not in taken:
|
||||||
|
return base
|
||||||
|
suffix = 2
|
||||||
|
while "%s-%d" % (base, suffix) in taken:
|
||||||
|
suffix += 1
|
||||||
|
return "%s-%d" % (base, suffix)
|
||||||
|
|
||||||
|
|
||||||
|
def sak_id(year_month, arbeidsgiver, rolle):
|
||||||
|
"""Build a `sak-id`: ``YYYY-MM-<arbeidsgiver-slug>-<rolle-slug>``.
|
||||||
|
|
||||||
|
``year_month`` is validated rather than trusted, because a malformed month
|
||||||
|
here becomes a directory name that no later glob will find.
|
||||||
|
"""
|
||||||
|
if not re.match(r"^\d{4}-\d{2}$", year_month or ""):
|
||||||
|
raise InvalidSakId("year_month must be YYYY-MM, got %r" % (year_month,))
|
||||||
|
return validate_sak_id(
|
||||||
|
"%s-%s-%s" % (year_month, slug(arbeidsgiver), slug(rolle))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sak_dirname(year_month, arbeidsgiver, rolle):
|
||||||
|
"""The workspace-relative directory for a case: ``saker/<sak-id>``."""
|
||||||
|
return "saker/%s" % sak_id(year_month, arbeidsgiver, rolle)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sak_id(value):
|
||||||
|
"""Return ``value`` if it matches :data:`SAK_ID_RE`, else raise."""
|
||||||
|
if not SAK_ID_RE.match(value or ""):
|
||||||
|
raise InvalidSakId(
|
||||||
|
"sak-id %r does not match %s" % (value, SAK_ID_RE.pattern)
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold(root):
|
||||||
|
"""Create the build-brief section 5 workspace tree under ``root``.
|
||||||
|
|
||||||
|
Idempotent: re-running over a populated workspace changes nothing. Files
|
||||||
|
are created only when absent, so an existing ``beslutninger.jsonl`` is
|
||||||
|
never truncated -- it is append-only, and a scaffold that emptied it would
|
||||||
|
destroy the decision log.
|
||||||
|
|
||||||
|
Returns the list of workspace-relative paths this call actually created.
|
||||||
|
"""
|
||||||
|
root = os.path.realpath(os.path.expanduser(root))
|
||||||
|
created = []
|
||||||
|
|
||||||
|
for relative in (".",) + WORKSPACE_DIRS:
|
||||||
|
target = os.path.join(root, relative) if relative != "." else root
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
os.makedirs(target)
|
||||||
|
if relative != ".":
|
||||||
|
created.append(relative)
|
||||||
|
|
||||||
|
for relative in WORKSPACE_FILES:
|
||||||
|
target = os.path.join(root, relative)
|
||||||
|
if os.path.exists(target):
|
||||||
|
continue
|
||||||
|
with open(target, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(_initial_content(relative))
|
||||||
|
created.append(relative)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_content(relative):
|
||||||
|
if relative == "beslutninger.jsonl":
|
||||||
|
# Append-only log. Empty, not absent: the difference matters at the
|
||||||
|
# first append, and an absent file reads as "never scaffolded".
|
||||||
|
return ""
|
||||||
|
if relative == "referanser.md":
|
||||||
|
return "# Referanser\n"
|
||||||
|
if relative == ".gitignore":
|
||||||
|
return (
|
||||||
|
"# This workspace is never committed. It holds the operator's own\n"
|
||||||
|
"# job search: listings, applications, correspondence, decisions.\n"
|
||||||
|
"*\n"
|
||||||
|
)
|
||||||
|
raise ValueError("no initial content defined for %r" % (relative,))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _from_conf(environ):
|
||||||
|
data_dir = _clean(environ.get("CLAUDE_PLUGIN_DATA"))
|
||||||
|
if data_dir is None:
|
||||||
|
return None
|
||||||
|
conf = os.path.join(os.path.expanduser(data_dir), CONF_FILENAME)
|
||||||
|
if not os.path.isfile(conf):
|
||||||
|
return None
|
||||||
|
with open(conf, "r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(CONF_KEY):
|
||||||
|
return _clean(line[len(CONF_KEY):])
|
||||||
|
return None
|
||||||
127
tests/test_paths.py
Normal file
127
tests/test_paths.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""The workspace path contract every later script depends on (plan Step 3).
|
||||||
|
|
||||||
|
Two risks are being tested here, not one. Risk C6: the host-side server runs
|
||||||
|
unsandboxed with caller-supplied paths, so a path that escapes the workspace
|
||||||
|
escapes onto the operator's machine -- traversal and symlink escape are the two
|
||||||
|
ways out and both are exercised below. Risk H8: macOS hands back filenames in
|
||||||
|
NFD, so a name that looks identical to an NFC one is a different byte string;
|
||||||
|
every generated path component is asserted to be pure ASCII rather than merely
|
||||||
|
looking right.
|
||||||
|
|
||||||
|
The third theme is refusing to guess. There is no implicit default workspace:
|
||||||
|
if none of the three resolution sources answers, that is an error, never a
|
||||||
|
silent write into the operator's home directory.
|
||||||
|
|
||||||
|
Style note: this file follows tests/test_toolchain.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from jobbsok_lib import paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_traversal_is_refused(tmp_path):
|
||||||
|
root = str(tmp_path / "ws")
|
||||||
|
os.makedirs(root)
|
||||||
|
with pytest.raises(paths.WorkspaceEscape):
|
||||||
|
paths.safe_join(root, "..", "..", "etc", "passwd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlink_escape_is_refused(tmp_path):
|
||||||
|
root = str(tmp_path / "ws")
|
||||||
|
outside = str(tmp_path / "outside")
|
||||||
|
os.makedirs(root)
|
||||||
|
os.makedirs(outside)
|
||||||
|
# A link that lives inside the workspace but resolves outside it. A
|
||||||
|
# lexical check on the joined string would call this safe; realpath does
|
||||||
|
# not, which is the whole reason safe_join resolves before it compares.
|
||||||
|
os.symlink(outside, os.path.join(root, "escape"))
|
||||||
|
with pytest.raises(paths.WorkspaceEscape):
|
||||||
|
paths.safe_join(root, "escape", "stolen.md")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_workspace_raises_rather_than_defaulting(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("JOBBSOK_WORKSPACE", raising=False)
|
||||||
|
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "no-such-data-dir"))
|
||||||
|
with pytest.raises(paths.WorkspaceUnresolved):
|
||||||
|
paths.workspace_root(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_of_norwegian_place_name_is_pure_ascii():
|
||||||
|
# The three folds the contract names, each in a real place name.
|
||||||
|
assert paths.slug("Tromsø og Bodø") == "tromso-og-bodo"
|
||||||
|
assert paths.slug("Målselv") == "malselv"
|
||||||
|
assert paths.slug("Ærøy Bærum") == "aeroy-baerum"
|
||||||
|
# And the same word arriving decomposed (what macOS hands back) must
|
||||||
|
# fold to the identical slug as the composed form -- risk H8.
|
||||||
|
composed = "Målselv"
|
||||||
|
decomposed = "Målselv"
|
||||||
|
assert composed != decomposed
|
||||||
|
assert paths.slug(composed) == paths.slug(decomposed) == "malselv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_generated_path_component_is_ascii():
|
||||||
|
messy = "Blåbær & Sønn AS — Seniør Rådgiver (København)"
|
||||||
|
generated = paths.slug(messy)
|
||||||
|
assert generated.isascii(), generated
|
||||||
|
assert generated == generated.strip("-")
|
||||||
|
assert "--" not in generated
|
||||||
|
for component in paths.sak_dirname("2026-09", "Blåbær AS", "Seniør Rådgiver").split("/"):
|
||||||
|
assert component.isascii(), component
|
||||||
|
|
||||||
|
|
||||||
|
def test_colliding_slugs_get_distinct_suffixes():
|
||||||
|
taken = set()
|
||||||
|
first = paths.slug("Bergen Kommune", taken=taken)
|
||||||
|
taken.add(first)
|
||||||
|
second = paths.slug("bergen kommune!", taken=taken)
|
||||||
|
taken.add(second)
|
||||||
|
third = paths.slug("BERGEN-KOMMUNE", taken=taken)
|
||||||
|
assert first == "bergen-kommune"
|
||||||
|
assert second == "bergen-kommune-2"
|
||||||
|
assert third == "bergen-kommune-3"
|
||||||
|
assert len({first, second, third}) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_creates_every_section_5_directory_and_gitignore(tmp_path):
|
||||||
|
root = str(tmp_path / "jobbsok-workspace")
|
||||||
|
paths.scaffold(root)
|
||||||
|
for directory in paths.WORKSPACE_DIRS:
|
||||||
|
assert os.path.isdir(os.path.join(root, directory)), directory
|
||||||
|
for filename in paths.WORKSPACE_FILES:
|
||||||
|
assert os.path.isfile(os.path.join(root, filename)), filename
|
||||||
|
# beslutninger.jsonl is append-only and starts empty, not absent: a missing
|
||||||
|
# file and an empty one read very differently at the first append.
|
||||||
|
assert os.path.getsize(os.path.join(root, "beslutninger.jsonl")) == 0
|
||||||
|
gitignore = os.path.join(root, ".gitignore")
|
||||||
|
with open(gitignore, "r", encoding="utf-8") as handle:
|
||||||
|
assert "*" in handle.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_is_idempotent_over_a_populated_workspace(tmp_path):
|
||||||
|
root = str(tmp_path / "jobbsok-workspace")
|
||||||
|
paths.scaffold(root)
|
||||||
|
# Populate it the way real use would, including the two files scaffold
|
||||||
|
# itself creates -- an idempotent scaffold must not truncate them.
|
||||||
|
with open(os.path.join(root, "beslutninger.jsonl"), "w", encoding="utf-8") as handle:
|
||||||
|
handle.write('{"sak_id": "2026-09-eksempel-as-radgiver", "beslutning": "soker"}\n')
|
||||||
|
with open(os.path.join(root, "referanser.md"), "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("# Referanser\n\nEn kontakt.\n")
|
||||||
|
os.makedirs(os.path.join(root, "saker", "2026-09-eksempel-as-radgiver"))
|
||||||
|
|
||||||
|
before = _snapshot(root)
|
||||||
|
paths.scaffold(root)
|
||||||
|
assert _snapshot(root) == before
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(root):
|
||||||
|
"""Every file under root as {relative path: bytes}."""
|
||||||
|
out = {}
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(root):
|
||||||
|
for name in filenames:
|
||||||
|
full = os.path.join(dirpath, name)
|
||||||
|
with open(full, "rb") as handle:
|
||||||
|
out[os.path.relpath(full, root)] = handle.read()
|
||||||
|
return out
|
||||||
Loading…
Add table
Add a link
Reference in a new issue