"""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-. 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: `` 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: ' 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--``. ``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/``.""" 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