From 5a41b9ba593d70e5051f332e793755fc61ef6583 Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:53:12 -0400 Subject: [PATCH 1/4] Require operator-owned secret placement in REST operations --- admin/README.md | 19 +++++-- admin/tests/test_tool_authoring.py | 33 ++++++++++++ admin/tool_authoring.py | 26 ++++++++-- admin/views/assets.py | 32 +++++++++++- docs/rest-forwarder.md | 48 ++++++++++++++--- toolstack_forwarder/config.py | 48 ++++++++++++++--- toolstack_forwarder/request_builder.py | 36 ++++++++++--- toolstack_forwarder/server.py | 13 +++-- toolstack_forwarder/tests/test_config.py | 30 +++++++++-- .../tests/test_request_builder.py | 52 +++++++++++++++++-- toolstack_forwarder/tests/test_server.py | 35 +++++++++++++ toolyard/openapi_import.py | 9 +++- toolyard/tests/test_openapi_import.py | 28 ++++++++++ 13 files changed, 361 insertions(+), 48 deletions(-) diff --git a/admin/README.md b/admin/README.md index 974d63f..d040927 100644 --- a/admin/README.md +++ b/admin/README.md @@ -123,10 +123,21 @@ neither process liveness nor health. `DELETE /api/tools/{id}` returns `deleted: true` for managed-folder deletion. Missing deletion metadata leaves the filesystem outcome unknown; other duplicate sources may still discover the tool. -OpenAPI `auth_headers` are caller-envelope hints, not stored automatic injection. -Preserve `allowed_headers` and secret declarations; callers supply -`{{secret:NAME}}` placeholders in request headers. Never enter actual secret values -in the operator UI. +OpenAPI imports store authentication in each operation's operator-owned `headers` +and `allowed_secrets`. The tool editor exposes those fields and an optional +`body_template` for a fully operator-owned text body. References such as +`{{secret:api_token}}` are permitted only in these templates and only for that +operation's declared secret allowlist. Never enter actual secret values in the UI. +Caller header values and bodies containing `{{secret:` are rejected with HTTP 400. +`allowed_headers` is only for caller-provided headers and must exclude static +header names. Caller bodies cannot override a body template. + +To migrate older tools, move placeholder-bearing headers/body into the operator +templates, fill `allowed_secrets`, remove the static names from `allowed_headers`, +and uncheck legacy caller substitution. Explicit `body_substitution = true` now +fails validation; the default is false. Update caller envelopes to omit the +operator-owned fields. See [REST forwarder](../docs/rest-forwarder.md). +Forwarder refresh operations return status only, without names or value lengths. At source 855da6e (broker a9319b9 / release059dd13), see [integrated security contracts](../docs/security-contracts.md) for disclosure and remaining gates. Optional operation approval_fields is preserved diff --git a/admin/tests/test_tool_authoring.py b/admin/tests/test_tool_authoring.py index f5f4688..e6866bf 100644 --- a/admin/tests/test_tool_authoring.py +++ b/admin/tests/test_tool_authoring.py @@ -42,6 +42,39 @@ } class Serialize(unittest.TestCase): + def test_operator_secret_templates_round_trip_through_editor_and_runtime(self): + from toolstack_forwarder.config import load_config + data = tool_authoring.normalize(REST_FULL) + op = data["operations"][1] + op.update(headers={"Authorization": "Bearer {{secret:auth_token}}"}, + allowed_secrets=["auth_token"], body_template='{"token":"{{secret:auth_token}}"}') + data = tool_authoring.normalize(data) + self.assertEqual(tool_authoring.validate(data), []) + self.assertNotIn("body", [a["name"] for a in data["operations"][1]["args"]]) + with tempfile.TemporaryDirectory() as directory: + path = tool_authoring.write(directory, data) + edited = tool_authoring.read(directory) + self.assertEqual(edited, data) + cfg = load_config(path) + self.assertEqual(cfg.operations["login"].headers, op["headers"]) + self.assertEqual(cfg.operations["login"].body_template, op["body_template"]) + self.assertFalse(cfg.operations["login"].body_substitution) + + def test_authoring_rejects_unsafe_template_and_legacy_fields(self): + for fields in ( + {"body_substitution": True}, + {"body_substitution": "false"}, + {"headers": {"Authorization": "{{secret:auth_token}}"}}, + {"allowed_secrets": ["undeclared"]}, + {"headers": []}, + {"headers": {"X-Trace": "fixed"}}, + {"body_template": "text on a bodyless operation"}, + ): + with self.subTest(fields=fields): + data = tool_authoring.normalize(REST_FULL) + data["operations"][0].update(fields) + self.assertTrue(tool_authoring.validate(tool_authoring.normalize(data))) + def test_to_toml_parses_back(self): parsed = tomllib.loads(tool_authoring.to_toml(tool_authoring.normalize(FULL))) self.assertEqual(parsed["id"], "weather") diff --git a/admin/tool_authoring.py b/admin/tool_authoring.py index d2f2395..8e7c261 100644 --- a/admin/tool_authoring.py +++ b/admin/tool_authoring.py @@ -18,6 +18,7 @@ from pathlib import Path from urllib.parse import urlsplit from broker.approval_summary import validate_fields +from toolstack_forwarder.config import ConfigError, validate_secret_placement from . import manifest_files # The risk taxonomy: read / write / destructive. (One vocabulary, no low/medium/high.) @@ -81,13 +82,16 @@ def s(x) -> str: "verb": verb, "path": path_template, "allowed_headers": allowed_headers, + "headers": o.get("headers", {}), + "allowed_secrets": o.get("allowed_secrets", []), + "body_template": o.get("body_template"), "body_kind": body_kind, "body_content_type": s(o.get("body_content_type")), - "body_substitution": bool(o.get("body_substitution", body_kind == "text")), + "body_substitution": o.get("body_substitution", False), "redact_response_body": o.get("redact_response_body"), "redact_response_headers": o.get("redact_response_headers"), "secret_update_rules": _norm_secret_update_rules(o.get("secret_update_rules")), - "args": _rest_envelope_args(path_template, allowed_headers, body_kind), + "args": _rest_envelope_args(path_template, allowed_headers, "none" if o.get("body_template") is not None else body_kind), }) continue args = [] @@ -278,6 +282,14 @@ def validate(data: dict) -> list[str]: errors.append(f"secret '{sec['name']}' path has invalid characters") if data["type"] == "rest": for o in data["operations"]: + if o.get("body_substitution", False) is not False: + errors.append("body_substitution is no longer supported; move secret references to operator headers or body_template and declare allowed_secrets") + try: + headers, _, _ = validate_secret_placement(o, {s["name"] for s in data["secrets"]}, o.get("body_kind")) + if {h.lower() for h in headers} & {h.lower() for h in o.get("allowed_headers", [])}: + errors.append("operator headers must not also appear in allowed_headers") + except ConfigError as exc: + errors.append(str(exc)) for flag in ("redact_response_body", "redact_response_headers"): if o.get("secret_update_rules") and not isinstance(o.get(flag), bool): errors.append(f"rest operation '{o['name']}' writeback needs explicit {flag} decision") @@ -390,8 +402,14 @@ def to_toml(data: dict) -> str: out.append(f"body_kind = {_s(o.get('body_kind', 'none'))}") if o.get("body_content_type"): out.append(f"body_content_type = {_s(o['body_content_type'])}") - if bool(o.get("body_substitution", o.get("body_kind") == "text")) != (o.get("body_kind") == "text"): - out.append(f"body_substitution = {str(bool(o.get('body_substitution'))).lower()}") + if o.get("headers"): + out.append("headers = { " + ", ".join(_s(k) + " = " + _s(v) for k, v in o["headers"].items()) + " }") + if o.get("allowed_secrets"): + out.append("allowed_secrets = [ " + ", ".join(_s(s) for s in o["allowed_secrets"]) + " ]") + if o.get("body_template") is not None: + out.append(f"body_template = {_s(o['body_template'])}") + if o.get("body_substitution"): + out.append("body_substitution = true") for flag in ("redact_response_body", "redact_response_headers"): if isinstance(o.get(flag), bool): out.append(f"{flag} = {str(o[flag]).lower()}") diff --git a/admin/views/assets.py b/admin/views/assets.py index 3c456eb..da22637 100644 --- a/admin/views/assets.py +++ b/admin/views/assets.py @@ -344,6 +344,15 @@ row.querySelector('.rm').onclick = function(){row.remove(); if(onChange)onChange();}; return row; } + function staticHeaderRow(name, value){ + var row = mk('
' + + '' + + '
'); + row.querySelector('.static-header-name').value = name||''; + row.querySelector('.static-header-value').value = value||''; + row.querySelector('.rm').onclick = function(){row.remove();}; + return row; + } function opCard(o, open){ o = o||{}; var card = mk('
' @@ -362,6 +371,13 @@ + '
Allowed headers' + '
' + '
' + + '
Operator-owned secret placement' + + '

Only these templates may reference secrets. Caller headers and bodies must contain no secret placeholders.

