Require permissions-debug for /-/threads

/-/threads exposes runtime internals - thread idents and asyncio task
reprs including file paths - but only required view-instance. It now
requires permissions-debug, like /-/actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
Claude 2026-07-04 16:22:31 +00:00
commit e5e9aca871
No known key found for this signature in database
8 changed files with 30 additions and 14 deletions

View file

@ -2584,7 +2584,9 @@ class Datasette:
r"/-/config(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(self, "threads.json", self._threads),
JsonDataView.as_view(
self, "threads.json", self._threads, permission="permissions-debug"
),
r"/-/threads(\.(?P<format>json))?$",
)
add_route(

View file

@ -252,7 +252,7 @@ Without those query string arguments, the page lists up to five tables with dete
/-/threads
----------
Shows details of threads and ``asyncio`` tasks. `Threads example <https://latest.datasette.io/-/threads>`_:
Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``permissions-debug`` permission, since it exposes runtime internals. `Threads example <https://latest.datasette.io/-/threads>`_:
.. code-block:: json

View file

@ -249,7 +249,7 @@ value replaced by `"***"` (utils/__init__.py:1532-1556).
### GET /-/threads(.json)
app.py:2566-2569, `Datasette._threads` (app.py:2268-2285). Permission
`view-instance`. No parameters.
**`permissions-debug`** (exposes runtime internals). No parameters.
Response: `num_threads`, `threads` (list of `{name, ident, daemon}`),
`num_tasks`, `tasks` (asyncio task repr strings). When the

View file

@ -306,9 +306,10 @@ Concerns:
unauthorized actors get a uniform response.~~ ✅ **Done** — permission is
checked first; the table schema view also now 404s (instead of a 500
KeyError) for an unknown database.
- **(P2) `/-/threads` exposes runtime internals** (thread idents, asyncio
- ~~**(P2) `/-/threads` exposes runtime internals** (thread idents, asyncio
task reprs including file paths) behind only `view-instance`. Consider
`permissions-debug`, alongside `/-/actions` which already requires it.
`permissions-debug`, alongside `/-/actions` which already requires it.~~
**Done**`/-/threads` now requires `permissions-debug`.
- **(P3) `/-/config` redaction is substring-based** on six key names
(app.py:2502-2505); plugins storing secrets under other names leak. Worth
a note in plugin authoring docs plus a `redact_keys` plugin hook.

View file

@ -525,7 +525,11 @@ def test_databases_json(app_client_two_attached_databases_one_immutable):
@pytest.mark.asyncio
async def test_threads_json(ds_client):
response = await ds_client.get("/-/threads.json")
ds_client.ds.root_enabled = True
try:
response = await ds_client.get("/-/threads.json", actor={"id": "root"})
finally:
ds_client.ds.root_enabled = False
expected_keys = {"ok", "threads", "num_threads"}
if sys.version_info >= (3, 7, 0):
expected_keys.update({"tasks", "num_tasks"})

View file

@ -554,13 +554,9 @@ async def test_schema_endpoints_no_existence_oracle(tmp_path_factory):
assert denied_existing.status_code == denied_missing.status_code == 403
# An authorized actor sees the real thing
root_existing = await ds.client.get(
"/data/-/schema.json", actor={"id": "root"}
)
root_existing = await ds.client.get("/data/-/schema.json", actor={"id": "root"})
assert root_existing.status_code == 200
root_missing = await ds.client.get(
"/nope/-/schema.json", actor={"id": "root"}
)
root_missing = await ds.client.get("/nope/-/schema.json", actor={"id": "root"})
assert root_missing.status_code == 404
finally:
ds.close()
@ -604,3 +600,15 @@ async def test_unknown_extra_ignored_on_html_pages(ds_client):
response = await ds_client.get("/fixtures/facetable?_extra=nope")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
# /-/threads exposes runtime internals and requires permissions-debug
@pytest.mark.asyncio
async def test_threads_requires_permissions_debug(ds_error_shape):
denied = await ds_error_shape.client.get("/-/threads.json")
assert_canonical_error(denied, 403)
allowed = await ds_error_shape.client.get("/-/threads.json", actor={"id": "root"})
assert allowed.status_code == 200
assert allowed.json()["ok"] is True

View file

@ -184,10 +184,11 @@ async def test_datasette_constructor():
@pytest.mark.asyncio
async def test_num_sql_threads_zero():
ds = Datasette([], memory=True, settings={"num_sql_threads": 0})
ds.root_enabled = True
db = ds.add_database(Database(ds, memory_name="test_num_sql_threads_zero"))
await db.execute_write("create table t(id integer primary key)")
await db.execute_write("insert into t (id) values (1)")
response = await ds.client.get("/-/threads.json")
response = await ds.client.get("/-/threads.json", actor={"id": "root"})
assert response.json() == {"ok": True, "num_threads": 0, "threads": []}
response2 = await ds.client.get("/test_num_sql_threads_zero/t.json?_shape=array")
assert response2.json() == [{"id": 1}]

View file

@ -34,7 +34,6 @@ def ds_envelope(tmp_path_factory):
"/-/versions.json",
"/-/settings.json",
"/-/config.json",
"/-/threads.json",
"/-/actor.json",
"/-/jump.json",
"/-/schema.json",
@ -58,6 +57,7 @@ async def test_success_object_has_ok_true(ds_client, path):
(
"/-/rules.json?action=view-instance",
"/-/check.json?action=view-instance",
"/-/threads.json",
),
)
async def test_permission_debug_success_has_ok_true(ds_envelope, path):