sandbroker 0.1.0__tar.gz

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 (35) hide show
  1. sandbroker-0.1.0/LICENSE +21 -0
  2. sandbroker-0.1.0/PKG-INFO +129 -0
  3. sandbroker-0.1.0/README.md +92 -0
  4. sandbroker-0.1.0/bin/sandbroker +300 -0
  5. sandbroker-0.1.0/bin/sandbroker-authz-check +90 -0
  6. sandbroker-0.1.0/bin/sandbroker-bridge +158 -0
  7. sandbroker-0.1.0/bin/sandbroker-check-auditor +325 -0
  8. sandbroker-0.1.0/bin/sandbroker-dashboard +308 -0
  9. sandbroker-0.1.0/bin/sandbroker-lock +47 -0
  10. sandbroker-0.1.0/bin/sandbroker-pushd +259 -0
  11. sandbroker-0.1.0/bin/sandbroker-session-init +41 -0
  12. sandbroker-0.1.0/bin/sandbroker-setup +28 -0
  13. sandbroker-0.1.0/bin/sandbroker-token-set +78 -0
  14. sandbroker-0.1.0/bin/sandbroker-vapid-init +105 -0
  15. sandbroker-0.1.0/bin/sandbroker-webauthn-enroll +342 -0
  16. sandbroker-0.1.0/pyproject.toml +120 -0
  17. sandbroker-0.1.0/sandbroker/backends/__init__.py +223 -0
  18. sandbroker-0.1.0/sandbroker/backends/onepassword.py +196 -0
  19. sandbroker-0.1.0/sandbroker/backends/registry.py +87 -0
  20. sandbroker-0.1.0/sandbroker/config.py +478 -0
  21. sandbroker-0.1.0/sandbroker/sandbrokerd.py +1780 -0
  22. sandbroker-0.1.0/sandbroker/setup.py +738 -0
  23. sandbroker-0.1.0/sandbroker/vaults.json +35 -0
  24. sandbroker-0.1.0/sandbroker/verbs.json +114 -0
  25. sandbroker-0.1.0/sandbroker/webauthn.py +615 -0
  26. sandbroker-0.1.0/sandbroker.egg-info/PKG-INFO +129 -0
  27. sandbroker-0.1.0/sandbroker.egg-info/SOURCES.txt +33 -0
  28. sandbroker-0.1.0/sandbroker.egg-info/dependency_links.txt +1 -0
  29. sandbroker-0.1.0/sandbroker.egg-info/entry_points.txt +2 -0
  30. sandbroker-0.1.0/sandbroker.egg-info/requires.txt +18 -0
  31. sandbroker-0.1.0/sandbroker.egg-info/top_level.txt +1 -0
  32. sandbroker-0.1.0/setup.cfg +4 -0
  33. sandbroker-0.1.0/tests/test_adversarial.py +489 -0
  34. sandbroker-0.1.0/tests/test_authz.py +198 -0
  35. sandbroker-0.1.0/tests/test_guarantee.py +303 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grayson Adams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: sandbroker
