workspaceguard-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workspaceguard/__init__.py +78 -0
- workspaceguard/adapters/__init__.py +3 -0
- workspaceguard/adapters/mock.py +39 -0
- workspaceguard/circuit_breaker.py +65 -0
- workspaceguard/cli.py +188 -0
- workspaceguard/config.py +156 -0
- workspaceguard/isolation_guard.py +158 -0
- workspaceguard/log.py +43 -0
- workspaceguard/namespace.py +21 -0
- workspaceguard/py.typed +0 -0
- workspaceguard/types.py +78 -0
- workspaceguard/usage.py +149 -0
- workspaceguard/vault.py +130 -0
- workspaceguard_cli-0.1.1.dist-info/METADATA +226 -0
- workspaceguard_cli-0.1.1.dist-info/RECORD +18 -0
- workspaceguard_cli-0.1.1.dist-info/WHEEL +4 -0
- workspaceguard_cli-0.1.1.dist-info/entry_points.txt +2 -0
- workspaceguard_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core isolation + metering logic. Ported from src/core/isolation-guard.ts.
|
|
3
|
+
Never imports a specific backend directly -- all backend-specific behavior
|
|
4
|
+
goes through the BackendAdapter interface, a fixed architectural boundary.
|
|
5
|
+
The CLI and the library export are both thin wrappers around this class;
|
|
6
|
+
there is exactly one implementation of the isolation/metering logic.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import DefaultDict, List, Optional
|
|
16
|
+
|
|
17
|
+
from .circuit_breaker import CircuitBreaker
|
|
18
|
+
from .config import load_config, resolve_workspace_id, save_config, set_workspace_cap, upsert_workspace
|
|
19
|
+
from .log import ConsoleLogger, FailClosedEvent, Logger
|
|
20
|
+
from .namespace import ensure_workspace_dirs
|
|
21
|
+
from .types import BackendAdapter, IdentityNotFoundError, WorkspaceGuardConfig
|
|
22
|
+
from .usage import UsageMeter, WorkspaceUsage
|
|
23
|
+
from .vault import Vault
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class WorkspaceUsageReport:
|
|
28
|
+
workspace_id: str
|
|
29
|
+
identity: str
|
|
30
|
+
monthly_message_cap: Optional[int]
|
|
31
|
+
percent_used: Optional[int]
|
|
32
|
+
period: str
|
|
33
|
+
message_count: int
|
|
34
|
+
estimated_bytes: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IsolationGuard:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
data_dir: str,
|
|
41
|
+
backend: BackendAdapter,
|
|
42
|
+
logger: Optional[Logger] = None,
|
|
43
|
+
circuit_open_cooldown_ms: Optional[int] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._data_dir = data_dir
|
|
46
|
+
self._backend = backend
|
|
47
|
+
self._logger = logger or ConsoleLogger()
|
|
48
|
+
self._vault = Vault(os.path.join(self._data_dir, ".workspaceguard", "master.key"))
|
|
49
|
+
kwargs = {} if circuit_open_cooldown_ms is None else {"open_cooldown_ms": circuit_open_cooldown_ms}
|
|
50
|
+
self._circuit = CircuitBreaker(backend.name, self._logger, **kwargs)
|
|
51
|
+
self._usage = UsageMeter(self._data_dir)
|
|
52
|
+
self._config = WorkspaceGuardConfig(backend="odysseus", identity_header="", workspaces=[])
|
|
53
|
+
# Per-workspace lock so a concurrent quota check-then-record can't
|
|
54
|
+
# race across two calls to chat() for the same workspace (in-process
|
|
55
|
+
# only, matching this project's documented single-sidecar-process
|
|
56
|
+
# deployment model -- see usage.py).
|
|
57
|
+
self._workspace_locks: DefaultDict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _config_path(self) -> str:
|
|
61
|
+
return os.path.join(self._data_dir, "workspaceguard.config.yaml")
|
|
62
|
+
|
|
63
|
+
async def init(self) -> None:
|
|
64
|
+
await self._vault.init()
|
|
65
|
+
self._config = load_config(self._config_path)
|
|
66
|
+
|
|
67
|
+
async def add_workspace(self, workspace_id: str, identity: str) -> None:
|
|
68
|
+
self._config = upsert_workspace(self._config, workspace_id, identity)
|
|
69
|
+
save_config(self._config_path, self._config)
|
|
70
|
+
await ensure_workspace_dirs(self._data_dir, workspace_id)
|
|
71
|
+
|
|
72
|
+
async def set_secret(self, workspace_id: str, secret: str) -> None:
|
|
73
|
+
await self._vault.write_secret(self._data_dir, workspace_id, secret)
|
|
74
|
+
|
|
75
|
+
async def rotate_key(self, workspace_id: str) -> None:
|
|
76
|
+
await self._vault.rotate(self._data_dir, workspace_id)
|
|
77
|
+
|
|
78
|
+
def resolve_workspace(self, identity_header_value: Optional[str]) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Resolves a workspace from an untrusted identity header value. Fails
|
|
81
|
+
closed on any miss -- never falls back to a default workspace. This
|
|
82
|
+
is the single choke point every request-handling path must go
|
|
83
|
+
through.
|
|
84
|
+
"""
|
|
85
|
+
workspace_id = resolve_workspace_id(self._config, identity_header_value)
|
|
86
|
+
if not workspace_id:
|
|
87
|
+
self._logger.log(
|
|
88
|
+
FailClosedEvent(
|
|
89
|
+
reason="identity_not_found",
|
|
90
|
+
detail={"identityHeaderValue": identity_header_value},
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
raise IdentityNotFoundError(identity_header_value)
|
|
94
|
+
return workspace_id
|
|
95
|
+
|
|
96
|
+
async def chat(self, identity_header_value: Optional[str], message: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
The quota check, backend call, and usage record are wrapped in a
|
|
99
|
+
single per-workspace lock so two concurrent chat() calls for the
|
|
100
|
+
same workspace can never both pass the same pre-cap quota check
|
|
101
|
+
(regression: previously unlocked, letting N concurrent requests at
|
|
102
|
+
cap-1 all read the same count and all proceed, and letting
|
|
103
|
+
concurrent record() calls lose updates to each other). Different
|
|
104
|
+
workspaces never contend with each other.
|
|
105
|
+
"""
|
|
106
|
+
workspace_id = self.resolve_workspace(identity_header_value)
|
|
107
|
+
entry = next((w for w in self._config.workspaces if w.workspace_id == workspace_id), None)
|
|
108
|
+
cap = entry.monthly_message_cap if entry is not None else None
|
|
109
|
+
|
|
110
|
+
async with self._workspace_locks[workspace_id]:
|
|
111
|
+
await self._usage.check_quota(workspace_id, cap)
|
|
112
|
+
|
|
113
|
+
async def _call() -> str:
|
|
114
|
+
return await self._backend.forward_chat(workspace_id, message)
|
|
115
|
+
|
|
116
|
+
response = await self._circuit.call(_call)
|
|
117
|
+
await self._usage.record(workspace_id, message)
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
async def status(self) -> List[dict]:
|
|
121
|
+
return [{"workspaceId": w.workspace_id, "identity": w.identity} for w in self._config.workspaces]
|
|
122
|
+
|
|
123
|
+
async def set_cap(self, workspace_id: str, cap: Optional[int]) -> None:
|
|
124
|
+
self._config = set_workspace_cap(self._config, workspace_id, cap)
|
|
125
|
+
save_config(self._config_path, self._config)
|
|
126
|
+
|
|
127
|
+
async def usage_report(self) -> List[WorkspaceUsageReport]:
|
|
128
|
+
"""
|
|
129
|
+
The admin-visibility surface this project actually ships (the OSS
|
|
130
|
+
free tier) -- a hosted billing dashboard on top of this data is a
|
|
131
|
+
separate, closed-source product, never built in this repo.
|
|
132
|
+
"""
|
|
133
|
+
ids = [w.workspace_id for w in self._config.workspaces]
|
|
134
|
+
usage_by_id = await self._usage.get_all(ids)
|
|
135
|
+
reports: List[WorkspaceUsageReport] = []
|
|
136
|
+
for w in self._config.workspaces:
|
|
137
|
+
usage = usage_by_id.get(w.workspace_id) or WorkspaceUsage(period="", message_count=0, estimated_bytes=0)
|
|
138
|
+
percent_used: Optional[int] = None
|
|
139
|
+
if w.monthly_message_cap is not None and w.monthly_message_cap > 0:
|
|
140
|
+
# math.floor(x + 0.5), not the builtin round(): Python's
|
|
141
|
+
# round() uses banker's rounding (round-half-to-even) while
|
|
142
|
+
# the TS original's Math.round() rounds half-up -- this
|
|
143
|
+
# kept percentUsed diverging between the two --json outputs
|
|
144
|
+
# for exact-half cases (e.g. 12.5% -> 12 vs 13) despite both
|
|
145
|
+
# READMEs documenting the outputs as identical.
|
|
146
|
+
percent_used = math.floor((usage.message_count / w.monthly_message_cap) * 100 + 0.5)
|
|
147
|
+
reports.append(
|
|
148
|
+
WorkspaceUsageReport(
|
|
149
|
+
workspace_id=w.workspace_id,
|
|
150
|
+
identity=w.identity,
|
|
151
|
+
monthly_message_cap=w.monthly_message_cap,
|
|
152
|
+
percent_used=percent_used,
|
|
153
|
+
period=usage.period,
|
|
154
|
+
message_count=usage.message_count,
|
|
155
|
+
estimated_bytes=usage.estimated_bytes,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return reports
|
workspaceguard/log.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured event logging. Ported from src/core/log.ts. Every fail-closed
|
|
3
|
+
decision and every circuit-breaker state change gets one JSON line -- the
|
|
4
|
+
only way the "zero cross-workspace leaks" claim is verifiable outside of
|
|
5
|
+
tests.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Dict, Literal, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FailClosedEvent:
|
|
19
|
+
reason: str
|
|
20
|
+
detail: Dict[str, Any]
|
|
21
|
+
type: Literal["fail_closed"] = "fail_closed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CircuitStateChangeEvent:
|
|
26
|
+
backend: str
|
|
27
|
+
state: Literal["open", "closed"]
|
|
28
|
+
type: Literal["circuit_state_change"] = "circuit_state_change"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
LogEvent = Any # FailClosedEvent | CircuitStateChangeEvent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Logger(ABC):
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def log(self, event: LogEvent) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConsoleLogger(Logger):
|
|
40
|
+
def log(self, event: LogEvent) -> None:
|
|
41
|
+
line: Dict[str, Any] = {"ts": datetime.now(timezone.utc).isoformat()}
|
|
42
|
+
line.update(vars(event))
|
|
43
|
+
print(json.dumps(line), file=sys.stdout)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chat history and memory get a directory boundary per workspace, not a
|
|
3
|
+
shared table/file with a workspace column -- easier to verify by inspection
|
|
4
|
+
than a query filter. Ported from src/core/namespace.ts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def chat_history_dir(data_dir: str, workspace_id: str) -> str:
|
|
12
|
+
return os.path.join(data_dir, "workspaces", workspace_id, "chat")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def memory_dir(data_dir: str, workspace_id: str) -> str:
|
|
16
|
+
return os.path.join(data_dir, "workspaces", workspace_id, "memory")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def ensure_workspace_dirs(data_dir: str, workspace_id: str) -> None:
|
|
20
|
+
os.makedirs(chat_history_dir(data_dir, workspace_id), exist_ok=True)
|
|
21
|
+
os.makedirs(memory_dir(data_dir, workspace_id), exist_ok=True)
|
workspaceguard/py.typed
ADDED
|
File without changes
|
workspaceguard/types.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared types for WorkspaceGuard's isolation and metering pipeline. Used by
|
|
3
|
+
both the CLI (workspaceguard/cli.py) and the programmatic library API
|
|
4
|
+
(workspaceguard/__init__.py).
|
|
5
|
+
|
|
6
|
+
This is a Python port of the TypeScript original's src/core/types.ts. Field
|
|
7
|
+
names follow Python (snake_case) convention; the config/usage persistence
|
|
8
|
+
layer (workspaceguard/config.py, workspaceguard/usage.py) re-serializes to
|
|
9
|
+
the same camelCase keys the npm package's YAML/JSON files use, so a
|
|
10
|
+
`workspaceguard.config.yaml` or `usage.json` written by one distribution
|
|
11
|
+
stays readable by the other.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class WorkspaceEntry:
|
|
22
|
+
workspace_id: str
|
|
23
|
+
identity: str
|
|
24
|
+
monthly_message_cap: Optional[int] = None
|
|
25
|
+
"""Monthly message cap for usage metering. None = unlimited (free-tier default)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class WorkspaceGuardConfig:
|
|
30
|
+
backend: str = "odysseus"
|
|
31
|
+
identity_header: str = ""
|
|
32
|
+
workspaces: List[WorkspaceEntry] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackendAdapter(ABC):
|
|
36
|
+
"""
|
|
37
|
+
Fixed architectural boundary: all backend-specific behavior goes through
|
|
38
|
+
this interface. Core isolation/metering logic never imports a specific
|
|
39
|
+
backend directly.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def health_check(self) -> bool: ...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def forward_chat(self, workspace_id: str, message: str) -> str: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IdentityNotFoundError(Exception):
|
|
52
|
+
def __init__(self, header_value: Optional[str]) -> None:
|
|
53
|
+
super().__init__("no workspace configured for this identity")
|
|
54
|
+
self.header_value = header_value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VaultDecryptionError(Exception):
|
|
58
|
+
def __init__(self, workspace_id: str) -> None:
|
|
59
|
+
super().__init__(f"could not decrypt vault for workspace {workspace_id}")
|
|
60
|
+
self.workspace_id = workspace_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BackendUnreachableError(Exception):
|
|
64
|
+
def __init__(self, backend: str) -> None:
|
|
65
|
+
super().__init__(f"backend {backend} did not respond")
|
|
66
|
+
self.backend = backend
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BackendCircuitOpenError(Exception):
|
|
70
|
+
def __init__(self, backend: str) -> None:
|
|
71
|
+
super().__init__(f"backend {backend} circuit is open, refusing calls")
|
|
72
|
+
self.backend = backend
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WorkspaceNotFoundError(Exception):
|
|
76
|
+
def __init__(self, workspace_id: str) -> None:
|
|
77
|
+
super().__init__(f'no workspace configured with id "{workspace_id}"')
|
|
78
|
+
self.workspace_id = workspace_id
|
workspaceguard/usage.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-workspace usage metering -- the wedge this project ships instead of
|
|
3
|
+
per-user isolation (already native in the target platform, see README).
|
|
4
|
+
Ported from src/core/usage.ts. Reads/writes are not lock-guarded against
|
|
5
|
+
concurrent processes, the same accepted tradeoff config.py already makes for
|
|
6
|
+
`workspaceguard.config.yaml` (single-sidecar-process deployment model).
|
|
7
|
+
|
|
8
|
+
The usage.json file uses camelCase keys (period, messageCount,
|
|
9
|
+
estimatedBytes) to stay wire-compatible with the npm package's own store.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
UsageStore = Dict[str, "WorkspaceUsage"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WorkspaceUsage:
|
|
24
|
+
period: str
|
|
25
|
+
message_count: int
|
|
26
|
+
estimated_bytes: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QuotaExceededError(Exception):
|
|
30
|
+
def __init__(self, workspace_id: str, cap: int) -> None:
|
|
31
|
+
super().__init__(f"workspace {workspace_id} has reached its monthly cap of {cap} messages")
|
|
32
|
+
self.workspace_id = workspace_id
|
|
33
|
+
self.cap = cap
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _current_period() -> str:
|
|
37
|
+
now = datetime.now(timezone.utc)
|
|
38
|
+
return f"{now.year}-{now.month:02d}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def usage_path(data_dir: str) -> str:
|
|
42
|
+
return os.path.join(data_dir, ".workspaceguard", "usage.json")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_usage_raw(data_dir: str) -> Dict[str, dict]:
|
|
46
|
+
"""
|
|
47
|
+
Only a missing file (no workspace has recorded usage yet) is a
|
|
48
|
+
legitimate empty store. Every other failure (permission error, disk
|
|
49
|
+
error, a corrupted/truncated JSON file) must propagate so a caller like
|
|
50
|
+
check_quota() fails closed instead of silently reading every
|
|
51
|
+
workspace's usage as zero -- a catch-everything here used to let any
|
|
52
|
+
I/O hiccup or a race-corrupted usage.json lift every quota in the
|
|
53
|
+
store.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
with open(usage_path(data_dir), "r", encoding="utf-8") as fh:
|
|
57
|
+
raw = fh.read()
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
parsed = json.loads(raw)
|
|
63
|
+
except json.JSONDecodeError as err:
|
|
64
|
+
raise ValueError(f"usage store at {usage_path(data_dir)} is corrupted and could not be parsed: {err}") from err
|
|
65
|
+
if not isinstance(parsed, dict):
|
|
66
|
+
raise ValueError(f"usage store at {usage_path(data_dir)} has an unexpected shape (expected a JSON object)")
|
|
67
|
+
return parsed
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_usage(data_dir: str) -> UsageStore:
|
|
71
|
+
raw = _load_usage_raw(data_dir)
|
|
72
|
+
store: UsageStore = {}
|
|
73
|
+
for workspace_id, entry in raw.items():
|
|
74
|
+
try:
|
|
75
|
+
store[workspace_id] = WorkspaceUsage(
|
|
76
|
+
period=entry["period"],
|
|
77
|
+
message_count=entry["messageCount"],
|
|
78
|
+
estimated_bytes=entry["estimatedBytes"],
|
|
79
|
+
)
|
|
80
|
+
except (KeyError, TypeError):
|
|
81
|
+
continue
|
|
82
|
+
return store
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_usage(data_dir: str, store: UsageStore) -> None:
|
|
86
|
+
path = usage_path(data_dir)
|
|
87
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
88
|
+
payload = {
|
|
89
|
+
workspace_id: {
|
|
90
|
+
"period": usage.period,
|
|
91
|
+
"messageCount": usage.message_count,
|
|
92
|
+
"estimatedBytes": usage.estimated_bytes,
|
|
93
|
+
}
|
|
94
|
+
for workspace_id, usage in store.items()
|
|
95
|
+
}
|
|
96
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
97
|
+
json.dump(payload, fh, indent=2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def current_usage_for(store: UsageStore, workspace_id: str) -> WorkspaceUsage:
|
|
101
|
+
"""
|
|
102
|
+
Rolls a workspace's counter over to a fresh period on read -- a stale
|
|
103
|
+
count from last month must never inflate this month's total or trip a
|
|
104
|
+
cap that shouldn't apply yet (no cron/scheduler needed, since every real
|
|
105
|
+
read/write already knows what period it is).
|
|
106
|
+
"""
|
|
107
|
+
existing = store.get(workspace_id)
|
|
108
|
+
period = _current_period()
|
|
109
|
+
if existing is None or existing.period != period:
|
|
110
|
+
return WorkspaceUsage(period=period, message_count=0, estimated_bytes=0)
|
|
111
|
+
return existing
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UsageMeter:
|
|
115
|
+
def __init__(self, data_dir: str) -> None:
|
|
116
|
+
self.data_dir = data_dir
|
|
117
|
+
|
|
118
|
+
async def record(self, workspace_id: str, message: str) -> WorkspaceUsage:
|
|
119
|
+
store = load_usage(self.data_dir)
|
|
120
|
+
usage = current_usage_for(store, workspace_id)
|
|
121
|
+
usage = WorkspaceUsage(
|
|
122
|
+
period=usage.period,
|
|
123
|
+
message_count=usage.message_count + 1,
|
|
124
|
+
estimated_bytes=usage.estimated_bytes + len(message.encode("utf-8")),
|
|
125
|
+
)
|
|
126
|
+
store[workspace_id] = usage
|
|
127
|
+
save_usage(self.data_dir, store)
|
|
128
|
+
return usage
|
|
129
|
+
|
|
130
|
+
async def get(self, workspace_id: str) -> WorkspaceUsage:
|
|
131
|
+
store = load_usage(self.data_dir)
|
|
132
|
+
return current_usage_for(store, workspace_id)
|
|
133
|
+
|
|
134
|
+
async def get_all(self, workspace_ids: List[str]) -> Dict[str, WorkspaceUsage]:
|
|
135
|
+
store = load_usage(self.data_dir)
|
|
136
|
+
return {workspace_id: current_usage_for(store, workspace_id) for workspace_id in workspace_ids}
|
|
137
|
+
|
|
138
|
+
async def check_quota(self, workspace_id: str, cap: Optional[int]) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Raises QuotaExceededError once a workspace has reached its
|
|
141
|
+
configured cap. cap=None means unlimited -- never blocks a
|
|
142
|
+
workspace that has no cap set, so this stays free/OSS-safe by
|
|
143
|
+
default.
|
|
144
|
+
"""
|
|
145
|
+
if cap is None:
|
|
146
|
+
return
|
|
147
|
+
usage = await self.get(workspace_id)
|
|
148
|
+
if usage.message_count >= cap:
|
|
149
|
+
raise QuotaExceededError(workspace_id, cap)
|
workspaceguard/vault.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One derived AES-256-GCM key per workspace: chat history and memory get a
|
|
3
|
+
directory boundary (namespace.py), the API-key vault gets its own encrypted
|
|
4
|
+
file per workspace so a leaked file alone (without the master key) reveals
|
|
5
|
+
nothing. Ported from src/core/vault.ts, using the `cryptography` package's
|
|
6
|
+
AESGCM primitive (the Python ecosystem's standard, actively maintained
|
|
7
|
+
choice for authenticated symmetric encryption) in place of Node's
|
|
8
|
+
node:crypto.
|
|
9
|
+
|
|
10
|
+
On-disk layout is kept identical to the TypeScript original (12-byte IV +
|
|
11
|
+
16-byte GCM auth tag + ciphertext, concatenated) so a vault file's format is
|
|
12
|
+
conceptually portable between the two distributions even though a real
|
|
13
|
+
deployment would run one or the other against a given data directory, not
|
|
14
|
+
both against the same one simultaneously.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from cryptography.exceptions import InvalidTag
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
|
|
27
|
+
from .types import VaultDecryptionError
|
|
28
|
+
|
|
29
|
+
KEY_BYTES = 32
|
|
30
|
+
IV_BYTES = 12
|
|
31
|
+
TAG_BYTES = 16
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Vault:
|
|
35
|
+
def __init__(self, master_key_path: str) -> None:
|
|
36
|
+
self._master_key_path = master_key_path
|
|
37
|
+
self._master_key: Optional[bytes] = None
|
|
38
|
+
|
|
39
|
+
async def init(self) -> None:
|
|
40
|
+
try:
|
|
41
|
+
with open(self._master_key_path, "r", encoding="utf-8") as fh:
|
|
42
|
+
raw = fh.read().strip()
|
|
43
|
+
import base64
|
|
44
|
+
|
|
45
|
+
self._master_key = base64.b64decode(raw)
|
|
46
|
+
except FileNotFoundError:
|
|
47
|
+
import base64
|
|
48
|
+
|
|
49
|
+
self._master_key = secrets.token_bytes(KEY_BYTES)
|
|
50
|
+
os.makedirs(os.path.dirname(self._master_key_path), exist_ok=True)
|
|
51
|
+
with open(self._master_key_path, "w", encoding="utf-8") as fh:
|
|
52
|
+
fh.write(base64.b64encode(self._master_key).decode("ascii"))
|
|
53
|
+
os.chmod(self._master_key_path, 0o600)
|
|
54
|
+
|
|
55
|
+
def _require_master_key(self) -> bytes:
|
|
56
|
+
if self._master_key is None:
|
|
57
|
+
raise RuntimeError("Vault.init() must run before any vault operation")
|
|
58
|
+
return self._master_key
|
|
59
|
+
|
|
60
|
+
def _derive_workspace_key(self, workspace_id: str, generation: int) -> bytes:
|
|
61
|
+
master = self._require_master_key()
|
|
62
|
+
message = f"{workspace_id}:{generation}".encode("utf-8")
|
|
63
|
+
return hmac.new(master, message, hashlib.sha256).digest()
|
|
64
|
+
|
|
65
|
+
def vault_path(self, data_dir: str, workspace_id: str) -> str:
|
|
66
|
+
return os.path.join(data_dir, "workspaces", workspace_id, "vault.bin")
|
|
67
|
+
|
|
68
|
+
def _generation_path(self, data_dir: str, workspace_id: str) -> str:
|
|
69
|
+
return os.path.join(data_dir, "workspaces", workspace_id, "key-generation")
|
|
70
|
+
|
|
71
|
+
def _read_generation(self, data_dir: str, workspace_id: str) -> int:
|
|
72
|
+
try:
|
|
73
|
+
with open(self._generation_path(data_dir, workspace_id), "r", encoding="utf-8") as fh:
|
|
74
|
+
return int(fh.read().strip() or "0")
|
|
75
|
+
except (FileNotFoundError, ValueError):
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
async def write_secret(self, data_dir: str, workspace_id: str, plaintext: str) -> None:
|
|
79
|
+
generation = self._read_generation(data_dir, workspace_id)
|
|
80
|
+
key = self._derive_workspace_key(workspace_id, generation)
|
|
81
|
+
iv = secrets.token_bytes(IV_BYTES)
|
|
82
|
+
aesgcm = AESGCM(key)
|
|
83
|
+
sealed = aesgcm.encrypt(iv, plaintext.encode("utf-8"), None)
|
|
84
|
+
ciphertext, auth_tag = sealed[:-TAG_BYTES], sealed[-TAG_BYTES:]
|
|
85
|
+
path = self.vault_path(data_dir, workspace_id)
|
|
86
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
87
|
+
with open(path, "wb") as fh:
|
|
88
|
+
fh.write(iv + auth_tag + ciphertext)
|
|
89
|
+
os.chmod(path, 0o600)
|
|
90
|
+
|
|
91
|
+
async def read_secret(self, data_dir: str, workspace_id: str) -> str:
|
|
92
|
+
generation = self._read_generation(data_dir, workspace_id)
|
|
93
|
+
key = self._derive_workspace_key(workspace_id, generation)
|
|
94
|
+
path = self.vault_path(data_dir, workspace_id)
|
|
95
|
+
try:
|
|
96
|
+
with open(path, "rb") as fh:
|
|
97
|
+
payload = fh.read()
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
raise VaultDecryptionError(workspace_id)
|
|
100
|
+
iv = payload[:IV_BYTES]
|
|
101
|
+
auth_tag = payload[IV_BYTES : IV_BYTES + TAG_BYTES]
|
|
102
|
+
ciphertext = payload[IV_BYTES + TAG_BYTES :]
|
|
103
|
+
try:
|
|
104
|
+
aesgcm = AESGCM(key)
|
|
105
|
+
plaintext = aesgcm.decrypt(iv, ciphertext + auth_tag, None)
|
|
106
|
+
return plaintext.decode("utf-8")
|
|
107
|
+
except (InvalidTag, ValueError):
|
|
108
|
+
raise VaultDecryptionError(workspace_id)
|
|
109
|
+
|
|
110
|
+
async def rotate(self, data_dir: str, workspace_id: str) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Rotation increments the per-workspace key generation BEFORE
|
|
113
|
+
re-encrypting, so the new ciphertext is under a genuinely different
|
|
114
|
+
derived key -- anything encrypted under the old generation can no
|
|
115
|
+
longer be decrypted once the generation file is bumped. Behavior for
|
|
116
|
+
an in-flight stream during rotation is a known open question, not
|
|
117
|
+
resolved here (matches the TypeScript original).
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
existing: Optional[str] = await self.read_secret(data_dir, workspace_id)
|
|
121
|
+
except VaultDecryptionError:
|
|
122
|
+
existing = None
|
|
123
|
+
current_generation = self._read_generation(data_dir, workspace_id)
|
|
124
|
+
next_generation = current_generation + 1
|
|
125
|
+
generation_path = self._generation_path(data_dir, workspace_id)
|
|
126
|
+
os.makedirs(os.path.dirname(generation_path), exist_ok=True)
|
|
127
|
+
with open(generation_path, "w", encoding="utf-8") as fh:
|
|
128
|
+
fh.write(str(next_generation))
|
|
129
|
+
if existing is not None:
|
|
130
|
+
await self.write_secret(data_dir, workspace_id, existing)
|