Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
292b32c
fix: apply device budgets during auth recovery and revisit stale reviews
the-vibe-dev Oct 31, 2026
00933cb
fix: preserve deletion scope and sanitize recovery snapshots
the-vibe-dev Oct 31, 2026
88aaf14
fix: use masked parent step-up dialog for protected actions
the-vibe-dev Oct 31, 2026
0d56db8
fix: hide tombstoned incidents from parent read paths
the-vibe-dev Oct 31, 2026
c1441ae
test: verify SQL failure leaves evidence file intact
the-vibe-dev Oct 31, 2026
22ca5f8
fix: enforce parent processing choices at worker boundaries
the-vibe-dev Oct 31, 2026
89e8195
fix: require open child requests for parent decisions
the-vibe-dev Oct 31, 2026
047752d
fix: deliver withdrawal stop through control-only device credential
the-vibe-dev Oct 31, 2026
42c9e26
test: cover failed parent step-up in browser
the-vibe-dev Oct 31, 2026
b0cf4db
fix: require stop acknowledgment before regrant can resume device
the-vibe-dev Oct 31, 2026
ab5655f
fix: resolve capture and context choices independently
the-vibe-dev Oct 31, 2026
1dd5775
fix: recover broker credentials and bind IPC responses
the-vibe-dev Oct 31, 2026
b108372
fix: gate unqualified Windows enforcement by default
the-vibe-dev Oct 31, 2026
15903bf
fix: gate device enforcement and broker child requests
the-vibe-dev Oct 31, 2026
0cd24a2
feat: surface child requests and honest capture onboarding
the-vibe-dev Oct 31, 2026
a24d9a5
fix: verify broker pipe owner and bound response reads
the-vibe-dev Oct 31, 2026
2e6ec02
fix: reject frames from older consent generations
the-vibe-dev Oct 31, 2026
61a2187
test: reject stale durable frames after consent regrant
the-vibe-dev Oct 31, 2026
b6e6241
fix: retry broker re-pair and disclose primary-monitor coverage
the-vibe-dev Oct 31, 2026
bd3fe7c
fix: show unproven capture coverage honestly
the-vibe-dev Oct 31, 2026
300a786
fix: label parent password input for browser accessibility
the-vibe-dev Oct 31, 2026
5c511e9
test: name restart probe exception consistently
the-vibe-dev Oct 31, 2026
98185d2
docs: record independent review remediation and release gates
the-vibe-dev Oct 31, 2026
e33a7b3
fix: satisfy backend type checks for deletion and child reviews
the-vibe-dev Oct 31, 2026
d51f4f4
chore: refresh audited image TLS and dashboard dependencies
the-vibe-dev Oct 31, 2026
c7c173b
docs: record dependency audit remediation
the-vibe-dev Oct 31, 2026
fb3959f
docs: acknowledge direct certifi dependency
the-vibe-dev Oct 31, 2026
ed1c65f
docs: record green CI and remaining release gates
the-vibe-dev Oct 31, 2026
05894c6
Fix Windows broker pipe access after live install test
the-vibe-dev Oct 31, 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
5 changes: 4 additions & 1 deletion KNOWN_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ GuardianNode is alpha/developer-preview software.
scaling.
- Vision models may misclassify images or miss important context.
- Local models vary by license, hardware requirements, speed, and quality.
- Screenshot capture may include sensitive on-screen information.
- Screenshot capture may include sensitive on-screen information. The default
full-screen mode currently captures only the primary monitor. It does not
provide all-monitor coverage, and downsampling can reduce small-text OCR
legibility on high-resolution displays.
- Guardian Review is a fallible external-model second opinion, not a finding of
intent, wrongdoing, diagnosis, or legal status.
- Guardian Review redaction is deterministic defense-in-depth, not a guarantee.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,12 @@ the backend directly to the public internet. Mutual TLS is not implemented. See
## What It Monitors

GuardianNode reviews visible screen content from the configured Windows session.
Current installer defaults enable visible desktop screenshot capture so parents
should assume screenshots may contain sensitive on-screen content. Depending on
policy/settings, deployments may use full-screen captures or capture only when
configured apps are active.
Current installer defaults capture the primary display of the visible desktop,
including content outside named apps. Parents should assume those screenshots
may contain sensitive on-screen content. Secondary monitors are not captured
by this mode; an active window on a secondary monitor can therefore be missed.
Depending on policy/settings, deployments may capture the primary display or
capture only when configured apps are active.

GuardianNode can process:

Expand Down
6 changes: 4 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ GuardianNode bundles or directly depends on the following third-party
components. Each component retains its original license.

