feat(m2): expose status and daily tools through jobbsok-tools

This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 22:09:17 +02:00
commit c7db9f16a0
4 changed files with 578 additions and 19 deletions

View file

@ -13,10 +13,19 @@ SDK would add a PyPI dependency the Cowork sandbox cannot install, and would
cost import time at every cold start against an MCP start-up timeout. Three
JSON-RPC methods is less code than the dependency it would replace.
**Nothing heavy is imported at module load.** `kandidat_schema` and
`vurdering` are imported inside the handlers, not at the top. The server has
to answer `initialize` fast enough that Cowork does not time it out, and it
answers that one without touching either module.
**Nothing heavy is imported at module load.** `kandidat_schema`,
`vurdering`, `sak_status`, `dagens` and `beslutninger` are imported inside the
handlers, not at the top. The server has to answer `initialize` fast enough
that Cowork does not time it out, and it answers that one without touching any
of them.
**The M2 tools call the command line's own `main()`, with the streams
redirected** (Step 24). Parity between the two paths is not a nicety here: every
skill degrades to reading what the CLI produced when the server is absent, and
that instruction is only true if the content is the same. A second renderer on
this side would be a copy free to drift from the first, so what runs is the
same entry point -- same argument parsing, same output, same refusals -- and
the only thing this layer adds is turning an exit code into an MCP answer.
**Every tool takes an explicit `workspace`.** `jobbsok_lib.paths` refuses to
guess at a home directory, and this process runs unsandboxed on the operator's
@ -61,6 +70,23 @@ WORKSPACE_ARG = {
),
}
#: The injected clock. Every M2 tool takes it, and none of them reads the wall
#: clock when it is absent only because a caller that cannot pin the date is
#: still better served by today than by a refusal -- but a `Verifiser`-step
#: that wants a reproducible answer passes it.
TODAY_ARG = {
"type": "string",
"description": (
"Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens "
"dato. Oppgi den i alt som skal kunne reproduseres."
),
}
SAK_ARG = {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet.",
}
TOOLS = [
{
"name": "kandidat_valider",
@ -145,6 +171,95 @@ TOOLS = [
"additionalProperties": False,
},
},
{
"name": "sak_status",
"description": (
"Utled status, ventende part, siste aktivitet, neste frist og "
"stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en "
"hurtigbuffer og loggen vinner. Skriver ingenting."
),
"inputSchema": {
"type": "object",
"properties": {
"workspace": WORKSPACE_ARG,
"today": TODAY_ARG,
"sak": SAK_ARG,
"format": {
"type": "string",
"enum": ["tekst", "json"],
"description": "Utdataform. Standard: tekst.",
},
},
"required": ["workspace"],
"additionalProperties": False,
},
},
{
"name": "sak_status_check",
"description": (
"Samme utledning, men rapporterer om en hurtigbuffer i sak.md er "
"uenig med loggen. Avviket staar i teksten, med saken navngitt; "
"kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. "
"Retter ingenting og skriver ingenting."
),
"inputSchema": {
"type": "object",
"properties": {
"workspace": WORKSPACE_ARG,
"today": TODAY_ARG,
"sak": SAK_ARG,
},
"required": ["workspace"],
"additionalProperties": False,
},
},
{
"name": "dagens",
"description": (
"Dagens arbeidsbilde som ren tekst: hva som krever handling, hva "
"som har gaatt stille, kommende frister og saker per tilstand. "
"Byte-stabil for samme arbeidsomraade og samme dato. Skriver "
"ingenting."
),
"inputSchema": {
"type": "object",
"properties": {"workspace": WORKSPACE_ARG, "today": TODAY_ARG},
"required": ["workspace"],
"additionalProperties": False,
},
},
{
"name": "beslutning_append",
"description": (
"Legg til én beslutning i den append-only beslutningsloggen "
"(build-brief 5.4), eller en korrigering som navngir en tidligere "
"beslutning. En retting er alltid en ny linje, aldri en "
"redigering. Returnerer posten som ble skrevet."
),
"inputSchema": {
"type": "object",
"properties": {
"workspace": WORKSPACE_ARG,
"post": {
"type": "object",
"description": (
"Posten. Feltene er build-brief 5.4: id, dato, kilde, "
"url, tittel, arbeidsgiver, beslutning, arsak, notat, "
"score_da, delscore, vekter, vekt_hash, korrigerer."
),
},
"korriger": {
"type": "boolean",
"description": (
"Posten er en korrigering av en tidligere beslutning. "
"Standard: false."
),
},
},
"required": ["workspace", "post"],
"additionalProperties": False,
},
},
]
@ -242,6 +357,95 @@ def tool_annonse_vurder(arguments):
return til_json(resultat)
def _streng(arguments, navn):
"""An optional string argument, refused rather than coerced.
Types are checked before anything reaches `argparse`: a non-string would
become a malformed argv, and argparse answers a malformed argv with
SystemExit -- which is a BaseException and would take the server down
rather than returning a tool error.
"""
verdi = arguments.get(navn)
if verdi is None:
return None
if not isinstance(verdi, str) or not verdi.strip():
raise ToolError("%s må være en ikke-tom tekst, fikk %r" % (navn, verdi))
return verdi
def _kjor_cli(modul, argv):
"""Run a command-line entry point in-process and return its stdout.
The exit code is translated, not swallowed: a hard failure becomes a tool
error carrying what the CLI wrote to stderr. `sak_status`'s exit code 1 --
"a cache diverged" -- is deliberately not a failure; it is an answer, and
it is in the text.
"""
import io as _io
ut, feil = _io.StringIO(), _io.StringIO()
kode = modul.main(argv, stdout=ut, stderr=feil)
if kode == modul.EXIT_FEIL:
raise ToolError(feil.getvalue().strip() or "kommandoen feilet uten melding")
return ut.getvalue()
def _sak_status_argv(arguments):
argv = ["--workspace", _workspace(arguments)]
today = _streng(arguments, "today")
if today:
argv.extend(["--today", today])
sak = _streng(arguments, "sak")
if sak:
argv.extend(["--sak", sak])
return argv
def tool_sak_status(arguments):
import sak_status
argv = _sak_status_argv(arguments)
form = _streng(arguments, "format") or "tekst"
if form not in ("tekst", "json"):
raise ToolError("format må være tekst eller json, fikk %r" % (form,))
return _kjor_cli(sak_status, argv + ["--format", form])
def tool_sak_status_check(arguments):
import sak_status
# Text, not JSON: the CLI appends the divergence line to stdout after the
# report, which is natural in the text form and would make the JSON form
# unparseable. The answer is in that line, because MCP has no exit code.
return _kjor_cli(sak_status, _sak_status_argv(arguments) + ["--check"])
def tool_dagens(arguments):
import dagens
argv = ["--workspace", _workspace(arguments)]
today = _streng(arguments, "today")
if today:
argv.extend(["--today", today])
return _kjor_cli(dagens, argv)
def tool_beslutning_append(arguments):
import beslutninger
root = _workspace(arguments)
post = arguments.get("post")
if not isinstance(post, dict):
raise ToolError(
"post mangler; forventet beslutningen som et kart med feltene fra "
"build-brief 5.4"
)
argv = ["--workspace", root, "--json", json.dumps(post, ensure_ascii=False)]
if arguments.get("korriger"):
argv.append("--korriger")
return _kjor_cli(beslutninger, argv)
def tool_selvsjekk(arguments):
root = _workspace(arguments)
@ -277,10 +481,16 @@ def tool_selvsjekk(arguments):
)
#: Insertion order is the order `tools/list` publishes, and the golden file
#: pins it. Keep it the same as TOOLS above.
HANDLERS = {
"kandidat_valider": tool_kandidat_valider,
"annonse_vurder": tool_annonse_vurder,
"selvsjekk": tool_selvsjekk,
"sak_status": tool_sak_status,
"sak_status_check": tool_sak_status_check,
"dagens": tool_dagens,
"beslutning_append": tool_beslutning_append,
}

