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.
- statelock/__init__.py +4 -0
- statelock/__main__.py +274 -0
- statelock/app.py +80 -0
- statelock/audit/__init__.py +19 -0
- statelock/audit/records.py +105 -0
- statelock/audit/redaction.py +117 -0
- statelock/audit/sequencing.py +90 -0
- statelock/audit/sink.py +163 -0
- statelock/auth.py +266 -0
- statelock/client/__init__.py +99 -0
- statelock/client/connection.py +86 -0
- statelock/client/files.py +237 -0
- statelock/client/files_sync.py +213 -0
- statelock/client/install.py +147 -0
- statelock/client/install_sync.py +118 -0
- statelock/client/patching.py +50 -0
- statelock/client/playwright.py +158 -0
- statelock/client/requests.py +183 -0
- statelock/client/sessions.py +181 -0
- statelock/client/transfer.py +426 -0
- statelock/client/violations.py +158 -0
- statelock/core/__init__.py +23 -0
- statelock/core/actions.py +287 -0
- statelock/core/enums.py +57 -0
- statelock/core/jsonutil.py +31 -0
- statelock/core/state.py +147 -0
- statelock/core/urls.py +171 -0
- statelock/core/verdict.py +79 -0
- statelock/credentials.py +213 -0
- statelock/demo/__init__.py +48 -0
- statelock/demo/bank.html +27 -0
- statelock/demo/erp.html +44 -0
- statelock/demo/finance.html +74 -0
- statelock/dependencies.py +43 -0
- statelock/events.py +45 -0
- statelock/fileio.py +74 -0
- statelock/integrations/__init__.py +6 -0
- statelock/integrations/browser_use.py +89 -0
- statelock/integrations/langchain.py +144 -0
- statelock/plugins.py +67 -0
- statelock/policy/__init__.py +63 -0
- statelock/policy/cookies.py +58 -0
- statelock/policy/currency.py +132 -0
- statelock/policy/evaluator.py +249 -0
- statelock/policy/extensions.py +73 -0
- statelock/policy/fields.py +330 -0
- statelock/policy/models.py +134 -0
- statelock/policy/patterns.py +17 -0
- statelock/policy/perception.py +347 -0
- statelock/policy/requests.py +166 -0
- statelock/policy/rules.py +622 -0
- statelock/policy/text.py +84 -0
- statelock/policy/triggers.py +72 -0
- statelock/proxy/__init__.py +2 -0
- statelock/proxy/attribution.py +64 -0
- statelock/proxy/bridge.py +643 -0
- statelock/proxy/browser.py +413 -0
- statelock/proxy/commands.py +104 -0
- statelock/proxy/connection.py +401 -0
- statelock/proxy/cookies.py +68 -0
- statelock/proxy/download_policy.py +73 -0
- statelock/proxy/downloads.py +380 -0
- statelock/proxy/files.py +63 -0
- statelock/proxy/governor.py +502 -0
- statelock/proxy/guard.py +638 -0
- statelock/proxy/inspector.py +378 -0
- statelock/proxy/js/__init__.py +18 -0
- statelock/proxy/js/describe_element.js +46 -0
- statelock/proxy/js/fetch.js +42 -0
- statelock/proxy/js/focused_element.js +13 -0
- statelock/proxy/js/guard.js +200 -0
- statelock/proxy/js/page_state.js +142 -0
- statelock/proxy/js/replay_hits.js +6 -0
- statelock/proxy/js/replay_point.js +29 -0
- statelock/proxy/memory.py +64 -0
- statelock/proxy/network_filter.py +40 -0
- statelock/proxy/pages.py +86 -0
- statelock/proxy/replay.py +187 -0
- statelock/proxy/reporter.py +161 -0
- statelock/proxy/requests.py +258 -0
- statelock/proxy/scripts.py +80 -0
- statelock/proxy/targets.py +128 -0
- statelock/proxy/tasks.py +44 -0
- statelock/proxy/uploads.py +183 -0
- statelock/proxy/worlds.py +206 -0
- statelock/py.typed +0 -0
- statelock/registry.py +27 -0
- statelock/review/__init__.py +22 -0
- statelock/review/api.py +107 -0
- statelock/review/page.html +173 -0
- statelock/review/queue.py +233 -0
- statelock/saved_sessions.py +354 -0
- statelock/services.py +110 -0
- statelock/sessions.py +190 -0
- statelock/settings.py +113 -0
- statelock/tokens.py +85 -0
- statelock/wire.py +140 -0
- statelock_ai-0.1.0.dist-info/METADATA +745 -0
- statelock_ai-0.1.0.dist-info/RECORD +104 -0
- statelock_ai-0.1.0.dist-info/WHEEL +5 -0
- statelock_ai-0.1.0.dist-info/entry_points.txt +2 -0
- statelock_ai-0.1.0.dist-info/licenses/LICENSE +202 -0
- statelock_ai-0.1.0.dist-info/licenses/NOTICE +4 -0
- statelock_ai-0.1.0.dist-info/top_level.txt +1 -0
statelock/__init__.py
ADDED
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
|