From 7f8b55d0366e11de91337e098d34e9d17a1df379 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 08:33:47 +0200 Subject: [PATCH] Add text parameter type; verify commands run in the owning repo root Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- docs/pack-spec.md | 8 +++++++- rtut/pack.py | 5 ++++- rtut/run.py | 2 +- schema/runbook-pack.schema.json | 2 +- tests/test_pack.py | 13 +++++++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/pack-spec.md b/docs/pack-spec.md index abb4938..a5017cf 100644 --- a/docs/pack-spec.md +++ b/docs/pack-spec.md @@ -26,7 +26,10 @@ under any root given on the command line. ## Parameters Each parameter has `id` (`[a-z][a-z0-9_]*`), `label`, `type` -(`string|enum|path|bool|int`), optional `values`, `default`, `pattern`, `help`. +(`string|text|enum|path|bool|int`), optional `values`, `default`, `pattern`, `help`. +`text` is single-line free text for use inside double quotes: spaces are allowed and +shell metacharacters (`; & | ` $ < > \ ' "`) are not. Every other type also rejects spaces. + **`secret` may only be `false`**: a parameter is never a secret. Steps refer to parameters as `{{id}}`. An undefined placeholder, an unused parameter, an `enum` without `values`, or a default that violates its `pattern`/`values` is a @@ -48,6 +51,9 @@ enabled for that step), `risk` (`read-only|changes-state|attended|destructive`), A step with `risk` of `changes-state`, `attended` or `destructive` must state a `rollback` (which may say "not reversible" and why). +Commands are relative to the root of the repository that owns the pack. Verify +commands run there, so a pack can call `make` targets and repo-relative scripts. + ## Exercise status Following the attended-procedure standard, a pack is `unexercised` until an diff --git a/rtut/pack.py b/rtut/pack.py index e382571..0f4a41f 100644 --- a/rtut/pack.py +++ b/rtut/pack.py @@ -120,7 +120,10 @@ def render(command: str, values: dict[str, str], pack: Pack) -> str: raise ValueError(f"parameter {name}: {val!r} not in {p['values']}") if p.get("pattern") and not re.fullmatch(p["pattern"], val): raise ValueError(f"parameter {name}: {val!r} violates pattern") - if re.search(r"[;&|`$<>\n\\'\"\s]", val) and p.get("type") != "path": + # "text" is single-line free text meant to sit inside double quotes: + # spaces are allowed, shell metacharacters are not. + bad = r"[;&|`$<>\n\\'\"]" if p.get("type") in ("text", "path") else r"[;&|`$<>\n\\'\"\s]" + if re.search(bad, val): raise ValueError(f"parameter {name}: unsafe characters") return val diff --git a/rtut/run.py b/rtut/run.py index 3c6a65b..fb9f821 100644 --- a/rtut/run.py +++ b/rtut/run.py @@ -154,7 +154,7 @@ def verify(run: Run, step_id: str, confirm: bool = False, manual_ok: bool | None t0 = time.monotonic() try: cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, - timeout=VERIFY_TIMEOUT) + timeout=VERIFY_TIMEOUT, cwd=repo_root(run.pack)) code, out = cp.returncode, cp.stdout + cp.stderr except subprocess.TimeoutExpired: code, out = -1, "" diff --git a/schema/runbook-pack.schema.json b/schema/runbook-pack.schema.json index c8c4e85..cada4c4 100644 --- a/schema/runbook-pack.schema.json +++ b/schema/runbook-pack.schema.json @@ -47,7 +47,7 @@ "properties": { "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, "label": {"type": "string"}, - "type": {"enum": ["string", "enum", "path", "bool", "int"]}, + "type": {"enum": ["string", "text", "enum", "path", "bool", "int"]}, "values": {"type": "array", "items": {"type": "string"}}, "default": {"type": ["string", "integer", "boolean"]}, "pattern": {"type": "string"}, diff --git a/tests/test_pack.py b/tests/test_pack.py index 9f9e383..3c4e86b 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -61,3 +61,16 @@ def test_render(): assert P.render(cmd, {"host": "example.org", "count": "2"}, pk) == "ping -c 2 example.org" with pytest.raises(ValueError): P.render(cmd, {"host": "a;rm -rf /"}, pk) + + +def test_text_parameter_allows_spaces_but_not_metacharacters(): + d = copy.deepcopy(GOOD) + d["parameters"].append({"id": "need", "label": "Need", "type": "text", "default": "read a password"}) + d["steps"][0]["command"] = 'echo "{{need}}" {{host}} {{count}}' + pk = P.Pack(Path("x"), d) + assert P.render(d["steps"][0]["command"], {"host": "h"}, pk) == 'echo "read a password" h 1' + with pytest.raises(ValueError): + P.render(d["steps"][0]["command"], {"host": "h", "need": 'x"; rm -rf /'}, pk) + with pytest.raises(ValueError): + P.render(d["steps"][0]["command"], {"host": "two words"}, pk) +