3
+ Version: 0.1.0
4
+ Summary: A typed-return secret broker: clients USE secrets over a unix socket, they never SEE the plaintext.
5
+ Author: Grayson Adams
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/312-dev/sandbroker
8
+ Project-URL: Documentation, https://github.com/312-dev/sandbroker/blob/master/DEPLOY.md
9
+ Keywords: secrets,broker,credentials,claude,1password,azure-key-vault,webauthn
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: No Input/Output (Daemon)
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS :: MacOS X
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: System :: Systems Administration
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: push
24
+ Requires-Dist: pywebpush<3,>=2.0; extra == "push"
25
+ Requires-Dist: py-vapid<2,>=1.9; extra == "push"
26
+ Requires-Dist: cryptography>=41; extra == "push"
27
+ Provides-Extra: webauthn
28
+ Requires-Dist: cryptography>=41; extra == "webauthn"
29
+ Provides-Extra: mcp
30
+ Requires-Dist: mcp<2,>=1.12; extra == "mcp"
31
+ Provides-Extra: all
32
+ Requires-Dist: sandbroker[push,webauthn]; extra == "all"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7; extra == "dev"
35
+ Requires-Dist: build>=1.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # sandbroker
39
+
40
+ A typed-return secret executor. Claude Code causes secrets to be **used**
41
+ without ever learning what they **are**.
42
+
43
+ **Full design, runbook, platform matrix, and discoveries log:**
44
+ [`~/dotfiles/docs/sandbroker-credential-broker.md`](../../dotfiles/docs/sandbroker-credential-broker.md)
45
+ (also on GitHub under `GraysonCAdams/dotfiles`). This file is the short version.
46
+
47
+ ## The claim
48
+
49
+ > No plaintext secret resolved by sandbroker appears in Claude's context, in the
50
+ > Anthropic API request body, in the local session transcript, or in any prompt
51
+ > cache, because no channel exists through which it could travel.
52
+
53
+ It does **not** claim anything about secrets arriving through doors sandbroker
54
+ doesn't mediate. "100% secure" is not on offer; see Known limits in the doc.
55
+
56
+ ## Why not a redaction hook
57
+
58
+ A `PostToolUse` redaction hook is a filter, and filters **fail open**: unmatched
59
+ pattern, unexpected encoding, or an unanticipated error string, and the raw value
60
+ proceeds to the wire. sandbroker is not a filter. The response schema has no field
61
+ a secret can occupy.
62
+
63
+ ## How it holds
64
+
65
+ Four structural properties, all asserted in `tests/test_guarantee.py` (29 tests):
66
+
67
+ 1. **The child cannot talk to us.** Targets spawn with stdout/stderr bound to
68
+ `/dev/null` at the fd level, so no output on any path reaches the caller.
69
+ 2. **Secrets never enter `argv`.** Rejected at registry load, because
70
+ `/proc/PID/cmdline` exposes argv to the same uid.
71
+ 3. **There is no shell.** Argv vectors only, so arguments can never become code.
72
+ 4. **Egress is allowlisted.** Response keys outside `{ok, error, verb}` are
73
+ dropped and unknown error tokens collapsed.
74
+
75
+ ## Install
76
+
77
+ Two commands on Linux/WSL2 or macOS:
78
+
79
+ ```bash
80
+ pipx install sandbroker # puts sandbroker, sandbrokerd, and the bin/ helpers on PATH
81
+ sandbroker setup # guided wizard: base + daemon, secret store, approver, Claude Code
82
+ ```
83
+
84
+ `sandbroker setup` runs unprivileged (user mode) by default; add `--hardened` for
85
+ the dedicated-uid `/opt/sandbroker` system install. See
86
+ [`docs/integration-notes/setup-wizard.md`](docs/integration-notes/setup-wizard.md).
87
+
88
+ ## Quick start from a checkout (Linux/WSL, systemd)
89
+
90
+ ```bash
91
+ python3 tests/test_guarantee.py # expect OK
92
+ sudo ./install.sh # creates sandbroker uid + claude-broker
93
+ install -m 0755 bin/sandbroker ~/.local/bin/sandbroker
94
+ install -m 0755 hooks/deny_secret_reads.py ~/.claude/hooks/
95
+
96
+ # new login session for group membership, then:
97
+ sudo ./canary/canary_provision.sh
98
+ sandbroker canary.probe --ref file://canary # expect {"ok": true}
99
+ sudo ./canary/canary_sweep.sh # expect clean
100
+ ```
101
+
102
+ The hook still needs merging into `~/.claude/settings.json`; `jq` is absent on
103
+ WSL, so use the python snippet in the doc.
104
+
105
+ ## Ref schemes
106
+
107
+ | Scheme | Backend |
108
+ | --- | --- |
109
+ | `file://canary` | local 0400 file, verification only |
110
+ | `akv://<vault>/<name>` | Azure Key Vault via `az` |
111
+ | `op://<vault>/<item>/<field>` | 1Password via `op` (macOS hosts) |
112
+
113
+ ## Layout
114
+
115
+ ```
116
+ sandbroker/sandbrokerd.py the daemon: exec core, registry validator, egress filter
117
+ sandbroker/verbs.json capability grants; installed root:root 0444
118
+ bin/sandbroker the only interface Claude uses
119
+ hooks/ PreToolUse deny for doors sandbroker doesn't mediate
120
+ canary/ provision + sweep: the leak test that must keep passing
121
+ tests/ the guarantee, as executable assertions
122
+ install.sh every sudo step (Linux/systemd)
123
+ ```
124
+
125
+ ## Verification note
126
+
127
+ **A sweep only means something when Claude runs the probe.** Running it yourself
128
+ proves nothing, because the canary was never near the model's context. Have
129
+ Claude exercise the verbs and attempt extraction, *then* sweep.
@@ -0,0 +1,92 @@
1
+ # sandbroker
2
+
3
+ A typed-return secret executor. Claude Code causes secrets to be **used**
4
+ without ever learning what they **are**.
5
+
6
+ **Full design, runbook, platform matrix, and discoveries log:**
7
+ [`~/dotfiles/docs/sandbroker-credential-broker.md`](../../dotfiles/docs/sandbroker-credential-broker.md)
8
+ (also on GitHub under `GraysonCAdams/dotfiles`). This file is the short version.
9
+
10
+ ## The claim
11
+
12
+ > No plaintext secret resolved by sandbroker appears in Claude's context, in the
13
+ > Anthropic API request body, in the local session transcript, or in any prompt
14
+ > cache, because no channel exists through which it could travel.
15
+
16
+ It does **not** claim anything about secrets arriving through doors sandbroker
17
+ doesn't mediate. "100% secure" is not on offer; see Known limits in the doc.
18
+
19
+ ## Why not a redaction hook
20
+
21
+ A `PostToolUse` redaction hook is a filter, and filters **fail open**: unmatched
22
+ pattern, unexpected encoding, or an unanticipated error string, and the raw value
23
+ proceeds to the wire. sandbroker is not a filter. The response schema has no field
24
+ a secret can occupy.
25
+
26
+ ## How it holds
27
+
28
+ Four structural properties, all asserted in `tests/test_guarantee.py` (29 tests):
29
+
30
+ 1. **The child cannot talk to us.** Targets spawn with stdout/stderr bound to
31
+ `/dev/null` at the fd level, so no output on any path reaches the caller.
32
+ 2. **Secrets never enter `argv`.** Rejected at registry load, because
33
+ `/proc/PID/cmdline` exposes argv to the same uid.
34
+ 3. **There is no shell.** Argv vectors only, so arguments can never become code.
35
+ 4. **Egress is allowlisted.** Response keys outside `{ok, error, verb}` are
36
+ dropped and unknown error tokens collapsed.
37
+
38
+ ## Install
39
+
40
+ Two commands on Linux/WSL2 or macOS:
41
+
42
+ ```bash
43
+ pipx install sandbroker # puts sandbroker, sandbrokerd, and the bin/ helpers on PATH
44
+ sandbroker setup # guided wizard: base + daemon, secret store, approver, Claude Code
45
+ ```
46
+
47
+ `sandbroker setup` runs unprivileged (user mode) by default; add `--hardened` for
48
+ the dedicated-uid `/opt/sandbroker` system install. See
49
+ [`docs/integration-notes/setup-wizard.md`](docs/integration-notes/setup-wizard.md).
50
+
51
+ ## Quick start from a checkout (Linux/WSL, systemd)
52
+
53
+ ```bash
54
+ python3 tests/test_guarantee.py # expect OK
55
+ sudo ./install.sh # creates sandbroker uid + claude-broker
56
+ install -m 0755 bin/sandbroker ~/.local/bin/sandbroker
57
+ install -m 0755 hooks/deny_secret_reads.py ~/.claude/hooks/
58
+
59
+ # new login session for group membership, then:
60
+ sudo ./canary/canary_provision.sh
61
+ sandbroker canary.probe --ref file://canary # expect {"ok": true}
62
+ sudo ./canary/canary_sweep.sh # expect clean
63
+ ```
64
+
65
+ The hook still needs merging into `~/.claude/settings.json`; `jq` is absent on
66
+ WSL, so use the python snippet in the doc.
67
+
68
+ ## Ref schemes
69
+
70
+ | Scheme | Backend |
71
+ | --- | --- |
72
+ | `file://canary` | local 0400 file, verification only |
73
+ | `akv://<vault>/<name>` | Azure Key Vault via `az` |
74
+ | `op://<vault>/<item>/<field>` | 1Password via `op` (macOS hosts) |
75
+
76
+ ## Layout
77
+
78
+ ```
79
+ sandbroker/sandbrokerd.py the daemon: exec core, registry validator, egress filter
80
+ sandbroker/verbs.json capability grants; installed root:root 0444
81
+ bin/sandbroker the only interface Claude uses
82
+ hooks/ PreToolUse deny for doors sandbroker doesn't mediate
83
+ canary/ provision + sweep: the leak test that must keep passing
84
+ tests/ the guarantee, as executable assertions
85
+ install.sh every sudo step (Linux/systemd)
86
+ ```
87
+
88
+ ## Verification note
89
+
90
+ **A sweep only means something when Claude runs the probe.** Running it yourself
91
+ proves nothing, because the canary was never near the model's context. Have
92
+ Claude exercise the verbs and attempt extraction, *then* sweep.
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env python3
2
+ """sandbroker - thin client for sandbroker.
3
+
4
+ The ONLY interface Claude uses to cause a secret to be USED. Sends a typed
5
+ request and prints the typed response verbatim. Never carries a secret VALUE.
6
+
7
+ Auto-tier verbs (frictionless):
8
+
9
+ sandbroker canary.probe --ref file://canary
10
+ sandbroker ref.check --ref 'akv://ent-prd-cus-kv/GRIZBOT-DISCORD-TOKEN'
11
+
12
+ Confirm-tier verbs (approval window + Windows Hello). sandbroker generates a
13
+ one-time APPROVAL CODE, prints it in the chat, and sends only its hash. The
14
+ human types that code into the Windows approval window and then approves with
15
+ Hello; the daemon binds the session and replays the operation. The session id is
16
+ taken from $SANDBROKER_SID (the launcher injects it; unset falls back to a shared
17
+ dev id).
18
+
19
+ sandbroker deploy.push --ref op://proj-prod/DB/pw --justification "run the 3pm migration"
20
+ -> prints: APPROVAL CODE: 4 8 2 1 5 9 (relay this to the human)
21
+ -> {"ok": false, "error": "hello_pending"}
22
+ # human types the code into the Windows window + approves Hello, then re-run
23
+ # the SAME command to poll:
24
+ sandbroker deploy.push --ref op://proj-prod/DB/pw --justification "run the 3pm migration"
25
+ -> {"ok": false, "error": "hello_waiting"} # still waiting (do NOT show a new code)
26
+ -> {"ok": true} # approved; op replayed
27
+
28
+ Two transports, tried in order:
29
+ 1. Unix socket to sandbroker directly (normal, unsandboxed sessions).
30
+ 2. File-queue BRIDGE (when the socket is unreachable, e.g. inside the srt
31
+ sandbox, whose seccomp filter blocks AF_UNIX). Drops a request file in
32
+ /tmp/sandbroker-bridge/req and polls resp; the host-side sandbroker-bridge
33
+ watcher relays to sandbroker. The queue carries only the request fields in and
34
+ {ok,error} out -- NEVER a secret. This is what lets the sandboxed default
35
+ agent use secrets without ever seeing them.
36
+
37
+ Set SANDBROKER_NO_BRIDGE=1 to force socket-only (the watcher sets this so it can't
38
+ recurse into its own queue).
39
+
40
+ Output: one JSON object {"ok": true} or {"ok": false, "error": "..."} on stdout.
41
+ The human-facing approval-code banner is printed to stderr, so stdout stays a
42
+ clean machine contract.
43
+ Exit: 0 ok, 1 typed failure, 2 client/transport problem.
44
+ """
45
+
46
+ import hashlib
47
+ import json
48
+ import os
49
+ import secrets
50
+ import socket
51
+ import sys
52
+ import time
53
+ import uuid
54
+
55
+ SOCKET_PATH = os.environ.get("SANDBROKER_SOCKET") or os.path.join(os.environ.get("SANDBROKER_BASE", "/opt/sandbroker"), "run", "sandbroker.sock")
56
+ BRIDGE_DIR = os.environ.get("SANDBROKER_BRIDGE_DIR", "/tmp/sandbroker-bridge")
57
+ NO_BRIDGE = os.environ.get("SANDBROKER_NO_BRIDGE") == "1"
58
+ BRIDGE_TIMEOUT = 150.0
59
+
60
+ # Must match sandbroker.DEFAULT_SID so the code hash the human satisfies matches
61
+ # what the daemon/approver compute when no launcher-minted SID is present.
62
+ DEFAULT_SID = "_nosid"
63
+
64
+
65
+ def emit(obj):
66
+ print(json.dumps(obj))
67
+ return 0 if obj.get("ok") else 1
68
+
69
+
70
+ def parse(argv):
71
+ sid = os.environ.get("SANDBROKER_SID") # launcher-injected; None -> daemon default
72
+
73
+ # `sandbroker list [env]` -> catalog of item references in a vault (names only,
74
+ # never values). Env defaults to the daemon's default (Dev). E.g.:
75
+ # sandbroker list -> Dev items
76
+ # sandbroker list Production -> Production items
77
+ if argv[1] == "list":
78
+ request = {"action": "catalog"}
79
+ if len(argv) > 2 and argv[2]:
80
+ request["vault"] = argv[2]
81
+ if sid:
82
+ request["sid"] = sid
83
+ return request
84
+
85
+ request = {"action": "invoke", "verb": argv[1], "args": {}}
86
+ if sid:
87
+ request["sid"] = sid
88
+ rest, i = argv[2:], 0
89
+ while i < len(rest):
90
+ tok = rest[i]
91
+ if tok == "--ref":
92
+ i += 1
93
+ if i >= len(rest):
94
+ raise ValueError("--ref requires a value")
95
+ request["ref"] = rest[i]
96
+ elif tok == "--justification":
97
+ i += 1
98
+ if i >= len(rest):
99
+ raise ValueError("--justification requires a value")
100
+ request["justification"] = rest[i]
101
+ elif "=" in tok:
102
+ k, _, v = tok.partition("=")
103
+ request["args"][k] = v
104
+ else:
105
+ raise ValueError("unparsed argument %r" % tok)
106
+ i += 1
107
+ return request
108
+
109
+
110
+ def via_socket(request):
111
+ """Direct unix socket. Raises OSError if unreachable (-> caller tries bridge)."""
112
+ conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
113
+ conn.settimeout(150)
114
+ conn.connect(SOCKET_PATH) # OSError here on EPERM (sandbox) / ENOENT
115
+ try:
116
+ conn.sendall((json.dumps(request) + "\n").encode("utf-8"))
117
+ buf = b""
118
+ while b"\n" not in buf:
119
+ chunk = conn.recv(4096)
120
+ if not chunk:
121
+ break
122
+ buf += chunk
123
+ finally:
124
+ conn.close()
125
+ line = buf.decode("utf-8", "replace").strip()
126
+ return json.loads(line) if line else {"ok": False, "error": "failed"}
127
+
128
+
129
+ def via_bridge(request):
130
+ """File-queue relay via this session's per-session queue namespace (C11).
131
+ Atomic write (tmp+rename) so the watcher never reads a partial request; poll
132
+ for the atomically-written response. The bridge takes the SID from the queue
133
+ PATH, so the session's identity is bound to its own directory."""
134
+ sid = os.environ.get("SANDBROKER_SID") or "_nosid"
135
+ session_dir = os.path.join(BRIDGE_DIR, sid)
136
+ req_dir = os.path.join(session_dir, "req")
137
+ resp_dir = os.path.join(session_dir, "resp")
138
+ if not os.path.isdir(BRIDGE_DIR):
139
+ return {"ok": False, "error": "denied",
140
+ "detail": "sandbroker-bridge not running (no %s)" % BRIDGE_DIR}
141
+ os.makedirs(req_dir, exist_ok=True)
142
+ os.makedirs(resp_dir, exist_ok=True)
143
+ rid = uuid.uuid4().hex
144
+ tmp = os.path.join(req_dir, rid + ".tmp")
145
+ final = os.path.join(req_dir, rid + ".json")
146
+ resp = os.path.join(resp_dir, rid + ".json")
147
+ with open(tmp, "w") as fh:
148
+ json.dump(request, fh)
149
+ os.rename(tmp, final) # atomic: watcher only globs *.json
150
+
151
+ deadline = time.time() + BRIDGE_TIMEOUT
152
+ while time.time() < deadline:
153
+ if os.path.exists(resp):
154
+ try:
155
+ with open(resp) as fh:
156
+ out = json.load(fh)
157
+ except (ValueError, OSError):
158
+ time.sleep(0.05)
159
+ continue
160
+ try:
161
+ os.unlink(resp)
162
+ except OSError:
163
+ pass
164
+ return out
165
+ time.sleep(0.1)
166
+ try:
167
+ os.unlink(final)
168
+ except OSError:
169
+ pass
170
+ return {"ok": False, "error": "failed", "detail": "bridge timeout"}
171
+
172
+
173
+ def send(request):
174
+ try:
175
+ return via_socket(request)
176
+ except OSError:
177
+ if NO_BRIDGE:
178
+ return {"ok": False, "error": "denied",
179
+ "detail": "cannot reach sandbroker socket"}
180
+ return via_bridge(request)
181
+
182
+
183
+ def _spaced(code):
184
+ return " ".join(code)
185
+
186
+
187
+ def print_code_banner(code, out=sys.stderr):
188
+ """Human-facing approval banner (stderr, so stdout stays pure JSON)."""
189
+ line = "=" * 60
190
+ out.write(
191
+ "\n%s\n"
192
+ " APPROVAL REQUIRED (confirm tier)\n"
193
+ " APPROVAL CODE: %s\n\n"
194
+ " Type this code into the Windows approval window that just\n"
195
+ " appeared, then approve with Windows Hello. Re-run the same\n"
196
+ " command to check status.\n"
197
+ "%s\n\n" % (line, _spaced(code), line)
198
+ )
199
+ out.flush()
200
+
201
+
202
+ def print_waiting_banner(out=sys.stderr):
203
+ out.write(
204
+ "\n[sandbroker] Approval still pending. Use the code already shown\n"
205
+ " (do not request a new one). Approve in the Windows\n"
206
+ " window + Hello, then re-run to poll.\n\n"
207
+ )
208
+ out.flush()
209
+
210
+
211
+ def print_reviewing_banner(out=sys.stderr):
212
+ out.write(
213
+ "\n[sandbroker] Request is under automated review by the independent AI\n"
214
+ " auditor. Waiting for its decision -- it will either auto-\n"
215
+ " approve or escalate to a human. No action needed yet.\n\n"
216
+ )
217
+ out.flush()
218
+
219
+
220
+ def print_catalog(resp):
221
+ """Render `sandbroker list` output: the item catalog for the bound vault
222
+ (names/ids only -- never values)."""
223
+ items = resp.get("items", [])
224
+ if not resp.get("ok"):
225
+ return emit(resp)
226
+ if not items:
227
+ sys.stderr.write("(no items, or session has no vault)\n")
228
+ else:
229
+ sys.stderr.write("%d item(s) in this session's vault "
230
+ "(append /<field>, e.g. /password, to reference):\n" % len(items))
231
+ for it in items:
232
+ sys.stderr.write(" %-40s [%s]\n" % (it.get("title", "") or "(untitled)", it.get("id", "")))
233
+ print(json.dumps(resp))
234
+ return 0
235
+
236
+
237
+ def main(argv):
238
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
239
+ print(__doc__)
240
+ return 0
241
+
242
+ # `sandbroker setup` -> the guided installer wizard (sandbroker/setup.py). Handled
243
+ # before the invoke/approval-code machinery: setup never touches the daemon
244
+ # socket, it lays out the install and starts the services. Bootstrap the
245
+ # sibling package onto sys.path so this works from a checkout or a pipx install.
246
+ if argv[1] == "setup":
247
+ import os as _os
248
+ _repo = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
249
+ if _repo not in sys.path:
250
+ sys.path.insert(0, _repo)
251
+ from sandbroker.setup import main as setup_main
252
+ return setup_main(argv[2:])
253
+
254
+ try:
255
+ request = parse(argv)
256
+ except ValueError as exc:
257
+ return emit({"ok": False, "error": "denied", "detail": str(exc)}) or 2
258
+
259
+ # Catalog (list) needs no approval code; render its items.
260
+ if request.get("action") == "catalog":
261
+ return print_catalog(send(request))
262
+
263
+ # Generate a per-invocation approval code and attach ONLY its hash. The
264
+ # plaintext code stays here and is shown to the human only if the daemon
265
+ # opens a fresh approval cycle (hello_pending). The daemon never receives
266
+ # the plaintext; it lives only in this session's chat and the human's typing.
267
+ sid = request.get("sid", DEFAULT_SID)
268
+ code = "%06d" % secrets.randbelow(10 ** 6)
269
+ request["code_hash"] = hashlib.sha256(("%s:%s" % (sid, code)).encode()).hexdigest()
270
+
271
+ resp = send(request)
272
+ err = resp.get("error")
273
+
274
+ # AI auditor fast-path: the daemon is reviewing this request. Poll a few times
275
+ # (short, automated) before resolving to the human/ok outcome. The SAME code is
276
+ # reused across polls, so if the auditor escalates, the code shown matches the
277
+ # daemon's stored hash. The daemon fail-closes to a human by its own deadline,
278
+ # so this loop always resolves.
279
+ reviews = 0
280
+ while err == "auditor_reviewing" and reviews < 6:
281
+ if reviews == 0:
282
+ print_reviewing_banner()
283
+ try:
284
+ delay = max(1, min(int(resp.get("retry_after", 5)), 30))
285
+ except (TypeError, ValueError):
286
+ delay = 5
287
+ time.sleep(delay)
288
+ reviews += 1
289
+ resp = send(request)
290
+ err = resp.get("error")
291
+
292
+ if err == "hello_pending":
293
+ print_code_banner(code)
294
+ elif err == "hello_waiting":
295
+ print_waiting_banner()
296
+ return emit(resp)
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.exit(main(sys.argv))
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """sandbroker-authz-check - show each session's authorization binding state.
3
+
4
+ The authz counterpart to claude-sandbox-check: it tells you which sessions are
5
+ BOUND to which vault right now, and how long until they idle out. Reads the
6
+ daemon's private session records, so on the production box run it as the daemon
7
+ user:
8
+
9
+ sudo -u sandbroker SANDBROKER_BASE=/opt/sandbroker sandbroker-authz-check
10
+
11
+ A BOUND row means that session can currently USE its vault's confirm-tier
12
+ secrets without another biometric prompt (until it idles out). If you see a
13
+ binding you don't recognize, run `sandbroker-lock` to clear every session.
14
+ """
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+ BASE = os.environ.get("SANDBROKER_BASE", "/opt/sandbroker")
21
+ SESSIONS_DIR = os.path.join(BASE, "var", "sessions")
22
+ PENDING_DIR = os.path.join(BASE, "var", "pending")
23
+ IDLE_TIMEOUT = 3600
24
+
25
+ green = "\033[32m"; yellow = "\033[33m"; red = "\033[31m"; dim = "\033[2m"; off = "\033[0m"
26
+
27
+
28
+ def fmt_age(secs):
29
+ secs = int(secs)
30
+ if secs < 60:
31
+ return "%ds" % secs
32
+ if secs < 3600:
33
+ return "%dm" % (secs // 60)
34
+ return "%dh%dm" % (secs // 3600, (secs % 3600) // 60)
35
+
36
+
37
+ def main():
38
+ try:
39
+ names = [n for n in os.listdir(SESSIONS_DIR) if n.endswith(".json")]
40
+ except PermissionError:
41
+ sys.stderr.write("sandbroker-authz-check: cannot read %s (run as the daemon "
42
+ "user: sudo -u sandbroker ... )\n" % SESSIONS_DIR)
43
+ return 2
44
+ except OSError:
45
+ names = []
46
+
47
+ pending = set()
48
+ try:
49
+ pending = {n[:-5] for n in os.listdir(PENDING_DIR) if n.endswith(".json")}
50
+ except OSError:
51
+ pass
52
+
53
+ if not names:
54
+ print("no registered sessions")
55
+ return 0
56
+
57
+ now = time.time()
58
+ print("%-14s %-20s %-8s %-10s %s" % ("SID", "VAULT", "TIER", "STATE", "IDLE/INFO"))
59
+ print("%-14s %-20s %-8s %-10s %s" % ("---", "-----", "----", "-----", "---------"))
60
+ for n in sorted(names):
61
+ sid = n[:-5]
62
+ try:
63
+ with open(os.path.join(SESSIONS_DIR, n)) as fh:
64
+ s = json.load(fh)
65
+ except (OSError, ValueError):
66
+ continue
67
+ vault = s.get("vault", "?")
68
+ tier = s.get("tier", "?")
69
+ state = s.get("state", "?")
70
+ if state == "BOUND":
71
+ idle = now - s.get("last_used", 0)
72
+ if idle > IDLE_TIMEOUT:
73
+ colored = "%sEXPIRED%s" % (yellow, off)
74
+ info = "idle %s (re-auth)" % fmt_age(idle)
75
+ else:
76
+ colored = "%sBOUND%s" % (green, off)
77
+ info = "idle %s / %s left" % (fmt_age(idle), fmt_age(IDLE_TIMEOUT - idle))
78
+ else:
79
+ colored = "%sUNBOUND%s" % (dim, off)
80
+ info = "awaiting approval" if sid in pending else "registered, not bound"
81
+ short = sid if len(sid) <= 14 else sid[:11] + "..."
82
+ print("%-14s %-20s %-8s %-20s %s%s%s"
83
+ % (short, vault, tier, colored, dim, info, off))
84
+ print()
85
+ print("%sBOUND = can use the vault's secrets now. `sandbroker-lock` clears all.%s" % (dim, off))
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())