Files
ironclaw/scripts/check_no_panics.py
firat.sertgoz 532fc61d25 feat: admin management panel — web UI for users and usage monitoring (#1963)
* feat(web): add admin management panel

* fix(web): address admin panel review findings

* fix(web): address remaining admin review feedback

* refactor(web): type admin api responses

* fix(db): aggregate admin usage summary in sql

* Add audit logging for admin privileged state-changes

Add structured tracing (warn-level) to suspend, activate, delete, and
update handlers so that privileged admin actions are recorded with the
acting admin's user_id, the action performed, and the target user.
Addresses security assessment item #1 from PR review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix PairingStore::new() call in test after staging merge

Use PairingStore::new_noop() since the test doesn't need a real DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(admin): address PR #1963 review feedback

- Fix total_jobs semantics: query agent_jobs directly instead of
  counting via LEFT JOIN on llm_calls (which missed jobs without
  LLM calls). Fixed in both libSQL and PostgreSQL backends.
- Fix showConfirmModal XSS: escape message parameter internally
  instead of relying on callers to sanitize.
- Add explicit ::numeric cast to PG COALESCE(SUM(cost), 0) to
  prevent integer type inference.
- Use info! instead of warn! for successful admin audit events
  (update, suspend, activate, delete) — warn implies anomaly.
- Add missing index on llm_calls.created_at for both PG (V21
  migration) and libSQL (incremental migration 21).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address remaining admin panel review follow-ups

* fix: address review comments — query consolidation, CSP, docs, security notes

- Collapse 4 redundant llm_calls subqueries into single subquery (libsql + pg)
- Add WARNING to V21 migration about table lock risk with CONCURRENTLY note
- Add performance doc comments on admin_usage_summary full-table scan
- Add CSP and noindex meta tags to admin.html
- Add JSDoc for showConfirmModal documenting auto-escaping
- Add sessionStorage threat model security comment
- Add serde(flatten) collision risk doc on AdminUserDetailResponse
- Add TODO(#1968) for inline styles migration to CSS custom properties
- Add PG parity test stub for admin_usage_summary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: renumber migration from V21/23 to V24 to avoid conflicts with staging

Staging added V21 (backfill_conversation_source_channel), V22
(sandbox_restart_params), and V23 (list_workspace_files_escape_like).
Renumber our llm_calls_created_at_index migration to V24 in both PG
and libSQL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — CSP, logging, validation, dispatch-exempt, tests

- Remove 'unsafe-inline' from script-src CSP; move CSP to HTTP response header
- Change audit tracing::info! to tracing::debug! (TUI corruption)
- Add dispatch-exempt annotation on usage_summary_handler
- Add server-side input validation on users_create_handler (name length, email, role)
- Rename detailRowHtml to detailRowRawHtml with XSS safety comment
- Add real PG integration test for admin_usage_summary with non-zero data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): skip test-only directories in no-panics check

Files under `src/**/tests/*.rs` are Rust test sub-modules included
behind `#[cfg(test)]` — they are never compiled in production builds.
The no-panics checker was flagging `.unwrap()` and `assert!()` in
helper functions at module level in these files as production code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(admin): scope cost aggregates to 30d, drop external fonts, flatten detail response

Addresses review feedback on #1963:

- Scope all llm_calls aggregates to the 30d `since` window so the admin
  dashboard query is served by `idx_llm_calls_created_at` rather than a
  full table scan. Drops the unused all-time `total_cost` subquery from
  both libsql and postgres backends.
- Self-contain the admin SPA — remove `fonts.googleapis.com` /
  `fonts.gstatic.com` link tags from admin.html and tighten the admin
  CSP to fully same-origin. Typography degrades to the system-font
  fallback already listed in `font-family`.
- Fold `metadata` into `AdminUserInfo` (optional, skip-if-none) and
  remove the `#[serde(flatten)]` wrapper, eliminating the
  documented collision risk.
- Add regression test asserting `since` actually bounds the LLM
  aggregates (future `since` should yield zero LLM counts without
  affecting non-windowed counts).

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ilblackdragon@gmail.com <ilblackdragon@gmail.com>
2026-04-14 15:52:52 +09:00

429 lines
14 KiB
Python

#!/usr/bin/env python3
# Requires Python 3.10+ for PEP 604 union syntax such as `int | None`.
import argparse
import pathlib
import re
import subprocess
import sys
import unittest
from dataclasses import dataclass
PANIC_PATTERN = re.compile(r"\.(?:unwrap|expect)\(|(?<!_)assert(?:_eq|_ne)?!")
TEST_ATTR_PATTERN = re.compile(
r"^\s*#\s*\[\s*(?:"
r"test"
r"|tokio::test(?:\s*\([^]]*\))?"
r"|rstest(?:\s*\([^]]*\))?"
r"|test_case(?:\s*\([^]]*\))?"
r"|cfg\s*\([^]]*\btest\b[^]]*\)"
r")\s*\]"
)
ITEM_PATTERN = re.compile(
r"^\s*"
r"(?:(?:pub(?:\([^)]*\))?|crate)\s+)?"
r"(?:(?:async|unsafe|const)\s+)*"
r"(fn|mod|struct|enum|trait|union|impl)\b"
r"(?:\s+([A-Za-z_][A-Za-z0-9_]*))?"
)
@dataclass
class LexerState:
block_comment_depth: int = 0
in_string: bool = False
string_escape: bool = False
in_char: bool = False
char_escape: bool = False
raw_string_hashes: int | None = None
def run_git(*args: str) -> str:
result = subprocess.run(
["git", *args],
check=True,
capture_output=True,
text=True,
)
return result.stdout
def sanitize_line(line: str, state: LexerState) -> str:
chars = list(line)
out = [" "] * len(chars)
i = 0
while i < len(chars):
ch = chars[i]
nxt = chars[i + 1] if i + 1 < len(chars) else ""
if state.block_comment_depth:
if ch == "/" and nxt == "*":
state.block_comment_depth += 1
i += 2
continue
if ch == "*" and nxt == "/":
state.block_comment_depth -= 1
i += 2
continue
i += 1
continue
if state.raw_string_hashes is not None:
if ch == '"':
hashes = 0
j = i + 1
while j < len(chars) and chars[j] == "#":
hashes += 1
j += 1
if hashes == state.raw_string_hashes:
state.raw_string_hashes = None
i = j
continue
i += 1
continue
if state.in_string:
if state.string_escape:
state.string_escape = False
elif ch == "\\":
state.string_escape = True
elif ch == '"':
state.in_string = False
i += 1
continue
if state.in_char:
if state.char_escape:
state.char_escape = False
elif ch == "\\":
state.char_escape = True
elif ch == "'":
state.in_char = False
i += 1
continue
if ch == "/" and nxt == "/":
break
if ch == "/" and nxt == "*":
state.block_comment_depth += 1
i += 2
continue
if ch == "r":
j = i + 1
while j < len(chars) and chars[j] == "#":
j += 1
if j < len(chars) and chars[j] == '"':
state.raw_string_hashes = j - i - 1
i = j + 1
continue
if ch == '"':
state.in_string = True
i += 1
continue
if ch == "'":
# Distinguish char literals ('x', '\n') from lifetime annotations
# ('a, 'static). Lifetimes are an apostrophe followed by an ASCII
# letter or underscore and then a non-apostrophe (identifiers, not
# closing-quote). Misclassifying a lifetime as a char literal
# blanks the rest of the line, hiding braces and causing the
# brace-depth tracker to desync across the whole file.
if nxt and (nxt.isalpha() or nxt == "_"):
# Peek past the identifier to see if it's 'x' (char) or 'ident (lifetime).
j = i + 2
while j < len(chars) and (chars[j].isalnum() or chars[j] == "_"):
j += 1
if j < len(chars) and chars[j] == "'":
# Closing quote found -> char literal like 'a' or 'ab' (invalid but safe to skip).
state.in_char = True
else:
# No closing quote -> lifetime annotation; skip the apostrophe.
out[i] = " "
else:
state.in_char = True
i += 1
continue
out[i] = ch
i += 1
# Rust char literals cannot span lines; reset if still open at EOL.
if state.in_char:
state.in_char = False
state.char_escape = False
return "".join(out)
def is_test_item(line: str, pending_test_attr: bool) -> tuple[bool, bool]:
match = ITEM_PATTERN.match(line)
if not match:
return False, False
kind, name = match.groups()
named_tests_module = kind == "mod" and name == "tests"
return True, pending_test_attr or named_tests_module
def line_test_contexts(lines: list[str]) -> list[bool]:
contexts = [False] * len(lines)
lexer = LexerState()
block_stack: list[bool] = []
pending_test_attr = False
pending_block_context: bool | None = None
for idx, raw in enumerate(lines):
code = sanitize_line(raw, lexer)
stripped = code.strip()
current_context = block_stack[-1] if block_stack else False
if TEST_ATTR_PATTERN.match(stripped):
pending_test_attr = True
item_found, item_is_test = is_test_item(code, pending_test_attr)
if item_found:
pending_block_context = item_is_test or current_context
pending_test_attr = False
elif stripped and not stripped.startswith("#[") and pending_test_attr:
pending_test_attr = False
contexts[idx] = current_context or bool(pending_block_context)
for ch in code:
if ch == "{":
if pending_block_context is not None:
block_stack.append(pending_block_context)
pending_block_context = None
else:
block_stack.append(block_stack[-1] if block_stack else False)
elif ch == "}" and block_stack:
block_stack.pop()
if stripped.endswith(";"):
pending_block_context = None
return contexts
def is_test_only_path(path: str) -> bool:
"""Return True for files that live inside a test-only directory.
Files under ``src/**/tests/*.rs`` are Rust test sub-modules, typically
included behind ``#[cfg(test)]`` and never compiled in production.
Top-level ``tests/*.rs`` integration test files are already outside
``src/`` / ``crates/`` and therefore never checked.
"""
parts = pathlib.PurePosixPath(path).parts
return "tests" in parts
def changed_rust_files(base: str, head: str) -> list[pathlib.Path]:
output = run_git("diff", "--name-only", f"{base}...{head}", "--", "src", "crates")
files = []
for line in output.splitlines():
if line.endswith(".rs") and (line.startswith("src/") or line.startswith("crates/")):
if not is_test_only_path(line):
files.append(pathlib.Path(line))
return files
def added_lines_for_file(base: str, head: str, path: pathlib.Path) -> set[int]:
diff = run_git("diff", "--unified=0", f"{base}...{head}", "--", str(path))
added: set[int] = set()
current_line = 0
for line in diff.splitlines():
if line.startswith("@@"):
match = re.search(r"\+(\d+)(?:,(\d+))?", line)
if not match:
continue
current_line = int(match.group(1))
continue
if line.startswith("+++ ") or line.startswith("--- "):
continue
if line.startswith("+"):
added.add(current_line)
current_line += 1
elif line.startswith("-"):
continue
else:
current_line += 1
return added
def collect_violations(base: str, head: str) -> list[tuple[str, int, str]]:
violations: list[tuple[str, int, str]] = []
for path in changed_rust_files(base, head):
if not path.exists():
continue
added_lines = added_lines_for_file(base, head, path)
if not added_lines:
continue
lines = path.read_text(encoding="utf-8").splitlines()
contexts = line_test_contexts(lines)
lexer = LexerState()
sanitized = [sanitize_line(line, lexer) for line in lines]
for line_no in sorted(added_lines):
if line_no < 1 or line_no > len(lines):
continue
if contexts[line_no - 1]:
continue
if "// safety:" in lines[line_no - 1]:
continue
if PANIC_PATTERN.search(sanitized[line_no - 1]):
violations.append((str(path), line_no, lines[line_no - 1].rstrip()))
return violations
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base", required=False, default="origin/staging")
parser.add_argument("--head", required=False, default="HEAD")
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
if args.self_test:
suite = unittest.defaultTestLoader.loadTestsFromTestCase(CheckNoPanicsTests)
result = unittest.TextTestRunner(verbosity=2).run(suite)
return 0 if result.wasSuccessful() else 1
violations = collect_violations(args.base, args.head)
if not violations:
print("OK: No panic-inducing calls in changed production code.")
return 0
print("::error::Found panic-style calls outside test-only Rust code.")
print("Production code must use proper error handling instead of panicking.")
print("Suppress false positives with an inline '// safety: <reason>' comment.")
print("")
for path, line_no, line in violations[:20]:
print(f"{path}:{line_no}: {line}")
print("")
print(f"Total: {len(violations)} violation(s)")
return 1
class CheckNoPanicsTests(unittest.TestCase):
def test_cfg_test_module_marks_inner_lines(self) -> None:
lines = [
"#[cfg(test)]\n",
"mod tests {\n",
" assert!(true);\n",
"}\n",
"fn prod() {\n",
" value.expect(\"boom\");\n",
"}\n",
]
contexts = line_test_contexts(lines)
self.assertTrue(contexts[1])
self.assertTrue(contexts[2])
self.assertFalse(contexts[4])
self.assertFalse(contexts[5])
def test_test_function_marks_body_only(self) -> None:
lines = [
"#[test]\n",
"fn it_works(\n",
") {\n",
" assert_eq!(2 + 2, 4);\n",
"}\n",
"fn prod() {\n",
" assert!(ready);\n",
"}\n",
]
contexts = line_test_contexts(lines)
self.assertTrue(contexts[1])
self.assertTrue(contexts[2])
self.assertTrue(contexts[3])
self.assertFalse(contexts[5])
self.assertFalse(contexts[6])
def test_proc_macro_test_attrs_mark_body_only(self) -> None:
attrs = [
"tokio::test",
'tokio::test(flavor = "multi_thread", worker_threads = 4)',
"rstest",
"test_case(1, 2)",
"cfg(all(test, unix))",
]
for attr in attrs:
with self.subTest(attr=attr):
lines = [
f"#[{attr}]\n",
"fn it_works() {\n",
' value.expect("allowed in test");\n',
"}\n",
"fn prod() {\n",
' value.expect("boom");\n',
"}\n",
]
contexts = line_test_contexts(lines)
self.assertTrue(contexts[1])
self.assertTrue(contexts[2])
self.assertFalse(contexts[4])
self.assertFalse(contexts[5])
def test_test_only_path_detection(self) -> None:
self.assertTrue(is_test_only_path("src/channels/web/tests/multi_tenant.rs"))
self.assertTrue(is_test_only_path("crates/foo/src/tests/helpers.rs"))
self.assertFalse(is_test_only_path("src/channels/web/mod.rs"))
self.assertFalse(is_test_only_path("src/channels/web/test_helpers.rs"))
self.assertFalse(is_test_only_path("crates/foo/src/lib.rs"))
def test_lifetime_annotations_do_not_desync_braces(self) -> None:
"""Lifetime annotations ('a, 'static) must not be parsed as char literals.
If they are, the sanitizer blanks the rest of the line — including any
opening brace — and the brace-depth tracker desyncs. This caused
false positives in large test modules (e.g. server.rs).
"""
lines = [
"#[cfg(test)]\n",
"mod tests {\n",
" fn set_env_var(key: &'static str) -> Guard {\n",
" let original = std::env::var(key).ok();\n",
" Guard { key, original }\n",
" }\n",
" fn later_helper() {\n",
' value.expect("should be test context");\n',
" }\n",
"}\n",
]
contexts = line_test_contexts(lines)
# All lines inside mod tests must be test context, even after
# a function signature containing a lifetime annotation.
self.assertTrue(contexts[2], "fn with 'static should be test context")
self.assertTrue(contexts[7], "later helper should still be test context")
def test_named_tests_module_marks_context(self) -> None:
lines = [
"mod tests {\n",
" fn helper() {\n",
" assert!(true);\n",
" }\n",
"}\n",
]
contexts = line_test_contexts(lines)
self.assertTrue(all(contexts))
if __name__ == "__main__":
sys.exit(main())