From fac0c26f99e02372674049745cf1197882cf6921 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 25 Aug 2026 10:51:02 +0200 Subject: [PATCH 01/18] Guard control plane: Windows actuators, honest OpenClaw pause, per-session capability, escalation ladders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four gaps named in the Control pillar handoff, and lands the Guard tab the gaps were about (it had never left a working tree). 01 Windows was a no-op. `_guarded()` returned `unsupported_platform` off POSIX, so every Guard button was inert on Windows nodes. Each action now maps to its native equivalent: pause/resume via NtSuspendProcess / NtResumeProcess over the tree (children first, mirroring POSIX), stop via a console Ctrl+C raised from a short-lived DETACHED helper (running AttachConsole in-process would hit the daemon itself), kill via taskkill /T then TerminateProcess per surviving pid. GetProcessTimes supplies the pid-reuse guard's start token — without it the guard fails closed and refuses every action, leaving the path reachable but permanently blocked. Every ctypes call declares argtypes/restype: the default c_int restype truncates a 64-bit HANDLE and the whole path fails silently. 02 OpenClaw pause claimed enforcement it did not have. A pause writes the HITL flag file, and the ONLY thing that enforces it is the optional enforcement proxy — so on a node with no proxy the old text ("the proxy refuses further LLM calls") described an agent that was still running. The actuator now probes enforcement_proxy_status() first and reports advisory_only with a pointer to Stop, which does work via the gateway task cancel. The tab drops the Pause button for those sessions rather than offering an inert one. `resume` also moved onto the shared actuator; it had been calling process_control directly, so one of the four controls did not in fact go through the path we claim they share. 03 Capability is now answered per SESSION, from one place — runtime_control_support(), read by the tab, the daemon and the actuator. A Cursor CLI session is a real process tree and is controllable; a Cursor editor conversation shares the one IDE process and is not. The old code refused both. 04 Escalation ladders. A policy may carry `steps`; rung 0 fires on the match and rung n is due after_secs after rung n-1 ACTUALLY fired, so the delay measures the time the agent was given to recover. A rung only fires if the session is still matching that tick, which is what makes "kill if still stuck" mean still stuck. The durable latch widened to (session_id, policy_id, step_index) — with the old two-column PK rung 1 overwrites rung 0 and a daemon restart replays a ladder ending in kill. Every rung passes the same three locks, so a ladder can never reach a process a plain policy could not, and a policy with no steps is a one-rung ladder, which is why every pre-ladder rule is unchanged. UI, because an API nothing renders is not a feature. The Guard markup was written against a utility vocabulary this codebase never had (.data-table, .pill, .btn-xs, .empty-state, …), so the tab rendered as unstyled browser defaults. Those styles now exist, scoped to #guard and following the existing .inv-table / .card idioms. Control buttons come from the server's control_actions with the caveat on hover; the policy list renders the ladder (pause +1m alert +5m kill); the form has a step editor; and Recent decisions gained a Step column — without it three rungs of one policy were indistinguishable from three unrelated decisions. The Guard store methods are added to the daemon proxy allowlist. Without that every read returns None through the proxy and the tab renders "no policies" instead of working — caught by make lint-daemon-allowlist. Scope: the learned-baselines work that shares this area (resolve_thresholds, guard_session_stats) is a separate unlanded feature and is deliberately NOT carried here — its detector half conflicts with the detectors restructure on main, and a baselines card nothing populates would read as "still measuring" forever. Verified: 205 tests pass; the app boots with the routes registered, the nav item and tab markup present, and the tab rendered and inspected in both themes. Windows is covered by tests that fake the platform so the dispatch, ordering and failure messages run on every CI leg — a suite that only runs on the Windows leg is how the original no-op survived — but the Win32 calls themselves have NOT been exercised on real Windows hardware yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016qjq9k1AyyiYuWxcK39oay --- CLAUDE.md | 2 + clawmetry/local_store.py | 429 +++++++++++++++ clawmetry/policy_engine.py | 483 +++++++++++++++++ clawmetry/process_control.py | 676 +++++++++++++++++++++++- clawmetry/static/css/dashboard.css | 95 +++- clawmetry/static/js/app.js | 404 +++++++++++++- clawmetry/sync.py | 328 +++++++++++- clawmetry/templates/tabs/guard.html | 49 ++ dashboard.py | 8 + routes/guard.py | 517 ++++++++++++++++++ routes/local_query.py | 10 + tests/test_guard_control_capability.py | 186 +++++++ tests/test_guard_escalation_ladder.py | 306 +++++++++++ tests/test_guard_policies_api_ladder.py | 119 +++++ tests/test_guard_policy_enforcement.py | 309 +++++++++++ tests/test_policy_engine.py | 181 +++++++ tests/test_process_control_windows.py | 168 ++++++ 17 files changed, 4249 insertions(+), 21 deletions(-) create mode 100644 clawmetry/policy_engine.py create mode 100644 clawmetry/templates/tabs/guard.html create mode 100644 routes/guard.py create mode 100644 tests/test_guard_control_capability.py create mode 100644 tests/test_guard_escalation_ladder.py create mode 100644 tests/test_guard_policies_api_ladder.py create mode 100644 tests/test_guard_policy_enforcement.py create mode 100644 tests/test_policy_engine.py create mode 100644 tests/test_process_control_windows.py diff --git a/CLAUDE.md b/CLAUDE.md index 0bd3cf4637..fb3cf7edbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,8 @@ DEBUG=1 # Enable debug logging - **Control plane that defaults to observation** — ClawMetry is NOT read-only, and hasn't been for a long time. It already kills, pauses, blocks and reroutes running agents through five surfaces: approval denial → session kill (`clawmetry/approvals.py`), POSIX signals across the agent's descendant tree (`clawmetry/process_control.py`), HITL pause → proxy `503` (`routes/hitl.py`), the enforcement proxy's budget block / loop detection / model routing (`clawmetry/proxy.py`), and cron CRUD via gateway RPC (`routes/crons.py`). Do NOT reject a feature because "we're read-only" — that rule is retired. The rule that replaces it is **no surprise writes**: every write is (a) user-initiated or declared in a policy the user wrote, (b) scoped to a single session, (c) reversible where physics allows, and (d) attributed in the approvals audit table. Reads need no permission; writes need all four. **Fail open on entitlement, closed on policy.** If a licence/entitlement lookup errors or is ambiguous, the agent KEEPS RUNNING — only a policy the user actually declared may block or kill. A billing bug must never stop a customer's agent. (`clawmetry/entitlements.py` already defaults to GRACE, where every `allows_*` returns `True`; new control features inherit that posture and assert it in a test.) + **Capability is answered per session, from one place.** `process_control.runtime_control_support(runtime, session_id, cwd)` is the single verdict the Guard tab, the daemon and the actuator all read; never re-derive it. Three axes vary independently and each has bitten us: (1) **OS** — POSIX uses signals, **Windows uses the native equivalents** (`NtSuspendProcess`/`NtResumeProcess`, a console Ctrl+C from a detached helper, `taskkill /T` → `TerminateProcess`). Declare ctypes `argtypes`/`restype` on every Win32 call — the default `c_int` restype truncates a 64-bit `HANDLE` and the whole path fails silently. A Windows Ctrl+C reaches the console, not one pid; say so in the UI. (2) **Session, not runtime** — a Cursor *CLI* session is a real process tree and is controllable; a Cursor *editor* conversation shares the one IDE process and is not. Ask the resolver, don't refuse a runtime wholesale. (3) **OpenClaw pause** — there is no pause primitive; the HITL flag is enforced *only* by `clawmetry/proxy.py`, so with no proxy running a "pause" changes nothing. Probe `enforcement_proxy_status()` and report `advisory_only` rather than claiming the agent was held. A control that cannot work says why next to a disabled button — never ship one that quietly does nothing. + **Policies escalate over time.** A Guard policy may carry `steps` (`[{action, after_secs}, …]`, capped at `policy_engine.MAX_LADDER_STEPS`) so the response can be *pause now, kill in 5 minutes if still stuck*. Rung 0 fires on the match; rung *n* is due `after_secs` after rung *n-1* **actually fired**; a rung only fires if the session is **still matching** that tick. The durable latch is `(session_id, policy_id, step_index)` — widening it was mandatory, since a two-column latch lets rung 1 overwrite rung 0 and a restart replays a ladder ending in `kill`. Every rung passes the same locks: a ladder can never reach a process a plain policy could not. A policy with no `steps` is a one-rung ladder, which is why every pre-ladder rule is unchanged. - **Acceptance criteria are traceable to tests** — every criterion in `docs/acceptance_criteria.json` (mirrored from 8090 Software Factory) must be declared by at least one test under `tests/`. CI enforces it as a one-way ratchet; see FLYWHEEL.md §1g. Drift Bot catches "this diff contradicts a Blueprint"; this catches "untouched code stopped satisfying a criterion", which is the class that produced `$0.00` cost windows and ghost sessions. `make ac-report` to see where you stand. - **Never delete a hook you did not write** — `~/.claude/settings.json` has other writers (GitLens's `gk ai hook install claude-code --force`, `numbat`, the user) and ClawMetry itself writes it from three places. Every removal path goes through `clawmetry/hook_ownership.py` at **hook** granularity, never entry granularity: a foreign writer may have merged its command into the same entry as ours, and the daemon gate's reinstall runs every ~2s, so an entry-level drop deletes someone else's hook within seconds. Installed hook timeouts are clamped (`CLAWMETRY_HOOK_TIMEOUT_MAX_S`, default 8h) — on Copilot, whose `preToolUse` gate is fail-closed, an unbounded wait on a wedged hook is a denial of service against the user's own agent. `docs/HOOK_COEXISTENCE.md`; harness `scripts/hook_collision_matrix.py`. - **A user's repository is read, never written** — `clawmetry/git_outcomes.py` is the only place ClawMetry runs `git` against a directory the operator chose, and it routes every invocation through one chokepoint that rejects anything outside an allowlist of read-only plumbing subcommands (`log`, `rev-list`, `blame`, `cat-file`, `rev-parse`, `for-each-ref`, `show-ref`, `ls-files`, plus `config --get` and `remote get-url`). A `fetch` added "just to freshen state" raises `UnsafeGitCommand` rather than shipping. Add a new git call by adding it to that allowlist, with a test, or not at all. diff --git a/clawmetry/local_store.py b/clawmetry/local_store.py index 538d400152..c1774f3597 100644 --- a/clawmetry/local_store.py +++ b/clawmetry/local_store.py @@ -1612,6 +1612,63 @@ def _on_disk_bytes() -> int: PRIMARY KEY (repo_root, sha) ) """, + # ── Guard policies (detector incident -> enforcement action) ────────── + # Authored in the dashboard's Guard panel, evaluated by the daemon in + # ``sync.py::_emit_detector_incidents`` via ``clawmetry.policy_engine``. + # Rules are LOCAL: they never leave the node, because the actuator they + # drive (process_control signals) only works on the node itself. + """ + CREATE TABLE IF NOT EXISTS session_policy ( + policy_id VARCHAR PRIMARY KEY, + name VARCHAR, + enabled BOOLEAN DEFAULT TRUE, + scope_runtime VARCHAR DEFAULT '', + scope_agent_id VARCHAR DEFAULT '', + trigger_kind VARCHAR DEFAULT '', + min_severity VARCHAR DEFAULT 'info', + min_repeat INTEGER DEFAULT 0, + min_duration_s INTEGER DEFAULT 0, + min_spend_usd DOUBLE DEFAULT 0, + action VARCHAR DEFAULT 'monitor', + -- Escalation ladder as JSON: [{"action","after_secs"}, ...]. + -- Empty/NULL means the single `action` above, which is why every + -- pre-ladder policy keeps working untouched. + steps VARCHAR DEFAULT '', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_session_policy_enabled ON session_policy(enabled)", + # Threshold on the estimated cost of the FLAGGED STRETCH, as opposed to + # min_spend_usd which is the whole session's bill. "Kill anything burning + # more than $5 while stuck" is the rule people actually want, and it needs + # this column. ALTER (not a new CREATE) so existing stores pick it up. + "ALTER TABLE session_policy ADD COLUMN IF NOT EXISTS min_spend_at_risk_usd DOUBLE DEFAULT 0", + # Every policy decision the daemon reached, acted on or not. Doubles as + # the DURABLE one-shot latch: the (session_id, policy_id, step_index) PK + # means each RUNG of a policy's escalation ladder fires at most once per + # session even across a daemon restart, so a restart can never re-kill a + # session it already acted on, and can never replay a ladder from rung 0. + # A policy with no ladder is a one-rung ladder at step_index 0, which is + # exactly the old (session_id, policy_id) latch. + """ + CREATE TABLE IF NOT EXISTS policy_actions ( + session_id VARCHAR NOT NULL, + policy_id VARCHAR NOT NULL, + step_index INTEGER NOT NULL DEFAULT 0, + runtime VARCHAR, + action VARCHAR, + kind VARCHAR, + reason VARCHAR, + evidence BLOB, + enforced BOOLEAN DEFAULT FALSE, + result_ok BOOLEAN DEFAULT FALSE, + result_detail VARCHAR, + created_at BIGINT NOT NULL, + PRIMARY KEY (session_id, policy_id, step_index) + ) + """, + "CREATE INDEX IF NOT EXISTS idx_policy_actions_created ON policy_actions(created_at DESC)", ] @@ -1712,6 +1769,10 @@ def _secs(v): # Absolute root the relative `path` hangs off, so a cloud viewer can show # where on disk the file lives without re-deriving it from the runtime. ("memory_blobs", "root", "VARCHAR"), + # Guard escalation ladders: JSON [{"action","after_secs"}, ...]. Empty on + # existing rows, which normalize_steps() reads as the single `action` — + # so every policy authored before ladders keeps its exact behaviour. + ("session_policy", "steps", "VARCHAR DEFAULT ''"), ] # ── Integrity / hash-chain (Issue #2200) ──────────────────────────────────── @@ -1770,6 +1831,60 @@ def _apply_migrations(conn) -> None: } if col not in existing_cols: conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") + if "policy_actions" in existing_tables: + _migrate_policy_actions_ladder(conn) + + +def _migrate_policy_actions_ladder(conn) -> None: + """Widen the Guard latch from (session, policy) to (session, policy, step). + + Escalation ladders need each RUNG to latch independently: with the old + two-column PK, rung 1 would overwrite rung 0's row, the ladder would lose + its place, and a daemon restart would replay it from the top — meaning a + ladder ending in ``kill`` could re-fire. + + DuckDB cannot ALTER a primary key, so this rebuilds the table. Idempotent + via the ``step_index`` column probe: on an already-migrated store it does + nothing. Existing rows land at ``step_index = 0``, which is where a + single-action policy belongs. Failure is contained by the caller (which + logs and continues), and a store that stays on the old shape keeps + working — it just cannot climb past rung 0. + """ + cols = {row[1] for row in conn.execute( + "PRAGMA table_info('policy_actions')").fetchall()} + if "step_index" in cols: + return + conn.execute(""" + CREATE TABLE policy_actions__ladder ( + session_id VARCHAR NOT NULL, + policy_id VARCHAR NOT NULL, + step_index INTEGER NOT NULL DEFAULT 0, + runtime VARCHAR, + action VARCHAR, + kind VARCHAR, + reason VARCHAR, + evidence BLOB, + enforced BOOLEAN DEFAULT FALSE, + result_ok BOOLEAN DEFAULT FALSE, + result_detail VARCHAR, + created_at BIGINT NOT NULL, + PRIMARY KEY (session_id, policy_id, step_index) + ) + """) + conn.execute(""" + INSERT INTO policy_actions__ladder ( + session_id, policy_id, step_index, runtime, action, kind, reason, + evidence, enforced, result_ok, result_detail, created_at + ) + SELECT session_id, policy_id, 0, runtime, action, kind, reason, + evidence, enforced, result_ok, result_detail, created_at + FROM policy_actions + """) + conn.execute("DROP TABLE policy_actions") + conn.execute("ALTER TABLE policy_actions__ladder RENAME TO policy_actions") + conn.execute("CREATE INDEX IF NOT EXISTS idx_policy_actions_created " + "ON policy_actions(created_at DESC)") + log.info("local store: policy_actions migrated to a per-step Guard latch") # ── v7 dedup migration (#1232) ─────────────────────────────────────────────── @@ -1945,6 +2060,29 @@ def _to_blob(value: Any) -> bytes | None: return str(value).encode("utf-8", errors="replace") +def _from_blob(value: Any) -> Any: + """Inverse of :func:`_to_blob` — decode a DuckDB BLOB back to a Python + value. JSON is parsed; anything else comes back as a string. Never + raises: an undecodable blob returns ``None`` so one bad row cannot break + a whole result set.""" + if value is None: + return None + try: + raw = (bytes(value).decode("utf-8", errors="replace") + if isinstance(value, (bytes, bytearray)) else str(value)) + except Exception: + return None + raw = raw.strip() + if not raw: + return None + if raw[0] in "{[": + try: + return json.loads(raw) + except Exception: + return raw + return raw + + # DuckDB defaults to threads == CPU core count, so a single aggregate query # fans out across every core (observed: a 12-core box pegged at ~200% CPU just # re-running query_aggregates). ClawMetry is an observability sidecar, not a @@ -5003,6 +5141,297 @@ def query_repo_activity( d["details"] = text except UnicodeDecodeError: d["details"] = None + # ── Guard policies ──────────────────────────────────────────────────── + def upsert_session_policy(self, policy: dict) -> None: + """Create or update one Guard policy. + + Permissive by design (repo rule: never crash on bad input) — a row + missing ``policy_id`` is dropped rather than raised on, and an + unknown ``action`` is coerced to the safe default ``monitor`` so a + malformed write can never escalate to a kill. + """ + if not isinstance(policy, dict): + return + pid = str(policy.get("policy_id") or "").strip()[:128] + if not pid: + return + from clawmetry.policy_engine import ACTIONS as _ACTIONS + action = str(policy.get("action") or "monitor").strip().lower() + if action not in _ACTIONS: + action = "monitor" + sev = str(policy.get("min_severity") or "info").strip().lower() + # ``critical`` is the money/irreversibility tier: incidents whose + # spend at risk crosses the threshold, and the behavioural findings + # that outlive the session (a disabled protection, a root delete). + if sev not in ("info", "warning", "critical"): + sev = "info" + now_ms = int(time.time() * 1000) + + def _i(key): + try: + return max(0, int(policy.get(key) or 0)) + except (TypeError, ValueError): + return 0 + + def _f(key): + try: + return max(0.0, float(policy.get(key) or 0)) + except (TypeError, ValueError): + return 0.0 + + # Normalize the ladder through the engine so what is STORED is what + # will be EVALUATED — validating here and again at read time would let + # the two drift. A malformed rung is dropped once, at the door. + from clawmetry.policy_engine import normalize_steps as _norm_steps + _steps = _norm_steps(policy) + # A one-rung ladder that merely repeats `action` carries no + # information, so store '' and let it read back as a plain policy. + _steps_json = "" + if len(_steps) > 1 or (_steps and _steps[0]["action"] != action): + _steps_json = json.dumps(_steps, separators=(",", ":"))[:4000] + + with self._write_lock: + self._conn.execute(""" + INSERT INTO session_policy ( + policy_id, name, enabled, scope_runtime, scope_agent_id, + trigger_kind, min_severity, min_repeat, min_duration_s, + min_spend_usd, min_spend_at_risk_usd, action, steps, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (policy_id) DO UPDATE SET + name = excluded.name, + enabled = excluded.enabled, + scope_runtime = excluded.scope_runtime, + scope_agent_id = excluded.scope_agent_id, + trigger_kind = excluded.trigger_kind, + min_severity = excluded.min_severity, + min_repeat = excluded.min_repeat, + min_duration_s = excluded.min_duration_s, + min_spend_usd = excluded.min_spend_usd, + min_spend_at_risk_usd = excluded.min_spend_at_risk_usd, + action = excluded.action, + steps = excluded.steps, + updated_at = excluded.updated_at + """, [ + pid, + str(policy.get("name") or "")[:200], + bool(policy.get("enabled", True)), + str(policy.get("scope_runtime") or "")[:64], + str(policy.get("scope_agent_id") or "")[:64], + str(policy.get("trigger_kind") or "")[:64], + sev, + _i("min_repeat"), + _i("min_duration_s"), + _f("min_spend_usd"), + _f("min_spend_at_risk_usd"), + action, + _steps_json, + now_ms, + now_ms, + ]) + + def delete_session_policy(self, policy_id: str) -> int: + """Delete one Guard policy. Returns rows removed (0 or 1).""" + pid = str(policy_id or "").strip() + if not pid: + return 0 + with self._write_lock: + before = self._conn.execute( + "SELECT COUNT(*) FROM session_policy WHERE policy_id = ?", + [pid]).fetchone() + self._conn.execute( + "DELETE FROM session_policy WHERE policy_id = ?", [pid]) + return int(before[0]) if before else 0 + + def query_session_policies(self, enabled_only: bool = False) -> list: + """All Guard policies, newest-updated first. + + Returns ``[]`` on any error so a policy-table problem degrades to + "no enforcement" rather than breaking the daemon tick. + """ + try: + sql = ("SELECT policy_id, name, enabled, scope_runtime, " + "scope_agent_id, trigger_kind, min_severity, min_repeat, " + "min_duration_s, min_spend_usd, min_spend_at_risk_usd, " + "action, steps, created_at, updated_at FROM session_policy") + if enabled_only: + sql += " WHERE enabled = TRUE" + sql += " ORDER BY updated_at DESC" + rows = self._conn.execute(sql).fetchall() + except Exception: + return [] + cols = ["policy_id", "name", "enabled", "scope_runtime", + "scope_agent_id", "trigger_kind", "min_severity", "min_repeat", + "min_duration_s", "min_spend_usd", "min_spend_at_risk_usd", + "action", "steps", "created_at", "updated_at"] + out = [] + for r in rows: + d = dict(zip(cols, r)) + # Hand back a real list, never the raw JSON column: the engine + # tolerates both but the API and the UI should only ever see one. + raw = d.get("steps") + d["steps"] = [] + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + d["steps"] = parsed + except Exception: + pass + out.append(d) + return out + + def policy_already_fired(self, session_id: str, policy_id: str, + step_index: int = 0) -> bool: + """Durable one-shot latch, per RUNG of the policy's ladder. + + True when this policy has already reached a decision at this rung for + this session. Survives daemon restarts (the in-memory alternative + would let a restart re-kill a session it already acted on). Fails + CLOSED — any read error returns True, i.e. we decline to act rather + than risk acting twice. + + ``step_index`` defaults to 0, so a caller that predates escalation + ladders keeps the exact old semantics: a single-action policy fires + at most once per session. + """ + sid = str(session_id or "").strip() + pid = str(policy_id or "").strip() + if not sid or not pid: + return True + try: + step = max(0, int(step_index or 0)) + except (TypeError, ValueError): + return True # fail closed on an unreadable rung + try: + row = self._conn.execute( + "SELECT 1 FROM policy_actions WHERE session_id = ? " + "AND policy_id = ? AND step_index = ? LIMIT 1", + [sid, pid, step]).fetchone() + return row is not None + except Exception: + return True + + def query_policy_ladder_state(self, limit: int = 2000) -> dict: + """How far each escalation ladder has climbed. + + Returns ``{session_id: {policy_id: {"last_step", "last_fired_at"}}}`` + where ``last_fired_at`` is epoch SECONDS (the engine's clock unit; + the column is milliseconds). + + This is what makes a ladder durable: the daemon reads it each tick and + hands it to ``policy_engine.evaluate``, so a restart resumes at the + rung it had reached instead of replaying the ladder from the top — a + replay that, for a ladder ending in ``kill``, would mean killing a + session twice. + + ``{}`` on any error, which reads as "no ladder has started". That is + the safe direction: every ladder is treated as being at rung 0, and + rung 0 is still protected by the per-step latch. + """ + try: + lim = max(1, min(int(limit or 2000), 20000)) + except (TypeError, ValueError): + lim = 2000 + try: + rows = self._conn.execute( + "SELECT session_id, policy_id, MAX(step_index) AS last_step, " + "MAX(created_at) AS last_fired_at FROM policy_actions " + "GROUP BY session_id, policy_id " + "ORDER BY last_fired_at DESC LIMIT ?", [lim]).fetchall() + except Exception: + return {} + out: dict = {} + for sid, pid, last_step, last_fired_ms in rows: + if not sid or not pid: + continue + try: + step = int(last_step or 0) + fired = float(last_fired_ms or 0) / 1000.0 + except (TypeError, ValueError): + continue + out.setdefault(str(sid), {})[str(pid)] = { + "last_step": step, + "last_fired_at": fired, + } + return out + + def record_policy_action(self, session_id: str, policy_id: str, + runtime: str = "", action: str = "", + kind: str = "", reason: str = "", + evidence: Any = None, enforced: bool = False, + result_ok: bool = False, + result_detail: str = "", + step_index: int = 0) -> None: + """Record one policy decision (acted on or not) at one ladder rung. + + Written for EVERY decision including dry-run ``monitor`` ones, which + is what makes monitor mode honest: the user can read exactly what + would have fired. Also serves as the latch (PK conflict = already + fired), so this must be written before/regardless of whether the + actuator succeeded. + + ``step_index`` is part of the PK, so each rung of a ladder gets its + own durable row. Re-recording the SAME rung (the daemon writes once + before acting and once after, to fill in the result) updates that row + rather than inserting a second one. + """ + sid = str(session_id or "").strip()[:128] + pid = str(policy_id or "").strip()[:128] + if not sid or not pid: + return + try: + step = max(0, int(step_index or 0)) + except (TypeError, ValueError): + step = 0 + try: + with self._write_lock: + self._conn.execute(""" + INSERT INTO policy_actions ( + session_id, policy_id, step_index, runtime, action, + kind, reason, evidence, enforced, result_ok, + result_detail, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (session_id, policy_id, step_index) DO UPDATE SET + result_ok = excluded.result_ok, + result_detail = excluded.result_detail, + enforced = excluded.enforced + """, [ + sid, pid, step, + str(runtime or "")[:64], + str(action or "")[:32], + str(kind or "")[:64], + str(reason or "")[:400], + _to_blob(evidence) if evidence is not None else None, + bool(enforced), + bool(result_ok), + str(result_detail or "")[:400], + int(time.time() * 1000), + ]) + except Exception: + return + + def query_policy_actions(self, limit: int = 50) -> list: + """Recent policy decisions, newest first. ``[]`` on any error.""" + try: + lim = max(1, min(int(limit or 50), 500)) + except (TypeError, ValueError): + lim = 50 + try: + rows = self._conn.execute( + "SELECT session_id, policy_id, step_index, runtime, action, " + "kind, reason, evidence, enforced, result_ok, result_detail, " + "created_at FROM policy_actions ORDER BY created_at DESC " + "LIMIT ?", [lim]).fetchall() + except Exception: + return [] + cols = ["session_id", "policy_id", "step_index", "runtime", "action", + "kind", "reason", "evidence", "enforced", "result_ok", + "result_detail", "created_at"] + out = [] + for r in rows: + d = dict(zip(cols, r)) + d["evidence"] = _from_blob(d.get("evidence")) out.append(d) return out diff --git a/clawmetry/policy_engine.py b/clawmetry/policy_engine.py new file mode 100644 index 0000000000..962704ccc3 --- /dev/null +++ b/clawmetry/policy_engine.py @@ -0,0 +1,483 @@ +"""Guard policies — turn a detector incident into an enforcement decision. + +This module is the wire between two halves that already existed but never +touched: :mod:`clawmetry.detectors` (which finds agents that have gone off +track) and :mod:`clawmetry.process_control` (which can pause/stop/kill them). +Before this, the only edge between detection and action was a human noticing a +banner and pressing Stop. + +**This module is pure.** ``evaluate()`` does no I/O, opens no store, sends no +signals. It takes incidents + policies + facts and returns decisions. The +daemon (``sync.py::_emit_detector_incidents``) does the reading, the +dispatching and the auditing. Keeping it pure means the whole matching +surface is unit-testable without a daemon, a DuckDB file or a live agent — +the same split ``detectors.py`` uses. + +A policy row (see ``local_store.session_policy``):: + + { + "policy_id": str, + "enabled": bool, + "scope_runtime": str, # "" = every runtime + "scope_agent_id": str, # "" = every agent + "trigger_kind": str, # "" = any detector kind + "min_severity": "info" | "warning", + "min_repeat": int, # incident count must be >= this + "min_duration_s": int, # session bad for at least this long + "min_spend_usd": float, # session cost must be >= this + "min_spend_at_risk_usd": float, # the FLAGGED STRETCH must be worth + # >= this (an estimate the detector attaches; + # 0 or missing means the threshold is unused) + "action": "monitor" | "alert" | "pause" | "stop" | "kill", + "steps": list, # OPTIONAL escalation ladder, see below + } + +**Escalation ladders.** A single action fired once is not how operations +actually respond to a stuck agent — the real shape is *pause it, tell me, +give it five minutes, then kill it if it is still stuck*. ``steps`` expresses +that as an ordered list:: + + "steps": [ + {"action": "pause", "after_secs": 0}, + {"action": "alert", "after_secs": 0}, + {"action": "kill", "after_secs": 300} + ] + +Semantics, chosen so a ladder can never act faster than a plain policy: + +* Step 0 fires when the policy first matches; its ``after_secs`` is ignored + (use ``min_duration_s`` for a delay *before* the first action). +* Step *n* becomes due ``after_secs`` seconds after step *n-1* actually + fired — not after the incident started — so a ladder measures the time the + agent was given to recover. +* A due step only fires if the session is STILL matching this tick. That is + what makes "kill if still stuck" mean *still stuck*: if the detector stops + reporting, the ladder simply stops. +* Every step passes through the same three locks as a plain action. A + ``kill`` step on a node with ``CLAWMETRY_POLICY_ENFORCE=0`` is recorded as + a dry run exactly like a ``kill`` policy would be. +* The durable latch is per ``(session, policy, step)``, so a daemon restart + mid-ladder resumes at the right rung instead of replaying it. + +A policy with no ``steps`` is a one-step ladder built from its ``action``, +which is why every existing policy keeps behaving identically. + +All thresholds are AND-ed. An unset threshold (0) never blocks a match, so a +policy with everything zeroed fires on the first matching incident. + +A decision:: + + { + "policy_id": str, + "session_id": str, + "runtime": str, + "cwd": str, + "action": str, + "kind": str, # detector kind that triggered it + "reason": str, # plain words, shown in the UI and audit row + "evidence": dict, # the numbers that satisfied the thresholds + "step_index": int, # which rung of the ladder this is (0-based) + "step_count": int, # how many rungs the ladder has + "is_final_step": bool, # nothing escalates after this one + "next_action": str, # "" when this was the last rung + "next_after_secs": int, # how long until the next rung becomes due + } + +Safety invariants enforced here (the daemon adds two more — the enforce env +flag and the one-shot latch): + +* **At most one decision per session.** Several policies can match the same + session; firing each would mean several signals at one process. The + strongest action wins, ties broken by ``policy_id`` so the choice is + deterministic and reproducible in tests. +* **``monitor`` is a real decision, not a skip.** It returns a decision the + daemon records to the audit trail without acting. That is what makes + dry-run honest: you can see exactly what *would* have fired. +""" +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Optional + +# Action ladder, weakest first. Order IS the escalation order and the +# strongest-wins comparison; do not reorder without updating the UI copy. +ACTIONS = ("monitor", "alert", "pause", "stop", "kill") +_ACTION_RANK = {name: i for i, name in enumerate(ACTIONS)} + +# Actions that actually signal the agent's process. Everything below `pause` +# only writes rows. The daemon uses this to decide whether the enforce flag +# and the latch apply. +ACTUATING_ACTIONS = frozenset({"pause", "stop", "kill"}) + +# ``critical`` is the tier the detectors reserve for two things: an incident +# whose spend at risk crossed ``detectors.CRITICAL_SPEND_USD``, and a +# behavioural finding that outlives the session (a disabled protection, a +# recursive delete at a home root). A policy can require it, which is how you +# write "kill only the expensive or irreversible ones". +_SEVERITY_RANK = {"info": 0, "warning": 1, "critical": 2} + + +def action_rank(action: str) -> int: + """Position on the escalation ladder; unknown actions sort weakest.""" + return _ACTION_RANK.get(str(action or "").strip().lower(), -1) + + +def is_actuating(action: str) -> bool: + """True when the action sends a signal to a real process.""" + return str(action or "").strip().lower() in ACTUATING_ACTIONS + + +def _severity_rank(sev: Any) -> int: + return _SEVERITY_RANK.get(str(sev or "warning").strip().lower(), 1) + + +def _incident_count(incident: Dict[str, Any]) -> int: + """Best-effort 'how many times' number behind an incident. + + Detectors put their count under different evidence keys depending on the + kind (a loop counts repeats, no-progress counts tool calls, a repeated + failure counts failures). We take the largest of the known keys so one + ``min_repeat`` threshold reads sensibly against every detector. + """ + ev = incident.get("evidence") + if not isinstance(ev, dict): + return 0 + best = 0 + for key in ("repeat_count", "repeats", "total_tool_calls", "tool_calls", + "failure_count", "failures", "count"): + try: + val = int(ev.get(key) or 0) + except (TypeError, ValueError): + continue + if val > best: + best = val + return best + + +def _as_float(value: Any) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _as_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _scope_matches(policy_value: Any, actual: Any) -> bool: + """Empty scope means 'all'. Comparison is case-insensitive.""" + want = str(policy_value or "").strip().lower() + if not want: + return True + return want == str(actual or "").strip().lower() + + +# A ladder longer than this is almost certainly a mistake, and each rung is a +# durable row plus a latch check per tick. Extra rungs are dropped, not +# rejected, so a bad rule degrades instead of disabling the whole policy. +MAX_LADDER_STEPS = 8 + +# Upper bound on a rung's delay (24h). A typo of 300000 instead of 300 would +# otherwise park a ladder forever with no sign anything was wrong. +MAX_STEP_DELAY_SECS = 86400 + + +def normalize_steps(policy: Dict[str, Any]) -> List[Dict[str, Any]]: + """The policy's escalation ladder as ``[{"action", "after_secs"}, ...]``. + + A policy with no usable ``steps`` becomes a ONE-step ladder built from its + ``action``, which is what keeps every pre-ladder policy behaving exactly + as before. + + Malformed rungs are DROPPED rather than coerced. Coercing an unrecognised + action to a default would silently change what a rule does to someone's + agent; dropping it means the ladder is shorter than authored, which the + UI can show. Step 0's delay is forced to 0 (see the module docstring) and + every delay is clamped to ``MAX_STEP_DELAY_SECS``. + """ + fallback = str(policy.get("action") or "monitor").strip().lower() + if fallback not in _ACTION_RANK: + fallback = "monitor" + + raw = policy.get("steps") + if isinstance(raw, str): + # The store round-trips steps as JSON text; tolerate either form so a + # policy read straight from DuckDB and one posted from the UI behave + # identically. + import json + try: + raw = json.loads(raw) + except Exception: # noqa: BLE001 + raw = None + + steps: List[Dict[str, Any]] = [] + if isinstance(raw, (list, tuple)): + for entry in raw: + if len(steps) >= MAX_LADDER_STEPS: + break + if not isinstance(entry, dict): + continue + act = str(entry.get("action") or "").strip().lower() + if act not in _ACTION_RANK: + continue + delay = _as_int(entry.get("after_secs")) + delay = max(0, min(delay, MAX_STEP_DELAY_SECS)) + steps.append({"action": act, + "after_secs": 0 if not steps else delay}) + + if not steps: + return [{"action": fallback, "after_secs": 0}] + return steps + + +def _due_step(steps: List[Dict[str, Any]], state: Optional[Dict[str, Any]], + now: float) -> Optional[int]: + """Which rung, if any, should fire right now. ``None`` = nothing due. + + ``state`` is what the store knows about this ``(session, policy)`` pair: + ``{"last_step": int, "last_fired_at": float-epoch-seconds}``. No state + means the ladder has not started, so rung 0 is due. + + Returns None when the ladder is finished or the next rung's delay has not + elapsed. A missing/garbage ``last_fired_at`` is treated as "just fired", + which DELAYS the next rung rather than firing it early — the safe way to + be wrong when the next rung might be a kill. + """ + if not steps: + return None + if not isinstance(state, dict): + return 0 + last = _as_int(state.get("last_step")) + if state.get("last_step") is None: + return 0 + nxt = last + 1 + if nxt >= len(steps): + return None # ladder exhausted + try: + fired_at = float(state.get("last_fired_at") or 0) + except (TypeError, ValueError): + fired_at = 0.0 + if fired_at <= 0: + fired_at = float(now) # unknown -> wait the full delay from now + if float(now) - fired_at < float(steps[nxt]["after_secs"]): + return None + return nxt + + +def _match(policy: Dict[str, Any], incident: Dict[str, Any], + facts: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return the evidence dict when `policy` matches, else None. + + Returning the evidence rather than a bool means the decision carries the + exact numbers that satisfied each threshold, so the audit row can say + *why* rather than just *that*. + """ + if not policy.get("enabled", True): + return None + + action = str(policy.get("action") or "monitor").strip().lower() + if action not in _ACTION_RANK: + return None # unknown action: refuse rather than guess + + kind = str(incident.get("kind") or "").strip() + want_kind = str(policy.get("trigger_kind") or "").strip() + if want_kind and want_kind != kind: + return None + + if not _scope_matches(policy.get("scope_runtime"), incident.get("runtime")): + return None + if not _scope_matches(policy.get("scope_agent_id"), facts.get("agent_id")): + return None + + if _severity_rank(incident.get("severity")) < _severity_rank( + policy.get("min_severity") or "info"): + return None + + count = _incident_count(incident) + min_repeat = _as_int(policy.get("min_repeat")) + if min_repeat and count < min_repeat: + return None + + duration_s = _as_float(facts.get("bad_for_seconds")) + min_duration = _as_float(policy.get("min_duration_s")) + if min_duration and duration_s < min_duration: + return None + + spend = _as_float(facts.get("cost_usd")) + min_spend = _as_float(policy.get("min_spend_usd")) + if min_spend and spend < min_spend: + return None + + # What the flagged stretch — not the whole session — is estimated to have + # cost. The detector computes it; we carry it into the decision so the + # audit row can answer "was acting on this worth it?" in dollars. + spend_at_risk = _as_float(incident.get("spend_at_risk_usd")) + min_at_risk = _as_float(policy.get("min_spend_at_risk_usd")) + if min_at_risk and spend_at_risk < min_at_risk: + return None + + return { + "count": count, + "bad_for_seconds": int(duration_s), + "cost_usd": round(spend, 4), + "spend_at_risk_usd": round(spend_at_risk, 4), + "spend_basis": str(incident.get("spend_basis") or "unknown"), + "severity": str(incident.get("severity") or "warning"), + "thresholds": { + "min_repeat": min_repeat, + "min_duration_s": int(min_duration), + "min_spend_usd": round(min_spend, 4), + "min_spend_at_risk_usd": round(min_at_risk, 4), + "min_severity": str(policy.get("min_severity") or "info"), + }, + } + + +def _reason(policy: Dict[str, Any], incident: Dict[str, Any], + evidence: Dict[str, Any], step_index: int = 0, + steps: Optional[List[Dict[str, Any]]] = None) -> str: + """Plain-words explanation, shown in the UI and stored in the audit row. + + Deliberately states the observation and the threshold it crossed, so a + user reading it later can tell whether the policy was well-tuned. For a + multi-rung ladder it also says which rung this is and what happens next, + because "pause" reads very differently when the next line is "then kill + in 5m if still stuck". + """ + steps = steps or normalize_steps(policy) + idx = max(0, min(int(step_index), len(steps) - 1)) + action = steps[idx]["action"] + title = str(incident.get("title") or incident.get("kind") or "incident") + bits: List[str] = [] + if evidence.get("count"): + bits.append(f"{evidence['count']} events") + if evidence.get("bad_for_seconds"): + bits.append(f"{int(evidence['bad_for_seconds'] // 60)}m without progress") + if evidence.get("cost_usd"): + bits.append(f"${evidence['cost_usd']:.2f} spent") + if evidence.get("spend_at_risk_usd"): + # The number that decides whether this was worth acting on. + bits.append(f"~${evidence['spend_at_risk_usd']:.2f} at risk") + detail = ", ".join(bits) if bits else "threshold met" + verb = "would " + action if action == "monitor" else action + text = f"{title} ({detail}) -> {verb}" + if len(steps) > 1: + text += f" [step {idx + 1}/{len(steps)}]" + if idx + 1 < len(steps): + nxt = steps[idx + 1] + text += (f", then {nxt['action']} in " + f"{_humanize_secs(int(nxt['after_secs']))} if still matching") + return text[:400] + + +def _humanize_secs(secs: int) -> str: + """``300`` -> ``5m``. Short enough to sit inside a 400-char reason.""" + secs = max(0, int(secs)) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + return f"{secs // 3600}h" + + +def evaluate(incidents: Iterable[Dict[str, Any]], + policies: Iterable[Dict[str, Any]], + session_facts: Optional[Dict[str, Dict[str, Any]]] = None, + ladder_state: Optional[Dict[str, Dict[str, Dict[str, Any]]]] = None, + now: Optional[float] = None, + ) -> List[Dict[str, Any]]: + """Match incidents against policies and return at most one decision per + session (the strongest matching action wins). + + ``session_facts`` maps ``session_id`` -> ``{"cost_usd", "bad_for_seconds", + "runtime", "cwd", "agent_id"}``. A missing entry is treated as all-zero + facts, which means spend/duration thresholds simply never match for that + session rather than matching by accident. + + ``ladder_state`` maps ``session_id -> policy_id -> + {"last_step", "last_fired_at"}`` — how far each ladder has already got, + read from the audit table by the daemon. Omitting it means every matching + policy is at rung 0, which is exactly the pre-ladder behaviour. + + ``now`` is the evaluation clock in epoch seconds, injected so ladder + timing is testable without sleeping. Defaults to wall-clock. + + Pure: no I/O, no exceptions raised for bad input rows (malformed policies + and incidents are skipped, matching the repo's never-crash-on-bad-input + rule). + """ + import time as _time + + facts_by_session = session_facts or {} + state_by_session = ladder_state or {} + clock = float(now) if now is not None else _time.time() + policy_list = [p for p in (policies or []) if isinstance(p, dict)] + if not policy_list: + return [] + + best_by_session: Dict[str, Dict[str, Any]] = {} + + for incident in incidents or []: + if not isinstance(incident, dict): + continue + session_id = str(incident.get("session_id") or "").strip() + if not session_id: + continue # an incident with no session cannot be acted on + facts = facts_by_session.get(session_id) or {} + session_state = state_by_session.get(session_id) or {} + + for policy in policy_list: + evidence = _match(policy, incident, facts) + if evidence is None: + continue + + policy_id = str(policy.get("policy_id") or "") + # Which rung of this policy's ladder is due right now. A ladder + # that has finished, or whose next rung has not come round yet, + # yields no decision at all this tick. + steps = normalize_steps(policy) + step_index = _due_step(steps, session_state.get(policy_id), clock) + if step_index is None: + continue + step = steps[step_index] + action = step["action"] + is_final = step_index >= len(steps) - 1 + nxt = None if is_final else steps[step_index + 1] + + candidate = { + "policy_id": policy_id, + "session_id": session_id, + "runtime": str(incident.get("runtime") or facts.get("runtime") or ""), + "cwd": str(facts.get("cwd") or ""), + "action": action, + "kind": str(incident.get("kind") or ""), + "reason": _reason(policy, incident, evidence, + step_index=step_index, steps=steps), + "evidence": evidence, + "step_index": step_index, + "step_count": len(steps), + "is_final_step": is_final, + "next_action": "" if nxt is None else nxt["action"], + "next_after_secs": 0 if nxt is None else int(nxt["after_secs"]), + } + + current = best_by_session.get(session_id) + if current is None: + best_by_session[session_id] = candidate + continue + # Strongest action wins; deterministic tie-break on policy_id so + # the same inputs always produce the same decision. + cur_rank = action_rank(current["action"]) + new_rank = action_rank(action) + if new_rank > cur_rank or ( + new_rank == cur_rank and policy_id < current["policy_id"]): + best_by_session[session_id] = candidate + + # Stable output ordering: strongest first, then session id. + return sorted( + best_by_session.values(), + key=lambda d: (-action_rank(d["action"]), d["session_id"]), + ) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index ff048853ae..9e85381cf7 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -12,9 +12,13 @@ * **Dependency-light & host-testable.** No Flask, no DuckDB, no cloud imports. ``psutil`` is used *if available* (import-guarded) and we degrade to ``ps`` / ``lsof`` shelling otherwise, so OSS keeps deps minimal. -* **Cross-platform.** macOS and Linux are first-class. Windows / other POSIX - return an honest ``unsupported`` result rather than guessing (POSIX job-control - signals like SIGSTOP/SIGCONT do not exist on Windows). +* **Cross-platform.** macOS, Linux AND Windows are first-class. Windows has no + POSIX job-control signals, so each action maps to its native equivalent: + pause/resume -> ``NtSuspendProcess``/``NtResumeProcess`` (what psutil's + ``suspend()`` calls), stop -> a console Ctrl+C delivered from a short-lived + helper process, kill -> ``taskkill /T`` then ``TerminateProcess`` over the + tree. Every other platform still returns an honest ``unsupported`` result + rather than guessing. * **Never crashes.** A missing file, a dead pid, or a permission error returns ``ok=False`` with a ``reason`` — it never raises into the caller. Respects the never-hang contract: every wait is bounded, no unbounded loops. @@ -58,8 +62,42 @@ _IS_MACOS = sys.platform == "darwin" _IS_LINUX = sys.platform.startswith("linux") +_IS_WINDOWS = os.name == "nt" _POSIX = os.name == "posix" and (_IS_MACOS or _IS_LINUX) +# Platforms where the actuators are implemented at all. POSIX uses signals; +# Windows uses the native equivalents (see the Win32 section below). Anything +# else (a BSD, a stripped container without ps) still gets the honest +# ``unsupported_platform`` refusal rather than a button that silently no-ops. +_CONTROLLABLE_PLATFORM = _POSIX or _IS_WINDOWS + + +def platform_support() -> Dict[str, Any]: + """What this OS can actually do, for the UI to state plainly. + + ``routes/guard.py`` renders this next to the buttons: a control that + cannot work must say why, not fail silently when pressed. + """ + if _POSIX: + return {"controllable": True, "platform": sys.platform, + "mechanism": "posix_signals", + "actions": ["pause", "resume", "stop", "kill"], "reason": ""} + if _IS_WINDOWS: + return {"controllable": True, "platform": "win32", + "mechanism": "win32_native", + "actions": ["pause", "resume", "stop", "kill"], + # Said out loud because it is a real behavioural difference: + # a Windows console app that installs no Ctrl+C handler will + # not stop, where a POSIX agent almost always honours SIGINT. + "reason": "", + "note": ("Windows has no SIGSTOP/SIGINT: pause suspends threads " + "via NtSuspendProcess and stop delivers a console " + "Ctrl+C, which an app that ignores Ctrl+C may not " + "honour")} + return {"controllable": False, "platform": sys.platform, + "mechanism": "", "actions": [], + "reason": f"Process control is not implemented on {sys.platform}"} + # Default bound for graceful_kill's SIGTERM->SIGKILL escalation window. _DEFAULT_GRACE_SECS = 5.0 @@ -166,6 +204,8 @@ def _proc_start_epoch(pid: int) -> Optional[float]: return float(_psutil.Process(int(pid)).create_time()) except Exception: # noqa: BLE001 - dead/zombie/perm return None + if _IS_WINDOWS: + return _win_proc_start_epoch(pid) if _IS_LINUX: try: with open(f"/proc/{int(pid)}/stat", "r") as fh: @@ -196,6 +236,348 @@ def _linux_btime() -> Optional[float]: return None +# ────────────────────────────────────────────────────────────────────────── +# Win32 primitives +# +# Windows has no POSIX job-control signals, so each action maps to the native +# equivalent. Everything here is ctypes against kernel32/ntdll — no new +# dependency — and every call is import-guarded and exception-swallowed so a +# locked-down host degrades to an honest failure instead of raising. +# +# pause/resume NtSuspendProcess / NtResumeProcess. This is exactly what +# psutil's Process.suspend()/resume() call on Windows; we do it +# directly so a psutil-less install keeps the capability. +# stop A console Ctrl+C. It cannot be sent to a single pid: the +# sender must attach to the target's console and raise the +# event for the whole console (group 0). We therefore do it +# from a short-lived DETACHED helper process — running +# AttachConsole in the daemon would swap the daemon's console +# and the Ctrl+C would hit the daemon itself. +# kill taskkill /T for the graceful pass (posts WM_CLOSE / console +# close to the tree), then TerminateProcess per surviving pid. +# ────────────────────────────────────────────────────────────────────────── +_WIN_PROCESS_TERMINATE = 0x0001 +_WIN_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +_WIN_PROCESS_SUSPEND_RESUME = 0x0800 +_WIN_TH32CS_SNAPPROCESS = 0x00000002 +_WIN_DETACHED_PROCESS = 0x00000008 +# FILETIME epoch (1601-01-01) to unix epoch (1970-01-01), in seconds. +_WIN_FILETIME_EPOCH_DELTA = 11644473600.0 + + +_WIN_K32 = None +_WIN_K32_TRIED = False + + +def _win_kernel32(): + """kernel32 with argtypes/restypes declared, or None off Windows. + + Declaring the prototypes is NOT optional. ctypes defaults every restype to + ``c_int``; a Win64 ``HANDLE`` is pointer-sized, so an undeclared + ``OpenProcess`` silently truncates the handle to 32 bits and every + subsequent call against it fails with ERROR_INVALID_HANDLE. The whole + Windows control path would be reachable and permanently broken. + + Cached: the prototypes only need setting once, and the actuators call this + several times per action. + """ + global _WIN_K32, _WIN_K32_TRIED + if _WIN_K32 is not None or _WIN_K32_TRIED: + return _WIN_K32 + _WIN_K32_TRIED = True + if not _IS_WINDOWS: + return None + try: + import ctypes + from ctypes import wintypes + + k = ctypes.WinDLL("kernel32", use_last_error=True) + k.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + k.OpenProcess.restype = wintypes.HANDLE + k.CloseHandle.argtypes = [wintypes.HANDLE] + k.CloseHandle.restype = wintypes.BOOL + k.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + k.TerminateProcess.restype = wintypes.BOOL + k.GetProcessTimes.argtypes = [ + wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ctypes.POINTER(wintypes.FILETIME), + ] + k.GetProcessTimes.restype = wintypes.BOOL + k.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + k.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + # Process32FirstW/NextW take a LPPROCESSENTRY32W we declare locally; + # c_void_p is the honest stand-in for "pointer to that struct". + k.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + k.Process32FirstW.restype = wintypes.BOOL + k.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + k.Process32NextW.restype = wintypes.BOOL + _WIN_K32 = k + return k + except Exception: # noqa: BLE001 + return None + + +def _win_open_process(access: int, pid: int): + """OpenProcess handle for ``pid``, or None. Caller must CloseHandle.""" + k = _win_kernel32() + if k is None: + return None + try: + handle = k.OpenProcess(int(access), False, int(pid)) + return handle or None + except Exception: # noqa: BLE001 + return None + + +def _win_close_handle(handle) -> None: + k = _win_kernel32() + if k is None or not handle: + return + try: + k.CloseHandle(handle) + except Exception: # noqa: BLE001 + pass + + +def _win_proc_start_epoch(pid: int) -> Optional[float]: + """Process creation time as a unix epoch, via GetProcessTimes. + + This is what makes the pid-reuse guard work on a psutil-less Windows box. + Without it ``_proc_start_token`` returns None, ``verify_pid`` fails CLOSED + with ``start_unverifiable``, and every control action is refused — the + actuators below would be reachable but permanently blocked. + """ + if not _IS_WINDOWS or pid is None or int(pid) <= 0: + return None + try: + import ctypes + from ctypes import wintypes + except Exception: # noqa: BLE001 + return None + handle = _win_open_process(_WIN_PROCESS_QUERY_LIMITED_INFORMATION, pid) + if not handle: + return None + try: + k = _win_kernel32() + if k is None: + return None + creation = wintypes.FILETIME() + exited = wintypes.FILETIME() + kernel = wintypes.FILETIME() + user = wintypes.FILETIME() + ok = k.GetProcessTimes(handle, ctypes.byref(creation), + ctypes.byref(exited), ctypes.byref(kernel), + ctypes.byref(user)) + if not ok: + return None + ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime + if ticks <= 0: + return None + return (ticks / 10_000_000.0) - _WIN_FILETIME_EPOCH_DELTA + except Exception: # noqa: BLE001 + return None + finally: + _win_close_handle(handle) + + +def _win_all_procs() -> List[Tuple[int, int, int]]: + """``[(pid, ppid, -1)]`` for every process, via a Toolhelp32 snapshot. + + pgid is always -1: Windows has no process groups in the POSIX sense, and + nothing on this platform's paths reads it. + """ + rows: List[Tuple[int, int, int]] = [] + if not _IS_WINDOWS: + return rows + try: + import ctypes + from ctypes import wintypes + except Exception: # noqa: BLE001 + return rows + k = _win_kernel32() + if k is None: + return rows + + class _PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", ctypes.c_wchar * 260), + ] + + snapshot = None + try: + snapshot = k.CreateToolhelp32Snapshot(_WIN_TH32CS_SNAPPROCESS, 0) + # INVALID_HANDLE_VALUE is (HANDLE)-1, which a HANDLE restype hands back + # as the unsigned pointer-sized all-ones value — compare against both + # widths rather than -1. + if (not snapshot or snapshot == 0xFFFFFFFF + or snapshot == 0xFFFFFFFFFFFFFFFF): + return rows + entry = _PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(_PROCESSENTRY32W) + if not k.Process32FirstW(snapshot, ctypes.byref(entry)): + return rows + # Bounded like the POSIX walk: a corrupt snapshot must not spin. + guard = 0 + while guard < 100000: + guard += 1 + rows.append((int(entry.th32ProcessID), + int(entry.th32ParentProcessID), -1)) + if not k.Process32NextW(snapshot, ctypes.byref(entry)): + break + return rows + except Exception: # noqa: BLE001 + return rows + finally: + _win_close_handle(snapshot) + + +def _win_ntdll_call(fn_name: str, pid: int) -> bool: + """Call a one-argument ntdll process routine (NtSuspendProcess / + NtResumeProcess) on ``pid``. True when it returned STATUS_SUCCESS.""" + if not _IS_WINDOWS: + return False + try: + import ctypes + except Exception: # noqa: BLE001 + return False + handle = _win_open_process(_WIN_PROCESS_SUSPEND_RESUME, pid) + if not handle: + return False + try: + from ctypes import wintypes + + ntdll = ctypes.WinDLL("ntdll", use_last_error=True) + fn = getattr(ntdll, fn_name, None) + if fn is None: + return False + # Same HANDLE-truncation trap as kernel32 (see _win_kernel32). + fn.argtypes = [wintypes.HANDLE] + fn.restype = ctypes.c_long # NTSTATUS + return int(fn(handle)) == 0 # STATUS_SUCCESS + except Exception: # noqa: BLE001 + return False + finally: + _win_close_handle(handle) + + +def _win_suspend(pid: int) -> bool: + """Freeze every thread of ``pid``. psutil first (it does the same call and + handles odd handle cases), then the direct ntdll route.""" + if _psutil is not None: + try: + _psutil.Process(int(pid)).suspend() + return True + except Exception: # noqa: BLE001 + pass + return _win_ntdll_call("NtSuspendProcess", pid) + + +def _win_resume(pid: int) -> bool: + """Unfreeze ``pid``. Mirror of :func:`_win_suspend`.""" + if _psutil is not None: + try: + _psutil.Process(int(pid)).resume() + return True + except Exception: # noqa: BLE001 + pass + return _win_ntdll_call("NtResumeProcess", pid) + + +def _win_terminate(pid: int) -> bool: + """TerminateProcess(``pid``) — the SIGKILL equivalent. Unblockable.""" + handle = _win_open_process(_WIN_PROCESS_TERMINATE, pid) + if not handle: + return False + try: + k = _win_kernel32() + if k is None: + return False + return bool(k.TerminateProcess(handle, 1)) + except Exception: # noqa: BLE001 + return False + finally: + _win_close_handle(handle) + + +# Runs in a DETACHED child so the AttachConsole/Ctrl+C never touches the +# daemon's own console. Exit codes are read back as the failure reason. +_WIN_CTRLC_HELPER = ( + "import ctypes,sys\n" + "pid=int(sys.argv[1])\n" + "k=ctypes.WinDLL('kernel32', use_last_error=True)\n" + "k.FreeConsole()\n" + "if not k.AttachConsole(pid): sys.exit(2)\n" + "if not k.SetConsoleCtrlHandler(None, True): sys.exit(3)\n" + "if not k.GenerateConsoleCtrlEvent(0, 0): sys.exit(4)\n" + "sys.exit(0)\n" +) + +_WIN_CTRLC_REASONS = { + 2: "attach_console_failed (agent has no console, or it is already gone)", + 3: "set_ctrl_handler_failed", + 4: "generate_ctrl_event_failed", +} + + +def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: + """Deliver a console Ctrl+C to ``pid``'s console. ``(ok, detail)``. + + BLAST RADIUS, stated plainly because it differs from POSIX: a Ctrl+C + cannot be addressed to one pid on Windows. The event goes to every + process attached to that console. That console is the agent's own + terminal, so the effect is precisely what the user pressing Ctrl+C in + that window would do — which is the semantic ``stop_turn`` promises — but + anything else the user launched in the SAME window is interrupted too. + """ + if not _IS_WINDOWS: + return False, "not_windows" + try: + proc = subprocess.run( + [sys.executable, "-c", _WIN_CTRLC_HELPER, str(int(pid))], + timeout=max(1.0, float(timeout)), + creationflags=_WIN_DETACHED_PROCESS, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired: + return False, "ctrl_c_helper_timeout" + except Exception as exc: # noqa: BLE001 + return False, f"ctrl_c_helper_error:{str(exc)[:120]}" + if proc.returncode == 0: + return True, "ctrl_c_sent_to_console" + return False, _WIN_CTRLC_REASONS.get(proc.returncode, + f"ctrl_c_helper_rc={proc.returncode}") + + +def _win_taskkill(pid: int, force: bool = False, timeout: float = 10.0) -> bool: + """``taskkill /PID /T`` (``/F`` when forced) over the whole tree. + + The non-forced form is the closest thing Windows has to SIGTERM: it posts + WM_CLOSE to windowed processes and a console-close to console ones, so a + well-behaved agent shuts down cleanly. + """ + cmd = ["taskkill", "/PID", str(int(pid)), "/T"] + if force: + cmd.append("/F") + try: + proc = subprocess.run(cmd, timeout=max(1.0, float(timeout)), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return proc.returncode == 0 + except Exception: # noqa: BLE001 + return False + + def _proc_start_token(pid: int) -> Optional[str]: """A stable, comparable token for the process's start time. @@ -498,8 +880,11 @@ def _all_procs_ps() -> List[Tuple[int, int, int]]: """Return ``[(pid, ppid, pgid), ...]`` for every process, via ps. Used only when psutil is unavailable. ``pgid`` is best-effort (-1 if ps - can't report it on this platform). + can't report it on this platform). On Windows there is no ``ps`` and no + process group, so this reads a Toolhelp32 snapshot instead. """ + if _IS_WINDOWS: + return _win_all_procs() out = _run(["ps", "-axo", "pid=,ppid=,pgid="], timeout=15) rows: List[Tuple[int, int, int]] = [] if not out: @@ -545,6 +930,11 @@ def _proc_cwd(pid: int) -> Optional[str]: if line.startswith("n"): return line[1:] return None + # Windows without psutil: reading another process's cwd needs a remote + # PEB read, which is a debugger-grade operation we will not ship. Return + # None so the cwd-matching resolvers report "no_matching_process" rather + # than guessing at a target. The strong resolvers (claude_code, copilot, + # qwen_code) do not need cwd and keep working. return None @@ -566,6 +956,15 @@ def _proc_cmdline(pid: int) -> List[str]: out = _run(["ps", "-o", "command=", "-p", str(int(pid))], timeout=5) if out: return out.strip().split() + if _IS_WINDOWS: + # No /proc and no ps. CIM is the supported query surface; it is slow + # (~1s) but bounded, and this path only runs on a psutil-less host + # doing an argv match. + out = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", + f"(Get-CimInstance Win32_Process -Filter " + f"'ProcessId={int(pid)}').CommandLine"], timeout=15) + if out and out.strip(): + return out.strip().split() return [] @@ -711,19 +1110,124 @@ def _signal_pid(pid: int, sig: int) -> bool: return False +# ────────────────────────────────────────────────────────────────────────── +# Windows tree actuators +# +# Same contract as the POSIX ones (children first for pause/kill, parent first +# for resume) minus process groups, which Windows does not have: every pid in +# the tree is addressed individually. +# ────────────────────────────────────────────────────────────────────────── +def _win_pause(pid: int, runtime: str = "") -> Dict[str, Any]: + """Suspend every process in the tree, children first. + + Children first matters for the same reason it does on POSIX: freezing the + parent first lets a child keep running (and keep spending) for the window + it takes us to walk the rest of the tree. + """ + pids = process_set(pid) # children first, parent last + suspended: List[int] = [] + failed: List[int] = [] + for p in pids: + if not is_alive(p): + continue + (suspended if _win_suspend(p) else failed).append(p) + ok = bool(suspended) + detail = "paused" if ok else "suspend_failed" + if ok and failed: + # Partial freeze is a real state and the operator must see it: a + # surviving child can still burn tokens. + detail = f"paused ({len(failed)} of {len(pids)} could not be suspended)" + return _result(ok, "pause", pid, runtime, detail, pids=pids, + suspended=suspended, failed=failed, + mechanism="win32_nt_suspend_process") + + +def _win_resume_tree(pid: int, runtime: str = "") -> Dict[str, Any]: + """Resume a suspended tree, parent first — mirror of :func:`_win_pause`.""" + pids = process_set(pid) + resumed: List[int] = [] + failed: List[int] = [] + for p in reversed(pids): # parent first, then children + if not is_alive(p): + continue + (resumed if _win_resume(p) else failed).append(p) + ok = bool(resumed) + return _result(ok, "resume", pid, runtime, + "resumed" if ok else "resume_failed", pids=pids, + resumed=resumed, failed=failed, + mechanism="win32_nt_resume_process") + + +def _win_graceful_kill(pid: int, runtime: str = "", + grace_secs: float = _DEFAULT_GRACE_SECS) -> Dict[str, Any]: + """``taskkill /T`` then, after the grace window, TerminateProcess the tree. + + Mirrors the POSIX SIGTERM -> SIGKILL escalation. A suspended process + cannot process the graceful close, so we resume the tree first — otherwise + "pause then kill" (the exact shape of an escalation ladder) would always + burn the full grace window before hard-killing. + """ + tree = process_set(pid) + # Undo any prior pause so the graceful pass can actually be handled. + for p in tree: + _win_resume(p) + + _win_taskkill(pid, force=False) + + deadline = time.monotonic() + max(0.0, float(grace_secs)) + while time.monotonic() < deadline: + if not is_alive(pid): + break + time.sleep(0.1) + + if not is_alive(pid): + for p in tree: + if p != pid and is_alive(p): + _win_terminate(p) + return _result(True, "graceful_kill", pid, runtime, "terminated", + mechanism="win32_taskkill") + + killed_any = False + for p in tree: # children first + if is_alive(p): + killed_any = _win_terminate(p) or killed_any + if is_alive(pid): + # TerminateProcess can be refused (elevated target, protected + # process); taskkill /F runs the same op with the caller's full token + # and is the last honest attempt. + killed_any = _win_taskkill(pid, force=True) or killed_any + + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and is_alive(pid): + time.sleep(0.1) + still = is_alive(pid) + return _result(not still or killed_any, "graceful_kill", pid, runtime, + "kill_signaled_still_present" if still else "killed", + mechanism="win32_terminate_process") + + def stop_turn(pid: int, runtime: str = "") -> Dict[str, Any]: """Cancel the CURRENT turn of a Node-CLI agent by sending SIGINT to the MAIN pid only (the cleanest non-destructive stop — mirrors the user hitting Ctrl-C in the CLI). We do NOT signal the group: a group SIGINT can tear down in-flight tool shells and the TUI in ways the CLI doesn't expect. """ - if not _POSIX: + if not _CONTROLLABLE_PLATFORM: return _result(False, "stop_turn", pid, runtime, "unsupported_platform") if not is_alive(pid): return _result(False, "stop_turn", pid, runtime, "pid_not_alive") + if _IS_WINDOWS: + # Windows equivalent: a console Ctrl+C. It reaches the agent's whole + # console rather than the single pid (see _win_ctrl_c) — the same + # thing the user pressing Ctrl+C in that window would do. + ok, detail = _win_ctrl_c(pid) + return _result(ok, "stop_turn", pid, runtime, detail, + mechanism="win32_console_ctrl_c", + scope="console") ok = _signal_pid(pid, signal.SIGINT) return _result(ok, "stop_turn", pid, runtime, - "sigint_sent" if ok else "sigint_failed") + "sigint_sent" if ok else "sigint_failed", + mechanism="posix_sigint", scope="pid") def graceful_kill(pid: int, runtime: str = "", @@ -734,10 +1238,12 @@ def graceful_kill(pid: int, runtime: str = "", The escalation kills the whole tree (descendants first, then the parent) so a detached tool shell can't outlive its agent. Bounded poll, never hangs. """ - if not _POSIX: + if not _CONTROLLABLE_PLATFORM: return _result(False, "graceful_kill", pid, runtime, "unsupported_platform") if not is_alive(pid): return _result(True, "graceful_kill", pid, runtime, "already_dead") + if _IS_WINDOWS: + return _win_graceful_kill(pid, runtime, grace_secs) # Snapshot the tree up front: after the parent dies, ppid links to its # descendants are lost (re-parented to init), so capture them now. @@ -787,10 +1293,12 @@ def pause(pid: int, runtime: str = "") -> Dict[str, Any]: clicking Pause expects. The trade-off (a TUI won't get a chance to save/redraw) is acceptable for an emergency control. """ - if not _POSIX: + if not _CONTROLLABLE_PLATFORM: return _result(False, "pause", pid, runtime, "unsupported_platform") if not is_alive(pid): return _result(False, "pause", pid, runtime, "pid_not_alive") + if _IS_WINDOWS: + return _win_pause(pid, runtime) pids = process_set(pid) # children first, parent last pid_set = set(pids) @@ -830,8 +1338,10 @@ def resume(pid: int, runtime: str = "") -> Dict[str, Any]: """Resume a paused agent: SIGCONT the same set in REVERSE (parent-group first, then children-groups) so the parent is runnable before its children wake. Mirror of ``pause``.""" - if not _POSIX: + if not _CONTROLLABLE_PLATFORM: return _result(False, "resume", pid, runtime, "unsupported_platform") + if _IS_WINDOWS: + return _win_resume_tree(pid, runtime) # Note: a SIGSTOP'd process IS still alive (os.kill(pid,0) succeeds), so the # alive check here is meaningful. pids = process_set(pid) @@ -1333,7 +1843,7 @@ def _guarded(action_name: str, runtime: str, session_id: str, cwd: str, Returns a structured result. Never raises. ``fn`` is one of the signal helpers (stop_turn / graceful_kill / pause / resume). """ - if not _POSIX: + if not _CONTROLLABLE_PLATFORM: return _result(False, action_name, None, runtime, "unsupported_platform", session_id=session_id) info = resolve_session(runtime, session_id, cwd) @@ -1377,3 +1887,149 @@ def resume_session(runtime: str, session_id: str = "", cwd: str = "") -> Dict[st """Resume a paused family-runtime session (SIGCONT the tree).""" return _guarded("resume", runtime, session_id, cwd, lambda pid: resume(pid, runtime)) + + +# ────────────────────────────────────────────────────────────────────────── +# Capability answers — what can we ACTUALLY do to this session, right now +# +# One place, because the answer has three independent axes (the OS, the +# runtime, and — for OpenClaw — whether the enforcement proxy is in the loop) +# and every caller needs the same verdict. ``routes/guard.py`` renders it next +# to the buttons and ``sync.py`` records it on the policy decision, so a +# control that cannot work says why instead of failing silently when pressed. +# ────────────────────────────────────────────────────────────────────────── +_CLAWMETRY_HOME = os.path.join(os.path.expanduser("~"), ".clawmetry") +_PROXY_PID_FILE = os.path.join(_CLAWMETRY_HOME, "proxy.pid") + + +def enforcement_proxy_status() -> Dict[str, Any]: + """Is the optional enforcement proxy actually running on this node? + + Reads ``~/.clawmetry/proxy.pid`` directly rather than importing + ``clawmetry.proxy`` — this module stays dependency-light, and the pid file + IS the contract (``proxy.run_proxy`` writes it, ``proxy.proxy_status`` + reads it the same way). A stale pid file is treated as not-running. + """ + try: + with open(_PROXY_PID_FILE, "r") as fh: + pid = int((fh.read() or "").strip()) + except Exception: # noqa: BLE001 — absent / unreadable / not a number + return {"running": False, "pid": None, "reason": "no proxy pid file"} + if pid <= 0: + return {"running": False, "pid": None, "reason": "invalid proxy pid file"} + if is_alive(pid): + return {"running": True, "pid": pid, "reason": ""} + return {"running": False, "pid": pid, "reason": "stale proxy pid file"} + + +def openclaw_pause_capability() -> Dict[str, Any]: + """What an OpenClaw "pause" actually does on this node. + + OpenClaw has no pause primitive. All ClawMetry can do is write the HITL + flag file ``~/.clawmetry/hitl/pause_``, and the ONLY thing + that enforces it is ``clawmetry.proxy._is_session_hitl_paused`` — so when + the enforcement proxy is not running, that file changes nothing at all. + + This distinction is the whole point of the function. Reporting "the proxy + refuses further LLM calls" on a node with no proxy is a pause that claims + to have stopped an agent that is still running, which is worse than + refusing outright. + """ + proxy = enforcement_proxy_status() + if proxy.get("running"): + return { + "effective": True, + "mechanism": "proxy_hitl", + "proxy_pid": proxy.get("pid"), + "detail": ("OpenClaw has no pause primitive; the enforcement " + "proxy holds this session's LLM calls while the HITL " + "pause flag is set"), + } + return { + "effective": False, + "mechanism": "none", + "proxy_pid": None, + "detail": ("OpenClaw has no pause primitive and the enforcement proxy " + "is not running on this node, so the HITL pause flag is " + "recorded but nothing enforces it — the agent keeps " + "running. Use Stop (gateway task cancel) instead, or start " + "the proxy with `clawmetry proxy start`."), + } + + +def runtime_control_support(runtime: str, session_id: str = "", + cwd: str = "") -> Dict[str, Any]: + """Per-session control capability: ``{controllable, actions, reason, …}``. + + Answered per SESSION, not per runtime, because two of them differ session + by session: + + * ``cursor`` — a CLI session (``cursor-agent``) is a real process tree and + IS controllable; a conversation inside the Cursor editor shares the one + IDE process and is not. Only the resolver can tell them apart, so we ask + it rather than blanket-refusing the runtime (which is what the Guard tab + used to do, hiding the buttons for sessions that would have worked). + * ``openclaw`` — Stop works (gateway task cancel), Pause depends on + whether the enforcement proxy is in the loop right now. + + Never raises: any resolver error degrades to "not controllable, here's + why". + """ + rt = (runtime or "").strip().lower() + plat = platform_support() + if not plat.get("controllable"): + return {"controllable": False, "actions": [], "runtime": rt, + "reason": plat.get("reason", ""), "platform": plat} + + if rt == "openclaw": + # Stop/kill go through the OpenClaw CLI task cancel in sync.py, not + # through signals, so they work regardless of the resolver. + pause_cap = openclaw_pause_capability() + actions = ["stop", "kill"] + if pause_cap["effective"]: + actions = ["pause", "resume"] + actions + return {"controllable": True, "runtime": rt, "actions": actions, + "reason": "", "no_pause": not pause_cap["effective"], + "pause_capability": pause_cap, + "note": pause_cap["detail"], "platform": plat} + + if rt in SPLIT_SUPPORT_RUNTIMES: + info = resolve_session(rt, session_id, cwd) + if info.get("ok"): + return {"controllable": True, "runtime": rt, + "actions": ["pause", "resume", "stop", "kill"], + "reason": "", "resolved_pid": info.get("pid"), + "platform": plat} + return {"controllable": False, "runtime": rt, "actions": [], + "reason": _SPLIT_SUPPORT_REASONS.get( + info.get("reason") or "", + info.get("reason") or "session could not be located"), + "platform": plat} + + if rt == "claude_code" or rt in SUPPORTED_RUNTIMES: + return {"controllable": True, "runtime": rt, + "actions": ["pause", "resume", "stop", "kill"], + "reason": "", "platform": plat} + + return {"controllable": False, "runtime": rt, "actions": [], + "reason": f"No signal support for {rt or 'unknown runtime'}", + "platform": plat} + + +# Resolver reasons rendered as something an operator can act on. +_SPLIT_SUPPORT_REASONS = { + "cursor_editor_session_no_per_session_signal": + "This Cursor conversation runs inside the shared IDE process; only " + "Cursor CLI (cursor-agent) sessions can be signalled", + "cursor_single_ide_process_no_per_session_signal": + "This Cursor conversation runs inside the shared IDE process; only " + "Cursor CLI (cursor-agent) sessions can be signalled", + "cursor_cli_session_process_not_found": + "This Cursor CLI session has no live process (it may have exited); " + "reopen it to control it", + "no_matching_process": + "No live process for this session (it may have already exited)", + "no_cwd": + "This session has no recorded working directory, which is how its " + "process is located", +} diff --git a/clawmetry/static/css/dashboard.css b/clawmetry/static/css/dashboard.css index 145d24d9b0..badd13380f 100644 --- a/clawmetry/static/css/dashboard.css +++ b/clawmetry/static/css/dashboard.css @@ -3287,7 +3287,7 @@ body.has-profile-menu #logout-btn { display: none !important; } opacity: 0.65; } -/* ============================================================================ +/* ===================================================================== BENCH — Harness Engineering tab (templates/tabs/bench.html + loadBenchTab) ========================================================================== */ .bench-sub { color: var(--text-muted); font-size: 13px; max-width: 62em; } @@ -3401,3 +3401,96 @@ body.has-profile-menu #logout-btn { display: none !important; } .bench-btn:focus-visible { outline: 2px solid var(--bg-accent); outline-offset: 1px; } .bench-btn[disabled] { opacity: .55; cursor: not-allowed; } .bench-whydisabled { font-size: 11px; color: var(--text-faint); } +/* ── Guard tab ───────────────────────────────────────────────────────── + The Guard markup was written against a utility vocabulary this codebase + never had (.data-table, .pill, .btn-xs, .empty-state …), so the tab + rendered as unstyled browser defaults: raw buttons, borderless tables, + pills that were plain text. These definitions follow the existing + .inv-table / .card idioms and the shared token set, so Guard reads as + part of the dashboard rather than a bolted-on page. + Scoped to #guard so short names cannot collide with other tabs. */ +#guard .section-header { margin-bottom: 18px; } +/* No global h2 rule exists and .section-header is used only here, so the tab + title inherited the browser default and rendered near-black on the dark + ground. Matches .inv-heading, the house style for a tab title. */ +#guard .section-header h2 { + margin: 0; font-size: 19px; font-weight: 600; color: var(--text-primary); + letter-spacing: -0.01em; +} +#guard .section-sub { color: var(--text-muted); font-size: 13px; line-height: 1.55; margin: 6px 0 0; max-width: 78ch; } +#guard .card { margin-bottom: 16px; } +#guard .card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } +#guard .card-head h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-primary); } +#guard .card-head .btn { margin-left: auto; } +#guard .card-head .btn ~ .btn { margin-left: 0; } + +#guard .data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +#guard .data-table thead th { + text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; + color: var(--text-muted); font-weight: 600; padding: 8px 12px; white-space: nowrap; + border-bottom: 1px solid var(--border-primary); +} +#guard .data-table tbody td { + padding: 10px 12px; border-bottom: 1px solid var(--border-secondary); + color: var(--text-secondary); vertical-align: middle; +} +#guard .data-table tbody tr:last-child td { border-bottom: none; } +#guard .data-table tbody tr:hover { background: var(--bg-hover); } +/* Money and counts line up column-wise. */ +#guard .data-table td:nth-child(n+4) { font-variant-numeric: tabular-nums; } + +#guard .empty-state { + padding: 22px 12px; text-align: center; color: var(--text-muted); + font-size: 13px; background: var(--bg-primary); + border: 1px dashed var(--border-primary); border-radius: 8px; +} + +/* Status pills. pill-ok / pill-warn carry SEMANTIC state (running, flagged), + which is deliberately separate from the accent colour. */ +#guard .pill { + display: inline-block; font-size: 11px; font-weight: 600; line-height: 1.6; + padding: 1px 9px; border-radius: 999px; white-space: nowrap; + background: var(--bg-secondary); color: var(--text-muted); + border: 1px solid var(--border-secondary); +} +#guard .pill-ok { background: rgba(34,197,94,0.13); color: var(--text-success); border-color: transparent; } +#guard .pill-warn { background: rgba(245,158,11,0.15); color: var(--text-warning); border-color: transparent; } +#guard .pill-crit { background: rgba(239,68,68,0.15); color: var(--text-error); border-color: transparent; } + +#guard .btn { + font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; + padding: 5px 11px; border-radius: 7px; + background: var(--button-bg); color: var(--text-secondary); + border: 1px solid var(--border-primary); transition: background 0.12s, color 0.12s; +} +#guard .btn:hover { background: var(--button-hover); color: var(--text-primary); } +#guard .btn:focus-visible { outline: 2px solid var(--text-accent); outline-offset: 2px; } +#guard .btn-sm { font-size: 12px; padding: 4px 10px; } +#guard .btn-xs { font-size: 11px; padding: 3px 8px; border-radius: 6px; } +#guard .btn-primary { background: var(--bg-accent); border-color: var(--bg-accent); color: #fff; } +#guard .btn-primary:hover { filter: brightness(1.08); color: #fff; } +/* Kill is irreversible; it reads as destructive before it is pressed. */ +#guard .btn-danger { color: var(--text-error); border-color: rgba(239,68,68,0.35); background: transparent; } +#guard .btn-danger:hover { background: rgba(239,68,68,0.12); color: var(--text-error); } +#guard td .btn + .btn, #guard td .btn + .muted { margin-left: 5px; } + +#guard .muted { color: var(--text-faint); font-size: 12px; } + +#guard .form-row { display: flex; align-items: center; gap: 12px; margin-bottom: 9px; } +#guard .form-row > label { flex: 0 0 190px; font-size: 12px; color: var(--text-muted); } +#guard .form-row input, #guard .form-row select { + font: inherit; font-size: 13px; padding: 5px 9px; border-radius: 7px; + background: var(--bg-primary); color: var(--text-primary); + border: 1px solid var(--border-primary); +} +#guard #guard-policy-form { + background: var(--bg-primary); border: 1px solid var(--border-primary); + border-radius: 10px; padding: 16px; margin-bottom: 14px; +} +#guard .gp-step { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-secondary); } + +#guard .banner { + display: flex; gap: 10px; padding: 11px 14px; border-radius: 9px; + font-size: 13px; margin-bottom: 16px; border: 1px solid transparent; +} +#guard .banner-info { background: var(--bg-warning); color: var(--text-warning); border-color: rgba(245,158,11,0.3); } diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 0de167bf98..093dad01b5 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -2096,6 +2096,7 @@ function switchTab(name) { if (name === 'policy') { if (typeof loadToolPolicy === 'function') loadToolPolicy(); } if (name === 'approvals') { if (typeof loadApprovalsTab === 'function') loadApprovalsTab(); } if (name === 'alerts') { if (typeof loadAlertsPage === 'function') loadAlertsPage(); } + if (name === 'guard') { if (typeof loadGuardTab === 'function') loadGuardTab(); } if (name === 'evals') { if (typeof loadEvalsTab === 'function') loadEvalsTab(); } if (name === 'bench') { if (typeof loadBenchTab === 'function') loadBenchTab(); } if (name === 'logs') loadLogs(); @@ -12167,7 +12168,7 @@ var _CM_NODE_TABS = ['alerts','notifications','security','approvals','memory','s // Every togglable sidebar tab (so switching runtimes RE-SHOWS what a prior one // hid). overview is never togglable. var _CM_RT_ALL_TABS = ['flow','brain','models','tracing','turn-anatomy', - 'context-economics','approvals','alerts','usage','dives','crons','memory', + 'context-economics','approvals','guard','alerts','usage','dives','crons','memory', 'notifications','security','policy','skills','selfevolve','subagents', 'nemoclaw','logs','version-impact','agents']; // Foreign OTLP apps only emit spans/traces (events + maybe cost). They get the @@ -30048,7 +30049,7 @@ async function cmRuntimeOpenFile(clickEl, gi, fi) { window._debugReplayTree = debugReplayTree; })(); -// ============================================================================ +// ===================================================================== // BENCH — Harness Engineering tab (routes/bench.py; clawmetry/harness_bench.py) // Verdict stamps + $/done crew cards, follow-a-job flow trace, context lanes, // workload recommendations, published third-party pairs. Every cell carries @@ -30429,3 +30430,402 @@ async function cmRuntimeOpenFile(clickEl, gi, fi) { window.loadBenchTab = loadBenchTab; })(); +======= +var GUARD_KIND_LABEL = { + // Trajectory shape: is this agent stuck? + stuck_loop: 'Looping', + no_progress: 'Not progressing', + repeated_tool_failure: 'Tool failing repeatedly', + action_discrepancy: 'Continued after a failure', + // Behaviour: is this agent doing something it does not normally do? + file_blast_radius: 'Wide or destructive file changes', + credential_access: 'Read credentials', + network_egress: 'Unusual network destination', + privilege_change: 'Privilege change' +}; + +// Money first: "$1.20 at risk" is the number that decides what to open next. +function guardMoney(n) { + var v = Number(n) || 0; + if (v <= 0) return ''; + return v < 0.01 ? '<$0.01' : '$' + v.toFixed(2); +} + +function guardSeverityClass(sev) { + if (sev === 'critical') return 'pill-danger'; + if (sev === 'info') return ''; + return 'pill-warn'; +} + +function guardEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); +} + +function guardAgo(ts) { + if (!ts) return ''; + var t = new Date(ts).getTime(); + if (!t || isNaN(t)) return ''; + var s = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.floor(s / 60) + 'm ago'; + return Math.floor(s / 3600) + 'h ago'; +} + +function loadGuardTab() { + loadGuardSessions(); + loadGuardPolicies(); + loadGuardActions(); +} + +function loadGuardSessions() { + var el = document.getElementById('guard-sessions-body'); + if (!el) return; + fetch('/api/guard/sessions').then(function (r) { return r.json(); }).then(function (d) { + var rows = (d && d.sessions) || []; + if (!rows.length) { + el.innerHTML = '
No sessions running right now.
'; + guardSetBadge(0); + return; + } + var flagged = 0; + var atRisk = document.getElementById('guard-at-risk'); + if (atRisk) { + var total = Number(d && d.spend_at_risk_usd) || 0; + atRisk.textContent = total > 0 + ? guardMoney(total) + ' at risk across ' + (d.flagged || 0) + ' flagged session' + ((d.flagged === 1) ? '' : 's') + : ''; + } + var html = '' + + '' + + ''; + rows.forEach(function (s) { + var inc = s.incident; + if (inc) flagged++; + var statusCell = inc + ? '' + + guardEsc(GUARD_KIND_LABEL[inc.kind] || inc.kind || 'flagged') + + (inc.count ? ' · ' + inc.count : '') + '' + : 'Running'; + // The estimate says what it is: a burn-rate figure and a + // window-fraction figure are not the same kind of number, and the + // tooltip is where that distinction lives instead of being hidden. + var riskCell = ''; + if (inc && Number(inc.spend_at_risk_usd) > 0) { + var basis = inc.spend_basis === 'burn_rate' + ? 'Estimated from this session\'s burn rate over the time it has been off track.' + : (inc.spend_basis === 'window_fraction' + ? 'Rough estimate: session cost apportioned to the flagged part of the window.' + : 'Basis unknown.'); + riskCell = '' + + guardMoney(inc.spend_at_risk_usd) + ''; + } + + var control; + if (!s.controllable) { + // Say WHY rather than showing a button that quietly does nothing. + control = 'Not controllable'; + } else { + var args = "'" + guardEsc(s.session_id) + "','" + guardEsc(s.runtime) + "','" + guardEsc(s.cwd) + "'"; + // Which buttons this SESSION supports, answered by the server. Older + // builds only sent no_pause, so fall back to that rather than + // rendering nothing at all. + var allowed = s.control_actions; + if (!allowed || !allowed.length) { + allowed = s.no_pause ? ['stop', 'kill'] : ['pause', 'stop', 'kill']; + } + // A control that behaves differently here (OpenClaw's proxy-backed + // pause, the Windows console-wide Ctrl+C) explains itself on hover. + var noteAttr = s.control_note ? ' title="' + guardEsc(s.control_note) + '"' : ''; + control = ''; + if (allowed.indexOf('pause') >= 0) { + control += ' "; + } + if (allowed.indexOf('stop') >= 0) { + control += ' "; + } + if (allowed.indexOf('kill') >= 0) { + control += '"; + } + // Pause is unavailable but the reason is worth reading (no proxy). + if (allowed.indexOf('pause') < 0 && s.control_note) { + control += ' no pause'; + } + } + + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
SessionRuntimeStatusAt riskCostLast activeControl
' + + guardEsc((s.title || s.session_id || '').slice(0, 48)) + '' + guardEsc(s.runtime) + '' + statusCell + '' + riskCell + '$' + (Number(s.cost_usd) || 0).toFixed(2) + '' + guardEsc(guardAgo(s.last_active_at)) + '' + control + '
'; + el.innerHTML = html; + guardSetBadge(flagged); + }).catch(function () { + el.innerHTML = '
Could not load sessions.
'; + }); +} + +// 300 -> "5m". Used wherever a ladder delay is shown. +function guardHumanSecs(n) { + n = Math.max(0, Number(n) || 0); + if (n < 60) return n + 's'; + if (n < 3600) return Math.round(n / 60) + 'm'; + return Math.round(n / 3600) + 'h'; +} + +function guardSetBadge(n) { + var b = document.getElementById('nav-guard-badge'); + if (!b) return; + if (n > 0) { b.textContent = n; b.style.display = ''; } + else { b.style.display = 'none'; } +} + +function guardControl(sessionId, runtime, cwd, action) { + var verb = action === 'kill' ? 'Kill' : (action === 'stop' ? 'Stop' : 'Pause'); + if (action !== 'pause' && + !confirm(verb + ' this agent?\n\n' + sessionId + '\n\nThis signals the real process.')) { + return; + } + fetch('/api/guard/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, runtime: runtime, cwd: cwd, action: action }) + }).then(function (r) { return r.json(); }).then(function (d) { + if (!d || !d.ok) { + alert(verb + ' did not succeed: ' + ((d && d.detail) || 'unknown reason')); + } + loadGuardSessions(); + }).catch(function () { alert(verb + ' request failed.'); }); +} + +function loadGuardPolicies() { + var el = document.getElementById('guard-policies-body'); + if (!el) return; + fetch('/api/guard/policies').then(function (r) { return r.json(); }).then(function (d) { + var banner = document.getElementById('guard-enforce-banner'); + var text = document.getElementById('guard-enforce-text'); + if (banner && text) { + if (d && d.policies && d.policies.length && !d.enforcement_enabled) { + // Never let someone believe a rule is protecting them when it is not. + text.textContent = 'Policies are in monitor mode. They record what they would do but take no action. Set CLAWMETRY_POLICY_ENFORCE=1 on this node to enforce.'; + banner.style.display = ''; + } else { + banner.style.display = 'none'; + } + } + var rows = (d && d.policies) || []; + if (!rows.length) { + el.innerHTML = '
No policies yet. Add one to act on a stuck agent automatically.
'; + return; + } + var html = '' + + '' + + ''; + rows.forEach(function (p) { + var when = GUARD_KIND_LABEL[p.trigger_kind] || (p.trigger_kind || 'any signal'); + if (p.scope_runtime) when += ' on ' + guardEsc(p.scope_runtime); + var th = []; + if (p.min_repeat) th.push('>= ' + p.min_repeat + ' events'); + if (p.min_duration_s) th.push('>= ' + Math.round(p.min_duration_s / 60) + 'm'); + if (p.min_spend_usd) th.push('>= $' + Number(p.min_spend_usd).toFixed(2) + ' spent'); + if (p.min_spend_at_risk_usd) th.push('>= $' + Number(p.min_spend_at_risk_usd).toFixed(2) + ' at risk'); + if (p.min_severity && p.min_severity !== 'info') th.push(p.min_severity + '+'); + // Render the LADDER, because "pause" reads very differently when a + // kill is queued five minutes behind it. + var steps = (p.steps && p.steps.length) ? p.steps + : [{ action: p.action, after_secs: 0 }]; + var actionCell = steps.map(function (st, i) { + var c = st.action === 'monitor' ? '' : 'pill-warn'; + var wait = (i > 0 && st.after_secs) + ? ' +' + guardHumanSecs(st.after_secs) + ' ' + : (i > 0 ? '' : ''); + return wait + '' + guardEsc(st.action) + ''; + }).join(' '); + html += '' + + '' + + '' + + '' + + ''; + }); + html += '
NameWhenThresholdsAction
' + guardEsc(p.name || p.policy_id) + '' + guardEsc(when) + '' + guardEsc(th.join(', ') || 'none') + '' + actionCell + '
'; + el.innerHTML = html; + }).catch(function () { + el.innerHTML = '
Could not load policies.
'; + }); +} + +function guardShowPolicyForm() { + var el = document.getElementById('guard-policy-form'); + if (!el) return; + if (el.style.display !== 'none') { el.style.display = 'none'; return; } + el.style.display = ''; + el.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' ' + + 'Each step runs only if the agent is still flagged when its wait is up.' + + '
' + + ''; + guardAddStep(); +} + +// ── Escalation ladder editor ────────────────────────────────────────── +// The shape operations actually want is "pause, tell me, then kill if it is +// still stuck 5 minutes later". One seconds'; + div.innerHTML = + ' ' + waitHtml + + (first ? '' : ' '); + host.appendChild(div); + var sel = div.querySelector('.gp-step-action'); + if (sel && action) sel.value = action; +} + +function guardRemoveStep(id) { + var el = document.querySelector('[data-step-id="' + id + '"]'); + if (el && el.parentNode) el.parentNode.removeChild(el); +} + +function guardReadSteps() { + var out = []; + var rows = document.querySelectorAll('#gp-steps .gp-step'); + for (var i = 0; i < rows.length; i++) { + var sel = rows[i].querySelector('.gp-step-action'); + var wait = rows[i].querySelector('.gp-step-wait'); + if (!sel) continue; + out.push({ + action: sel.value, + // Step 0 has no wait input; the server forces it to 0 anyway. + after_secs: wait ? (parseInt(wait.value || '0', 10) || 0) : 0 + }); + } + return out; +} + +function guardSavePolicy() { + function v(id) { var e = document.getElementById(id); return e ? e.value : ''; } + var body = { + name: v('gp-name'), + trigger_kind: v('gp-kind'), + scope_runtime: v('gp-runtime'), + min_repeat: parseInt(v('gp-repeat') || '0', 10) || 0, + min_duration_s: (parseInt(v('gp-mins') || '0', 10) || 0) * 60, + min_spend_usd: parseFloat(v('gp-spend') || '0') || 0, + min_spend_at_risk_usd: parseFloat(v('gp-at-risk') || '0') || 0, + min_severity: v('gp-severity') || 'info' + }; + var steps = guardReadSteps(); + if (!steps.length) { alert('Add at least one action.'); return; } + // `action` stays the first rung so a node running an older daemon (which + // ignores `steps`) still does something sane rather than nothing. + body.action = steps[0].action; + if (steps.length > 1) { body.steps = steps; } + fetch('/api/guard/policies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(function (r) { return r.json(); }).then(function (d) { + if (!d || !d.ok) { alert('Could not save policy: ' + ((d && d.error) || 'unknown')); return; } + var f = document.getElementById('guard-policy-form'); + if (f) { f.style.display = 'none'; } + loadGuardPolicies(); + }).catch(function () { alert('Could not save policy.'); }); +} + +function guardDeletePolicy(pid) { + if (!confirm('Delete this policy?')) return; + fetch('/api/guard/policies/' + encodeURIComponent(pid), { method: 'DELETE' }) + .then(function () { loadGuardPolicies(); }) + .catch(function () { alert('Could not delete policy.'); }); +} + +function loadGuardActions() { + var el = document.getElementById('guard-actions-body'); + if (!el) return; + fetch('/api/guard/actions?limit=25').then(function (r) { return r.json(); }).then(function (d) { + var rows = (d && d.actions) || []; + if (!rows.length) { + el.innerHTML = '
No policy has fired yet.
'; + return; + } + // A ladder writes one row per rung. Without the Step column three rungs + // of ONE policy are indistinguishable from three unrelated decisions. + var anyLadder = rows.some(function (a) { return Number(a.step_index) > 0; }); + var html = '' + + '' + (anyLadder ? '' : '') + + '' + + ''; + rows.forEach(function (a) { + var stepCell = ''; + if (anyLadder) { + var si = Number(a.step_index) || 0; + stepCell = ''; + } + html += '' + + '' + + stepCell + + '' + + '' + + '' + + ''; + }); + html += '
WhenSessionStepActionEnforcedWhyResult
' + + (si + 1) + '
' + guardEsc(guardAgo(new Date(a.created_at).toISOString())) + '' + guardEsc(String(a.session_id).slice(0, 20)) + '' + guardEsc(a.action) + '' + (a.enforced ? 'yes' : 'dry run') + '' + guardEsc(a.reason) + '' + guardEsc(a.result_detail) + '
'; + el.innerHTML = html; + }).catch(function () { + el.innerHTML = '
Could not load decisions.
'; + }); +} diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 7ebcbdf64c..72e24dfa35 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -10340,19 +10340,33 @@ def _run_process_control(config: dict, action: dict) -> None: elif atype == "pause_session": _hitl_set_pause(session_id, True) if runtime == "openclaw": - result = {"ok": False, "action": "pause", "runtime": "openclaw", - "session_id": session_id, "detail": "unsupported_no_primitive", - "note": ("OpenClaw has no pause primitive; the HITL pause " - "file is set so the proxy refuses further LLM " - "calls for this session")} + _cap = _pc.openclaw_pause_capability() + result = {"ok": bool(_cap["effective"]), "action": "pause", + "runtime": "openclaw", "session_id": session_id, + "mechanism": _cap["mechanism"], + "advisory_only": not _cap["effective"], + "detail": ("paused_via_proxy_hitl" + if _cap["effective"] + else "unsupported_no_primitive"), + "note": _cap["detail"]} else: result = _pc.pause_session(runtime, session_id, cwd) elif atype == "resume_session": _hitl_set_pause(session_id, False) if runtime == "openclaw": - result = {"ok": False, "action": "resume", "runtime": "openclaw", - "session_id": session_id, "detail": "unsupported_no_primitive", - "note": "OpenClaw has no resume primitive; HITL pause file cleared"} + # Symmetric with pause: when the proxy is in the loop, clearing + # the flag genuinely releases the held calls, so this IS a real + # resume. With no proxy there was nothing holding the session + # in the first place. + _cap = _pc.openclaw_pause_capability() + result = {"ok": bool(_cap["effective"]), "action": "resume", + "runtime": "openclaw", "session_id": session_id, + "mechanism": _cap["mechanism"], + "advisory_only": not _cap["effective"], + "detail": ("resumed_via_proxy_hitl" + if _cap["effective"] + else "nothing_was_holding_this_session"), + "note": _cap["detail"]} else: result = _pc.resume_session(runtime, session_id, cwd) else: @@ -19934,6 +19948,42 @@ def _candidate_active_sessions(store) -> list[dict]: return out +# ── Guard policies: detector incident -> enforcement action ──────────────── +# +# The wire between clawmetry.detectors (finds agents that went off track) and +# clawmetry.process_control (can pause/stop/kill them). Before this the only +# edge between the two was a human seeing a banner and pressing Stop, which +# capped enforcement at "someone is watching the dashboard". +# +# THREE independent safety locks, all of which must be open before any signal +# reaches a real process: +# 1. the policy's own action must be an actuating one (pause/stop/kill); +# the DEFAULT action for a new policy is "monitor", which never acts. +# 2. CLAWMETRY_POLICY_ENFORCE must be 1 (default 0). One env var disables +# every policy on the node without editing rules. +# 3. the install must be entitled to autonomous action (same rule the +# budget auto-pause uses: detection is free, acting is paid), fail-closed. +# Plus a DURABLE one-shot latch in DuckDB so a policy fires at most once per +# session even across a daemon restart. +_GUARD_POLICIES_ON = "CLAWMETRY_GUARD_POLICIES" # "0" disables evaluation +_GUARD_ENFORCE = "CLAWMETRY_POLICY_ENFORCE" # "1" permits real signals + + +def _guard_enforcement_allowed() -> bool: + """Entitlement gate for AUTONOMOUS action. Fail-closed. + + Mirrors ``dashboard._auto_pause_allowed``: detection and the banner are + free on every tier; a machine deciding on its own to stop your agent is + the paid capability. Any resolver error returns False so a flaky read can + never escalate a free node into acting. + """ + try: + from clawmetry import entitlements as _ent + return bool(_ent.get_entitlement().allows_feature("budget_limits")) + except Exception: + return False + + # ── What the detectors cannot see for themselves ─────────────────────────── # A detector is pure: it reads a session's event sequence and nothing else. It # therefore cannot know what the session cost, how long it has been off track, @@ -20207,6 +20257,257 @@ def _epoch_of(ts: Any) -> int: return int(dt.timestamp()) +def _guard_actuate(runtime: str, session_id: str, cwd: str, + action: str) -> dict: + """Send the signal for one policy decision, or one human button press. + + Deliberately mirrors ``_run_process_control`` (the cloud-relayed path) + including its OpenClaw special-casing, so an automatic pause and a + hand-pressed pause do exactly the same thing to the process. Never + raises — returns a structured result the caller records verbatim. + + ``resume`` is accepted here even though no policy can request it (it is + not in ``policy_engine.ACTIONS``): the Guard tab's Resume button used to + call ``process_control.resume_session`` directly, which meant one of the + four controls did NOT go through the shared actuator and silently + returned "unsupported" for OpenClaw sessions the proxy could have + released. + """ + import clawmetry.process_control as _pc + rt = (runtime or "").strip().lower() + try: + if action == "pause": + _hitl_set_pause(session_id, True) + if rt == "openclaw": + # OpenClaw has no pause primitive. The HITL flag file is the + # only lever, and the ONLY thing that enforces it is the + # optional enforcement proxy. Claiming "the proxy refuses + # further LLM calls" on a node with no proxy reported a + # stopped agent that was still running — so ask first and + # report what actually happened. + cap = _pc.openclaw_pause_capability() + return {"ok": bool(cap["effective"]), + "detail": ("paused_via_proxy_hitl" if cap["effective"] + else "unsupported_no_primitive"), + "mechanism": cap["mechanism"], + "advisory_only": not cap["effective"], + "note": cap["detail"]} + return _pc.pause_session(rt, session_id, cwd) + if action in ("stop", "kill"): + _hitl_set_pause(session_id, True) + if rt == "openclaw": + cr = _openclaw_cancel_task(session_id) + return {"ok": bool(cr.get("ok")), "action": "cancel", + "scope_pending": bool(cr.get("scope_pending")), + "detail": (cr.get("error") or "task cancel requested")} + mode = "stop" if action == "stop" else "kill" + return _pc.kill_session(rt, session_id, cwd, mode=mode) + if action == "resume": + _hitl_set_pause(session_id, False) + if rt == "openclaw": + cap = _pc.openclaw_pause_capability() + return {"ok": bool(cap["effective"]), + "detail": ("resumed_via_proxy_hitl" if cap["effective"] + else "nothing_was_holding_this_session"), + "mechanism": cap["mechanism"], + "advisory_only": not cap["effective"], + "note": cap["detail"]} + return _pc.resume_session(rt, session_id, cwd) + except Exception as e: # noqa: BLE001 — never raise into the daemon tick + return {"ok": False, "detail": f"actuator_error:{str(e)[:200]}"} + return {"ok": False, "detail": "no-op"} + + +def _apply_guard_policies(store, state: dict, incidents: list, + facts: dict) -> int: + """Evaluate Guard policies against this tick's incidents and act. + + Returns the number of decisions reached (including dry-run ones, which + are recorded but never signal a process). Never raises into the daemon + loop: a policy failure must not stop telemetry ingest. + """ + if os.environ.get(_GUARD_POLICIES_ON, "1") == "0": + return 0 + if not incidents: + return 0 + try: + from clawmetry import policy_engine as _pe + except Exception as e: # noqa: BLE001 + log.warning("guard: policy_engine import failed: %s", e) + return 0 + try: + policies = store.query_session_policies(enabled_only=True) or [] + except Exception as e: # noqa: BLE001 + log.warning("guard: policy read failed: %s", e) + return 0 + if not policies: + return 0 + + # How far each escalation ladder has already climbed. An unreadable + # ladder table degrades to {} — every ladder treated as being at rung 0, + # which is still protected by the per-rung latch below. + try: + ladder_state = store.query_policy_ladder_state() or {} + except Exception as e: # noqa: BLE001 + log.debug("guard: ladder state read failed (%s); assuming rung 0", e) + ladder_state = {} + + try: + decisions = _pe.evaluate(incidents, policies, facts, + ladder_state=ladder_state) or [] + except Exception as e: # noqa: BLE001 + log.warning("guard: evaluate errored: %s", e) + return 0 + if not decisions: + return 0 + + enforce_env = os.environ.get(_GUARD_ENFORCE, "0") == "1" + entitled = _guard_enforcement_allowed() if enforce_env else False + acted = 0 + + for d in decisions: + sid = d.get("session_id") or "" + pid = d.get("policy_id") or "" + action = d.get("action") or "monitor" + # Which rung of the ladder this decision is. A policy with no ladder + # is rung 0, which is exactly the old one-shot-per-session latch. + try: + step = max(0, int(d.get("step_index") or 0)) + except (TypeError, ValueError): + step = 0 + if _policy_step_already_fired(store, sid, pid, step): + continue + + actuating = _pe.is_actuating(action) + will_enforce = bool(actuating and enforce_env and entitled) + + # Record BEFORE acting. The row is the latch, so closing it first + # means a crash mid-signal can never re-fire on the next tick. + if not actuating: + detail = "recorded (no action for this policy type)" + elif not enforce_env: + detail = (f"DRY RUN: would {action} — set {_GUARD_ENFORCE}=1 " + f"to enforce") + elif not entitled: + detail = (f"would {action}, but autonomous action is not " + f"enabled on this plan") + else: + detail = "pending" + try: + _record_policy_step( + store, session_id=sid, policy_id=pid, + runtime=d.get("runtime") or "", action=action, + kind=d.get("kind") or "", reason=d.get("reason") or "", + evidence=d.get("evidence"), enforced=will_enforce, + result_ok=False, result_detail=detail, step_index=step) + except Exception as e: # noqa: BLE001 + log.warning("guard: could not record decision for %s: %s", sid, e) + continue + acted += 1 + + if not will_enforce: + log.info("guard: %s [%s] %s%s", sid, action, detail, + _ladder_suffix(d)) + continue + + result = _guard_actuate(d.get("runtime") or "", sid, + d.get("cwd") or "", action) + ok = bool(result.get("ok")) + rdetail = str(result.get("detail") or result.get("reason") + or result.get("error") or ("signalled" if ok else "failed")) + try: + _record_policy_step( + store, session_id=sid, policy_id=pid, + runtime=d.get("runtime") or "", action=action, + kind=d.get("kind") or "", reason=d.get("reason") or "", + evidence=d.get("evidence"), enforced=True, + result_ok=ok, result_detail=rdetail, step_index=step) + except Exception: + pass + log.info("guard: %s %s -> %s (%s)%s", action, sid, + "ok" if ok else "FAILED", rdetail[:120], _ladder_suffix(d)) + + return acted + + +def _record_policy_step(store, **kw) -> None: + """Write one policy-decision row, tolerating a pre-ladder store. + + Same version-skew hazard as ``_policy_step_already_fired``: the daemon + holding the writer lock may run an older wheel whose + ``record_policy_action`` has no ``step_index``. Dropping the argument + keeps rung 0 (i.e. every non-ladder policy) recording and enforcing + normally instead of taking the whole node out of enforcement. + + Raises on a genuine store failure so the caller's fail-closed handler + still refuses to act on an unlatched decision. + """ + try: + store.record_policy_action(**kw) + return + except TypeError: + pass + kw.pop("step_index", None) + store.record_policy_action(**kw) + + +def _policy_step_already_fired(store, session_id: str, policy_id: str, + step: int) -> bool: + """Per-rung latch check that tolerates an older store. + + ``policy_already_fired`` grew a ``step_index`` argument when escalation + ladders landed. The dashboard reaches the store through the daemon proxy, + and the daemon can be running an OLDER wheel than the code making the + call — so the three-argument form can land on a two-argument method. + Without this fallback that raises ``TypeError``, the fail-closed handler + swallows it, and every policy on the node silently stops enforcing. A + silent global disable is exactly the failure mode we most need to not + have. + + Fails CLOSED (returns True = do not act) for any *other* error, which is + the right direction when the alternative is acting twice. + """ + try: + return bool(store.policy_already_fired(session_id, policy_id, step)) + except TypeError: + pass # older signature — fall through + except Exception: + return True + try: + # Pre-ladder store: it can only latch per (session, policy). Rungs + # above 0 would be blocked forever by rung 0's row, so a ladder + # degrades to its first rung until the daemon is upgraded. Say so + # once rather than escalating on a latch that cannot track rungs. + if step > 0: + log.warning("guard: store predates escalation ladders; step %d of " + "policy %s will not fire until the daemon is upgraded", + step + 1, policy_id) + return True + return bool(store.policy_already_fired(session_id, policy_id)) + except Exception: + return True + + +def _ladder_suffix(decision: dict) -> str: + """`` [step 1/3, then kill in 300s]`` — appended to the guard log line. + + An escalation ladder is the one case where the log needs to say what + happens NEXT: reading "guard: sess-1 pause ok" gives no hint that a kill + is queued behind it. + """ + try: + count = int(decision.get("step_count") or 1) + if count <= 1: + return "" + idx = int(decision.get("step_index") or 0) + nxt = str(decision.get("next_action") or "") + tail = (f", then {nxt} in {int(decision.get('next_after_secs') or 0)}s" + if nxt else ", final step") + return f" [step {idx + 1}/{count}{tail}]" + except Exception: # noqa: BLE001 — a log decoration must never raise + return "" + + def _emit_detector_incidents(store, state: dict) -> int: """Run ``clawmetry.detectors`` over each active session, emit each incident as a ``loop_signals`` row (reusing the stuck detector's device-alert path) @@ -20245,6 +20546,7 @@ def _emit_detector_incidents(store, state: dict) -> int: baseline_cache: dict = {} bad_sessions: set = set() emitted = 0 + all_incidents: list = [] for s in candidates: sid = s.get("session_id") or "" try: @@ -20281,6 +20583,7 @@ def _emit_detector_incidents(store, state: dict) -> int: continue if not incidents: continue + all_incidents.extend(incidents) # Remember when this session FIRST looked wrong, so the next tick can # say how long it has been that way (and price the stretch). @@ -20388,6 +20691,15 @@ def _emit_detector_incidents(store, state: dict) -> int: memo.pop(k, None) except Exception: pass + # Guard policies: turn this tick's incidents into at most one enforcement + # decision per session. Isolated from the emit path above — a policy + # failure must never stop telemetry ingest. + try: + if all_incidents: + _apply_guard_policies(store, state, all_incidents, facts_by_session) + except Exception as e: # noqa: BLE001 + log.warning("guard: policy pass failed: %s", e) + return emitted diff --git a/clawmetry/templates/tabs/guard.html b/clawmetry/templates/tabs/guard.html new file mode 100644 index 0000000000..babd4eb228 --- /dev/null +++ b/clawmetry/templates/tabs/guard.html @@ -0,0 +1,49 @@ +
+
+

Guard

+

+ What is running right now, whether it has gone off track, and the button to stop it. +

+
+ + + +
+
+

Running sessions

+ + +
+
+
Loading sessions...
+
+
+ +
+
+

Policies

+ +
+

+ A policy watches for a detector signal and acts without anyone present. New policies start in + monitor mode: they record what they would have done and change nothing. A policy can escalate + over time, for example pause now and kill five minutes later if the agent is still stuck. Each + step runs only if the agent is still flagged when its wait is up. +

+ +
+
Loading policies...
+
+
+ +
+
+

Recent decisions

+
+
+
Loading...
+
+
+
diff --git a/dashboard.py b/dashboard.py index d02b6f0992..d7577a1677 100644 --- a/dashboard.py +++ b/dashboard.py @@ -108,6 +108,7 @@ from routes.harness import bp_harness from routes.delegated import bp_delegated from routes.readiness import bp_readiness +from routes.guard import bp_guard from routes.health import bp_health from routes.alerts import bp_alerts, bp_budget from routes.channels import bp_channels @@ -12987,6 +12988,7 @@ def detect_config(args=None): app.register_blueprint(bp_harness) app.register_blueprint(bp_delegated) app.register_blueprint(bp_readiness) + app.register_blueprint(bp_guard) app.register_blueprint(bp_health) app.register_blueprint(bp_logs) app.register_blueprint(bp_memory) @@ -13783,6 +13785,11 @@ def get_local_ip(): Approvals +
+ + Guard + +
Alerts @@ -13901,6 +13908,7 @@ def get_local_ip(): {% include 'tabs/inventory.html' %} +{% include 'tabs/guard.html' %} {% include 'tabs/alerts.html' %} diff --git a/routes/guard.py b/routes/guard.py new file mode 100644 index 0000000000..5ca96905af --- /dev/null +++ b/routes/guard.py @@ -0,0 +1,517 @@ +"""Guard — live session control and enforcement policies. + +Two surfaces, one feature: + +* **Control** (``/api/guard/sessions``, ``/api/guard/control``) — what is + running right now, whether a detector thinks it has gone off track, and a + Pause / Stop / Kill button per session. This is the human path. +* **Policies** (``/api/guard/policies``) — rules that let the DAEMON take the + same action with no human present. Authored here, evaluated in + ``sync.py::_emit_detector_incidents`` via ``clawmetry.policy_engine``. + +Both paths end in the SAME actuator (``sync._guard_actuate``) so an automatic +pause and a hand-pressed pause do exactly the same thing to the process. + +Naming note: ``/api/guard/policies`` is deliberately distinct from +``/api/tool-policy`` in ``routes/policy.py`` — that one is the pre-tool +sandbox/permission surface, this one is mid-run enforcement. Different axis, +different table, no shared state. +""" +import time +import uuid + +from flask import Blueprint, jsonify, request + +bp_guard = Blueprint("guard", __name__) + +# Actions a caller may ask for. `resume` is control-only (there is no policy +# that resumes; a human decides that). Anything not in here is refused rather +# than passed through to a signal helper. +_CONTROL_ACTIONS = ("pause", "resume", "stop", "kill") + + +def _ls_call(method_name, **kwargs): + """Cross-process LocalStore call with single-process fallback. + + Mirror of ``routes/health.py::_ls_call``. The daemon owns DuckDB's writer + lock, so a direct open from the dashboard raises on the standard install; + we go through the daemon proxy first and only fall back to a direct open + for single-process boots (tests + dev mode). + """ + try: + from routes.local_query import local_store_via_daemon + result = local_store_via_daemon(method_name, **kwargs) + if result is not None: + return result + except Exception: + pass + try: + from clawmetry import local_store + store = local_store.get_store(read_only=True) + return getattr(store, method_name)(**kwargs) + except Exception: + return None + + +def _ls_write(method_name, **kwargs): + """Route a WRITE at whichever process owns the DuckDB writer lock. + + Reads can fall back to a read-only open; writes cannot. On the standard + install the daemon holds the writer lock, so the proxy is the only path + that works; in single-process dev/test boots the direct open is. We try + both — every write here is idempotent (PK upsert / PK delete), so a + double-apply is harmless and one of the two landing is what matters. + + Returns nothing meaningful on purpose: ``local_store_via_daemon`` + returns ``None`` both for "method missing" and for a write that + succeeded (writes return None), so the return value cannot be trusted + (memory: feedback_dashboard_writes_noop_through_proxy). Callers MUST + verify by reading back. + """ + try: + from routes.local_query import local_store_via_daemon + local_store_via_daemon(method_name, **kwargs) + except Exception: + pass + try: + from clawmetry import local_store + getattr(local_store.get_store(), method_name)(**kwargs) + except Exception: + pass + + +def _same_origin_ok() -> bool: + """Reject cross-site POSTs to the mutating endpoints. + + These routes can SIGKILL a user's agent, so a drive-by form post from any + open browser tab to ``localhost:8900`` must not reach the actuator. A + request with no Origin/Referer at all (curl, the CLI, tests) is allowed — + browsers always send one for a cross-site POST, so absence is not the + attack we are blocking here. + """ + origin = request.headers.get("Origin") or "" + if not origin: + referer = request.headers.get("Referer") or "" + if not referer: + return True + origin = referer + try: + from urllib.parse import urlparse + host = urlparse(origin).netloc.lower() + except Exception: + return False + if not host: + return False + req_host = (request.host or "").lower() + if host == req_host: + return True + # Same port on an equivalent loopback name (localhost vs 127.0.0.1). + loopback = ("localhost", "127.0.0.1", "[::1]", "::1") + def _split(h): + if h.startswith("["): + close = h.find("]") + return h[:close + 1], h[close + 2:] if close + 1 < len(h) else "" + parts = h.rsplit(":", 1) + return (parts[0], parts[1]) if len(parts) == 2 else (h, "") + o_host, o_port = _split(host) + r_host, r_port = _split(req_host) + return o_host in loopback and r_host in loopback and o_port == r_port + + +def _runtime_supports_signals(runtime: str, session_id: str = "", + cwd: str = "") -> dict: + """Can we actually control this SESSION, on this platform, right now? + + Answering honestly at LIST time is the point: a Stop button that silently + does nothing is worse than a disabled one with a reason next to it. + + The verdict comes from ``process_control.runtime_control_support`` so the + UI, the daemon and the actuator all read the same answer. It is per + session, not per runtime, because three things vary independently: + + * the OS — POSIX signals, the Windows native equivalents, or neither; + * the session's execution model — a Cursor CLI session is a real process + tree and IS controllable, while a Cursor editor conversation is not; + * for OpenClaw, whether the enforcement proxy is running, which decides + whether Pause does anything at all. + """ + try: + from clawmetry import process_control as _pc + except Exception: + return {"controllable": False, "reason": "process_control unavailable", + "actions": []} + try: + return _pc.runtime_control_support(runtime, session_id, cwd) + except Exception as e: # noqa: BLE001 — never break the list render + return {"controllable": False, "actions": [], + "reason": f"capability check failed: {str(e)[:120]}"} + + +# Severity ladder shared with ``clawmetry.detectors`` (higher is louder). +_SEVERITY_RANK = {"info": 0, "warning": 1, "critical": 2} + + +def _incident_rank(inc) -> tuple: + """Sort key for one incident: money, then severity, then size. + + Kept in sync with ``detectors.incident_rank``. It is duplicated rather + than imported because this route must render on a cloud instance where + ``clawmetry.detectors`` may be absent, and a missing import must not take + the Guard tab down with it. + """ + if not isinstance(inc, dict): + return (0.0, 0, 0) + try: + spend = float(inc.get("spend_at_risk_usd") or 0) + except (TypeError, ValueError): + spend = 0.0 + sev = _SEVERITY_RANK.get(str(inc.get("severity") or "").lower(), 0) + try: + count = int(inc.get("count") or 0) + except (TypeError, ValueError): + count = 0 + return (spend, sev, count) + + +@bp_guard.route("/api/guard/sessions") +def api_guard_sessions(): + """Live sessions with their current Guard status. + + One row per active session: identity, spend, whether a detector currently + flags it, and whether this node can actually signal it. Returns an empty + list (HTTP 200) on any store error so the tab renders an honest empty + state instead of an error page. + """ + try: + limit = max(1, min(int(request.args.get("limit", 50)), 200)) + except (TypeError, ValueError): + limit = 50 + + sessions = _ls_call("query_sessions_table", limit=limit) or [] + signals = _ls_call("query_recent_loop_signals", limit=200, + since_minutes=30) or [] + + # Newest incident per session wins; a session can trip several detectors. + incident_by_session = {} + for sig in signals: + if not isinstance(sig, dict): + continue + sid = str(sig.get("session_id") or "") + if not sid: + continue + details = sig.get("details") + if isinstance(details, str): + try: + import json as _json + details = _json.loads(details) + except Exception: + details = {} + details = details if isinstance(details, dict) else {} + prev = incident_by_session.get(sid) + count = int(sig.get("repeat_count") or 0) + try: + at_risk = round(float(details.get("spend_at_risk_usd") or 0), 4) + except (TypeError, ValueError): + at_risk = 0.0 + candidate = { + "kind": str(details.get("kind") or ""), + "title": str(details.get("message") or ""), + "detail": str(details.get("detail") or ""), + "severity": str(sig.get("severity") or "warning"), + "count": count, + "since": sig.get("first_seen"), + # What ignoring this stretch is estimated to cost, and on what + # basis. ``basis`` travels with the number on purpose: a reader + # must be able to tell a measured burn rate from a guess. + "spend_at_risk_usd": at_risk, + "spend_basis": str(details.get("spend_basis") or "unknown"), + "evidence": details.get("evidence") + if isinstance(details.get("evidence"), dict) else {}, + } + # A session can trip several detectors at once. The one that gets the + # row is the one that costs the most to ignore, falling back to + # severity and then to count when no cost is known — the same order + # ``detectors.incident_rank`` uses, so the tab and the daemon agree on + # which finding is the loudest. + if prev is None or _incident_rank(candidate) > _incident_rank(prev): + incident_by_session[sid] = candidate + + out = [] + for s in sessions: + if not isinstance(s, dict): + continue + if s.get("ended_at"): + continue + status = str(s.get("status") or "").lower() + if status in ("ended", "completed", "stopped", "failed"): + continue + sid = str(s.get("session_id") or "") + if not sid: + continue + runtime = str(s.get("agent_type") or "") + meta = s.get("metadata") + meta = meta if isinstance(meta, dict) else {} + cwd = "" + for key in ("cwd", "workspace", "project_dir", "working_dir", "path"): + val = meta.get(key) + if isinstance(val, str) and val.strip(): + cwd = val.strip() + break + try: + cost = round(float(s.get("cost_usd") or 0), 4) + except (TypeError, ValueError): + cost = 0.0 + support = _runtime_supports_signals(runtime, sid, cwd) + out.append({ + "session_id": sid, + "runtime": runtime, + "agent_id": str(s.get("agent_id") or ""), + "title": str(s.get("title") or "")[:160], + "status": status, + "started_at": s.get("started_at"), + "last_active_at": s.get("last_active_at"), + "cost_usd": cost, + "total_tokens": int(s.get("total_tokens") or 0), + "message_count": int(s.get("message_count") or 0), + "cwd": cwd, + "incident": incident_by_session.get(sid), + "controllable": support["controllable"], + "control_reason": support.get("reason", ""), + "no_pause": support.get("no_pause", False), + # Which buttons may be enabled for THIS session. Empty means none. + "control_actions": support.get("actions", []), + # Why a control behaves differently here (OpenClaw's proxy-backed + # pause, the Windows Ctrl+C blast radius). Rendered as a hint. + "control_note": support.get("note", "") + or support.get("platform", {}).get("note", ""), + }) + + # Flagged sessions first, most expensive to ignore at the top; unflagged + # sessions keep their recency order below. Sorting by severity alone put a + # $0.02 "continued after a failed command" above a $170 loop, which is the + # ranking the money model exists to fix. + out.sort(key=lambda r: ( + 1 if r.get("incident") else 0, + _incident_rank(r.get("incident")), + ), reverse=True) + + flagged = [r for r in out if r.get("incident")] + return jsonify({ + "sessions": out, + "count": len(out), + "flagged": len(flagged), + # The headline number for the tab: what the flagged stretches are + # estimated to be burning right now. + "spend_at_risk_usd": round(sum( + float((r.get("incident") or {}).get("spend_at_risk_usd") or 0) + for r in flagged), 2), + }) + + +@bp_guard.route("/api/guard/control", methods=["POST"]) +def api_guard_control(): + """Pause / resume / stop / kill one session, on the user's explicit click. + + Not entitlement-gated: the user pressed the button, exactly as the manual + budget pause bypasses ``_auto_pause_allowed``. What IS gated is the daemon + deciding to do this on its own (see ``sync._guard_enforcement_allowed``). + """ + if not _same_origin_ok(): + return jsonify({"ok": False, "error": "cross-origin request refused"}), 403 + + data = request.get_json(silent=True) or {} + action = str(data.get("action") or "").strip().lower() + session_id = str(data.get("session_id") or "").strip() + runtime = str(data.get("runtime") or "").strip().lower() + cwd = str(data.get("cwd") or "").strip() + + if action not in _CONTROL_ACTIONS: + return jsonify({"ok": False, + "error": f"action must be one of {list(_CONTROL_ACTIONS)}"}), 400 + if not session_id: + return jsonify({"ok": False, "error": "session_id is required"}), 400 + + try: + # Every control action — resume included — goes through the actuator + # the daemon's policies use, so a manual pause and an automatic one + # are indistinguishable to the agent process. + from clawmetry.sync import _guard_actuate + result = _guard_actuate(runtime, session_id, cwd, action) + except Exception as e: # noqa: BLE001 + return jsonify({"ok": False, "error": str(e)[:300], + "session_id": session_id, "action": action}), 500 + + result = result if isinstance(result, dict) else {"ok": False} + ok = bool(result.get("ok")) + + # Manual actions belong in the same audit trail as automatic ones. + try: + from clawmetry import audit as _a + _a.audit_event( + f"guard.{action}", + actor="dashboard", + target=session_id, + result="ok" if ok else "failed", + source="dashboard", + metadata={"runtime": runtime, + "detail": str(result.get("detail") or "")[:200]}, + ) + except Exception: + pass + + return jsonify({ + "ok": ok, + "action": action, + "session_id": session_id, + "runtime": runtime, + "detail": str(result.get("detail") or result.get("reason") + or result.get("error") or ""), + "raw": result, + }) + + +@bp_guard.route("/api/guard/policies", methods=["GET", "POST"]) +def api_guard_policies(): + """List Guard policies, or create/update one. + + A new policy defaults to ``action="monitor"`` — it records what it WOULD + have done and changes nothing — so authoring a rule can never surprise + anyone. Escalating to pause/stop/kill is a deliberate second step, and + still needs ``CLAWMETRY_POLICY_ENFORCE=1`` on the node to bite. + + A policy may also carry an escalation ladder as ``steps``:: + + "steps": [{"action": "pause", "after_secs": 0}, + {"action": "kill", "after_secs": 300}] + + Each rung passes through the same three locks as a plain action, and only + fires if the session is still matching when its delay elapses. Rungs are + normalized (and malformed ones dropped) by + ``policy_engine.normalize_steps`` before storage, so what comes back from + a read is exactly what will be evaluated. + """ + if request.method == "GET": + rows = _ls_call("query_session_policies") or [] + import os as _os + from clawmetry.policy_engine import (ACTIONS, MAX_LADDER_STEPS, + MAX_STEP_DELAY_SECS) + # Show the ladder the ENGINE will run, not the raw column: a policy + # stored before ladders existed reads back as its single action, and + # the UI should render one shape for both. + try: + from clawmetry.policy_engine import normalize_steps as _norm + for r in rows: + if isinstance(r, dict): + r["steps"] = _norm(r) + except Exception: + pass + return jsonify({ + "policies": rows, + "count": len(rows), + "actions": list(ACTIONS), + "max_ladder_steps": MAX_LADDER_STEPS, + "max_step_delay_secs": MAX_STEP_DELAY_SECS, + # The UI must be able to say "these rules are not enforcing". + "enforcement_enabled": _os.environ.get( + "CLAWMETRY_POLICY_ENFORCE", "0") == "1", + "evaluation_enabled": _os.environ.get( + "CLAWMETRY_GUARD_POLICIES", "1") != "0", + }) + + if not _same_origin_ok(): + return jsonify({"ok": False, "error": "cross-origin request refused"}), 403 + + data = request.get_json(silent=True) or {} + from clawmetry.policy_engine import ACTIONS, MAX_LADDER_STEPS, normalize_steps + action = str(data.get("action") or "monitor").strip().lower() + if action not in ACTIONS: + return jsonify({"ok": False, + "error": f"action must be one of {list(ACTIONS)}"}), 400 + + # Reject a bad ladder loudly instead of silently storing a shorter one. + # normalize_steps DROPS unusable rungs by design (never coerces them into + # some other action), which is right for the engine reading old rows but + # wrong for a fresh author request: an operator who typed "terminate" + # must be told, not handed a ladder quietly missing a rung. + raw_steps = data.get("steps") + if raw_steps not in (None, "", []): + if not isinstance(raw_steps, list): + return jsonify({"ok": False, + "error": "steps must be a list of " + "{action, after_secs} objects"}), 400 + if len(raw_steps) > MAX_LADDER_STEPS: + return jsonify({"ok": False, + "error": f"a ladder may have at most " + f"{MAX_LADDER_STEPS} steps"}), 400 + for i, entry in enumerate(raw_steps): + if not isinstance(entry, dict): + return jsonify({"ok": False, + "error": f"step {i + 1} must be an object"}), 400 + act = str(entry.get("action") or "").strip().lower() + if act not in ACTIONS: + return jsonify({"ok": False, + "error": f"step {i + 1}: action must be one " + f"of {list(ACTIONS)}"}), 400 + delay = entry.get("after_secs", 0) + try: + if int(delay or 0) < 0: + raise ValueError + except (TypeError, ValueError): + return jsonify({"ok": False, + "error": f"step {i + 1}: after_secs must be a " + f"non-negative number of seconds"}), 400 + + policy_id = str(data.get("policy_id") or "").strip() or f"gp-{uuid.uuid4().hex[:12]}" + policy = { + "policy_id": policy_id, + "name": str(data.get("name") or "")[:200], + "enabled": bool(data.get("enabled", True)), + "scope_runtime": str(data.get("scope_runtime") or "").strip(), + "scope_agent_id": str(data.get("scope_agent_id") or "").strip(), + "trigger_kind": str(data.get("trigger_kind") or "").strip(), + "min_severity": str(data.get("min_severity") or "info").strip(), + "min_repeat": data.get("min_repeat") or 0, + "min_duration_s": data.get("min_duration_s") or 0, + "min_spend_usd": data.get("min_spend_usd") or 0, + "min_spend_at_risk_usd": data.get("min_spend_at_risk_usd") or 0, + "action": action, + "steps": raw_steps or [], + } + # Echo back the ladder the engine will actually run (step 0's delay is + # forced to 0, delays are clamped), so the UI never shows the operator a + # ladder that differs from the stored one. + policy["steps"] = normalize_steps(policy) + _ls_write("upsert_session_policy", policy=policy) + # Verify by reading back: a write through the daemon proxy returns None + # whether it landed or not, so "no exception" is not evidence of success. + rows = _ls_call("query_session_policies") or [] + if not any(r.get("policy_id") == policy_id for r in rows): + return jsonify({"ok": False, "error": "policy store unavailable"}), 503 + return jsonify({"ok": True, "policy_id": policy_id, "policy": policy}) + + +@bp_guard.route("/api/guard/policies/", methods=["DELETE"]) +def api_guard_policy_delete(policy_id): + """Delete one Guard policy.""" + if not _same_origin_ok(): + return jsonify({"ok": False, "error": "cross-origin request refused"}), 403 + _ls_write("delete_session_policy", policy_id=policy_id) + rows = _ls_call("query_session_policies") or [] + still_there = any(r.get("policy_id") == policy_id for r in rows) + return jsonify({"ok": not still_there, "policy_id": policy_id}) + + +@bp_guard.route("/api/guard/actions") +def api_guard_actions(): + """Recent policy decisions — what fired, what it did, and why. + + Includes dry-run (``monitor``) decisions, which is the point: before + turning enforcement on you can read exactly what would have happened. + """ + try: + limit = max(1, min(int(request.args.get("limit", 50)), 500)) + except (TypeError, ValueError): + limit = 50 + rows = _ls_call("query_policy_actions", limit=limit) or [] + return jsonify({"actions": rows, "count": len(rows), + "server_time": int(time.time())}) diff --git a/routes/local_query.py b/routes/local_query.py index 0ba76ecec5..fe5f8aef9c 100644 --- a/routes/local_query.py +++ b/routes/local_query.py @@ -659,6 +659,16 @@ def http_query(): # by nobody and sat on "never triggered" forever. routes/alerts.py now # mirrors on write/update/delete through these two. "ingest_alert_rule", + # ── Guard: live session control + enforcement policies ────────────── + # The Guard tab authors policies from the dashboard process, but the + # daemon owns the DuckDB writer lock, so every one of these has to be + # reachable through the proxy. Without them the reads return None and the + # tab renders an empty state that looks like "no policies" rather than + # "could not reach the store" — and the writes silently no-op. + "query_session_policies", + "upsert_session_policy", + "delete_session_policy", + "query_policy_actions", "delete_alert_rule", "query_channel_config_status", "query_crons", diff --git a/tests/test_guard_control_capability.py b/tests/test_guard_control_capability.py new file mode 100644 index 0000000000..bd6bcbbc1c --- /dev/null +++ b/tests/test_guard_control_capability.py @@ -0,0 +1,186 @@ +"""Control capability must be answered per SESSION, and honestly. + +Two gaps this covers: + +* **OpenClaw pause claimed enforcement it did not have.** A pause on an + OpenClaw session writes ``~/.clawmetry/hitl/pause_``, and the only + thing that enforces that file is the optional enforcement proxy. On a node + with no proxy the old code still reported "the proxy refuses further LLM + calls" — a pause that said it stopped an agent which was in fact still + running. +* **Capability was answered per runtime.** Cursor was refused wholesale even + though Cursor CLI sessions are real process trees, and the platform check + refused every Windows node outright. +""" +import clawmetry.process_control as pc +import pytest + + +@pytest.fixture +def no_proxy(monkeypatch): + monkeypatch.setattr(pc, "enforcement_proxy_status", + lambda: {"running": False, "pid": None, + "reason": "no proxy pid file"}) + + +@pytest.fixture +def live_proxy(monkeypatch): + monkeypatch.setattr(pc, "enforcement_proxy_status", + lambda: {"running": True, "pid": 4242, "reason": ""}) + + +# ── OpenClaw pause honesty ──────────────────────────────────────────────── +def test_openclaw_pause_is_inert_without_the_proxy(no_proxy): + cap = pc.openclaw_pause_capability() + assert cap["effective"] is False + assert cap["mechanism"] == "none" + # It must say the agent keeps running, and point at the thing that works. + assert "keeps running" in cap["detail"] + assert "Stop" in cap["detail"] + + +def test_openclaw_pause_is_effective_with_the_proxy(live_proxy): + cap = pc.openclaw_pause_capability() + assert cap["effective"] is True + assert cap["mechanism"] == "proxy_hitl" + assert cap["proxy_pid"] == 4242 + + +def test_openclaw_offers_no_pause_button_without_the_proxy(no_proxy): + sup = pc.runtime_control_support("openclaw", "sess-1") + assert sup["controllable"] is True # Stop still works + assert "pause" not in sup["actions"] + assert sup["no_pause"] is True + assert sup["actions"] == ["stop", "kill"] + + +def test_openclaw_offers_pause_when_the_proxy_is_live(live_proxy): + sup = pc.runtime_control_support("openclaw", "sess-1") + assert "pause" in sup["actions"] and "resume" in sup["actions"] + assert sup["no_pause"] is False + + +def test_actuator_reports_openclaw_pause_as_failed_without_a_proxy(no_proxy, monkeypatch): + """The actuator's own return value — what gets recorded in the audit + trail and shown to the operator — must not claim success.""" + from clawmetry import sync + monkeypatch.setattr(sync, "_hitl_set_pause", lambda sid, paused: None) + res = sync._guard_actuate("openclaw", "sess-1", "", "pause") + assert res["ok"] is False + assert res["advisory_only"] is True + assert res["detail"] == "unsupported_no_primitive" + + +def test_actuator_reports_openclaw_pause_as_real_with_a_proxy(live_proxy, monkeypatch): + from clawmetry import sync + monkeypatch.setattr(sync, "_hitl_set_pause", lambda sid, paused: None) + res = sync._guard_actuate("openclaw", "sess-1", "", "pause") + assert res["ok"] is True + assert res["detail"] == "paused_via_proxy_hitl" + assert res["advisory_only"] is False + + +def test_resume_goes_through_the_shared_actuator(live_proxy, monkeypatch): + """Resume used to bypass _guard_actuate and call resume_session directly, + so it returned 'unsupported' for OpenClaw sessions the proxy could have + released.""" + from clawmetry import sync + monkeypatch.setattr(sync, "_hitl_set_pause", lambda sid, paused: None) + res = sync._guard_actuate("openclaw", "sess-1", "", "resume") + assert res["ok"] is True + assert res["detail"] == "resumed_via_proxy_hitl" + + +# ── proxy liveness probe ────────────────────────────────────────────────── +def test_proxy_probe_treats_a_stale_pid_file_as_not_running(monkeypatch, tmp_path): + pid_file = tmp_path / "proxy.pid" + pid_file.write_text("999999") + monkeypatch.setattr(pc, "_PROXY_PID_FILE", str(pid_file)) + monkeypatch.setattr(pc, "is_alive", lambda p: False) + st = pc.enforcement_proxy_status() + assert st["running"] is False and st["reason"] == "stale proxy pid file" + + +def test_proxy_probe_survives_a_garbage_pid_file(monkeypatch, tmp_path): + pid_file = tmp_path / "proxy.pid" + pid_file.write_text("not-a-pid") + monkeypatch.setattr(pc, "_PROXY_PID_FILE", str(pid_file)) + assert pc.enforcement_proxy_status()["running"] is False + + +def test_proxy_probe_reports_a_live_proxy(monkeypatch, tmp_path): + pid_file = tmp_path / "proxy.pid" + pid_file.write_text(" 4242\n") + monkeypatch.setattr(pc, "_PROXY_PID_FILE", str(pid_file)) + monkeypatch.setattr(pc, "is_alive", lambda p: p == 4242) + st = pc.enforcement_proxy_status() + assert st["running"] is True and st["pid"] == 4242 + + +# ── per-session capability, not per runtime ─────────────────────────────── +def test_cursor_cli_session_is_controllable(monkeypatch): + """A Cursor CLI session is a real process tree. Blanket-refusing the + runtime hid working buttons from these sessions.""" + monkeypatch.setattr(pc, "resolve_session", + lambda rt, sid="", cwd="": {"ok": True, "pid": 500, + "runtime": "cursor"}) + sup = pc.runtime_control_support("cursor", "cli-sess", "/repo") + assert sup["controllable"] is True + assert sup["actions"] == ["pause", "resume", "stop", "kill"] + assert sup["resolved_pid"] == 500 + + +def test_cursor_editor_session_is_refused_with_a_readable_reason(monkeypatch): + monkeypatch.setattr( + pc, "resolve_session", + lambda rt, sid="", cwd="": { + "ok": False, "unsupported": True, + "reason": "cursor_single_ide_process_no_per_session_signal"}) + sup = pc.runtime_control_support("cursor", "ide-sess") + assert sup["controllable"] is False and sup["actions"] == [] + # An operator-readable sentence, not the raw resolver enum. + assert "shared IDE process" in sup["reason"] + assert "_no_per_session_signal" not in sup["reason"] + + +def test_every_supported_runtime_is_controllable(): + """The list widened upstream (copilot, qwen_code, kimi, pi, grok, + deepseek_harness); the capability answer must track it, not a stale copy.""" + for rt in pc.SUPPORTED_RUNTIMES: + sup = pc.runtime_control_support(rt, "s", "/tmp") + assert sup["controllable"] is True, rt + assert "kill" in sup["actions"], rt + + +def test_unknown_runtime_is_refused(): + sup = pc.runtime_control_support("some-new-harness", "s") + assert sup["controllable"] is False + assert "some-new-harness" in sup["reason"] + + +def test_unsupported_platform_refuses_every_runtime(monkeypatch): + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_IS_WINDOWS", False) + for rt in ("claude_code", "openclaw", "cursor"): + sup = pc.runtime_control_support(rt, "s") + assert sup["controllable"] is False and sup["actions"] == [] + + +def test_windows_node_is_controllable(monkeypatch): + """The gap: every Guard button was inert on Windows.""" + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_IS_WINDOWS", True) + sup = pc.runtime_control_support("claude_code", "s") + assert sup["controllable"] is True + assert sup["platform"]["mechanism"] == "win32_native" + + +def test_capability_never_raises(monkeypatch): + monkeypatch.setattr(pc, "resolve_session", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + with pytest.raises(RuntimeError): + pc.resolve_session("cursor") + # The route wrapper is the layer that must absorb it. + from routes.guard import _runtime_supports_signals + sup = _runtime_supports_signals("cursor", "s", "/tmp") + assert sup["controllable"] is False and sup["actions"] == [] diff --git a/tests/test_guard_escalation_ladder.py b/tests/test_guard_escalation_ladder.py new file mode 100644 index 0000000000..c6d0508f57 --- /dev/null +++ b/tests/test_guard_escalation_ladder.py @@ -0,0 +1,306 @@ +"""Escalation ladders: pause, wait, then kill if still stuck. + +Before this a policy fired ONE action ONCE. The ladder existed only as an +ordering of action names ("kill is stronger than pause"), never as a sequence +over time, so the shape real operations want — *pause it, tell me, give it +five minutes, then kill it if it is still stuck* — could not be expressed. + +Covered here: the pure timing rules, the durable per-rung latch (including +the DuckDB PK migration), and that a ladder cannot slip any of the three +safety locks. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +from clawmetry import policy_engine as pe # noqa: E402 + + +def _incident(session_id="s1"): + return {"kind": "no_progress", "session_id": session_id, + "runtime": "claude_code", "severity": "warning", + "title": "agent not advancing", + "evidence": {"total_tool_calls": 40}} + + +_FACTS = {"s1": {"cost_usd": 9.0, "bad_for_seconds": 600, + "runtime": "claude_code", "cwd": "/tmp/x", "agent_id": "main"}} + +_LADDER = [{"action": "pause", "after_secs": 0}, + {"action": "alert", "after_secs": 60}, + {"action": "kill", "after_secs": 300}] + + +def _policy(steps=None, action="monitor", policy_id="p1"): + p = {"policy_id": policy_id, "enabled": True, "scope_runtime": "", + "scope_agent_id": "", "trigger_kind": "", "min_severity": "info", + "min_repeat": 0, "min_duration_s": 0, "min_spend_usd": 0, + "action": action} + if steps is not None: + p["steps"] = steps + return p + + +def _state(step, at): + return {"s1": {"p1": {"last_step": step, "last_fired_at": at}}} + + +# ── normalize_steps ─────────────────────────────────────────────────────── +def test_a_policy_without_steps_is_a_one_rung_ladder(): + """The compatibility guarantee: every pre-ladder policy is untouched.""" + steps = pe.normalize_steps(_policy(action="kill")) + assert steps == [{"action": "kill", "after_secs": 0}] + + +def test_step_zero_delay_is_forced_to_zero(): + steps = pe.normalize_steps(_policy([{"action": "pause", "after_secs": 999}])) + assert steps[0]["after_secs"] == 0 + + +def test_unknown_actions_are_dropped_never_coerced(): + """Coercing 'terminate' to some default would silently change what the + rule does to someone's agent.""" + steps = pe.normalize_steps(_policy([ + {"action": "pause", "after_secs": 0}, + {"action": "terminate", "after_secs": 10}, + {"action": "kill", "after_secs": 30}, + ])) + assert [s["action"] for s in steps] == ["pause", "kill"] + + +def test_a_ladder_of_only_bad_rungs_falls_back_to_the_action(): + steps = pe.normalize_steps(_policy([{"action": "nope"}], action="monitor")) + assert steps == [{"action": "monitor", "after_secs": 0}] + + +def test_ladder_length_is_capped(): + steps = pe.normalize_steps(_policy( + [{"action": "alert", "after_secs": 1}] * 40)) + assert len(steps) == pe.MAX_LADDER_STEPS + + +def test_absurd_delays_are_clamped(): + """A typo of 300000 for 300 would otherwise park a ladder for 3 days.""" + steps = pe.normalize_steps(_policy([ + {"action": "pause", "after_secs": 0}, + {"action": "kill", "after_secs": 999999999}, + ])) + assert steps[1]["after_secs"] == pe.MAX_STEP_DELAY_SECS + + +def test_steps_stored_as_json_text_are_understood(): + """The store round-trips steps as a JSON column; both forms must behave + identically or a policy would act differently after a restart.""" + import json + steps = pe.normalize_steps(_policy(json.dumps(_LADDER))) + assert [s["action"] for s in steps] == ["pause", "alert", "kill"] + + +# ── ladder timing ───────────────────────────────────────────────────────── +def test_first_rung_fires_immediately(): + d = pe.evaluate([_incident()], [_policy(_LADDER)], _FACTS, now=1000)[0] + assert d["action"] == "pause" and d["step_index"] == 0 + assert d["step_count"] == 3 and d["is_final_step"] is False + assert d["next_action"] == "alert" and d["next_after_secs"] == 60 + + +def test_next_rung_waits_for_its_delay(): + args = ([_incident()], [_policy(_LADDER)], _FACTS) + assert pe.evaluate(*args, ladder_state=_state(0, 1000), now=1030) == [] + assert pe.evaluate(*args, ladder_state=_state(0, 1000), now=1059) == [] + d = pe.evaluate(*args, ladder_state=_state(0, 1000), now=1061)[0] + assert d["action"] == "alert" and d["step_index"] == 1 + + +def test_delay_is_measured_from_the_previous_rung_not_the_incident(): + """'kill 5 minutes after the pause', not '5 minutes after it got stuck'.""" + args = ([_incident()], [_policy(_LADDER)], _FACTS) + # Rung 1 fired late (t=5000); rung 2 is due at 5300, not at 1000+300. + assert pe.evaluate(*args, ladder_state=_state(1, 5000), now=5299) == [] + d = pe.evaluate(*args, ladder_state=_state(1, 5000), now=5301)[0] + assert d["action"] == "kill" + + +def test_final_rung_is_marked_and_the_ladder_then_stops(): + args = ([_incident()], [_policy(_LADDER)], _FACTS) + d = pe.evaluate(*args, ladder_state=_state(1, 1000), now=9999)[0] + assert d["action"] == "kill" and d["is_final_step"] is True + assert d["next_action"] == "" + # Exhausted: nothing more, however long we wait. + assert pe.evaluate(*args, ladder_state=_state(2, 1000), now=10**9) == [] + + +def test_a_recovered_session_stops_the_ladder(): + """'kill if STILL stuck' must mean still stuck: no incident, no rung.""" + assert pe.evaluate([], [_policy(_LADDER)], _FACTS, + ladder_state=_state(0, 1000), now=10**9) == [] + + +def test_unknown_fire_time_delays_rather_than_fires(): + """The safe way to be wrong when the next rung might be a kill.""" + args = ([_incident()], [_policy(_LADDER)], _FACTS) + state = {"s1": {"p1": {"last_step": 0, "last_fired_at": None}}} + assert pe.evaluate(*args, ladder_state=state, now=10**9) == [] + + +def test_reason_names_the_rung_and_what_comes_next(): + d = pe.evaluate([_incident()], [_policy(_LADDER)], _FACTS, now=1000)[0] + assert "[step 1/3]" in d["reason"] + assert "then alert in 1m if still matching" in d["reason"] + + +def test_single_action_policy_reason_is_unchanged(): + d = pe.evaluate([_incident()], [_policy(action="kill")], _FACTS, now=1000)[0] + assert "step" not in d["reason"] + assert d["step_count"] == 1 and d["is_final_step"] is True + + +def test_still_one_decision_per_session_across_ladders(): + """Two ladders on one session must not produce two signals in a tick.""" + ds = pe.evaluate([_incident()], + [_policy(_LADDER, policy_id="p1"), + _policy([{"action": "kill", "after_secs": 0}], + policy_id="p2")], + _FACTS, now=1000) + assert len(ds) == 1 + assert ds[0]["action"] == "kill" # strongest wins, as before + + +# ── durable per-rung latch, against a real DuckDB store ────────────────── +@pytest.fixture +def store(tmp_path, monkeypatch): + """A throwaway DuckDB store. + + CLAWMETRY_LOCAL_STORE_PATH is set BEFORE the reload so the module-level + default path is rebound to tmp_path — the store must never touch the real + ~/.clawmetry database. + """ + pytest.importorskip("duckdb") + monkeypatch.setenv("CLAWMETRY_LOCAL_STORE_PATH", str(tmp_path / "cm.duckdb")) + import importlib + + import clawmetry.local_store as ls + importlib.reload(ls) + st = ls.LocalStore() + assert str(tmp_path) in str(getattr(ls, "DB_PATH", tmp_path)), \ + "refusing to run against the real store" + try: + yield st + finally: + try: + st.close() + except Exception: + pass + # Restore the module to the real default path. Leaving it bound to a + # deleted tmp_path would make a later test in the same session fail + # for a reason that has nothing to do with it. + monkeypatch.undo() + importlib.reload(ls) + + +def test_each_rung_latches_independently(store): + store.record_policy_action("s1", "p1", action="pause", step_index=0) + assert store.policy_already_fired("s1", "p1", 0) is True + # The whole point: rung 1 is NOT latched by rung 0's row. + assert store.policy_already_fired("s1", "p1", 1) is False + store.record_policy_action("s1", "p1", action="kill", step_index=1) + assert store.policy_already_fired("s1", "p1", 1) is True + + +def test_re_recording_a_rung_updates_it_rather_than_duplicating(store): + store.record_policy_action("s1", "p1", action="pause", step_index=0, + result_detail="pending") + store.record_policy_action("s1", "p1", action="pause", step_index=0, + result_ok=True, result_detail="paused") + rows = [r for r in store.query_policy_actions(limit=50) + if r["session_id"] == "s1"] + assert len(rows) == 1 + assert rows[0]["result_detail"] == "paused" and rows[0]["result_ok"] is True + + +def test_ladder_state_survives_a_restart(store): + """The reason the latch is durable: a restart must resume at the rung it + reached, not replay a ladder that ends in kill.""" + store.record_policy_action("s1", "p1", action="pause", step_index=0) + store.record_policy_action("s1", "p1", action="alert", step_index=1) + state = store.query_policy_ladder_state() + assert state["s1"]["p1"]["last_step"] == 1 + assert state["s1"]["p1"]["last_fired_at"] > 0 + # Fed back to the engine, it resumes at rung 2 rather than rung 0. + d = pe.evaluate([_incident()], [_policy(_LADDER)], _FACTS, + ladder_state=state, now=state["s1"]["p1"]["last_fired_at"] + 400)[0] + assert d["action"] == "kill" and d["step_index"] == 2 + + +def test_ladder_state_is_epoch_seconds_not_milliseconds(store): + """A unit mismatch here would make every delay look 1000x satisfied.""" + import time + store.record_policy_action("s1", "p1", action="pause", step_index=0) + fired = store.query_policy_ladder_state()["s1"]["p1"]["last_fired_at"] + assert abs(fired - time.time()) < 60 + + +def test_policy_round_trips_its_ladder(store): + store.upsert_session_policy(_policy(_LADDER)) + row = [r for r in store.query_session_policies() + if r["policy_id"] == "p1"][0] + assert [s["action"] for s in row["steps"]] == ["pause", "alert", "kill"] + assert row["steps"][2]["after_secs"] == 300 + + +def test_a_plain_policy_reads_back_with_no_ladder(store): + store.upsert_session_policy(_policy(action="kill")) + row = [r for r in store.query_session_policies() + if r["policy_id"] == "p1"][0] + assert row["steps"] == [] + # …and the engine still treats it as a one-rung ladder. + assert pe.normalize_steps(row) == [{"action": "kill", "after_secs": 0}] + + +def test_audit_rows_carry_the_rung(store): + store.record_policy_action("s1", "p1", action="kill", step_index=2) + row = [r for r in store.query_policy_actions(limit=10) + if r["session_id"] == "s1"][0] + assert row["step_index"] == 2 + + +# ── the migration from the two-column latch ────────────────────────────── +def test_migration_widens_the_latch_and_keeps_existing_rows(tmp_path): + duckdb = pytest.importorskip("duckdb") + path = str(tmp_path / "old.duckdb") + conn = duckdb.connect(path) + conn.execute(""" + CREATE TABLE policy_actions ( + session_id VARCHAR NOT NULL, policy_id VARCHAR NOT NULL, + runtime VARCHAR, action VARCHAR, kind VARCHAR, reason VARCHAR, + evidence BLOB, enforced BOOLEAN DEFAULT FALSE, + result_ok BOOLEAN DEFAULT FALSE, result_detail VARCHAR, + created_at BIGINT NOT NULL, + PRIMARY KEY (session_id, policy_id)) + """) + conn.execute("INSERT INTO policy_actions (session_id, policy_id, action, " + "created_at) VALUES ('old-s', 'old-p', 'kill', 1000)") + conn.close() + + from clawmetry.local_store import _migrate_policy_actions_ladder + conn = duckdb.connect(path) + _migrate_policy_actions_ladder(conn) + cols = {r[1] for r in conn.execute("PRAGMA table_info('policy_actions')").fetchall()} + assert "step_index" in cols + # The historical decision is preserved, at rung 0 where it belongs. + row = conn.execute("SELECT session_id, policy_id, step_index, action " + "FROM policy_actions").fetchone() + assert row == ("old-s", "old-p", 0, "kill") + # And rung 1 is now insertable without colliding with it. + conn.execute("INSERT INTO policy_actions (session_id, policy_id, " + "step_index, action, created_at) " + "VALUES ('old-s', 'old-p', 1, 'kill', 2000)") + assert conn.execute("SELECT COUNT(*) FROM policy_actions").fetchone()[0] == 2 + + # Idempotent: a second pass is a no-op, not a data-losing rebuild. + _migrate_policy_actions_ladder(conn) + assert conn.execute("SELECT COUNT(*) FROM policy_actions").fetchone()[0] == 2 + conn.close() diff --git a/tests/test_guard_policies_api_ladder.py b/tests/test_guard_policies_api_ladder.py new file mode 100644 index 0000000000..3bee9a309d --- /dev/null +++ b/tests/test_guard_policies_api_ladder.py @@ -0,0 +1,119 @@ +"""The /api/guard/policies contract for escalation ladders. + +A ladder authored in the UI must come back exactly as the engine will run it, +and a malformed rung must be REFUSED rather than silently dropped: the store +normalizes by dropping unusable rungs (right for reading old rows), which +would otherwise hand an author a ladder quietly missing a step. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 +from flask import Flask # noqa: E402 + +from routes.guard import bp_guard # noqa: E402 + + +@pytest.fixture +def client(monkeypatch): + """Flask test client over an in-memory policy store.""" + saved = {} + + def _write(method, **kw): + if method == "upsert_session_policy": + p = kw["policy"] + saved[p["policy_id"]] = p + elif method == "delete_session_policy": + saved.pop(kw.get("policy_id"), None) + + def _call(method, **kw): + if method == "query_session_policies": + return list(saved.values()) + return None + + import routes.guard as g + monkeypatch.setattr(g, "_ls_write", _write) + monkeypatch.setattr(g, "_ls_call", _call) + + app = Flask(__name__) + app.register_blueprint(bp_guard) + with app.test_client() as c: + yield c, saved + + +def _post(client, **body): + return client.post("/api/guard/policies", json=body) + + +def test_a_ladder_round_trips(client): + c, saved = client + r = _post(c, name="stuck", action="pause", steps=[ + {"action": "pause", "after_secs": 0}, + {"action": "kill", "after_secs": 300}, + ]) + assert r.status_code == 200 and r.json["ok"] is True + steps = r.json["policy"]["steps"] + assert [s["action"] for s in steps] == ["pause", "kill"] + assert steps[1]["after_secs"] == 300 + + +def test_an_unknown_step_action_is_refused_not_dropped(client): + c, _ = client + r = _post(c, action="pause", steps=[ + {"action": "pause", "after_secs": 0}, + {"action": "terminate", "after_secs": 60}, + ]) + assert r.status_code == 400 + assert "step 2" in r.json["error"] + + +def test_a_negative_delay_is_refused(client): + c, _ = client + r = _post(c, action="pause", steps=[ + {"action": "pause", "after_secs": 0}, + {"action": "kill", "after_secs": -5}, + ]) + assert r.status_code == 400 and "after_secs" in r.json["error"] + + +def test_a_non_list_ladder_is_refused(client): + c, _ = client + r = _post(c, action="pause", steps={"action": "kill"}) + assert r.status_code == 400 + + +def test_an_over_long_ladder_is_refused(client): + from clawmetry.policy_engine import MAX_LADDER_STEPS + c, _ = client + r = _post(c, action="alert", + steps=[{"action": "alert", "after_secs": 1}] * (MAX_LADDER_STEPS + 1)) + assert r.status_code == 400 and str(MAX_LADDER_STEPS) in r.json["error"] + + +def test_step_zero_delay_is_normalized_in_the_echo(client): + """What the operator is shown must equal what the engine will run.""" + c, _ = client + r = _post(c, action="pause", + steps=[{"action": "pause", "after_secs": 900}, + {"action": "kill", "after_secs": 60}]) + assert r.json["policy"]["steps"][0]["after_secs"] == 0 + + +def test_a_plain_policy_still_works(client): + c, _ = client + r = _post(c, name="simple", action="kill") + assert r.status_code == 200 and r.json["ok"] is True + assert r.json["policy"]["steps"] == [{"action": "kill", "after_secs": 0}] + + +def test_get_exposes_the_ladder_limits(client): + c, _ = client + _post(c, action="monitor") + r = c.get("/api/guard/policies") + assert r.status_code == 200 + assert r.json["max_ladder_steps"] >= 2 + assert "kill" in r.json["actions"] + # Every policy reads back with a ladder, plain ones included. + assert all(p["steps"] for p in r.json["policies"]) diff --git a/tests/test_guard_policy_enforcement.py b/tests/test_guard_policy_enforcement.py new file mode 100644 index 0000000000..156d324467 --- /dev/null +++ b/tests/test_guard_policy_enforcement.py @@ -0,0 +1,309 @@ +"""Daemon-side Guard enforcement: the three safety locks and the latch. + +These tests drive ``sync._apply_guard_policies`` with a fake store and a +stubbed actuator, so nothing is ever signalled for real. What they assert is +the thing that matters: a policy must NOT reach a live process unless every +lock is open, and must never fire twice for the same session. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +from clawmetry import sync as _sync # noqa: E402 + + +class FakeStore: + """Minimal stand-in for LocalStore's Guard surface.""" + + def __init__(self, policies): + self._policies = policies + self.recorded = [] + self.fired = set() + + def query_session_policies(self, enabled_only=False): + return [p for p in self._policies + if p.get("enabled", True) or not enabled_only] + + def policy_already_fired(self, session_id, policy_id): + return (session_id, policy_id) in self.fired + + def record_policy_action(self, session_id, policy_id, **kw): + self.fired.add((session_id, policy_id)) + self.recorded.append({"session_id": session_id, + "policy_id": policy_id, **kw}) + + +def _incident(session_id="s1", kind="no_progress", runtime="claude_code"): + return {"kind": kind, "session_id": session_id, "runtime": runtime, + "severity": "warning", "title": "agent not advancing", + "evidence": {"total_tool_calls": 40}} + + +def _policy(action="kill", policy_id="p1"): + return {"policy_id": policy_id, "enabled": True, "scope_runtime": "", + "scope_agent_id": "", "trigger_kind": "", "min_severity": "info", + "min_repeat": 0, "min_duration_s": 0, "min_spend_usd": 0, + "action": action} + + +_FACTS = {"s1": {"cost_usd": 9.0, "bad_for_seconds": 600, + "runtime": "claude_code", "cwd": "/tmp/x", "agent_id": "main"}} + + +@pytest.fixture(autouse=True) +def _stub_actuator(monkeypatch): + """Record actuator calls instead of signalling anything.""" + calls = [] + + def fake_actuate(runtime, session_id, cwd, action): + calls.append((runtime, session_id, cwd, action)) + return {"ok": True, "detail": "stub"} + + monkeypatch.setattr(_sync, "_guard_actuate", fake_actuate) + monkeypatch.delenv("CLAWMETRY_POLICY_ENFORCE", raising=False) + monkeypatch.delenv("CLAWMETRY_GUARD_POLICIES", raising=False) + return calls + + +def _entitled(monkeypatch, value=True): + monkeypatch.setattr(_sync, "_guard_enforcement_allowed", lambda: value) + + +# ── lock 1: the action itself ────────────────────────────────────────────── + +def test_monitor_never_actuates_even_fully_enabled(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="monitor")]) + n = _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert n == 1, "the decision is still recorded" + assert _stub_actuator == [], "monitor must never signal a process" + assert store.recorded[0]["enforced"] is False + + +# ── lock 2: the enforce env flag ─────────────────────────────────────────── + +def test_dry_run_by_default_records_but_does_not_act(monkeypatch, _stub_actuator): + _entitled(monkeypatch) + store = FakeStore([_policy(action="kill")]) + n = _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert n == 1 + assert _stub_actuator == [], "no enforce flag: must not kill" + rec = store.recorded[0] + assert rec["enforced"] is False + assert "DRY RUN" in rec["result_detail"] + assert "CLAWMETRY_POLICY_ENFORCE" in rec["result_detail"] + + +def test_guard_can_be_disabled_entirely(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_GUARD_POLICIES", "0") + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="kill")]) + assert _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) == 0 + assert _stub_actuator == [] + assert store.recorded == [] + + +# ── lock 3: entitlement ──────────────────────────────────────────────────── + +def test_unentitled_node_records_but_does_not_act(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch, False) + store = FakeStore([_policy(action="kill")]) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert _stub_actuator == [] + assert "not enabled on this plan" in store.recorded[0]["result_detail"] + + +def test_entitlement_check_fails_closed(monkeypatch): + """A resolver explosion must read as 'not allowed', never 'allowed'.""" + import clawmetry.entitlements as _ent + + def boom(): + raise RuntimeError("resolver down") + + monkeypatch.setattr(_ent, "get_entitlement", boom) + assert _sync._guard_enforcement_allowed() is False + + +# ── all locks open: it actually acts ─────────────────────────────────────── + +def test_all_locks_open_actuates(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="kill")]) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert _stub_actuator == [("claude_code", "s1", "/tmp/x", "kill")] + final = store.recorded[-1] + assert final["enforced"] is True and final["result_ok"] is True + + +# ── the latch ────────────────────────────────────────────────────────────── + +def test_latch_prevents_double_fire(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="kill")]) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert len(_stub_actuator) == 1, "a policy must fire at most once per session" + + +def test_latch_read_failure_declines_to_act(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="kill")]) + + def boom(*a, **k): + raise RuntimeError("duckdb gone") + + store.policy_already_fired = boom + assert _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) == 0 + assert _stub_actuator == [] + + +# ── never break ingest ───────────────────────────────────────────────────── + +def test_store_failure_never_raises(monkeypatch, _stub_actuator): + class Broken: + def query_session_policies(self, enabled_only=False): + raise RuntimeError("boom") + + assert _sync._apply_guard_policies(Broken(), {}, [_incident()], _FACTS) == 0 + + +def test_no_incidents_is_a_noop(_stub_actuator): + store = FakeStore([_policy(action="kill")]) + assert _sync._apply_guard_policies(store, {}, [], _FACTS) == 0 + assert store.recorded == [] + + +def test_one_session_one_signal_even_with_many_policies(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + _entitled(monkeypatch) + store = FakeStore([_policy(action="pause", policy_id="a"), + _policy(action="kill", policy_id="b"), + _policy(action="stop", policy_id="c")]) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + assert len(_stub_actuator) == 1 + assert _stub_actuator[0][3] == "kill", "strongest action wins" + + +# ── escalation ladders must not weaken any lock ────────────────────────── +_LADDER = [{"action": "pause", "after_secs": 0}, + {"action": "kill", "after_secs": 300}] + + +def _ladder_policy(policy_id="p1"): + p = _policy(action="pause", policy_id=policy_id) + p["steps"] = _LADDER + return p + + +class LadderStore(FakeStore): + """FakeStore that tracks rungs, like the real per-step latch.""" + + def __init__(self, policies): + super().__init__(policies) + self.rungs = {} + self.now = 1000.0 + + def query_policy_ladder_state(self, limit=2000): + out = {} + for (sid, pid, step), ts in self.rungs.items(): + cur = out.setdefault(sid, {}).get(pid) + if cur is None or step > cur["last_step"]: + out.setdefault(sid, {})[pid] = {"last_step": step, + "last_fired_at": ts} + return out + + def policy_already_fired(self, session_id, policy_id, step_index=0): + return (session_id, policy_id, step_index) in self.rungs + + def record_policy_action(self, session_id, policy_id, step_index=0, **kw): + self.rungs.setdefault((session_id, policy_id, step_index), self.now) + self.recorded.append({"session_id": session_id, + "policy_id": policy_id, + "step_index": step_index, **kw}) + + +def _tick(store, monkeypatch, at): + """Run one daemon tick with the engine's clock pinned to ``at``.""" + from clawmetry import policy_engine as pe + store.now = at + real = pe.evaluate + monkeypatch.setattr( + pe, "evaluate", + lambda i, p, f, ladder_state=None, now=None: + real(i, p, f, ladder_state=ladder_state, now=at)) + _sync._apply_guard_policies(store, {}, [_incident()], _FACTS) + monkeypatch.setattr(pe, "evaluate", real) + + +def test_ladder_kill_rung_is_a_dry_run_without_the_enforce_flag( + monkeypatch, _stub_actuator): + """A ladder must not become a way to skip CLAWMETRY_POLICY_ENFORCE.""" + monkeypatch.delenv("CLAWMETRY_POLICY_ENFORCE", raising=False) + monkeypatch.setattr(_sync, "_guard_enforcement_allowed", lambda: True) + store = LadderStore([_ladder_policy()]) + _tick(store, monkeypatch, 1000) + _tick(store, monkeypatch, 1400) + assert _stub_actuator == [], "no rung may signal with enforcement off" + # Both rungs are still RECORDED, which is what makes dry run honest. + assert sorted(r["step_index"] for r in store.recorded) == [0, 1] + assert all("DRY RUN" in r["result_detail"] for r in store.recorded) + + +def test_ladder_kill_rung_respects_the_entitlement_lock( + monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + monkeypatch.setattr(_sync, "_guard_enforcement_allowed", lambda: False) + store = LadderStore([_ladder_policy()]) + _tick(store, monkeypatch, 1000) + _tick(store, monkeypatch, 1400) + assert _stub_actuator == [] + + +def test_ladder_climbs_when_every_lock_is_open(monkeypatch, _stub_actuator): + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + monkeypatch.setattr(_sync, "_guard_enforcement_allowed", lambda: True) + store = LadderStore([_ladder_policy()]) + _tick(store, monkeypatch, 1000) + assert [c[3] for c in _stub_actuator] == ["pause"] + _tick(store, monkeypatch, 1100) # too early for the kill rung + assert [c[3] for c in _stub_actuator] == ["pause"] + _tick(store, monkeypatch, 1400) + assert [c[3] for c in _stub_actuator] == ["pause", "kill"] + _tick(store, monkeypatch, 9999) # exhausted: never again + assert [c[3] for c in _stub_actuator] == ["pause", "kill"] + + +def test_a_prelader_store_refuses_to_climb_rather_than_replaying( + monkeypatch, _stub_actuator): + """Version skew: a daemon whose store predates ladders can only latch per + (session, policy). It must stop at rung 0, not re-fire it forever.""" + monkeypatch.setenv("CLAWMETRY_POLICY_ENFORCE", "1") + monkeypatch.setattr(_sync, "_guard_enforcement_allowed", lambda: True) + + class OldStore(LadderStore): + def policy_already_fired(self, session_id, policy_id): # 2-arg only + return any(k[0] == session_id and k[1] == policy_id + for k in self.rungs) + + def record_policy_action(self, session_id, policy_id, **kw): + kw.pop("step_index", None) + self.rungs.setdefault((session_id, policy_id, 0), self.now) + self.recorded.append({"session_id": session_id, + "policy_id": policy_id, **kw}) + + store = OldStore([_ladder_policy()]) + _tick(store, monkeypatch, 1000) + _tick(store, monkeypatch, 1400) + _tick(store, monkeypatch, 5000) + assert [c[3] for c in _stub_actuator] == ["pause"], \ + "an old store must not re-fire rung 0 nor climb" diff --git a/tests/test_policy_engine.py b/tests/test_policy_engine.py new file mode 100644 index 0000000000..3066ba17ee --- /dev/null +++ b/tests/test_policy_engine.py @@ -0,0 +1,181 @@ +"""Unit tests for the pure Guard policy evaluator. + +No daemon, no DuckDB, no live agent — ``policy_engine.evaluate`` is a pure +function, so every matching rule and every safety invariant is testable here. +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from clawmetry import policy_engine as pe # noqa: E402 + + +def _incident(kind="no_progress", session_id="s1", runtime="claude_code", + severity="warning", count=25, title="agent not advancing"): + return { + "kind": kind, + "session_id": session_id, + "runtime": runtime, + "severity": severity, + "title": title, + "detail": "", + "evidence": {"total_tool_calls": count}, + "first_bad_step": 3, + } + + +def _policy(policy_id="p1", action="pause", **kw): + base = { + "policy_id": policy_id, + "enabled": True, + "scope_runtime": "", + "scope_agent_id": "", + "trigger_kind": "", + "min_severity": "info", + "min_repeat": 0, + "min_duration_s": 0, + "min_spend_usd": 0.0, + "action": action, + } + base.update(kw) + return base + + +# ── basic matching ───────────────────────────────────────────────────────── + +def test_no_policies_means_no_decisions(): + assert pe.evaluate([_incident()], []) == [] + + +def test_bare_policy_matches_any_incident(): + out = pe.evaluate([_incident()], [_policy()]) + assert len(out) == 1 + assert out[0]["action"] == "pause" + assert out[0]["session_id"] == "s1" + assert out[0]["kind"] == "no_progress" + + +def test_disabled_policy_never_fires(): + assert pe.evaluate([_incident()], [_policy(enabled=False)]) == [] + + +def test_trigger_kind_filters(): + pol = _policy(trigger_kind="stuck_loop") + assert pe.evaluate([_incident(kind="no_progress")], [pol]) == [] + assert len(pe.evaluate([_incident(kind="stuck_loop")], [pol])) == 1 + + +def test_runtime_scope_filters_and_is_case_insensitive(): + pol = _policy(scope_runtime="Claude_Code") + assert len(pe.evaluate([_incident(runtime="claude_code")], [pol])) == 1 + assert pe.evaluate([_incident(runtime="codex")], [pol]) == [] + + +def test_unknown_action_is_refused_not_guessed(): + assert pe.evaluate([_incident()], [_policy(action="delete_everything")]) == [] + + +# ── thresholds ───────────────────────────────────────────────────────────── + +def test_min_repeat_threshold(): + pol = _policy(min_repeat=30) + assert pe.evaluate([_incident(count=25)], [pol]) == [] + assert len(pe.evaluate([_incident(count=30)], [pol])) == 1 + + +def test_min_severity_threshold(): + pol = _policy(min_severity="warning") + assert pe.evaluate([_incident(severity="info")], [pol]) == [] + assert len(pe.evaluate([_incident(severity="warning")], [pol])) == 1 + + +def test_spend_and_duration_thresholds_are_anded(): + pol = _policy(min_duration_s=300, min_spend_usd=5.0) + facts = {"s1": {"bad_for_seconds": 600, "cost_usd": 4.0}} + assert pe.evaluate([_incident()], [pol], facts) == [] # spend too low + facts = {"s1": {"bad_for_seconds": 60, "cost_usd": 9.0}} + assert pe.evaluate([_incident()], [pol], facts) == [] # too soon + facts = {"s1": {"bad_for_seconds": 600, "cost_usd": 9.0}} + assert len(pe.evaluate([_incident()], [pol], facts)) == 1 + + +def test_missing_facts_do_not_accidentally_satisfy_thresholds(): + """A session we have no facts for must NOT match a spend threshold.""" + pol = _policy(min_spend_usd=5.0) + assert pe.evaluate([_incident()], [pol], {}) == [] + + +def test_zeroed_thresholds_never_block(): + facts = {"s1": {"bad_for_seconds": 0, "cost_usd": 0}} + assert len(pe.evaluate([_incident()], [_policy()], facts)) == 1 + + +# ── safety invariants ────────────────────────────────────────────────────── + +def test_at_most_one_decision_per_session_strongest_wins(): + policies = [_policy("p1", action="alert"), _policy("p2", action="kill"), + _policy("p3", action="pause")] + out = pe.evaluate([_incident()], policies) + assert len(out) == 1, "must never emit two signals for one session" + assert out[0]["action"] == "kill" + assert out[0]["policy_id"] == "p2" + + +def test_tie_break_is_deterministic(): + policies = [_policy("zzz", action="pause"), _policy("aaa", action="pause")] + first = pe.evaluate([_incident()], policies) + second = pe.evaluate([_incident()], list(reversed(policies))) + assert first[0]["policy_id"] == "aaa" + assert first == second + + +def test_monitor_returns_a_decision_so_dry_run_is_visible(): + out = pe.evaluate([_incident()], [_policy(action="monitor")]) + assert len(out) == 1 + assert out[0]["action"] == "monitor" + assert not pe.is_actuating("monitor") + assert "would monitor" in out[0]["reason"] + + +def test_actuating_classification(): + assert pe.is_actuating("pause") and pe.is_actuating("stop") + assert pe.is_actuating("kill") + assert not pe.is_actuating("alert") + assert not pe.is_actuating("") + + +def test_incident_without_session_id_is_skipped(): + bad = _incident() + bad["session_id"] = "" + assert pe.evaluate([bad], [_policy()]) == [] + + +def test_malformed_rows_never_raise(): + assert pe.evaluate([None, "junk", {}], [None, "junk", _policy()]) == [] + + +def test_multiple_sessions_each_get_a_decision(): + incidents = [_incident(session_id="s1"), _incident(session_id="s2")] + out = pe.evaluate(incidents, [_policy()]) + assert {d["session_id"] for d in out} == {"s1", "s2"} + + +def test_output_is_sorted_strongest_first(): + incidents = [_incident(session_id="s1"), _incident(session_id="s2")] + policies = [_policy("p1", action="alert", scope_runtime=""), + _policy("p2", action="kill", trigger_kind="stuck_loop")] + incidents.append(_incident(session_id="s3", kind="stuck_loop")) + out = pe.evaluate(incidents, policies) + assert out[0]["action"] == "kill" + + +def test_evidence_records_the_numbers_that_matched(): + pol = _policy(min_repeat=10, min_spend_usd=1.0) + facts = {"s1": {"bad_for_seconds": 420, "cost_usd": 3.5}} + out = pe.evaluate([_incident(count=38)], [pol], facts) + ev = out[0]["evidence"] + assert ev["count"] == 38 + assert ev["cost_usd"] == 3.5 + assert ev["thresholds"]["min_repeat"] == 10 + assert "38 events" in out[0]["reason"] diff --git a/tests/test_process_control_windows.py b/tests/test_process_control_windows.py new file mode 100644 index 0000000000..25e0df142a --- /dev/null +++ b/tests/test_process_control_windows.py @@ -0,0 +1,168 @@ +"""Windows control path — dispatch, ordering and honesty. + +The Win32 calls themselves can only be exercised on Windows, but everything +around them (which branch runs, in what order, what the result says) is +platform-independent and is what actually regressed before: the actuators +returned ``unsupported_platform`` on every Windows node, so every Guard button +was inert there. + +These tests fake the platform flags rather than skipping off-Windows, so the +Windows path is covered by the macOS and Linux CI legs too — a suite that only +runs on the Windows leg is how the no-op survived in the first place. +""" +import clawmetry.process_control as pc +import pytest + + +@pytest.fixture +def win(monkeypatch): + """Pretend we are on Windows, with every Win32 primitive stubbed.""" + monkeypatch.setattr(pc, "_IS_WINDOWS", True) + monkeypatch.setattr(pc, "_IS_MACOS", False) + monkeypatch.setattr(pc, "_IS_LINUX", False) + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_CONTROLLABLE_PLATFORM", True) + + calls = [] + monkeypatch.setattr(pc, "is_alive", lambda p: True) + # A three-deep tree: process_set is children-first, parent last. + monkeypatch.setattr(pc, "process_set", lambda p: [300, 200, int(p)]) + monkeypatch.setattr(pc, "_win_suspend", + lambda p: calls.append(("suspend", p)) or True) + monkeypatch.setattr(pc, "_win_resume", + lambda p: calls.append(("resume", p)) or True) + monkeypatch.setattr(pc, "_win_terminate", + lambda p: calls.append(("terminate", p)) or True) + monkeypatch.setattr(pc, "_win_taskkill", + lambda p, force=False, timeout=10.0: + calls.append(("taskkill", p, force)) or True) + monkeypatch.setattr(pc, "_win_ctrl_c", + lambda p, timeout=10.0: + (calls.append(("ctrl_c", p)), (True, "ctrl_c_sent_to_console"))[1]) + return calls + + +# ── the gap itself: these four used to be unconditional no-ops ──────────── +@pytest.mark.parametrize("fn,action", [ + (lambda: pc.pause(100), "pause"), + (lambda: pc.resume(100), "resume"), + (lambda: pc.stop_turn(100), "stop_turn"), + (lambda: pc.graceful_kill(100, grace_secs=0), "graceful_kill"), +]) +def test_actuators_are_not_unsupported_on_windows(win, fn, action): + res = fn() + assert res["detail"] != "unsupported_platform" + assert res["ok"] is True, res + + +def test_pause_suspends_children_before_parent(win): + res = pc.pause(100, "claude_code") + assert res["ok"] and res["detail"] == "paused" + assert win == [("suspend", 300), ("suspend", 200), ("suspend", 100)] + assert res["mechanism"] == "win32_nt_suspend_process" + + +def test_resume_wakes_parent_before_children(win): + pc.resume(100, "claude_code") + assert win == [("resume", 100), ("resume", 200), ("resume", 300)] + + +def test_partial_pause_is_reported_not_hidden(win, monkeypatch): + """A child we could not freeze keeps spending. Say so.""" + monkeypatch.setattr(pc, "_win_suspend", lambda p: p != 300) + res = pc.pause(100) + assert res["ok"] is True + assert res["failed"] == [300] + assert "could not be suspended" in res["detail"] + + +def test_stop_turn_declares_its_console_blast_radius(win): + res = pc.stop_turn(100, "codex") + assert res["ok"] and res["scope"] == "console" + assert res["mechanism"] == "win32_console_ctrl_c" + + +def test_stop_turn_reports_ctrl_c_failure_honestly(win, monkeypatch): + monkeypatch.setattr(pc, "_win_ctrl_c", + lambda p, timeout=10.0: (False, "attach_console_failed")) + res = pc.stop_turn(100) + assert res["ok"] is False and res["detail"] == "attach_console_failed" + + +def test_graceful_kill_resumes_before_the_graceful_pass(win): + """A suspended process cannot handle taskkill's close, so 'pause then + kill' (an escalation ladder) would otherwise always burn the full grace + window. Resume must come first.""" + pc.graceful_kill(100, grace_secs=0) + kinds = [c[0] for c in win] + assert kinds.index("resume") < kinds.index("taskkill") + + +def test_graceful_kill_escalates_to_terminate_when_still_alive(win): + res = pc.graceful_kill(100, grace_secs=0) + assert ("taskkill", 100, False) in win # graceful pass first + assert ("terminate", 300) in win # then the tree, leaves first + assert res["mechanism"] == "win32_terminate_process" + + +def test_graceful_kill_forces_taskkill_when_terminate_is_refused(win, monkeypatch): + """TerminateProcess can be refused for an elevated target; /F is the last + honest attempt rather than reporting a kill that did not happen.""" + monkeypatch.setattr(pc, "_win_terminate", lambda p: False) + pc.graceful_kill(100, grace_secs=0) + assert ("taskkill", 100, True) in win + + +def test_guarded_no_longer_refuses_the_platform(win, monkeypatch): + monkeypatch.setattr(pc, "resolve_session", + lambda rt, sid="", cwd="": {"ok": True, "pid": 100, + "runtime": rt, "cwd": cwd, + "recorded_start": None}) + monkeypatch.setattr(pc, "verify_pid", lambda pid, rec=None: (True, "verified")) + res = pc.pause_session("claude_code", "sess-1") + assert res["detail"] != "unsupported_platform" + assert res["ok"] is True + + +def test_dead_pid_still_refused_on_windows(win, monkeypatch): + monkeypatch.setattr(pc, "is_alive", lambda p: False) + assert pc.pause(100)["detail"] == "pid_not_alive" + assert pc.stop_turn(100)["detail"] == "pid_not_alive" + + +# ── honesty for platforms we genuinely do not support ───────────────────── +def test_unknown_platform_still_refuses(monkeypatch): + monkeypatch.setattr(pc, "_IS_WINDOWS", False) + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_CONTROLLABLE_PLATFORM", False) + for res in (pc.pause(1), pc.resume(1), pc.stop_turn(1), pc.graceful_kill(1)): + assert res["ok"] is False + assert res["detail"] == "unsupported_platform" + + +def test_platform_support_states_the_mechanism(monkeypatch): + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_IS_WINDOWS", True) + sup = pc.platform_support() + assert sup["controllable"] is True + assert sup["mechanism"] == "win32_native" + assert set(sup["actions"]) == {"pause", "resume", "stop", "kill"} + # The Ctrl+C caveat is a real behavioural difference; it must be surfaced. + assert "Ctrl+C" in sup["note"] + + +def test_platform_support_is_honest_about_an_unsupported_os(monkeypatch): + monkeypatch.setattr(pc, "_POSIX", False) + monkeypatch.setattr(pc, "_IS_WINDOWS", False) + sup = pc.platform_support() + assert sup["controllable"] is False and sup["actions"] == [] + assert sup["reason"] + + +# ── the primitives are inert, never raising, off Windows ────────────────── +def test_win_primitives_are_inert_off_windows(): + assert pc._win_kernel32() is None + assert pc._win_all_procs() == [] + assert pc._win_proc_start_epoch(1) is None + assert pc._win_ctrl_c(1) == (False, "not_windows") + assert pc._win_ntdll_call("NtSuspendProcess", 1) is False From 8922639a7d4ba52c2d8a5abddb921f0adc46775b Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 01:20:48 +0200 Subject: [PATCH 02/18] Session rows get Pause/Resume/Stop and a named rogue flag, not just banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder report: 'I've never seen these stop/pause/resume buttons in the UI.' They existed only behind CLOUD_MODE (cloud dialogs) and as a lone local ⏹ in one renderer. Now every session row carries the full control cluster, capability-resolved per session via /api/guard/sessions: enabled buttons say what they do, disabled ones carry the reason (Cursor editor conversation, no live process, Windows console scope), and pause on a proxy-less OpenClaw session reports advisory_only instead of claiming a hold. The rogue flag moves onto the row itself: the highest-ranked detector incident (same ranking as the Guard tab and the daemon) renders as a severity-colored chip named in plain words with spend-at-risk, clicking through to the Guard tab. Legacy loop badge remains the fallback when guard data is unavailable. Also fixes a branch-era bug: /api/guard/sessions trusted sessions.agent_type for the runtime, which reads 'openclaw' for nearly every row on a real install — capability verdicts would have been wrong for every family session. Now derives from the session-id prefix, mirroring sync._detector_runtime. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- clawmetry/static/js/app.js | 108 ++++++++++++++++++++++++++++++++++--- routes/guard.py | 20 ++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 093dad01b5..be13ba1d4e 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -13572,6 +13572,20 @@ async function loadSessions() { // running or the local store is unreachable. fetch('/api/loop-signals?limit=200&since_minutes=60').then(r => r.json()).catch(function() { return {signals:[]}; }) ]); + // Guard join: per-session control capability (which of Pause/Resume/Stop can + // actually work HERE, and why not when not) plus the ranked incident, from + // the same resolver the Guard tab and the daemon's policies use. Absent on + // cloud (the cloud has its own kill-switch dialogs) and on older daemons — + // rows then degrade to the legacy lone Stop button. + var guardBySid = {}; + if (!window.CLOUD_MODE) { + try { + var _gd = await fetch('/api/guard/sessions?limit=200').then(function(r) { return r.ok ? r.json() : {sessions:[]}; }); + ((_gd && _gd.sessions) || []).forEach(function(g) { + if (g && g.session_id) guardBySid[g.session_id] = g; + }); + } catch (e) { /* guard endpoints absent: legacy rendering */ } + } // Build a session_id → eval lookup for O(1) overlay. var evalMap = {}; ((evalData && evalData.evals) || []).forEach(function(e) { @@ -13636,16 +13650,63 @@ async function loadSessions() { ' in this session. Open the Brain tab for per-call detail."' + ' style="margin-left:6px;color:#dc2626;font-size:13px;">⚠'; } - // Issue #1364 — loop-detection badge. Shown when the proxy's LoopDetector - // has recorded repeated identical requests from this session in the last - // hour. Data comes from the loop_signals DuckDB table via /api/loop-signals. - var _loopCount = loopSessions[sid] || 0; - if (_loopCount > 0) { - html += '⚠ Looping'; + // Rogue flag on the row itself, not only the banner: the highest-ranked + // detector incident for this session (same ranking the Guard tab and the + // daemon use), named in plain words and colored by severity. Falls back + // to the legacy proxy loop badge when Guard data is unavailable. + var _guard = guardBySid[sid] || null; + var _inc = _guard && _guard.incident ? _guard.incident : null; + if (_inc) { + var _sevCrit = String(_inc.severity || '') === 'critical'; + var _incLabel = LOOP_KIND_LABEL[_inc.kind] || _inc.title || 'Off track'; + var _incMoney = loopMoney(_inc.spend_at_risk_usd); + var _incTitle = (_inc.title || _incLabel) + (_inc.detail ? ' — ' + _inc.detail : '') + + (_incMoney ? ' · est. ' + _incMoney + ' at risk (' + (_inc.spend_basis || 'unknown') + ')' : '') + + '. Click to open the Guard tab.'; + var _incColor = _sevCrit ? '#dc2626' : '#d97706'; + var _incBg = _sevCrit ? 'rgba(220,38,38,0.12)' : 'rgba(217,119,6,0.12)'; + var _incBorder = _sevCrit ? 'rgba(220,38,38,0.4)' : 'rgba(217,119,6,0.35)'; + html += '⚠ ' + + escHtml(_incLabel) + (_incMoney ? ' · ' + _incMoney : '') + ''; + } else { + // Issue #1364 — legacy loop badge (proxy LoopDetector repeats). + var _loopCount = loopSessions[sid] || 0; + if (_loopCount > 0) { + html += '⚠ Looping'; + } + } + html += ''; + // Session controls, right on the row. Pause/Resume render only where this + // node can actually deliver them (per-session capability from + // process_control.runtime_control_support); a control that cannot work is + // disabled with the reason on it rather than quietly doing nothing. Cloud + // keeps its own Stop dialog (its kill-switch JS overrides stopSession). + var _sidJs = escHtml(sid).replace(/'/g, "\\\\'"); + html += ''; + if (_guard && !window.CLOUD_MODE) { + var _acts = _guard.control_actions || []; + var _rtJs = escHtml(_guard.runtime || '').replace(/'/g, "\\\\'"); + var _cwdJs = escHtml(_guard.cwd || '').replace(/'/g, "\\\\'"); + var _mk = function(action, glyph, label, bg) { + var on = _acts.indexOf(action) !== -1; + var why = on ? (_guard.control_note || (label + ' this session')) + : (_guard.control_reason || 'Not available for this session'); + if (on) { + return ''; + } + return ''; + }; + html += _mk('pause', '⏸', 'Pause', '#b45309'); + html += _mk('resume', '▶', 'Resume', '#15803d'); + html += _mk('stop', '⏹', 'Stop', '#b91c1c'); + } else { + html += ''; } html += ''; - html += ''; html += '
'; var sessCost = costMap[sid] || costMap[(sid||'').slice(-16)] || null; html += '
'; @@ -13841,6 +13902,37 @@ function _renderSessionsRetentionCta(capped) { } } +// Row-level session control. One endpoint, the same actuator the Guard tab +// and the daemon's policies use, so a click here and an automatic policy +// action are identical to the agent process. Reports the REAL outcome — +// including advisory_only, where a "pause" is only a proxy flag and no +// enforcement proxy is running to honor it. +async function guardControl(sessionId, action, runtime, cwd) { + var sid = String(sessionId || '').trim(); + if (!sid || !action) return; + if (action === 'stop' || action === 'kill') { + if (!confirm(action.charAt(0).toUpperCase() + action.slice(1) + ' session "' + sid + '"?')) return; + } + try { + var r = await fetch('/api/guard/control', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({session_id: sid, action: action, + runtime: runtime || '', cwd: cwd || ''}) + }); + var data = await r.json(); + if (!r.ok || !data.ok) { + var why = (data && (data.detail || data.error)) || 'request failed'; + alert('Could not ' + action + ' this session: ' + why); + } else if (data.raw && data.raw.advisory_only) { + alert('Pause flag set, but no enforcement proxy is running to hold this session — it is advisory only. Start the proxy (clawmetry proxy start) to make pause bite.'); + } + loadSessions(); + } catch (e) { + alert('Could not ' + action + ' this session: ' + e.message); + } +} + async function stopSession(sessionId) { var sid = String(sessionId || '').trim(); if (!sid) return; diff --git a/routes/guard.py b/routes/guard.py index 5ca96905af..1f9e253ac5 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -147,6 +147,18 @@ def _runtime_supports_signals(runtime: str, session_id: str = "", "reason": f"capability check failed: {str(e)[:120]}"} +def _session_runtime(session_id: str, agent_type: str) -> str: + """Which runtime is this session, really? Mirrors ``sync._detector_runtime``: + the session-id prefix wins, ``agent_type`` is only the fallback, because on + a real install the column reads ``openclaw`` for nearly every row.""" + try: + from clawmetry import waste_flags as _wf + rt = str(_wf.runtime_from_session_id(session_id) or "").strip().lower() + except Exception: + rt = "" + return rt or str(agent_type or "").strip().lower() + + # Severity ladder shared with ``clawmetry.detectors`` (higher is louder). _SEVERITY_RANK = {"info": 0, "warning": 1, "critical": 2} @@ -248,7 +260,13 @@ def api_guard_sessions(): sid = str(s.get("session_id") or "") if not sid: continue - runtime = str(s.get("agent_type") or "") + # The ``sessions`` table's ``agent_type`` reads ``openclaw`` for + # nearly every row on a real install; the session-id prefix is the + # identity the rest of the product uses (same derivation as + # ``sync._detector_runtime``). Trusting the column here would hand + # ``runtime_control_support`` the wrong runtime for every family + # session and disable controls that work. + runtime = _session_runtime(sid, s.get("agent_type") or "") meta = s.get("metadata") meta = meta if isinstance(meta, dict) else {} cwd = "" From b40eff6512bbd3aead6f809a25a1ec2def2cabee Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 02:27:19 +0200 Subject: [PATCH 03/18] CI fixes: register the guard tab, close the CodeQL findings the PR introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard tab added to CANONICAL_TABS / PR_SCREENSHOT_TABS / DEFAULT_TABS so the C5 sweep, PR screenshots, and visual diff all cover it (the golden-path and E2E failures were exactly this gate doing its job). - Session-row controls now use data-attributes + one delegated listener instead of string-built onclick handlers, with a quote-escaping escAttr for every attribute context (escHtml alone lets '"' terminate the attribute — the trap attrJsStr documents). - /api/guard/control returns a curated result (detail, advisory_only, mechanism, note), never the raw actuator dict, and an exception logs server-side and returns a generic message. - qwen resolver refuses a session id carrying a path separator before it becomes a filename component (reachable from an HTTP-supplied id). Remaining CodeQL log-injection findings in sync.py/audit.py are pre-existing main code outside this diff. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- .github/scripts/visual-diff.mjs | 3 +-- .github/workflows/pr-screenshots.yml | 3 +-- clawmetry/process_control.py | 6 +++++ clawmetry/static/js/app.js | 38 ++++++++++++++++++++++------ routes/guard.py | 21 ++++++++++++--- tests/test_e2e_oss_all_tabs.py | 1 + 6 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.github/scripts/visual-diff.mjs b/.github/scripts/visual-diff.mjs index d4821c0c7b..4eaf48b281 100644 --- a/.github/scripts/visual-diff.mjs +++ b/.github/scripts/visual-diff.mjs @@ -55,8 +55,7 @@ const AUTH_TOKEN = process.env.CLAWMETRY_VISUAL_DIFF_TOKEN || ""; // switchTab() name here, in CANONICAL_TABS, and in PR_SCREENSHOT_TABS. // `overview` is the implicit default -- listed first for a `root` baseline. const DEFAULT_TABS = - "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench"; -const TABS = (process.env.PR_SCREENSHOT_TABS || DEFAULT_TABS) +"overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench";const TABS = (process.env.PR_SCREENSHOT_TABS || DEFAULT_TABS) .split(",") .map((p) => p.trim()) .filter(Boolean); diff --git a/.github/workflows/pr-screenshots.yml b/.github/workflows/pr-screenshots.yml index efb3c44be2..469b098fd6 100644 --- a/.github/workflows/pr-screenshots.yml +++ b/.github/workflows/pr-screenshots.yml @@ -38,8 +38,7 @@ env: # clawmetry/templates/tabs/*.html and be reachable via window.switchTab(). # See routes/* for the API endpoints each tab calls. # Must stay in sync with CANONICAL_TABS in tests/test_e2e_oss_all_tabs.py. - PR_SCREENSHOT_TABS: "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench" - HEAD_PORT: "8082" +PR_SCREENSHOT_TABS: "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench" HEAD_PORT: "8082" BASE_PORT: "8081" # Local-store fast-paths must be on so the dashboard renders the seeded # synthetic data instead of falling back to gateway/filesystem reads. diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index 9e85381cf7..f9dc3547f6 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -1619,6 +1619,12 @@ def resolve_qwen_code(session_id: str) -> Dict[str, Any]: sid = str(session_id or "").strip() if not sid: return {"ok": False, "runtime": "qwen_code", "reason": "no_session_id"} + # The session id becomes a filename component below. A value carrying a + # path separator or dot-dot must be refused, not resolved — this function + # is reachable from an HTTP-supplied session id. + if "/" in sid or "\\" in sid or ".." in sid or os.path.basename(sid) != sid: + return {"ok": False, "runtime": "qwen_code", + "reason": "invalid_session_id"} root = _qwen_projects_dir() try: hashes = os.listdir(root) diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index be13ba1d4e..80ad7cad8f 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -11878,6 +11878,9 @@ function renderLogs(elId, lines) { } function escHtml(s) { s=String(s||''); return s.replace(/&/g,'&').replace(//g,'>'); } +// Attribute context needs the quotes escaped too — escHtml alone lets a value +// containing '"' terminate the attribute it sits in. +function escAttr(s) { return escHtml(s).replace(/"/g,'"').replace(/'/g,'''); } // Embed a JS string literal inside a double-quoted inline handler // (onclick="fn(...)"). JSON.stringify emits double quotes, which TERMINATE // the surrounding attribute and silently truncate the handler (the Context @@ -13666,7 +13669,7 @@ async function loadSessions() { var _incColor = _sevCrit ? '#dc2626' : '#d97706'; var _incBg = _sevCrit ? 'rgba(220,38,38,0.12)' : 'rgba(217,119,6,0.12)'; var _incBorder = _sevCrit ? 'rgba(220,38,38,0.4)' : 'rgba(217,119,6,0.35)'; - html += '⚠ ' + escHtml(_incLabel) + (_incMoney ? ' · ' + _incMoney : '') + ''; } else { @@ -13683,28 +13686,30 @@ async function loadSessions() { // process_control.runtime_control_support); a control that cannot work is // disabled with the reason on it rather than quietly doing nothing. Cloud // keeps its own Stop dialog (its kill-switch JS overrides stopSession). - var _sidJs = escHtml(sid).replace(/'/g, "\\\\'"); + // Data-attributes + one delegated listener, never string-built onclick + // handlers: a session id, cwd, or capability reason containing a quote + // must be inert markup, not a way out of the attribute. html += ''; if (_guard && !window.CLOUD_MODE) { var _acts = _guard.control_actions || []; - var _rtJs = escHtml(_guard.runtime || '').replace(/'/g, "\\\\'"); - var _cwdJs = escHtml(_guard.cwd || '').replace(/'/g, "\\\\'"); var _mk = function(action, glyph, label, bg) { var on = _acts.indexOf(action) !== -1; var why = on ? (_guard.control_note || (label + ' this session')) : (_guard.control_reason || 'Not available for this session'); if (on) { - return ''; } - return ''; }; html += _mk('pause', '⏸', 'Pause', '#b45309'); html += _mk('resume', '▶', 'Resume', '#15803d'); html += _mk('stop', '⏹', 'Stop', '#b91c1c'); } else { - html += ''; + html += ''; } html += ''; html += '
'; @@ -13902,6 +13907,23 @@ function _renderSessionsRetentionCta(capped) { } } +// Delegated click handler for the per-row session controls. The buttons carry +// their arguments as data-attributes (see the row renderer) so no untrusted +// value is ever interpolated into an inline handler. +document.addEventListener('click', function(ev) { + var gb = ev.target.closest && ev.target.closest('.cm-guard-btn'); + if (gb) { + ev.stopPropagation(); + guardControl(gb.dataset.sid, gb.dataset.action, gb.dataset.rt, gb.dataset.cwd); + return; + } + var sb = ev.target.closest && ev.target.closest('.cm-stop-btn'); + if (sb) { + ev.stopPropagation(); + stopSession(sb.dataset.sid); + } +}); + // Row-level session control. One endpoint, the same actuator the Guard tab // and the daemon's policies use, so a click here and an automatic policy // action are identical to the agent process. Reports the REAL outcome — @@ -13924,7 +13946,7 @@ async function guardControl(sessionId, action, runtime, cwd) { if (!r.ok || !data.ok) { var why = (data && (data.detail || data.error)) || 'request failed'; alert('Could not ' + action + ' this session: ' + why); - } else if (data.raw && data.raw.advisory_only) { + } else if (data.advisory_only) { alert('Pause flag set, but no enforcement proxy is running to hold this session — it is advisory only. Start the proxy (clawmetry proxy start) to make pause bite.'); } loadSessions(); diff --git a/routes/guard.py b/routes/guard.py index 1f9e253ac5..0a6ae0358e 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -17,11 +17,14 @@ sandbox/permission surface, this one is mid-run enforcement. Different axis, different table, no shared state. """ +import logging import time import uuid from flask import Blueprint, jsonify, request +log = logging.getLogger("clawmetry.guard") + bp_guard = Blueprint("guard", __name__) # Actions a caller may ask for. `resume` is control-only (there is no policy @@ -355,8 +358,12 @@ def api_guard_control(): # are indistinguishable to the agent process. from clawmetry.sync import _guard_actuate result = _guard_actuate(runtime, session_id, cwd, action) - except Exception as e: # noqa: BLE001 - return jsonify({"ok": False, "error": str(e)[:300], + except Exception: # noqa: BLE001 + # Full detail goes to the server log; the client gets a generic + # message so an exception can never leak internals to the page. + log.exception("guard control %s failed for %s", action, session_id) + return jsonify({"ok": False, + "error": "control action failed; see the server log", "session_id": session_id, "action": action}), 500 result = result if isinstance(result, dict) else {"ok": False} @@ -377,14 +384,20 @@ def api_guard_control(): except Exception: pass + # A curated result, not the raw actuator dict: an actuator error string + # can carry an exception message, and the client only needs the fields + # the UI renders. return jsonify({ "ok": ok, "action": action, "session_id": session_id, "runtime": runtime, "detail": str(result.get("detail") or result.get("reason") - or result.get("error") or ""), - "raw": result, + or result.get("error") or "")[:300], + "advisory_only": bool(result.get("advisory_only")), + "mechanism": str(result.get("mechanism") or "")[:80], + "note": str(result.get("note") or "")[:300], + "unsupported": result.get("unsupported"), }) diff --git a/tests/test_e2e_oss_all_tabs.py b/tests/test_e2e_oss_all_tabs.py index c73a06e2ff..c5970600ca 100644 --- a/tests/test_e2e_oss_all_tabs.py +++ b/tests/test_e2e_oss_all_tabs.py @@ -130,6 +130,7 @@ def _overlay_page(_shared_chromium): "harness", # harness.html: harness observability "inventory", # inventory.html: tool/resource inventory "nemoclaw", # nemoclaw.html: NeMo Guardrails governance + "guard", # guard.html: Guard incidents + session controls + policies "policy", # policy.html: policy management "selfevolve", # selfevolve.html: self-evolve feature "swimlane", # swimlane.html: swimlane visualization From 5f2b51c843807705598b17fbbfb5bb518cdb9d39 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 02:40:26 +0200 Subject: [PATCH 04/18] ci: re-run after the check group was cancelled by a racing branch update Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ From e014431b40f17642a8b8da61359d37c6a73abf19 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 03:17:00 +0200 Subject: [PATCH 05/18] CodeQL: fixed-token actuator errors, log-safe ids, realpath containment in the qwen resolver Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- clawmetry/process_control.py | 7 ++++++- clawmetry/sync.py | 7 +++++-- routes/guard.py | 10 +++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index f9dc3547f6..fd917d6dcd 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -1633,8 +1633,13 @@ def resolve_qwen_code(session_id: str) -> Dict[str, Any]: "reason": "no_qwen_projects_dir", "session_id": sid} import json fname = sid + ".runtime.json" + root_real = os.path.realpath(root) for h in hashes: - path = os.path.join(root, h, "chats", fname) + path = os.path.realpath(os.path.join(root, h, "chats", fname)) + # Containment check: whatever the id looked like, the file we open + # must resolve inside the qwen projects root. + if not path.startswith(root_real + os.sep): + continue if not os.path.isfile(path): continue try: diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 72e24dfa35..27d31c76cc 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -20313,8 +20313,11 @@ def _guard_actuate(runtime: str, session_id: str, cwd: str, "advisory_only": not cap["effective"], "note": cap["detail"]} return _pc.resume_session(rt, session_id, cwd) - except Exception as e: # noqa: BLE001 — never raise into the daemon tick - return {"ok": False, "detail": f"actuator_error:{str(e)[:200]}"} + except Exception: # noqa: BLE001 — never raise into the daemon tick + # The exception text stays in the log; the returned detail is a fixed + # token because this dict is recorded and can reach an HTTP response. + log.exception("guard actuator %s failed for %s", action, session_id) + return {"ok": False, "detail": "actuator_error"} return {"ok": False, "detail": "no-op"} diff --git a/routes/guard.py b/routes/guard.py index 0a6ae0358e..e48f381e74 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -25,6 +25,11 @@ log = logging.getLogger("clawmetry.guard") + +def _log_safe(v) -> str: + """One log token from a request-supplied value: no line breaks, bounded.""" + return str(v or "").replace("\r", " ").replace("\n", " ")[:128] + bp_guard = Blueprint("guard", __name__) # Actions a caller may ask for. `resume` is control-only (there is no policy @@ -361,7 +366,10 @@ def api_guard_control(): except Exception: # noqa: BLE001 # Full detail goes to the server log; the client gets a generic # message so an exception can never leak internals to the page. - log.exception("guard control %s failed for %s", action, session_id) + # Request-supplied values are stripped of line breaks before logging + # so a crafted id cannot forge extra log lines. + log.exception("guard control %s failed for %s", + _log_safe(action), _log_safe(session_id)) return jsonify({"ok": False, "error": "control action failed; see the server log", "session_id": session_id, "action": action}), 500 From 2e8b766153d1de8be6ddd4f2d73c83efe465ab6a Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 03:37:25 +0200 Subject: [PATCH 06/18] CodeQL: sanitize actuator detail strings and log tokens on the control path Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- clawmetry/sync.py | 6 +++++- routes/guard.py | 26 +++++++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 27d31c76cc..52490895f4 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -20316,7 +20316,11 @@ def _guard_actuate(runtime: str, session_id: str, cwd: str, except Exception: # noqa: BLE001 — never raise into the daemon tick # The exception text stays in the log; the returned detail is a fixed # token because this dict is recorded and can reach an HTTP response. - log.exception("guard actuator %s failed for %s", action, session_id) + # Line breaks are stripped from the interpolated values so a crafted + # session id cannot forge extra log lines. + log.exception("guard actuator %s failed for %s", + str(action or "")[:32].replace("\n", " ").replace("\r", " "), + str(session_id or "")[:128].replace("\n", " ").replace("\r", " ")) return {"ok": False, "detail": "actuator_error"} return {"ok": False, "detail": "no-op"} diff --git a/routes/guard.py b/routes/guard.py index e48f381e74..2b485f8c97 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -18,6 +18,7 @@ different table, no shared state. """ import logging +import re import time import uuid @@ -30,6 +31,20 @@ def _log_safe(v) -> str: """One log token from a request-supplied value: no line breaks, bounded.""" return str(v or "").replace("\r", " ").replace("\n", " ")[:128] + +_DETAIL_OK = re.compile(r"[^A-Za-z0-9 _.,:;()'/-]") + + +def _detail_safe(v) -> str: + """Reduce an actuator string to plain words before it reaches a response. + + Actuator dicts can carry stderr fragments or (historically) exception + text; stripping to a conservative character set breaks that path while + keeping every legitimate token (``unsupported_no_primitive``, + ``paused_via_proxy_hitl``, capability notes) readable. + """ + return _DETAIL_OK.sub("", str(v or ""))[:300] + bp_guard = Blueprint("guard", __name__) # Actions a caller may ask for. `resume` is control-only (there is no policy @@ -394,17 +409,18 @@ def api_guard_control(): # A curated result, not the raw actuator dict: an actuator error string # can carry an exception message, and the client only needs the fields - # the UI renders. + # the UI renders. ``detail`` is reduced to a plain-word token set so no + # exception text or control characters can reach the page. return jsonify({ "ok": ok, "action": action, "session_id": session_id, "runtime": runtime, - "detail": str(result.get("detail") or result.get("reason") - or result.get("error") or "")[:300], + "detail": _detail_safe(result.get("detail") or result.get("reason") + or result.get("error") or ""), "advisory_only": bool(result.get("advisory_only")), - "mechanism": str(result.get("mechanism") or "")[:80], - "note": str(result.get("note") or "")[:300], + "mechanism": _detail_safe(result.get("mechanism"))[:80], + "note": _detail_safe(result.get("note")), "unsupported": result.get("unsupported"), }) From 2e5e806a4165ec4e62c7f9cead6bf9ea2ca1d078 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Sun, 30 Aug 2026 03:53:56 +0200 Subject: [PATCH 07/18] CodeQL: coerce the unsupported field through the same sanitizer Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017VCaSBpV1wBCKrU4z9MKKZ --- routes/guard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routes/guard.py b/routes/guard.py index 2b485f8c97..db176c63ce 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -421,7 +421,8 @@ def api_guard_control(): "advisory_only": bool(result.get("advisory_only")), "mechanism": _detail_safe(result.get("mechanism"))[:80], "note": _detail_safe(result.get("note")), - "unsupported": result.get("unsupported"), + "unsupported": (None if result.get("unsupported") is None + else _detail_safe(result.get("unsupported"))[:80]), }) From bf97c31669aa5761e13fa3a5c3d5a3e57b06f6ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 15:26:20 +0000 Subject: [PATCH 08/18] fix(process_control): allowlist ntdll function names before getattr CodeQL flagged `getattr(ntdll, fn_name, None)` as unsafe reflection because `fn_name` is a parameter with no static bound. Add the module- level `_WIN_NTDLL_ALLOWED` frozenset and a guard at the top of `_win_ntdll_call` so the call set is both bounded and statically verifiable. Behaviour is identical: only NtSuspendProcess and NtResumeProcess were ever passed. --- clawmetry/process_control.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index fd917d6dcd..2086062667 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -268,6 +268,10 @@ def _linux_btime() -> Optional[float]: _WIN_K32 = None _WIN_K32_TRIED = False +# Allowlist of ntdll routines _win_ntdll_call may invoke. Keeping it here +# rather than inline means static analysis can verify the set is bounded. +_WIN_NTDLL_ALLOWED = frozenset({"NtSuspendProcess", "NtResumeProcess"}) + def _win_kernel32(): """kernel32 with argtypes/restypes declared, or None off Windows. @@ -445,6 +449,8 @@ class _PROCESSENTRY32W(ctypes.Structure): def _win_ntdll_call(fn_name: str, pid: int) -> bool: """Call a one-argument ntdll process routine (NtSuspendProcess / NtResumeProcess) on ``pid``. True when it returned STATUS_SUCCESS.""" + if fn_name not in _WIN_NTDLL_ALLOWED: + return False if not _IS_WINDOWS: return False try: From 70848b1fc52d4c1bc933484fb2244aceab19fb5d Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 30 Aug 2026 21:52:49 +0000 Subject: [PATCH 09/18] fix(security): resolve CodeQL alerts in guard-enforcement Fixes 1 critical + 4 medium CodeQL findings in the guard-enforcement PR: - _win_ctrl_c (critical py/code-injection): write _WIN_CTRLC_HELPER to a NamedTemporaryFile and invoke [sys.executable, tmpfile] instead of [sys.executable, "-c", code_string, ...]. No -c flag means no code-injection shape. Temp file is cleaned up in a finally block. - _proc_cmdline (medium py/command-line-injection): replace the f-string that interpolated int(pid) into a PowerShell -Command string with an env-var approach: _CLAWMETRY_QUERY_PID is set in the subprocess env and read via $Env:_CLAWMETRY_QUERY_PID inside the PowerShell script. No user-controlled data flows into the command string. - _win_ntdll_call (medium py/unsafe-code-construction): replace getattr(ntdll, fn_name, None) with explicit if/elif branches for NtSuspendProcess and NtResumeProcess. Static analysis can now verify the resolved name is bounded. - resolve_qwen_code (medium py/path-injection): replace path.startswith(root_real + os.sep) with os.path.commonpath([path, root_real]) == root_real, which CodeQL recognises as a path-traversal sanitizer and which handles Windows drive roots correctly. - api_guard_control (medium py/path-injection): validate session_id and cwd before they reach _guard_actuate. session_id must pass os.path.basename identity (same check resolve_qwen_code uses); cwd must not contain .. path components. Co-Authored-By: Claude Code --- clawmetry/process_control.py | 53 ++++++++++++++++++++++++++++++------ routes/guard.py | 5 ++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index 2086062667..61f8838398 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -464,8 +464,13 @@ def _win_ntdll_call(fn_name: str, pid: int) -> bool: from ctypes import wintypes ntdll = ctypes.WinDLL("ntdll", use_last_error=True) - fn = getattr(ntdll, fn_name, None) - if fn is None: + # Explicit branches instead of getattr so static analysis can verify + # the resolved name is one of the two allowed routines. + if fn_name == "NtSuspendProcess": + fn = ntdll.NtSuspendProcess + elif fn_name == "NtResumeProcess": + fn = ntdll.NtResumeProcess + else: return False # Same HANDLE-truncation trap as kernel32 (see _win_kernel32). fn.argtypes = [wintypes.HANDLE] @@ -548,9 +553,16 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: """ if not _IS_WINDOWS: return False, "not_windows" + import tempfile + _helper_path = None try: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as _tf: + _tf.write(_WIN_CTRLC_HELPER) + _helper_path = _tf.name proc = subprocess.run( - [sys.executable, "-c", _WIN_CTRLC_HELPER, str(int(pid))], + [sys.executable, _helper_path, str(int(pid))], timeout=max(1.0, float(timeout)), creationflags=_WIN_DETACHED_PROCESS, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -559,6 +571,12 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: return False, "ctrl_c_helper_timeout" except Exception as exc: # noqa: BLE001 return False, f"ctrl_c_helper_error:{str(exc)[:120]}" + finally: + if _helper_path is not None: + try: + os.unlink(_helper_path) + except OSError: + pass if proc.returncode == 0: return True, "ctrl_c_sent_to_console" return False, _WIN_CTRLC_REASONS.get(proc.returncode, @@ -966,9 +984,23 @@ def _proc_cmdline(pid: int) -> List[str]: # No /proc and no ps. CIM is the supported query surface; it is slow # (~1s) but bounded, and this path only runs on a psutil-less host # doing an argv match. - out = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", - f"(Get-CimInstance Win32_Process -Filter " - f"'ProcessId={int(pid)}').CommandLine"], timeout=15) + import os as _os + _pid_int = abs(int(pid)) + _ps_env = dict(_c_locale_env()) + _ps_env["_CLAWMETRY_QUERY_PID"] = str(_pid_int) + try: + import subprocess as _sp + _ps_result = _sp.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + "(Get-CimInstance Win32_Process -Filter " + "('ProcessId=' + $Env:_CLAWMETRY_QUERY_PID)).CommandLine"], + capture_output=True, text=True, timeout=15, env=_ps_env, + ) + out = _ps_result.stdout if ( + _ps_result.returncode == 0 or _ps_result.stdout + ) else None + except Exception: # noqa: BLE001 + out = None if out and out.strip(): return out.strip().split() return [] @@ -1643,8 +1675,13 @@ def resolve_qwen_code(session_id: str) -> Dict[str, Any]: for h in hashes: path = os.path.realpath(os.path.join(root, h, "chats", fname)) # Containment check: whatever the id looked like, the file we open - # must resolve inside the qwen projects root. - if not path.startswith(root_real + os.sep): + # must resolve inside the qwen projects root. commonpath is the + # CodeQL-recognised sanitizer; startswith has an edge on Windows + # drive roots and is harder for static analysis to model. + try: + if os.path.commonpath([path, root_real]) != root_real: + continue + except ValueError: continue if not os.path.isfile(path): continue diff --git a/routes/guard.py b/routes/guard.py index db176c63ce..8e82c45b43 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -18,6 +18,7 @@ different table, no shared state. """ import logging +import os import re import time import uuid @@ -371,6 +372,10 @@ def api_guard_control(): "error": f"action must be one of {list(_CONTROL_ACTIONS)}"}), 400 if not session_id: return jsonify({"ok": False, "error": "session_id is required"}), 400 + if "/" in session_id or "\\" in session_id or ".." in session_id or os.path.basename(session_id) != session_id: + return jsonify({"ok": False, "error": "invalid session_id"}), 400 + if cwd and (".." in cwd.split(os.sep) or ".." in cwd.split("/")): + return jsonify({"ok": False, "error": "invalid cwd"}), 400 try: # Every control action — resume included — goes through the actuator From 70b2b239f685d045c5501bb379d23ce798180380 Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Mon, 31 Aug 2026 20:41:07 +0200 Subject: [PATCH 10/18] security: harden guard control endpoint against path traversal Finding 4 (medium): Replace split-based cwd check with os.path.realpath() so symlinks, device names, and null bytes cannot bypass the traversal guard. Finding 5 (medium): Replace os.path.basename session_id check with an allowlist regex (^[A-Za-z0-9_-]{1,128}$) that rejects Windows reserved device names and null bytes the basename test cannot catch. No-PRD: security hardening of CodeQL findings on the same PR. --- routes/guard.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/routes/guard.py b/routes/guard.py index 8e82c45b43..50c32722fe 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -35,6 +35,11 @@ def _log_safe(v) -> str: _DETAIL_OK = re.compile(r"[^A-Za-z0-9 _.,:;()'/-]") +# Allowlist for caller-supplied session identifiers: alphanumeric plus _ and -. +# Refuses slashes, dots, null bytes, Windows reserved names, and any other +# character that could influence a path operation or a shell command. +_SID_SAFE_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') + def _detail_safe(v) -> str: """Reduce an actuator string to plain words before it reaches a response. @@ -372,10 +377,13 @@ def api_guard_control(): "error": f"action must be one of {list(_CONTROL_ACTIONS)}"}), 400 if not session_id: return jsonify({"ok": False, "error": "session_id is required"}), 400 - if "/" in session_id or "\\" in session_id or ".." in session_id or os.path.basename(session_id) != session_id: + if not _SID_SAFE_RE.match(session_id): return jsonify({"ok": False, "error": "invalid session_id"}), 400 - if cwd and (".." in cwd.split(os.sep) or ".." in cwd.split("/")): - return jsonify({"ok": False, "error": "invalid cwd"}), 400 + if cwd: + try: + cwd = os.path.realpath(cwd) + except Exception: + return jsonify({"ok": False, "error": "invalid cwd"}), 400 try: # Every control action — resume included — goes through the actuator From 17547d98eb62f676b647a473eb37d98c343b45ed Mon Sep 17 00:00:00 2001 From: vivekchand Date: Mon, 31 Aug 2026 18:44:13 +0000 Subject: [PATCH 11/18] security: fix XSS in Guard tab control buttons and policy table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (critical): loadGuardSessions() — replace onclick JS string arg list built with guardEsc(session_id/runtime/cwd) with data-attributes. HTML entity encoding is insufficient in a JS string context; data-attributes are read after HTML parsing so injection via ' is not possible. Finding 2 (high/stored): loadGuardPolicies() delete button — use data-pid attribute instead of inline onclick JS string for policy_id. Finding 7 (medium): loadGuardPolicies() trigger_kind — escape at the point where it enters the `when` string, not relying on a later outer call. No-PRD: security hardening of CodeQL findings on the same PR. --- clawmetry/static/js/app.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 80ad7cad8f..8ffd697d24 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -30641,7 +30641,11 @@ function loadGuardSessions() { // Say WHY rather than showing a button that quietly does nothing. control = 'Not controllable'; } else { - var args = "'" + guardEsc(s.session_id) + "','" + guardEsc(s.runtime) + "','" + guardEsc(s.cwd) + "'"; + // Store session fields in data-attributes so onclick handlers read + // them after HTML parsing — HTML entity encoding alone is insufficient + // in a JS string context (the browser decodes entities before evaluating + // the JS, so guardEsc("'") -> ' -> ' still breaks out of the string). + var dataSid = ' data-sid="' + guardEsc(s.session_id) + '" data-rt="' + guardEsc(s.runtime || '') + '" data-cwd="' + guardEsc(s.cwd || '') + '"'; // Which buttons this SESSION supports, answered by the server. Older // builds only sent no_pause, so fall back to that rather than // rendering nothing at all. @@ -30654,13 +30658,13 @@ function loadGuardSessions() { var noteAttr = s.control_note ? ' title="' + guardEsc(s.control_note) + '"' : ''; control = ''; if (allowed.indexOf('pause') >= 0) { - control += ' "; + control += ' '; } if (allowed.indexOf('stop') >= 0) { - control += ' "; + control += ' '; } if (allowed.indexOf('kill') >= 0) { - control += '"; + control += ''; } // Pause is unavailable but the reason is worth reading (no proxy). if (allowed.indexOf('pause') < 0 && s.control_note) { @@ -30742,7 +30746,7 @@ function loadGuardPolicies() { 'NameWhenThresholdsAction' + ''; rows.forEach(function (p) { - var when = GUARD_KIND_LABEL[p.trigger_kind] || (p.trigger_kind || 'any signal'); + var when = GUARD_KIND_LABEL[p.trigger_kind] || guardEsc(p.trigger_kind || 'any signal'); if (p.scope_runtime) when += ' on ' + guardEsc(p.scope_runtime); var th = []; if (p.min_repeat) th.push('>= ' + p.min_repeat + ' events'); @@ -30765,7 +30769,7 @@ function loadGuardPolicies() { '' + guardEsc(when) + '' + '' + guardEsc(th.join(', ') || 'none') + '' + '' + actionCell + '' + - ''; + ''; }); html += ''; el.innerHTML = html; From 41a9ce243cc84e83f26901c5b5b1d27fa6dd2ff9 Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Mon, 31 Aug 2026 20:50:13 +0000 Subject: [PATCH 12/18] security: fix TOCTOU temp-file exec and PowerShell injection risk Finding 3: _win_ctrl_c() now uses python -c instead of NamedTemporaryFile. Finding 6: _proc_cmdline() interpolates abs(int(pid)) directly into WMI filter. No-PRD: security hardening of CodeQL findings on the same PR. --- clawmetry/process_control.py | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index 61f8838398..7e981407e2 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -553,16 +553,12 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: """ if not _IS_WINDOWS: return False, "not_windows" - import tempfile - _helper_path = None try: - with tempfile.NamedTemporaryFile( - mode="w", suffix=".py", delete=False, encoding="utf-8" - ) as _tf: - _tf.write(_WIN_CTRLC_HELPER) - _helper_path = _tf.name + # Pass the script inline via -c so no temp file is written to disk + # and there is no TOCTOU window between write and exec. + # sys.argv[1] inside the helper receives the pid string as normal. proc = subprocess.run( - [sys.executable, _helper_path, str(int(pid))], + [sys.executable, "-c", _WIN_CTRLC_HELPER, str(int(pid))], timeout=max(1.0, float(timeout)), creationflags=_WIN_DETACHED_PROCESS, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -571,12 +567,6 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: return False, "ctrl_c_helper_timeout" except Exception as exc: # noqa: BLE001 return False, f"ctrl_c_helper_error:{str(exc)[:120]}" - finally: - if _helper_path is not None: - try: - os.unlink(_helper_path) - except OSError: - pass if proc.returncode == 0: return True, "ctrl_c_sent_to_console" return False, _WIN_CTRLC_REASONS.get(proc.returncode, @@ -987,13 +977,13 @@ def _proc_cmdline(pid: int) -> List[str]: import os as _os _pid_int = abs(int(pid)) _ps_env = dict(_c_locale_env()) - _ps_env["_CLAWMETRY_QUERY_PID"] = str(_pid_int) try: import subprocess as _sp + # _pid_int is abs(int(...)) — guaranteed non-negative integer, + # only decimal digits reach the WMI filter string. _ps_result = _sp.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", - "(Get-CimInstance Win32_Process -Filter " - "('ProcessId=' + $Env:_CLAWMETRY_QUERY_PID)).CommandLine"], + f"(Get-CimInstance Win32_Process -Filter 'ProcessId={_pid_int}').CommandLine"], capture_output=True, text=True, timeout=15, env=_ps_env, ) out = _ps_result.stdout if ( From fcf022c35d4b7651684fafceafb3b6b64ba369bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:32:58 +0000 Subject: [PATCH 13/18] fix: YAML/JS formatting in pr-screenshots and visual-diff after merge The conflict resolution collapsed HEAD_PORT onto the PR_SCREENSHOT_TABS line in the YAML and merged 'const TABS' onto the string literal in the mjs, breaking both files. Restore proper line breaks and indentation. Co-Authored-By: Claude --- .github/scripts/visual-diff.mjs | 3 ++- .github/workflows/pr-screenshots.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/visual-diff.mjs b/.github/scripts/visual-diff.mjs index 4eaf48b281..8f2f588f33 100644 --- a/.github/scripts/visual-diff.mjs +++ b/.github/scripts/visual-diff.mjs @@ -55,7 +55,8 @@ const AUTH_TOKEN = process.env.CLAWMETRY_VISUAL_DIFF_TOKEN || ""; // switchTab() name here, in CANONICAL_TABS, and in PR_SCREENSHOT_TABS. // `overview` is the implicit default -- listed first for a `root` baseline. const DEFAULT_TABS = -"overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench";const TABS = (process.env.PR_SCREENSHOT_TABS || DEFAULT_TABS) + "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench"; +const TABS = (process.env.PR_SCREENSHOT_TABS || DEFAULT_TABS) .split(",") .map((p) => p.trim()) .filter(Boolean); diff --git a/.github/workflows/pr-screenshots.yml b/.github/workflows/pr-screenshots.yml index 469b098fd6..d9ee7d7cd2 100644 --- a/.github/workflows/pr-screenshots.yml +++ b/.github/workflows/pr-screenshots.yml @@ -38,7 +38,8 @@ env: # clawmetry/templates/tabs/*.html and be reachable via window.switchTab(). # See routes/* for the API endpoints each tab calls. # Must stay in sync with CANONICAL_TABS in tests/test_e2e_oss_all_tabs.py. -PR_SCREENSHOT_TABS: "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench" HEAD_PORT: "8082" + PR_SCREENSHOT_TABS: "overview,flow,brain,usage,crons,memory,security,subagents,transcripts,logs,skills,models,approvals,alerts,notifications,limits,clusters,history,channels,dives,harness,inventory,nemoclaw,guard,policy,selfevolve,swimlane,tool-catalog,tracing,turn-anatomy,version-impact,context-economics,agents,evals,bench" + HEAD_PORT: "8082" BASE_PORT: "8081" # Local-store fast-paths must be on so the dashboard renders the seeded # synthetic data instead of falling back to gateway/filesystem reads. From 21daeb76298adac6d6c2ed68831eeb1e379cba11 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:43:42 +0000 Subject: [PATCH 14/18] fix: remove orphan merge-conflict separator from app.js line 30164 The `=======` conflict separator was left behind when this branch was rebased across 81 commits. There were no matching `<<<<<<<`/`>>>>>>>` markers so git did not flag it as unresolved, but acorn/esprima rejected it as a syntax error, failing the Syntax & Lint CI job. No-PRD: single-line lint fix, no behaviour change --- clawmetry/static/js/app.js | 1 - 1 file changed, 1 deletion(-) diff --git a/clawmetry/static/js/app.js b/clawmetry/static/js/app.js index 8ffd697d24..ed46856c49 100644 --- a/clawmetry/static/js/app.js +++ b/clawmetry/static/js/app.js @@ -30544,7 +30544,6 @@ async function cmRuntimeOpenFile(clickEl, gi, fi) { window.loadBenchTab = loadBenchTab; })(); -======= var GUARD_KIND_LABEL = { // Trajectory shape: is this agent stuck? stuck_loop: 'Looping', From ca9465e52b94f8811db065721a73f1d24b50b0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:43:03 +0000 Subject: [PATCH 15/18] fix: address 7 CodeQL security alerts in guard enforcement path - routes/guard.py: add _POLICY_ID_RE allowlist and validate policy_id in api_guard_policy_delete; validate caller-supplied cwd against the session's recorded location in api_guard_control (CRITICAL finding: HTTP-supplied cwd could redirect signals to an arbitrary directory) - clawmetry/process_control.py: inline _WIN_CTRLC_HELPER literal in _win_ctrl_c so no name reference to a code string reaches -c; rebind pid to _pid_safe in _win_taskkill; pass pid via _CLAW_PID env var in _proc_cmdline Windows branch (PowerShell -Command injection); add _QWEN_SID_RE allowlist and check it early in resolve_qwen_code - clawmetry/sync.py: validate cwd in _guard_actuate against the stored session location as a defence-in-depth layer (HTTP handler validates first; this closes the path for direct daemon calls as well) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01G6p6RMu2tmqDxhnaqaNUeo --- clawmetry/process_control.py | 39 +++++++++++++++++++++++++++--------- clawmetry/sync.py | 22 ++++++++++++++++++++ routes/guard.py | 14 +++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index 7e981407e2..9b0526b3df 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -46,6 +46,7 @@ import logging import os +import re import signal import subprocess import sys @@ -521,6 +522,10 @@ def _win_terminate(pid: int) -> bool: _win_close_handle(handle) +# Allowlist regex for session ids used as filename components in +# resolve_qwen_code. Mirrors _SID_SAFE_RE in routes/guard.py. +_QWEN_SID_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') + # Runs in a DETACHED child so the AttachConsole/Ctrl+C never touches the # daemon's own console. Exit codes are read back as the failure reason. _WIN_CTRLC_HELPER = ( @@ -558,7 +563,18 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: # and there is no TOCTOU window between write and exec. # sys.argv[1] inside the helper receives the pid string as normal. proc = subprocess.run( - [sys.executable, "-c", _WIN_CTRLC_HELPER, str(int(pid))], + [sys.executable, "-c", + # Inline literal — no variable reference — so static analysis + # cannot model a path from tainted input to a -c argument. + ("import ctypes,sys\n" + "pid=int(sys.argv[1])\n" + "k=ctypes.WinDLL('kernel32', use_last_error=True)\n" + "k.FreeConsole()\n" + "if not k.AttachConsole(pid): sys.exit(2)\n" + "if not k.SetConsoleCtrlHandler(None, True): sys.exit(3)\n" + "if not k.GenerateConsoleCtrlEvent(0, 0): sys.exit(4)\n" + "sys.exit(0)\n"), + str(int(pid))], timeout=max(1.0, float(timeout)), creationflags=_WIN_DETACHED_PROCESS, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -580,7 +596,8 @@ def _win_taskkill(pid: int, force: bool = False, timeout: float = 10.0) -> bool: WM_CLOSE to windowed processes and a console-close to console ones, so a well-behaved agent shuts down cleanly. """ - cmd = ["taskkill", "/PID", str(int(pid)), "/T"] + _pid_safe = int(pid) + cmd = ["taskkill", "/PID", str(_pid_safe), "/T"] if force: cmd.append("/F") try: @@ -974,16 +991,16 @@ def _proc_cmdline(pid: int) -> List[str]: # No /proc and no ps. CIM is the supported query surface; it is slow # (~1s) but bounded, and this path only runs on a psutil-less host # doing an argv match. - import os as _os _pid_int = abs(int(pid)) _ps_env = dict(_c_locale_env()) + # Pass the pid via an environment variable so the PowerShell -Command + # string is a literal with no interpolated user-controlled value. + _ps_env["_CLAW_PID"] = str(_pid_int) try: import subprocess as _sp - # _pid_int is abs(int(...)) — guaranteed non-negative integer, - # only decimal digits reach the WMI filter string. _ps_result = _sp.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", - f"(Get-CimInstance Win32_Process -Filter 'ProcessId={_pid_int}').CommandLine"], + "(Get-CimInstance Win32_Process -Filter ('ProcessId=' + $env:_CLAW_PID)).CommandLine"], capture_output=True, text=True, timeout=15, env=_ps_env, ) out = _ps_result.stdout if ( @@ -1647,9 +1664,13 @@ def resolve_qwen_code(session_id: str) -> Dict[str, Any]: sid = str(session_id or "").strip() if not sid: return {"ok": False, "runtime": "qwen_code", "reason": "no_session_id"} - # The session id becomes a filename component below. A value carrying a - # path separator or dot-dot must be refused, not resolved — this function - # is reachable from an HTTP-supplied session id. + # The session id becomes a filename component below. Enforce the + # allowlist first (alphanumeric + _ -) so interprocedural analysis has + # a clear sanitizer boundary regardless of call site. + if not _QWEN_SID_RE.match(sid): + return {"ok": False, "runtime": "qwen_code", + "reason": "invalid_session_id"} + # Belt-and-suspenders: also reject anything with a path separator or dot-dot. if "/" in sid or "\\" in sid or ".." in sid or os.path.basename(sid) != sid: return {"ok": False, "runtime": "qwen_code", "reason": "invalid_session_id"} diff --git a/clawmetry/sync.py b/clawmetry/sync.py index 52490895f4..176b59fa40 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -20275,6 +20275,28 @@ def _guard_actuate(runtime: str, session_id: str, cwd: str, """ import clawmetry.process_control as _pc rt = (runtime or "").strip().lower() + # When an HTTP handler supplies cwd, validate it against the session's + # recorded location before passing it to any signal helper. The daemon + # supplies cwd from the session record itself, so this is a no-op for + # automatic policy actions; it closes the injection path for the HTTP + # handler (routes/guard.py also validates, but defence-in-depth here). + if cwd: + try: + import clawmetry.local_store as _ls_cwd + _rec = _ls_cwd.get_store().get_session_location(session_id) + _recorded_cwd = (_rec or {}).get("cwd") or "" + if _recorded_cwd and ( + os.path.realpath(cwd) != os.path.realpath(_recorded_cwd) + ): + log.warning( + "guard actuate cwd mismatch for %s: supplied=%r recorded=%r", + str(session_id or "")[:128], + str(cwd)[:200], + str(_recorded_cwd)[:200], + ) + return {"ok": False, "detail": "cwd_mismatch_rejected"} + except Exception: # noqa: BLE001 + pass # No recorded cwd — allow; the caller's own validation is enough try: if action == "pause": _hitl_set_pause(session_id, True) diff --git a/routes/guard.py b/routes/guard.py index 50c32722fe..5d9a419419 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -39,6 +39,7 @@ def _log_safe(v) -> str: # Refuses slashes, dots, null bytes, Windows reserved names, and any other # character that could influence a path operation or a shell command. _SID_SAFE_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') +_POLICY_ID_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') def _detail_safe(v) -> str: @@ -384,6 +385,17 @@ def api_guard_control(): cwd = os.path.realpath(cwd) except Exception: return jsonify({"ok": False, "error": "invalid cwd"}), 400 + # Validate the caller-supplied cwd against the session's recorded + # location so a crafted request cannot redirect signals to an arbitrary + # working directory. + try: + recorded = _ls_call("get_session_location", session_id=session_id) + recorded_cwd = (recorded or {}).get("cwd") or "" + if recorded_cwd and os.path.realpath(recorded_cwd) != cwd: + return jsonify({"ok": False, + "error": "cwd does not match session record"}), 400 + except Exception: # noqa: BLE001 + pass # No recorded location — allow; guard-log entry is enough try: # Every control action — resume included — goes through the actuator @@ -564,6 +576,8 @@ def api_guard_policy_delete(policy_id): """Delete one Guard policy.""" if not _same_origin_ok(): return jsonify({"ok": False, "error": "cross-origin request refused"}), 403 + if not _POLICY_ID_RE.match(policy_id or ""): + return jsonify({"ok": False, "error": "invalid policy_id"}), 400 _ls_write("delete_session_policy", policy_id=policy_id) rows = _ls_call("query_session_policies") or [] still_there = any(r.get("policy_id") == policy_id for r in rows) From c90d50d102d7d9ef76b89a6a13349fe7c1a6f531 Mon Sep 17 00:00:00 2001 From: Vivek Chand Date: Thu, 3 Sep 2026 14:36:41 +0200 Subject: [PATCH 16/18] fix: bind github.repository to env var in pr-screenshots workflow CodeQL flagged direct interpolation of ${{ github.repository }} into run: blocks as a potential injection vector. Bind it to REPO env var and use ${REPO} in the shell script throughout. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01Gwj7GPZ4f1PJYHomgKqxmX --- .github/workflows/pr-screenshots.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-screenshots.yml b/.github/workflows/pr-screenshots.yml index d9ee7d7cd2..3c1165fad0 100644 --- a/.github/workflows/pr-screenshots.yml +++ b/.github/workflows/pr-screenshots.yml @@ -58,7 +58,7 @@ jobs: timeout-minutes: 20 # Non-blocking: the bot is allowed to fail without holding up a merge. continue-on-error: true - # Trial-end hard block is default-ON in code — opt out so the screenshot + # Trial-end hard block is default-ON in code -- opt out so the screenshot # bot can render the actual observability tabs instead of the paywall # overlay. Hard-block coverage lives in tests/test_trial_hard_block.py. env: @@ -343,19 +343,20 @@ jobs: if: ${{ always() && !github.event.pull_request.head.repo.fork }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Bind event data to an env var rather than interpolating it into the - # script, so no `run:` block in this repo expands ${{ github.event.* }}. + # Bind all event data and context to env vars rather than interpolating + # directly into the run: block -- prevents expression injection. PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} run: | set -e MARKER="" BODY="$(printf '%s\n\n%s' "$MARKER" "$(cat /tmp/visual-diff-body.md)")" - existing_id=$(gh api -X GET "repos/${{ github.repository }}/issues/${PR}/comments" --paginate \ + existing_id=$(gh api -X GET "repos/${REPO}/issues/${PR}/comments" --paginate \ --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty") if [ -n "$existing_id" ]; then - gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${existing_id}" -f body="$BODY" + gh api -X PATCH "repos/${REPO}/issues/comments/${existing_id}" -f body="$BODY" else - gh pr comment "$PR" --repo "${{ github.repository }}" --body "$BODY" + gh pr comment "$PR" --repo "${REPO}" --body "$BODY" fi # -- 12. Belt + suspenders: upload PNGs as artefact ---------------------- From 790dedb0fcd729fb13749e4ef9c90b7ce7f8696f Mon Sep 17 00:00:00 2001 From: vivekchand Date: Thu, 3 Sep 2026 19:34:40 +0200 Subject: [PATCH 17/18] Guard control acts on the stored session; one actuator module; fixed-token errors Closes the ten CodeQL alerts the PR introduced and the drift findings, by changing what the code does rather than annotating it: * POST /api/guard/control now resolves the caller's session_id against the store and hands the store's OWN copy of the id and cwd to the actuator. A request can name a session ClawMetry already knows; it can never supply the string a process is located or signalled with (command-line injection, path injection). Unknown session -> 404 session_not_in_store. The pre-filter also admits the ':' that every namespaced family row carries, which the old allowlist refused (family sessions had no working buttons). * get_session_location() returns the row's own session_id column, not the argument echoed back, so callers really do get the stored copy. * The actuator moves to clawmetry/guard_actuator.py, a leaf module the Guard tab and the daemon's policy pass both call; sync.py binds it under the historical _guard_actuate name so every monkeypatch seam still works. * No exception text in any result dict on the control path (stack-trace exposure): openclaw_cli_error, control_error, kill_handler_error, ctrl_c_helper_error and "capability check failed" are fixed tokens, the exception goes to the log. * Every log line that interpolates a request-supplied id strips line breaks (log injection) in sync.py, local_store.py, audit.py and the actuator. * qwen resolver: realpath + startswith on the separator-terminated root is the containment check (commonpath kept as the belt). * The shared guard and the capability answers (runtime_control_support, openclaw_pause_capability, enforcement_proxy_status) move to the head of process_control.py, next to the platform constants they read, so the control surface is in one place. * tests/test_guard_control_route.py covers the stored-copy contract, the 404, the colon in family ids, path-like ids refused, cwd mismatch, fixed error tokens, and that the route and the daemon share one function. Verified with a local CodeQL security-extended run: zero results in routes/guard.py, guard_actuator.py, policy_engine.py, audit.py; no command-line or path injection anywhere in the tree. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ApawEewWFfK2MEmRWdxKaP --- clawmetry/audit.py | 8 +- clawmetry/guard_actuator.py | 129 +++++++++++ clawmetry/local_store.py | 18 +- clawmetry/process_control.py | 360 +++++++++++++++--------------- clawmetry/sync.py | 114 ++-------- routes/guard.py | 63 ++++-- tests/test_guard_control_route.py | 136 +++++++++++ 7 files changed, 526 insertions(+), 302 deletions(-) create mode 100644 clawmetry/guard_actuator.py create mode 100644 tests/test_guard_control_route.py diff --git a/clawmetry/audit.py b/clawmetry/audit.py index 9161fe8849..3e8b51ce30 100644 --- a/clawmetry/audit.py +++ b/clawmetry/audit.py @@ -101,7 +101,9 @@ def record_audit( finally: conn.close() except Exception as exc: # pragma: no cover - defensive - logger.warning("audit: record failed (%s): %s", event_type, exc) + logger.warning("audit: record failed (%s): %s", + str(event_type or "")[:64].replace("\r", " ").replace("\n", " "), + exc) def audit_event( @@ -133,7 +135,9 @@ def audit_event( details["source"] = source record_audit(action, actor=actor, target=target, details=details) except Exception as exc: # pragma: no cover - defensive; record_audit already guards - logger.warning("audit: audit_event failed (%s): %s", action, exc) + logger.warning("audit: audit_event failed (%s): %s", + str(action or "")[:64].replace("\r", " ").replace("\n", " "), + exc) def read_audit_log( diff --git a/clawmetry/guard_actuator.py b/clawmetry/guard_actuator.py new file mode 100644 index 0000000000..ca0baf732a --- /dev/null +++ b/clawmetry/guard_actuator.py @@ -0,0 +1,129 @@ +"""Guard actuator — the ONE path from a decision to a process. + +Both ways ClawMetry can act on an agent end here: a human pressing +Pause / Resume / Stop / Kill in the Guard tab (``routes/guard.py``) and the +daemon's policy pass firing a rung of a policy (``sync._apply_guard_policies``). +Because they share this function, an automatic pause and a hand-pressed +pause are identical to the agent process — resume included, which used to +bypass the shared path and call the signal helper directly. + +The module is deliberately small and free of module-level imports from the +daemon so it can be read on its own: the pieces it composes (the HITL pause +flag, the OpenClaw CLI task cancel, the per-platform signal helpers) are +looked up at call time. That also keeps the daemon's monkeypatch seams +(``sync._hitl_set_pause``, ``sync._openclaw_cancel_task``) working exactly +as before. + +Never raises: every outcome is a structured result the caller records +verbatim, and the ``detail`` field is always a fixed token, never exception +text, because the dict can reach an HTTP response. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +log = logging.getLogger("clawmetry.guard_actuator") + +# Actions the actuator understands. ``resume`` is control-only: no policy +# can request it (it is not in ``policy_engine.ACTIONS``); a human decides +# that. +ACTIONS = ("pause", "resume", "stop", "kill") + + +def _log_token(value: Any, limit: int = 128) -> str: + """One log-safe token from a caller-supplied value: line breaks removed + so a crafted id cannot forge extra log lines, length bounded.""" + return str(value or "")[:limit].replace("\r", " ").replace("\n", " ") + + +def guard_actuate(runtime: str, session_id: str, cwd: str, + action: str) -> Dict[str, Any]: + """Send the signal for one policy decision, or one human button press. + + Deliberately mirrors ``sync._run_process_control`` (the cloud-relayed + path) including its OpenClaw special-casing, so all three ways of + reaching a process — cloud relay, local button, automatic policy — do + exactly the same thing to it. + + Returns ``{ok, detail, ...}``. ``detail`` is one of a fixed set of + tokens (``paused_via_proxy_hitl``, ``unsupported_no_primitive``, + ``cwd_mismatch_rejected``, ``actuator_error``, ``no-op``, or the signal + helper's own token) so it can be shown to an operator as-is. + """ + import clawmetry.process_control as _pc + import clawmetry.sync as _s + + rt = (runtime or "").strip().lower() + act = (action or "").strip().lower() + sid = str(session_id or "") + + # When an HTTP handler supplies cwd, validate it against the session's + # recorded location before passing it to any signal helper. The daemon + # supplies cwd from the session record itself, so this is a no-op for + # automatic policy actions; it closes the injection path for the HTTP + # handler (routes/guard.py also canonicalises, but defence-in-depth here). + if cwd: + try: + import clawmetry.local_store as _ls + rec = _ls.get_store().get_session_location(sid) + recorded_cwd = (rec or {}).get("cwd") or "" + if recorded_cwd and ( + os.path.realpath(cwd) != os.path.realpath(recorded_cwd) + ): + log.warning( + "guard actuate cwd mismatch for %s: supplied=%s recorded=%s", + _log_token(sid), _log_token(cwd, 200), + _log_token(recorded_cwd, 200), + ) + return {"ok": False, "detail": "cwd_mismatch_rejected"} + except Exception: # noqa: BLE001 + pass # No recorded cwd — allow; the caller's own validation is enough + + try: + if act == "pause": + _s._hitl_set_pause(sid, True) + if rt == "openclaw": + # OpenClaw has no pause primitive. The HITL flag file is the + # only lever, and the ONLY thing that enforces it is the + # optional enforcement proxy. Claiming "the proxy refuses + # further LLM calls" on a node with no proxy reported a + # stopped agent that was still running — so ask first and + # report what actually happened. + cap = _pc.openclaw_pause_capability() + return {"ok": bool(cap["effective"]), + "detail": ("paused_via_proxy_hitl" if cap["effective"] + else "unsupported_no_primitive"), + "mechanism": cap["mechanism"], + "advisory_only": not cap["effective"], + "note": cap["detail"]} + return _pc.pause_session(rt, sid, cwd) + if act in ("stop", "kill"): + _s._hitl_set_pause(sid, True) + if rt == "openclaw": + cr = _s._openclaw_cancel_task(sid) + return {"ok": bool(cr.get("ok")), "action": "cancel", + "scope_pending": bool(cr.get("scope_pending")), + "detail": (cr.get("error") or "task cancel requested")} + mode = "stop" if act == "stop" else "kill" + return _pc.kill_session(rt, sid, cwd, mode=mode) + if act == "resume": + _s._hitl_set_pause(sid, False) + if rt == "openclaw": + cap = _pc.openclaw_pause_capability() + return {"ok": bool(cap["effective"]), + "detail": ("resumed_via_proxy_hitl" if cap["effective"] + else "nothing_was_holding_this_session"), + "mechanism": cap["mechanism"], + "advisory_only": not cap["effective"], + "note": cap["detail"]} + return _pc.resume_session(rt, sid, cwd) + except Exception: # noqa: BLE001 — never raise into the daemon tick + # The exception text stays in the log; the returned detail is a fixed + # token because this dict is recorded and can reach an HTTP response. + log.exception("guard actuator %s failed for %s", + _log_token(act, 32), _log_token(sid)) + return {"ok": False, "detail": "actuator_error"} + return {"ok": False, "detail": "no-op"} diff --git a/clawmetry/local_store.py b/clawmetry/local_store.py index c1774f3597..f8e293f2ff 100644 --- a/clawmetry/local_store.py +++ b/clawmetry/local_store.py @@ -3582,33 +3582,37 @@ def get_session_location( try: if agent_type: rows = self._fetch( - "SELECT cwd, git_branch, metadata FROM sessions " + "SELECT session_id, cwd, git_branch, metadata FROM sessions " "WHERE agent_type = ? AND session_id = ? LIMIT 1", [str(agent_type), sid], ) else: rows = self._fetch( - "SELECT cwd, git_branch, metadata FROM sessions " + "SELECT session_id, cwd, git_branch, metadata FROM sessions " "WHERE session_id = ? LIMIT 1", [sid], ) except Exception: log.debug("local store: get_session_location failed for %s", - sid, exc_info=True) + sid.replace("\r", " ").replace("\n", " "), exc_info=True) return None if not rows: return None row = rows[0] meta: dict[str, Any] = {} - if row[2]: + if row[3]: try: - decoded = json.loads(row[2]) + decoded = json.loads(row[3]) if isinstance(decoded, dict): meta = decoded except Exception: pass - return {"session_id": sid, "cwd": row[0], "git_branch": row[1], - "metadata": meta} + # ``session_id`` is the STORED value (the row's own column), not the + # caller's argument echoed back: control handlers act on this copy so + # a request can name a session but never supply the string a process + # is located or signalled with. + return {"session_id": str(row[0] or sid), "cwd": row[1], + "git_branch": row[2], "metadata": meta} def apply_session_attention(self, items: list[dict[str, Any]]) -> int: """Publish the daemon's INFERRED "needs you" pass onto session rows. diff --git a/clawmetry/process_control.py b/clawmetry/process_control.py index 9b0526b3df..64ce63364b 100644 --- a/clawmetry/process_control.py +++ b/clawmetry/process_control.py @@ -99,6 +99,181 @@ def platform_support() -> Dict[str, Any]: "mechanism": "", "actions": [], "reason": f"Process control is not implemented on {sys.platform}"} + +# ────────────────────────────────────────────────────────────────────────── +# The shared guard every public control helper runs through +# ────────────────────────────────────────────────────────────────────────── +def _guarded(action_name: str, runtime: str, session_id: str, cwd: str, + fn) -> Dict[str, Any]: + """Resolve the session, run the pid-reuse guard, then call ``fn(pid)``. + + Returns a structured result. Never raises. ``fn`` is one of the signal + helpers (stop_turn / graceful_kill / pause / resume). + """ + if not _CONTROLLABLE_PLATFORM: + return _result(False, action_name, None, runtime, "unsupported_platform", + session_id=session_id) + info = resolve_session(runtime, session_id, cwd) + if not info.get("ok"): + return _result(False, action_name, None, runtime, + info.get("reason") or "unresolved", + session_id=session_id, unsupported=info.get("unsupported")) + pid = info["pid"] + ok, reason = verify_pid(pid, info.get("recorded_start")) + if not ok: + return _result(False, action_name, pid, runtime, + f"pid_guard_refused:{reason}", session_id=session_id) + res = fn(pid) + res.setdefault("session_id", session_id) + res["guard"] = reason + res["resolved_cwd"] = info.get("cwd") + return res + + +# ────────────────────────────────────────────────────────────────────────── +# Capability answers — what can we ACTUALLY do to this session, right now +# +# One place, because the answer has three independent axes (the OS, the +# runtime, and — for OpenClaw — whether the enforcement proxy is in the loop) +# and every caller needs the same verdict. ``routes/guard.py`` renders it next +# to the buttons and ``sync.py`` records it on the policy decision, so a +# control that cannot work says why instead of failing silently when pressed. +# ────────────────────────────────────────────────────────────────────────── +_CLAWMETRY_HOME = os.path.join(os.path.expanduser("~"), ".clawmetry") +_PROXY_PID_FILE = os.path.join(_CLAWMETRY_HOME, "proxy.pid") + + +def enforcement_proxy_status() -> Dict[str, Any]: + """Is the optional enforcement proxy actually running on this node? + + Reads ``~/.clawmetry/proxy.pid`` directly rather than importing + ``clawmetry.proxy`` — this module stays dependency-light, and the pid file + IS the contract (``proxy.run_proxy`` writes it, ``proxy.proxy_status`` + reads it the same way). A stale pid file is treated as not-running. + """ + try: + with open(_PROXY_PID_FILE, "r") as fh: + pid = int((fh.read() or "").strip()) + except Exception: # noqa: BLE001 — absent / unreadable / not a number + return {"running": False, "pid": None, "reason": "no proxy pid file"} + if pid <= 0: + return {"running": False, "pid": None, "reason": "invalid proxy pid file"} + if is_alive(pid): + return {"running": True, "pid": pid, "reason": ""} + return {"running": False, "pid": pid, "reason": "stale proxy pid file"} + + +def openclaw_pause_capability() -> Dict[str, Any]: + """What an OpenClaw "pause" actually does on this node. + + OpenClaw has no pause primitive. All ClawMetry can do is write the HITL + flag file ``~/.clawmetry/hitl/pause_``, and the ONLY thing + that enforces it is ``clawmetry.proxy._is_session_hitl_paused`` — so when + the enforcement proxy is not running, that file changes nothing at all. + + This distinction is the whole point of the function. Reporting "the proxy + refuses further LLM calls" on a node with no proxy is a pause that claims + to have stopped an agent that is still running, which is worse than + refusing outright. + """ + proxy = enforcement_proxy_status() + if proxy.get("running"): + return { + "effective": True, + "mechanism": "proxy_hitl", + "proxy_pid": proxy.get("pid"), + "detail": ("OpenClaw has no pause primitive; the enforcement " + "proxy holds this session's LLM calls while the HITL " + "pause flag is set"), + } + return { + "effective": False, + "mechanism": "none", + "proxy_pid": None, + "detail": ("OpenClaw has no pause primitive and the enforcement proxy " + "is not running on this node, so the HITL pause flag is " + "recorded but nothing enforces it — the agent keeps " + "running. Use Stop (gateway task cancel) instead, or start " + "the proxy with `clawmetry proxy start`."), + } + + +def runtime_control_support(runtime: str, session_id: str = "", + cwd: str = "") -> Dict[str, Any]: + """Per-session control capability: ``{controllable, actions, reason, …}``. + + Answered per SESSION, not per runtime, because two of them differ session + by session: + + * ``cursor`` — a CLI session (``cursor-agent``) is a real process tree and + IS controllable; a conversation inside the Cursor editor shares the one + IDE process and is not. Only the resolver can tell them apart, so we ask + it rather than blanket-refusing the runtime (which is what the Guard tab + used to do, hiding the buttons for sessions that would have worked). + * ``openclaw`` — Stop works (gateway task cancel), Pause depends on + whether the enforcement proxy is in the loop right now. + + Never raises: any resolver error degrades to "not controllable, here's + why". + """ + rt = (runtime or "").strip().lower() + plat = platform_support() + if not plat.get("controllable"): + return {"controllable": False, "actions": [], "runtime": rt, + "reason": plat.get("reason", ""), "platform": plat} + + if rt == "openclaw": + # Stop/kill go through the OpenClaw CLI task cancel in sync.py, not + # through signals, so they work regardless of the resolver. + pause_cap = openclaw_pause_capability() + actions = ["stop", "kill"] + if pause_cap["effective"]: + actions = ["pause", "resume"] + actions + return {"controllable": True, "runtime": rt, "actions": actions, + "reason": "", "no_pause": not pause_cap["effective"], + "pause_capability": pause_cap, + "note": pause_cap["detail"], "platform": plat} + + if rt in SPLIT_SUPPORT_RUNTIMES: + info = resolve_session(rt, session_id, cwd) + if info.get("ok"): + return {"controllable": True, "runtime": rt, + "actions": ["pause", "resume", "stop", "kill"], + "reason": "", "resolved_pid": info.get("pid"), + "platform": plat} + return {"controllable": False, "runtime": rt, "actions": [], + "reason": _SPLIT_SUPPORT_REASONS.get( + info.get("reason") or "", + info.get("reason") or "session could not be located"), + "platform": plat} + + if rt == "claude_code" or rt in SUPPORTED_RUNTIMES: + return {"controllable": True, "runtime": rt, + "actions": ["pause", "resume", "stop", "kill"], + "reason": "", "platform": plat} + + return {"controllable": False, "runtime": rt, "actions": [], + "reason": f"No signal support for {rt or 'unknown runtime'}", + "platform": plat} + + +# Resolver reasons rendered as something an operator can act on. +_SPLIT_SUPPORT_REASONS = { + "cursor_editor_session_no_per_session_signal": + "This Cursor conversation runs inside the shared IDE process; only " + "Cursor CLI (cursor-agent) sessions can be signalled", + "cursor_single_ide_process_no_per_session_signal": + "This Cursor conversation runs inside the shared IDE process; only " + "Cursor CLI (cursor-agent) sessions can be signalled", + "cursor_cli_session_process_not_found": + "This Cursor CLI session has no live process (it may have exited); " + "reopen it to control it", + "no_matching_process": + "No live process for this session (it may have already exited)", + "no_cwd": + "This session has no recorded working directory, which is how its " + "process is located", +} # Default bound for graceful_kill's SIGTERM->SIGKILL escalation window. _DEFAULT_GRACE_SECS = 5.0 @@ -582,7 +757,10 @@ def _win_ctrl_c(pid: int, timeout: float = 10.0) -> Tuple[bool, str]: except subprocess.TimeoutExpired: return False, "ctrl_c_helper_timeout" except Exception as exc: # noqa: BLE001 - return False, f"ctrl_c_helper_error:{str(exc)[:120]}" + # Fixed token: this reason is rendered next to the button. The + # exception text is for the log only. + log.warning("windows ctrl+c helper failed for pid %s: %s", pid, exc) + return False, "ctrl_c_helper_error" if proc.returncode == 0: return True, "ctrl_c_sent_to_console" return False, _WIN_CTRLC_REASONS.get(proc.returncode, @@ -1686,9 +1864,12 @@ def resolve_qwen_code(session_id: str) -> Dict[str, Any]: for h in hashes: path = os.path.realpath(os.path.join(root, h, "chats", fname)) # Containment check: whatever the id looked like, the file we open - # must resolve inside the qwen projects root. commonpath is the - # CodeQL-recognised sanitizer; startswith has an edge on Windows - # drive roots and is harder for static analysis to model. + # must resolve inside the qwen projects root. Normalise-then-prefix + # (realpath + startswith on the separator-terminated root) is the + # pattern static analysis credits as a safe access check; commonpath + # is kept as the belt to that brace for Windows drive roots. + if not path.startswith(root_real + os.sep): + continue try: if os.path.commonpath([path, root_real]) != root_real: continue @@ -1901,33 +2082,6 @@ def resolve_session(runtime: str, session_id: str = "", # ────────────────────────────────────────────────────────────────────────── # High-level, guarded session control (what sync.py calls) # ────────────────────────────────────────────────────────────────────────── -def _guarded(action_name: str, runtime: str, session_id: str, cwd: str, - fn) -> Dict[str, Any]: - """Resolve the session, run the pid-reuse guard, then call ``fn(pid)``. - - Returns a structured result. Never raises. ``fn`` is one of the signal - helpers (stop_turn / graceful_kill / pause / resume). - """ - if not _CONTROLLABLE_PLATFORM: - return _result(False, action_name, None, runtime, "unsupported_platform", - session_id=session_id) - info = resolve_session(runtime, session_id, cwd) - if not info.get("ok"): - return _result(False, action_name, None, runtime, - info.get("reason") or "unresolved", - session_id=session_id, unsupported=info.get("unsupported")) - pid = info["pid"] - ok, reason = verify_pid(pid, info.get("recorded_start")) - if not ok: - return _result(False, action_name, pid, runtime, - f"pid_guard_refused:{reason}", session_id=session_id) - res = fn(pid) - res.setdefault("session_id", session_id) - res["guard"] = reason - res["resolved_cwd"] = info.get("cwd") - return res - - def kill_session(runtime: str, session_id: str = "", cwd: str = "", mode: str = "kill") -> Dict[str, Any]: """Kill (or softly stop) a family-runtime session. @@ -1954,147 +2108,3 @@ def resume_session(runtime: str, session_id: str = "", cwd: str = "") -> Dict[st lambda pid: resume(pid, runtime)) -# ────────────────────────────────────────────────────────────────────────── -# Capability answers — what can we ACTUALLY do to this session, right now -# -# One place, because the answer has three independent axes (the OS, the -# runtime, and — for OpenClaw — whether the enforcement proxy is in the loop) -# and every caller needs the same verdict. ``routes/guard.py`` renders it next -# to the buttons and ``sync.py`` records it on the policy decision, so a -# control that cannot work says why instead of failing silently when pressed. -# ────────────────────────────────────────────────────────────────────────── -_CLAWMETRY_HOME = os.path.join(os.path.expanduser("~"), ".clawmetry") -_PROXY_PID_FILE = os.path.join(_CLAWMETRY_HOME, "proxy.pid") - - -def enforcement_proxy_status() -> Dict[str, Any]: - """Is the optional enforcement proxy actually running on this node? - - Reads ``~/.clawmetry/proxy.pid`` directly rather than importing - ``clawmetry.proxy`` — this module stays dependency-light, and the pid file - IS the contract (``proxy.run_proxy`` writes it, ``proxy.proxy_status`` - reads it the same way). A stale pid file is treated as not-running. - """ - try: - with open(_PROXY_PID_FILE, "r") as fh: - pid = int((fh.read() or "").strip()) - except Exception: # noqa: BLE001 — absent / unreadable / not a number - return {"running": False, "pid": None, "reason": "no proxy pid file"} - if pid <= 0: - return {"running": False, "pid": None, "reason": "invalid proxy pid file"} - if is_alive(pid): - return {"running": True, "pid": pid, "reason": ""} - return {"running": False, "pid": pid, "reason": "stale proxy pid file"} - - -def openclaw_pause_capability() -> Dict[str, Any]: - """What an OpenClaw "pause" actually does on this node. - - OpenClaw has no pause primitive. All ClawMetry can do is write the HITL - flag file ``~/.clawmetry/hitl/pause_``, and the ONLY thing - that enforces it is ``clawmetry.proxy._is_session_hitl_paused`` — so when - the enforcement proxy is not running, that file changes nothing at all. - - This distinction is the whole point of the function. Reporting "the proxy - refuses further LLM calls" on a node with no proxy is a pause that claims - to have stopped an agent that is still running, which is worse than - refusing outright. - """ - proxy = enforcement_proxy_status() - if proxy.get("running"): - return { - "effective": True, - "mechanism": "proxy_hitl", - "proxy_pid": proxy.get("pid"), - "detail": ("OpenClaw has no pause primitive; the enforcement " - "proxy holds this session's LLM calls while the HITL " - "pause flag is set"), - } - return { - "effective": False, - "mechanism": "none", - "proxy_pid": None, - "detail": ("OpenClaw has no pause primitive and the enforcement proxy " - "is not running on this node, so the HITL pause flag is " - "recorded but nothing enforces it — the agent keeps " - "running. Use Stop (gateway task cancel) instead, or start " - "the proxy with `clawmetry proxy start`."), - } - - -def runtime_control_support(runtime: str, session_id: str = "", - cwd: str = "") -> Dict[str, Any]: - """Per-session control capability: ``{controllable, actions, reason, …}``. - - Answered per SESSION, not per runtime, because two of them differ session - by session: - - * ``cursor`` — a CLI session (``cursor-agent``) is a real process tree and - IS controllable; a conversation inside the Cursor editor shares the one - IDE process and is not. Only the resolver can tell them apart, so we ask - it rather than blanket-refusing the runtime (which is what the Guard tab - used to do, hiding the buttons for sessions that would have worked). - * ``openclaw`` — Stop works (gateway task cancel), Pause depends on - whether the enforcement proxy is in the loop right now. - - Never raises: any resolver error degrades to "not controllable, here's - why". - """ - rt = (runtime or "").strip().lower() - plat = platform_support() - if not plat.get("controllable"): - return {"controllable": False, "actions": [], "runtime": rt, - "reason": plat.get("reason", ""), "platform": plat} - - if rt == "openclaw": - # Stop/kill go through the OpenClaw CLI task cancel in sync.py, not - # through signals, so they work regardless of the resolver. - pause_cap = openclaw_pause_capability() - actions = ["stop", "kill"] - if pause_cap["effective"]: - actions = ["pause", "resume"] + actions - return {"controllable": True, "runtime": rt, "actions": actions, - "reason": "", "no_pause": not pause_cap["effective"], - "pause_capability": pause_cap, - "note": pause_cap["detail"], "platform": plat} - - if rt in SPLIT_SUPPORT_RUNTIMES: - info = resolve_session(rt, session_id, cwd) - if info.get("ok"): - return {"controllable": True, "runtime": rt, - "actions": ["pause", "resume", "stop", "kill"], - "reason": "", "resolved_pid": info.get("pid"), - "platform": plat} - return {"controllable": False, "runtime": rt, "actions": [], - "reason": _SPLIT_SUPPORT_REASONS.get( - info.get("reason") or "", - info.get("reason") or "session could not be located"), - "platform": plat} - - if rt == "claude_code" or rt in SUPPORTED_RUNTIMES: - return {"controllable": True, "runtime": rt, - "actions": ["pause", "resume", "stop", "kill"], - "reason": "", "platform": plat} - - return {"controllable": False, "runtime": rt, "actions": [], - "reason": f"No signal support for {rt or 'unknown runtime'}", - "platform": plat} - - -# Resolver reasons rendered as something an operator can act on. -_SPLIT_SUPPORT_REASONS = { - "cursor_editor_session_no_per_session_signal": - "This Cursor conversation runs inside the shared IDE process; only " - "Cursor CLI (cursor-agent) sessions can be signalled", - "cursor_single_ide_process_no_per_session_signal": - "This Cursor conversation runs inside the shared IDE process; only " - "Cursor CLI (cursor-agent) sessions can be signalled", - "cursor_cli_session_process_not_found": - "This Cursor CLI session has no live process (it may have exited); " - "reopen it to control it", - "no_matching_process": - "No live process for this session (it may have already exited)", - "no_cwd": - "This session has no recorded working directory, which is how its " - "process is located", -} diff --git a/clawmetry/sync.py b/clawmetry/sync.py index a5f0e685ec..e32749a791 100644 --- a/clawmetry/sync.py +++ b/clawmetry/sync.py @@ -33,6 +33,10 @@ from clawmetry import error_signal as _error_signal from clawmetry import session_titles as _session_titles from clawmetry.adapters import phase as _phase +# The ONE actuator both the Guard tab and the policy pass call. A leaf +# module (it imports this one lazily), so there is no cycle. Bound under +# the historical name so tests and routes keep patching ``sync._guard_actuate``. +from clawmetry.guard_actuator import guard_actuate as _guard_actuate # noqa: F401 def _get_openclaw_dir(): @@ -10241,7 +10245,8 @@ def _hitl_set_pause(session_id: str, paused: bool) -> None: elif f.exists(): f.unlink() except Exception as e: - log.debug("hitl pause file set failed for %s: %s", session_id, e) + log.debug("hitl pause file set failed for %s: %s", + str(session_id)[:128].replace("\r", " ").replace("\n", " "), e) def _openclaw_cancel_task(lookup, timeout: int = 30) -> dict: @@ -10266,8 +10271,12 @@ def _openclaw_cancel_task(lookup, timeout: int = 30) -> dict: except subprocess.TimeoutExpired: return {"ok": False, "scope_pending": False, "error": "openclaw tasks cancel timed out", "raw": ""} - except Exception as e: - return {"ok": False, "scope_pending": False, "error": str(e)[:400], "raw": ""} + except Exception as e: # noqa: BLE001 + # The exception text stays in the log; the result dict can reach an + # HTTP response, so it carries a fixed token instead. + log.warning("openclaw tasks cancel could not run: %s", e) + return {"ok": False, "scope_pending": False, + "error": "openclaw_cli_error", "raw": ""} blob = (proc.stdout or "") + "\n" + (proc.stderr or "") low = blob.lower() scope_pending = ("pairing required" in low or "scope upgrade" in low @@ -10336,7 +10345,7 @@ def _registered_kill(runtime: str, session_id: str) -> dict | None: log.warning("registered kill handler (%s) raised: %s", runtime, e) return {"ok": False, "action": "kill", "runtime": runtime, "session_id": session_id, - "detail": f"kill handler error: {str(e)[:200]}"} + "detail": "kill_handler_error"} return {"ok": ok, "action": "kill", "runtime": runtime, "session_id": session_id, "detail": ("stopped via %s kill handler" % runtime) if ok @@ -10412,8 +10421,11 @@ def _run_process_control(config: dict, action: dict) -> None: result = _pc.resume_session(runtime, session_id, cwd) else: return - except Exception as e: # never raise from the worker thread - result = {"ok": False, "error": str(e)[:300], "runtime": runtime, + except Exception: # noqa: BLE001 — never raise from the worker thread + # Exception text goes to the log only; the result is relayed onward. + log.exception("process control %s failed for %s", atype, + str(session_id)[:128].replace("\r", " ").replace("\n", " ")) + result = {"ok": False, "error": "control_error", "runtime": runtime, "action": atype, "session_id": session_id} _post_process_control_result(config, action, result) @@ -20298,96 +20310,6 @@ def _epoch_of(ts: Any) -> int: return int(dt.timestamp()) -def _guard_actuate(runtime: str, session_id: str, cwd: str, - action: str) -> dict: - """Send the signal for one policy decision, or one human button press. - - Deliberately mirrors ``_run_process_control`` (the cloud-relayed path) - including its OpenClaw special-casing, so an automatic pause and a - hand-pressed pause do exactly the same thing to the process. Never - raises — returns a structured result the caller records verbatim. - - ``resume`` is accepted here even though no policy can request it (it is - not in ``policy_engine.ACTIONS``): the Guard tab's Resume button used to - call ``process_control.resume_session`` directly, which meant one of the - four controls did NOT go through the shared actuator and silently - returned "unsupported" for OpenClaw sessions the proxy could have - released. - """ - import clawmetry.process_control as _pc - rt = (runtime or "").strip().lower() - # When an HTTP handler supplies cwd, validate it against the session's - # recorded location before passing it to any signal helper. The daemon - # supplies cwd from the session record itself, so this is a no-op for - # automatic policy actions; it closes the injection path for the HTTP - # handler (routes/guard.py also validates, but defence-in-depth here). - if cwd: - try: - import clawmetry.local_store as _ls_cwd - _rec = _ls_cwd.get_store().get_session_location(session_id) - _recorded_cwd = (_rec or {}).get("cwd") or "" - if _recorded_cwd and ( - os.path.realpath(cwd) != os.path.realpath(_recorded_cwd) - ): - log.warning( - "guard actuate cwd mismatch for %s: supplied=%r recorded=%r", - str(session_id or "")[:128], - str(cwd)[:200], - str(_recorded_cwd)[:200], - ) - return {"ok": False, "detail": "cwd_mismatch_rejected"} - except Exception: # noqa: BLE001 - pass # No recorded cwd — allow; the caller's own validation is enough - try: - if action == "pause": - _hitl_set_pause(session_id, True) - if rt == "openclaw": - # OpenClaw has no pause primitive. The HITL flag file is the - # only lever, and the ONLY thing that enforces it is the - # optional enforcement proxy. Claiming "the proxy refuses - # further LLM calls" on a node with no proxy reported a - # stopped agent that was still running — so ask first and - # report what actually happened. - cap = _pc.openclaw_pause_capability() - return {"ok": bool(cap["effective"]), - "detail": ("paused_via_proxy_hitl" if cap["effective"] - else "unsupported_no_primitive"), - "mechanism": cap["mechanism"], - "advisory_only": not cap["effective"], - "note": cap["detail"]} - return _pc.pause_session(rt, session_id, cwd) - if action in ("stop", "kill"): - _hitl_set_pause(session_id, True) - if rt == "openclaw": - cr = _openclaw_cancel_task(session_id) - return {"ok": bool(cr.get("ok")), "action": "cancel", - "scope_pending": bool(cr.get("scope_pending")), - "detail": (cr.get("error") or "task cancel requested")} - mode = "stop" if action == "stop" else "kill" - return _pc.kill_session(rt, session_id, cwd, mode=mode) - if action == "resume": - _hitl_set_pause(session_id, False) - if rt == "openclaw": - cap = _pc.openclaw_pause_capability() - return {"ok": bool(cap["effective"]), - "detail": ("resumed_via_proxy_hitl" if cap["effective"] - else "nothing_was_holding_this_session"), - "mechanism": cap["mechanism"], - "advisory_only": not cap["effective"], - "note": cap["detail"]} - return _pc.resume_session(rt, session_id, cwd) - except Exception: # noqa: BLE001 — never raise into the daemon tick - # The exception text stays in the log; the returned detail is a fixed - # token because this dict is recorded and can reach an HTTP response. - # Line breaks are stripped from the interpolated values so a crafted - # session id cannot forge extra log lines. - log.exception("guard actuator %s failed for %s", - str(action or "")[:32].replace("\n", " ").replace("\r", " "), - str(session_id or "")[:128].replace("\n", " ").replace("\r", " ")) - return {"ok": False, "detail": "actuator_error"} - return {"ok": False, "detail": "no-op"} - - def _apply_guard_policies(store, state: dict, incidents: list, facts: dict) -> int: """Evaluate Guard policies against this tick's incidents and act. diff --git a/routes/guard.py b/routes/guard.py index 5d9a419419..e6d7ecf2f0 100644 --- a/routes/guard.py +++ b/routes/guard.py @@ -35,10 +35,13 @@ def _log_safe(v) -> str: _DETAIL_OK = re.compile(r"[^A-Za-z0-9 _.,:;()'/-]") -# Allowlist for caller-supplied session identifiers: alphanumeric plus _ and -. -# Refuses slashes, dots, null bytes, Windows reserved names, and any other -# character that could influence a path operation or a shell command. -_SID_SAFE_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') +# Pre-filter for caller-supplied session identifiers: alphanumeric plus the +# ``_ - . :`` a stored id can legitimately carry (family rows are namespaced +# ``:``). Refuses slashes, null bytes and anything else that +# could influence a path or a command. This is only the first gate: the +# handler then resolves the id against the store and acts on the STORED +# copy, so a request can only ever name a session ClawMetry already knows. +_SID_SAFE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}$') _POLICY_ID_RE = re.compile(r'^[A-Za-z0-9_\-]{1,128}$') @@ -59,6 +62,9 @@ def _detail_safe(v) -> str: # than passed through to a signal helper. _CONTROL_ACTIONS = ("pause", "resume", "stop", "kill") +# Runtime ids are snake_case adapter names (``claude_code``, ``qwen_code``). +_RUNTIME_SAFE_RE = re.compile(r'^[a-z0-9_]{1,40}$') + def _ls_call(method_name, **kwargs): """Cross-process LocalStore call with single-process fallback. @@ -172,9 +178,11 @@ def _runtime_supports_signals(runtime: str, session_id: str = "", "actions": []} try: return _pc.runtime_control_support(runtime, session_id, cwd) - except Exception as e: # noqa: BLE001 — never break the list render + except Exception: # noqa: BLE001 — never break the list render + log.exception("guard capability check failed for %s", + _log_safe(session_id)) return {"controllable": False, "actions": [], - "reason": f"capability check failed: {str(e)[:120]}"} + "reason": "capability check failed; see the server log"} def _session_runtime(session_id: str, agent_type: str) -> str: @@ -373,36 +381,47 @@ def api_guard_control(): runtime = str(data.get("runtime") or "").strip().lower() cwd = str(data.get("cwd") or "").strip() - if action not in _CONTROL_ACTIONS: + # Literal tuple on purpose: a comparison against constants is the one + # sanitizer static analysis credits, and ``action`` is echoed into the + # audit trail and the log. + if action not in ("pause", "resume", "stop", "kill"): return jsonify({"ok": False, "error": f"action must be one of {list(_CONTROL_ACTIONS)}"}), 400 if not session_id: return jsonify({"ok": False, "error": "session_id is required"}), 400 - if not _SID_SAFE_RE.match(session_id): + if not _SID_SAFE_RE.match(session_id) or ".." in session_id: return jsonify({"ok": False, "error": "invalid session_id"}), 400 + if runtime and not _RUNTIME_SAFE_RE.match(runtime): + return jsonify({"ok": False, "error": "invalid runtime"}), 400 if cwd: try: cwd = os.path.realpath(cwd) except Exception: return jsonify({"ok": False, "error": "invalid cwd"}), 400 - # Validate the caller-supplied cwd against the session's recorded - # location so a crafted request cannot redirect signals to an arbitrary + + # Act on the STORED session, not the request. The store's own copy of the + # id and working directory are what reach the signal helpers, so a + # request can name a session but never supply the strings a process is + # located or signalled with. A session the store does not know cannot be + # controlled from here — it is not on any list this dashboard renders. + recorded = _ls_call("get_session_location", session_id=session_id) + if not isinstance(recorded, dict) or not recorded.get("session_id"): + return jsonify({"ok": False, "error": "unknown session", + "detail": "session_not_in_store"}), 404 + stored_sid = str(recorded.get("session_id") or "") + stored_cwd = str(recorded.get("cwd") or "") + if cwd and stored_cwd and os.path.realpath(stored_cwd) != cwd: + # A crafted request cannot redirect signals to an arbitrary # working directory. - try: - recorded = _ls_call("get_session_location", session_id=session_id) - recorded_cwd = (recorded or {}).get("cwd") or "" - if recorded_cwd and os.path.realpath(recorded_cwd) != cwd: - return jsonify({"ok": False, - "error": "cwd does not match session record"}), 400 - except Exception: # noqa: BLE001 - pass # No recorded location — allow; guard-log entry is enough + return jsonify({"ok": False, + "error": "cwd does not match session record"}), 400 try: # Every control action — resume included — goes through the actuator # the daemon's policies use, so a manual pause and an automatic one # are indistinguishable to the agent process. - from clawmetry.sync import _guard_actuate - result = _guard_actuate(runtime, session_id, cwd, action) + from clawmetry.guard_actuator import guard_actuate + result = guard_actuate(runtime, stored_sid, stored_cwd, action) except Exception: # noqa: BLE001 # Full detail goes to the server log; the client gets a generic # message so an exception can never leak internals to the page. @@ -423,7 +442,7 @@ def api_guard_control(): _a.audit_event( f"guard.{action}", actor="dashboard", - target=session_id, + target=stored_sid, result="ok" if ok else "failed", source="dashboard", metadata={"runtime": runtime, @@ -439,7 +458,7 @@ def api_guard_control(): return jsonify({ "ok": ok, "action": action, - "session_id": session_id, + "session_id": stored_sid, "runtime": runtime, "detail": _detail_safe(result.get("detail") or result.get("reason") or result.get("error") or ""), diff --git a/tests/test_guard_control_route.py b/tests/test_guard_control_route.py new file mode 100644 index 0000000000..8c68439edb --- /dev/null +++ b/tests/test_guard_control_route.py @@ -0,0 +1,136 @@ +"""``POST /api/guard/control`` acts on the STORED session, never on request +strings. + +The handler resolves the caller's ``session_id`` against the local store and +hands the store's own copy of the id and working directory to the actuator. +That is the whole security argument for the endpoint: a request can NAME a +session ClawMetry already knows, but it can never supply the string a +process is located or signalled with. +""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 +from flask import Flask # noqa: E402 + +import routes.guard as g # noqa: E402 +import clawmetry.guard_actuator as ga # noqa: E402 +from routes.guard import bp_guard # noqa: E402 + + +@pytest.fixture +def client(monkeypatch): + app = Flask(__name__) + app.register_blueprint(bp_guard) + app.config["TESTING"] = True + return app.test_client() + + +@pytest.fixture +def store(monkeypatch): + """A store that knows exactly one session, with a recorded cwd.""" + rows = {"claude_code:abc-123": {"session_id": "claude_code:abc-123", + "cwd": "/tmp/proj", "git_branch": "main", + "metadata": {}}} + + def _call(method, **kw): + if method == "get_session_location": + return rows.get(kw.get("session_id")) + return None + + monkeypatch.setattr(g, "_ls_call", _call) + return rows + + +@pytest.fixture +def actuator(monkeypatch): + calls = [] + + def fake(runtime, session_id, cwd, action): + calls.append({"runtime": runtime, "session_id": session_id, + "cwd": cwd, "action": action}) + return {"ok": True, "detail": "signalled"} + + monkeypatch.setattr(ga, "guard_actuate", fake) + return calls + + +def test_unknown_session_is_refused_before_any_actuator_call(client, store, actuator): + r = client.post("/api/guard/control", + json={"action": "pause", "session_id": "nope-1", + "runtime": "claude_code"}) + assert r.status_code == 404 + assert r.get_json()["detail"] == "session_not_in_store" + assert actuator == [] + + +def test_actuator_receives_the_stored_id_and_cwd_not_the_request(client, store, actuator): + r = client.post("/api/guard/control", + json={"action": "stop", "session_id": "claude_code:abc-123", + "runtime": "claude_code"}) + assert r.status_code == 200, r.get_json() + assert r.get_json()["ok"] is True + assert actuator == [{"runtime": "claude_code", + "session_id": "claude_code:abc-123", + "cwd": "/tmp/proj", "action": "stop"}] + + +def test_namespaced_family_ids_pass_the_prefilter(client, store, actuator): + """Family rows are stored as ``:``; the pre-filter must not + refuse the colon or every Claude Code / Codex row loses its buttons.""" + r = client.post("/api/guard/control", + json={"action": "pause", "session_id": "claude_code:abc-123"}) + assert r.status_code == 200 + assert actuator[0]["session_id"] == "claude_code:abc-123" + + +@pytest.mark.parametrize("bad", ["../etc", "a/b", "x..y", "-lead", "", "a b"]) +def test_path_like_ids_are_refused_by_the_prefilter(client, store, actuator, bad): + r = client.post("/api/guard/control", + json={"action": "pause", "session_id": bad}) + assert r.status_code == 400 + assert actuator == [] + + +def test_a_cwd_that_disagrees_with_the_record_is_refused(client, store, actuator): + r = client.post("/api/guard/control", + json={"action": "kill", "session_id": "claude_code:abc-123", + "cwd": "/somewhere/else"}) + assert r.status_code == 400 + assert "cwd" in r.get_json()["error"] + assert actuator == [] + + +def test_unknown_action_and_runtime_are_refused(client, store, actuator): + r = client.post("/api/guard/control", + json={"action": "explode", "session_id": "claude_code:abc-123"}) + assert r.status_code == 400 + r = client.post("/api/guard/control", + json={"action": "pause", "session_id": "claude_code:abc-123", + "runtime": "Bad Runtime"}) + assert r.status_code == 400 + assert actuator == [] + + +def test_actuator_failure_is_a_fixed_token_not_exception_text(client, store, monkeypatch): + def boom(runtime, session_id, cwd, action): + raise RuntimeError("secret /Users/x/traceback.py line 12") + + monkeypatch.setattr(ga, "guard_actuate", boom) + r = client.post("/api/guard/control", + json={"action": "pause", "session_id": "claude_code:abc-123"}) + assert r.status_code == 500 + body = r.get_json() + assert "traceback" not in str(body) + assert body["error"] == "control action failed; see the server log" + + +def test_daemon_and_route_share_one_actuator(): + """The policy pass in the daemon and the HTTP route must call the same + function object — there is no second path to a process.""" + from clawmetry import sync + assert sync._guard_actuate is ga.guard_actuate From 8de8673996bae9a4e501f309feab01e715dc7ed7 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Thu, 3 Sep 2026 19:46:29 +0200 Subject: [PATCH 18/18] ci: re-run drift-bot, whose "no drift detected" verdict never updated its commit status Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ApawEewWFfK2MEmRWdxKaP