' + + '
' + + '' + + '' + + '' + + '
' + '
Response redaction' + '' + '
' @@ -375,7 +391,14 @@ card.querySelector('.op-path').value = o.path||''; card.querySelector('.op-body-kind').value = o.body_kind||'none'; card.querySelector('.op-body-content-type').value = o.body_content_type||''; - card._body_substitution = o.body_substitution; + card.querySelector('.op-legacy-substitution').checked = !!o.body_substitution; + card.querySelector('.op-allowed-secrets').value = (o.allowed_secrets||[]).join(', '); + card.querySelector('.op-template-enabled').checked = o.body_template != null; + card.querySelector('.op-body-template').value = o.body_template||''; + var staticHeaders = card.querySelector('.static-headers'); + Object.keys(o.headers||{}).forEach(function(name){staticHeaders.appendChild(staticHeaderRow(name, o.headers[name]));}); + card.querySelector('.add-static-header').onclick = function(){staticHeaders.appendChild(staticHeaderRow());}; + if (staticHeaders.children.length || o.body_template != null || o.body_substitution) staticHeaders.closest('details').open = true; // Nonvisual metadata: retain absence versus an explicit empty allowlist. if (Object.prototype.hasOwnProperty.call(o, 'approval_fields')) card._approval_fields = o.approval_fields; card.querySelector('.op-redact-body').checked = !!o.redact_response_body; @@ -444,7 +467,12 @@ allowed_headers: headers, body_kind: card.querySelector('.op-body-kind').value, body_content_type: card.querySelector('.op-body-content-type').value, - body_substitution: card._body_substitution, + body_substitution: card.querySelector('.op-legacy-substitution').checked, + headers: Object.fromEntries([].map.call(card.querySelectorAll('.static-headerrow'), function(r){ + return [r.querySelector('.static-header-name').value.trim(), r.querySelector('.static-header-value').value]; + }).filter(function(h){return h[0];})), + allowed_secrets: card.querySelector('.op-allowed-secrets').value.split(',').map(function(s){return s.trim();}).filter(Boolean), + body_template: card.querySelector('.op-template-enabled').checked ? card.querySelector('.op-body-template').value : null, redact_response_body: card.querySelector('.op-redact-body').checked, redact_response_headers: card.querySelector('.op-redact-headers').checked, secret_update_rules: rules, diff --git a/docs/rest-forwarder.md b/docs/rest-forwarder.md index 913f141..9de3f9d 100644 --- a/docs/rest-forwarder.md +++ b/docs/rest-forwarder.md @@ -46,13 +46,46 @@ pairs remain, the leading `?` is removed from the URL entirely before the outbound call. Static query text (pairs without a `{name}` placeholder) is always preserved. Path placeholders still raise `missing_variable` when absent. -`body_kind` is `none`, `text`, or `binary`. Text bodies are UTF-8 strings and may -substitute `{{secret:NAME}}`; binary bodies are base64 and force substitution off. +`body_kind` is `none`, `text`, or `binary`. Caller text bodies are UTF-8 strings; +binary bodies are base64. Caller header values and bodies (including decoded +binary bodies) containing `{{secret:` are rejected with HTTP 400 before sending. `TOOLSTACK_REST_BODY_MAX` caps outbound body bytes and upstream response bytes. +### Operator-owned secret placement + +Only per-operation templates in the manifest can insert secrets: + +```toml +# Inside [[operations]]: +headers = { Authorization = "Bearer {{secret:api_token}}" } +allowed_secrets = ["api_token"] +# Optional, for a fully operator-owned text body: +# body_kind = "text" +# body_template = '{"token":"{{secret:api_token}}"}' + +[[secrets]] +name = "api_token" +field = "API_TOKEN" +``` + +Every reference must name a tool secret listed in that operation's +`allowed_secrets` (default empty). Substitution is single-pass and does not +interpolate caller data into templates. Operators must choose an appropriate +encoding for the upstream body format. Static header names cannot also appear +in `allowed_headers`; callers cannot override them, including with different +casing. A `body_template` accepts no caller body. Keep secret-bearing responses +redacted where the upstream may reflect credentials. + +`body_substitution` now defaults to `false`; explicit `true` fails configuration +loading with migration instructions. For older manifests, move caller secret +references into `headers` or `body_template`, declare `allowed_secrets`, remove +static header names from `allowed_headers`, and remove `body_substitution = true`. +Remove those headers/body from caller envelopes and discovery arguments. Old +caller placeholders fail with a migration error, rather than being sent literally. + Discovery should describe the broker envelope: `variables` for path template values, `headers` for the allowlisted outbound headers, and `body` when -`body_kind` is not `none`. +`body_kind` is not `none` and there is no `body_template`. ## OpenAPI Import @@ -62,8 +95,9 @@ Toolyard can scaffold the manifest from an OpenAPI/Swagger JSON file: python3 -m toolyard.openapi_import openapi.json --id jira --port 4621 > tools/jira/toolyard.toml ``` -The importer generates named operations for path templates and header auth -scaffolding. The admin panel exposes the same parser and accepts YAML when its +The importer generates named operations with operator-owned auth `headers` and +`allowed_secrets`. Review the generated authentication bindings for each operation. +The admin panel exposes the same parser and accepts YAML when its optional PyYAML dependency is installed. ## Broker Wire @@ -73,7 +107,7 @@ The broker calls the forwarder: ```json { "op": "get_user", - "arguments": {"variables": {"user_id": "u42"}, "headers": {}, "body": "{}"}, + "arguments": {"variables": {"user_id": "u42"}, "headers": {}}, "broker_request_id": 123, "caller": {"name": "hermes"} } @@ -97,6 +131,8 @@ Failure: The forwarder never follows redirects, strips `Set-Cookie` and hop-by-hop headers from result envelopes, and returns upstream 3xx/4xx/5xx as data. +Local `refresh` and `refresh_one` return only `{"status":"ok"}` on success. +Refresh and missing-secret errors do not return secret names or value lengths. ## Secret Update Rules diff --git a/toolstack_forwarder/config.py b/toolstack_forwarder/config.py index 633a236..a6f65f5 100644 --- a/toolstack_forwarder/config.py +++ b/toolstack_forwarder/config.py @@ -10,7 +10,7 @@ import re import tomllib import urllib.parse -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -22,6 +22,7 @@ class ConfigError(ValueError): _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") _HEADER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*\Z") _SECRET_RE = re.compile(r"^[A-Za-z0-9_-]+\Z") +_SECRET_REF = re.compile(r"\{\{secret:([A-Za-z0-9_-]+)\}\}") _TEMPLATE_VAR = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") _PATH_PRINTABLE = frozenset(chr(c) for c in range(0x21, 0x7F)) _QUERY_PRINTABLE = _PATH_PRINTABLE | {" ", "'"} @@ -60,6 +61,9 @@ class Operation: redact_response_body: bool = False redact_response_headers: bool = False secret_update_rules: tuple[SecretUpdateRule, ...] = () + headers: dict[str, str] = field(default_factory=dict) + allowed_secrets: frozenset[str] = frozenset() + body_template: str | None = None @dataclass(frozen=True) @@ -169,15 +173,14 @@ def _load_operations(path: Path, raw: Any, secrets: dict[str, SecretDecl]) -> di elif not body_content_type: body_content_type = "application/json" if body_kind == "text" else "application/octet-stream" - body_substitution = item.get("body_substitution") - if body_substitution is None: - body_substitution = body_kind == "text" + body_substitution = item.get("body_substitution", False) if not isinstance(body_substitution, bool): raise _err(path, f"{field}.body_substitution", "must be a boolean") - if body_kind == "binary": - body_substitution = False - if body_kind == "none": - body_substitution = False + if body_substitution: + raise _err(path, field, "body_substitution is no longer supported; move secret references to operator headers or body_template and declare allowed_secrets") + headers, allowed_secrets, body_template = validate_secret_placement(item, set(secrets), body_kind) + if allowed & {h.lower() for h in headers}: + raise _err(path, field, "operator headers must not also appear in allowed_headers") redact_body = item.get("redact_response_body", False) if not isinstance(redact_body, bool): @@ -202,10 +205,39 @@ def _load_operations(path: Path, raw: Any, secrets: dict[str, SecretDecl]) -> di redact_response_body=redact_body, redact_response_headers=redact_headers, secret_update_rules=rules, + headers=headers, + allowed_secrets=allowed_secrets, + body_template=body_template, ) return operations +def validate_secret_placement(item: dict, declared: set[str], body_kind: str) -> tuple[dict[str, str], frozenset[str], str | None]: + """Validate operator-owned templates; shared with manifest authoring.""" + allowed = item.get("allowed_secrets", []) + if not isinstance(allowed, list) or not all(isinstance(s, str) and s in declared for s in allowed): + raise ConfigError("allowed_secrets must be a list of declared tool secret names") + headers = item.get("headers", {}) + if not isinstance(headers, dict): + raise ConfigError("headers must be an operator-owned table of strings") + seen = set() + for name, value in headers.items(): + if not isinstance(name, str) or not _HEADER_RE.fullmatch(name) or name.lower() in seen: + raise ConfigError("operator header names must be valid and case-insensitively unique") + seen.add(name.lower()) + if not isinstance(value, str) or any(ord(c) < 32 or ord(c) == 127 for c in value): + raise ConfigError("operator header values must be strings without control characters") + template = item.get("body_template") + if template is not None and (not isinstance(template, str) or body_kind != "text"): + raise ConfigError("body_template requires body_kind = text and a string template") + for text in [*headers.values(), *([template] if template is not None else [])]: + if "{{secret:" in _SECRET_REF.sub("", text): + raise ConfigError("operator template contains an invalid secret reference") + if not set(_SECRET_REF.findall(text)) <= set(allowed): + raise ConfigError("operator template references must be declared in allowed_secrets") + return dict(headers), frozenset(allowed), template + + def _load_rules(path: Path, op_field: str, raw: Any, writable: set[str]) -> tuple[SecretUpdateRule, ...]: if not isinstance(raw, list): raise _err(path, f"{op_field}.secret_update_rules", "must be a list") diff --git a/toolstack_forwarder/request_builder.py b/toolstack_forwarder/request_builder.py index 369c44c..597b085 100644 --- a/toolstack_forwarder/request_builder.py +++ b/toolstack_forwarder/request_builder.py @@ -67,8 +67,8 @@ def read_secret(secrets: SecretClient, name: str) -> str: return secrets.get(name) except KeyError: raise RequestBuildError( - "missing_secret", "configured secret is unavailable", secret=name - ) + "missing_secret", "configured secret is unavailable" + ) from None def _hydrate_path(op: Operation, arguments: dict) -> str: @@ -192,16 +192,29 @@ def _build_headers(op: Operation, arguments: dict, secrets: SecretClient) -> dic raise RequestBuildError("header_not_allowed", "header is not allowed for this operation", name=name) if not isinstance(value, str): raise RequestBuildError("invalid_header", "header value must be a string", name=name) - substituted = _substitute_secrets(value, secrets) - if any(ord(c) < 0x20 or ord(c) == 0x7f for c in substituted): + _refuse_caller_refs(value) + if name.lower() in {h.lower() for h in op.headers}: + raise RequestBuildError("header_not_allowed", "operator-owned headers cannot be overridden") + if any(ord(c) < 0x20 or ord(c) == 0x7f for c in value): raise RequestBuildError("invalid_header", "header value contains a control character", name=name) - headers[name] = substituted + headers[name] = value + for name, template in op.headers.items(): + value = _substitute_secrets(template, secrets, op.allowed_secrets) + if any(ord(c) < 0x20 or ord(c) == 0x7f for c in value): + raise RequestBuildError("invalid_header", "configured header contains a control character") + headers[name] = value return headers def _build_body(op: Operation, arguments: dict, secrets: SecretClient, max_body: int) -> bytes | None: present = "body" in arguments body = arguments.get("body") + if isinstance(body, str): + _refuse_caller_refs(body) + if op.body_template is not None: + if present: + raise RequestBuildError("body_not_allowed", "operator-owned body cannot be overridden") + body = _substitute_secrets(op.body_template, secrets, op.allowed_secrets) if op.body_kind == "none": if present: raise RequestBuildError("body_not_allowed", "this operation does not accept a body") @@ -209,21 +222,28 @@ def _build_body(op: Operation, arguments: dict, secrets: SecretClient, max_body: if not isinstance(body, str): raise RequestBuildError("missing_body", "this operation requires body as a string") if op.body_kind == "text": - if op.body_substitution: - body = _substitute_secrets(body, secrets) data = body.encode("utf-8") else: try: data = base64.b64decode(body, validate=True) except (binascii.Error, ValueError): raise RequestBuildError("invalid_body", "binary body must be base64") + _refuse_caller_refs(data) if len(data) > max_body: raise RequestBuildError("body_too_large", "body exceeds configured limit", limit_bytes=max_body) return data -def _substitute_secrets(text: str, secrets: SecretClient) -> str: +def _refuse_caller_refs(text: str | bytes) -> None: + marker = b"{{secret:" if isinstance(text, bytes) else "{{secret:" + if marker in text: + raise RequestBuildError("caller_secret_reference", "caller secret references are not supported; configure operator headers or body_template with allowed_secrets") + + +def _substitute_secrets(text: str, secrets: SecretClient, allowed: frozenset[str]) -> str: def fill(match: re.Match[str]) -> str: + if match.group(1) not in allowed: + raise RequestBuildError("secret_not_allowed", "configured secret is not allowed for this operation") return read_secret(secrets, match.group(1)) out = _SECRET_REF.sub(fill, text) diff --git a/toolstack_forwarder/server.py b/toolstack_forwarder/server.py index a6fb1de..2799075 100644 --- a/toolstack_forwarder/server.py +++ b/toolstack_forwarder/server.py @@ -73,11 +73,10 @@ def do_POST(self) -> None: # Secret-refresh ops are broker-injected for tools with [[secrets]] declared # (broker/registry.py). Handle them locally on the forwarder's SPS-backed # SecretClient -- no upstream call, no proxy needed. The broker treats these - # as risk="read" ops; we return only the refreshed name list (or length), never - # the value, so the secret never crosses the wire back to the broker. + # as risk="read" ops; return status only, without cache metadata. if op == "refresh": secrets.refresh_all() - return self._reply(200, {"status": "ok", "refreshed": list(secrets.names())}) + return self._reply(200, {"status": "ok"}) if op == "refresh_one": target = arguments.get("name") if isinstance(arguments, dict) else None if not isinstance(target, str) or not target: @@ -86,10 +85,10 @@ def do_POST(self) -> None: "detail": "refresh_one: 'name' argument is required", }) try: - new_value = secrets.refresh(target) - except KeyError as exc: - return self._reply(404, {"error": "unknown_secret", "detail": str(exc)}) - return self._reply(200, {"status": "ok", "refreshed": [target], "len": len(new_value)}) + secrets.refresh(target) + except KeyError: + return self._reply(404, {"error": "unknown_secret", "detail": "configured secret is unavailable"}) + return self._reply(200, {"status": "ok"}) try: req = build_request(config, op, arguments, secrets, max_body) except RequestBuildError as exc: diff --git a/toolstack_forwarder/tests/test_config.py b/toolstack_forwarder/tests/test_config.py index cea5764..4aaaaa5 100644 --- a/toolstack_forwarder/tests/test_config.py +++ b/toolstack_forwarder/tests/test_config.py @@ -89,9 +89,9 @@ def test_post_defaults_to_text_json_body(self): op = cfg.operations["login"] self.assertEqual(op.body_kind, "text") self.assertEqual(op.body_content_type, "application/json") - self.assertTrue(op.body_substitution) + self.assertFalse(op.body_substitution) - def test_binary_forces_body_substitution_off(self): + def test_binary_defaults_body_substitution_off(self): cfg = load_config(self._write(BASE + """ [[operations]] @@ -100,13 +100,37 @@ def test_binary_forces_body_substitution_off(self): verb = "POST" path = "/upload/{file_id}" body_kind = "binary" -body_substitution = true """)) op = cfg.operations["upload"] self.assertEqual(op.body_kind, "binary") self.assertEqual(op.body_content_type, "application/octet-stream") self.assertFalse(op.body_substitution) + def test_legacy_substitution_fails_with_migration_guidance(self): + for kind in ("none", "text", "binary"): + with self.subTest(kind=kind), self.assertRaisesRegex(ConfigError, "move secret references"): + load_config(self._write(BASE + f'body_kind = "{kind}"\nbody_substitution = true\n')) + + def test_operator_templates_require_per_operation_declared_secrets(self): + declarations = '\n[[secrets]]\nname="token"\nfield="TOKEN"\n[[secrets]]\nname="admin_token"\nfield="ADMIN_TOKEN"\n' + good = 'headers = { Authorization = "Bearer {{secret:token}}" }\nallowed_secrets = ["token"]\n' + cfg = load_config(self._write(BASE + good + declarations)) + self.assertEqual(cfg.operations["get_user"].allowed_secrets, frozenset({"token"})) + for bad in ( + good.replace('["token"]', '[]'), + good.replace('["token"]', '["other"]'), + good.replace('{{secret:token}}', '{{secret:admin_token}}'), + good.replace('{{secret:token}}', '{{secret:token'), + good.replace('Authorization', 'x-trace'), + 'headers = { Auth = "a", auth = "b" }\n', + 'headers = { Auth = 3 }\n', + 'headers = []\n', + 'body_template = "no body permitted"\n', + 'allowed_secrets = "token"\n', + ): + with self.subTest(bad=bad), self.assertRaises(ConfigError): + load_config(self._write(BASE + bad + declarations)) + def test_rejects_credentialed_base_url(self): with self.assertRaises(ConfigError) as cm: load_config(self._write(BASE.replace("https://api.example.test/v1", "https://u:p@example.test"))) diff --git a/toolstack_forwarder/tests/test_request_builder.py b/toolstack_forwarder/tests/test_request_builder.py index 94f0f76..d267524 100644 --- a/toolstack_forwarder/tests/test_request_builder.py +++ b/toolstack_forwarder/tests/test_request_builder.py @@ -2,6 +2,7 @@ import shutil import tempfile import unittest +from dataclasses import replace from pathlib import Path from toolstack_forwarder.config import load_config @@ -45,7 +46,8 @@ def get(self, name): risk = "write" verb = "POST" path = "/tenants/{tenant}/login" -allowed_headers = ["Authorization"] +headers = { Authorization = "Bearer {{secret:auth_token}}" } +allowed_secrets = ["auth_token"] body_kind = "text" body_content_type = "application/json" @@ -198,12 +200,11 @@ def test_rejects_header_outside_allowlist(self): self.assertEqual(cm.exception.code, "header_not_allowed") def test_substitutes_secrets_in_headers_and_text_body(self): + self.cfg.operations["login"] = replace(self.cfg.operations["login"], body_template='{"token":"{{secret:auth_token}}"}') req = self.build( "login", { "variables": {"tenant": "acme"}, - "headers": {"Authorization": "Bearer {{secret:auth_token}}"}, - "body": '{"token":"{{secret:auth_token}}"}', }, ) self.assertEqual(req.headers["Authorization"], "Bearer sekret") @@ -218,12 +219,55 @@ def test_rejects_header_control_character_after_substitution(self): "login", { "variables": {"tenant": "acme"}, - "headers": {"Authorization": "Bearer {{secret:auth_token}}"}, "body": "{}", }, ) self.assertEqual(cm.exception.code, "invalid_header") + def test_caller_placeholders_are_refused_even_when_malformed_or_binary(self): + for marker in ("{{secret:auth_token}}", "{{secret:other}}", "{{secret:"): + cases = [ + ("get_user", {"variables": {"user_id": "u"}, "headers": {"X-Trace": marker}}), + ("login", {"variables": {"tenant": "a"}, "body": marker}), + ("upload", {"variables": {"file_id": "a"}, "body": base64.b64encode(marker.encode()).decode()}), + ] + for op, args in cases: + with self.subTest(op=op, marker=marker), self.assertRaises(RequestBuildError) as cm: + self.build(op, args) + self.assertEqual(cm.exception.code, "caller_secret_reference") + self.assertIn("configure operator", cm.exception.detail) + + def test_caller_cannot_override_operator_header_or_body(self): + for name in ("Authorization", "authorization", "AUTHORIZATION"): + with self.subTest(name=name), self.assertRaises(RequestBuildError): + self.build("login", {"variables": {"tenant": "a"}, "headers": {name: "replacement"}, "body": "{}"}) + self.cfg.operations["login"] = replace(self.cfg.operations["login"], body_template="fixed") + with self.assertRaisesRegex(RequestBuildError, "operator-owned body"): + self.build("login", {"variables": {"tenant": "a"}, "body": "replacement"}) + + def test_runtime_allowlist_and_missing_secret_do_not_disclose_names(self): + op = self.cfg.operations["login"] + self.cfg.operations["login"] = replace(op, allowed_secrets=frozenset()) + with self.assertRaises(RequestBuildError) as cm: + self.build("login", {"variables": {"tenant": "a"}, "body": "{}"}) + self.assertEqual(cm.exception.code, "secret_not_allowed") + self.cfg.operations["login"] = op + self.secrets._t.clear() + with self.assertRaises(RequestBuildError) as cm: + self.build("login", {"variables": {"tenant": "a"}, "body": "{}"}) + self.assertEqual(cm.exception.envelope(), {"error": "missing_secret", "detail": "configured secret is unavailable"}) + + def test_template_substitution_is_not_recursive_and_body_is_bounded(self): + self.secrets._t["auth_token"] = "{{secret:other}}" + self.secrets._t["other"] = "never-rendered" + with self.assertRaises(RequestBuildError): + self.build("login", {"variables": {"tenant": "a"}, "body": "{}"}) + self.secrets._t["auth_token"] = "long-token" + self.cfg.operations["login"] = replace(self.cfg.operations["login"], body_template="{{secret:auth_token}}") + with self.assertRaises(RequestBuildError) as cm: + self.build("login", {"variables": {"tenant": "a"}}, max_body=3) + self.assertEqual(cm.exception.code, "body_too_large") + def test_binary_body_is_base64_decoded_and_substitution_is_off(self): req = self.build("upload", {"variables": {"file_id": "f1"}, "body": base64.b64encode(b"\x00abc").decode()}) self.assertEqual(req.headers["Content-Type"], "application/octet-stream") diff --git a/toolstack_forwarder/tests/test_server.py b/toolstack_forwarder/tests/test_server.py index 37dd1da..9dec088 100644 --- a/toolstack_forwarder/tests/test_server.py +++ b/toolstack_forwarder/tests/test_server.py @@ -187,6 +187,41 @@ def test_broker_secret_mismatch_is_401_json_when_configured(self): self.assertEqual(status, 401) self.assertEqual(body["error"], "channel_secret_mismatch") + def test_caller_secret_references_fail_before_outbound_send(self): + cfg = self._config(''' +[[operations]] +name = "create" +verb = "POST" +path = "/create" +''') + url = self._forwarder(cfg) + for payload in ( + {"op": "get_item", "arguments": {"variables": {"item_id": "i"}, "headers": {"X-Trace": "{{secret:other}}"}}}, + {"op": "create", "arguments": {"body": "{{secret:other}}"}}, + ): + with self.subTest(payload=payload), mock.patch("toolstack_forwarder.server.outbound.send") as dispatch: + status, body = self._post(url, payload) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "caller_secret_reference") + dispatch.assert_not_called() + self.assertEqual(self.upstream.seen, []) + + def test_refresh_returns_status_without_cache_metadata(self): + secrets = _fake_secrets({"private_name": "fake-value"}) + secrets.refresh_all = mock.Mock() + secrets.refresh = mock.Mock(wraps=secrets.refresh) + url = self._forwarder(secrets=secrets) + with mock.patch("toolstack_forwarder.server.outbound.send") as dispatch: + for op in ("refresh", "refresh_one"): + status, body = self._post(url, {"op": op, "arguments": {"name": "private_name"}}) + self.assertEqual((status, body), (200, {"status": "ok"})) + status, body = self._post(url, {"op": "refresh_one", "arguments": {"name": "missing_name"}}) + self.assertEqual(status, 404) + self.assertEqual(body, {"error": "unknown_secret", "detail": "configured secret is unavailable"}) + dispatch.assert_not_called() + secrets.refresh_all.assert_called_once_with() + self.assertEqual(secrets.refresh.call_args_list, [mock.call("private_name"), mock.call("missing_name")]) + def test_missing_channel_env_refuses_requests(self): # No TOOLSTACK_E_SECRET -> channel auth off; any header value passes. os.environ.pop("TOOLSTACK_CHANNEL_SECRET") diff --git a/toolyard/openapi_import.py b/toolyard/openapi_import.py index 2fd11b1..63888f8 100644 --- a/toolyard/openapi_import.py +++ b/toolyard/openapi_import.py @@ -163,8 +163,8 @@ def _rest_args(path: str, allowed_headers: list[str], body_kind: str) -> list[di def build_operations(spec: dict) -> list[dict]: - header_templates, _ = _auth(spec) - allowed_headers = [h["name"] for h in header_templates] + header_templates, secrets = _auth(spec) + allowed_headers = [] used: set = set() ops = [] for path in sorted((spec.get("paths") or {}).keys()): @@ -184,6 +184,8 @@ def build_operations(spec: dict) -> list[dict]: "risk": _VERB_RISK[verb], "description": str(operation.get("summary") or operation.get("description") or "")[:200], "allowed_headers": allowed_headers, + "headers": {h["name"]: h["value"] for h in header_templates}, + "allowed_secrets": [s["name"] for s in secrets], "body_kind": body_kind, "body_content_type": body_content_type, "args": _rest_args(path, allowed_headers, body_kind), @@ -235,6 +237,9 @@ def build_toolyard_toml(spec: dict, *, tool_id: str, port: int) -> str: lines.append("args = [ " + ", ".join(_inline(a) for a in op["args"]) + " ]") if op["allowed_headers"]: lines.append("allowed_headers = [ " + ", ".join(_q(h) for h in op["allowed_headers"]) + " ]") + if op["headers"]: + lines.append("headers = { " + ", ".join(_q(k) + " = " + _q(v) for k, v in op["headers"].items()) + " }") + lines.append("allowed_secrets = [ " + ", ".join(_q(s) for s in op["allowed_secrets"]) + " ]") lines.append(f"body_kind = {_q(op['body_kind'])}") if op["body_content_type"]: lines.append(f"body_content_type = {_q(op['body_content_type'])}") diff --git a/toolyard/tests/test_openapi_import.py b/toolyard/tests/test_openapi_import.py index 7c53708..3458736 100644 --- a/toolyard/tests/test_openapi_import.py +++ b/toolyard/tests/test_openapi_import.py @@ -82,6 +82,34 @@ def test_generated_toml_uses_forwarder_contract(self): self.assertNotIn("proxy", parsed) self.assertEqual(parsed["entrypoint"]["command"], "python3 -m toolstack_forwarder") self.assertEqual(parsed["secrets"][0]["name"], "api_token") + for op in parsed["operations"]: + self.assertEqual(op["headers"], {"Authorization": "Bearer {{secret:api_token}}"}) + self.assertEqual(op["allowed_secrets"], ["api_token"]) + self.assertNotIn("Authorization", op.get("allowed_headers", [])) + self.assertNotIn("headers", [a["name"] for a in op.get("args", [])]) + + def test_imported_auth_builds_without_caller_header_hints(self): + from toolstack_forwarder.request_builder import build_request + from unittest.mock import Mock + _, tool_dir = self._write_generated() + cfg = load_rest_config(tool_dir / "toolyard.toml") + secrets = Mock() + secrets.get.return_value = "fake-token" + req = build_request(cfg, "listMessages", {}, secrets, 1024) + self.assertEqual(req.headers, {"Authorization": "Bearer fake-token"}) + secrets.get.assert_called_once_with("api_token") + + def test_header_auth_kinds_are_stored_in_each_operation(self): + for scheme, header, secret in ( + ({"type": "oauth2"}, "Authorization", "access_token"), + ({"type": "apiKey", "in": "header", "name": "X-Api-Key"}, "X-Api-Key", "api_key"), + ): + with self.subTest(scheme=scheme): + spec = {**SPEC, "components": {"securitySchemes": {"auth": scheme}}} + parsed = tomllib.loads(openapi_import.build_toolyard_toml(spec, tool_id="demo", port=4640)) + for op in parsed["operations"]: + self.assertIn(header, op["headers"]) + self.assertEqual(op["allowed_secrets"], [secret]) def test_generated_tool_loads_in_registry_and_forwarder(self): _, tool_dir = self._write_generated() From dec6303d36cb1a1267e096c9cd21056a7b359665 Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:43:20 -0400 Subject: [PATCH 2/4] Record approved tool declarations and narrow SPS configuration reads --- admin/api.py | 14 ++- admin/reconciler.py | 2 +- admin/server.py | 18 ++-- admin/sps_link.py | 7 +- admin/tests/test_admin_controls.py | 7 +- admin/tests/test_api.py | 1 + admin/tests/test_declaration_approval.py | 106 +++++++++++++++++++++ admin/tests/test_reconciler.py | 4 +- admin/tests/test_sps_link.py | 6 +- admin/tool_authoring.py | 8 +- admin/tool_sources.py | 33 ++++--- admin/toolyard_ops.py | 31 +++++- admin/views/tools.py | 17 +++- docs/secret-declarations.md | 31 ++++++ toolyard/config.py | 6 +- toolyard/runner.py | 40 ++++---- toolyard/sps_config.py | 95 ++++++++++++++++++ toolyard/tests/test_partial_cleanup.py | 2 +- toolyard/tests/test_registration_config.py | 57 +++++++++++ toolyard/tests/test_runner.py | 3 +- toolyard/tests/test_uplift.py | 7 +- 21 files changed, 415 insertions(+), 80 deletions(-) create mode 100644 admin/tests/test_declaration_approval.py create mode 100644 docs/secret-declarations.md create mode 100644 toolyard/sps_config.py create mode 100644 toolyard/tests/test_registration_config.py diff --git a/admin/api.py b/admin/api.py index 3d05268..9c3fd29 100644 --- a/admin/api.py +++ b/admin/api.py @@ -295,12 +295,15 @@ async def api_add_tool(request: Request, user: str = Depends(require_user)): if repo: tool = await work.run(tool_sources.add_from_github, repo, config.tools_root, existing, - subdir=data.get("subdir", ""), ref=data.get("ref", "")) + subdir=data.get("subdir", ""), ref=data.get("ref", ""), + confirm_secret_items=data.get("confirm_secret_items") is True) elif manifest is not None: # author-from-code-only: copy the folder + write the supplied manifest (no toolyard.toml needed) - tool = await work.run(tool_sources.add_with_manifest, source, config.tools_root, existing, manifest) + tool = await work.run(tool_sources.add_with_manifest, source, config.tools_root, existing, manifest, + confirm_secret_items=data.get("confirm_secret_items") is True) else: - tool = await work.run(tool_sources.add_from_path, source, config.tools_root, existing) + tool = await work.run(tool_sources.add_from_path, source, config.tools_root, existing, + confirm_secret_items=data.get("confirm_secret_items") is True) except tool_sources.NoManifest: raise HTTPException(status_code=422, detail="no toolyard.toml at that location; author one to add it") except ValueError as exc: @@ -354,11 +357,12 @@ async def api_edit_tool(tool_id: str, request: Request, user: str = Depends(requ raise HTTPException(status_code=400, detail="secrets must be a list") tool["secrets"] = data["secrets"] # replace: sending `secrets` overwrites all declarations; omit to keep tool = tool_authoring.normalize(tool) - errors = tool_authoring.validate(tool) + errors = tool_authoring.validate(tool, confirm_secret_items=data.get("confirm_secret_items") is True) if errors: raise HTTPException(status_code=400, detail="; ".join(errors)) try: - tool_authoring.write(dir_path, tool) + tool_authoring.write(dir_path, tool, confirm_secret_items=data.get("confirm_secret_items") is True) + toolyard_ops.record_edited_declarations(tool) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) with open_store(config) as store: diff --git a/admin/reconciler.py b/admin/reconciler.py index 1078211..f045dcd 100644 --- a/admin/reconciler.py +++ b/admin/reconciler.py @@ -58,7 +58,7 @@ def reconcile_running_tools(config, link, *, wait_timeout: float = 60.0) -> int: continue try: toolyard_ops.start(tool_id, config.tools_root, config.tool_dirs, - backend=running.backend) + backend=running.backend, recovery=True) started += 1 log.info("reconcile: restarted %s", tool_id) except LookupError: diff --git a/admin/server.py b/admin/server.py index 4287e30..442a8d2 100644 --- a/admin/server.py +++ b/admin/server.py @@ -608,7 +608,7 @@ async def create_tool(request: Request): return HTMLResponse(views.tool_editor_view( user=user, csrf=csrf_for(request), backend=settings.sps_info(), mode="new", tool=tool, dir_value=dir_path, error="Invalid CSRF token.")) - errors = list(tool_authoring.validate(tool)) + errors = list(tool_authoring.validate(tool, confirm_secret_items=data.get("confirm_secret_items") == "1")) if not os.path.isabs(dir_path): errors.append("tool directory must be an absolute path") elif not Path(dir_path).is_dir(): @@ -622,7 +622,7 @@ async def create_tool(request: Request): return HTMLResponse(views.tool_editor_view( user=user, csrf=csrf_for(request), backend=settings.sps_info(), mode="new", tool=tool, dir_value=dir_path, error="; ".join(errors))) - tool_authoring.write(dir_path, tool) + tool_authoring.write(dir_path, tool, confirm_secret_items=data.get("confirm_secret_items") == "1") norm_dir = str(Path(dir_path)) # normalize so the later removable check matches if norm_dir not in config.tool_dirs: previous = replace(config) @@ -675,12 +675,13 @@ async def save_tool(request: Request, tool_id: str): return HTMLResponse(views.tool_editor_view( user=user, csrf=csrf_for(request), backend=settings.sps_info(), mode="edit", tool=tool, dir_value=dir_path, error="Invalid CSRF token.")) - errors = tool_authoring.validate(tool) + errors = tool_authoring.validate(tool, confirm_secret_items=data.get("confirm_secret_items") == "1") if errors: return HTMLResponse(views.tool_editor_view( user=user, csrf=csrf_for(request), backend=settings.sps_info(), mode="edit", tool=tool, dir_value=dir_path, error="; ".join(errors))) - tool_authoring.write(dir_path, tool) + tool_authoring.write(dir_path, tool, confirm_secret_items=data.get("confirm_secret_items") == "1") + toolyard_ops.record_edited_declarations(tool) with open_store(config) as store: operations.record_admin_event(store, user, "tool_edited", {"tool": tool["id"], "dir": dir_path}) return render_dashboard(request, user, banner=( @@ -735,9 +736,11 @@ def again(error: str) -> HTMLResponse: if data.get("kind") == "github": tool = await work.run(tool_sources.add_from_github, repo_val, config.tools_root, existing_ids, - subdir=(data.get("subdir") or "").strip(), ref=(data.get("ref") or "").strip()) + subdir=(data.get("subdir") or "").strip(), ref=(data.get("ref") or "").strip(), + confirm_secret_items=data.get("confirm_secret_items") == "1") else: - tool = await work.run(tool_sources.add_from_path, src_val, config.tools_root, existing_ids) + tool = await work.run(tool_sources.add_from_path, src_val, config.tools_root, existing_ids, + confirm_secret_items=data.get("confirm_secret_items") == "1") except tool_sources.NoManifest: return again("No toolyard.toml at that location; use “Author a tool” to create one.") except ValueError as exc: @@ -775,7 +778,8 @@ async def update_tool_source(request: Request, tool_id: str): operations.record_admin_event(store, user, "tool_updated", {"tool": tool_id, "dir": tool["path"]}) return render_dashboard(request, user, banner=( f"Updated tool {views.esc(tool_id)} from its source. Restart the broker if its entrypoint " - "or operations changed.")) + "or operations changed." + (" Upstream secret declarations differ; local declarations were kept. Review them in Edit." + if tool.get("secret_declarations_changed") else ""))) # JSON operator API (bearer-token auth) for native / automation clients: same ops, # JSON face. See admin/api.py (T-029). diff --git a/admin/sps_link.py b/admin/sps_link.py index 297ddc6..9d24206 100644 --- a/admin/sps_link.py +++ b/admin/sps_link.py @@ -20,18 +20,13 @@ import time from sps.client import SPSError, SPSClient -from sps.config import load_config, parse_bool +from toolyard.sps_config import load_registration_config as load_config, parse_verify log = logging.getLogger(__name__) _DEFAULT_SPS_ENV = "/etc/toolstack/sps.env" -def parse_verify(value: str) -> bool: - """Use the integrated shared TLS verification contract.""" - return parse_bool(value, name="TOOLSTACK_SPS_VERIFY") - - class SPSLink: def __init__(self, host: str, port: int, ca_file: str | None, verify: bool = True) -> None: diff --git a/admin/tests/test_admin_controls.py b/admin/tests/test_admin_controls.py index 68cc190..1f2393a 100644 --- a/admin/tests/test_admin_controls.py +++ b/admin/tests/test_admin_controls.py @@ -45,7 +45,10 @@ def test_strict_boolean(self): for text in ("1", "true", "yes", "on", " TRUE "): self.assertIs(parse_verify(text), True) for text in ("0", "false", "no", "off"): - self.assertIs(parse_verify(text), False) + with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_DEV": "1"}): + self.assertIs(parse_verify(text), False) + with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_DEV": "0"}), self.assertRaises(ValueError): + parse_verify(text) for text in ("", "typo", "2"): with self.assertRaises(ValueError): parse_verify(text) @@ -125,7 +128,7 @@ def test_missing_runner_lock_fails_closed(self): class NativeLiveness(unittest.TestCase): def test_recorded_state_is_not_health_evidence(self): fixture = json.loads((Path(__file__).parent / "fixtures/native_api.json").read_text()) - definition = SimpleNamespace(id="fake", type="api", port=9999, path=Path("/fake")) + definition = SimpleNamespace(id="fake", type="api", port=9999, path=Path("/fake"), secrets=[]) running = RunningTool("fake", 9999, "process", "fake") for expected in fixture["tool_states"]: record = {"fake": asdict(running)} if expected["running"] else {} diff --git a/admin/tests/test_api.py b/admin/tests/test_api.py index 321a4db..9aa18d6 100644 --- a/admin/tests/test_api.py +++ b/admin/tests/test_api.py @@ -289,6 +289,7 @@ def test_clear_audit_removes_events(self): def test_edit_tool_updates_description_and_secrets(self): r = self.client.post("/api/tools/echo", headers=self._auth(), json={ "description": "now with feeling", + "confirm_secret_items": True, "secrets": [{"name": "api_key", "field": "NEW_KEY", "writable": False}, {"name": "token", "field": "TOKEN", "writable": True, "item": "oauth"}]}) self.assertEqual(r.status_code, 200, r.text) diff --git a/admin/tests/test_declaration_approval.py b/admin/tests/test_declaration_approval.py new file mode 100644 index 0000000..b2e59d7 --- /dev/null +++ b/admin/tests/test_declaration_approval.py @@ -0,0 +1,106 @@ +import os +from dataclasses import asdict +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +from admin import tool_authoring, tool_sources, toolyard_ops +from admin.views.tools import tools_view +from toolyard import cli, config, runner + + +MANIFEST = ('id="demo"\ntype="api"\n[entrypoint]\ncommand="python3 app.py"\nport=4701\n' + '[[operations]]\nname="read"\nrisk="read"\n') +SECRET = '\n[[secrets]]\nname="token"\nfield="TOKEN"\nitem="other"\n' + + +class DeclarationApproval(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + env = patch.dict(os.environ, {"XDG_STATE_HOME": str(self.root / "state")}) + env.start() + self.addCleanup(env.stop) + self.source = self.root / "source" + self.source.mkdir() + self.manifest = self.source / "toolyard.toml" + self.manifest.write_text(MANIFEST) + (self.source / "app.py").write_text("# fixture\n") + + def test_update_keeps_empty_local_declarations_and_returns_difference(self): + imported = tool_sources.add_from_path(str(self.source), str(self.root / "tools")) + self.manifest.write_text(MANIFEST + SECRET) + result = tool_sources.update(imported["path"]) + self.assertTrue(result["secret_declarations_changed"]) + self.assertEqual(result["secret_declarations"]["kept"], []) + self.assertEqual(result["secret_declarations"]["upstream"][0]["item"], "other") + self.assertEqual(config.load(Path(imported["path"]) / "toolyard.toml").secrets, ()) + + def test_import_and_authoring_require_out_of_band_confirmation(self): + self.manifest.write_text(MANIFEST + SECRET + '\nconfirm_secret_items=true\n') + data = tool_authoring.read(self.source) + self.assertTrue(tool_authoring.validate(data)) + self.assertEqual(tool_authoring.validate(data, confirm_secret_items=True), []) + with self.assertRaisesRegex(ValueError, "explicitly confirm"): + tool_sources.add_from_path(str(self.source), str(self.root / "tools")) + tool_sources.add_from_path(str(self.source), str(self.root / "tools"), confirm_secret_items=True) + + def _record(self): + definition = config.load(self.manifest) + record = runner.RunningTool("demo", 4701, "process", "123", e_secret="fixture", + secret_declarations=[], manifest_digest=definition.manifest_digest) + cli._save_state({"demo": asdict(record)}) + return record + + def test_watchdog_uses_recorded_declarations_after_manifest_changes(self): + self._record() + self.manifest.write_text(MANIFEST + SECRET) + fake_runner = Mock() + fake_runner.is_alive.return_value = True + with patch.object(runner, "get_runner", return_value=fake_runner), patch.object(runner, "_sps_register") as register: + result = runner._reregister_results(tools_root="", tool_dirs=[str(self.source)], sps_env_path=str(self.manifest)) + self.assertEqual(result, {"demo": None}) + self.assertEqual(register.call_args.args[0].secrets, ()) + + def test_legacy_record_requires_explicit_start(self): + cli._save_state({"demo": asdict(runner.RunningTool("demo", 4701, "process", "123", e_secret="fixture"))}) + fake_runner = Mock() + fake_runner.is_alive.return_value = True + with patch.object(runner, "get_runner", return_value=fake_runner), patch.object(runner, "_sps_register") as register: + result = runner._reregister_results(tools_root="", tool_dirs=[str(self.source)], sps_env_path=str(self.manifest)) + self.assertIsNotNone(result["demo"]) + register.assert_not_called() + + def test_reconcile_keeps_snapshot_and_explicit_start_adopts_disk(self): + record = self._record() + self.manifest.write_text(MANIFEST + SECRET) + fake_runner = Mock() + fake_runner.is_alive.return_value = False + fake_runner.start.return_value = record + with patch.object(toolyard_ops, "get_runner", return_value=fake_runner): + toolyard_ops.start("demo", "", [str(self.source)], recovery=True) + self.assertEqual(fake_runner.start.call_args.args[0].secrets, ()) + self.assertEqual(fake_runner.start.call_args.args[0].manifest_digest, record.manifest_digest) + toolyard_ops.start("demo", "", [str(self.source)]) + self.assertEqual(fake_runner.start.call_args.args[0].secrets[0].item, "other") + + def test_explicit_edit_records_new_digest_and_declarations(self): + record = self._record() + self.manifest.write_text(MANIFEST + SECRET) + approved = tool_authoring.read(self.source) + self.manifest.write_text(MANIFEST) + toolyard_ops.record_edited_declarations(approved) + updated = cli._load_state()["demo"] + self.assertNotEqual(updated["manifest_digest"], record.manifest_digest) + self.assertEqual(updated["secret_declarations"][0]["item"], "other") + + def test_tools_page_displays_declarations_and_start_warning(self): + self.manifest.write_text(MANIFEST + SECRET) + tools = toolyard_ops.list_tools("", [str(self.source)]) + html = tools_view(user="operator", csrf="fixture", tools=tools, tools_root="") + self.assertIn("token (item: other)", html) + self.assertIn("host's credentials", html) + self.assertIn("Only start tools you trust", html) + self.assertIn("cancel.autofocus=true", html) diff --git a/admin/tests/test_reconciler.py b/admin/tests/test_reconciler.py index 3082c7e..8b09ce6 100644 --- a/admin/tests/test_reconciler.py +++ b/admin/tests/test_reconciler.py @@ -46,7 +46,7 @@ def test_waits_for_sps_then_starts_dead_tools(self): get_runner.return_value.is_alive.return_value = False n = reconciler.reconcile_running_tools(_Cfg(), link, wait_timeout=1.0) self.assertEqual(n, 1) - start.assert_called_once_with("echo", "/tools", [], backend="process") + start.assert_called_once_with("echo", "/tools", [], backend="process", recovery=True) def test_skips_alive_tools(self): _save_state({"echo": _record()}) @@ -86,7 +86,7 @@ def test_none_link_starts_dead_tools(self): get_runner.return_value.is_alive.return_value = False n = reconciler.reconcile_running_tools(_Cfg(), None, wait_timeout=1.0) self.assertEqual(n, 1) - start.assert_called_once_with("echo", "/tools", [], backend="process") + start.assert_called_once_with("echo", "/tools", [], backend="process", recovery=True) def test_gate_exception_is_contained(self): _save_state({"echo": _record()}) diff --git a/admin/tests/test_sps_link.py b/admin/tests/test_sps_link.py index 7f7a900..42e6fc9 100644 --- a/admin/tests/test_sps_link.py +++ b/admin/tests/test_sps_link.py @@ -52,18 +52,18 @@ def test_from_env_bad_port_returns_none(self): with mock.patch.dict(os.environ, {"TOOLSTACK_SPS_PORT": "not-a-port"}): self.assertIsNone(SPSLink.from_env(str(env))) - def test_from_env_missing_plugin_block_key_returns_none(self): + def test_from_env_ignores_provider_configuration(self): tmp = Path(tempfile.mkdtemp(prefix="spslink-")) self.addCleanup(__import__("shutil").rmtree, tmp, ignore_errors=True) env = tmp / "sps.env" env.write_text( 'SP_HOST = "127.0.0.1"\nSP_PORT = "8743"\nSP_SECRET = "s"\n' 'SP_TLS_CA = "/tmp/ca.crt"\nSP_PLUGIN = "localfile"\n' - '[localfile]\n', # missing VAULT_FILE -> KeyError inside load_config + '[localfile]\n', encoding="utf-8", ) os.chmod(env, 0o600) - self.assertIsNone(SPSLink.from_env(str(env))) + self.assertIsNotNone(SPSLink.from_env(str(env))) def test_wait_until_up_retries_then_succeeds(self): link = SPSLink(host="h", port=1, ca_file=None, verify=False) diff --git a/admin/tool_authoring.py b/admin/tool_authoring.py index 8e7c261..cfbd4d7 100644 --- a/admin/tool_authoring.py +++ b/admin/tool_authoring.py @@ -210,7 +210,7 @@ def _rest_envelope_args(path_template: str, allowed_headers: list[str], body_kin return args -def validate(data: dict) -> list[str]: +def validate(data: dict, *, confirm_secret_items: bool = False) -> list[str]: """Return a list of human-readable problems (empty == valid). Uniqueness of the id across other tools is the caller's job (it needs the registry).""" errors: list[str] = [] @@ -274,6 +274,8 @@ def validate(data: dict) -> list[str]: errors.append(f"argument '{a['name']}' in '{o['name']}' may contain only letters, digits and _") writable = {sec["name"] for sec in data["secrets"] if sec.get("writable")} for sec in data["secrets"]: + if sec.get("item") and sec["item"] != data["id"] and not confirm_secret_items: + errors.append(f"secret '{sec['name']}' uses another item; explicitly confirm secret items") if not _SECRET_RE.match(sec["name"]): errors.append(f"secret name '{sec['name']}' has invalid characters") if not sec["field"]: @@ -442,11 +444,11 @@ def read(dir_path: str | Path) -> dict: }) -def write(dir_path: str | Path, data: dict, runner: str | None = None) -> Path: +def write(dir_path: str | Path, data: dict, runner: str | None = None, *, confirm_secret_items=False) -> Path: """Validate and write ``/toolyard.toml``. Raises ValueError on invalid input, a missing directory, or an entrypoint the ``runner`` can't start; only ever writes a file named toolyard.toml. ``runner`` defaults to the deployment's runner.""" - errors = validate(data) + errors = validate(data, confirm_secret_items=confirm_secret_items) if errors: raise ValueError("; ".join(errors)) path = Path(dir_path) diff --git a/admin/tool_sources.py b/admin/tool_sources.py index fdfce57..2b6b0af 100644 --- a/admin/tool_sources.py +++ b/admin/tool_sources.py @@ -45,7 +45,7 @@ def _resolved_dir(source: str) -> Path: return src -def add_from_path(source: str, tools_root: str, existing_ids=()) -> dict: +def add_from_path(source: str, tools_root: str, existing_ids=(), *, confirm_secret_items=False) -> dict: """Copy a ready tool folder (one containing toolyard.toml) into ``tools_root/`` and record its source. Returns ``{id, type, description, path}``. Raises ``NoManifest`` if the folder has no manifest, or ``ValueError`` on a bad source / id clash / unwritable destination.""" @@ -56,11 +56,12 @@ def add_from_path(source: str, tools_root: str, existing_ids=()) -> dict: if src.resolve() == root.resolve() or root.resolve() in src.resolve().parents: # otherwise discover() could list both the source and the copy as the same tool raise ValueError("source folder is inside the tools dir; choose a folder outside it") - return _ingest(src, root, existing_ids, {"type": "path", "source": str(src.resolve())}) + return _ingest(src, root, existing_ids, {"type": "path", "source": str(src.resolve())}, + confirm_secret_items=confirm_secret_items) def add_from_github(repo: str, tools_root: str, existing_ids=(), *, - subdir: str = "", ref: str = "") -> dict: + subdir: str = "", ref: str = "", confirm_secret_items=False) -> dict: """Shallow-clone a git repo and copy its tool folder (the repo root, or ``subdir`` within it) into ``tools_root/``, recording the repo for a later Update. Returns ``{id, type, description, path}``. Raises ``NoManifest`` (no toolyard.toml) or ``ValueError`` (bad URL / @@ -74,7 +75,8 @@ def add_from_github(repo: str, tools_root: str, existing_ids=(), *, with tempfile.TemporaryDirectory(prefix="tsr-clone-") as tmp: tool_dir = _clone_into(Path(tmp), url, branch, sub) return _ingest(tool_dir, Path(tools_root), existing_ids, - {"type": "github", "url": url, "subdir": sub, "ref": branch}) + {"type": "github", "url": url, "subdir": sub, "ref": branch}, + confirm_secret_items=confirm_secret_items) def _clone_into(tmp: Path, url: str, branch: str, sub: str) -> Path: @@ -148,11 +150,10 @@ def update(dir_path: str | Path) -> dict: if merged["id"] != tool_id: raise ValueError(f"the source now declares id '{merged['id']}', not '{tool_id}'; " "remove the tool and add it again") - # operator's wiring wins WHEN they set it; otherwise fall through to the source's (so a tool - # the operator never customized still picks up upstream's new description / required secrets). + upstream_secrets = merged["secrets"] merged["description"] = local["description"] or merged["description"] - merged["secrets"] = local["secrets"] or merged["secrets"] - errors = list(tool_authoring.validate(merged)) + merged["secrets"] = local["secrets"] + errors = list(tool_authoring.validate(merged, confirm_secret_items=True)) ep = tool_authoring.entrypoint_error(merged, new_dir) if ep: errors.append(ep) @@ -160,7 +161,9 @@ def update(dir_path: str | Path) -> dict: raise ValueError("; ".join(errors)) _swap_in_place(dest, new_dir, merged, source) td = load_tool(dest / "toolyard.toml") - return {"id": td.id, "type": td.type, "description": td.description, "path": str(dest)} + return {"id": td.id, "type": td.type, "description": td.description, "path": str(dest), + "secret_declarations_changed": upstream_secrets != local["secrets"], + "secret_declarations": {"kept": local["secrets"], "upstream": upstream_secrets}} def _swap_in_place(dest: Path, new_dir: Path, merged: dict, source: dict) -> None: @@ -178,7 +181,7 @@ def _swap_in_place(dest: Path, new_dir: Path, merged: dict, source: dict) -> Non backup = work / (dest.name + ".old") try: shutil.copytree(new_dir, staging, ignore=_IGNORE, symlinks=True) - tool_authoring.write(staging, merged) # merged toolyard.toml (upstream ops + operator wiring) + tool_authoring.write(staging, merged, confirm_secret_items=True) # retain local wiring write_source(staging, source) # keep the source record for the next Update except Exception: shutil.rmtree(work, ignore_errors=True) @@ -208,7 +211,7 @@ def _clone_error(exc: subprocess.CalledProcessError) -> str: return (lines[-1].strip()[:200] if lines else f"exit {exc.returncode}") -def _ingest(tool_dir: Path, root: Path, existing_ids, meta: dict) -> dict: +def _ingest(tool_dir: Path, root: Path, existing_ids, meta: dict, *, confirm_secret_items=False) -> dict: """Validate the tool at ``tool_dir`` and copy it into ``root/`` with a source sidecar. Shared by add_from_path / add_from_github. Raises ``NoManifest`` / ``ValueError``.""" manifest_files.validate(tool_dir) @@ -217,7 +220,7 @@ def _ingest(tool_dir: Path, root: Path, existing_ids, meta: dict) -> dict: tool = tool_authoring.read(tool_dir) # normalized dict (id/description/ops/secrets/...) # validate() rejects a broken tool now (not at broker start) AND bounds the id to a safe single # path component (no separators, length-capped): that's what makes `root / id` safe. - errors = list(tool_authoring.validate(tool)) + errors = list(tool_authoring.validate(tool, confirm_secret_items=confirm_secret_items)) ep = tool_authoring.entrypoint_error(tool, tool_dir) if ep: errors.append(ep) @@ -232,7 +235,7 @@ def _ingest(tool_dir: Path, root: Path, existing_ids, meta: dict) -> dict: return {"id": td.id, "type": td.type, "description": td.description, "path": str(dest)} -def add_with_manifest(source: str, tools_root: str, existing_ids, manifest: dict) -> dict: +def add_with_manifest(source: str, tools_root: str, existing_ids, manifest: dict, *, confirm_secret_items=False) -> dict: """The 'point at code, author the tool in-app' flow: copy a folder that need NOT contain a toolyard.toml into ``tools_root/`` and write the authored manifest into the copy. Returns ``{id, type, description, path}``. Raises ``ValueError`` on a bad source / invalid manifest / id @@ -245,7 +248,7 @@ def add_with_manifest(source: str, tools_root: str, existing_ids, manifest: dict if src.resolve() == root.resolve() or root.resolve() in src.resolve().parents: raise ValueError("source folder is inside the tools dir; choose a folder outside it") tool = tool_authoring.normalize(manifest) - errors = list(tool_authoring.validate(tool)) # also bounds the id to a safe single path component + errors = list(tool_authoring.validate(tool, confirm_secret_items=confirm_secret_items)) ep = tool_authoring.entrypoint_error(tool, src) if ep: errors.append(ep) @@ -255,7 +258,7 @@ def add_with_manifest(source: str, tools_root: str, existing_ids, manifest: dict if tool_id in set(existing_ids): raise ValueError(f"a tool named '{tool_id}' already exists") dest = _copy_tool_dir(src, root, tool_id) - tool_authoring.write(dest, tool) # write the authored toolyard.toml into the copied code + tool_authoring.write(dest, tool, confirm_secret_items=confirm_secret_items) td = load_tool(dest / "toolyard.toml") return {"id": td.id, "type": td.type, "description": td.description, "path": str(dest)} diff --git a/admin/toolyard_ops.py b/admin/toolyard_ops.py index 17393b0..4ce68b3 100644 --- a/admin/toolyard_ops.py +++ b/admin/toolyard_ops.py @@ -14,13 +14,13 @@ from __future__ import annotations import shutil -from dataclasses import asdict +from dataclasses import asdict, replace from pathlib import Path # Reuse the toolyard's single state file so the panel and the CLI never disagree # about which tools are running. from toolyard.cli import _load_state, _save_state -from toolyard.config import discover +from toolyard.config import discover, SecretSpec from toolyard.config import load as load_tool from toolyard.runner import RunningTool, RunnerStartCleanupError, get_runner @@ -95,12 +95,13 @@ def list_tools(tools_root: str, tool_dirs=()) -> list[dict]: "state": "unknown" if alive is None else "running" if alive else "crashed" if record else "stopped", "health": "unknown" if alive or alive is None else "stopped", "backend": record["backend"] if record else None, + "secrets": [asdict(s) for s in d.secrets], "removable": str(Path(d.path).resolve()) in dir_set, }) return tools -def _start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: +def _start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process", *, recovery=False) -> None: """Start the tool via the runner. Secrets are resolved by the tool at boot (Phase 5: SPS pull), so the panel hands the runner nothing but the tool definition.""" @@ -108,6 +109,13 @@ def _start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") - if tool_id not in defs: raise LookupError(f"unknown tool: {tool_id}") state = _load_state() + if recovery: + rec = state.get(tool_id, {}) + if rec.get("secret_declarations") is None or not rec.get("manifest_digest"): + raise ValueError("recorded declarations missing; operator Start required") + defs[tool_id] = replace(defs[tool_id], secrets=tuple( + SecretSpec(**s) for s in rec["secret_declarations"]), + manifest_digest=rec["manifest_digest"]) if tool_id in state: # The state file survives the process, VM, and host. Treat it as running # intent, not proof that the recorded runtime still exists. @@ -157,9 +165,22 @@ def restart(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") _start(tool_id, tools_root, tool_dirs, backend) -def start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process") -> None: +def start(tool_id: str, tools_root: str, tool_dirs, backend: str = "process", *, recovery=False) -> None: with state_lock(): - _start(tool_id, tools_root, tool_dirs, backend) + _start(tool_id, tools_root, tool_dirs, backend, recovery=recovery) + + +def record_edited_declarations(tool): + """An explicit operator edit may change the next recovery registration.""" + import hashlib + from .tool_authoring import to_toml + digest = hashlib.sha256(to_toml(tool).encode("utf-8")).hexdigest() + with state_lock(): + state = _load_state() + if tool["id"] in state: + state[tool["id"]]["secret_declarations"] = [dict(s) for s in tool["secrets"]] + state[tool["id"]]["manifest_digest"] = digest + _save_state(state) def stop(tool_id: str) -> None: diff --git a/admin/views/tools.py b/admin/views/tools.py index fbd0e59..6af784f 100644 --- a/admin/views/tools.py +++ b/admin/views/tools.py @@ -28,8 +28,11 @@ def _secret_backend_note(sps: dict | None) -> str: def tools_view(*, user, csrf, tools, tools_root, banner=None, error=None, catalog=None, broker_port=None, focused=False, mutation_outcome=None) -> str: def action(tool_id, act, label, disabled, primary=False): d = " disabled" if disabled else "" - confirmation = (f" data-confirm='{esc('Restart ' + tool_id + '? Stops then starts trusted tool code; interrupts active calls. Requests are not replayed.')}'" - if act == "restart" else "") + warning = " This tool runs with this host's credentials. Only start tools you trust." + if act == "restart": + warning += " Stops then starts tool code; interrupts active calls. Requests are not replayed." + confirmation = (f" data-confirm='{esc(('Restart ' if act == 'restart' else 'Start ') + tool_id + '?' + warning)}'" + if act in ("start", "restart") else "") return (f"
" f"{_csrf_field(csrf)}
") @@ -80,6 +83,10 @@ def action(tool_id, act, label, disabled, primary=False): f"{esc(state)}
{registration}
" f"{esc(t.get('path', ''))}" "" + + "
Secrets: " + (", ".join( + esc(s["name"]) + " (item: " + esc(s.get("item") or t["id"]) + ")" + for s in t.get("secrets", [])) or "none") + "
" + + f"Edit" f"{action(t['id'], 'start', 'Start', alive, focused and not running)}" f"{action(t['id'], 'stop', 'Stop', not running)}" @@ -125,6 +132,8 @@ def tool_add_view(*, user, csrf, source_value="", repo_value="", error=None) -> "" "" + "" "
" "

