From 032a59f378ecb0072d125da539622f906fa6979d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=A8?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD?= Date: Wed, 19 Aug 2026 11:49:09 +0300 Subject: [PATCH 1/5] A palette that inverted everything except the scrollbars, a deny list that read a quoted pipe as an operator, and a fourth submitter the prompt never credited Reported as "in dark mode the scrollbars stay light", which is exact and is the whole of it. Every token in `ui/tokens.py` dresses something the app draws, and a scrollbar is not one of those things: the browser draws it, and it takes its colour from `color-scheme` alone -- which the stylesheet never declared. No token above it could have reached the thing. `color-scheme` is emitted from `colour_variables()` rather than from `css_variables()`, and the placement is the fix rather than a detail of it. `css_variables()` is the non-colour half and ships once, with the light palette; a declaration there would have pinned every theme to `light` and left the dark block with no way to override the one property it most needed to. Emitting it from the colour half puts it in both blocks, so it flips with the attribute the shell already sets and the switch stays instant and reload-free. `COLOR_SCHEME` is a table rather than `name == "dark"` for the same reason `PALETTES` is one: a third palette should have to say which of the two it is, not be guessed at. `ui/render.py`'s notebook render declares its own from the same table -- it is a document of its own, and the pane that scrolls furthest. `_segments()` split on `|` with a regex that could not see quotes, so `grep -n "zzz\|pip install" file` became `grep -n "zzz\` and `pip install" file` and the second segment's head was `pip`. A read-only grep was denied as though someone had typed an install command. The pattern that trips it is a grep for the deny list's own vocabulary, which is what anyone auditing `hooks.py` writes, so the false denial landed on the people best placed to hit it. The replacement walks the string tracking quote state, and the two things it deliberately does *not* do are what make it safe to be less aggressive. Command substitution stays live inside double quotes, where `"$(ssh box)"` really does start a new command and is also the one place worth hiding one; single quotes suppress it, as the shell does. Unbalanced quotes fall back to the old blind split, because a string this cannot parse is one it must not vouch for. Over-splitting only ever costs a false denial, and that is the direction a deny list is allowed to be wrong in. **The two new tests fail against the old code rather than merely passing beside it**, and a third guards the refactor: `test_quote_awareness_still_fails_closed` passes under both splits on purpose, because a quote-aware split that stopped denying `"$(ssh box)"` would be a worse defect than the one being fixed, and it would look like it worked. `prompts/system.md` named `gpu.py`, `jobs.py` and `kaggle.py` as the tools that hold the credentials. `modal.py` holds `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, is introduced sixty lines above as the fourth submitter, and was left out of the one sentence that says where credentials live. Three further defects reported alongside these turned out to be artifacts of a non-editable install rather than of the code, and need no change here. `.venv` held a copied 0.3.0 while the tree was 0.3.1, so `core` imported from `site-packages`, `code_dir()` resolved there, `.grad-workspace.json` was never found, and `paths.root()` fell through to `site-packages` as the workspace -- which is why a CLI reported zero projects against a ledger with four, and why `skills_dir()` appeared to name a directory that did not exist. It reproduces from every directory except the checkout, where the tree shadows `site-packages` on `sys.path` and everything looks correct, and it is repaired with `pip install -e .`. Worth stating because the obvious workaround -- pinning `GRAD_ROOT` -- points Grad at the checkout rather than at the workspace and looks like it worked. Co-Authored-By: Claude Opus 5 --- hooks.py | 67 ++++++++++++++++++++++++++++++++++++++++- prompts/system.md | 4 +-- tests/test_hooks.py | 35 +++++++++++++++++++++ tests/test_ui_tokens.py | 16 ++++++++++ ui/render.py | 5 +++ ui/tokens.py | 29 ++++++++++++++++-- 6 files changed, 151 insertions(+), 5 deletions(-) diff --git a/hooks.py b/hooks.py index 325a0e1..0190a68 100644 --- a/hooks.py +++ b/hooks.py @@ -214,14 +214,79 @@ def _cost_bearing_over_budget(command: str) -> Denial | None: ) +#: Longest first, so `||` is never read as two `|` and `&&` never as two `&`. +_OPERATORS = ("||", "&&", "$(", "|", ";", "&", "`", "\n", "\r") + + +def _blind_segments(command: str) -> list[str]: + """The split that ignores quoting. Kept as the fallback -- see `_segments`.""" + return [s for s in re.split(r"\|\||&&|[|;&\r\n]|\$\(|`", command) if s.strip()] + + def _segments(command: str) -> list[str]: """Split on shell operators so `foo && ssh bar` is inspected as two commands. A newline is in the class because a newline *is* a command separator: without it `"true\\nssh gpu-box nvidia-smi"` was one segment whose head was `true`, and the cheapest possible bypass of the deny list was pressing Enter. + + **Quoting is respected, because an operator inside quotes is not one.** + `grep -n "a\\|b" file` is a single command, and splitting it blindly left a + tail of `b" file`; a pattern that happened to contain a denied word was + denied as though the user had typed it as a command. Read-only greps for the + deny list's own vocabulary are exactly what someone auditing this file + writes, so the false denial landed on the people best placed to hit it. + + **Command substitution is the exception, and stays live inside double + quotes**, where `"$(ssh box)"` really does start a new command -- which is + also the one place worth hiding one. Single quotes suppress it, as the shell + does. + + Unbalanced quotes fall back to the blind split: a string this cannot parse + is one it must not vouch for. Over-splitting only ever costs a false denial, + and that is the direction a deny list is allowed to be wrong in. """ - return [s for s in re.split(r"\|\||&&|[|;&\r\n]|\$\(|`", command) if s.strip()] + out: list[str] = [] + buf: list[str] = [] + quote: str | None = None + i, n = 0, len(command) + while i < n: + char = command[i] + # Outside single quotes a backslash escapes whatever follows, so an + # escaped operator is text and must not split. + if char == "\\" and quote != "'" and i + 1 < n: + buf.append(char) + buf.append(command[i + 1]) + i += 2 + continue + if quote is not None: + if char == quote: + quote = None + elif quote == '"' and (command.startswith("$(", i) or char == "`"): + out.append("".join(buf)) + buf = [] + i += 2 if char == "$" else 1 + continue + buf.append(char) + i += 1 + continue + if char in "'\"": + quote = char + buf.append(char) + i += 1 + continue + operator = next((op for op in _OPERATORS if command.startswith(op, i)), None) + if operator is not None: + out.append("".join(buf)) + buf = [] + i += len(operator) + continue + buf.append(char) + i += 1 + if quote is not None: + return _blind_segments(command) + out.append("".join(buf)) + return [s for s in out if s.strip()] def _head(segment: str) -> str: diff --git a/prompts/system.md b/prompts/system.md index bdd6ef9..0fe571a 100644 --- a/prompts/system.md +++ b/prompts/system.md @@ -154,8 +154,8 @@ you need a workflow. Don't guess flags. `submit` refuses without a passing preflight for the exact submission, without an open expectation, over either spend ceiling, or while a run is uncollected past its window. `ssh`, `scp`, `rsync`, `hf`, `huggingface-cli`, and `kaggle` are -denied directly — use `gpu.py`, `jobs.py`, and `kaggle.py`, which hold the -credentials. These are not obstacles to route around; +denied directly — use `gpu.py`, `jobs.py`, `kaggle.py`, and `modal.py`, which +hold the credentials. These are not obstacles to route around; they are the parts of the system that survive a deadline. `pip`, `pip3` and `conda` are denied too, and for a different reason: they diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1a0e30f..cb3d11f 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -106,3 +106,38 @@ def test_command_string_matching_is_not_the_security_model(): """Documented honestly: `ssh` reached through an interpreter is invisible here, which is why the credentials live in Credential Manager instead.""" assert evaluate_bash("python -c \"import subprocess; subprocess.run(['ssh','h','ls'])\"") is None + + +@pytest.mark.parametrize( + "command", + [ + r'grep -n "zzz\|pip install" file', + r'grep -rn "ssh\|scp" hooks.py', + "grep -n 'pip install|conda' notes.md", + 'echo "a | b"', + "echo 'ssh box'", + ], +) +def test_an_operator_inside_quotes_is_not_an_operator(command): + """`_segments` split on `|` without seeing quotes, so a grep for the deny + list's own vocabulary was denied as though it were the command it matched: + `grep -n "zzz\\|pip install" file` left a tail of `pip install" file` whose + head was `pip`. Anyone auditing this file writes exactly that grep.""" + assert evaluate_bash(command) is None + + +@pytest.mark.parametrize( + "command", + [ + 'echo "$(ssh gpu-box ls)"', + "echo `ssh gpu-box ls`", + 'echo "unbalanced | ssh gpu-box ls', + ], +) +def test_quote_awareness_still_fails_closed(command): + """The two things the quote-aware split deliberately does not do. Command + substitution stays live inside double quotes, which is where one would be + hidden; single quotes suppress it, as the shell does. An unbalanced quote + falls back to the blind split, because over-splitting costs a false denial + and that is the direction this list is allowed to be wrong in.""" + assert evaluate_bash(command) is not None diff --git a/tests/test_ui_tokens.py b/tests/test_ui_tokens.py index 7d8cdda..1bea096 100644 --- a/tests/test_ui_tokens.py +++ b/tests/test_ui_tokens.py @@ -244,6 +244,22 @@ def test_the_switch_is_one_attribute_and_ships_in_the_same_sheet(): assert sheet.count("--grad-handle:") == 1 +def test_every_palette_declares_the_scheme_the_browser_draws_in(): + """The scrollbars were the one part of the inversion no token could reach. + + Everything else here dresses something the app renders; a scrollbar is drawn + by the browser, which takes its colour from `color-scheme` alone -- so + without this the dark palette kept light scrollbars on every pane that + overflowed. It is emitted from the *colour* half on purpose: the non-colour + half ships once, with the light palette, and a declaration there would pin + every theme to `light` with no way to override it.""" + for name in tokens.PALETTES: + assert name in tokens.COLOR_SCHEME, name + assert f"color-scheme: {tokens.COLOR_SCHEME[name]};" in tokens.colour_variables(name) + # One per palette in the shipped sheet: `:root` and the dark override block. + assert tokens.stylesheet().count("color-scheme:") == len(tokens.PALETTES) + + def test_an_unknown_theme_falls_back_rather_than_failing(): """What a settings file written by a newer version looks like from an older one. The answer is the design's default, not a stylesheet that will not diff --git a/ui/render.py b/ui/render.py index 01dbb65..4efbc18 100644 --- a/ui/render.py +++ b/ui/render.py @@ -161,6 +161,11 @@ def _document(name: str, body: str, theme: str | None = None) -> str: return f"""