Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fac0c26
Guard control plane: Windows actuators, honest OpenClaw pause, per-se…
vivekchand Aug 25, 2026
8922639
Session rows get Pause/Resume/Stop and a named rogue flag, not just b…
vivekchand Aug 29, 2026
b40eff6
CI fixes: register the guard tab, close the CodeQL findings the PR in…
vivekchand Aug 30, 2026
5f2b51c
ci: re-run after the check group was cancelled by a racing branch update
vivekchand Aug 30, 2026
e014431
CodeQL: fixed-token actuator errors, log-safe ids, realpath containme…
vivekchand Aug 30, 2026
2e8b766
CodeQL: sanitize actuator detail strings and log tokens on the contro…
vivekchand Aug 30, 2026
2e5e806
CodeQL: coerce the unsupported field through the same sanitizer
vivekchand Aug 30, 2026
bf97c31
fix(process_control): allowlist ntdll function names before getattr
claude Aug 30, 2026
70848b1
fix(security): resolve CodeQL alerts in guard-enforcement
vivekchand Aug 30, 2026
70b2b23
security: harden guard control endpoint against path traversal
vivekchand Aug 31, 2026
17547d9
security: fix XSS in Guard tab control buttons and policy table
Aug 31, 2026
41a9ce2
security: fix TOCTOU temp-file exec and PowerShell injection risk
Aug 31, 2026
fcf022c
fix: YAML/JS formatting in pr-screenshots and visual-diff after merge
claude Sep 1, 2026
21daeb7
fix: remove orphan merge-conflict separator from app.js line 30164
claude Sep 1, 2026
ca9465e
fix: address 7 CodeQL security alerts in guard enforcement path
claude Sep 2, 2026
c90d50d
fix: bind github.repository to env var in pr-screenshots workflow
vivekchand Sep 3, 2026
1e22ad3
Merge branch 'main' into feat/guard-enforcement
vivekchand Sep 3, 2026
790dedb
Guard control acts on the stored session; one actuator module; fixed-…
vivekchand Sep 3, 2026
8de8673
ci: re-run drift-bot, whose "no drift detected" verdict never updated…
vivekchand Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/visual-diff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +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";
"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())
Expand Down
15 changes: 8 additions & 7 deletions .github/workflows/pr-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +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"
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
Expand All @@ -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:
Expand Down Expand Up @@ -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="<!-- visual-diff-bot -->"
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 ----------------------
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions clawmetry/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
129 changes: 129 additions & 0 deletions clawmetry/guard_actuator.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading
Loading