From a Git repository

" @@ -136,6 +145,8 @@ def tool_add_view(*, user, csrf, source_value="", repo_value="", error=None) -> "" "" + "" "
" "

Cloned code is copied in, never executed by the panel. Restart the broker to " "register the new tool.

" @@ -211,6 +222,8 @@ def tool_editor_view(*, user, csrf, mode, tool, dir_value="", backend=None, erro "Path (Infisical)" "Writable
" "
" + "" "" "
" "Cancel
" diff --git a/docs/secret-declarations.md b/docs/secret-declarations.md new file mode 100644 index 0000000..4ddb8a6 --- /dev/null +++ b/docs/secret-declarations.md @@ -0,0 +1,31 @@ +# Operator-approved secret declarations + +The tools page shows secret names and backend items beside Start, never values. +Start and Restart ask for confirmation: a started tool runs with this host's +credentials, so only start tools you trust. Cancel is the default web action. + +Source updates always retain the local secret declarations, including an empty +list. The update result includes `secret_declarations_changed` and a +`secret_declarations` object containing `kept` and `upstream` lists. The web +update banner warns when they differ. Review and save an explicit Edit to adopt +changes; upstream declarations never become grants merely by updating code. + +Authoring and import reject a nonempty secret `item` different from the tool id +unless the operator checks the explicit confirmation. JSON clients send +`confirm_secret_items: true` alongside the request, outside the manifest. +An empty item retains the default of the tool id. + +Each process Start records declarations and the SHA-256 manifest digest in +private toolyard state. SPS recovery and automatic process reconciliation use +that snapshot even if the manifest changes on disk. An explicit operator Edit +updates the recorded declarations; Start/Restart adopts the current manifest. +Older running records without snapshots require an operator stop/start rather +than having grants inferred from disk during recovery. + +Admin and runner read only `SP_HOST`, `SP_PORT`, `SP_TLS_CA`, and `SP_SECRET` +from the top-level SPS TOML env file, which must remain a regular mode-0600 file. +Provider tables are not decoded. A registration-only env file can therefore be +used via `TOOLSTACK_SPS_ENV`; provider configuration remains SPS's responsibility. +TLS verification is enabled by default. `TOOLSTACK_SPS_VERIFY=0` is refused +unless `TOOLSTACK_SPS_DEV=1` is also set explicitly for trusted development. +This does not change `TOOLSTACK_SPS_SKIP=1`, which skips SPS entirely. diff --git a/toolyard/config.py b/toolyard/config.py index 655eb42..67e4d4a 100644 --- a/toolyard/config.py +++ b/toolyard/config.py @@ -8,6 +8,7 @@ from __future__ import annotations import re +import hashlib import tomllib from dataclasses import dataclass from pathlib import Path @@ -46,12 +47,14 @@ class ToolDef: path: Path # directory containing toolyard.toml (and the tool's files) description: str = "" # optional tool-level summary (the broker registry ignores it) egress: tuple[str, ...] = () # [sandbox] egress: hosts a sandboxed tool may reach outbound + manifest_digest: str | None = None def load(toml_path: str | Path) -> ToolDef: path = Path(toml_path) with open(path, "rb") as f: - data = tomllib.load(f) + raw = f.read() + data = tomllib.loads(raw.decode("utf-8")) tool_id = data.get("id") # Reject a missing/invalid id at load, naming the file + id; mirrors the broker registry's # check (broker/registry.py). A dotted id would slip through here and only misroute policy @@ -107,6 +110,7 @@ def load(toml_path: str | Path) -> ToolDef: image=entry.get("image"), secrets=secrets, path=path.parent, + manifest_digest=hashlib.sha256(raw).hexdigest(), description=data.get("description", ""), egress=tuple(egress), ) diff --git a/toolyard/runner.py b/toolyard/runner.py index 0a3b9e7..27a04dc 100644 --- a/toolyard/runner.py +++ b/toolyard/runner.py @@ -21,7 +21,7 @@ import subprocess import sys import time -from dataclasses import dataclass, field, replace +from dataclasses import asdict, dataclass, field, replace from pathlib import Path from typing import Protocol @@ -186,21 +186,11 @@ def _reregister_results(*, tools_root: str, tool_dirs, sps_env_path: str) -> dic log.warning("SPS re-register: sps.env not found at %s; skipping", sps_env_path) raise RunnerConfigError("SPS configuration missing; recovery must retry") from .cli import _load_state - from .config import discover, load as load_tool + from types import SimpleNamespace + from .config import SecretSpec state = _load_state() if not state: return {} - defs: dict[str, ToolDef] = {} - if tools_root: - for d in discover(tools_root): - defs[d.id] = d - for path in tool_dirs or (): - toml_path = Path(path) / "toolyard.toml" - if toml_path.exists(): - try: - defs[load_tool(toml_path).id] = load_tool(toml_path) - except Exception as exc: - log.warning("SPS re-register: cannot load %s: %s", toml_path, exc) results = {} for tool_id, rec in state.items(): if not isinstance(rec, dict): @@ -219,12 +209,13 @@ def _reregister_results(*, tools_root: str, tool_dirs, sps_env_path: str) -> dic alive = True if not alive: continue # positively dead; reconcile restarts it - tool_def = defs.get(tool_id) - if tool_def is None: - log.warning("SPS re-register: tool %s has e_secret but no tool_def", tool_id) - results[tool_id] = "tool definition missing" + if rec.get("secret_declarations") is None or not rec.get("manifest_digest"): + log.warning("SPS re-register: tool %s needs an operator Start to record declarations", tool_id) + results[tool_id] = "recorded declarations missing; operator Start required" continue try: + tool_def = SimpleNamespace(id=tool_id, secrets=tuple( + SecretSpec(**s) for s in rec["secret_declarations"])) _sps_register(tool_def, e_secret, sps_env_path) results[tool_id] = None log.info("SPS re-registered %s", tool_id) @@ -246,9 +237,9 @@ class RunnerConfigError(ValueError): def strict_bool(value: str) -> bool: - from sps.config import parse_bool + from .sps_config import parse_verify try: - return parse_bool(value, name="TOOLSTACK_SPS_VERIFY") + return parse_verify(value) except ValueError as exc: raise RunnerConfigError(str(exc)) from exc @@ -263,7 +254,7 @@ class ResolvedSPS: def _resolve_sps(path): - from sps.config import load_config + from .sps_config import load_registration_config as load_config try: cfg = load_config(path) return ResolvedSPS(os.environ.get("TOOLSTACK_SPS_HOST", cfg.sp_host), @@ -276,7 +267,7 @@ def _resolve_sps(path): def _check_sps_env(path: str) -> str: """Compatibility validation entry point; start resolves/validates once instead.""" - from sps.config import ConfigModeError, load_config + from .sps_config import ConfigModeError, load_registration_config as load_config try: load_config(path) except ConfigModeError as exc: @@ -311,7 +302,6 @@ def _sps_unregister(tool_id: str, sps_env_path: str) -> None: """Best-effort: a failed unregister should not turn a clean stop into a failure. Logged as a warning, never raised to the caller.""" from sps.client import SPSClient - from sps.config import load_config try: cfg = _resolve_sps(sps_env_path) except Exception as exc: @@ -467,6 +457,8 @@ class RunningTool: process_exited: bool = False log_exited: bool = False egress_exited: bool = False + secret_declarations: list[dict] | None = None + manifest_digest: str | None = None class RunnerStartCleanupError(RuntimeError): @@ -619,7 +611,9 @@ def start(self, tool_def: ToolDef) -> RunningTool: log_path = _tool_log_path(tool_def) running = RunningTool(tool_def.id, tool_def.port, self.backend, "", log_path=str(log_path), boot_id=_boot_id(), - e_secret=e_secret, channel_secret=channel_secret) + e_secret=e_secret, channel_secret=channel_secret, + secret_declarations=[asdict(s) for s in tool_def.secrets], + manifest_digest=tool_def.manifest_digest) def mark_reaped(): nonlocal running diff --git a/toolyard/sps_config.py b/toolyard/sps_config.py new file mode 100644 index 0000000..a0c278d --- /dev/null +++ b/toolyard/sps_config.py @@ -0,0 +1,95 @@ +"""Read only the registration client's top-level SPS settings.""" +from __future__ import annotations + +import os +import stat +import tomllib +from dataclasses import dataclass, field + + +class ConfigModeError(ValueError): + pass + + +@dataclass(frozen=True) +class RegistrationConfig: + sp_host: str + sp_port: int + sp_secret: str = field(repr=False) + sp_tls_ca: str = "" + + +def _statements(stream): + """Frame TOML statements without decoding values we do not need.""" + buf = "" + quote = None + depth = 0 + for line in stream: + buf += line + i = 0 + while i < len(line): + c = line[i] + if quote: + if quote.startswith('"') and c == "\\": + i += 2 + continue + if line.startswith(quote, i): + i += len(quote) + quote = None + continue + elif c == "#": + break + elif c in "\"'": + quote = c * 3 if line.startswith(c * 3, i) else c + i += len(quote) + continue + elif c in "[{": + depth += 1 + elif c in "]}": + depth -= 1 + i += 1 + if quote is None and depth == 0: + yield buf + buf = "" + if buf.strip(): + raise ValueError("incomplete SPS configuration statement") + + +def load_registration_config(path): + allowed = {"SP_HOST", "SP_PORT", "SP_TLS_CA", "SP_SECRET"} + data = {} + with open(path, "r", encoding="utf-8") as stream: + st = os.fstat(stream.fileno()) + if not stat.S_ISREG(st.st_mode) or stat.S_IMODE(st.st_mode) != 0o600: + raise ConfigModeError("SPS configuration must be a regular mode 0600 file") + for statement in _statements(stream): + text = statement.strip() + if text.startswith("["): + break + if not text or text.startswith("#"): + continue + key = text.split("=", 1)[0].strip().strip("\"'") + if key not in allowed: + continue + try: + value = tomllib.loads(text) + except ValueError: + raise ValueError("invalid SPS registration setting") from None + if set(value) != {key} or key in data: + raise ValueError("invalid or duplicate SPS registration setting") + data.update(value) + for key in ("SP_HOST", "SP_PORT", "SP_SECRET"): + if not isinstance(data.get(key), str) or not data[key]: + raise ValueError(f"missing SPS registration setting {key}") + if not isinstance(data.get("SP_TLS_CA", ""), str): + raise ValueError("invalid SP_TLS_CA") + return RegistrationConfig(data["SP_HOST"], int(data["SP_PORT"]), + data["SP_SECRET"], data.get("SP_TLS_CA", "")) + + +def parse_verify(value): + from sps.config import parse_bool + verify = parse_bool(value, name="TOOLSTACK_SPS_VERIFY") + if not verify and os.environ.get("TOOLSTACK_SPS_DEV") != "1": + raise ValueError("TOOLSTACK_SPS_VERIFY=0 requires TOOLSTACK_SPS_DEV=1") + return verify diff --git a/toolyard/tests/test_partial_cleanup.py b/toolyard/tests/test_partial_cleanup.py index 62d3a64..71ed33c 100644 --- a/toolyard/tests/test_partial_cleanup.py +++ b/toolyard/tests/test_partial_cleanup.py @@ -132,7 +132,7 @@ def test_positive_receipt_skips_reused_pid_on_retry(self): def start_fixture(self): directory = self.stack.enter_context(tempfile.TemporaryDirectory()) - tool = SimpleNamespace(id="fake", port=123, path=Path(directory).resolve(), command="fake", secrets=[]) + tool = SimpleNamespace(id="fake", port=123, path=Path(directory).resolve(), command="fake", secrets=[], manifest_digest="recorded") sps_env = tool.path / "fake.env" sps_env.touch() self.stack.enter_context(patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "0", "TOOLSTACK_SPS_ENV": str(sps_env)})) diff --git a/toolyard/tests/test_registration_config.py b/toolyard/tests/test_registration_config.py new file mode 100644 index 0000000..55f7f8a --- /dev/null +++ b/toolyard/tests/test_registration_config.py @@ -0,0 +1,57 @@ +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from toolyard import runner, sps_config +from admin.sps_link import SPSLink + + +class RegistrationConfiguration(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) / "sps.env" + self.path.write_text('SP_HOST="localhost"\nSP_PORT="8743"\nSP_SECRET="fixture"\n' + 'SP_TLS_CA="ca.pem"\nSP_PLUGIN="unused"\n' + '[vault]\nTOKEN="provider-fixture"\n') + self.path.chmod(0o600) + + def test_only_registration_values_are_decoded(self): + original = sps_config.tomllib.loads + with patch.object(sps_config.tomllib, "loads", wraps=original) as decode: + cfg = sps_config.load_registration_config(self.path) + self.assertEqual(cfg.sp_host, "localhost") + self.assertEqual(cfg.sp_port, 8743) + self.assertNotIn("fixture", repr(cfg)) + parsed = "\n".join(call.args[0] for call in decode.call_args_list) + self.assertNotIn("provider-fixture", parsed) + self.assertNotIn("SP_PLUGIN", parsed) + + def test_ignored_multiline_value_cannot_spoof_registration(self): + self.path.write_text("IGNORED='''\nSP_HOST=\"wrong\"\n'''\n" + 'SP_HOST="right"\nSP_PORT="8743"\nSP_SECRET="fixture"\n') + self.assertEqual(sps_config.load_registration_config(self.path).sp_host, "right") + + def test_quoted_keys_comments_and_multiline_strings(self): + self.path.write_text('# comment\n"SP_HOST" = "localhost" # comment\n' + "SP_PORT='8743'\nSP_SECRET='''fixture\nvalue'''\n") + self.assertEqual(sps_config.load_registration_config(self.path).sp_secret, "fixture\nvalue") + + def test_private_mode_required(self): + self.path.chmod(0o644) + with self.assertRaises(sps_config.ConfigModeError): + sps_config.load_registration_config(self.path) + + def test_both_clients_use_registration_loader_and_dev_gate(self): + with patch.dict(os.environ, {}, clear=True), patch("sps.config.load_config", side_effect=AssertionError): + self.assertTrue(runner._resolve_sps(str(self.path)).verify) + self.assertTrue(SPSLink.from_env(str(self.path)).verify) + with patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "0"}, clear=True): + with self.assertRaises(runner.RunnerConfigError): + runner._resolve_sps(str(self.path)) + self.assertIsNone(SPSLink.from_env(str(self.path))) + with patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "0", "TOOLSTACK_SPS_DEV": "1"}, clear=True): + self.assertFalse(runner._resolve_sps(str(self.path)).verify) + self.assertFalse(SPSLink.from_env(str(self.path)).verify) diff --git a/toolyard/tests/test_runner.py b/toolyard/tests/test_runner.py index e9f6c89..a28a429 100644 --- a/toolyard/tests/test_runner.py +++ b/toolyard/tests/test_runner.py @@ -1055,7 +1055,8 @@ def setUp(self): 'id = "echo"\ntype = "api"\n[entrypoint]\ncommand = "true"\nport = 4601\n', encoding="utf-8") _save_state({"echo": asdict(RunningTool("echo", 4601, "process", "123", - e_secret="e" * 32, boot_id="b"))}) + e_secret="e" * 32, boot_id="b", + secret_declarations=[], manifest_digest="recorded"))}) self.env = Path(self.tmp) / "sps.env" self.env.write_text('SP_HOST="h"\nSP_PORT="1"\nSP_SECRET="s"\n' 'SP_PLUGIN="localfile"\n', encoding="utf-8") diff --git a/toolyard/tests/test_uplift.py b/toolyard/tests/test_uplift.py index 7de1926..f9a9ac0 100644 --- a/toolyard/tests/test_uplift.py +++ b/toolyard/tests/test_uplift.py @@ -114,7 +114,7 @@ def test_child_environment_drops_master_and_provider_credentials(self): def test_resolved_verify_normalized_once(self): cfg = SimpleNamespace(sp_host="fake", sp_port=123, sp_secret="fake", sp_tls_ca="fake-ca") - with patch("sps.config.load_config", return_value=cfg), patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "YeS"}, clear=True): + with patch("toolyard.sps_config.load_registration_config", return_value=cfg), patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "YeS"}, clear=True): resolved = runner._resolve_sps("fake.env") self.assertIs(resolved.verify, True) with patch.dict(os.environ, {"TOOLSTACK_SPS_VERIFY": "invalid"}, clear=True), patch("sps.client.SPSClient") as client: @@ -194,7 +194,8 @@ def test_strict_bool(self): for value in ["1", "TRUE", "yes"]: self.assertTrue(runner.strict_bool(value)) for value in ["0", "false", "NO"]: - self.assertFalse(runner.strict_bool(value)) + with patch.dict(os.environ, {"TOOLSTACK_SPS_DEV": "1"}): + self.assertFalse(runner.strict_bool(value)) with self.assertRaises(ValueError): runner.strict_bool("typo") @@ -250,7 +251,7 @@ def test_unrecordable_start_rolled_back(self): def test_sps_resolution_reused_and_channel_independent(self): cfg = SimpleNamespace(sp_host="configured.invalid", sp_port=4444, sp_tls_ca="fake-ca", verify=False) - tool = SimpleNamespace(id="fake", port=123, path=Path("."), command="fake", secrets=[]) + tool = SimpleNamespace(id="fake", port=123, path=Path("."), command="fake", secrets=[], manifest_digest="recorded") with patch.object(runner, "_check_port_free"), patch.object(runner.os.path, "exists", return_value=True), patch.object(runner, "_resolve_sps", return_value=cfg) as resolve, patch.object(runner, "_sps_register") as register, patch.object(runner, "_tool_log_path", return_value=Path(os.devnull)), patch.object(runner.os, "fchmod"), patch.object(runner.os, "posix_spawn", return_value=999) as spawn, patch.object(runner, "_process_identity", return_value="start"), patch.object(runner.ProcessRunner, "is_alive", return_value=True), patch.object(runner.time, "sleep"), patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "0", "TOOLSTACK_SPS_ENV": "fake.env"}): record = runner.ProcessRunner().start(tool) resolve.assert_called_once_with("fake.env") From eb3fe3f3a01cfe908f3f9b524f1d865d2befc8bb Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:01:58 -0400 Subject: [PATCH 3/4] Harden request handling, operator sessions, and private runtime files --- admin/README.md | 11 +++ admin/api.py | 10 ++- admin/auth.py | 51 +++++++++++++- admin/broker_config.py | 4 +- admin/server.py | 18 +++++ admin/settings.py | 11 ++- admin/supervisor.py | 4 +- admin/tests/test_api.py | 38 +++++++++- admin/tests/test_app.py | 2 +- admin/tests/test_auth.py | 30 +++++++- admin/tests/test_dialog_logout.py | 2 + admin/tests/test_private_writes.py | 40 +++++++++++ admin/views/assets.py | 2 +- broker/gateway.py | 12 +++- broker/mcp.py | 3 +- broker/ratelimit.py | 2 +- broker/request_lifecycle.py | 4 +- broker/server.py | 7 ++ broker/tests/test_request_guards.py | 69 +++++++++++++++++++ client/tests/test_proxy_policy.py | 14 ++++ client/toolstack.py | 2 +- deploy/docker/README.md | 4 ++ deploy/install.sh | 2 +- macapp/Sources/ToolstackApp/AppModel.swift | 9 ++- .../Sources/ToolstackApp/ToolstackApp.swift | 2 +- macapp/Sources/ToolstackKit/ApiClient.swift | 5 ++ .../ActiveRefreshTests.swift | 13 ++-- .../NativeContractTests.swift | 6 +- .../StalePublicationTests.swift | 2 +- toolstack_forwarder/outbound.py | 2 +- .../tests/test_proxy_policy.py | 14 ++++ toolyard/egress_proxy.py | 20 ++++-- toolyard/runner.py | 21 ++++-- toolyard/tests/test_egress_proxy.py | 7 +- toolyard/tests/test_log_guards.py | 63 +++++++++++++++++ toolyard/tests/test_partial_cleanup.py | 3 + toolyard/tests/test_runner.py | 22 +++--- 37 files changed, 479 insertions(+), 52 deletions(-) create mode 100644 admin/tests/test_private_writes.py create mode 100644 broker/tests/test_request_guards.py create mode 100644 client/tests/test_proxy_policy.py create mode 100644 toolstack_forwarder/tests/test_proxy_policy.py create mode 100644 toolyard/tests/test_log_guards.py diff --git a/admin/README.md b/admin/README.md index d040927..d41d04c 100644 --- a/admin/README.md +++ b/admin/README.md @@ -168,6 +168,17 @@ Password reset rotates the session generation atomically. Running admin instance reject old browser cookies and bearer tokens on their next request, without a restart. An already-authorized in-flight action is not cancelled by reset. +Browser sign-out and `POST /api/logout` revoke the presented session on the server. +Sessions have independent random identifiers; revocations persist until expiry. +At the bounded revocation capacity, the signing generation rotates to invalidate +all sessions. Upgrading from the older session format requires signing in again. +The native app calls logout before discarding its client token; an unreachable +server cannot confirm revocation. Accepted HTTP hosts are localhost, loopback +addresses, and the configured admin host. + +`GET /api/audit` includes request metadata only, never stored action arguments or +result payloads. Correlation identifiers remain available to native clients. + Configuration audit records changed schema field names, never values, tokens, paths or destinations. An intent audit must succeed before saving. If completion auditing fails after saving, the operation fails with an explicit already-saved diff --git a/admin/api.py b/admin/api.py index 9c3fd29..b1f3059 100644 --- a/admin/api.py +++ b/admin/api.py @@ -111,6 +111,11 @@ async def api_login(request: Request): token = auth.sign_session(username, request.state.session_secret, settings.session_ttl_seconds()) return {"token": token, "username": username} + @app.post("/api/logout") + async def api_logout(request: Request, user: str = Depends(require_user)): + auth.revoke_session(request.headers["authorization"][7:].strip(), request.state.session_secret) + return {"ok": True} + @app.get("/api/broker") async def api_broker_status(user: str = Depends(require_user)): return supervisor.status() @@ -224,7 +229,10 @@ async def api_audit(user: str = Depends(require_user), limit: int = 50): limit = max(1, min(limit, 500)) with open_store(broker_config.load()) as store: return {"audit": store.recent_audit(limit=limit), - "requests": _rows(store.list_requests(limit=limit))} + "requests": [{key: row[key] for key in ( + "id", "correlation_id", "caller_id", "tool", "op", "status", "error", + "created_at", "updated_at", "idempotency_key", "pruned_at")} + for row in store.list_requests(limit=limit)]} @app.delete("/api/audit") async def api_clear_audit(user: str = Depends(require_user)): diff --git a/admin/auth.py b/admin/auth.py index 41af716..0b6e289 100644 --- a/admin/auth.py +++ b/admin/auth.py @@ -19,6 +19,11 @@ import hmac import secrets import time +import json +import fcntl +import os + +from . import settings def hash_password(password: str) -> str: @@ -58,7 +63,7 @@ def verify_password(password: str, encoded: str) -> bool: def sign_session(username: str, secret: str, ttl_seconds: int) -> str: expires_at = int(time.time()) + ttl_seconds - message = f"{username}|{expires_at}" + message = f"{username}|{expires_at}|{secrets.token_hex(16)}" signature = hmac.new(secret.encode("utf-8"), ("session|" + message).encode("utf-8"), hashlib.sha256).hexdigest() raw = f"{message}|{signature}".encode("utf-8") return base64.urlsafe_b64encode(raw).decode("ascii") @@ -69,19 +74,59 @@ def verify_session(cookie: str | None, secret: str) -> str | None: return None try: decoded = base64.urlsafe_b64decode(cookie.encode("ascii")).decode("utf-8") - username, raw_expires, signature = decoded.split("|", 2) + username, raw_expires, session_id, signature = decoded.split("|") expires_at = int(raw_expires) + if len(session_id) != 32 or any(c not in "0123456789abcdef" for c in session_id): + return None except Exception: return None if expires_at < int(time.time()): return None - message = f"{username}|{expires_at}" + message = f"{username}|{expires_at}|{session_id}" expected = hmac.new(secret.encode("utf-8"), ("session|" + message).encode("utf-8"), hashlib.sha256).hexdigest() if not signature.isascii() or not hmac.compare_digest(signature, expected): return None + try: + if session_id in _revoked_sessions(): + return None + except (OSError, ValueError, TypeError): + return None return username +def _revoked_sessions() -> dict: + path = settings.config_dir() / "revoked-sessions.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + if not isinstance(data, dict) or any(not isinstance(v, int) for v in data.values()): + raise ValueError("invalid session revocations") + return data + + +def revoke_session(token: str, secret: str) -> None: + """Persist revocation until expiry, serializing writers across admin processes.""" + directory = settings.config_dir() + settings._private_directory(directory) + fd = os.open(directory / "revoked-sessions.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + if verify_session(token, secret) is None: + return + _, expiry, session_id, _ = base64.urlsafe_b64decode(token).decode().split("|") + revoked = {key: value for key, value in _revoked_sessions().items() if value >= int(time.time())} + if len(revoked) >= 4096: + # Bound state without making any revoked token valid again. + settings.rotate_session_secret() + revoked = {} + else: + revoked[session_id] = int(expiry) + settings._write_private(directory / "revoked-sessions.json", json.dumps(revoked)) + finally: + os.close(fd) + + def csrf_token(session_value: str, secret: str) -> str: """A CSRF token bound to one session, derived via HMAC over the session cookie.""" return hmac.new( diff --git a/admin/broker_config.py b/admin/broker_config.py index 49ebc4a..10d6e23 100644 --- a/admin/broker_config.py +++ b/admin/broker_config.py @@ -190,9 +190,7 @@ def _warn_if_suspect(cfg: BrokerRunConfig) -> None: def save(config: BrokerRunConfig) -> None: path = config_file() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(_to_toml(asdict(config)), encoding="utf-8") - path.chmod(0o600) # holds the nod token + settings._write_private(path, _to_toml(asdict(config))) def _toml_str(value) -> str: diff --git a/admin/server.py b/admin/server.py index 442a8d2..38d74ce 100644 --- a/admin/server.py +++ b/admin/server.py @@ -47,6 +47,23 @@ def create_app() -> FastAPI: guard = loginguard.LoginGuard() # shared by /login and /api/login app = FastAPI(title="Toolstack Admin", docs_url=None, redoc_url=None, openapi_url=None) + @app.middleware("http") + async def trusted_host(request: Request, call_next): + # Parse IPv6 authorities correctly (including brackets and optional port). + from urllib.parse import urlsplit + try: + authority = urlsplit("//" + request.headers.get("host", "")) + host = authority.hostname + if (len(request.headers.getlist("host")) != 1 or authority.username is not None + or authority.password is not None or authority.path or authority.query or authority.fragment): + host = None + except ValueError: + host = None + allowed = {"localhost", "127.0.0.1", "::1", settings.admin_host().lower()} + if host not in allowed: + return JSONResponse({"detail": "Invalid host"}, status_code=400) + return await call_next(request) + @app.middleware("http") async def session_generation(request: Request, call_next): # Snapshot once per request; never use the startup key after reset. @@ -227,6 +244,7 @@ async def logout(request: Request): data = await read_form(request) if not csrf_ok(request, data): return redirect("/") + auth.revoke_session(request.cookies.get(SESSION_COOKIE, ""), request.state.session_secret) resp = redirect("/login") resp.delete_cookie(SESSION_COOKIE) return resp diff --git a/admin/settings.py b/admin/settings.py index 7ab6e48..922db98 100644 --- a/admin/settings.py +++ b/admin/settings.py @@ -109,7 +109,7 @@ def sps_info() -> dict: def _write_private(path: Path, data: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) + _private_directory(path.parent) fd, name = tempfile.mkstemp(prefix=".admin-", dir=path.parent) try: with os.fdopen(fd, "w", encoding="utf-8") as stream: @@ -122,6 +122,15 @@ def _write_private(path: Path, data: str) -> None: os.unlink(name) +def _private_directory(path: Path) -> None: + """Create missing ancestors privately; reject an unsafe immediate state parent.""" + if not path.exists(): + _private_directory(path.parent) + path.mkdir(mode=0o700, exist_ok=True) + if path.is_symlink() or not path.is_dir() or path.stat().st_mode & 0o022: + raise PermissionError("admin state directory must not be group/world writable") + + def read_password_hash() -> str | None: """The stored admin password hash, or None if no password has been set.""" path = password_hash_file() diff --git a/admin/supervisor.py b/admin/supervisor.py index 713719e..aae7d8f 100644 --- a/admin/supervisor.py +++ b/admin/supervisor.py @@ -74,7 +74,7 @@ def _read_state() -> dict | None: def _write_state(state: dict) -> None: path = _state_file() - path.parent.mkdir(parents=True, exist_ok=True) + settings._private_directory(path.parent) path.write_text(json.dumps(state), encoding="utf-8") @@ -141,7 +141,7 @@ def start(config: BrokerRunConfig) -> dict: if current["running"]: return current state_dir = settings.state_dir() - state_dir.mkdir(parents=True, exist_ok=True) + settings._private_directory(state_dir) env = {**os.environ, **config.to_env()} # os.open/os.close (not open()) so the parent leaves no Python file object to # warn about; the child keeps its own dup'd copies of fds 1 and 2. diff --git a/admin/tests/test_api.py b/admin/tests/test_api.py index 9aa18d6..61d7d16 100644 --- a/admin/tests/test_api.py +++ b/admin/tests/test_api.py @@ -9,6 +9,7 @@ import subprocess import tempfile import unittest +from contextlib import closing from pathlib import Path from unittest import mock @@ -47,7 +48,7 @@ def setUp(self): encoding="utf-8") self.db_path = str(Path(self.tmp, "broker.sqlite3")) broker_config.save(BrokerRunConfig(db_path=self.db_path, tools_root=str(Path(self.tmp, "tools")))) - self.client = TestClient(create_app()) + self.client = TestClient(create_app(), base_url="http://127.0.0.1") def _restore_env(self): for key, prev in self._env.items(): @@ -56,6 +57,41 @@ def _restore_env(self): else: os.environ[key] = prev + def test_logout_revokes_only_presented_session_and_survives_app_recreation(self): + first, second = self._auth(), self._auth() + self.assertNotEqual(first, second) + self.assertEqual(self.client.post("/api/logout", headers=first).status_code, 200) + fresh = TestClient(create_app(), base_url="http://127.0.0.1") + self.assertEqual(fresh.get("/api/config", headers=first).status_code, 401) + self.assertEqual(fresh.get("/api/config", headers=second).status_code, 200) + self.assertEqual((settings.config_dir() / "revoked-sessions.json").stat().st_mode & 0o777, 0o600) + + def test_host_is_checked_before_login(self): + for host in ("attacker.invalid", "localhost.attacker.invalid", "127.0.0.1.attacker.invalid"): + self.assertEqual(self.client.post("/api/login", headers={"Host": host}, json={"password": PASSWORD}).status_code, 400) + for host in ("localhost:8780", "127.0.0.1:8780", "[::1]:8780"): + self.assertEqual(self.client.post("/api/login", headers={"Host": host}, json={"password": PASSWORD}).status_code, 200) + with mock.patch.dict(os.environ, {"TOOLSTACK_ADMIN_HOST": "configured.invalid", "TOOLSTACK_ADMIN_ALLOW_NONLOOPBACK": "1"}): + self.assertEqual(self.client.post("/api/login", headers={"Host": "configured.invalid"}, json={"password": PASSWORD}).status_code, 200) + + def test_audit_requests_have_only_metadata(self): + from broker.tests.support import seed_caller, make_registry, FakeRuntime + from broker.context import BrokerContext + from broker.audit import AuditLog + from broker.identity import authenticate + from broker.request_lifecycle import submit + with closing(Store(self.db_path)) as store: + token = seed_caller(store, allow=["echo.say"]) + ctx = BrokerContext(store=store, registry=make_registry({"echo": {"say": "read"}}), runtime=FakeRuntime(), audit=AuditLog(store, sink=None)) + submit(ctx, authenticate(store, f"Bearer {token}"), "echo", "say", {"private": "fake-payload"}, "fake-correlation") + response = self.client.get("/api/audit", headers=self._auth()) + self.assertEqual(response.status_code, 200) + row = response.json()["requests"][0] + self.assertEqual(row["correlation_id"], "fake-correlation") + self.assertEqual(row["status"], "completed") + self.assertFalse({"arguments_json", "action_json", "result_json"} & row.keys()) + self.assertNotIn("fake-payload", response.text) + def test_reset_invalidates_live_bearer_without_restart(self): authz = self._auth() self.assertEqual(self.client.get("/api/config", headers=authz).status_code, 200) diff --git a/admin/tests/test_app.py b/admin/tests/test_app.py index 4ed286b..af539c8 100644 --- a/admin/tests/test_app.py +++ b/admin/tests/test_app.py @@ -71,7 +71,7 @@ def setUp(self): self.db_path = str(Path(self.tmp, "broker.sqlite3")) broker_config.save(BrokerRunConfig(db_path=self.db_path, tools_root=str(Path(self.tmp, "tools")))) - self.client = TestClient(create_app()) + self.client = TestClient(create_app(), base_url="http://127.0.0.1") def _restore_env(self): for key, prev in self._env.items(): diff --git a/admin/tests/test_auth.py b/admin/tests/test_auth.py index f5f54e6..4f1698e 100644 --- a/admin/tests/test_auth.py +++ b/admin/tests/test_auth.py @@ -6,8 +6,12 @@ import hmac import time import unittest +import json +import os +import tempfile +from unittest.mock import patch -from admin import auth +from admin import auth, settings class Password(unittest.TestCase): @@ -26,6 +30,30 @@ def test_verify_rejects_garbage(self): class Session(unittest.TestCase): SECRET = "session-secret" + def test_revocation_prunes_expired_entries_and_corrupt_state_fails_closed(self): + with tempfile.TemporaryDirectory() as root, patch.dict(os.environ, {"XDG_CONFIG_HOME": root}): + path = settings.config_dir() / "revoked-sessions.json" + settings._write_private(path, json.dumps({"expired": 1})) + token = auth.sign_session("admin", self.SECRET, 60) + auth.revoke_session(token, self.SECRET) + self.assertNotIn("expired", json.loads(path.read_text())) + self.assertIsNone(auth.verify_session(token, self.SECRET)) + fresh = auth.sign_session("admin", self.SECRET, 60) + settings._write_private(path, "invalid json") + self.assertIsNone(auth.verify_session(fresh, self.SECRET)) + + def test_revocation_capacity_rotates_authority_instead_of_resurrecting_tokens(self): + with tempfile.TemporaryDirectory() as root, patch.dict(os.environ, {"XDG_CONFIG_HOME": root}): + key = settings.load_or_create_session_secret() + token = auth.sign_session("admin", key, 60) + path = settings.config_dir() / "revoked-sessions.json" + settings._write_private(path, json.dumps({str(i): int(time.time()) + 60 for i in range(4096)})) + auth.revoke_session(token, key) + new_key = settings.load_or_create_session_secret() + self.assertNotEqual(new_key, key) + self.assertIsNone(auth.verify_session(token, new_key)) + self.assertLessEqual(len(json.loads(path.read_text())), 4096) + def test_roundtrip(self): cookie = auth.sign_session("admin", self.SECRET, ttl_seconds=60) self.assertEqual(auth.verify_session(cookie, self.SECRET), "admin") diff --git a/admin/tests/test_dialog_logout.py b/admin/tests/test_dialog_logout.py index c93db3f..531ed6d 100644 --- a/admin/tests/test_dialog_logout.py +++ b/admin/tests/test_dialog_logout.py @@ -49,6 +49,8 @@ def test_signout_form_clears_session_and_get_logout_is_not_an_action(self): self.assertEqual(response.headers['location'], '/login') self.assertIn('Max-Age=0', response.headers['set-cookie']) self.assertIsNone(self.client.cookies.get(SESSION_COOKIE)) + from admin import auth, settings + self.assertIsNone(auth.verify_session(cookie, settings.load_or_create_session_secret())) response = self.client.get('/', follow_redirects=False) self.assertEqual(response.status_code, 303) self.assertEqual(response.headers['location'], '/login') diff --git a/admin/tests/test_private_writes.py b/admin/tests/test_private_writes.py new file mode 100644 index 0000000..d0d259b --- /dev/null +++ b/admin/tests/test_private_writes.py @@ -0,0 +1,40 @@ +"""Private admin files are private from creation, independently of umask.""" +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from admin import settings, broker_config + + +class PrivateWrites(unittest.TestCase): + def test_config_and_ancestors_private_with_permissive_umask(self): + with tempfile.TemporaryDirectory() as root, patch.dict(os.environ, {"XDG_CONFIG_HOME": root + "/config"}): + previous = os.umask(0o002) + try: + broker_config.save(broker_config.BrokerRunConfig(nod_token="fake-token")) + finally: + os.umask(previous) + path = broker_config.config_file() + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) + self.assertEqual(Path(root, "config").stat().st_mode & 0o777, 0o700) + + def test_existing_writable_parent_is_refused(self): + with tempfile.TemporaryDirectory() as root: + parent = Path(root, "unsafe") + parent.mkdir() + parent.chmod(0o770) + with self.assertRaises(PermissionError): + settings._write_private(parent / "state", "fake") + self.assertFalse((parent / "state").exists()) + + def test_tool_editor_uses_text_nodes_for_option_labels(self): + from admin.views import assets + source = Path(assets.__file__).read_text() + self.assertIn("o.textContent=v", source) + self.assertNotIn("+'>'+v+''", source) + + def test_installer_log_directory_is_owner_only(self): + source = (Path(__file__).resolve().parents[2] / "deploy/install.sh").read_text() + self.assertIn('-m 700 /var/log/toolstack', source) diff --git a/admin/views/assets.py b/admin/views/assets.py index da22637..a1bc215 100644 --- a/admin/views/assets.py +++ b/admin/views/assets.py @@ -289,7 +289,7 @@ var REST_BODY_KINDS = ["none","text","binary"]; var REST_RULE_RESPONSE_TYPES = ["json","xml","form","plaintext"]; function mk(html){var t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstChild;} - function opts(values, sel){return values.map(function(v){return ''+v+'';}).join('');} + function opts(values, sel){return values.map(function(v){var o=document.createElement('option');o.textContent=v;if(v===sel)o.setAttribute('selected','');return o.outerHTML;}).join('');} function riskOpts(sel){return RISK_CHOICES.indexOf(sel)<0 ? [sel].concat(RISK_CHOICES) : RISK_CHOICES;} function currentSecretNames(){ return [].map.call(document.querySelectorAll('#secrets .sec-name'), function(n){return n.value.trim();}).filter(Boolean); diff --git a/broker/gateway.py b/broker/gateway.py index e421941..6f03d65 100644 --- a/broker/gateway.py +++ b/broker/gateway.py @@ -21,6 +21,7 @@ from . import request_lifecycle as lifecycle import re from .identity import authenticate, token_fingerprint +from .registry import _ID_RE # lifecycle terminal status -> (request.* audit event_type, audit outcome word). A single # terminal request.* event per request makes "what was the outcome" directly queryable, @@ -90,9 +91,14 @@ def handle(method: str, path: str, headers: dict, body, ctx) -> Response: audited = not (method == "GET" and path == HEALTH_PATH) if audited: + audit_path = path + if path.startswith(ACTIONS_PREFIX): + parts = path[len(ACTIONS_PREFIX):].split(".") + if len(parts) != 2 or not all(_ID_RE.fullmatch(part) for part in parts): + audit_path = ACTIONS_PREFIX + "" ctx.audit.record( "gateway", "request_received", "accepted", correlation_id, - details={"method": method, "path": path, "has_bearer": "authorization" in headers}, + details={"method": method, "path": audit_path, "has_bearer": "authorization" in headers}, ) response = _route(method, path, headers, body, ctx, correlation_id) if audited: @@ -145,6 +151,8 @@ def _route(method, path, headers, body, ctx, correlation_id) -> Response: return _describe_tool(path, ctx, caller, correlation_id) if method == "GET" and path.startswith(REQUESTS_PREFIX): + if ctx.rate_limiter is not None and not ctx.rate_limiter.allow(caller.id): + return Response(429, {"error": "rate_limited"}, correlation_id) return _request_status(path, ctx, caller, correlation_id) return Response(404, {"error": "not_found"}, correlation_id) @@ -206,7 +214,7 @@ def _request_status(path, ctx, caller, correlation_id) -> Response: def _action(path, body, ctx, caller, correlation_id, idempotency_key=None) -> Response: spec = path[len(ACTIONS_PREFIX):] parts = spec.split(".") - if len(parts) != 2 or not all(parts): + if len(parts) != 2 or not all(_ID_RE.fullmatch(p) for p in parts): return Response(400, {"error": "invalid", "detail": "expected /v1/actions/."}, correlation_id) tool, op = parts diff --git a/broker/mcp.py b/broker/mcp.py index 0af2bb0..dafcd18 100644 --- a/broker/mcp.py +++ b/broker/mcp.py @@ -128,7 +128,8 @@ def _resolve_name(ctx, name: str): if name in registered: return registered[name] tool, sep, op = name.partition("__") - if sep and tool and op: + from .registry import _ID_RE + if sep and _ID_RE.fullmatch(tool) and _ID_RE.fullmatch(op): return tool, op return None, None diff --git a/broker/ratelimit.py b/broker/ratelimit.py index 9e04322..6afdef9 100644 --- a/broker/ratelimit.py +++ b/broker/ratelimit.py @@ -1,6 +1,6 @@ """Per-caller fixed-window rate limiter (in-memory, single process). -Bounds how many action submissions a caller can make per minute. Disabled when +Bounds action submissions and request-status polls per caller per minute. Disabled when the limit is <= 0. `now` is injectable for testing. Single-threaded dev server, so a plain dict is fine; revisit with locking under concurrent serving. """ diff --git a/broker/request_lifecycle.py b/broker/request_lifecycle.py index bf5ad0c..fbb4da6 100644 --- a/broker/request_lifecycle.py +++ b/broker/request_lifecycle.py @@ -24,6 +24,7 @@ from . import policy as policy_rules from .approval_summary import summarize from .runtime import NotDispatched +from .redaction import redact OK = "ok" PENDING = "pending_approval" @@ -287,6 +288,7 @@ def resolve_request(ctx, request_id, now=None, caller=None) -> Outcome: return _outcome_from_request(ctx.store.request(request_id), ctx.store.approval_for_request(request_id)) state = ctx.surface.poll(approval_row["surface_ref"]) + state = replace(state, note=redact(state.note)) after_poll = max(now, time.time()) current_req = ctx.store.request(request_id) current_approval = ctx.store.approval_for_request(request_id) @@ -351,7 +353,7 @@ def _outcome_from_request(req, approval_row=None) -> Outcome: } reason = "approval_rejected" if req["error"] == "approval_rejected" else None approver = approval_row["approver"] if approval_row else None - note = approval_row["note"] if approval_row else None + note = redact(approval_row["note"]) if approval_row else None decided_at = approval_row["decided_at"] if approval_row else None return Outcome(mapping.get(status, UNKNOWN), request_id=req["id"], result=result, error=req["error"], reason=reason, diff --git a/broker/server.py b/broker/server.py index b541882..ac51b0b 100644 --- a/broker/server.py +++ b/broker/server.py @@ -30,6 +30,7 @@ from .audit import AuditLog, stderr_sink from .context import BrokerContext from .gateway import handle +from .identity import authenticate from .ratelimit import RateLimiter from .registry import Registry from .runtime import HttpRuntime @@ -242,6 +243,8 @@ def _read_body(self): cap = self._body_cap() if length > cap: return _TooLarge(cap) + if length > MAX_BODY_BYTES and authenticate(self._ctx().store, self.headers.get("Authorization")) is None: + raise PermissionError("unauthorized") raw = self.rfile.read(length) if len(raw) != length: raise ValueError("incomplete body") @@ -255,6 +258,10 @@ def _read_body(self): def _respond(self) -> None: try: body = self._read_body() + except PermissionError: + self.close_connection = True + self._send_json(401, b'{"error":"unauthorized"}', uuid.uuid4().hex) + return except ValueError: self.send_error(400) return diff --git a/broker/tests/test_request_guards.py b/broker/tests/test_request_guards.py new file mode 100644 index 0000000..a7c4c40 --- /dev/null +++ b/broker/tests/test_request_guards.py @@ -0,0 +1,69 @@ +"""Input admission and approval receipt regressions with fake transports.""" +import io +from email.message import Message +from types import SimpleNamespace +from unittest.mock import Mock + +from broker import approval, mcp +from broker.gateway import handle +from broker.ratelimit import RateLimiter +from broker.server import _Handler, MAX_BODY_BYTES +from .support import BrokerTestCase, seed_caller + + +class RequestGuards(BrokerTestCase): + def test_large_body_authentication_precedes_read(self): + ctx = self.make_ctx() + token = seed_caller(ctx.store) + handler = object.__new__(_Handler) + handler.server = SimpleNamespace(ctx=ctx) + handler.path = "/v1/actions/echo.say" + handler.headers = Message() + handler.headers["Content-Length"] = str(MAX_BODY_BYTES + 1) + handler.rfile = Mock() + with self.assertRaises(PermissionError): + handler._read_body() + handler.rfile.read.assert_not_called() + handler.headers["Authorization"] = f"Bearer {token}" + handler.rfile = io.BytesIO(b" " * (MAX_BODY_BYTES - 1) + b"{}") + self.assertEqual(handler._read_body(), {}) + + def test_invalid_names_never_reserve_or_lookup(self): + ctx = self.make_ctx() + token = seed_caller(ctx.store) + headers = {"Authorization": f"Bearer {token}", "Idempotency-Key": "fake-key"} + for name in ("echo.bad/name", "echo.bad%00", "-echo.say", "echo.\n"): + response = handle("POST", "/v1/actions/" + name, headers, {}, ctx) + self.assertEqual(response.status, 400) + for name in ("echo__bad/name", "-echo__say", "echo__bad%00"): + self.assertEqual(mcp._resolve_name(ctx, name), (None, None)) + self.assertEqual(ctx.store.list_requests(), []) + self.assertFalse(any(e["event_type"] == "tool_lookup_failed" for e in ctx.audit.events())) + self.assertNotIn("bad/name", str(ctx.audit.events())) + + def test_status_polls_are_metered_before_surface_contact(self): + ctx = self.make_ctx(catalog={"echo": {"say": "read"}}) + token = seed_caller(ctx.store, review=["echo.say"]) + headers = {"Authorization": f"Bearer {token}"} + response = handle("POST", "/v1/actions/echo.say", headers, {}, ctx) + path = f'/v1/requests/{response.body["request_id"]}' + ctx.rate_limiter = RateLimiter(1) + ctx.surface.poll = Mock(wraps=ctx.surface.poll) + self.assertEqual(handle("GET", path, headers, {}, ctx).status, 200) + self.assertEqual(handle("GET", path, headers, {}, ctx).status, 429) + ctx.surface.poll.assert_called_once() + + def test_approval_note_is_masked_in_storage_and_receipt(self): + for outcome in (approval.APPROVED, approval.REJECTED): + ctx = self.make_ctx(catalog={"echo": {"say": "read"}}) + token = seed_caller(ctx.store, review=["echo.say"]) + headers = {"Authorization": f"Bearer {token}"} + response = handle("POST", "/v1/actions/echo.say", headers, {}, ctx) + rid = response.body["request_id"] + ctx.surface.set(outcome, note="Bearer fake-secret " + "words " * 200) + receipt = handle("GET", f"/v1/requests/{rid}", headers, {}, ctx) + note = ctx.store.approval_for_request(rid)["note"] + self.assertNotIn("fake-secret", note) + self.assertLessEqual(len(note), 283) + self.assertIn("[redacted]", note) + self.assertNotIn("fake-secret", str(receipt.body)) diff --git a/client/tests/test_proxy_policy.py b/client/tests/test_proxy_policy.py new file mode 100644 index 0000000..488093c --- /dev/null +++ b/client/tests/test_proxy_policy.py @@ -0,0 +1,14 @@ +import importlib +import os +import unittest +import urllib.request +from unittest.mock import patch +from client import toolstack + + +class ProxyPolicy(unittest.TestCase): + def test_ambient_proxies_are_not_installed(self): + with patch.dict(os.environ, {"HTTP_PROXY": "http://fake.invalid:1", "HTTPS_PROXY": "http://fake.invalid:1"}): + importlib.reload(toolstack) + self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) and h.proxies + for h in toolstack._OPENER.handlers)) diff --git a/client/toolstack.py b/client/toolstack.py index b2b5af2..a82df8b 100755 --- a/client/toolstack.py +++ b/client/toolstack.py @@ -48,7 +48,7 @@ def redirect_request(self, *args, **kwargs): return None -_OPENER = urllib.request.build_opener(_NoRedirect) +_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect) def _base() -> str: diff --git a/deploy/docker/README.md b/deploy/docker/README.md index d8e68c3..248c8a5 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -9,6 +9,10 @@ does not provide proven runner registration/recovery. Do not use it for the supp release. See [runtime matrix](../../docs/runtime-matrix.md) and native deployment. The image's process runner does not isolate hostile tools. +The one-box service must be the only container on its Docker network. Admin and +broker bind all container interfaces: loopback-only published ports do not prevent +another container on the same network from connecting directly. + Before any future Docker/SPS support claim, prove: SPS lifecycle, TLS server name/CA matching the reachable service, explicit runner config and passphrase wiring, private state/log volumes, offline vault provisioning, separate broker channel credentials, diff --git a/deploy/install.sh b/deploy/install.sh index d595e65..fa3e313 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -51,7 +51,7 @@ sudo -u "$SERVICE_USER" sh -c "cd '$REPO_ROOT' && '$VENV_PIP' install --quiet '. # 4. State + log dirs (the unit's StateDirectory/ReadWritePaths keep them; pre-create # so set-password + the SPS audit logger land right instead of crashing on first write) install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 700 /var/lib/toolstack -install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 750 /var/log/toolstack +install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 700 /var/log/toolstack install -d -o root -g "$SERVICE_GROUP" -m 750 /etc/toolstack # 5. Admin password (the panel fails closed without one) diff --git a/macapp/Sources/ToolstackApp/AppModel.swift b/macapp/Sources/ToolstackApp/AppModel.swift index 4da0fcd..76853ef 100644 --- a/macapp/Sources/ToolstackApp/AppModel.swift +++ b/macapp/Sources/ToolstackApp/AppModel.swift @@ -185,7 +185,7 @@ final class AppModel: ObservableObject { clearAdminState() restoring = false tokenStorage.delete(account) - await oldClient.setToken(nil) + try? await oldClient.logout() } func refreshAll() async { @@ -241,9 +241,12 @@ final class AppModel: ObservableObject { /// Start / stop / restart a tool, then refresh so its row reflects the new run state. func toolAction(id: String, action: String, confirmed: Bool = false) async { guard uncertainMutation == nil, !effectInFlight else { return } - if action == "restart", !confirmed { + if (action == "start" || action == "restart"), !confirmed { let epoch = generation - confirmation = Confirmation(title: "Restart \(id)?", effect: "Stops and starts \(id), interrupting active calls. Requests will not be replayed.") { + let title = action == "start" ? "Start \(id)?" : "Restart \(id)?" + let effect = "A started tool runs with this host's credentials. Only start tools you trust." + + (action == "restart" ? " Stops and starts \(id), interrupting active calls. Requests will not be replayed." : "") + confirmation = Confirmation(title: title, effect: effect) { guard epoch == self.generation else { return } await self.toolAction(id: id, action: action, confirmed: true) } diff --git a/macapp/Sources/ToolstackApp/ToolstackApp.swift b/macapp/Sources/ToolstackApp/ToolstackApp.swift index a5c2da5..a5add35 100644 --- a/macapp/Sources/ToolstackApp/ToolstackApp.swift +++ b/macapp/Sources/ToolstackApp/ToolstackApp.swift @@ -116,7 +116,7 @@ struct ContentView: View { .alert(model.confirmation?.title ?? "Confirm", isPresented: Binding( get: { model.confirmation != nil }, set: { if !$0 { model.confirmation = nil } } ), presenting: model.confirmation) { confirmation in - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) Button(confirmation.title.replacingOccurrences(of: "?", with: ""), role: .destructive) { Task { await confirmation.action() } } diff --git a/macapp/Sources/ToolstackKit/ApiClient.swift b/macapp/Sources/ToolstackKit/ApiClient.swift index 5185b00..9fec447 100644 --- a/macapp/Sources/ToolstackKit/ApiClient.swift +++ b/macapp/Sources/ToolstackKit/ApiClient.swift @@ -48,6 +48,11 @@ public actor ApiClient { try await send("GET", "/api/broker") } + public func logout() async throws { + defer { token = nil } + let _: [String: Bool] = try await send("POST", "/api/logout", body: [:]) + } + public func catalogObservation() async throws -> CatalogObservation { guard let token, !token.isEmpty else { throw ApiError.unauthorized } return try await send("GET", "/api/catalog") diff --git a/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift b/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift index e7d9efc..8a58f71 100644 --- a/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift +++ b/macapp/Tests/ToolstackKitTests/ActiveRefreshTests.swift @@ -17,6 +17,7 @@ final class ActiveRefreshTests: XCTestCase { case "/api/broker": json = #"{"running":true}"# case "/api/callers": json = #"{"callers":[{"id":1,"name":"demo","revoked_at":123}]}"# case "/api/tools": json = #"{"tools":[]}"# + case "/api/logout": json = #"{"ok":true}"# default: XCTFail("Unexpected request: \(request)"); json = "{}" } return (200, [:], Data(json.utf8)) @@ -80,7 +81,7 @@ final class ActiveRefreshTests: XCTestCase { await first.value; await second.value XCTAssertEqual(PendingTransport.count, 3) } - @MainActor func testSignOutDeletesStoredTokenClearsClientAndReturnsToLoginWithoutServerLogout() async throws { + @MainActor func testSignOutRevokesSessionDeletesStoredTokenAndReturnsToLogin() async throws { stub() let api = client() var tokens = [account: "FAKE-TEST-TOKEN"] @@ -94,6 +95,8 @@ final class ActiveRefreshTests: XCTestCase { XCTAssertEqual(StubURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer FAKE-TEST-TOKEN") let count = StubURLProtocol.requestCount await model.logout() + XCTAssertEqual(StubURLProtocol.lastRequest?.url?.path, "/api/logout") + XCTAssertEqual(StubURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer FAKE-TEST-TOKEN") XCTAssertEqual(deleted, [account]); XCTAssertNil(storage.load(account)) let authorized = await api.isAuthenticated XCTAssertFalse(authorized) @@ -101,7 +104,7 @@ final class ActiveRefreshTests: XCTestCase { XCTAssertTrue(model.callers.isEmpty); XCTAssertNil(model.broker) await model.refreshWhileActive(true) await model.refreshAll() - XCTAssertEqual(StubURLProtocol.requestCount, count, "Sign-out is client-side only; no logout endpoint or signed-out refresh") + XCTAssertEqual(StubURLProtocol.requestCount, count + 1, "Sign-out revokes once; no signed-out refresh") _ = try await api.brokerStatus() XCTAssertNil(StubURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization")) } @@ -114,11 +117,13 @@ final class ActiveRefreshTests: XCTestCase { PendingTransport.started = { started.fulfill() } let task = Task { await model.refreshWhileActive(true) } await fulfillment(of: [started], timeout: 2) + let held = PendingTransport.pending + PendingTransport.started = { PendingTransport.pending?.respond(200, #"{"ok":true}"#) } await model.logout() - PendingTransport.pending?.respond(200, #"{"running":true}"#) + held?.respond(200, #"{"running":true}"#) await task.value await model.refreshWhileActive(true) - XCTAssertEqual(PendingTransport.count, 1) + XCTAssertEqual(PendingTransport.count, 2) XCTAssertNil(model.broker); XCTAssertTrue(model.callers.isEmpty) } override func tearDown() { diff --git a/macapp/Tests/ToolstackKitTests/NativeContractTests.swift b/macapp/Tests/ToolstackKitTests/NativeContractTests.swift index 7b9f3e7..c46426f 100644 --- a/macapp/Tests/ToolstackKitTests/NativeContractTests.swift +++ b/macapp/Tests/ToolstackKitTests/NativeContractTests.swift @@ -138,7 +138,7 @@ final class NativeContractTests: XCTestCase { let model = AppModel(client: client(), serverURL: "http://fake.invalid") _ = await model.refreshTools() XCTAssertNotNil(model.toolsError) - await model.toolAction(id: "demo", action: "start") + await model.toolAction(id: "demo", action: "start", confirmed: true) XCTAssertNotNil(model.uncertainMutation) XCTAssertNotNil(model.toolsError) listingWorks = true @@ -170,6 +170,10 @@ final class NativeContractTests: XCTestCase { XCTAssertEqual(effects, ["/api/tools"]) XCTAssertFalse(model.registrationObserved(for: "demo")) await model.toolAction(id: "demo", action: "start") + XCTAssertEqual(effects.count, 1) + XCTAssertEqual(model.confirmation?.title, "Start demo?") + XCTAssertTrue(model.confirmation?.effect.contains("this host's credentials") == true) + await model.confirmation?.action() XCTAssertEqual(effects.count, 2) XCTAssertFalse(model.registrationObserved(for: "demo")) await model.brokerAction("restart") diff --git a/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift index 04b6dff..c471301 100644 --- a/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift +++ b/macapp/Tests/ToolstackKitTests/StalePublicationTests.swift @@ -168,7 +168,7 @@ final class StalePublicationTests: XCTestCase { let oldTask = Task { await model.refreshTools() } await fulfillment(of: [first], timeout: 2) let old = try XCTUnwrap(PendingTransport.pending) - let actionTask = Task { await model.toolAction(id: "demo", action: "start") } + let actionTask = Task { await model.toolAction(id: "demo", action: "start", confirmed: true) } await fulfillment(of: [action], timeout: 2) PendingTransport.pending?.respond(200, #"{"id":"demo"}"#) await fulfillment(of: [listing], timeout: 2) diff --git a/toolstack_forwarder/outbound.py b/toolstack_forwarder/outbound.py index b81d44d..319535d 100644 --- a/toolstack_forwarder/outbound.py +++ b/toolstack_forwarder/outbound.py @@ -14,7 +14,7 @@ def redirect_request(self, *args, **kwargs): return None -_OPENER = urllib.request.build_opener(_NoRedirect) +_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect) _HOP_BY_HOP = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", diff --git a/toolstack_forwarder/tests/test_proxy_policy.py b/toolstack_forwarder/tests/test_proxy_policy.py new file mode 100644 index 0000000..40e61d3 --- /dev/null +++ b/toolstack_forwarder/tests/test_proxy_policy.py @@ -0,0 +1,14 @@ +import importlib +import os +import unittest +import urllib.request +from unittest.mock import patch +from toolstack_forwarder import outbound + + +class ProxyPolicy(unittest.TestCase): + def test_ambient_proxies_are_not_installed(self): + with patch.dict(os.environ, {"HTTP_PROXY": "http://fake.invalid:1", "HTTPS_PROXY": "http://fake.invalid:1"}): + importlib.reload(outbound) + self.assertFalse(any(isinstance(h, urllib.request.ProxyHandler) and h.proxies + for h in outbound._OPENER.handlers)) diff --git a/toolyard/egress_proxy.py b/toolyard/egress_proxy.py index 41b7f0a..a0d6ac7 100644 --- a/toolyard/egress_proxy.py +++ b/toolyard/egress_proxy.py @@ -12,7 +12,9 @@ HTTP: the tool sends an absolute-form request (`GET http://host/..`); the proxy checks the host and forwards it in origin form. -Host matching is exact. Run detached by the runner; serves until killed on tool stop: +Host matching is exact. CONNECT permits port 443 for a bare host entry; other +ports require an explicit host:port entry (IPv6: [host]:port). +Run detached by the runner; serves until killed on tool stop: python3 -m toolyard.egress_proxy --port --allow [--allow ...] """ @@ -144,11 +146,19 @@ def _deny(self, host: str) -> None: def do_CONNECT(self) -> None: self.close_connection = True - host, _, port = self.path.partition(":") - if host not in allow: - return self._deny(host) try: - upstream = self._connect((host, int(port or 443))) + target = urlsplit("//" + self.path) + host = target.hostname + port = 443 if target.port is None else target.port + if not host or not 1 <= port <= 65535 or target.username is not None or target.password is not None or target.path or target.query or target.fragment: + return self._deny("invalid destination") + except ValueError: + return self._deny("invalid destination") + authority = f"[{host}]:{port}" if ":" in host else f"{host}:{port}" + if not (authority in allow or (port == 443 and host in allow)): + return self._deny(authority) + try: + upstream = self._connect((host, port)) except (OSError, ValueError): return self.send_error(502, "upstream failed") try: diff --git a/toolyard/runner.py b/toolyard/runner.py index 27a04dc..aa2444f 100644 --- a/toolyard/runner.py +++ b/toolyard/runner.py @@ -61,7 +61,7 @@ def _child_environment() -> dict[str, str]: """ allowed = {"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "TZ", "PYTHONPATH", "VIRTUAL_ENV", "PYTHONUNBUFFERED", "SSL_CERT_FILE", - "SSL_CERT_DIR", "TOOLSTACK_SPS_FAKE"} + "SSL_CERT_DIR"} return {key: value for key, value in os.environ.items() if key in allowed or key.startswith("LC_")} @@ -71,10 +71,23 @@ def _tool_log_path(tool_def: ToolDef) -> Path: the tool, not an opaque global state directory.""" d = tool_def.path / "logs" d.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(d, 0o700) + fd = os.open(d, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fchmod(fd, 0o700) + finally: + os.close(fd) return d / "tool.log" +def _open_tool_log(path: Path) -> int: + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + return os.open(path.name, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, + 0o600, dir_fd=directory) + finally: + os.close(directory) + + def _check_port_free(port: int) -> None: """Fail early + clearly if the tool's loopback port is already taken; otherwise the tool binds, exits, and only surfaces as a 502 at call time.""" @@ -505,7 +518,7 @@ def _stop_egress_proxy(running: RunningTool) -> None: def _start_docker_log_follower(container: str, log_path: Path, *, _on_spawn=None) -> str: - fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + fd = _open_tool_log(log_path) os.fchmod(fd, 0o600) try: pid = os.posix_spawn( @@ -670,7 +683,7 @@ def mark_reaped(): # warn about; setpgroup=0 gives it its own group so stop() can killpg it. inner_script = f"cd {shlex.quote(str(tool_def.path))} && exec {_bind_interpreter(tool_def.command)}" executable, argv = self._spawn_argv(tool_def, inner_script, None, egress_port) - log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) + log_fd = _open_tool_log(log_path) os.fchmod(log_fd, 0o600) try: pid = os.posix_spawn( diff --git a/toolyard/tests/test_egress_proxy.py b/toolyard/tests/test_egress_proxy.py index 9000be8..e3642a5 100644 --- a/toolyard/tests/test_egress_proxy.py +++ b/toolyard/tests/test_egress_proxy.py @@ -71,7 +71,7 @@ def setUp(self): echo_thread.start() self.addCleanup(self._stop_echo, echo_thread) - self.proxy = serve(_free_port(), ["127.0.0.1"]) + self.proxy = serve(_free_port(), ["127.0.0.1", f"127.0.0.1:{self.echo_port}"]) self.proxy.daemon_threads = False proxy_thread = threading.Thread(target=self.proxy.serve_forever) proxy_thread.start() @@ -248,6 +248,11 @@ def test_connect_to_denied_host_is_403(self): self.addCleanup(s.close) self.assertIn(b"403", status) + def test_connect_to_unlisted_port_is_403(self): + s, status = self._connect(f"127.0.0.1:{self.origin_port}") + self.addCleanup(s.close) + self.assertIn(b"403", status) + if __name__ == "__main__": unittest.main() diff --git a/toolyard/tests/test_log_guards.py b/toolyard/tests/test_log_guards.py new file mode 100644 index 0000000..e0e176f --- /dev/null +++ b/toolyard/tests/test_log_guards.py @@ -0,0 +1,63 @@ +"""Tool environment and log paths remain constrained before launch.""" +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch +from unittest.mock import Mock + +from toolyard import runner + + +class LogGuards(unittest.TestCase): + def test_tunnel_default_and_explicit_ports_checked_before_connect(self): + from toolyard.egress_proxy import _handler + for authority, allow, permitted in ( + ("fake.invalid:443", {"fake.invalid"}, True), + ("fake.invalid:8443", {"fake.invalid"}, False), + ("fake.invalid:8443", {"fake.invalid:8443"}, True), + ("fake.invalid:0", {"fake.invalid"}, False), + ("[::1]:8443", {"[::1]:8443"}, True), + ("user@fake.invalid:443", {"fake.invalid"}, False), + ): + handler = object.__new__(_handler(frozenset(allow))) + handler.path = authority + handler._connect = Mock(side_effect=OSError) + handler.send_error = Mock() + handler._deny = Mock() + handler.do_CONNECT() + self.assertEqual(handler._connect.called, permitted, authority) + self.assertEqual(handler._deny.called, not permitted, authority) + + def test_fake_secret_switch_not_inherited(self): + with patch.dict(os.environ, {"TOOLSTACK_SPS_FAKE": "1", "PATH": "/bin"}, clear=True): + self.assertEqual(runner._child_environment(), {"PATH": "/bin"}) + + def test_log_directory_symlink_is_refused_without_chmod(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "target" + target.mkdir(mode=0o755) + before = target.stat().st_mode + (root / "logs").symlink_to(target, target_is_directory=True) + with self.assertRaises(OSError): + runner._tool_log_path(SimpleNamespace(path=root)) + self.assertEqual(target.stat().st_mode, before) + self.assertFalse((target / "tool.log").exists()) + + def test_log_final_component_and_swapped_parent_refuse_links(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = runner._tool_log_path(SimpleNamespace(path=root)) + target = root / "target" + target.write_text("unchanged") + path.symlink_to(target) + with self.assertRaises(OSError): + runner._open_tool_log(path) + path.unlink() + path.parent.rmdir() + path.parent.symlink_to(root, target_is_directory=True) + with self.assertRaises(OSError): + runner._open_tool_log(path) + self.assertEqual(target.read_text(), "unchanged") diff --git a/toolyard/tests/test_partial_cleanup.py b/toolyard/tests/test_partial_cleanup.py index 71ed33c..6d47a70 100644 --- a/toolyard/tests/test_partial_cleanup.py +++ b/toolyard/tests/test_partial_cleanup.py @@ -261,6 +261,9 @@ def test_docker_removal_failure_keeps_full_helper_record(self): def test_docker_log_close_failure_still_retains_spawned_helper(self): tool, _ = self.start_fixture() tool.type, tool.image = "rest", "fake-image" + log_path = runner._tool_log_path(tool) + self.enterContext(patch.object(runner, "_tool_log_path", return_value=log_path)) + self.enterContext(patch.object(runner, "_open_tool_log", return_value=99)) with patch.dict(os.environ, {"TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_E_SECRET": ""}), patch.object(runner.DockerRunner, "_docker"), patch.object(runner.os, "open", return_value=99), patch.object(runner.os, "fchmod"), patch.object(runner.os, "close", side_effect=OSError("close failed")), patch.object(runner.subprocess, "run", return_value=SimpleNamespace(returncode=0)), patch.object(runner, "_terminate", side_effect=RuntimeError()) as terminate: with self.assertRaises(runner.RunnerStartCleanupError) as error: runner.DockerRunner().start(tool) diff --git a/toolyard/tests/test_runner.py b/toolyard/tests/test_runner.py index a28a429..f672568 100644 --- a/toolyard/tests/test_runner.py +++ b/toolyard/tests/test_runner.py @@ -30,7 +30,7 @@ from broker.registry import Registry, ToolOp from broker.runtime import HttpRuntime from broker.server import build_server -from toolyard.config import load +from toolyard.config import load as _load from toolyard.egress_proxy import serve as _serve_egress_proxy from toolyard.runner import (BwrapRunner, DockerRunner, ProcessRunner, RunningTool, SeatbeltRunner, RunnerConfigError, _SANDBOX_EXEC, _netguard_argv, _seatbelt_profile) @@ -47,7 +47,7 @@ def setUpModule(): _boot_id() # Resolve before any subprocess/spawn mocks can pollute the cache. _fixture_dir = tempfile.TemporaryDirectory() _fixture_env = mock.patch.dict(os.environ, { - "TOOLSTACK_SPS_SKIP": "1", "TOOLSTACK_SPS_FAKE": "1", + "TOOLSTACK_SPS_SKIP": "1", "PYTHONPATH": str(REPO), "TOOLSTACK_SPS_ENV": str(Path(_fixture_dir.name) / "absent.env"), "TOOLSTACK_TOOLYARD_STATE": str(Path(_fixture_dir.name) / "state.json"), @@ -56,14 +56,8 @@ def setUpModule(): _fixture_env.start() # Foreign SPS credentials must never become channel credentials. os.environ.setdefault('TOOLSTACK_E_SECRET', 'foreign-sps-key') - # Phase 5: tool boot pulls secrets from SPS. The 12 tool-starting tests - # don't exercise the SPS wire (that's covered in sps/tests); they only - # need the tool subprocess to come up. TOOLSTACK_SPS_FAKE=1 makes the - # SDK's from_env() return a fake client whose cache is empty and whose - # _cli is None, so the tool boots without ever dialing SPS. The SpsRegistration - # tests reset os.environ under their own mock.patch.dict, so this flag never - # leaks into assertions there. Production never sets this env var. - os.environ.setdefault('TOOLSTACK_SPS_FAKE', '1') + # Echo subprocess fixtures explicitly enable their fake SDK in the test + # command below; no test-only switch is inherited through the runner. # Channel fixtures use only runner-minted independent keys. @@ -83,6 +77,14 @@ def _remember_running(running): TOOL_MCP_TOML = REPO / "tools" / "echo_mcp" / "toolyard.toml" SECRET = "dev-secret-123" + +def load(path): + tool = _load(path) + if path in (TOOL_TOML, TOOL_MCP_TOML): + bootstrap = "import os, runpy; os.environ['TOOLSTACK_SPS_FAKE']='1'; runpy.run_path('app.py', run_name='__main__')" + tool = dataclasses.replace(tool, command=f"{shlex.quote(sys.executable)} -B -c {shlex.quote(bootstrap)}") + return tool + # A pid value used as a mock-return value in tests that simulate `posix_spawn`. Why # this high: if a future refactor accidentally lets the value reach `os.killpg` / `os.kill` # without the test's mock being in scope, the OS will report `ProcessLookupError` (no From 99473e4195c6846c96b0b12e94b62d13da82cc0e Mon Sep 17 00:00:00 2001 From: batteryshark <1574871+batteryshark@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:08:25 -0400 Subject: [PATCH 4/4] Accept the Start confirmation in offscreen recovery fixtures --- admin/tests/webkit_fixtures.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/admin/tests/webkit_fixtures.py b/admin/tests/webkit_fixtures.py index 7407cf1..f1c0e35 100644 --- a/admin/tests/webkit_fixtures.py +++ b/admin/tests/webkit_fixtures.py @@ -69,11 +69,13 @@ UNKNOWN_HTML = r""" const form=document.querySelector('form[data-mutation]');let posts=0; window.fetch=async()=>{posts++;return {ok:true,status:200,url:form.action,text:async()=>window.RETURNED_PAGE};}; -form.requestSubmit();await wait(150); +// Start now confirms first (host-credential warning); accept it to send the one mutation. +form.requestSubmit();document.querySelector('dialog[open]').querySelectorAll('button')[1].click();await wait(150); check(document.body.textContent.includes('outcome unknown')||document.body.textContent.includes('Outcome unknown'),'returned unknown stage remains visible'); const retry=document.querySelector('form[data-mutation]'); check(!!retry&&retry.querySelector('button').disabled,'200 unknown stage cannot immediately retry without observation'); if(retry)retry.requestSubmit();await wait();check(posts===1,'returned unknown stage sends no duplicate mutation'); +document.querySelectorAll('dialog[open]').forEach(d=>d.close()); const alternate=document.querySelector('form[action$="/restart"]'); alternate.requestSubmit();const dialog=document.querySelector('dialog[open]'); if(dialog)dialog.querySelectorAll('button')[1].click();await wait(); @@ -84,7 +86,7 @@ REPLACEMENT = r""" let posts=0; window.fetch=async()=>{posts++;return {ok:true,status:200,url:'https://fixture.invalid/tools?tool=notes-demo',text:async()=>window.RETURNED_PAGE};}; -document.querySelector('form[data-mutation]').requestSubmit();await wait(150); +document.querySelector('form[data-mutation]').requestSubmit();document.querySelector('dialog[open]').querySelectorAll('button')[1].click();await wait(150); const form=document.querySelector('form[data-confirm]'); check(!!form,'replacement keeps focused recovery form'); form.requestSubmit();check(!!document.querySelector('dialog[open]'),'document.write replacement initializes production scripts');