statelock-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. statelock/__init__.py +4 -0
  2. statelock/__main__.py +274 -0
  3. statelock/app.py +80 -0
  4. statelock/audit/__init__.py +19 -0
  5. statelock/audit/records.py +105 -0
  6. statelock/audit/redaction.py +117 -0
  7. statelock/audit/sequencing.py +90 -0
  8. statelock/audit/sink.py +163 -0
  9. statelock/auth.py +266 -0
  10. statelock/client/__init__.py +99 -0
  11. statelock/client/connection.py +86 -0
  12. statelock/client/files.py +237 -0
  13. statelock/client/files_sync.py +213 -0
  14. statelock/client/install.py +147 -0
  15. statelock/client/install_sync.py +118 -0
  16. statelock/client/patching.py +50 -0
  17. statelock/client/playwright.py +158 -0
  18. statelock/client/requests.py +183 -0
  19. statelock/client/sessions.py +181 -0
  20. statelock/client/transfer.py +426 -0
  21. statelock/client/violations.py +158 -0
  22. statelock/core/__init__.py +23 -0
  23. statelock/core/actions.py +287 -0
  24. statelock/core/enums.py +57 -0
  25. statelock/core/jsonutil.py +31 -0
  26. statelock/core/state.py +147 -0
  27. statelock/core/urls.py +171 -0
  28. statelock/core/verdict.py +79 -0
  29. statelock/credentials.py +213 -0
  30. statelock/demo/__init__.py +48 -0
  31. statelock/demo/bank.html +27 -0
  32. statelock/demo/erp.html +44 -0
  33. statelock/demo/finance.html +74 -0
  34. statelock/dependencies.py +43 -0
  35. statelock/events.py +45 -0
  36. statelock/fileio.py +74 -0
  37. statelock/integrations/__init__.py +6 -0
  38. statelock/integrations/browser_use.py +89 -0
  39. statelock/integrations/langchain.py +144 -0
  40. statelock/plugins.py +67 -0
  41. statelock/policy/__init__.py +63 -0
  42. statelock/policy/cookies.py +58 -0
  43. statelock/policy/currency.py +132 -0
  44. statelock/policy/evaluator.py +249 -0
  45. statelock/policy/extensions.py +73 -0
  46. statelock/policy/fields.py +330 -0
  47. statelock/policy/models.py +134 -0
  48. statelock/policy/patterns.py +17 -0
  49. statelock/policy/perception.py +347 -0
  50. statelock/policy/requests.py +166 -0
  51. statelock/policy/rules.py +622 -0
  52. statelock/policy/text.py +84 -0
  53. statelock/policy/triggers.py +72 -0
  54. statelock/proxy/__init__.py +2 -0
  55. statelock/proxy/attribution.py +64 -0
  56. statelock/proxy/bridge.py +643 -0
  57. statelock/proxy/browser.py +413 -0
  58. statelock/proxy/commands.py +104 -0
  59. statelock/proxy/connection.py +401 -0
  60. statelock/proxy/cookies.py +68 -0
  61. statelock/proxy/download_policy.py +73 -0
  62. statelock/proxy/downloads.py +380 -0
  63. statelock/proxy/files.py +63 -0
  64. statelock/proxy/governor.py +502 -0
  65. statelock/proxy/guard.py +638 -0
  66. statelock/proxy/inspector.py +378 -0
  67. statelock/proxy/js/__init__.py +18 -0
  68. statelock/proxy/js/describe_element.js +46 -0
  69. statelock/proxy/js/fetch.js +42 -0
  70. statelock/proxy/js/focused_element.js +13 -0
  71. statelock/proxy/js/guard.js +200 -0
  72. statelock/proxy/js/page_state.js +142 -0
  73. statelock/proxy/js/replay_hits.js +6 -0
  74. statelock/proxy/js/replay_point.js +29 -0
  75. statelock/proxy/memory.py +64 -0
  76. statelock/proxy/network_filter.py +40 -0
  77. statelock/proxy/pages.py +86 -0
  78. statelock/proxy/replay.py +187 -0
  79. statelock/proxy/reporter.py +161 -0
  80. statelock/proxy/requests.py +258 -0
  81. statelock/proxy/scripts.py +80 -0
  82. statelock/proxy/targets.py +128 -0
  83. statelock/proxy/tasks.py +44 -0
  84. statelock/proxy/uploads.py +183 -0
  85. statelock/proxy/worlds.py +206 -0
  86. statelock/py.typed +0 -0
  87. statelock/registry.py +27 -0
  88. statelock/review/__init__.py +22 -0
  89. statelock/review/api.py +107 -0
  90. statelock/review/page.html +173 -0
  91. statelock/review/queue.py +233 -0
  92. statelock/saved_sessions.py +354 -0
  93. statelock/services.py +110 -0
  94. statelock/sessions.py +190 -0
  95. statelock/settings.py +113 -0
  96. statelock/tokens.py +85 -0
  97. statelock/wire.py +140 -0
  98. statelock_ai-0.1.0.dist-info/METADATA +745 -0
  99. statelock_ai-0.1.0.dist-info/RECORD +104 -0
  100. statelock_ai-0.1.0.dist-info/WHEEL +5 -0
  101. statelock_ai-0.1.0.dist-info/entry_points.txt +2 -0
  102. statelock_ai-0.1.0.dist-info/licenses/LICENSE +202 -0
  103. statelock_ai-0.1.0.dist-info/licenses/NOTICE +4 -0
  104. statelock_ai-0.1.0.dist-info/top_level.txt +1 -0
