workspaceguard-cli 0.1.1__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.
- workspaceguard_cli-0.1.1/.gitignore +8 -0
- workspaceguard_cli-0.1.1/LICENSE +21 -0
- workspaceguard_cli-0.1.1/PKG-INFO +226 -0
- workspaceguard_cli-0.1.1/README.md +188 -0
- workspaceguard_cli-0.1.1/pyproject.toml +70 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/__init__.py +78 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/adapters/__init__.py +3 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/adapters/mock.py +39 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/circuit_breaker.py +65 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/cli.py +188 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/config.py +156 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/isolation_guard.py +158 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/log.py +43 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/namespace.py +21 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/py.typed +0 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/types.py +78 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/usage.py +149 -0
- workspaceguard_cli-0.1.1/src/workspaceguard/vault.py +130 -0
- workspaceguard_cli-0.1.1/tests/__init__.py +0 -0
- workspaceguard_cli-0.1.1/tests/test_cli.py +152 -0
- workspaceguard_cli-0.1.1/tests/test_isolation_guard.py +316 -0
- workspaceguard_cli-0.1.1/tests/test_usage.py +114 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rudrendu Paul
|
|
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,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workspaceguard-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Per-workspace usage metering and quota caps for shared self-hosted AI assistant deployments (Odysseus and compatible backends).
|
|
5
|
+
Project-URL: Homepage, https://github.com/RudrenduPaul/workspaceguard
|
|
6
|
+
Project-URL: Repository, https://github.com/RudrenduPaul/workspaceguard
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/RudrenduPaul/workspaceguard/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/RudrenduPaul/workspaceguard/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Documentation, https://github.com/RudrenduPaul/workspaceguard/blob/main/docs/getting-started.md
|
|
10
|
+
Project-URL: Author - Rudrendu Paul, https://github.com/RudrenduPaul
|
|
11
|
+
Project-URL: Author - Sourav Nandy, https://github.com/Sourav-nandy-ai
|
|
12
|
+
Author: Rudrendu Paul, Sourav Nandy
|
|
13
|
+
License-Expression: MIT
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Keywords: agent-native,ai-assistant,billing,cli,multi-tenant,odysseus,quota,reverse-proxy,self-hosted,usage-metering
|
|
16
|
+
Classifier: Development Status :: 3 - Alpha
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Security
|
|
28
|
+
Classifier: Topic :: System :: Systems Administration
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Requires-Dist: cryptography<46,>=42
|
|
31
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: build<2,>=1.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio<1,>=0.23; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest<9,>=7.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: twine<7,>=5.0; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# workspaceguard-cli (Python)
|
|
40
|
+
|
|
41
|
+
Per-workspace usage metering and quota caps for one shared self-hosted AI
|
|
42
|
+
assistant deployment -- a genuine, independent Python port of the
|
|
43
|
+
[`workspaceguard-cli`](https://github.com/RudrenduPaul/workspaceguard) npm
|
|
44
|
+
package, not a wrapper around a Node binary.
|
|
45
|
+
|
|
46
|
+
[](https://pypi.org/project/workspaceguard-cli/)
|
|
47
|
+
[](https://github.com/RudrenduPaul/workspaceguard/blob/main/LICENSE)
|
|
48
|
+
[](https://pypi.org/project/workspaceguard-cli/)
|
|
49
|
+
|
|
50
|
+
## Why this exists
|
|
51
|
+
|
|
52
|
+
Run [Odysseus](https://github.com/pewdiepie-archdaemon/odysseus) (or a
|
|
53
|
+
compatible self-hosted assistant) for your whole household or small team,
|
|
54
|
+
and there is no way to see who sent how many messages this month, or to
|
|
55
|
+
stop one person's usage from burning through everyone else's API budget.
|
|
56
|
+
WorkspaceGuard is a thin sidecar that adds that layer: per-workspace
|
|
57
|
+
message counts, optional monthly caps that fail closed, and a CLI report
|
|
58
|
+
an admin (or another agent) can read.
|
|
59
|
+
|
|
60
|
+
This project originally set out to add per-user workspace isolation
|
|
61
|
+
(separate chat history, memory, API keys) to a self-hosted AI chat
|
|
62
|
+
platform. A feasibility spike found that premise was false for the target
|
|
63
|
+
platform's current default configuration: per-user ownership on chat
|
|
64
|
+
history, memory, and API tokens is already enforced by default. Rather
|
|
65
|
+
than ship a competing reimplementation of something the target platform
|
|
66
|
+
already does correctly, this project keeps its tested isolation engine
|
|
67
|
+
(namespace separation, an AES-256-GCM vault with real key rotation,
|
|
68
|
+
fail-closed identity resolution, a self-healing circuit breaker) as the
|
|
69
|
+
identity-resolution substrate, and builds the layer that platform
|
|
70
|
+
genuinely does not have: usage metering and quota enforcement per
|
|
71
|
+
workspace. See the [project README](https://github.com/RudrenduPaul/workspaceguard#readme)
|
|
72
|
+
for the full story.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install workspaceguard-cli
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Current status**: this Python port is fully built, tested (32/32 pytest
|
|
81
|
+
tests passing), and packaged -- the wheel and sdist are built and verified,
|
|
82
|
+
installing and running correctly end to end in a fresh virtual environment.
|
|
83
|
+
The first PyPI publish is pending: PyPI's own account-level throttle on
|
|
84
|
+
registering brand-new project names ("429 Too many new projects created")
|
|
85
|
+
is currently blocking the upload, unrelated to any 2FA requirement (this
|
|
86
|
+
account's PyPI publishing needs no human 2FA). It will publish as soon as
|
|
87
|
+
that throttle window clears. Separately, the npm package (`workspaceguard-cli`)
|
|
88
|
+
is built with CI green but not yet published to the npm registry either
|
|
89
|
+
(blocked on a manual 2FA-gated publish step). Neither `pip install
|
|
90
|
+
workspaceguard-cli` nor `npm install -g workspaceguard-cli` is live yet as
|
|
91
|
+
of this writing -- check [PyPI](https://pypi.org/project/workspaceguard-cli/)
|
|
92
|
+
for current availability. This package is a genuine, independent port --
|
|
93
|
+
not a wrapper around the npm package -- so it will work regardless of npm's
|
|
94
|
+
status once it publishes.
|
|
95
|
+
|
|
96
|
+
## Quickstart
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Register the workspaces sharing one deployment (identity = the header
|
|
100
|
+
# value your reverse proxy sets after authenticating, e.g. Cloudflare
|
|
101
|
+
# Access).
|
|
102
|
+
workspaceguard add-workspace alex --identity alex@example.com
|
|
103
|
+
workspaceguard add-workspace jordan --identity jordan@example.com
|
|
104
|
+
|
|
105
|
+
# Optional: cap alex at 1000 messages/month. Omit for unlimited (the default).
|
|
106
|
+
workspaceguard set-cap alex 1000
|
|
107
|
+
|
|
108
|
+
# See usage for every workspace.
|
|
109
|
+
workspaceguard usage
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
$ workspaceguard usage --json
|
|
114
|
+
{"ok": true, "usage": [{"workspaceId": "alex", "identity": "alex@example.com", "monthlyMessageCap": 1000, "percentUsed": 0, "period": "2026-07", "messageCount": 0, "estimatedBytes": 0}]}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## CLI reference
|
|
118
|
+
|
|
119
|
+
Every command accepts `--json` for a structured, agent-native output shape
|
|
120
|
+
instead of the human-readable text shown below. Identical command surface
|
|
121
|
+
to the npm CLI.
|
|
122
|
+
|
|
123
|
+
| Command | What it does |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `workspaceguard init` | Initializes the data directory and vault for this deployment. |
|
|
126
|
+
| `workspaceguard add-workspace <id> --identity <value>` | Registers a workspace, idempotent on repeat calls for the same id. |
|
|
127
|
+
| `workspaceguard status [--json]` | Lists configured workspaces. |
|
|
128
|
+
| `workspaceguard usage [--json]` | Per-workspace message count, cap, and percent-used for the current month. |
|
|
129
|
+
| `workspaceguard set-cap <id> <count\|none>` | Sets or clears a workspace's monthly message cap. |
|
|
130
|
+
| `workspaceguard rotate-key <id>` | Rotates a workspace's vault encryption key (invalidates the old ciphertext). |
|
|
131
|
+
| `workspaceguard scan [--json]` | Isolation config scan (scaffold stub, carried over from the original build). |
|
|
132
|
+
|
|
133
|
+
## Library API
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import asyncio
|
|
137
|
+
from workspaceguard import create_workspace_guard, MockAdapter, QuotaExceededError
|
|
138
|
+
|
|
139
|
+
async def main():
|
|
140
|
+
guard = await create_workspace_guard(data_dir="./data", backend=MockAdapter())
|
|
141
|
+
await guard.add_workspace("alex", "alex@example.com")
|
|
142
|
+
await guard.set_cap("alex", 1000)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
await guard.chat("alex@example.com", "hello")
|
|
146
|
+
except QuotaExceededError:
|
|
147
|
+
pass # alex is over their monthly cap
|
|
148
|
+
|
|
149
|
+
report = await guard.usage_report()
|
|
150
|
+
|
|
151
|
+
asyncio.run(main())
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The library API is async (`asyncio`), matching the async architecture of
|
|
155
|
+
the original TypeScript source rather than flattening it to synchronous
|
|
156
|
+
calls.
|
|
157
|
+
|
|
158
|
+
## How it works
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
target: an inbound chat request with an identity header value
|
|
162
|
+
-> resolve_workspace() -- fail closed on any miss, never a default workspace
|
|
163
|
+
-> check_quota() -- QuotaExceededError if the workspace is at its cap
|
|
164
|
+
-> circuit breaker -- calls the BackendAdapter, opens after 3 consecutive
|
|
165
|
+
failures, self-heals via a half-open probe after a cooldown
|
|
166
|
+
-> record usage -- per-workspace, per-month counters with automatic
|
|
167
|
+
period rollover
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `workspaceguard/isolation_guard.py` -- the single choke point (`chat()`)
|
|
171
|
+
every request flows through: resolve workspace -> check quota -> call
|
|
172
|
+
backend -> record usage.
|
|
173
|
+
- `workspaceguard/usage.py` -- the usage-metering engine this project adds:
|
|
174
|
+
per-workspace, per-month counters with automatic period rollover, and
|
|
175
|
+
`QuotaExceededError` enforcement.
|
|
176
|
+
- `workspaceguard/vault.py`, `workspaceguard/namespace.py`,
|
|
177
|
+
`workspaceguard/circuit_breaker.py` -- the original isolation-engine
|
|
178
|
+
code, kept as the identity/workspace-boundary substrate the metering
|
|
179
|
+
layer reads from, not shipped as a competing isolation product.
|
|
180
|
+
- `workspaceguard/adapters/` -- `BackendAdapter` abstract base class; a
|
|
181
|
+
real Odysseus HTTP adapter is the next step (currently `MockAdapter`
|
|
182
|
+
only, same as the TypeScript original).
|
|
183
|
+
|
|
184
|
+
Backend-specific behavior never enters the core modules directly --
|
|
185
|
+
everything goes through `BackendAdapter`.
|
|
186
|
+
|
|
187
|
+
## Trust boundary
|
|
188
|
+
|
|
189
|
+
WorkspaceGuard trusts an upstream identity header (default:
|
|
190
|
+
`Cf-Access-Authenticated-User-Email`) to resolve the workspace. It must
|
|
191
|
+
never be directly reachable from the network -- only from behind whatever
|
|
192
|
+
trusted proxy sets that header (Cloudflare Access, Tailscale, etc.). This
|
|
193
|
+
is documented, not code-enforced.
|
|
194
|
+
|
|
195
|
+
## What's real vs. not yet built
|
|
196
|
+
|
|
197
|
+
- Real, tested (28/28 pytest tests passing): usage metering, quota
|
|
198
|
+
enforcement, the original isolation engine (vault, namespace separation,
|
|
199
|
+
circuit breaker), CLI with `--json` mode.
|
|
200
|
+
- Not yet built: a real Odysseus HTTP adapter (only `MockAdapter` exists so
|
|
201
|
+
far, same as the TypeScript original), a hosted managed billing
|
|
202
|
+
dashboard (deliberately out of scope for this MIT project).
|
|
203
|
+
|
|
204
|
+
## Security
|
|
205
|
+
|
|
206
|
+
The vault uses AES-256-GCM (via the `cryptography` package) with one
|
|
207
|
+
derived key per workspace per generation -- rotating a workspace's key
|
|
208
|
+
increments its generation, so old ciphertext genuinely can no longer be
|
|
209
|
+
decrypted, not a no-op. See
|
|
210
|
+
[SECURITY.md](https://github.com/RudrenduPaul/workspaceguard/blob/main/SECURITY.md)
|
|
211
|
+
for the disclosure process and what's in/out of scope.
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
See [CONTRIBUTING.md](https://github.com/RudrenduPaul/workspaceguard/blob/main/CONTRIBUTING.md).
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
cd python
|
|
219
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
220
|
+
pip install -e ".[dev]"
|
|
221
|
+
pytest
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT, see [LICENSE](https://github.com/RudrenduPaul/workspaceguard/blob/main/LICENSE).
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# workspaceguard-cli (Python)
|
|
2
|
+
|
|
3
|
+
Per-workspace usage metering and quota caps for one shared self-hosted AI
|
|
4
|
+
assistant deployment -- a genuine, independent Python port of the
|
|
5
|
+
[`workspaceguard-cli`](https://github.com/RudrenduPaul/workspaceguard) npm
|
|
6
|
+
package, not a wrapper around a Node binary.
|
|
7
|
+
|
|
8
|
+
[](https://pypi.org/project/workspaceguard-cli/)
|
|
9
|
+
[](https://github.com/RudrenduPaul/workspaceguard/blob/main/LICENSE)
|
|
10
|
+
[](https://pypi.org/project/workspaceguard-cli/)
|
|
11
|
+
|
|
12
|
+
## Why this exists
|
|
13
|
+
|
|
14
|
+
Run [Odysseus](https://github.com/pewdiepie-archdaemon/odysseus) (or a
|
|
15
|
+
compatible self-hosted assistant) for your whole household or small team,
|
|
16
|
+
and there is no way to see who sent how many messages this month, or to
|
|
17
|
+
stop one person's usage from burning through everyone else's API budget.
|
|
18
|
+
WorkspaceGuard is a thin sidecar that adds that layer: per-workspace
|
|
19
|
+
message counts, optional monthly caps that fail closed, and a CLI report
|
|
20
|
+
an admin (or another agent) can read.
|
|
21
|
+
|
|
22
|
+
This project originally set out to add per-user workspace isolation
|
|
23
|
+
(separate chat history, memory, API keys) to a self-hosted AI chat
|
|
24
|
+
platform. A feasibility spike found that premise was false for the target
|
|
25
|
+
platform's current default configuration: per-user ownership on chat
|
|
26
|
+
history, memory, and API tokens is already enforced by default. Rather
|
|
27
|
+
than ship a competing reimplementation of something the target platform
|
|
28
|
+
already does correctly, this project keeps its tested isolation engine
|
|
29
|
+
(namespace separation, an AES-256-GCM vault with real key rotation,
|
|
30
|
+
fail-closed identity resolution, a self-healing circuit breaker) as the
|
|
31
|
+
identity-resolution substrate, and builds the layer that platform
|
|
32
|
+
genuinely does not have: usage metering and quota enforcement per
|
|
33
|
+
workspace. See the [project README](https://github.com/RudrenduPaul/workspaceguard#readme)
|
|
34
|
+
for the full story.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install workspaceguard-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Current status**: this Python port is fully built, tested (32/32 pytest
|
|
43
|
+
tests passing), and packaged -- the wheel and sdist are built and verified,
|
|
44
|
+
installing and running correctly end to end in a fresh virtual environment.
|
|
45
|
+
The first PyPI publish is pending: PyPI's own account-level throttle on
|
|
46
|
+
registering brand-new project names ("429 Too many new projects created")
|
|
47
|
+
is currently blocking the upload, unrelated to any 2FA requirement (this
|
|
48
|
+
account's PyPI publishing needs no human 2FA). It will publish as soon as
|
|
49
|
+
that throttle window clears. Separately, the npm package (`workspaceguard-cli`)
|
|
50
|
+
is built with CI green but not yet published to the npm registry either
|
|
51
|
+
(blocked on a manual 2FA-gated publish step). Neither `pip install
|
|
52
|
+
workspaceguard-cli` nor `npm install -g workspaceguard-cli` is live yet as
|
|
53
|
+
of this writing -- check [PyPI](https://pypi.org/project/workspaceguard-cli/)
|
|
54
|
+
for current availability. This package is a genuine, independent port --
|
|
55
|
+
not a wrapper around the npm package -- so it will work regardless of npm's
|
|
56
|
+
status once it publishes.
|
|
57
|
+
|
|
58
|
+
## Quickstart
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Register the workspaces sharing one deployment (identity = the header
|
|
62
|
+
# value your reverse proxy sets after authenticating, e.g. Cloudflare
|
|
63
|
+
# Access).
|
|
64
|
+
workspaceguard add-workspace alex --identity alex@example.com
|
|
65
|
+
workspaceguard add-workspace jordan --identity jordan@example.com
|
|
66
|
+
|
|
67
|
+
# Optional: cap alex at 1000 messages/month. Omit for unlimited (the default).
|
|
68
|
+
workspaceguard set-cap alex 1000
|
|
69
|
+
|
|
70
|
+
# See usage for every workspace.
|
|
71
|
+
workspaceguard usage
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
$ workspaceguard usage --json
|
|
76
|
+
{"ok": true, "usage": [{"workspaceId": "alex", "identity": "alex@example.com", "monthlyMessageCap": 1000, "percentUsed": 0, "period": "2026-07", "messageCount": 0, "estimatedBytes": 0}]}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## CLI reference
|
|
80
|
+
|
|
81
|
+
Every command accepts `--json` for a structured, agent-native output shape
|
|
82
|
+
instead of the human-readable text shown below. Identical command surface
|
|
83
|
+
to the npm CLI.
|
|
84
|
+
|
|
85
|
+
| Command | What it does |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `workspaceguard init` | Initializes the data directory and vault for this deployment. |
|
|
88
|
+
| `workspaceguard add-workspace <id> --identity <value>` | Registers a workspace, idempotent on repeat calls for the same id. |
|
|
89
|
+
| `workspaceguard status [--json]` | Lists configured workspaces. |
|
|
90
|
+
| `workspaceguard usage [--json]` | Per-workspace message count, cap, and percent-used for the current month. |
|
|
91
|
+
| `workspaceguard set-cap <id> <count\|none>` | Sets or clears a workspace's monthly message cap. |
|
|
92
|
+
| `workspaceguard rotate-key <id>` | Rotates a workspace's vault encryption key (invalidates the old ciphertext). |
|
|
93
|
+
| `workspaceguard scan [--json]` | Isolation config scan (scaffold stub, carried over from the original build). |
|
|
94
|
+
|
|
95
|
+
## Library API
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import asyncio
|
|
99
|
+
from workspaceguard import create_workspace_guard, MockAdapter, QuotaExceededError
|
|
100
|
+
|
|
101
|
+
async def main():
|
|
102
|
+
guard = await create_workspace_guard(data_dir="./data", backend=MockAdapter())
|
|
103
|
+
await guard.add_workspace("alex", "alex@example.com")
|
|
104
|
+
await guard.set_cap("alex", 1000)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
await guard.chat("alex@example.com", "hello")
|
|
108
|
+
except QuotaExceededError:
|
|
109
|
+
pass # alex is over their monthly cap
|
|
110
|
+
|
|
111
|
+
report = await guard.usage_report()
|
|
112
|
+
|
|
113
|
+
asyncio.run(main())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The library API is async (`asyncio`), matching the async architecture of
|
|
117
|
+
the original TypeScript source rather than flattening it to synchronous
|
|
118
|
+
calls.
|
|
119
|
+
|
|
120
|
+
## How it works
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
target: an inbound chat request with an identity header value
|
|
124
|
+
-> resolve_workspace() -- fail closed on any miss, never a default workspace
|
|
125
|
+
-> check_quota() -- QuotaExceededError if the workspace is at its cap
|
|
126
|
+
-> circuit breaker -- calls the BackendAdapter, opens after 3 consecutive
|
|
127
|
+
failures, self-heals via a half-open probe after a cooldown
|
|
128
|
+
-> record usage -- per-workspace, per-month counters with automatic
|
|
129
|
+
period rollover
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `workspaceguard/isolation_guard.py` -- the single choke point (`chat()`)
|
|
133
|
+
every request flows through: resolve workspace -> check quota -> call
|
|
134
|
+
backend -> record usage.
|
|
135
|
+
- `workspaceguard/usage.py` -- the usage-metering engine this project adds:
|
|
136
|
+
per-workspace, per-month counters with automatic period rollover, and
|
|
137
|
+
`QuotaExceededError` enforcement.
|
|
138
|
+
- `workspaceguard/vault.py`, `workspaceguard/namespace.py`,
|
|
139
|
+
`workspaceguard/circuit_breaker.py` -- the original isolation-engine
|
|
140
|
+
code, kept as the identity/workspace-boundary substrate the metering
|
|
141
|
+
layer reads from, not shipped as a competing isolation product.
|
|
142
|
+
- `workspaceguard/adapters/` -- `BackendAdapter` abstract base class; a
|
|
143
|
+
real Odysseus HTTP adapter is the next step (currently `MockAdapter`
|
|
144
|
+
only, same as the TypeScript original).
|
|
145
|
+
|
|
146
|
+
Backend-specific behavior never enters the core modules directly --
|
|
147
|
+
everything goes through `BackendAdapter`.
|
|
148
|
+
|
|
149
|
+
## Trust boundary
|
|
150
|
+
|
|
151
|
+
WorkspaceGuard trusts an upstream identity header (default:
|
|
152
|
+
`Cf-Access-Authenticated-User-Email`) to resolve the workspace. It must
|
|
153
|
+
never be directly reachable from the network -- only from behind whatever
|
|
154
|
+
trusted proxy sets that header (Cloudflare Access, Tailscale, etc.). This
|
|
155
|
+
is documented, not code-enforced.
|
|
156
|
+
|
|
157
|
+
## What's real vs. not yet built
|
|
158
|
+
|
|
159
|
+
- Real, tested (28/28 pytest tests passing): usage metering, quota
|
|
160
|
+
enforcement, the original isolation engine (vault, namespace separation,
|
|
161
|
+
circuit breaker), CLI with `--json` mode.
|
|
162
|
+
- Not yet built: a real Odysseus HTTP adapter (only `MockAdapter` exists so
|
|
163
|
+
far, same as the TypeScript original), a hosted managed billing
|
|
164
|
+
dashboard (deliberately out of scope for this MIT project).
|
|
165
|
+
|
|
166
|
+
## Security
|
|
167
|
+
|
|
168
|
+
The vault uses AES-256-GCM (via the `cryptography` package) with one
|
|
169
|
+
derived key per workspace per generation -- rotating a workspace's key
|
|
170
|
+
increments its generation, so old ciphertext genuinely can no longer be
|
|
171
|
+
decrypted, not a no-op. See
|
|
172
|
+
[SECURITY.md](https://github.com/RudrenduPaul/workspaceguard/blob/main/SECURITY.md)
|
|
173
|
+
for the disclosure process and what's in/out of scope.
|
|
174
|
+
|
|
175
|
+
## Contributing
|
|
176
|
+
|
|
177
|
+
See [CONTRIBUTING.md](https://github.com/RudrenduPaul/workspaceguard/blob/main/CONTRIBUTING.md).
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
cd python
|
|
181
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
pytest
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT, see [LICENSE](https://github.com/RudrenduPaul/workspaceguard/blob/main/LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "workspaceguard-cli"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Per-workspace usage metering and quota caps for shared self-hosted AI assistant deployments (Odysseus and compatible backends)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Rudrendu Paul" },
|
|
14
|
+
{ name = "Sourav Nandy" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["self-hosted", "ai-assistant", "usage-metering", "quota", "billing", "multi-tenant", "reverse-proxy", "odysseus", "cli", "agent-native"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Security",
|
|
30
|
+
"Topic :: System :: Systems Administration",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"PyYAML>=6.0,<7",
|
|
34
|
+
"cryptography>=42,<46",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7.0,<9",
|
|
40
|
+
"pytest-asyncio>=0.23,<1",
|
|
41
|
+
"build>=1.0,<2",
|
|
42
|
+
"twine>=5.0,<7",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/RudrenduPaul/workspaceguard"
|
|
47
|
+
Repository = "https://github.com/RudrenduPaul/workspaceguard"
|
|
48
|
+
"Bug Tracker" = "https://github.com/RudrenduPaul/workspaceguard/issues"
|
|
49
|
+
Changelog = "https://github.com/RudrenduPaul/workspaceguard/blob/main/CHANGELOG.md"
|
|
50
|
+
Documentation = "https://github.com/RudrenduPaul/workspaceguard/blob/main/docs/getting-started.md"
|
|
51
|
+
"Author - Rudrendu Paul" = "https://github.com/RudrenduPaul"
|
|
52
|
+
"Author - Sourav Nandy" = "https://github.com/Sourav-nandy-ai"
|
|
53
|
+
|
|
54
|
+
[project.scripts]
|
|
55
|
+
workspaceguard = "workspaceguard.cli:main"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/workspaceguard"]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = [
|
|
62
|
+
"/src/workspaceguard",
|
|
63
|
+
"/tests",
|
|
64
|
+
"/README.md",
|
|
65
|
+
"/LICENSE",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Programmatic / agent-native entry point.
|
|
3
|
+
|
|
4
|
+
from workspaceguard import create_workspace_guard, MockAdapter
|
|
5
|
+
|
|
6
|
+
guard = await create_workspace_guard(data_dir="./data", backend=MockAdapter())
|
|
7
|
+
await guard.add_workspace("alex", "alex@example.com")
|
|
8
|
+
await guard.set_cap("alex", 1000)
|
|
9
|
+
report = await guard.usage_report()
|
|
10
|
+
|
|
11
|
+
Same core isolation/metering logic as the `workspaceguard` CLI;
|
|
12
|
+
workspaceguard/cli.py is a thin argument-parsing wrapper over this class.
|
|
13
|
+
|
|
14
|
+
This is the Python port of the workspaceguard-cli npm package
|
|
15
|
+
(https://www.npmjs.com/package/workspaceguard-cli, not yet published as of
|
|
16
|
+
this port -- see README for current npm status). See
|
|
17
|
+
https://github.com/RudrenduPaul/workspaceguard for the canonical
|
|
18
|
+
documentation and the original TypeScript source.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .adapters.mock import MockAdapter
|
|
25
|
+
from .config import DuplicateIdentityError, InvalidWorkspaceIdError
|
|
26
|
+
from .isolation_guard import IsolationGuard, WorkspaceUsageReport
|
|
27
|
+
from .types import (
|
|
28
|
+
BackendAdapter,
|
|
29
|
+
BackendCircuitOpenError,
|
|
30
|
+
BackendUnreachableError,
|
|
31
|
+
IdentityNotFoundError,
|
|
32
|
+
VaultDecryptionError,
|
|
33
|
+
WorkspaceNotFoundError,
|
|
34
|
+
)
|
|
35
|
+
from .usage import QuotaExceededError, WorkspaceUsage
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def create_workspace_guard(
|
|
41
|
+
data_dir: str,
|
|
42
|
+
backend: BackendAdapter,
|
|
43
|
+
circuit_open_cooldown_ms: Optional[int] = None,
|
|
44
|
+
) -> IsolationGuard:
|
|
45
|
+
"""
|
|
46
|
+
The library entry point -- the "agent-native" surface. An orchestrator
|
|
47
|
+
process can call this directly instead of shelling out to the CLI. No
|
|
48
|
+
default backend -- a caller must be explicit about which adapter it
|
|
49
|
+
wants (the real Odysseus adapter ships once the feasibility spike
|
|
50
|
+
confirms clean HTTP interception; MockAdapter is exported for tests and
|
|
51
|
+
local experimentation).
|
|
52
|
+
"""
|
|
53
|
+
guard = IsolationGuard(
|
|
54
|
+
data_dir=data_dir,
|
|
55
|
+
backend=backend,
|
|
56
|
+
circuit_open_cooldown_ms=circuit_open_cooldown_ms,
|
|
57
|
+
)
|
|
58
|
+
await guard.init()
|
|
59
|
+
return guard
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"create_workspace_guard",
|
|
64
|
+
"IsolationGuard",
|
|
65
|
+
"WorkspaceUsageReport",
|
|
66
|
+
"BackendAdapter",
|
|
67
|
+
"MockAdapter",
|
|
68
|
+
"IdentityNotFoundError",
|
|
69
|
+
"VaultDecryptionError",
|
|
70
|
+
"BackendUnreachableError",
|
|
71
|
+
"BackendCircuitOpenError",
|
|
72
|
+
"WorkspaceNotFoundError",
|
|
73
|
+
"DuplicateIdentityError",
|
|
74
|
+
"InvalidWorkspaceIdError",
|
|
75
|
+
"QuotaExceededError",
|
|
76
|
+
"WorkspaceUsage",
|
|
77
|
+
"__version__",
|
|
78
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory mock backend, used for the scaffold-stage isolation tests only.
|
|
3
|
+
Ported from src/adapters/mock.ts. The real Odysseus adapter is blocked on a
|
|
4
|
+
feasibility spike: does Odysseus's HTTP API expose clean interception
|
|
5
|
+
points, or does it read/write some of this directly from disk/DB in a way
|
|
6
|
+
an HTTP-level proxy can't see? See docs/integrations/backends.md.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from ..types import BackendAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MockAdapter(BackendAdapter):
|
|
16
|
+
name = "mock"
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._messages_by_workspace: Dict[str, List[str]] = {}
|
|
20
|
+
self._failures_remaining = 0
|
|
21
|
+
|
|
22
|
+
async def health_check(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
async def forward_chat(self, workspace_id: str, message: str) -> str:
|
|
26
|
+
if self._failures_remaining > 0:
|
|
27
|
+
self._failures_remaining -= 1
|
|
28
|
+
raise RuntimeError("simulated backend failure")
|
|
29
|
+
existing = self._messages_by_workspace.setdefault(workspace_id, [])
|
|
30
|
+
existing.append(message)
|
|
31
|
+
return f"echo: {message}"
|
|
32
|
+
|
|
33
|
+
def _fail_next_calls(self, n: int) -> None:
|
|
34
|
+
"""Test-only: makes the next N forward_chat calls fail, to exercise the circuit breaker."""
|
|
35
|
+
self._failures_remaining = n
|
|
36
|
+
|
|
37
|
+
def _messages_for(self, workspace_id: str) -> List[str]:
|
|
38
|
+
"""Test-only inspection point -- never exposed through the public adapter interface."""
|
|
39
|
+
return self._messages_by_workspace.get(workspace_id, [])
|