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.
- sandbroker-0.1.0/LICENSE +21 -0
- sandbroker-0.1.0/PKG-INFO +129 -0
- sandbroker-0.1.0/README.md +92 -0
- sandbroker-0.1.0/bin/sandbroker +300 -0
- sandbroker-0.1.0/bin/sandbroker-authz-check +90 -0
- sandbroker-0.1.0/bin/sandbroker-bridge +158 -0
- sandbroker-0.1.0/bin/sandbroker-check-auditor +325 -0
- sandbroker-0.1.0/bin/sandbroker-dashboard +308 -0
- sandbroker-0.1.0/bin/sandbroker-lock +47 -0
- sandbroker-0.1.0/bin/sandbroker-pushd +259 -0
- sandbroker-0.1.0/bin/sandbroker-session-init +41 -0
- sandbroker-0.1.0/bin/sandbroker-setup +28 -0
- sandbroker-0.1.0/bin/sandbroker-token-set +78 -0
- sandbroker-0.1.0/bin/sandbroker-vapid-init +105 -0
- sandbroker-0.1.0/bin/sandbroker-webauthn-enroll +342 -0
- sandbroker-0.1.0/pyproject.toml +120 -0
- sandbroker-0.1.0/sandbroker/backends/__init__.py +223 -0
- sandbroker-0.1.0/sandbroker/backends/onepassword.py +196 -0
- sandbroker-0.1.0/sandbroker/backends/registry.py +87 -0
- sandbroker-0.1.0/sandbroker/config.py +478 -0
- sandbroker-0.1.0/sandbroker/sandbrokerd.py +1780 -0
- sandbroker-0.1.0/sandbroker/setup.py +738 -0
- sandbroker-0.1.0/sandbroker/vaults.json +35 -0
- sandbroker-0.1.0/sandbroker/verbs.json +114 -0
- sandbroker-0.1.0/sandbroker/webauthn.py +615 -0
- sandbroker-0.1.0/sandbroker.egg-info/PKG-INFO +129 -0
- sandbroker-0.1.0/sandbroker.egg-info/SOURCES.txt +33 -0
- sandbroker-0.1.0/sandbroker.egg-info/dependency_links.txt +1 -0
- sandbroker-0.1.0/sandbroker.egg-info/entry_points.txt +2 -0
- sandbroker-0.1.0/sandbroker.egg-info/requires.txt +18 -0
- sandbroker-0.1.0/sandbroker.egg-info/top_level.txt +1 -0
- sandbroker-0.1.0/setup.cfg +4 -0
- sandbroker-0.1.0/tests/test_adversarial.py +489 -0
- sandbroker-0.1.0/tests/test_authz.py +198 -0
- sandbroker-0.1.0/tests/test_guarantee.py +303 -0
sandbroker-0.1.0/LICENSE
ADDED
|
@@ -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())
|