From a5053c0e294dda605ac6af8a8a2d88e09c1f19b9 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Tue, 29 Sep 2026 13:36:03 -0700 Subject: [PATCH 1/4] fix(docker): mount plugins at /coder_eval/plugins/ and point the staged task there The runner resolved each agent.plugins[].path on the host and mounted it at its host path, but the task.yaml staged into the container kept the authored string. The in-container agent then re-resolved it against the container's cwd: a relative path, or a $VAR the container did not have, pointed at nothing and the skill silently never loaded (only a warning). Now plugin i mounts :ro at /coder_eval/plugins/, and the staged task.yaml is rewritten to that path, via one shared _plugin_mounts() mapping so the bind and the rewrite cannot disagree. The anti-cheat tmpfs masks move under the container path. The host-side task is untouched. Entries that do not resolve to a host directory are neither mounted nor rewritten. /coder_eval/plugins joins RESERVED_CONTAINER_DIRS. Co-Authored-By: Claude Opus 5.5 --- docs/DOCKER_ISOLATION.md | 14 +++- src/coder_eval/isolation/docker_runner.py | 82 ++++++++++++++++------- src/coder_eval/models/__init__.py | 2 + src/coder_eval/models/container_paths.py | 3 + tests/test_container_context.py | 31 +++++++++ tests/test_docker_runner_mounts.py | 39 ++++++----- 6 files changed, 128 insertions(+), 43 deletions(-) diff --git a/docs/DOCKER_ISOLATION.md b/docs/DOCKER_ISOLATION.md index 83a5cb1e..9b2e818d 100644 --- a/docs/DOCKER_ISOLATION.md +++ b/docs/DOCKER_ISOLATION.md @@ -361,9 +361,19 @@ reference window's posture above; neither contains an adversarial agent. read-write (a `:ro` mount rejects `rm` with EROFS). `prior.json` is kept: it is read later on the regrade path, and a regrade runs no agent so it is not a leak. +- **Plugins mount at a fixed container path.** The `i`-th `agent.plugins[]` entry + is resolved on the host (a relative path against the task YAML's directory, `$VAR` + and `~` expanded) and mounted `:ro` at `/coder_eval/plugins/`. The task YAML + staged into the container is rewritten to point at that path, so the in-container + agent loads the directory the host mounted, whatever form the authored path took + and whatever the container's cwd. (Before, the staged YAML kept the authored string: + a relative or `$VAR` path the container could not resolve loaded no skill, with only + a warning.) An entry that does not resolve to a host directory is neither mounted + nor rewritten. + - **Auto-mounted plugin trees are default-deny masked.** An `agent.plugins[].path` - (or a `TemplateDirSource.path` that is itself a plugin root) is auto-mounted at - its host path `:ro` so the plugin loads. Eval material colocated under that tree + (at `/coder_eval/plugins/`) or a `TemplateDirSource.path` that is itself a plugin + root (at its host path) is auto-mounted `:ro` so the plugin loads. Eval material colocated under that tree as siblings of the skills dir — sibling task YAMLs, reference solutions, test fixtures — would otherwise be readable. So the runner keeps the whole root mounted but layers an empty `--tmpfs` over every child dir OUTSIDE the keep-set diff --git a/src/coder_eval/isolation/docker_runner.py b/src/coder_eval/isolation/docker_runner.py index e3217dad..3e5c7fe1 100644 --- a/src/coder_eval/isolation/docker_runner.py +++ b/src/coder_eval/isolation/docker_runner.py @@ -30,6 +30,7 @@ CONTAINER_GRADE_WORKSPACE, CONTAINER_INPUT_DIR, CONTAINER_OUTPUT_DIR, + CONTAINER_PLUGINS_DIR, CONTAINER_REFERENCE_DIR, CONTAINER_TASK_DIR, CONTAINER_WORK_DIR, @@ -724,6 +725,17 @@ async def _stage_inputs(self, input_dir: Path) -> None: # Rationale: .claude/notes/orchestration.md § The host-side driver rewrite execution_sandbox = SandboxConfig.model_validate({**self.rt.task.sandbox.model_dump(), "driver": "tempdir"}) # noqa: CE051 execution_task = self.rt.task.model_copy(update={"sandbox": execution_sandbox}) + # Point each mounted plugin at its container path; the host path (relative, + # $VAR, or absolute) need not exist inside the container. + plugin_mounts = self._plugin_mounts() + if plugin_mounts and execution_task.agent is not None: + plugins = [ + {**plugin, "path": plugin_mounts[i][1]} if i in plugin_mounts else plugin + for i, plugin in enumerate(execution_task.agent.plugins or []) + ] + execution_task = execution_task.model_copy( + update={"agent": execution_task.agent.model_copy(update={"plugins": plugins})} + ) def _dump_task_yaml() -> str: return yaml.safe_dump(execution_task.model_dump(mode="json"), sort_keys=False) @@ -1236,13 +1248,34 @@ def _resolve_mount_path(self, raw_path: str) -> Path: expanded = self.rt.task_file.parent / expanded return expanded.resolve() + def _plugin_mounts(self) -> dict[int, tuple[Path, str]]: + """``agent.plugins`` index -> (host dir, container path) for every plugin whose path resolves to a dir. + + Shared by ``_stage_inputs`` (rewrites the staged task.yaml) and + ``_append_auto_mounts`` (emits the binds), so the two cannot disagree. + """ + plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or [] + mounts: dict[int, tuple[Path, str]] = {} + for i, plugin in enumerate(plugins): + raw_path = plugin.get("path") if isinstance(plugin, dict) else None + if not raw_path: + continue + resolved = self._resolve_mount_path(raw_path) + if resolved.is_dir(): + mounts[i] = (resolved, f"{CONTAINER_PLUGINS_DIR}/{i}") + return mounts + def _append_auto_mounts(self, argv: list[str]) -> None: - """Bind-mount the plugin, template and system-prompt paths a task references, ``:ro`` at their host path. + """Bind-mount the plugin, template and system-prompt paths a task references, ``:ro``. - A plugin root also gets every non-skill child dir masked with an empty tmpfs. The - reference is deliberately NOT here: it has its own mount (``_reference_mount_args``). + Plugin ``i`` goes to ``CONTAINER_PLUGINS_DIR/i`` (see ``_plugin_mounts``); the + rest mount at their host path. A plugin root also gets every non-skill child dir + masked with an empty tmpfs. The reference is deliberately NOT here: it has its + own mount (``_reference_mount_args``). """ + # Host sources bound at their host path (dedupe), and every container dest bound. mounted: set[Path] = set() + dests: set[str] = set() # Warned, not refused: `plugin.path` / `reference.directory` / # `template_sources` are user-controlled strings, and legitimate uses exist. # Rationale: .claude/notes/isolation.md § Extra mounts and reserved destinations @@ -1251,19 +1284,11 @@ def _append_auto_mounts(self, argv: list[str]) -> None: # Lazy: eval_material imports agents._skills, whose package imports this module. from coder_eval.isolation.eval_material import mask_dirs - # Masked dir -> its plugin root. Emitted only once every bind is known, so a - # nested plugin root's bind can win over its parent's mask of the same path. - mask_targets: dict[Path, Path] = {} + # Masked container dir -> its host plugin root. Emitted only once every bind is + # known, so a nested plugin root's bind can win over its parent's mask of the same path. + mask_targets: dict[str, Path] = {} - def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None: - if not raw_path: - return - resolved = self._resolve_mount_path(raw_path) - # File paths get mounted as the parent dir so a single -v covers - # the file; container-side reads still resolve at the same path. - target = resolved if (dir_only or resolved.is_dir()) else resolved.parent - if target in mounted or not target.is_dir(): - return + def _bind(target: Path, dest: str) -> None: for sensitive in sensitive_sources: if target == sensitive or sensitive in target.parents: logger.warning( @@ -1271,17 +1296,28 @@ def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None: target, ) break - mounted.add(target) - argv.extend(["-v", f"{target}:{target}:ro"]) + dests.add(dest) + argv.extend(["-v", f"{target}:{dest}:ro"]) masks = mask_dirs(target) if not masks and (target / ".claude-plugin" / "plugin.json").is_file(): logger.warning(_MASK_STANDDOWN_WARNING, target) for masked_dir in masks: - mask_targets.setdefault(masked_dir, target) + mask_targets.setdefault(str(Path(dest) / masked_dir.relative_to(target)), target) - plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or [] - for plugin in plugins: - _auto_mount(plugin.get("path") if isinstance(plugin, dict) else None) + def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None: + if not raw_path: + return + resolved = self._resolve_mount_path(raw_path) + # File paths get mounted as the parent dir so a single -v covers + # the file; container-side reads still resolve at the same path. + target = resolved if (dir_only or resolved.is_dir()) else resolved.parent + if target in mounted or not target.is_dir(): + return + mounted.add(target) + _bind(target, str(target)) + + for host_dir, dest in self._plugin_mounts().values(): + _bind(host_dir, dest) from coder_eval.models import TemplateDirSource @@ -1299,9 +1335,9 @@ def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None: # A deeper --tmpfs wins over the enclosing :ro bind regardless of argv order; # a mask that is also a bind would be a duplicate mount point, so the bind wins. for masked_dir, root in sorted(mask_targets.items()): - if masked_dir in mounted: + if masked_dir in dests: continue - argv.extend(["--tmpfs", str(masked_dir)]) + argv.extend(["--tmpfs", masked_dir]) logger.warning(_MASK_WARNING, masked_dir, root) def _build_argv( diff --git a/src/coder_eval/models/__init__.py b/src/coder_eval/models/__init__.py index 0ee1538b..1f52bc7a 100644 --- a/src/coder_eval/models/__init__.py +++ b/src/coder_eval/models/__init__.py @@ -36,6 +36,7 @@ CONTAINER_GRADE_WORKSPACE, CONTAINER_INPUT_DIR, CONTAINER_OUTPUT_DIR, + CONTAINER_PLUGINS_DIR, CONTAINER_REFERENCE_DIR, CONTAINER_TASK_DIR, CONTAINER_WORK_DIR, @@ -324,6 +325,7 @@ "CONTAINER_INPUT_DIR", "CONTAINER_OUTPUT_DIR", "CONTAINER_GRADE_WORKSPACE", + "CONTAINER_PLUGINS_DIR", "CONTAINER_REFERENCE_DIR", "IN_CONTAINER_ENV", "CONTAINER_TASK_DIR", diff --git a/src/coder_eval/models/container_paths.py b/src/coder_eval/models/container_paths.py index ceb07779..4d9750bc 100644 --- a/src/coder_eval/models/container_paths.py +++ b/src/coder_eval/models/container_paths.py @@ -91,6 +91,8 @@ def command_uses_token(command: str, token: str) -> bool: # Rationale: .claude/notes/isolation.md § Why the grading container gets a private scratch directory CONTAINER_GRADE_WORKSPACE = "/work/workspace" +CONTAINER_PLUGINS_DIR = "/coder_eval/plugins" # agent.plugins[i] mounts :ro at /; see DOCKER_ISOLATION.md + # Paths a task's WORKDIR must never collide with. Consumed by SandboxConfig's # working_dir validator and re-asserted host-side in docker_runner. RESERVED_CONTAINER_DIRS = frozenset( @@ -102,5 +104,6 @@ def command_uses_token(command: str, token: str) -> bool: CONTAINER_TASK_DIR, CONTAINER_REFERENCE_DIR, CONTAINER_GRADE_WORKSPACE, + CONTAINER_PLUGINS_DIR, } ) diff --git a/tests/test_container_context.py b/tests/test_container_context.py index 78975e88..6f93a412 100644 --- a/tests/test_container_context.py +++ b/tests/test_container_context.py @@ -18,6 +18,7 @@ from coder_eval.cli import app, run_task_internal_command from coder_eval.isolation.docker_runner import DockerRunner from coder_eval.models import ( + CONTAINER_PLUGINS_DIR, AgentKind, ConfigLineageEntry, ContainerContext, @@ -293,6 +294,36 @@ async def test_the_staged_task_yaml_says_tempdir(tmp_path: Path) -> None: assert staged.sandbox.driver == "tempdir" +async def test_the_staged_task_yaml_points_plugins_at_their_container_mounts(tmp_path: Path) -> None: + # A relative plugin path resolves against the task-file dir on the host; the + # container gets the fixed mount path instead, since that host path (or its + # $VAR) need not exist inside. An unresolvable plugin is left as authored. + (tmp_path / "plugin" / "skills" / "demo").mkdir(parents=True) + plugins = [{"type": "local", "path": "plugin"}, {"type": "local", "path": "does/not/exist"}] + task = TaskDefinition.model_validate( + { + **_authored_docker_task().model_dump(), + "initial_prompt": "go", + "agent": {"type": "claude-code", "plugins": plugins}, + } + ) + rt = ResolvedTask( + task=task, + task_file=tmp_path / "t.yaml", + run_dir=tmp_path / "run", + variant_id="default", + original_task_id="staged", + ) + input_dir = tmp_path / "input" + input_dir.mkdir() + await DockerRunner(rt)._stage_inputs(input_dir) + + staged, _ = load_task(input_dir / "task.yaml") + assert [p["path"] for p in staged.agent.plugins] == [f"{CONTAINER_PLUGINS_DIR}/0", "does/not/exist"] + # The host-side task is untouched: argv rendering still needs the host path. + assert rt.task.agent.plugins[0]["path"] == "plugin" + + async def test_the_contract_carries_the_authored_sandbox(tmp_path: Path) -> None: rt, _, ctx = await _stage(tmp_path) diff --git a/tests/test_docker_runner_mounts.py b/tests/test_docker_runner_mounts.py index 89a95694..234d2ae3 100644 --- a/tests/test_docker_runner_mounts.py +++ b/tests/test_docker_runner_mounts.py @@ -25,6 +25,7 @@ CLAUDE_COPY_MAX_ATTEMPTS, CONTAINER_ENTRYPOINT, CONTAINER_OUTPUT_DIR, + CONTAINER_PLUGINS_DIR, CONTAINER_REFERENCE_DIR, CONTAINER_TASK_DIR, DockerRunError, @@ -790,18 +791,18 @@ def test_plugin_root_masks_non_skill_children(self, tmp_path: Path): argv = self._argv(runner, tmp_path) tmpfs = self._tmpfs(argv) - # The whole root is :ro-mounted so the plugin loads. - assert f"{root.resolve()}:{root.resolve()}:ro" in self._mounts(argv) + # The whole root is :ro-mounted at plugin 0's container path so the plugin loads. + assert f"{root.resolve()}:{CONTAINER_PLUGINS_DIR}/0:ro" in self._mounts(argv) # Non-skill children masked; skill surface + manifest not. - assert str((root / "tests").resolve()) in tmpfs - assert str((root / "reference").resolve()) in tmpfs - assert str((root / "node_modules").resolve()) in tmpfs - assert str((root / "skills").resolve()) not in tmpfs - assert str((root / ".claude-plugin").resolve()) not in tmpfs - - def test_tmpfs_targets_are_under_mounted_host_root(self, tmp_path: Path): - # Codex symlinks / Antigravity search paths dereference the ORIGINAL - # mounted host path, so the mask must sit on that path, not a copy. + assert f"{CONTAINER_PLUGINS_DIR}/0/tests" in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/0/reference" in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/0/node_modules" in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/0/skills" not in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/0/.claude-plugin" not in tmpfs + + def test_tmpfs_targets_are_under_mounted_container_root(self, tmp_path: Path): + # Codex symlinks / Antigravity search paths dereference the container path + # the staged task.yaml names, so the mask must sit under that path. root = self._plugin_root(tmp_path / "plugin") (root / "skills" / "demo").mkdir(parents=True) (root / "tests").mkdir() @@ -810,7 +811,7 @@ def test_tmpfs_targets_are_under_mounted_host_root(self, tmp_path: Path): argv = self._argv(runner, tmp_path) for masked in self._tmpfs(argv): - assert Path(masked).is_relative_to(root.resolve()) + assert Path(masked).is_relative_to(f"{CONTAINER_PLUGINS_DIR}/0") def test_template_source_plugin_root_is_masked(self, tmp_path: Path): from coder_eval.models import TemplateDirSource @@ -860,12 +861,14 @@ def test_nested_plugin_root_bind_wins_over_mask(self, tmp_path: Path): argv = self._argv(runner, tmp_path) mounts, tmpfs = self._mounts(argv), self._tmpfs(argv) - # B is bind-mounted (so it loads) and NOT tmpfs-masked (no duplicate dest). - assert f"{b.resolve()}:{b.resolve()}:ro" in mounts - assert str(b.resolve()) not in tmpfs - # A's own non-skill child is still masked; B masks its own. - assert str((a / "tests").resolve()) in tmpfs - assert str((b / "tests").resolve()) in tmpfs + # B is bind-mounted at its own container path (so it loads) and NOT + # tmpfs-masked there (no duplicate dest). + assert f"{b.resolve()}:{CONTAINER_PLUGINS_DIR}/1:ro" in mounts + assert f"{CONTAINER_PLUGINS_DIR}/1" not in tmpfs + # A's own non-skill children (B included) are masked in A's view; B masks its own. + assert f"{CONTAINER_PLUGINS_DIR}/0/tests" in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/0/nested_b" in tmpfs + assert f"{CONTAINER_PLUGINS_DIR}/1/tests" in tmpfs # No --tmpfs target collides with a bind destination (the M2 crash). bind_dests = {m.split(":")[1] for m in mounts if m.count(":") >= 2} assert not (set(tmpfs) & bind_dests) From 2db2474130e88222440e53bb316fc57fc0968506 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Tue, 29 Sep 2026 13:41:00 -0700 Subject: [PATCH 2/4] refactor(docker): mount plugins under /work/plugins, not /coder_eval/plugins Every other framework-owned container path lives under /work, and _validate_extra_mount already refuses any extra_mounts destination under /work/, so a task can no longer shadow a plugin mount with its own. Co-Authored-By: Claude Opus 5.5 --- docs/DOCKER_ISOLATION.md | 7 ++++--- src/coder_eval/models/container_paths.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/DOCKER_ISOLATION.md b/docs/DOCKER_ISOLATION.md index 9b2e818d..8c6e6383 100644 --- a/docs/DOCKER_ISOLATION.md +++ b/docs/DOCKER_ISOLATION.md @@ -363,16 +363,17 @@ reference window's posture above; neither contains an adversarial agent. - **Plugins mount at a fixed container path.** The `i`-th `agent.plugins[]` entry is resolved on the host (a relative path against the task YAML's directory, `$VAR` - and `~` expanded) and mounted `:ro` at `/coder_eval/plugins/`. The task YAML + and `~` expanded) and mounted `:ro` at `/work/plugins/`. The task YAML staged into the container is rewritten to point at that path, so the in-container agent loads the directory the host mounted, whatever form the authored path took and whatever the container's cwd. (Before, the staged YAML kept the authored string: a relative or `$VAR` path the container could not resolve loaded no skill, with only a warning.) An entry that does not resolve to a host directory is neither mounted - nor rewritten. + nor rewritten. It sits under `/work`, so a `sandbox.docker.extra_mounts` destination + cannot shadow it (those are refused anywhere under `/work/`). - **Auto-mounted plugin trees are default-deny masked.** An `agent.plugins[].path` - (at `/coder_eval/plugins/`) or a `TemplateDirSource.path` that is itself a plugin + (at `/work/plugins/`) or a `TemplateDirSource.path` that is itself a plugin root (at its host path) is auto-mounted `:ro` so the plugin loads. Eval material colocated under that tree as siblings of the skills dir — sibling task YAMLs, reference solutions, test fixtures — would otherwise be readable. So the runner keeps the whole root diff --git a/src/coder_eval/models/container_paths.py b/src/coder_eval/models/container_paths.py index 4d9750bc..9c90dc9f 100644 --- a/src/coder_eval/models/container_paths.py +++ b/src/coder_eval/models/container_paths.py @@ -91,7 +91,7 @@ def command_uses_token(command: str, token: str) -> bool: # Rationale: .claude/notes/isolation.md § Why the grading container gets a private scratch directory CONTAINER_GRADE_WORKSPACE = "/work/workspace" -CONTAINER_PLUGINS_DIR = "/coder_eval/plugins" # agent.plugins[i] mounts :ro at /; see DOCKER_ISOLATION.md +CONTAINER_PLUGINS_DIR = "/work/plugins" # agent.plugins[i] mounts :ro at /; see DOCKER_ISOLATION.md # Paths a task's WORKDIR must never collide with. Consumed by SandboxConfig's # working_dir validator and re-asserted host-side in docker_runner. From 135b13786a1094c067a94ddc99590c8515134cd4 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Tue, 29 Sep 2026 13:59:26 -0700 Subject: [PATCH 3/4] chore(deps): bump pyjwt to 2.15.1 and ignore oauthlib CVE-2026-49265 pip-audit started failing on two new advisories: - pyjwt 2.13.0, CVE-2026-102274 (a malformed RSA JWK aborts parsing of the whole JWK set): floor raised to >=2.14.0; the lock resolves 2.15.1, the newest release past the safe-chain minimum package age. - oauthlib 3.3.1, CVE-2026-49265 (PKCE timing side channel): the flaw is in the server-side code_challenge check, which coder-eval never runs; oauthlib is only transitive (azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump published inside the minimum-age window, so ignore it in pip-audit and osv-scanner for now and revisit next month. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-checks.yml | 2 +- osv-scanner.toml | 4 ++++ pyproject.toml | 2 +- uv.lock | 8 ++++---- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1690fba9..fecf7a03 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -123,7 +123,7 @@ jobs: # PHASE 3: Security scanning - name: Security - Dependency vulnerabilities (pip-audit) - run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity + pyjwt 2.12.1 weak-encryption (disputed by supplier; key length is application-chosen); no fixes available on PyPI yet — revisit quarterly + run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183 --ignore-vuln CVE-2026-49265 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity + pyjwt 2.12.1 weak-encryption (disputed by supplier; key length is application-chosen); no fixes available on PyPI yet — revisit quarterly. oauthlib 3.3.1 PKCE timing side channel: server-side check, unused here (transitive via azure-monitor exporter -> msrest); fix 4.0.0 is a major bump still inside the safe-chain minimum package age — revisit next month - name: Security - OSV vulnerability scan (osv-scanner) # Complements pip-audit: pip-audit queries the PyPI advisory DB; diff --git a/osv-scanner.toml b/osv-scanner.toml index b81b65ef..4a635ca4 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -9,3 +9,7 @@ [[IgnoredVulns]] id = "PYSEC-2025-183" reason = "pyjwt 2.12.1 weak-encryption — disputed by supplier (key length is application-chosen). No fix available on PyPI; revisit quarterly." + +[[IgnoredVulns]] +id = "CVE-2026-49265" +reason = "oauthlib 3.3.1 PKCE timing side channel in the server-side code_challenge check, which coder-eval never runs (oauthlib is transitive via azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump still inside the safe-chain minimum package age; revisit next month." diff --git a/pyproject.toml b/pyproject.toml index 007c0390..22169121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -227,7 +227,7 @@ constraint-dependencies = [ "langgraph>=1.0.10", "langsmith>=0.6.3", "orjson>=3.11.6", - "pyjwt>=2.13.0", + "pyjwt>=2.14.0", "python-multipart>=0.0.31", "requests>=2.33.0", "starlette>=1.3.1", diff --git a/uv.lock b/uv.lock index c2cbc7f2..dbb20126 100644 --- a/uv.lock +++ b/uv.lock @@ -14,7 +14,7 @@ constraints = [ { name = "langgraph", specifier = ">=1.0.10" }, { name = "langsmith", specifier = ">=0.6.3" }, { name = "orjson", specifier = ">=3.11.6" }, - { name = "pyjwt", specifier = ">=2.13.0" }, + { name = "pyjwt", specifier = ">=2.14.0" }, { name = "python-multipart", specifier = ">=0.0.31" }, { name = "requests", specifier = ">=2.33.0" }, { name = "starlette", specifier = ">=1.3.1" }, @@ -2265,11 +2265,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] From 5ff7c9a119ebdf3c54bd7cabf361469eb72568fd Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Tue, 29 Sep 2026 14:09:52 -0700 Subject: [PATCH 4/4] chore(deps): ignore oauthlib CVE-2026-49264 and drop the stale pyjwt ignore osv-scanner flagged a second oauthlib 3.3.1 advisory, GHSA-hj66-6f7g-4r5v (CVE-2026-49264, RevocationEndpoint JSONP callback injection with enable_jsonp=True), published 2026-09-29 and not yet in pip-audit's DB. Like CVE-2026-49265 it is a server-side OAuth endpoint coder-eval never runs, and the fix is the same too-new 4.0.0, so ignore it in both scanners. PYSEC-2025-183 (pyjwt 2.12.1) no longer applies now that pyjwt is 2.15.1; osv-scanner reported it as an unused ignore. Removed from both lists. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-checks.yml | 2 +- osv-scanner.toml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index fecf7a03..5ba2cdb6 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -123,7 +123,7 @@ jobs: # PHASE 3: Security scanning - name: Security - Dependency vulnerabilities (pip-audit) - run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183 --ignore-vuln CVE-2026-49265 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity + pyjwt 2.12.1 weak-encryption (disputed by supplier; key length is application-chosen); no fixes available on PyPI yet — revisit quarterly. oauthlib 3.3.1 PKCE timing side channel: server-side check, unused here (transitive via azure-monitor exporter -> msrest); fix 4.0.0 is a major bump still inside the safe-chain minimum package age — revisit next month + run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-49265 --ignore-vuln CVE-2026-49264 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity; no fixes available on PyPI yet — revisit quarterly. oauthlib 3.3.1 PKCE timing side channel + RevocationEndpoint JSONP callback injection: both server-side OAuth endpoints, unused here (transitive via azure-monitor exporter -> msrest); fix 4.0.0 is a major bump still inside the safe-chain minimum package age — revisit next month - name: Security - OSV vulnerability scan (osv-scanner) # Complements pip-audit: pip-audit queries the PyPI advisory DB; diff --git a/osv-scanner.toml b/osv-scanner.toml index 4a635ca4..62b12d00 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -6,10 +6,10 @@ # When an advisory is fixed upstream, drop the entry here AND remove the # matching pip-audit flag in the same PR. -[[IgnoredVulns]] -id = "PYSEC-2025-183" -reason = "pyjwt 2.12.1 weak-encryption — disputed by supplier (key length is application-chosen). No fix available on PyPI; revisit quarterly." - [[IgnoredVulns]] id = "CVE-2026-49265" reason = "oauthlib 3.3.1 PKCE timing side channel in the server-side code_challenge check, which coder-eval never runs (oauthlib is transitive via azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump still inside the safe-chain minimum package age; revisit next month." + +[[IgnoredVulns]] +id = "GHSA-hj66-6f7g-4r5v" +reason = "CVE-2026-49264: oauthlib 3.3.1 RevocationEndpoint JSONP callback injection (only with enable_jsonp=True), a server-side OAuth endpoint coder-eval never runs (oauthlib is transitive via azure-monitor-opentelemetry-exporter -> msrest -> requests-oauthlib). The fix, 4.0.0, is a major bump still inside the safe-chain minimum package age; revisit next month."