Add text parameter type; verify commands run in the owning repo root
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s
Work Records / validate (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 08:33:47 +02:00
parent f790c3c11b
commit 7f8b55d036
5 changed files with 26 additions and 4 deletions

View file

@ -26,7 +26,10 @@ under any root given on the command line.
## Parameters ## Parameters
Each parameter has `id` (`[a-z][a-z0-9_]*`), `label`, `type` 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 **`secret` may only be `false`**: a parameter is never a secret. Steps refer to
parameters as `{{id}}`. An undefined placeholder, an unused parameter, an parameters as `{{id}}`. An undefined placeholder, an unused parameter, an
`enum` without `values`, or a default that violates its `pattern`/`values` is a `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 A step with `risk` of `changes-state`, `attended` or `destructive` must state a
`rollback` (which may say "not reversible" and why). `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 ## Exercise status
Following the attended-procedure standard, a pack is `unexercised` until an Following the attended-procedure standard, a pack is `unexercised` until an

View file

@ -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']}") raise ValueError(f"parameter {name}: {val!r} not in {p['values']}")
if p.get("pattern") and not re.fullmatch(p["pattern"], val): if p.get("pattern") and not re.fullmatch(p["pattern"], val):
raise ValueError(f"parameter {name}: {val!r} violates pattern") 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") raise ValueError(f"parameter {name}: unsafe characters")
return val return val

View file

@ -154,7 +154,7 @@ def verify(run: Run, step_id: str, confirm: bool = False, manual_ok: bool | None
t0 = time.monotonic() t0 = time.monotonic()
try: try:
cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, 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 code, out = cp.returncode, cp.stdout + cp.stderr
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
code, out = -1, "" code, out = -1, ""

View file

@ -47,7 +47,7 @@
"properties": { "properties": {
"id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
"label": {"type": "string"}, "label": {"type": "string"},
"type": {"enum": ["string", "enum", "path", "bool", "int"]}, "type": {"enum": ["string", "text", "enum", "path", "bool", "int"]},
"values": {"type": "array", "items": {"type": "string"}}, "values": {"type": "array", "items": {"type": "string"}},
"default": {"type": ["string", "integer", "boolean"]}, "default": {"type": ["string", "integer", "boolean"]},
"pattern": {"type": "string"}, "pattern": {"type": "string"},

View file

@ -61,3 +61,16 @@ def test_render():
assert P.render(cmd, {"host": "example.org", "count": "2"}, pk) == "ping -c 2 example.org" assert P.render(cmd, {"host": "example.org", "count": "2"}, pk) == "ping -c 2 example.org"
with pytest.raises(ValueError): with pytest.raises(ValueError):
P.render(cmd, {"host": "a;rm -rf /"}, pk) 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)