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.
@@ -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,3 @@
1
+ from .mock import MockAdapter
2
+
3
+ __all__ = ["MockAdapter"]
@@ -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, [])
@@ -0,0 +1,65 @@
1
+ """
2
+ Fail closed, never pass-through-unisolated -- the worst failure mode here is
3
+ a silent cross-workspace leak, so an unreachable backend must block, not
4
+ bypass, isolation. Ported from src/core/circuit-breaker.ts.
5
+
6
+ Opens after 3 consecutive failures. Once the cooldown has passed since
7
+ opening, the next call is let through as a half-open probe; success closes
8
+ the circuit, failure keeps it open and restarts the cooldown -- a real
9
+ circuit breaker with a path back to closed, not a one-way trip switch.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from typing import Awaitable, Callable, TypeVar
16
+
17
+ from .log import CircuitStateChangeEvent, Logger
18
+ from .types import BackendCircuitOpenError, BackendUnreachableError
19
+
20
+ T = TypeVar("T")
21
+
22
+ FAILURE_THRESHOLD = 3
23
+ CONNECT_TIMEOUT_MS = 3000
24
+ DEFAULT_OPEN_COOLDOWN_MS = 10_000
25
+
26
+
27
+ class CircuitBreaker:
28
+ def __init__(self, backend_name: str, logger: Logger, open_cooldown_ms: int = DEFAULT_OPEN_COOLDOWN_MS) -> None:
29
+ self._backend_name = backend_name
30
+ self._logger = logger
31
+ self._open_cooldown_ms = open_cooldown_ms
32
+ self._consecutive_failures = 0
33
+ self._open = False
34
+ self._opened_at = 0.0
35
+
36
+ def _probe_allowed(self) -> bool:
37
+ return self._open and (time.monotonic() * 1000 - self._opened_at) >= self._open_cooldown_ms
38
+
39
+ async def call(self, fn: Callable[[], Awaitable[T]]) -> T:
40
+ if self._open and not self._probe_allowed():
41
+ raise BackendCircuitOpenError(self._backend_name)
42
+ try:
43
+ result = await asyncio.wait_for(fn(), timeout=CONNECT_TIMEOUT_MS / 1000)
44
+ self._consecutive_failures = 0
45
+ self.on_health_check_success()
46
+ return result
47
+ except Exception:
48
+ self._consecutive_failures += 1
49
+ if self._consecutive_failures >= FAILURE_THRESHOLD:
50
+ self._open_circuit()
51
+ raise BackendUnreachableError(self._backend_name)
52
+
53
+ def _open_circuit(self) -> None:
54
+ was_open = self._open
55
+ self._open = True
56
+ self._opened_at = time.monotonic() * 1000
57
+ if not was_open:
58
+ self._logger.log(CircuitStateChangeEvent(backend=self._backend_name, state="open"))
59
+
60
+ def on_health_check_success(self) -> None:
61
+ """A health check success (or a successful half-open probe) closes the circuit again."""
62
+ if self._open:
63
+ self._open = False
64
+ self._consecutive_failures = 0
65
+ self._logger.log(CircuitStateChangeEvent(backend=self._backend_name, state="closed"))
workspaceguard/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Console entry point: `workspaceguard <command> [args] [--json]`, installed
4
+ via the `workspaceguard` console-script defined in python/pyproject.toml --
5
+ the same command name as the (not-yet-published) npm package's `bin` entry.
6
+
7
+ Ported from src/cli/index.ts. Commands, flags, human-readable text, and
8
+ --json output shapes are kept identical to the TypeScript CLI so the two
9
+ distributions are drop-in equivalents for anything that shells out to
10
+ either one.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ import sys
17
+ from dataclasses import asdict
18
+ from typing import Any, List, Optional, Tuple
19
+
20
+ from . import create_workspace_guard
21
+ from .adapters.mock import MockAdapter
22
+
23
+ STARTUP_WARNING = (
24
+ "WARNING: workspaceguard must never be directly reachable from the network.\n"
25
+ "It trusts an upstream identity header (e.g. Cf-Access-Authenticated-User-Email)\n"
26
+ "set by your reverse proxy. If this port is exposed without that proxy in front\n"
27
+ "of it, anyone can impersonate any workspace. Firewall this port; only your\n"
28
+ "trusted proxy should be able to reach it."
29
+ )
30
+
31
+ USAGE = "usage: workspaceguard <init|add-workspace|status|rotate-key|usage|set-cap|scan> [--json]"
32
+
33
+
34
+ def _extract_json_flag(args: List[str]) -> Tuple[bool, List[str]]:
35
+ """Strips a boolean flag out of an argv slice, agent-native mode toggle for every command."""
36
+ rest = [a for a in args if a != "--json"]
37
+ return len(rest) != len(args), rest
38
+
39
+
40
+ def _print_result(json_mode: bool, data: Any, human_lines: List[str]) -> None:
41
+ if json_mode:
42
+ import json as _json
43
+
44
+ print(_json.dumps(data))
45
+ return
46
+ for line in human_lines:
47
+ print(line)
48
+
49
+
50
+ def _usage_report_to_dict(report: Any) -> dict:
51
+ d = asdict(report)
52
+ return {
53
+ "workspaceId": d["workspace_id"],
54
+ "identity": d["identity"],
55
+ "monthlyMessageCap": d["monthly_message_cap"],
56
+ "percentUsed": d["percent_used"],
57
+ "period": d["period"],
58
+ "messageCount": d["message_count"],
59
+ "estimatedBytes": d["estimated_bytes"],
60
+ }
61
+
62
+
63
+ async def _run(argv: List[str]) -> int:
64
+ command = argv[1] if len(argv) > 1 else None
65
+ raw_args = argv[2:]
66
+ json_mode, args = _extract_json_flag(raw_args)
67
+ data_dir = os.environ.get("WORKSPACEGUARD_DATA_DIR") or os.getcwd()
68
+
69
+ if command == "init":
70
+ if not json_mode:
71
+ print(STARTUP_WARNING)
72
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
73
+ workspaces = await guard.status()
74
+ _print_result(json_mode, {"ok": True, "dataDir": data_dir, "workspaces": workspaces}, [
75
+ f"workspaceguard initialized in {data_dir}"
76
+ ])
77
+ return 0
78
+
79
+ if command == "add-workspace":
80
+ workspace_id: Optional[str] = args[0] if len(args) > 0 else None
81
+ identity: Optional[str] = args[2] if len(args) > 2 else None # add-workspace <id> --identity <value>
82
+ if not workspace_id or not identity:
83
+ _print_result(
84
+ json_mode,
85
+ {"ok": False, "error": "usage: workspaceguard add-workspace <id> --identity <value>"},
86
+ ["usage: workspaceguard add-workspace <id> --identity <value>"],
87
+ )
88
+ return 1
89
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
90
+ await guard.add_workspace(workspace_id, identity)
91
+ _print_result(json_mode, {"ok": True, "workspaceId": workspace_id, "identity": identity}, [
92
+ f"workspace {workspace_id} added"
93
+ ])
94
+ return 0
95
+
96
+ if command == "status":
97
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
98
+ workspaces = await guard.status()
99
+ if workspaces:
100
+ lines = [f"-> {w['workspaceId']} [isolated] identity: {w['identity']}" for w in workspaces]
101
+ lines.append("No cross-workspace leaks detected.")
102
+ else:
103
+ lines = ["no workspaces configured"]
104
+ _print_result(json_mode, {"ok": True, "workspaces": workspaces}, lines)
105
+ return 0
106
+
107
+ if command == "rotate-key":
108
+ workspace_id = args[0] if len(args) > 0 else None
109
+ if not workspace_id:
110
+ _print_result(json_mode, {"ok": False, "error": "usage: workspaceguard rotate-key <id>"}, [
111
+ "usage: workspaceguard rotate-key <id>"
112
+ ])
113
+ return 1
114
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
115
+ await guard.rotate_key(workspace_id)
116
+ _print_result(json_mode, {"ok": True, "workspaceId": workspace_id}, [f"workspace {workspace_id} key rotated"])
117
+ return 0
118
+
119
+ if command == "usage":
120
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
121
+ report = await guard.usage_report()
122
+ report_dicts = [_usage_report_to_dict(r) for r in report]
123
+ if report:
124
+ lines = []
125
+ for r in report:
126
+ cap = str(r.monthly_message_cap) if r.monthly_message_cap is not None else "unlimited"
127
+ pct = f" ({r.percent_used}%)" if r.percent_used is not None else ""
128
+ lines.append(
129
+ f"-> {r.workspace_id} [{r.identity}]: {r.message_count} messages this period, cap {cap}{pct}"
130
+ )
131
+ else:
132
+ lines = ["no workspaces configured"]
133
+ _print_result(json_mode, {"ok": True, "usage": report_dicts}, lines)
134
+ return 0
135
+
136
+ if command == "set-cap":
137
+ workspace_id = args[0] if len(args) > 0 else None
138
+ cap_arg = args[1] if len(args) > 1 else None
139
+ if not workspace_id or not cap_arg:
140
+ _print_result(
141
+ json_mode,
142
+ {"ok": False, "error": "usage: workspaceguard set-cap <workspaceId> <count|none>"},
143
+ ["usage: workspaceguard set-cap <workspaceId> <count|none>"],
144
+ )
145
+ return 1
146
+ cap: Optional[int]
147
+ if cap_arg == "none":
148
+ cap = None
149
+ else:
150
+ try:
151
+ cap = int(cap_arg)
152
+ if cap < 0:
153
+ raise ValueError
154
+ except ValueError:
155
+ _print_result(json_mode, {"ok": False, "error": f"invalid cap value: {cap_arg}"}, [
156
+ f'invalid cap value: {cap_arg} (must be a non-negative integer or "none")'
157
+ ])
158
+ return 1
159
+ guard = await create_workspace_guard(data_dir=data_dir, backend=MockAdapter())
160
+ await guard.set_cap(workspace_id, cap)
161
+ _print_result(json_mode, {"ok": True, "workspaceId": workspace_id, "cap": cap}, [
162
+ f"workspace {workspace_id} cap cleared (unlimited)"
163
+ if cap is None
164
+ else f"workspace {workspace_id} cap set to {cap} messages/month"
165
+ ])
166
+ return 0
167
+
168
+ if command == "scan":
169
+ _print_result(json_mode, {"ok": True, "findings": []}, [
170
+ "isolation config scan: no misconfigurations detected (scaffold stub)"
171
+ ])
172
+ return 0
173
+
174
+ _print_result(json_mode, {"ok": False, "error": USAGE}, [USAGE])
175
+ return 1
176
+
177
+
178
+ def main() -> None:
179
+ try:
180
+ code = asyncio.run(_run(sys.argv))
181
+ except Exception as err: # noqa: BLE001 -- top-level crash guard, mirrors src/cli/index.ts's catch-all
182
+ print(str(err), file=sys.stderr)
183
+ sys.exit(1)
184
+ sys.exit(code)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
@@ -0,0 +1,156 @@
1
+ """
2
+ Load/save `workspaceguard.config.yaml` and the pure config-mutation helpers
3
+ (add a workspace, set a cap, resolve an identity to a workspace id). Ported
4
+ from src/core/config.ts. YAML keys are camelCase (backend, identityHeader,
5
+ workspaces: [{workspaceId, identity, monthlyMessageCap}]) to stay
6
+ file-compatible with the npm package's own config file.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from typing import Optional
13
+
14
+ import yaml
15
+
16
+ from .types import WorkspaceEntry, WorkspaceGuardConfig, WorkspaceNotFoundError
17
+
18
+ DEFAULT_IDENTITY_HEADER = "cf-access-authenticated-user-email"
19
+
20
+ # workspace_id is used to build filesystem paths (vault, namespace, chat
21
+ # history dirs) -- an allowlist plus a reserved-name denylist closes a
22
+ # path-traversal vector (e.g. "../../etc") before it ever reaches those
23
+ # call sites.
24
+ _WORKSPACE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
25
+ _RESERVED_WORKSPACE_IDS = {"__proto__", "constructor", "prototype", ".", ".."}
26
+
27
+
28
+ class InvalidWorkspaceIdError(Exception):
29
+ def __init__(self, workspace_id: str) -> None:
30
+ super().__init__(
31
+ f'workspace id "{workspace_id}" is invalid: must match {_WORKSPACE_ID_PATTERN.pattern} '
32
+ "and must not be a reserved name"
33
+ )
34
+ self.workspace_id = workspace_id
35
+
36
+
37
+ def _assert_valid_workspace_id(workspace_id: str) -> None:
38
+ if not _WORKSPACE_ID_PATTERN.match(workspace_id) or workspace_id in _RESERVED_WORKSPACE_IDS:
39
+ raise InvalidWorkspaceIdError(workspace_id)
40
+
41
+
42
+ def _normalize_identity(identity: str) -> str:
43
+ """
44
+ Identities are compared case/whitespace-insensitively so that
45
+ "Alex@Example.com " and "alex@example.com" resolve to the same
46
+ workspace -- without this, the duplicate-identity guard below could be
47
+ evaded by registering a near-duplicate, producing two workspaces (and
48
+ two independent quotas) for what is really one real-world identity.
49
+ The original casing is still stored, only comparisons are normalized.
50
+ """
51
+ return identity.strip().lower()
52
+
53
+
54
+ def _default_config() -> WorkspaceGuardConfig:
55
+ return WorkspaceGuardConfig(backend="odysseus", identity_header=DEFAULT_IDENTITY_HEADER, workspaces=[])
56
+
57
+
58
+ def load_config(config_path: str) -> WorkspaceGuardConfig:
59
+ try:
60
+ with open(config_path, "r", encoding="utf-8") as fh:
61
+ raw = fh.read()
62
+ parsed = yaml.safe_load(raw) or {}
63
+ workspaces = [
64
+ WorkspaceEntry(
65
+ workspace_id=w["workspaceId"],
66
+ identity=w["identity"],
67
+ monthly_message_cap=w.get("monthlyMessageCap"),
68
+ )
69
+ for w in parsed.get("workspaces", [])
70
+ ]
71
+ return WorkspaceGuardConfig(
72
+ backend=parsed.get("backend") or "odysseus",
73
+ identity_header=parsed.get("identityHeader") or DEFAULT_IDENTITY_HEADER,
74
+ workspaces=workspaces,
75
+ )
76
+ except Exception:
77
+ return _default_config()
78
+
79
+
80
+ def save_config(config_path: str, config: WorkspaceGuardConfig) -> None:
81
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
82
+ payload = {
83
+ "backend": config.backend,
84
+ "identityHeader": config.identity_header,
85
+ "workspaces": [
86
+ {
87
+ "workspaceId": w.workspace_id,
88
+ "identity": w.identity,
89
+ **({"monthlyMessageCap": w.monthly_message_cap} if w.monthly_message_cap is not None else {}),
90
+ }
91
+ for w in config.workspaces
92
+ ],
93
+ }
94
+ with open(config_path, "w", encoding="utf-8") as fh:
95
+ yaml.safe_dump(payload, fh, sort_keys=False)
96
+
97
+
98
+ class DuplicateIdentityError(Exception):
99
+ def __init__(self, identity: str, existing_workspace_id: str) -> None:
100
+ super().__init__(f'identity "{identity}" is already assigned to workspace "{existing_workspace_id}"')
101
+ self.identity = identity
102
+ self.existing_workspace_id = existing_workspace_id
103
+
104
+
105
+ def upsert_workspace(config: WorkspaceGuardConfig, workspace_id: str, identity: str) -> WorkspaceGuardConfig:
106
+ """
107
+ add-workspace on an existing id is idempotent -- returns the existing
108
+ config unchanged, never overwrites. Rejects loudly if the identity is
109
+ already claimed by a DIFFERENT workspace id: without this check, two
110
+ workspace ids could silently share one identity, and resolve_workspace_id
111
+ would deterministically route to whichever was added first, merging two
112
+ workspaces from the routing layer's perspective without either operator
113
+ being told.
114
+ """
115
+ for w in config.workspaces:
116
+ if w.workspace_id == workspace_id:
117
+ return config
118
+ _assert_valid_workspace_id(workspace_id)
119
+ normalized = _normalize_identity(identity)
120
+ for w in config.workspaces:
121
+ if _normalize_identity(w.identity) == normalized:
122
+ raise DuplicateIdentityError(identity, w.workspace_id)
123
+ return WorkspaceGuardConfig(
124
+ backend=config.backend,
125
+ identity_header=config.identity_header,
126
+ workspaces=[*config.workspaces, WorkspaceEntry(workspace_id=workspace_id, identity=identity)],
127
+ )
128
+
129
+
130
+ def set_workspace_cap(
131
+ config: WorkspaceGuardConfig, workspace_id: str, cap: Optional[int]
132
+ ) -> WorkspaceGuardConfig:
133
+ """
134
+ Sets (or clears, with cap=None) a workspace's monthly message cap.
135
+ Rejects loudly on an unknown workspace id rather than silently
136
+ no-op'ing, matching upsert_workspace's fail-loud-on-ambiguity precedent.
137
+ """
138
+ index = next((i for i, w in enumerate(config.workspaces) if w.workspace_id == workspace_id), None)
139
+ if index is None:
140
+ raise WorkspaceNotFoundError(workspace_id)
141
+ workspaces = list(config.workspaces)
142
+ existing = workspaces[index]
143
+ workspaces[index] = WorkspaceEntry(
144
+ workspace_id=existing.workspace_id, identity=existing.identity, monthly_message_cap=cap
145
+ )
146
+ return WorkspaceGuardConfig(backend=config.backend, identity_header=config.identity_header, workspaces=workspaces)
147
+
148
+
149
+ def resolve_workspace_id(config: WorkspaceGuardConfig, identity_header_value: Optional[str]) -> Optional[str]:
150
+ if not identity_header_value:
151
+ return None
152
+ normalized = _normalize_identity(identity_header_value)
153
+ for w in config.workspaces:
154
+ if _normalize_identity(w.identity) == normalized:
155
+ return w.workspace_id
156
+ return None