From c576b5898f6e2f3a23356bf8cafd1899bae1e5d0 Mon Sep 17 00:00:00 2001 From: HAL Date: Mon, 19 Oct 2026 12:44:50 +0100 Subject: [PATCH] v3.51.0: superadmin can permanently delete a task from the cockpit (dashboard 0.26.0) The cockpit (and its iOS home-screen app) could dismiss a task but never delete it. The task drawer, desktop panel and phone bottom sheet alike, now has "Delete permanently" for a task in any status: open, done or dismissed. - POST /api/tasks//delete shells `pt rm --yes -- PT-N` (tombstoned, so sync clients drop it). The sidecar looks up the PT-N first: `pt rm` reads a uuid as an open-task title and would miss closed tasks. - Superadmin-only, like approval decisions: the tailnet also carries the fleet's agents, so it needs X-PTask-Decide-Token = PTASK_DASH_DECIDE_TOKEN (403 decide_token_required / decide_disabled otherwise). - /api/config reports superadmin, so the button only shows where the token is configured. The cockpit confirms first, asks for the token once and keeps it in localStorage. - Not on the Rust pt serve dashboard (no superadmin in its config); machine clients already have task_delete on /sync. CI unblock: deny.toml ignores RUSTSEC-2026-0367/0368/0369 (zip 7.2.0, published this month, failing cargo-deny on main too). All three are in zip's extract(); our only zip user, candle-core 0.10.2 (native-ml), reads entries by name and never extracts. The fix is zip >= 9 and candle 0.11.0 still pins zip ^8.6, so there is no upgrade path yet. Tests: 9 new sidecar tests (any status by uuid or PT-N, token gate, disabled without a strong token, bad ids never reach pt, cross-origin, pt failure, markup); 124 Python + 24 Node dashboard tests pass. End to end against real pt 3.50.6 on a scratch DB: a done task deleted by uuid, task.deleted tombstone attributed to dashboard. Phone-size UI checked in a browser. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 +-- Cargo.toml | 14 +-- dashboard/README.md | 29 ++++-- dashboard/server.py | 78 ++++++++++++--- dashboard/tests/test_delete.py | 177 +++++++++++++++++++++++++++++++++ dashboard/tests/test_server.py | 4 +- dashboard/www/index.html | 47 ++++++++- deny.toml | 14 ++- docs/gen/pt.1 | 4 +- 9 files changed, 337 insertions(+), 44 deletions(-) create mode 100644 dashboard/tests/test_delete.py diff --git a/Cargo.lock b/Cargo.lock index c552e886..6f3098c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "ptask-bot" -version = "3.50.7" +version = "3.51.0" dependencies = [ "anyhow", "jiff", @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ptask-cli" -version = "3.50.7" +version = "3.51.0" dependencies = [ "anyhow", "axum", @@ -2693,7 +2693,7 @@ dependencies = [ [[package]] name = "ptask-core" -version = "3.50.7" +version = "3.51.0" dependencies = [ "hex", "interim", @@ -2714,7 +2714,7 @@ dependencies = [ [[package]] name = "ptask-distill" -version = "3.50.7" +version = "3.51.0" dependencies = [ "anyhow", "candle-core", @@ -2738,7 +2738,7 @@ dependencies = [ [[package]] name = "ptask-notify" -version = "3.50.7" +version = "3.51.0" dependencies = [ "lettre", "ptask-core", @@ -2751,7 +2751,7 @@ dependencies = [ [[package]] name = "ptask-server" -version = "3.50.7" +version = "3.51.0" dependencies = [ "anyhow", "axum", @@ -2783,7 +2783,7 @@ dependencies = [ [[package]] name = "ptask-tui" -version = "3.50.7" +version = "3.51.0" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 1499b63a..52ae091c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "3.50.7" +version = "3.51.0" edition = "2024" license-file = "LICENSE" authors = ["PureTensor, Inc. "] @@ -52,12 +52,12 @@ rmcp = { version = "3.3.0", features = ["server", "macros", "schemars", "transpo futures-util = { version = "0.3", default-features = false } # Internal crates -ptask-core = { path = "crates/ptask-core", version = "3.50.7" } -ptask-tui = { path = "crates/ptask-tui", version = "3.50.7" } -ptask-server = { path = "crates/ptask-server", version = "3.50.7" } -ptask-bot = { path = "crates/ptask-bot", version = "3.50.7" } -ptask-distill = { path = "crates/ptask-distill", version = "3.50.7" } -ptask-notify = { path = "crates/ptask-notify", version = "3.50.7" } +ptask-core = { path = "crates/ptask-core", version = "3.51.0" } +ptask-tui = { path = "crates/ptask-tui", version = "3.51.0" } +ptask-server = { path = "crates/ptask-server", version = "3.51.0" } +ptask-bot = { path = "crates/ptask-bot", version = "3.51.0" } +ptask-distill = { path = "crates/ptask-distill", version = "3.51.0" } +ptask-notify = { path = "crates/ptask-notify", version = "3.51.0" } # HMAC for outbound webhook signing (v0.3.5). sha2 = "0.10" diff --git a/dashboard/README.md b/dashboard/README.md index 79ee24c1..07b1f5ba 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -4,7 +4,7 @@ A visually compelling, on-brand, live **triage cockpit** for PureTensor Task Intelligence (`pt`). Surfaces pending tasks and critical issues ranked by the server's composite `priority_score`, with four runtime-switchable themes. -**Live:** https://ptask.tail07f9ef.ts.net (tailnet-gated; no login; approval decisions need the decide token) +**Live:** https://ptask.tail07f9ef.ts.net (tailnet-gated; no login; approval decisions and permanent deletes need the decide token) ## What it is @@ -143,6 +143,7 @@ transaction. | POST | `/api/tasks//snooze` `{days?}` | shells `pt snooze " days"` (v0.12) | | POST | `/api/tasks//dismiss` | shells `pt dismiss [--note=…] ` (v0.12; optional `{"note"}` reason, v0.23) | | POST | `/api/tasks//reopen` | shells `pt reopen ` (v0.12) | +| POST | `/api/tasks//delete` | superadmin: shells `pt rm --yes -- PT-N` for a task in any status (`` is the PT-N or uuid; the sidecar looks up the PT-N, since `pt rm` reads anything else as an open-task title). Permanent, tombstoned for delta sync. Needs `X-PTask-Decide-Token` = `PTASK_DASH_DECIDE_TOKEN` (403 `decide_token_required` / `decide_disabled` otherwise); unknown task 404 (v0.26) | | POST | `/api/tasks//edit` `{title?, description?, priority?, deadline?, labels_add?, labels_remove?}` | shells `pt edit` (+ `pt priority` for level); null deadline clears (v0.12) | | POST | `/api/tasks` `{title, description?, priority?, deadline?}` | shells `pt add [--priority=] [--description=] [--deadline=] -- ""` | | POST | `/api/voice` (raw audio body) | Whisper STT → Bedrock Claude draft → `{transcript, fields:{title,description,priority,deadline,labels,domain,reason}}` to pre-fill the composer | @@ -182,7 +183,7 @@ PTASK_DB=/tmp/tasks.dev.db PTASK_DASH_BIND=127.0.0.1:9519 python3 server.py | `PTASK_ACTOR` | `dashboard` | actor stamped on dashboard-originated `pt` writes | | `PTASK_WAL_AUTOCHECKPOINT` | `0` in `ptask-dashboard.service` | passed to the `pt` writers the sidecar spawns, so they leave WAL checkpoints to Litestream like every other writer; set `1000` in `.dashboard.env` on a host without Litestream | | `PTASK_DASH_ALLOWED_HOSTS` | _(unset)_ | extra comma-separated `Host` names to serve besides IP literals, `localhost`, the machine's short hostname and `*.ts.net`; `.suffix` entries match the suffix | -| `PTASK_DASH_DECIDE_TOKEN` | _(unset)_ | secret (≥ 16 chars) required to approve/reject from the cockpit; unset disables approval decisions here | +| `PTASK_DASH_DECIDE_TOKEN` | _(unset)_ | superadmin secret (≥ 16 chars) required to approve/reject and to delete a task from the cockpit; unset disables both here and hides the drawer's Delete (`/api/config` → `superadmin: false`) | | `PTASK_STT_URL` | `http://127.0.0.1:9000/transcribe` | voice STT endpoint (local Whisper); accepts `-F audio=@` | | `PTASK_VOICE_MODEL` | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | Bedrock model for voice→task extraction | | `PTASK_VOICE_REGION` | `$AWS_DEFAULT_REGION` or `us-east-1` | Bedrock region (keyless, IAM via `~/.aws`) | @@ -240,18 +241,21 @@ on the tailnet is the access decision for the board, with two limits: writes included, to the internet. Remove any tunnel that predates v0.21.0. The Host check refuses unknown public names; do not add them to `PTASK_DASH_ALLOWED_HOSTS`. -- **Approval decisions need the decide token.** The tailnet carries the - fleet's agents too, and "only the operator decides" cannot rest on network - reach. Put a random secret in `~/puretensor-tasks/.dashboard.env`, restart, - and enter it once per browser when the cockpit asks: +- **Superadmin actions need the decide token.** Approval decisions and + permanent deletes are operator-only. The tailnet carries the fleet's agents + too, and "only the operator" cannot rest on network reach. Put a random + secret in `~/puretensor-tasks/.dashboard.env` (and in Vaultwarden as + `pTask cockpit superadmin token`), restart, and enter it once per browser or + home-screen app when the cockpit asks: ```bash printf 'PTASK_DASH_DECIDE_TOKEN=%s\n' "$(openssl rand -hex 24)" >> ~/puretensor-tasks/.dashboard.env systemctl --user restart ptask-dashboard ``` - Without it, approve/reject answers 403 `decide_disabled`; decide at a - terminal with `pt approve|reject AP-n` instead. + Without it, approve/reject and delete answer 403 `decide_disabled` and the + drawer offers no Delete; use `pt approve|reject AP-n` or `pt rm PT-N` at a + terminal instead. ## Rollback @@ -274,6 +278,15 @@ The canonical `pt serve` and `tasks.db` are never modified — nothing to revert ## Version +- **v0.26.0** (pt 3.51.0) — Superadmin delete. The task drawer, on desktop and + as the phone bottom sheet, has **Delete permanently** for a task in any + status (open, done or dismissed). It confirms first, then + `POST /api/tasks/<id>/delete` shells `pt rm --yes -- PT-N` (tombstoned, so + sync clients drop it). Like approval decisions it needs the decide token, + asked for once and kept in localStorage; `/api/config` reports + `superadmin` so the button only shows where the token is configured. + **Not** on the Rust `pt serve` dashboard, whose config carries no + `superadmin` (machine clients delete with `task_delete` on `/sync`). - **v0.25.0** (pt 3.48.0) — Acceptance criteria. The task drawer shows a task's definition of done as a checklist (checked / unchecked, who checked it), folded from the full criteria trail in the journal (not only the newest 60 diff --git a/dashboard/server.py b/dashboard/server.py index 87876401..39c7782c 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -23,6 +23,10 @@ POST /api/tasks/<id>/note {text} -> shells `pt note <id> -- <text>` POST /api/tasks/<id>/dismiss {note?} -> shells `pt dismiss [--note=] <id>` POST /api/tasks/<id>/priority {level:1..5} -> shells `pt priority <id> <level>` + POST /api/tasks/<id>/delete -> shells `pt rm --yes PT-N`: permanent delete + of a task in any status (tombstoned for + delta sync). Superadmin-only: needs + X-PTask-Decide-Token (see below) POST /api/tasks {title, description?, priority?, deadline?} -> shells `pt add [--priority=] [--description=] [--deadline=] -- "<title>"` @@ -47,10 +51,12 @@ DNS-rebinding page could address the sidecar under the attacker's own name and read or write as same-origin. State-changing POSTs also need a same-origin Origin header when one is sent. - - Approval decisions are operator-only, and the tailnet does not separate - the operator from the fleet's agents. Approve/reject needs the - X-PTask-Decide-Token header to match PTASK_DASH_DECIDE_TOKEN; with the - variable unset (or shorter than 16 characters) decisions are refused. + - Approval decisions and permanent deletes are operator-only (superadmin), + and the tailnet does not separate the operator from the fleet's agents. + Both need the X-PTask-Decide-Token header to match + PTASK_DASH_DECIDE_TOKEN; with the variable unset (or shorter than 16 + characters) they are refused. /api/config reports `superadmin` so the + cockpit only offers Delete where it can work. Config (env) ------------ @@ -65,8 +71,9 @@ (default "dashboard") PTASK_DASH_ALLOWED_HOSTS extra comma-separated Host names to serve (".example.org" entries match the suffix) - PTASK_DASH_DECIDE_TOKEN secret the cockpit sends to approve/reject - (unset = approval decisions disabled here) + PTASK_DASH_DECIDE_TOKEN superadmin secret the cockpit sends to + approve/reject and to delete a task (unset = + decisions and deletes disabled here) """ from __future__ import annotations @@ -140,7 +147,7 @@ def resolve_default_domain(domains: list[dict], requested: str | None) -> str | # the dashboard exposes the same task data. Production sets PTASK_DASH_BIND. BIND = os.environ.get("PTASK_DASH_BIND", "127.0.0.1:9510") -VERSION = "0.25.0" +VERSION = "0.26.0" def _allowed_host_entry(entry: str) -> str: @@ -159,9 +166,10 @@ def _allowed_host_entry(entry: str) -> str: filter(None, (_allowed_host_entry(h) for h in os.environ.get("PTASK_DASH_ALLOWED_HOSTS", "").split(","))) ) -# Shared secret for approval decisions. Shorter values are refused rather than -# trusted: a guessable secret would put decisions back within any tailnet -# agent's reach. Popped from the environment so no pt or aws child inherits it. +# Superadmin secret for approval decisions and permanent deletes. Shorter +# values are refused rather than trusted: a guessable secret would put both +# back within any tailnet agent's reach. Popped from the environment so no pt +# or aws child inherits it. DECIDE_TOKEN_MIN = 16 DECIDE_TOKEN = os.environ.pop("PTASK_DASH_DECIDE_TOKEN", "").strip() DASH_TITLE = os.environ.get("PTASK_DASH_TITLE", "PTASK") @@ -712,6 +720,24 @@ def _event_dicts(rows): return events +def q_task_ref(task_id: str) -> dict | None: + """PT-N, title and status of one task in any status, by PT-N or uuid. + + `pt rm` resolves a PT-N in any status but reads anything else as a title + substring of an open task, so a delete always hands it the PT-N. + """ + con = connect() + try: + r = con.execute( + "SELECT id, pt_id, title, status_v2 AS status FROM tasks" + " WHERE pt_id=? OR id=? LIMIT 1", + (task_id.upper(), task_id), + ).fetchone() + return dict(r) if r else None + finally: + con.close() + + def q_task_events(task_uuid: str, limit: int = 60): """Return attributed event history for the detail drawer, newest first. @@ -1329,17 +1355,19 @@ def _redirect(self, location="/", code=302): def _host_ok(self) -> bool: return host_allowed(self.headers.get("Host")) - def _decide_refusal(self): - """None when this request may decide an approval, else (code, message).""" + def _decide_refusal(self, what="approval decisions", + fallback="decide with `pt approve|reject AP-n` at a terminal"): + """None when this request carries the superadmin (decide) token, else + (code, message). Gates approval decisions and permanent deletes.""" if len(DECIDE_TOKEN) < DECIDE_TOKEN_MIN: return ("decide_disabled", - "approval decisions are disabled on this dashboard: set " + f"{what} are disabled on this dashboard: set " f"PTASK_DASH_DECIDE_TOKEN (at least {DECIDE_TOKEN_MIN} characters), " - "or decide with `pt approve|reject AP-n` at a terminal") + f"or {fallback}") presented = (self.headers.get("X-PTask-Decide-Token") or "").strip() if presented and hmac.compare_digest(presented.encode(), DECIDE_TOKEN.encode()): return None - return ("decide_token_required", "approval decisions need the decide token") + return ("decide_token_required", f"{what} need the decide token") def _origin_ok(self) -> bool: # CSRF for state-changing requests: a browser on the tailnet sending @@ -1511,6 +1539,9 @@ def do_GET(self): "domains": DASH_DOMAINS, "default_domain": DASH_DEFAULT_DOMAIN, "version": VERSION, + # Superadmin actions (permanent delete) are configured here; + # the token itself is still checked on every such request. + "superadmin": len(DECIDE_TOKEN) >= DECIDE_TOKEN_MIN, }) try: @@ -1662,6 +1693,23 @@ def do_POST(self): ok, msg = pt_exec([verb, "--", tid]) return self._json({"ok": ok, "message": msg}, 200 if ok else 500) + m = re.match(r"^/api/tasks/([^/]+)/delete$", u.path) + if m: + tid = m.group(1) + if not _ID_RE.match(tid): + return self._json({"error": "bad id"}, 400) + refusal = self._decide_refusal( + "permanent deletes", "delete with `pt rm PT-N` at a terminal") + if refusal is not None: + code, message = refusal + return self._json({"ok": False, "code": code, "error": message}, 403) + task = q_task_ref(tid) + if task is None or not task["pt_id"]: + return self._json({"ok": False, "error": f"no task {tid}"}, 404) + ok, msg = pt_exec(["rm", "--yes", "--", task["pt_id"]]) + return self._json({"ok": ok, "pt_id": task["pt_id"], "message": msg}, + 200 if ok else 500) + m = re.match(r"^/api/tasks/([^/]+)/note$", u.path) if m: tid = m.group(1) diff --git a/dashboard/tests/test_delete.py b/dashboard/tests/test_delete.py new file mode 100644 index 00000000..7d7d31cb --- /dev/null +++ b/dashboard/tests/test_delete.py @@ -0,0 +1,177 @@ +"""Superadmin permanent delete (dashboard). + +The cockpit's drawer offers "Delete permanently" for a task in any status. +`POST /api/tasks/<id>/delete` shells `pt rm --yes -- PT-N` and, like approval +decisions, is operator-only: the tailnet also carries the fleet's agents, so +it needs the X-PTask-Decide-Token. These tests drive the real handler over +HTTP against a scratch DB and a fake `pt` that records its argv. +""" + +import http.client +import json +import os +import sqlite3 +import stat +import tempfile +import threading +import unittest +from pathlib import Path + +import server + +FAKE_PT = r"""#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ["FAKE_PT_LOG"], "a") as f: + f.write(json.dumps({"args": args}) + "\n") +if args[-1] == "PT-13": + print("error: database is locked", file=sys.stderr) + sys.exit(1) +print("deleted " + args[-1]) +""" + +TOKEN = "operator-decide-token-0123456789" +UUID_DONE = "0b6340f6-1618-434b-ba03-46d9a14df49e" + + +class SuperadminDeleteTests(unittest.TestCase): + def setUp(self): + self.td = tempfile.TemporaryDirectory() + td = Path(self.td.name) + db = td / "tasks.db" + con = sqlite3.connect(db) + con.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, pt_id TEXT, title TEXT," + " status_v2 TEXT)") + con.executemany("INSERT INTO tasks VALUES (?,?,?,?)", [ + ("11111111-1111-4111-8111-111111111111", "PT-12", "open task", "pending"), + (UUID_DONE, "PT-40", "finished task", "done"), + ("33333333-3333-4333-8333-333333333333", "PT-41", "dismissed task", "dismissed"), + ("44444444-4444-4444-8444-444444444444", "PT-13", "locked task", "pending"), + ]) + con.commit() + con.close() + self.log = td / "calls.jsonl" + fake = td / "pt" + fake.write_text(FAKE_PT) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC) + self.saved = (server.PT_BIN, server.DB_PATH, server.DECIDE_TOKEN, + os.environ.get("FAKE_PT_LOG")) + server.PT_BIN, server.DB_PATH, server.DECIDE_TOKEN = str(fake), str(db), TOKEN + os.environ["FAKE_PT_LOG"] = str(self.log) + self.httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + self.host = f"127.0.0.1:{self.httpd.server_port}" + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + server.PT_BIN, server.DB_PATH, server.DECIDE_TOKEN, log = self.saved + if log is None: + os.environ.pop("FAKE_PT_LOG", None) + else: + os.environ["FAKE_PT_LOG"] = log + self.td.cleanup() + + def request(self, method, path, body=None, origin="self", token=TOKEN): + conn = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port) + headers = {} + if token is not None: + headers["X-PTask-Decide-Token"] = token + if origin == "self": + headers["Origin"] = f"http://{self.host}" + elif origin: + headers["Origin"] = origin + payload = None + if body is not None: + payload = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + raw = resp.read() + conn.close() + return resp.status, (json.loads(raw) if raw else None) + + def calls(self): + if not self.log.exists(): + return [] + return [json.loads(line)["args"] for line in self.log.read_text().splitlines()] + + def test_delete_shells_pt_rm_with_the_pt_id(self): + status, data = self.request("POST", "/api/tasks/PT-12/delete", {}) + self.assertEqual(status, 200, data) + self.assertTrue(data["ok"]) + self.assertEqual(data["pt_id"], "PT-12") + self.assertEqual(self.calls(), [["rm", "--yes", "--", "PT-12"]]) + + def test_any_status_can_be_deleted_by_uuid_or_pt_id(self): + # The drawer posts the task uuid; pt rm only resolves a PT-N in any + # status, so the sidecar must translate. Closed tasks are deletable. + status, data = self.request("POST", f"/api/tasks/{UUID_DONE}/delete", {}) + self.assertEqual(status, 200, data) + status, data = self.request("POST", "/api/tasks/PT-41/delete", {}) + self.assertEqual(status, 200, data) + self.assertEqual(self.calls(), [["rm", "--yes", "--", "PT-40"], + ["rm", "--yes", "--", "PT-41"]]) + + def test_delete_needs_the_superadmin_token(self): + for origin in ("self", None): + status, data = self.request("POST", "/api/tasks/PT-12/delete", {}, + origin=origin, token=None) + self.assertEqual(status, 403, data) + self.assertEqual(data["code"], "decide_token_required") + self.assertIn("permanent deletes", data["error"]) + status, data = self.request("POST", "/api/tasks/PT-12/delete", {}, + token="not-the-operator-token-at-all") + self.assertEqual(status, 403, data) + self.assertEqual(self.calls(), []) + + def test_delete_is_disabled_without_a_strong_configured_token(self): + for configured in ("", "short-token"): + server.DECIDE_TOKEN = configured + status, data = self.request("POST", "/api/tasks/PT-12/delete", {}, + token=configured or None) + self.assertEqual(status, 403, data) + self.assertEqual(data["code"], "decide_disabled") + self.assertIn("pt rm PT-N", data["error"]) + self.assertEqual(self.calls(), []) + + def test_config_reports_superadmin_only_with_a_strong_token(self): + status, cfg = self.request("GET", "/api/config", origin=None, token=None) + self.assertEqual(status, 200) + self.assertIs(cfg["superadmin"], True) + self.assertNotIn(TOKEN, json.dumps(cfg)) + server.DECIDE_TOKEN = "short-token" + _, cfg = self.request("GET", "/api/config", origin=None, token=None) + self.assertIs(cfg["superadmin"], False) + + def test_bad_or_unknown_ids_never_reach_pt(self): + for bad in ("--yes", "PT-", "x;rm", "AP-7"): + status, _ = self.request("POST", f"/api/tasks/{bad}/delete", {}) + self.assertIn(status, (400, 404), bad) + status, data = self.request("POST", "/api/tasks/PT-999/delete", {}) + self.assertEqual(status, 404, data) + self.assertEqual(self.calls(), []) + + def test_cross_origin_delete_rejected(self): + status, _ = self.request("POST", "/api/tasks/PT-12/delete", {}, + origin="https://evil.example") + self.assertEqual(status, 403) + self.assertEqual(self.calls(), []) + + def test_pt_failure_is_reported(self): + status, data = self.request("POST", "/api/tasks/PT-13/delete", {}) + self.assertEqual(status, 500, data) + self.assertFalse(data["ok"]) + self.assertIn("database is locked", data["message"]) + + def test_drawer_offers_delete_behind_the_config_flag(self): + html = (Path(server.__file__).parent / "www" / "index.html").read_text() + self.assertIn('id="d-delete"', html) + self.assertIn("/delete'", html) + self.assertIn("cfg.superadmin", html) + self.assertIn("$('#d-danger').hidden=!_superadmin", html) + self.assertIn("window.confirm('Permanently delete", html) + + +if __name__ == "__main__": + unittest.main() diff --git a/dashboard/tests/test_server.py b/dashboard/tests/test_server.py index 1f581625..f61684dd 100644 --- a/dashboard/tests/test_server.py +++ b/dashboard/tests/test_server.py @@ -1407,7 +1407,9 @@ def test_config_is_public_and_reflects_env(self): self.assertEqual([d["key"] for d in cfg["domains"]], ["puretensor", "personal"]) self.assertEqual(cfg["domains"][1]["abbr"], "ME") self.assertEqual(cfg["version"], server.VERSION) - self.assertEqual(set(cfg), {"title", "domains", "default_domain", "version"}) + self.assertEqual(set(cfg), + {"title", "domains", "default_domain", "version", "superadmin"}) + self.assertIs(cfg["superadmin"], len(server.DECIDE_TOKEN) >= server.DECIDE_TOKEN_MIN) finally: httpd.shutdown() httpd.server_close() diff --git a/dashboard/www/index.html b/dashboard/www/index.html index 78ee89f8..443fa309 100644 --- a/dashboard/www/index.html +++ b/dashboard/www/index.html @@ -729,6 +729,12 @@ padding:8px 11px;font-family:var(--font);font-size:12px;width:100%;resize:vertical;min-height:54px;line-height:1.45} .dnote-form textarea:focus,.cnote textarea:focus{outline:none;border-color:var(--accent);box-shadow:var(--glow)} .dnote-form .mbtn{align-self:flex-end} +/* superadmin-only permanent delete: outlined in the critical colour, a full + touch target, set apart from the triage verbs */ +.drawer .ddanger{border-top:1px solid var(--border);padding-top:12px;margin-bottom:16px;display:flex;justify-content:flex-end} +.mbtn.danger{color:var(--p5t);border-color:var(--p5t);display:inline-flex;align-items:center;gap:6px;min-height:44px} +.mbtn.danger:hover{background:rgba(255,59,92,.12);border-color:var(--p5t)} +.mbtn.danger:disabled{opacity:.5;cursor:default} .cnote{display:flex;flex-direction:column;gap:6px;margin-bottom:16px} .cnote label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)} .cnote label .opt{color:var(--dim);font-weight:600;text-transform:none;letter-spacing:0} @@ -1038,6 +1044,9 @@ <h3 id="d-title"></h3> <button type="submit" class="mbtn" id="d-note-add">Add note</button> </form> </div> + <div class="ddanger" id="d-danger" hidden> + <button type="button" class="mbtn danger" id="d-delete"><svg class="ic" aria-hidden="true"><use href="#i-trash"/></svg> Delete permanently</button> + </div> <div class="dhist"><h4>History</h4><div id="d-hist"></div></div> </aside> <!-- body-level overlays (survive the 15s #lanes/#crit re-render) --> @@ -1192,9 +1201,12 @@ <h3 id="compose-h">New task</h3> const saved=localStorage.getItem('ptask-domain')||'all'; setDomain(saved==='all'||_domainByKey.has(saved)?saved:'all'); } +/* true when this dashboard has the superadmin token configured: only then is + the drawer's permanent Delete offered (the server still checks the token) */ +let _superadmin=false; const _configReady=fetch('/api/config',{headers:{'Accept':'application/json'}}) .then(r=>{if(!r.ok)throw new Error('/api/config '+r.status);return r.json();}) - .then(cfg=>applyDomainConfig(cfg)); + .then(cfg=>{_superadmin=!!(cfg&&cfg.superadmin);applyDomainConfig(cfg);}); const ENG_PROJECTS=new Set(['infra','ptve','specola','puresentinel','nexus','immune','vulcan', 'scriptorium','habla','pureprospect','instance08-hardening','ptask','puremind','warroom']); @@ -2377,6 +2389,7 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t. $('#d-hist').innerHTML='<span class="age">loading…</span>'; $('#d-crit-wrap').hidden=true; $('#d-notes').innerHTML=''; + $('#d-danger').hidden=!_superadmin; if(_drawerUuid!==uuid) $('#d-note').value=''; _drawerUuid=uuid; dr.classList.add('open'); @@ -2457,6 +2470,38 @@ <h3 style="cursor:pointer" role="button" tabindex="0" data-act="drawer">${esc(t. }catch(err){toast('✗ '+err.message);} finally{btn.disabled=false;} }); +/* Permanent delete, any status (pt rm: tombstoned, cannot be undone). The + sidecar treats it as superadmin-only, like approval decisions, so it wants + the same decide token: asked for once, kept in localStorage. */ +function postDelete(uuid, token){ + return fetch('/api/tasks/'+encodeURIComponent(uuid)+'/delete',{method:'POST', + headers:{'Content-Type':'application/json','X-PTask-Decide-Token':token},body:'{}'}); +} +$('#d-delete').onclick=async()=>{ + const uuid=_drawerUuid; if(!uuid) return; + const t=_pendingAll.find(x=>x.id===uuid)||_recentAll.find(x=>x.id===uuid); + const label=(t&&t.pt_id?t.pt_id+' ':'')+'"'+((t&&t.title)||$('#d-title').textContent)+'"'; + if(!window.confirm('Permanently delete '+label+'?\n\nThis cannot be undone.')) return; + const btn=$('#d-delete'); btn.disabled=true; + try{ + let r=await postDelete(uuid, storedDecideToken()); + let j=await r.json().catch(()=>({})); + if(r.status===403 && j.code==='decide_token_required'){ + const entered=(window.prompt('Superadmin token (PTASK_DASH_DECIDE_TOKEN)')||'').trim(); + if(entered){ + r=await postDelete(uuid, entered); + j=await r.json().catch(()=>({})); + if(r.ok && j.ok){try{localStorage.setItem(DECIDE_KEY, entered);}catch(e){}} + } + } + if(r.ok && j.ok){ + toast('🗑 '+(j.pt_id||uuid)+' deleted permanently'); + if(_drawerUuid===uuid) closeDrawer(); + load(true); + } else toast('✗ '+(j.error||j.message||'delete failed')); + }catch(e){toast('✗ '+e.message);} + finally{btn.disabled=false;} +}; function drawerOpen(){ return $('#drawer').classList.contains('open'); } function closeDrawer(){ $('#drawer').classList.remove('open'); $('#drawer-scrim').classList.remove('show'); restoreInvoker(); } $('#d-close').onclick=closeDrawer; diff --git a/deny.toml b/deny.toml index 6cceaf39..6af5d113 100644 --- a/deny.toml +++ b/deny.toml @@ -8,9 +8,8 @@ all-features = true [advisories] -# Accepted "unmaintained" notices (no vulnerability), both reachable only -# through the native-ml feature, neither with an upgrade path today. -# Revisit when candle/tokenizers/hf-hub move on. +# Accepted notices, all reachable only through the native-ml feature, none +# with an upgrade path today. Revisit when candle/tokenizers/hf-hub move on. ignore = [ # paste 1.0.15: proc-macro crate, archived by its author. Pulled in by # gemm/candle-core, pulp, macro_rules_attribute and tokenizers; it runs @@ -19,6 +18,15 @@ ignore = [ # number_prefix 0.4.0: formats byte counts for indicatif 0.17, which # hf-hub 0.4.3 pins. Pure formatting, no I/O or unsafe surface we feed. { id = "RUSTSEC-2025-0119", reason = "unmaintained formatting helper via hf-hub -> indicatif 0.17; hf-hub pins it" }, + # zip 7.2.0 extraction flaws (published 2026-10): SUID/SGID bits kept, + # symlink targets and permissions applied outside the destination. All + # three are in ZipArchive::extract / ZipStreamReader::extract. Our only + # zip user is candle-core 0.10.2, which opens archives and reads entries + # by name (pickle.rs, npy.rs) or writes .npz, and never extracts. The fix + # is zip >= 9.0; candle-core 0.11.0 still pins zip ^8.6. + { id = "RUSTSEC-2026-0367", reason = "zip extract() keeps SUID/SGID; candle-core never extracts; no candle release on zip 9" }, + { id = "RUSTSEC-2026-0368", reason = "zip extract() symlink escape; candle-core never extracts; no candle release on zip 9" }, + { id = "RUSTSEC-2026-0369", reason = "zip extract() permissions via symlinks; candle-core never extracts; no candle release on zip 9" }, ] [sources] diff --git a/docs/gen/pt.1 b/docs/gen/pt.1 index 7080583b..54a5e880 100644 --- a/docs/gen/pt.1 +++ b/docs/gen/pt.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH pt 1 "pt 3.50.7" +.TH pt 1 "pt 3.51.0" .SH NAME pt \- Sovereign task manager for PureTensor .SH SYNOPSIS @@ -208,4 +208,4 @@ Generate shell completions (bash/zsh/fish) to stdout pt\-help(1) Print this message or the help of the given subcommand(s) .SH VERSION -v3.50.7 +v3.51.0