View file

@ -44,11 +44,31 @@ Two environment variables, and they are NOT the same one:
spelling problem above: a Windows adopter setting it
merely to say `python` would serve without the guard.
The gate is on the VERSION, not on the guard being importable. That is the
shell version's semantics carried over unchanged, and it is a known weakness
(O4 in docs/cowork-probe.md): `selvsjekk` can report `guard_versjon: null`
while the server runs happily. It is harmless while nothing writes and becomes
a defect in M2. Fixing it is an M2 decision, not a porting one.
Two gates, in this order (O4, decided in docs/cowork-probe.md and implemented
at plan Step 24):
1. VERSION. The interpreter reports at least 3.10, or it is not used.
2. THE GUARD. The interpreter can import `llm_ingestion_guard`, or the
launcher refuses to serve at all and says which command installs it.
Until M2 there was only the first, and the measured consequence is the reason
for the second: the M1 probe found the server running happily on the host's
3.14.0 with `selvsjekk` reporting `guard_versjon: null`, because nothing had
ever asked. That cost nothing while no tool wrote anything. From M2 the case
folder and the decision log persist employer names, titles, URLs and notes
that came out of a listing, and the guard is the boundary those writes are
supposed to cross.
Refusing is cheap here, and that is the deciding argument rather than a
consolation: every skill in this plugin degrades to manual paste with
`jobbsok-tools` absent. A launcher that refuses removes a connector; it does
not strand the operator. Failing open would persist untrusted text through the
one boundary this plugin exists to hold.
The guard gate costs one extra interpreter start-up at launch, which is paid
once per session against Cowork's MCP start-up timeout. It is a real cost and
it is worth naming; it is not a reason to check something cheaper that answers
a different question.
"""
import os
@ -118,6 +138,25 @@ def version_ok(tolk):
)
#: The ingestion guard, by the name the interpreter has to be able to import.
#: build-brief calls this the trust boundary, and the boundary is the write.
GUARD_MODUL = "llm_ingestion_guard"
#: What fixes a failed guard gate. Named in the refusal, because a refusal
#: that does not say what to run is a dead end.
BOOTSTRAP = "python scripts/bootstrap.py"
def guard_ok(tolk):
"""True when ``tolk`` can import the ingestion guard. Runs it; does not
look for a file, because what matters is that interpreter's own path."""
return 0 == subprocess.call(
[tolk, "-c", "import " + GUARD_MODUL],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def version_of(tolk):
"""The version ``tolk`` reports, or "ukjent" when it will not say."""
try:
@ -206,9 +245,11 @@ def resolve_interpreter(env, plugin_root, which=None, gate=None):
return None
def main(argv, env=None):
def main(argv, env=None, guard=None):
if env is None:
env = os.environ
if guard is None:
guard = guard_ok
plugin_root = env.get("CLAUDE_PLUGIN_ROOT") or os.path.dirname(SCRIPT_DIR)
server = os.path.join(plugin_root, "scripts", "jobbsok_tools.py")
@ -243,6 +284,23 @@ def main(argv, env=None):
sys.stderr.write("jobbsok-tools: server not found at %s\n" % server)
return 1
# Gate two. Last, because it is the expensive one and there is no point
# probing an interpreter that already failed the floor.
if not guard(tolk):
sys.stderr.write(
"jobbsok-tools: %s cannot import %s.\n" % (tolk, GUARD_MODUL)
)
sys.stderr.write(
"jobbsok-tools: run '%s' against the installed plugin; the guard "
"is installed with it.\n" % BOOTSTRAP
)
sys.stderr.write(
"jobbsok-tools: refusing to start rather than serving tools that "
"write without the ingestion guard. Every skill still works by "
"manual paste.\n"
)
return 1
# POSIX replaces this process, as the shell version's `exec` did: the MCP
# client's child stays the process it spawned, and killing it kills the
# server. Windows has no such replacement -- os.execv there starts a new

View file

@ -80,6 +80,111 @@
],
"additionalProperties": false
}
},
{
"name": "sak_status",
"description": "Utled status, ventende part, siste aktivitet, neste frist og stillhets-flagg for hver sak, fra logg.jsonl. sak.md er en hurtigbuffer og loggen vinner. Skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
},
"sak": {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
},
"format": {
"type": "string",
"enum": [
"tekst",
"json"
],
"description": "Utdataform. Standard: tekst."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "sak_status_check",
"description": "Samme utledning, men rapporterer om en hurtigbuffer i sak.md er uenig med loggen. Avviket staar i teksten, med saken navngitt; kommandolinja svarer i tillegg med exit-kode 1, som MCP ikke har. Retter ingenting og skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
},
"sak": {
"type": "string",
"description": "Begrens til én sak-id. Utelates den, tas hele arbeidsområdet."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "dagens",
"description": "Dagens arbeidsbilde som ren tekst: hva som krever handling, hva som har gaatt stille, kommende frister og saker per tilstand. Byte-stabil for samme arbeidsomraade og samme dato. Skriver ingenting.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"today": {
"type": "string",
"description": "Datoen som skal brukes, YYYY-MM-DD. Utelates den, brukes dagens dato. Oppgi den i alt som skal kunne reproduseres."
}
},
"required": [
"workspace"
],
"additionalProperties": false
}
},
{
"name": "beslutning_append",
"description": "Legg til én beslutning i den append-only beslutningsloggen (build-brief 5.4), eller en korrigering som navngir en tidligere beslutning. En retting er alltid en ny linje, aldri en redigering. Returnerer posten som ble skrevet.",
"inputSchema": {
"type": "object",
"properties": {
"workspace": {
"type": "string",
"description": "Absolutt sti til arbeidsområdet. Påkrevd. Det finnes ingen implisitt standard — serveren gjetter aldri på hjemmekatalogen."
},
"post": {
"type": "object",
"description": "Posten. Feltene er build-brief 5.4: id, dato, kilde, url, tittel, arbeidsgiver, beslutning, arsak, notat, score_da, delscore, vekter, vekt_hash, korrigerer."
},
"korriger": {
"type": "boolean",
"description": "Posten er en korrigering av en tidligere beslutning. Standard: false."
}
},
"required": [
"workspace",
"post"
],
"additionalProperties": false
}
}
]
}

