forked from Rockachopa/Timmy-time-dashboard
* polish: streamline nav, extract inline styles, improve tablet UX - Restructure desktop nav from 8+ flat links + overflow dropdown into 5 grouped dropdowns (Core, Agents, Intel, System, More) matching the mobile menu structure to reduce decision fatigue - Extract all inline styles from mission_control.html and base.html notification elements into mission-control.css with semantic classes - Replace JS-built innerHTML with secure DOM construction in notification loader and chat history - Add CONNECTING state to connection indicator (amber) instead of showing OFFLINE before WebSocket connects - Add tablet breakpoint (1024px) with larger touch targets for Apple Pencil / stylus use and safe-area padding for iPad toolbar - Add active-link highlighting in desktop dropdown menus - Rename "Mission Control" page title to "System Overview" to disambiguate from the chat home page - Add "Home — Timmy Time" page title to index.html https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h * fix(security): move auth-gate credentials to environment variables Hardcoded username, password, and HMAC secret in auth-gate.py replaced with os.environ lookups. Startup now refuses to run if any variable is unset. Added AUTH_GATE_SECRET/USER/PASS to .env.example. https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h * refactor(tooling): migrate from black+isort+bandit to ruff Replace three separate linting/formatting tools with a single ruff invocation. Updates tox.ini (lint, format, pre-push, pre-commit envs), .pre-commit-config.yaml, and CI workflow. Fixes all ruff errors including unused imports, missing raise-from, and undefined names. Ruff config maps existing bandit skips to equivalent S-rules. https://claude.ai/code/session_015uPUoKyYa8M2UAcyk5Gt6h --------- Co-authored-by: Claude <noreply@anthropic.com>
176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
"""Approval item management — the governance layer for autonomous Timmy actions.
|
|
|
|
The GOLDEN_TIMMY constant is the single source of truth for whether Timmy
|
|
may act autonomously. All features that want to take action must:
|
|
|
|
1. Create an ApprovalItem
|
|
2. Check GOLDEN_TIMMY
|
|
3. If True → wait for owner approval before executing
|
|
4. If False → log the action and proceed (Dark Timmy mode)
|
|
|
|
Default is always True. The owner changes this intentionally.
|
|
"""
|
|
|
|
import sqlite3
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GOLDEN TIMMY RULE
|
|
# ---------------------------------------------------------------------------
|
|
GOLDEN_TIMMY = True
|
|
# When True: no autonomous action executes without an approved ApprovalItem.
|
|
# When False: Dark Timmy mode — Timmy may act on his own judgment.
|
|
# Default is always True. Owner changes this intentionally.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Persistence
|
|
# ---------------------------------------------------------------------------
|
|
_DEFAULT_DB = Path.home() / ".timmy" / "approvals.db"
|
|
_EXPIRY_DAYS = 7
|
|
|
|
|
|
@dataclass
|
|
class ApprovalItem:
|
|
id: str
|
|
title: str
|
|
description: str
|
|
proposed_action: str # what Timmy wants to do
|
|
impact: str # "low" | "medium" | "high"
|
|
created_at: datetime
|
|
status: str # "pending" | "approved" | "rejected"
|
|
|
|
|
|
def _get_conn(db_path: Path = _DEFAULT_DB) -> sqlite3.Connection:
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS approval_items (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
proposed_action TEXT NOT NULL,
|
|
impact TEXT NOT NULL DEFAULT 'low',
|
|
created_at TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
)
|
|
""")
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _row_to_item(row: sqlite3.Row) -> ApprovalItem:
|
|
return ApprovalItem(
|
|
id=row["id"],
|
|
title=row["title"],
|
|
description=row["description"],
|
|
proposed_action=row["proposed_action"],
|
|
impact=row["impact"],
|
|
created_at=datetime.fromisoformat(row["created_at"]),
|
|
status=row["status"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def create_item(
|
|
title: str,
|
|
description: str,
|
|
proposed_action: str,
|
|
impact: str = "low",
|
|
db_path: Path = _DEFAULT_DB,
|
|
) -> ApprovalItem:
|
|
"""Create and persist a new approval item."""
|
|
item = ApprovalItem(
|
|
id=str(uuid.uuid4()),
|
|
title=title,
|
|
description=description,
|
|
proposed_action=proposed_action,
|
|
impact=impact,
|
|
created_at=datetime.now(UTC),
|
|
status="pending",
|
|
)
|
|
conn = _get_conn(db_path)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO approval_items
|
|
(id, title, description, proposed_action, impact, created_at, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
item.id,
|
|
item.title,
|
|
item.description,
|
|
item.proposed_action,
|
|
item.impact,
|
|
item.created_at.isoformat(),
|
|
item.status,
|
|
),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return item
|
|
|
|
|
|
def list_pending(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
|
"""Return all pending approval items, newest first."""
|
|
conn = _get_conn(db_path)
|
|
rows = conn.execute(
|
|
"SELECT * FROM approval_items WHERE status = 'pending' ORDER BY created_at DESC"
|
|
).fetchall()
|
|
conn.close()
|
|
return [_row_to_item(r) for r in rows]
|
|
|
|
|
|
def list_all(db_path: Path = _DEFAULT_DB) -> list[ApprovalItem]:
|
|
"""Return all approval items regardless of status, newest first."""
|
|
conn = _get_conn(db_path)
|
|
rows = conn.execute("SELECT * FROM approval_items ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [_row_to_item(r) for r in rows]
|
|
|
|
|
|
def get_item(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
|
conn = _get_conn(db_path)
|
|
row = conn.execute("SELECT * FROM approval_items WHERE id = ?", (item_id,)).fetchone()
|
|
conn.close()
|
|
return _row_to_item(row) if row else None
|
|
|
|
|
|
def approve(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
|
"""Mark an approval item as approved."""
|
|
conn = _get_conn(db_path)
|
|
conn.execute("UPDATE approval_items SET status = 'approved' WHERE id = ?", (item_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return get_item(item_id, db_path)
|
|
|
|
|
|
def reject(item_id: str, db_path: Path = _DEFAULT_DB) -> ApprovalItem | None:
|
|
"""Mark an approval item as rejected."""
|
|
conn = _get_conn(db_path)
|
|
conn.execute("UPDATE approval_items SET status = 'rejected' WHERE id = ?", (item_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return get_item(item_id, db_path)
|
|
|
|
|
|
def expire_old(db_path: Path = _DEFAULT_DB) -> int:
|
|
"""Auto-expire pending items older than EXPIRY_DAYS. Returns count removed."""
|
|
cutoff = (datetime.now(UTC) - timedelta(days=_EXPIRY_DAYS)).isoformat()
|
|
conn = _get_conn(db_path)
|
|
cursor = conn.execute(
|
|
"DELETE FROM approval_items WHERE status = 'pending' AND created_at < ?",
|
|
(cutoff,),
|
|
)
|
|
conn.commit()
|
|
count = cursor.rowcount
|
|
conn.close()
|
|
return count
|