statelock/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Statelock: governed browser actions for AI agents."""
3
+
4
+ __version__ = "0.1.0" # the package version: pyproject.toml reads it from here
statelock/__main__.py ADDED
@@ -0,0 +1,274 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`statelock` command.
3
+
4
+ statelock [--host H] [--port P] run the proxy (default: 127.0.0.1:8010)
5
+ statelock keygen --agent-id ID [--tenant T] [--append FILE] new API key and its keys-file entry
6
+ (or --reviewer / --auditor ID)
7
+ statelock session-url [--saved-session NAME [--save]] single-use URL for a governed session
8
+ statelock saved-sessions list | delete NAME the agent's saved browser sessions
9
+ statelock hash-key < key.txt SHA-256 of a key read from stdin
10
+ statelock check-sandbox launch Chromium with the configured sandbox setting
11
+ statelock perception-check IMAGE -i TEXT [-x name=type] try the configured vision model on a screenshot
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import base64
19
+ import json
20
+ import stat
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import uvicorn
26
+ import yaml
27
+
28
+ from statelock.auth import DEFAULT_TENANT, KeysFile, generate_key, hash_key
29
+ from statelock.fileio import load_yaml, write_atomic
30
+
31
+ DEFAULT_PORT = 8010 # the port the README and the examples use
32
+
33
+
34
+ def _run(args: argparse.Namespace) -> int:
35
+ uvicorn.run("statelock.app:create_app", factory=True, host=args.host, port=args.port)
36
+ return 0
37
+
38
+
39
+ def _keygen(args: argparse.Namespace) -> int:
40
+ key = generate_key()
41
+ if args.reviewer:
42
+ section, id_field, principal = "reviewers", "reviewer_id", args.reviewer
43
+ elif args.auditor:
44
+ section, id_field, principal = "auditors", "auditor_id", args.auditor
45
+ else:
46
+ section, id_field, principal = "agents", "agent_id", args.agent_id
47
+ entry = {id_field: principal, "tenant": args.tenant, "key_sha256": hash_key(key)}
48
+ if args.append is not None:
49
+ try:
50
+ append_key_entry(Path(args.append), section, entry)
51
+ except (OSError, ValueError, yaml.YAMLError) as error:
52
+ print(f"Could not add the key to {args.append}: {error}", file=sys.stderr)
53
+ return 1
54
+ # The key goes to stderr so the keys-file entry on stdout can be redirected safely.
55
+ print(f"API key for {principal} (store it as a secret; it is not shown again):", file=sys.stderr)
56
+ print(key, file=sys.stderr)
57
+ if args.append is not None:
58
+ print(f"Added {id_field} {principal} to {section} in {args.append}", file=sys.stderr)
59
+ else:
60
+ print(yaml.safe_dump({section: [entry]}, sort_keys=False), end="")
61
+ return 0
62
+
63
+
64
+ def append_key_entry(path: Path, section: str, entry: dict[str, Any]) -> None:
65
+ """Add an entry to a section of a keys file (created when missing), keeping the rest
66
+ of the file as written, comments included. The result must be a valid keys file;
67
+ it replaces the old one atomically."""
68
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
69
+ before = load_yaml(text) or {}
70
+ KeysFile.model_validate(before)
71
+ updated = _insert_entry(text, section, yaml.safe_dump([entry], sort_keys=False))
72
+ after = load_yaml(updated) or {}
73
+ KeysFile.model_validate(after) # also refuses a key that is already listed
74
+ # Exactly one change: the entry appended to its section.
75
+ if {**after, section: None} != {**before, section: None} or after.get(section) != [
76
+ *(before.get(section) or []),
77
+ entry,
78
+ ]:
79
+ raise ValueError("the entry could not be added; add it by hand")
80
+ mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
81
+ write_atomic(path, updated.encode("utf-8"), mode=mode)
82
+
83
+
84
+ def _insert_entry(text: str, section: str, item: str) -> str:
85
+ """``text`` with the YAML list ``item`` added at the end of the top-level ``section`` list,
86
+ or a new section at the end. Plain text insertion: the caller parses the result and checks
87
+ that it holds exactly the one new entry."""
88
+ lines = text.splitlines(keepends=True)
89
+ if lines and not lines[-1].endswith("\n"):
90
+ lines[-1] += "\n"
91
+ start = next((i for i, line in enumerate(lines) if line.startswith(f"{section}:")), None)
92
+ if start is None:
93
+ return "".join(lines) + f"{section}:\n{_indent(item, ' ')}"
94
+ value = lines[start][len(section) + 1 :].strip()
95
+ if value and not value.startswith("#"):
96
+ raise ValueError(f"{section} must be a block list (- entries) to append to; add the entry by hand")
97
+ # The section runs until the next top-level key; a comment after its last entry (one that
98
+ # introduces the next section) stays after the new entry.
99
+ end = start + 1
100
+ while end < len(lines) and (not lines[end].strip() or lines[end][0] in " #-"):
101
+ end += 1
102
+ while end > start + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
103
+ end -= 1
104
+ entries = [line for line in lines[start + 1 : end] if line.lstrip().startswith("-")]
105
+ indent = entries[0][: len(entries[0]) - len(entries[0].lstrip())] if entries else " "
106
+ return "".join([*lines[:end], _indent(item, indent), *lines[end:]])
107
+
108
+
109
+ def _indent(block: str, indent: str) -> str:
110
+ return "".join(indent + line for line in block.splitlines(keepends=True))
111
+
112
+
113
+ def _session_url(args: argparse.Namespace) -> int:
114
+ from statelock.client.sessions import StatelockClientError, create_session_url # noqa: PLC0415
115
+
116
+ try:
117
+ url = create_session_url(
118
+ args.server,
119
+ agent_id=args.agent_id,
120
+ ttl_seconds=args.ttl,
121
+ saved_session=args.saved_session,
122
+ save_session=args.save,
123
+ )
124
+ except StatelockClientError as error:
125
+ print(error, file=sys.stderr)
126
+ return 1
127
+ print(url.ws_url if args.ws else url.cdp_url)
128
+ print(f"session {url.session_id} for {url.agent_id}; single use, expires {url.expires_at}", file=sys.stderr)
129
+ return 0
130
+
131
+
132
+ def _saved_sessions(args: argparse.Namespace) -> int:
133
+ from statelock.client.sessions import ( # noqa: PLC0415
134
+ StatelockClientError,
135
+ delete_saved_session,
136
+ list_saved_sessions,
137
+ )
138
+
139
+ try:
140
+ if args.action == "delete":
141
+ if not args.name:
142
+ print("delete needs a saved-session name", file=sys.stderr)
143
+ return 2
144
+ deleted = delete_saved_session(args.name, args.server, agent_id=args.agent_id)
145
+ print("deleted" if deleted else "not found", file=sys.stderr)
146
+ return 0 if deleted else 1
147
+ for name in list_saved_sessions(args.server, agent_id=args.agent_id):
148
+ print(name)
149
+ except StatelockClientError as error:
150
+ print(error, file=sys.stderr)
151
+ return 1
152
+ return 0
153
+
154
+
155
+ def _hash_key(_args: argparse.Namespace) -> int:
156
+ key = sys.stdin.read().strip()
157
+ if not key:
158
+ print("no key on stdin", file=sys.stderr)
159
+ return 1
160
+ print(hash_key(key))
161
+ return 0
162
+
163
+
164
+ def _check_sandbox(_args: argparse.Namespace) -> int:
165
+ from statelock.proxy.browser import BrowserLaunchError # noqa: PLC0415 - heavy import
166
+ from statelock.services import launcher # noqa: PLC0415
167
+ from statelock.settings import Settings # noqa: PLC0415
168
+
169
+ settings = Settings()
170
+
171
+ async def launch() -> None:
172
+ browser = await launcher(settings).launch()
173
+ await browser.close()
174
+
175
+ try:
176
+ asyncio.run(launch())
177
+ except BrowserLaunchError as error:
178
+ print(f"Chromium failed to start (sandbox={settings.chromium_sandbox}): {error}", file=sys.stderr)
179
+ return 1
180
+ print(f"Chromium started (sandbox={'on' if settings.chromium_sandbox else 'off'}).")
181
+ return 0
182
+
183
+
184
+ def _perception_check(args: argparse.Namespace) -> int:
185
+ from statelock.core.state import BrowserState # noqa: PLC0415
186
+ from statelock.policy.perception import PerceptionRequest # noqa: PLC0415
187
+ from statelock.services import perception_evaluator # noqa: PLC0415
188
+ from statelock.settings import Settings # noqa: PLC0415
189
+
190
+ extract: dict[str, str] = {}
191
+ for item in args.extract:
192
+ name, _, kind = item.partition("=")
193
+ extract[name] = kind or "string"
194
+ try:
195
+ evaluator = perception_evaluator(Settings())
196
+ except RuntimeError as error:
197
+ print(error, file=sys.stderr)
198
+ return 2
199
+ if evaluator is None:
200
+ print("Set STATELOCK_PERCEPTION_MODEL (and STATELOCK_PERCEPTION_API_BASE for a local model).", file=sys.stderr)
201
+ return 2
202
+ image = base64.b64encode(Path(args.image).read_bytes()).decode("ascii")
203
+ try:
204
+ request = PerceptionRequest(
205
+ check_name="cli", instruction=args.instruction, extract=extract, state=BrowserState(screenshot_base64=image)
206
+ )
207
+ except ValueError as error:
208
+ print(f"Bad --extract: {error}", file=sys.stderr)
209
+ return 2
210
+ verdict = asyncio.run(evaluator.evaluate(request))
211
+ print(json.dumps(verdict.model_dump(), indent=2))
212
+ return 0 if verdict.passed else 1
213
+
214
+
215
+ def main(argv: list[str] | None = None) -> int:
216
+ parser = argparse.ArgumentParser(prog="statelock", description="Statelock proxy and tools.")
217
+ parser.add_argument(
218
+ "--host", default="127.0.0.1", help="address the proxy listens on (default: 127.0.0.1, this machine only)"
219
+ )
220
+ parser.add_argument(
221
+ "--port", type=int, default=DEFAULT_PORT, help=f"port the proxy listens on (default: {DEFAULT_PORT})"
222
+ )
223
+ parser.set_defaults(handler=_run)
224
+ commands = parser.add_subparsers(title="commands")
225
+
226
+ keygen = commands.add_parser("keygen", help="create an API key and its keys-file entry")
227
+ principal = keygen.add_mutually_exclusive_group(required=True)
228
+ principal.add_argument("--agent-id")
229
+ principal.add_argument("--reviewer", metavar="REVIEWER_ID", help="create a reviewer key instead")
230
+ principal.add_argument("--auditor", metavar="AUDITOR_ID", help="create an auditor key (evidence access)")
231
+ keygen.add_argument("--tenant", default=DEFAULT_TENANT)
232
+ keygen.add_argument(
233
+ "--append", metavar="FILE", help="add the entry to this keys file (created if missing) instead of printing it"
234
+ )
235
+ keygen.set_defaults(handler=_keygen)
236
+
237
+ session = commands.add_parser(
238
+ "session-url", help="print a single-use URL that opens a governed session (key: STATELOCK_API_KEY)"
239
+ )
240
+ session.add_argument("--server", help="Statelock URL (default: STATELOCK_URL)")
241
+ session.add_argument("--agent-id", help="only when the proxy's authentication is off")
242
+ session.add_argument("--ttl", type=int, help="seconds the URL stays usable (default: the server's, 300)")
243
+ session.add_argument("--ws", action="store_true", help="print the WebSocket URL instead of the http one")
244
+ session.add_argument("--saved-session", metavar="NAME", help="restore this saved browser session")
245
+ session.add_argument("--save", action="store_true", help="save the browser state under NAME at a clean end")
246
+ session.set_defaults(handler=_session_url)
247
+
248
+ saved = commands.add_parser("saved-sessions", help="list or delete the agent's saved browser sessions")
249
+ saved.add_argument("action", choices=["list", "delete"])
250
+ saved.add_argument("name", nargs="?")
251
+ saved.add_argument("--server", help="Statelock URL (default: STATELOCK_URL)")
252
+ saved.add_argument("--agent-id", help="only when the proxy's authentication is off")
253
+ saved.set_defaults(handler=_saved_sessions)
254
+ commands.add_parser("hash-key", help="print the SHA-256 of a key read from stdin").set_defaults(handler=_hash_key)
255
+ commands.add_parser("check-sandbox", help="start Chromium with the configured sandbox").set_defaults(
256
+ handler=_check_sandbox
257
+ )
258
+
259
+ perception = commands.add_parser(
260
+ "perception-check", help="ask the configured vision model (STATELOCK_PERCEPTION_MODEL) about a screenshot"
261
+ )
262
+ perception.add_argument("image", help="a JPEG screenshot")
263
+ perception.add_argument("-i", "--instruction", required=True, help="the check, e.g. 'The amounts match.'")
264
+ perception.add_argument(
265
+ "-x", "--extract", action="append", default=[], metavar="NAME=TYPE", help="a value to read (string, number...)"
266
+ )
267
+ perception.set_defaults(handler=_perception_check)
268
+
269
+ args = parser.parse_args(argv)
270
+ return int(args.handler(args))
271
+
272
+
273
+ if __name__ == "__main__":
274
+ sys.exit(main())
statelock/app.py ADDED
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """FastAPI application factory.
3
+
4
+ Run with: uvicorn --factory statelock.app:create_app
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Annotated, Any
11
+
12
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket
13
+
14
+ from statelock import __version__
15
+ from statelock.auth import Identity
16
+ from statelock.demo import router as demo_router
17
+ from statelock.dependencies import authenticated_auditor, authenticated_identity
18
+ from statelock.plugins import PluginContext, discover_plugins, setup_plugins
19
+ from statelock.proxy.bridge import CdpBridge
20
+ from statelock.review.api import authenticated_reviewer
21
+ from statelock.review.api import router as review_router
22
+ from statelock.services import Services, get_services
23
+ from statelock.sessions import SESSIONS_PATH, install_log_redaction
24
+ from statelock.sessions import router as sessions_router
25
+ from statelock.settings import Settings
26
+ from statelock.wire import VIOLATION_ENDPOINT_PREFIX, WEBSOCKET_PATH
27
+
28
+ __all__ = ["authenticated_auditor", "authenticated_identity", "authenticated_reviewer", "create_app", "get_services"]
29
+
30
+ LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
31
+
32
+
33
+ core_router = APIRouter()
34
+
35
+
36
+ @core_router.get("/health")
37
+ async def health() -> dict[str, str]:
38
+ return {"status": "ok"}
39
+
40
+
41
+ @core_router.get(VIOLATION_ENDPOINT_PREFIX + "/{session_id}")
42
+ async def get_violation(
43
+ session_id: str, request: Request, identity: Annotated[Identity | None, Depends(authenticated_identity)]
44
+ ) -> dict[str, Any]:
45
+ violation = get_services(request.app).registry.get(session_id)
46
+ # With authentication on, an agent sees only its own sessions (others look absent).
47
+ if violation is None or (identity is not None and violation.get("agent_id") != identity.agent_id):
48
+ raise HTTPException(status_code=404, detail="No violation recorded for session")
49
+ return violation
50
+
51
+
52
+ @core_router.websocket(WEBSOCKET_PATH)
53
+ async def statelock_session(websocket: WebSocket) -> None:
54
+ await CdpBridge(get_services(websocket.app)).handle(websocket)
55
+
56
+
57
+ @core_router.websocket(SESSIONS_PATH + "/{token}/devtools")
58
+ async def statelock_session_url(websocket: WebSocket, token: str) -> None:
59
+ """A session opened from a session URL: the token identifies the agent (no headers)."""
60
+ await CdpBridge(get_services(websocket.app)).handle(websocket, token)
61
+
62
+
63
+ def create_app(settings: Settings | None = None, services: Services | None = None) -> FastAPI:
64
+ """Build services once, register routes, and load plugins."""
65
+ settings = settings or (services.settings if services else Settings())
66
+ if settings.log_level:
67
+ # No-op when the host (e.g. a test runner) already configured logging.
68
+ logging.basicConfig(level=settings.log_level.upper(), format=LOG_FORMAT)
69
+ services = services or Services.from_settings(settings)
70
+
71
+ app = FastAPI(title="Statelock", version=__version__)
72
+ app.state.services = services
73
+ app.include_router(core_router)
74
+ app.include_router(review_router)
75
+ app.include_router(sessions_router)
76
+ install_log_redaction()
77
+ if settings.demo:
78
+ app.include_router(demo_router)
79
+ setup_plugins(PluginContext(app=app, services=services), discover_plugins(settings.plugins))
80
+ return app
@@ -0,0 +1,19 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Artifact records, redaction and sinks."""
3
+
4
+ from statelock.audit.records import SCHEMA_VERSION, ActionRecord, build_action_record, build_network_record
5
+ from statelock.audit.redaction import mask_secret_input, redact_payload
6
+ from statelock.audit.sequencing import SequencedWriter
7
+ from statelock.audit.sink import ArtifactSink, LocalJsonSink
8
+
9
+ __all__ = [
10
+ "SCHEMA_VERSION",
11
+ "ActionRecord",
12
+ "ArtifactSink",
13
+ "LocalJsonSink",
14
+ "SequencedWriter",
15
+ "build_action_record",
16
+ "build_network_record",
17
+ "mask_secret_input",
18
+ "redact_payload",
19
+ ]
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Versioned artifact record schema.
3
+
4
+ An ActionRecord is what the proxy hands to an ArtifactSink for each governed
5
+ action. ``payload`` is the redacted JSON body stored as ``context.json``;
6
+ ``screenshots`` holds the raw image bytes keyed by file name. Sinks and sink
7
+ wrappers (for example, a hash chain) may add top-level keys to the
8
+ payload; the keys written by build_action_record are reserved.
9
+
10
+ Schema history (all changes so far are additive: readers of "1" can read "2"):
11
+ - "1": initial schema.
12
+ - "2": ``context.tenant_id``; ``context.remembered`` (remembered policy fields);
13
+ ``params.statelock_uploads`` on DOM.setFileInputFiles; ``params.statelock_redacted``
14
+ when typed secrets are masked; download records (``Statelock.download``, kind
15
+ ``download``); ``target_element.input_type`` / ``autocomplete``.
16
+ - "3": ``review`` (human review of a paused action: id, status, reviewer, comment,
17
+ times, the failed rules) and its ``review-screenshot.jpg``; verdict decision
18
+ ``review`` is never stored (a stored verdict is the outcome: allow or block);
19
+ ``verdict.evidence.review_deferred`` for actions allowed while a review rule failed.
20
+ - "4": ``policy_id`` (in verdicts, violations, review failures and
21
+ ``post_conditions_evaluated``) names the policy, not the agent: the policy's ``id``
22
+ from the policy file, or ``<agent_id>#<n>`` for the agent's n-th policy.
23
+ Also in "4": ``page_text_unread`` in ``browser_state`` / ``post_browser_state`` (shown
24
+ frames whose text could not be read); ``target_element.unresolved`` and
25
+ ``target_element.frame_url`` (an action into a frame Statelock cannot read);
26
+ ``params.statelock_follows_key_down`` on a key ``char`` event that types the key a
27
+ preceding ``rawKeyDown`` pressed (part of that key press, not a new activation).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import binascii
34
+ from typing import Any
35
+
36
+ from pydantic import BaseModel, Field
37
+
38
+ from statelock.audit.redaction import redact_payload
39
+ from statelock.core.state import ActionContext
40
+ from statelock.core.verdict import PolicyVerdict
41
+ from statelock.review.queue import REVIEW_SCREENSHOT_FILE, Review
42
+
43
+ SCHEMA_VERSION = "4"
44
+
45
+ SCREENSHOT_FILES = {
46
+ "browser_state": "screenshot.jpg",
47
+ "post_browser_state": "post-screenshot.jpg",
48
+ }
49
+
50
+
51
+ class ActionRecord(BaseModel):
52
+ schema_version: str = SCHEMA_VERSION
53
+ session_id: str
54
+ sequence: int
55
+ payload: dict[str, Any]
56
+ screenshots: dict[str, bytes] = Field(default_factory=dict)
57
+
58
+
59
+ def build_action_record(
60
+ context: ActionContext,
61
+ verdict: PolicyVerdict,
62
+ post_verdict: PolicyVerdict | None = None,
63
+ review: Review | None = None,
64
+ ) -> ActionRecord:
65
+ context_payload = context.model_dump(mode="json")
66
+ screenshots: dict[str, bytes] = {}
67
+ for state_key, filename in SCREENSHOT_FILES.items():
68
+ state = context_payload.get(state_key)
69
+ if not isinstance(state, dict):
70
+ continue
71
+ encoded = state.pop("screenshot_base64", None)
72
+ state["screenshot_file"] = None
73
+ if encoded:
74
+ try:
75
+ screenshots[filename] = base64.b64decode(encoded)
76
+ state["screenshot_file"] = filename
77
+ except (binascii.Error, ValueError):
78
+ state["screenshot_error"] = "invalid base64 screenshot"
79
+
80
+ review_block = review.evidence() if review is not None else None
81
+ if review is not None and review.screenshot:
82
+ screenshots[REVIEW_SCREENSHOT_FILE] = review.screenshot
83
+
84
+ payload = redact_payload(
85
+ {
86
+ "schema_version": SCHEMA_VERSION,
87
+ "context": context_payload,
88
+ "verdict": verdict.model_dump(mode="json"),
89
+ "post_verdict": post_verdict.model_dump(mode="json") if post_verdict else None,
90
+ "policy_violation": verdict.violation_summary(),
91
+ "post_policy_violation": post_verdict.violation_summary() if post_verdict else None,
92
+ "review": review_block,
93
+ }
94
+ )
95
+ return ActionRecord(
96
+ session_id=context.session_id,
97
+ sequence=context.sequence,
98
+ payload=payload,
99
+ screenshots=screenshots,
100
+ )
101
+
102
+
103
+ def build_network_record(record: dict[str, Any]) -> dict[str, Any]:
104
+ """Redacted network log line."""
105
+ return redact_payload({"schema_version": SCHEMA_VERSION, **record})
@@ -0,0 +1,117 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Redaction applied to every artifact payload before it is written."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+ REDACTED = "[REDACTED]"
10
+ # Not part of a longer identifier: a UUID, a hex digest, a path or a file name.
11
+ _BEFORE = r"(?<![0-9A-Za-z/_.-])"
12
+ _AFTER = r"(?![0-9A-Za-z/_-]|\.\w)"
13
+ # US SSN: 123-45-6789, 123 45 6789 or 123456789, without the never-issued 000/666/9xx
14
+ # areas, 00 groups and 0000 serials.
15
+ SSN_PATTERN = re.compile(_BEFORE + r"(?!000|666|9)\d{3}([- ]?)(?!00)\d{2}\1(?!0000)\d{4}" + _AFTER)
16
+ # Card-like numbers: 12 to 19 digits, plain or grouped as printed on cards (4-4-4-x, or
17
+ # 4-6-5 / 4-6-4) with spaces or dashes; only those that pass the Luhn check are redacted.
18
+ CARD_PATTERN = re.compile(
19
+ _BEFORE + r"(?:\d{12,19}|\d{4}(?P<s>[ -])\d{4}(?P=s)\d{4}(?P=s)\d{1,7}|\d{4}(?P<t>[ -])\d{6}(?P=t)\d{4,5})" + _AFTER
20
+ )
21
+ EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
22
+
23
+
24
+ def luhn_valid(digits: str) -> bool:
25
+ total = 0
26
+ for index, char in enumerate(reversed(digits)):
27
+ digit = int(char)
28
+ if index % 2 == 1:
29
+ digit = digit * 2 - 9 if digit > 4 else digit * 2
30
+ total += digit
31
+ return total % 10 == 0
32
+
33
+
34
+ def _redact_card(match: re.Match[str]) -> str:
35
+ digits = re.sub(r"[ -]", "", match.group())
36
+ return REDACTED if luhn_valid(digits) else match.group()
37
+
38
+
39
+ def redact_text(text: str) -> str:
40
+ text = CARD_PATTERN.sub(_redact_card, text)
41
+ text = SSN_PATTERN.sub(REDACTED, text)
42
+ return EMAIL_PATTERN.sub(REDACTED, text)
43
+
44
+
45
+ OMITTED_LARGE_FIELDS = {"dom_snapshot", "accessibility_tree"}
46
+
47
+ # Identifier fields are never pattern-redacted. A UUID group of 12 digits would
48
+ # otherwise match the card-number pattern and corrupt session identity.
49
+ IDENTIFIER_FIELDS = {
50
+ "session_id",
51
+ "cdp_session_id",
52
+ "target_id",
53
+ "screenshot_file",
54
+ "guid",
55
+ "path",
56
+ "sha256",
57
+ "uploadId",
58
+ "browserContextId",
59
+ "frameId",
60
+ }
61
+
62
+
63
+ def redact_payload(payload: dict[str, Any]) -> dict[str, Any]:
64
+ redacted = _redact_value(payload)
65
+ if not isinstance(redacted, dict): # pragma: no cover - dict in, dict out
66
+ raise TypeError("redaction must preserve the payload mapping")
67
+ return redacted
68
+
69
+
70
+ def _redact_value(value: Any, key: str | None = None) -> Any:
71
+ if key in OMITTED_LARGE_FIELDS:
72
+ return {"redacted": True, "reason": "large_capture_payload_omitted"}
73
+ if isinstance(value, dict):
74
+ return {item_key: _redact_value(item_value, item_key) for item_key, item_value in value.items()}
75
+ if isinstance(value, list):
76
+ return [_redact_value(item) for item in value]
77
+ if isinstance(value, str):
78
+ if key in IDENTIFIER_FIELDS:
79
+ return value
80
+ return redact_text(value)
81
+ return value
82
+
83
+
84
+ # Text input params that reveal what was typed (Input.insertText, imeSetComposition,
85
+ # dispatchKeyEvent). Named keys such as Enter or Tab are kept.
86
+ SECRET_TEXT_PARAMS = ("text", "unmodifiedText")
87
+ SECRET_KEY_PARAMS = ("key", "code", "windowsVirtualKeyCode", "nativeVirtualKeyCode", "keyIdentifier")
88
+ SECRET_MARKER = "[SECRET]" # noqa: S105 - a placeholder, not a password
89
+
90
+
91
+ def _is_character_key(params: dict[str, Any]) -> bool:
92
+ key = params.get("key")
93
+ text = params.get("text")
94
+ return (isinstance(key, str) and len(key) == 1) or (isinstance(text, str) and bool(text))
95
+
96
+
97
+ def mask_secret_input(params: dict[str, Any], *, secret_target: bool) -> dict[str, Any]:
98
+ """Params for the record when the focused element is a secret field (password, OTP, card).
99
+
100
+ Returns a copy; the command sent to the browser is not changed.
101
+ """
102
+ if not secret_target:
103
+ return params
104
+ masked = dict(params)
105
+ changed = False
106
+ for name in SECRET_TEXT_PARAMS:
107
+ if name in masked:
108
+ masked[name] = SECRET_MARKER
109
+ changed = True
110
+ if _is_character_key(params):
111
+ for name in SECRET_KEY_PARAMS:
112
+ if name in masked:
113
+ masked[name] = SECRET_MARKER
114
+ changed = True
115
+ if changed:
116
+ masked["statelock_redacted"] = "secret_input"
117
+ return masked