<!-- third-party-notices:acknowledged
backend: fastapi, uvicorn, pydantic, pydantic-settings, sqlalchemy, alembic, cryptography, argon2-cffi, httpx, python-multipart, pyyaml, zeroconf, qrcode, pillow, pytesseract, python-ulid, itsdangerous, jinja2
agent: httpx, pyyaml, psutil, mss, pillow, pydantic, argon2-cffi, cryptography, python-ulid, zeroconf, pywin32, pystray, opencv-python, pytesseract
backend: fastapi, uvicorn, pydantic, pydantic-settings, sqlalchemy, alembic, cryptography, argon2-cffi, httpx, python-multipart, pyyaml, zeroconf, qrcode, pillow, certifi, pytesseract, python-ulid, itsdangerous, jinja2
agent: httpx, pyyaml, psutil, mss, pillow, certifi, pydantic, argon2-cffi, cryptography, python-ulid, zeroconf, pywin32, pystray, opencv-python, pytesseract
dashboard: @fontsource/inter, @fontsource/sora, @tailwindcss/postcss, @tanstack/react-query, lucide-react, react, react-dom, react-router-dom
other: inno setup, winsw, pyinstaller, ollama
-->
Expand All @@ -28,6 +28,7 @@ other: inno setup, winsw, pyinstaller, ollama
| zeroconf | LGPL-2.1 | https://github.com/python-zeroconf/python-zeroconf |
| qrcode | BSD-3-Clause | https://github.com/lincolnloop/python-qrcode |
| Pillow | HPND | https://python-pillow.org/ |
| certifi | MPL-2.0 | https://github.com/certifi/python-certifi |
| pytesseract | Apache-2.0 | https://github.com/madmaze/pytesseract |
| python-ulid | MIT | https://github.com/mdomke/python-ulid |
| itsdangerous | BSD-3-Clause | https://github.com/pallets/itsdangerous |
Expand All @@ -46,6 +47,7 @@ deployment if their compliance requirements demand it.
| psutil | BSD-3-Clause | https://github.com/giampaolo/psutil |
| mss | MIT | https://github.com/BoboTiG/python-mss |
| Pillow | HPND | https://python-pillow.org/ |
| certifi | MPL-2.0 | https://github.com/certifi/python-certifi |
| Pydantic | MIT | https://github.com/pydantic/pydantic |
| argon2-cffi | MIT | https://github.com/hynek/argon2-cffi |
| cryptography | Apache-2.0 / BSD-3-Clause | https://github.com/pyca/cryptography |
Expand Down
3 changes: 2 additions & 1 deletion agent-windows/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ dependencies = [
"pyyaml>=6.0,<7.0",
"psutil>=5.9,<8.0",
"mss>=9.0,<11.0",
"Pillow>=12.2,<13.0",
"Pillow>=13.0.0,<14.0",
"certifi>=2026.10.18",
"pydantic>=2.5,<3.0",
"argon2-cffi>=23.1,<26.0",
"cryptography>=50.0.0,<51.0",
Expand Down
10 changes: 8 additions & 2 deletions agent-windows/src/backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def send_screenshot(
capture_scope: str = "monitored_app",
policy_id: str | None = None,
policy_version: str | None = None,
policy_generation: str | None = None,
collector_version: str | None = None,
timestamp: str | None = None,
idempotency_key: str | None = None,
Expand All @@ -77,6 +78,8 @@ async def send_screenshot(
data["policy_id"] = policy_id
if policy_version:
data["policy_version"] = policy_version
if policy_generation:
data["policy_generation"] = policy_generation
if collector_version:
data["collector_version"] = collector_version
if timestamp:
Expand All @@ -93,15 +96,18 @@ async def send_screenshot(
r.raise_for_status()
return r.json()

async def heartbeat(self, queued_frames: int = 0) -> bool:
async def heartbeat(self, queued_frames: int = 0, applied_policy_generation: str | None = None) -> bool:
"""Liveness + upload-backlog report. Falls back to the plain health
probe when talking to a backend that predates /api/devices/heartbeat."""
try:
async with httpx.AsyncClient(timeout=5.0, verify=self._verify()) as c:
body: dict[str, int | str] = {"queued_frames": queued_frames}
if applied_policy_generation:
body["applied_policy_generation"] = applied_policy_generation
r = await c.post(
f"{self.base_url}/api/devices/heartbeat",
headers=self._headers(),
json={"queued_frames": queued_frames},
json=body,
)
if r.status_code in (404, 405):
r = await c.get(f"{self.base_url}/api/health", headers=self._headers())
Expand Down
60 changes: 53 additions & 7 deletions agent-windows/src/broker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
from __future__ import annotations

import os
import time
from typing import Any

from src.broker_protocol import (
MAX_MESSAGE_BYTES,
PIPE_NAME,
PROTOCOL_VERSION,
ProtocolError,
decode_frame,
encode_frame,
Expand All @@ -29,12 +32,13 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st
import win32file # type: ignore
import win32pipe # type: ignore

frame = encode_frame(make_request(action, payload))
request = make_request(action, payload)
frame = encode_frame(request)
try:
win32pipe.WaitNamedPipe(self.pipe_name, self.timeout_ms)
handle = win32file.CreateFile(
self.pipe_name,
win32file.GENERIC_READ | win32file.GENERIC_WRITE,
0x00120003, # SYNCHRONIZE | READ_CONTROL | read/write data, not pipe-instance creation
0,
None,
win32file.OPEN_EXISTING,
Expand All @@ -44,8 +48,9 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st
except Exception as exc:
raise BrokerUnavailable(str(exc)) from exc
try:
self._verify_broker_owner(handle)
win32file.WriteFile(handle, frame)
response = self._read_response(handle)
response = self._read_response(handle, request["request_id"])
finally:
win32file.CloseHandle(handle)
if not response.get("ok"):
Expand All @@ -56,13 +61,53 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st
return payload_obj

@staticmethod
def _read_response(handle) -> dict[str, Any]: # noqa: ANN001
def _verify_broker_owner(handle) -> None: # noqa: ANN001
import win32security # type: ignore

descriptor = win32security.GetSecurityInfo(
handle,
win32security.SE_KERNEL_OBJECT,
win32security.OWNER_SECURITY_INFORMATION,
)
owner = descriptor.GetSecurityDescriptorOwner()
if win32security.ConvertSidToStringSid(owner) != "S-1-5-18":
raise BrokerUnavailable("local broker pipe is not owned by SYSTEM")

@staticmethod
def _read_exact(handle, size: int) -> bytes: # noqa: ANN001
import win32file # type: ignore
import win32pipe # type: ignore

_, header = win32file.ReadFile(handle, 4)
data = bytearray()
deadline = time.monotonic() + 5.0
while len(data) < size:
if time.monotonic() >= deadline:
raise BrokerUnavailable("broker response deadline exceeded")
_peeked, available, _left = win32pipe.PeekNamedPipe(handle, 0)
if not available:
time.sleep(0.01)
continue
_, chunk = win32file.ReadFile(handle, min(size - len(data), available))
if not chunk:
raise ProtocolError("broker disconnected during response")
data.extend(chunk)
return bytes(data)

@staticmethod
def _read_response(handle, request_id: str) -> dict[str, Any]: # noqa: ANN001
header = BrokerClient._read_exact(handle, 4)
size = int.from_bytes(header, "big")
_, body = win32file.ReadFile(handle, size)
return decode_frame(header + body)
if size > MAX_MESSAGE_BYTES:
raise ProtocolError("broker response too large")
body = BrokerClient._read_exact(handle, size)
response = decode_frame(header + body)
if response.get("version") != PROTOCOL_VERSION:
raise ProtocolError("unsupported broker protocol version")
if response.get("request_id") != request_id:
raise ProtocolError("broker response request_id mismatch")
if not isinstance(response.get("ok"), bool):
raise ProtocolError("broker returned invalid response")
return response

def health(self) -> dict[str, Any]:
return self.request("health")
Expand All @@ -84,6 +129,7 @@ def submit_screenshot(self, payload: dict[str, Any]) -> dict[str, Any]:
"capture_scope",
"policy_id",
"policy_version",
"policy_generation",
"collector_version",
"timestamp",
"idempotency_key",
Expand Down
30 changes: 29 additions & 1 deletion agent-windows/src/broker_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@

PROTOCOL_VERSION = 1
PIPE_NAME = r"\\.\pipe\GuardianNodeEndpointService"
PIPE_SECURITY_SDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
# Interactive users may exchange data, but may not create a competing pipe
# instance (FILE_CREATE_PIPE_INSTANCE is bit 0x4, included in GENERIC_WRITE).
# 0x0012019B includes READ_CONTROL and the pipe metadata rights Windows checks
# when a medium-integrity client opens a pipe. It excludes bit 0x4, so clients
# still cannot create another pipe instance.
# SYSTEM's default token owner may be Administrators. Set the pipe owner
# explicitly so interactive clients can verify they are talking to our broker.
PIPE_SECURITY_SDDL = "O:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x0012019B;;;IU)"
MAX_MESSAGE_BYTES = 2 * 1024 * 1024
MAX_SCREENSHOT_BYTES = 1024 * 1024
MAX_STRING_BYTES = 4096
MAX_REQUEST_ID_BYTES = 80

ALLOWED_ACTIONS = {
"child_request_create",
"child_request_list",
"capture_config",
"health",
"status",
Expand Down Expand Up @@ -123,6 +132,24 @@ def image_from_b64(value: Any) -> bytes:


def _validate_payload(action: str, payload: dict[str, Any]) -> None:
if action == "child_request_list":
if payload:
raise ProtocolError("child_request_list takes no payload")
return
if action == "child_request_create":
if set(payload) - {"request_type", "target", "reason"}:
raise ProtocolError("unsupported child request field")
if payload.get("request_type") not in {
"more_time", "site_exception", "app_exception", "other",
}:
raise ProtocolError("invalid child request type")
for key, limit in (("target", 1024), ("reason", 4096)):
value = payload.get(key)
if value is not None:
if not isinstance(value, str):
raise ProtocolError(f"{key} must be a string")
_bound_string(key, value, limit)
return
if action == "submit_screenshot":
image_from_b64(payload.get("image_b64"))
for key in (
Expand All @@ -131,6 +158,7 @@ def _validate_payload(action: str, payload: dict[str, Any]) -> None:
"capture_scope",
"policy_id",
"policy_version",
"policy_generation",
"collector_version",
"timestamp",
"idempotency_key",
Expand Down
Loading
Loading