View file

@ -44,6 +44,21 @@ SCRIPTS = os.path.join(REPO, "scripts")
GOLDEN = os.path.join(REPO, "tests", "golden", "jobbsok-tools.tools.json")
LAUNCHER = os.path.join(SCRIPTS, "jobbsok_tools_launch.py")
KORPUS = os.path.join(REPO, "tests", "fixtures", "workspace")
TODAY = "2026-09-15"
#: The tool surface, in the order the server declares it. A tool list is an
#: API: M1 shipped the first three, Step 24 added the four M2 tools.
VERKTOY = [
"kandidat_valider",
"annonse_vurder",
"selvsjekk",
"sak_status",
"sak_status_check",
"dagens",
"beslutning_append",
]
PROFIL_FIXTURE = ("profiles", "01-gyldig.md")
ANNONSE_FIXTURE = ("listings", "01-alt-passer.md")
@ -96,9 +111,10 @@ def test_initialize_and_tools_list_match_the_golden_surface(klient, golden):
verktoy = klient.tools()
golden(GOLDEN, json.dumps({"tools": verktoy}, ensure_ascii=False, indent=2) + "\n")
# The golden pins the shape; this pins the fact that M1 ships exactly three
# tools, so Step 24 adding four more is a visible change and not a drift.
assert [t["name"] for t in verktoy] == ["kandidat_valider", "annonse_vurder", "selvsjekk"]
# The golden pins the shape; this pins the surface, so a tool appearing or
# disappearing is a visible change and not a drift. M1 shipped the first
# three; Step 24 added the four M2 tools.
assert [t["name"] for t in verktoy] == VERKTOY
def test_every_tool_requires_an_explicit_workspace(klient, arbeidsomrade):
@ -206,7 +222,8 @@ def test_importing_the_server_does_not_import_the_capability_modules():
"-c",
"import sys; import jobbsok_tools; "
"print(sorted(m for m in ('kandidat_schema', 'vurdering', "
"'llm_ingestion_guard') if m in sys.modules))",
"'sak_status', 'dagens', 'beslutninger', 'llm_ingestion_guard') "
"if m in sys.modules))",
],
cwd=REPO,
env=dict(os.environ, PYTHONPATH=SCRIPTS),
@ -350,8 +367,177 @@ def test_the_launcher_refuses_an_interpreter_below_3_10_and_serves_on_a_good_one
"expected exactly two replies, one per request: %r" % (servert.stdout,)
)
assert svar[0]["result"]["serverInfo"]["name"] == "jobbsok-tools"
assert [t["name"] for t in svar[1]["result"]["tools"]] == [
"kandidat_valider",
"annonse_vurder",
"selvsjekk",
]
assert [t["name"] for t in svar[1]["result"]["tools"]] == VERKTOY
def cli(script, *argv):
"""Run one of the command-line entry points and return its stdout."""
env = dict(os.environ)
env.pop("JOBBSOK_WORKSPACE", None)
env.pop("CLAUDE_PLUGIN_DATA", None)
proc = subprocess.run(
[sys.executable, os.path.join(SCRIPTS, script)] + list(argv),
capture_output=True, text=True, env=env,
)
return proc.stdout
def test_the_m2_tools_answer_with_the_bytes_the_command_line_produces(
klient, empty_workspace, tmp_path
):
"""Parity is what makes the degradation branch honest.
Every skill degrades to reading what the CLI produced when the server is
absent. That instruction is only true if the two paths produce the same
content -- otherwise the degraded operator is reading a different report
and does not know it.
"""
for verktoy, argumenter, script, argv in (
("sak_status",
{"workspace": KORPUS, "today": TODAY, "format": "json"},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--format", "json"]),
("sak_status_check",
{"workspace": KORPUS, "today": TODAY},
"sak_status.py", ["--workspace", KORPUS, "--today", TODAY, "--check"]),
("dagens",
{"workspace": KORPUS, "today": TODAY},
"dagens.py", ["--workspace", KORPUS, "--today", TODAY]),
):
fra_server = klient.call_text(verktoy, argumenter)
fra_kommandolinja = cli(script, *argv)
assert fra_server == fra_kommandolinja, (
"%s and the command line disagree; the degradation branch tells "
"the operator these are the same content" % verktoy
)
# `sak_status_check` must carry the verdict in the payload: over MCP there
# is no exit code, and a check whose answer was only an exit status would
# arrive at Cowork as an unqualified report.
svar = klient.call_text("sak_status_check", {"workspace": KORPUS, "today": TODAY})
assert "utdatert hurtigbuffer" in svar, (
"the corpus holds a deliberately divergent case and the check never "
"says so: %r" % (svar[-200:],)
)
# The writing tool, appended through each path into its own fresh
# workspace, because the log refuses a reused id by design.
from jobbsok_lib import paths as _paths
andre = str(tmp_path / "andre-workspace")
_paths.scaffold(andre)
post = {
"id": "b-0001", "dato": "2026-09-01T09:00:00+02:00", "kilde": "manuell",
"url": "https://stillinger.example/sak-1", "tittel": "Dataingeniør",
"arbeidsgiver": "Værøy Sjømat AS", "beslutning": "ja",
"arsak": ["fagomrade"], "notat": "treffer kjernen", "score_da": 78,
"delscore": {"fagomrade": 78, "oppgavetype": 73, "teknologi": 68,
"selskapstype": 63},
"vekter": {"fagomrade": 40, "oppgavetype": 30, "teknologi": 20,
"selskapstype": 10},
"vekt_hash": "sha256:" + "e4" * 32,
}
fra_server = klient.call_text(
"beslutning_append", {"workspace": empty_workspace, "post": post}
)
fra_kommandolinja = cli(
"beslutninger.py", "--workspace", andre, "--json", json.dumps(post)
)
assert fra_server == fra_kommandolinja
# And it actually wrote, in both places: a parity test over two no-ops
# would pass just as quietly.
for rot in (empty_workspace, andre):
with open(os.path.join(rot, "beslutninger.jsonl"), "r", encoding="utf-8") as handle:
assert len(handle.read().splitlines()) == 1
def tolk_uten_vakt(katalog, navn, versjon="3.14.0", har_vakt=False):
"""A program that clears the version gate and answers the guard probe.
Two probes, and the fake has to tell them apart: the version gate runs
`sys.version_info >= ...` and the guard gate runs `import
llm_ingestion_guard`. With ``har_vakt`` false the second one fails, which
is exactly the measured M1 state -- an interpreter at 3.14.0 that clears
the floor and has no guard (O4 in docs/cowork-probe.md).
The Windows form is written but NOT measured; this suite has only ever run
on macOS.
"""
if os.name == "nt":
sti = katalog / (navn + ".cmd")
linjer = ["@echo off"]
if not har_vakt:
linjer.append('echo %* | findstr /C:"llm_ingestion_guard" >nul && exit /b 1')
linjer.append('echo %%* | findstr /C:"print" >nul && (echo %s & exit /b 0)' % versjon)
linjer.append("exit /b 0")
sti.write_text("\r\n".join(linjer) + "\r\n")
return sti
avvis = "" if har_vakt else " *llm_ingestion_guard*) exit 1 ;;\n"
sti = katalog / navn
sti.write_text(
"#!/bin/sh\n"
"# Clears the version gate at %s; the guard import is the variable.\n"
'case "$*" in\n'
"%s"
" *print*) echo '%s'; exit 0 ;;\n"
"esac\n"
"exit 0\n" % (versjon, avvis, versjon)
)
sti.chmod(0o755)
return sti
def test_the_launcher_refuses_to_serve_without_the_ingestion_guard(tmp_path):
"""O4, decided in docs/cowork-probe.md and implemented here.
The measured M1 incident: the server served on the host's 3.14.0, cleared
the version floor, and reported `guard_versjon: null` because nothing had
ever asked. From M2 the case folder and the decision log persist employer
names, titles, URLs and notes that came out of a listing, so serving
without the guard is a defect rather than an observation.
Failing closed is cheap here and that is the deciding argument: every
skill degrades to manual paste with the server absent, so a launcher that
refuses removes a connector -- it does not strand anyone.
"""
(tmp_path / "uten").mkdir()
uten = tolk_uten_vakt(tmp_path / "uten", "python3.14", har_vakt=False)
avvist = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(uten)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert avvist.returncode != 0, (
"the launcher served on an interpreter that cannot import the guard"
)
assert "llm_ingestion_guard" in avvist.stderr, (
"the refusal must name what was missing: %r" % (avvist.stderr,)
)
assert "scripts/bootstrap.py" in avvist.stderr, (
"the refusal must name the command that fixes it, or it is a dead end: "
"%r" % (avvist.stderr,)
)
# The positive control, without which the assertion above could be passing
# on the version gate, on a missing file, or on nothing at all: the same
# fake with the guard import answering must NOT be refused.
(tmp_path / "med").mkdir()
med = tolk_uten_vakt(tmp_path / "med", "python3.14", har_vakt=True)
sluppet_gjennom = subprocess.run(
[sys.executable, LAUNCHER],
env=dict(os.environ, JOBBSOK_PYTHON=str(med)),
input="",
capture_output=True,
text=True,
timeout=30,
)
assert sluppet_gjennom.returncode == 0, (
"an interpreter that clears both gates was still refused: %r"
% (sluppet_gjennom.stderr,)
)
assert "refusing" not in sluppet_gjennom.stderr