safe-colab 0.19.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.
- safe_colab-0.19.0/PKG-INFO +110 -0
- safe_colab-0.19.0/README.md +73 -0
- safe_colab-0.19.0/pyproject.toml +76 -0
- safe_colab-0.19.0/safe_colab/__init__.py +3 -0
- safe_colab-0.19.0/safe_colab/__main__.py +4 -0
- safe_colab-0.19.0/safe_colab/artifacts.py +255 -0
- safe_colab-0.19.0/safe_colab/auth.py +147 -0
- safe_colab-0.19.0/safe_colab/cli.py +1616 -0
- safe_colab-0.19.0/safe_colab/daemon.py +596 -0
- safe_colab-0.19.0/safe_colab/dashboard.html +219 -0
- safe_colab-0.19.0/safe_colab/env_manager.py +588 -0
- safe_colab-0.19.0/safe_colab/event_log.py +151 -0
- safe_colab-0.19.0/safe_colab/guardian.py +200 -0
- safe_colab-0.19.0/safe_colab/kernel.py +585 -0
- safe_colab-0.19.0/safe_colab/market.py +569 -0
- safe_colab-0.19.0/safe_colab/owner.py +462 -0
- safe_colab-0.19.0/safe_colab/remote.py +385 -0
- safe_colab-0.19.0/safe_colab/sandbox.py +858 -0
- safe_colab-0.19.0/safe_colab/service.py +1205 -0
- safe_colab-0.19.0/safe_colab/session_manager.py +455 -0
- safe_colab-0.19.0/safe_colab/upload.py +437 -0
- safe_colab-0.19.0/safe_colab.egg-info/PKG-INFO +110 -0
- safe_colab-0.19.0/safe_colab.egg-info/SOURCES.txt +78 -0
- safe_colab-0.19.0/safe_colab.egg-info/dependency_links.txt +1 -0
- safe_colab-0.19.0/safe_colab.egg-info/entry_points.txt +3 -0
- safe_colab-0.19.0/safe_colab.egg-info/requires.txt +31 -0
- safe_colab-0.19.0/safe_colab.egg-info/top_level.txt +1 -0
- safe_colab-0.19.0/setup.cfg +4 -0
- safe_colab-0.19.0/tests/test_artifacts_alias.py +52 -0
- safe_colab-0.19.0/tests/test_auth.py +130 -0
- safe_colab-0.19.0/tests/test_clamp_cmd_timeout.py +76 -0
- safe_colab-0.19.0/tests/test_cli_logs.py +137 -0
- safe_colab-0.19.0/tests/test_consent_hint.py +41 -0
- safe_colab-0.19.0/tests/test_daemon.py +338 -0
- safe_colab-0.19.0/tests/test_dataset_alias.py +134 -0
- safe_colab-0.19.0/tests/test_e2e.py +97 -0
- safe_colab-0.19.0/tests/test_env_manager.py +252 -0
- safe_colab-0.19.0/tests/test_execute_command_attribution.py +57 -0
- safe_colab-0.19.0/tests/test_guardian.py +467 -0
- safe_colab-0.19.0/tests/test_guardian_live.py +173 -0
- safe_colab-0.19.0/tests/test_host.py +227 -0
- safe_colab-0.19.0/tests/test_human_size.py +44 -0
- safe_colab-0.19.0/tests/test_import_env_extract_safe.py +58 -0
- safe_colab-0.19.0/tests/test_inspect_columns.py +75 -0
- safe_colab-0.19.0/tests/test_kernel.py +78 -0
- safe_colab-0.19.0/tests/test_kernel_timeout.py +98 -0
- safe_colab-0.19.0/tests/test_kernel_walltime_timeout.py +108 -0
- safe_colab-0.19.0/tests/test_market_auth_0326.py +101 -0
- safe_colab-0.19.0/tests/test_market_buy.py +84 -0
- safe_colab-0.19.0/tests/test_market_format.py +126 -0
- safe_colab-0.19.0/tests/test_market_launch.py +100 -0
- safe_colab-0.19.0/tests/test_market_parse.py +53 -0
- safe_colab-0.19.0/tests/test_marketplace_heartbeat.py +111 -0
- safe_colab-0.19.0/tests/test_meta_dataset_id.py +100 -0
- safe_colab-0.19.0/tests/test_micromamba_extract_safe.py +75 -0
- safe_colab-0.19.0/tests/test_output_audit_channels.py +331 -0
- safe_colab-0.19.0/tests/test_owner.py +191 -0
- safe_colab-0.19.0/tests/test_owner_earnings.py +55 -0
- safe_colab-0.19.0/tests/test_remote_collect.py +92 -0
- safe_colab-0.19.0/tests/test_remote_start.py +138 -0
- safe_colab-0.19.0/tests/test_run_code_image_gate_0296.py +108 -0
- safe_colab-0.19.0/tests/test_run_one.py +126 -0
- safe_colab-0.19.0/tests/test_sandbox.py +324 -0
- safe_colab-0.19.0/tests/test_sandbox_tiered.py +242 -0
- safe_colab-0.19.0/tests/test_service_failclosed.py +141 -0
- safe_colab-0.19.0/tests/test_service_logs.py +128 -0
- safe_colab-0.19.0/tests/test_session_cleanup.py +119 -0
- safe_colab-0.19.0/tests/test_session_guardian_probe.py +88 -0
- safe_colab-0.19.0/tests/test_session_manager.py +353 -0
- safe_colab-0.19.0/tests/test_start_guardian_probe.py +71 -0
- safe_colab-0.19.0/tests/test_test_guardian_classify.py +51 -0
- safe_colab-0.19.0/tests/test_test_guardian_cmd.py +119 -0
- safe_colab-0.19.0/tests/test_topup.py +176 -0
- safe_colab-0.19.0/tests/test_upload_audit_coverage.py +96 -0
- safe_colab-0.19.0/tests/test_upload_binary_failclosed_0291.py +108 -0
- safe_colab-0.19.0/tests/test_upload_binary_gate_0120.py +95 -0
- safe_colab-0.19.0/tests/test_upload_file_attribution.py +66 -0
- safe_colab-0.19.0/tests/test_upload_stream_0076.py +100 -0
- safe_colab-0.19.0/tests/test_upload_toctou_0308.py +108 -0
- safe_colab-0.19.0/tests/test_wallet_format.py +53 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safe-colab
|
|
3
|
+
Version: 0.19.0
|
|
4
|
+
Summary: CLI tool for safe collaboration - sandboxed Jupyter kernel with remote Hypha service access
|
|
5
|
+
Author: Amun AI AB
|
|
6
|
+
License: All Rights Reserved
|
|
7
|
+
Project-URL: Homepage, https://safe-colab.amun.ai
|
|
8
|
+
Project-URL: Guardian, https://guard.amun.ai
|
|
9
|
+
Keywords: safe-colab,sandbox,jupyter,hypha,remote-execution
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: hypha-rpc>=0.20.0
|
|
13
|
+
Requires-Dist: jupyter_client>=8.0
|
|
14
|
+
Requires-Dist: ipykernel>=6.0
|
|
15
|
+
Requires-Dist: click>=8.0
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0
|
|
17
|
+
Requires-Dist: httpx>=0.24.0
|
|
18
|
+
Requires-Dist: aiohttp>=3.9
|
|
19
|
+
Requires-Dist: nono-py>=0.1.0; sys_platform != "win32"
|
|
20
|
+
Requires-Dist: pandas>=2.0
|
|
21
|
+
Requires-Dist: numpy>=1.24
|
|
22
|
+
Provides-Extra: desktop
|
|
23
|
+
Requires-Dist: aiohttp>=3.9; extra == "desktop"
|
|
24
|
+
Provides-Extra: sandbox
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-timeout>=2.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-aiohttp>=1.0; extra == "dev"
|
|
30
|
+
Requires-Dist: aiohttp>=3.9; extra == "dev"
|
|
31
|
+
Requires-Dist: pyyaml>=6.0; extra == "dev"
|
|
32
|
+
Provides-Extra: e2e
|
|
33
|
+
Requires-Dist: hypha[s3]>=0.21; extra == "e2e"
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == "e2e"
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "e2e"
|
|
36
|
+
Requires-Dist: httpx>=0.24.0; extra == "e2e"
|
|
37
|
+
|
|
38
|
+
# Safe Colab CLI
|
|
39
|
+
|
|
40
|
+
**Enable AI agents to analyze your sensitive data remotely — without ever copying it.**
|
|
41
|
+
|
|
42
|
+
A command-line tool that launches a sandboxed Python environment on your machine (or in the cloud), registers it as a remote service via [Hypha](https://hypha.amun.ai), and lets AI agents execute code through a guardian agent that enforces your sensitivity policy.
|
|
43
|
+
|
|
44
|
+
## Key Features
|
|
45
|
+
|
|
46
|
+
- **Guardian Agent** — LLM-powered security checks validate code before execution and output before return
|
|
47
|
+
- **Sensitivity Contract** — Define protected columns, minimum aggregation, and allowed operations in a README.md
|
|
48
|
+
- **Live Dashboard** — Web dashboard with real-time activity log and operation stats
|
|
49
|
+
- **Protected Audit Logs** — Full audit trail in `~/.safe-colab/logs/`, protected from sandboxed code
|
|
50
|
+
- **Sandbox Isolation** — nono (macOS/Linux) or Docker restricts kernel filesystem access
|
|
51
|
+
- **Remote Hosting** — Upload dataset once, cloud pods spin up on demand (no laptop needed)
|
|
52
|
+
- **Zero Config for Agents** — Agents call `get_skill_md` via curl to learn the API. No SDK, no token needed.
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
|
|
56
|
+
### Local Mode (data stays on your machine)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install safe-colab-cli
|
|
60
|
+
safe-colab login --server-url https://hypha.amun.ai
|
|
61
|
+
safe-colab start --data-dir ./my-data --guardian-url https://guard.amun.ai
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Remote Mode (upload once, pods on demand)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install safe-colab-cli
|
|
68
|
+
safe-colab login --server-url https://hypha.amun.ai
|
|
69
|
+
safe-colab deploy --data-dir ./my-data --guardian-url https://guard.amun.ai
|
|
70
|
+
# → Uploads dataset, registers app. Share the App ID with your AI agent.
|
|
71
|
+
# → Pods spin up automatically when an agent calls start().
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Use the Amun AI production servers
|
|
75
|
+
|
|
76
|
+
Safe Colab talks to **two** services and both must point at the Amun AI
|
|
77
|
+
deployment (older installs and some docs default to an `aicell.io` instance):
|
|
78
|
+
|
|
79
|
+
| Service | Amun AI URL | How to set it |
|
|
80
|
+
|---------|-------------|---------------|
|
|
81
|
+
| **Hypha** (registers your kernel; chosen at **login**) | `https://hypha.amun.ai` | `safe-colab login --server-url https://hypha.amun.ai` |
|
|
82
|
+
| **Guardian** (validates every code/output) | `https://guard.amun.ai` | `--guardian-url https://guard.amun.ai` on `start`/`deploy` |
|
|
83
|
+
|
|
84
|
+
The Hypha server is fixed **at login time** — if you logged in against a
|
|
85
|
+
different server, re-run `safe-colab login --server-url https://hypha.amun.ai`
|
|
86
|
+
to overwrite the saved credentials in `~/.safe-colab/.env`. You can also set
|
|
87
|
+
both via environment (persist them in your shell profile):
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export HYPHA_SERVER_URL=https://hypha.amun.ai
|
|
91
|
+
export SAFE_COLAB_GUARDIAN_URL=https://guard.amun.ai
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Verify the guardian before a session or demo (accepts the Terms of Use, then
|
|
95
|
+
runs a built-in safe/blocked suite):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
safe-colab test-guardian --url https://guard.amun.ai --accept-terms
|
|
99
|
+
# → Health: OK … 4/4 tests passed
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Documentation
|
|
103
|
+
|
|
104
|
+
See the [docs site](https://aicell-lab.github.io/safe-colab-cli/) or the [GitHub project](https://github.com/aicell-lab/safe-colab-cli) for full documentation.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
Copyright 2024-2026 Amun AI AB. All rights reserved.
|
|
109
|
+
|
|
110
|
+
This software is proprietary and confidential. No part of this software may be reproduced, distributed, or transmitted in any form or by any means without the prior written permission of Amun AI AB. Unauthorized copying, modification, distribution, or use of this software is strictly prohibited.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Safe Colab CLI
|
|
2
|
+
|
|
3
|
+
**Enable AI agents to analyze your sensitive data remotely — without ever copying it.**
|
|
4
|
+
|
|
5
|
+
A command-line tool that launches a sandboxed Python environment on your machine (or in the cloud), registers it as a remote service via [Hypha](https://hypha.amun.ai), and lets AI agents execute code through a guardian agent that enforces your sensitivity policy.
|
|
6
|
+
|
|
7
|
+
## Key Features
|
|
8
|
+
|
|
9
|
+
- **Guardian Agent** — LLM-powered security checks validate code before execution and output before return
|
|
10
|
+
- **Sensitivity Contract** — Define protected columns, minimum aggregation, and allowed operations in a README.md
|
|
11
|
+
- **Live Dashboard** — Web dashboard with real-time activity log and operation stats
|
|
12
|
+
- **Protected Audit Logs** — Full audit trail in `~/.safe-colab/logs/`, protected from sandboxed code
|
|
13
|
+
- **Sandbox Isolation** — nono (macOS/Linux) or Docker restricts kernel filesystem access
|
|
14
|
+
- **Remote Hosting** — Upload dataset once, cloud pods spin up on demand (no laptop needed)
|
|
15
|
+
- **Zero Config for Agents** — Agents call `get_skill_md` via curl to learn the API. No SDK, no token needed.
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
### Local Mode (data stays on your machine)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install safe-colab-cli
|
|
23
|
+
safe-colab login --server-url https://hypha.amun.ai
|
|
24
|
+
safe-colab start --data-dir ./my-data --guardian-url https://guard.amun.ai
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Remote Mode (upload once, pods on demand)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install safe-colab-cli
|
|
31
|
+
safe-colab login --server-url https://hypha.amun.ai
|
|
32
|
+
safe-colab deploy --data-dir ./my-data --guardian-url https://guard.amun.ai
|
|
33
|
+
# → Uploads dataset, registers app. Share the App ID with your AI agent.
|
|
34
|
+
# → Pods spin up automatically when an agent calls start().
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Use the Amun AI production servers
|
|
38
|
+
|
|
39
|
+
Safe Colab talks to **two** services and both must point at the Amun AI
|
|
40
|
+
deployment (older installs and some docs default to an `aicell.io` instance):
|
|
41
|
+
|
|
42
|
+
| Service | Amun AI URL | How to set it |
|
|
43
|
+
|---------|-------------|---------------|
|
|
44
|
+
| **Hypha** (registers your kernel; chosen at **login**) | `https://hypha.amun.ai` | `safe-colab login --server-url https://hypha.amun.ai` |
|
|
45
|
+
| **Guardian** (validates every code/output) | `https://guard.amun.ai` | `--guardian-url https://guard.amun.ai` on `start`/`deploy` |
|
|
46
|
+
|
|
47
|
+
The Hypha server is fixed **at login time** — if you logged in against a
|
|
48
|
+
different server, re-run `safe-colab login --server-url https://hypha.amun.ai`
|
|
49
|
+
to overwrite the saved credentials in `~/.safe-colab/.env`. You can also set
|
|
50
|
+
both via environment (persist them in your shell profile):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export HYPHA_SERVER_URL=https://hypha.amun.ai
|
|
54
|
+
export SAFE_COLAB_GUARDIAN_URL=https://guard.amun.ai
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Verify the guardian before a session or demo (accepts the Terms of Use, then
|
|
58
|
+
runs a built-in safe/blocked suite):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
safe-colab test-guardian --url https://guard.amun.ai --accept-terms
|
|
62
|
+
# → Health: OK … 4/4 tests passed
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Documentation
|
|
66
|
+
|
|
67
|
+
See the [docs site](https://aicell-lab.github.io/safe-colab-cli/) or the [GitHub project](https://github.com/aicell-lab/safe-colab-cli) for full documentation.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
Copyright 2024-2026 Amun AI AB. All rights reserved.
|
|
72
|
+
|
|
73
|
+
This software is proprietary and confidential. No part of this software may be reproduced, distributed, or transmitted in any form or by any means without the prior written permission of Amun AI AB. Unauthorized copying, modification, distribution, or use of this software is strictly prohibited.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "safe-colab"
|
|
7
|
+
version = "0.19.0"
|
|
8
|
+
description = "CLI tool for safe collaboration - sandboxed Jupyter kernel with remote Hypha service access"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "All Rights Reserved"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "Amun AI AB"}]
|
|
13
|
+
keywords = ["safe-colab", "sandbox", "jupyter", "hypha", "remote-execution"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"hypha-rpc>=0.20.0",
|
|
16
|
+
"jupyter_client>=8.0",
|
|
17
|
+
"ipykernel>=6.0",
|
|
18
|
+
"click>=8.0",
|
|
19
|
+
"python-dotenv>=1.0",
|
|
20
|
+
"httpx>=0.24.0",
|
|
21
|
+
"aiohttp>=3.9",
|
|
22
|
+
"nono-py>=0.1.0; sys_platform != 'win32'",
|
|
23
|
+
# Data-science packages so the served analysis kernel can actually do work
|
|
24
|
+
# (the product is for analyzing tabular data; without these every query fails
|
|
25
|
+
# with ModuleNotFoundError on a fresh install).
|
|
26
|
+
"pandas>=2.0",
|
|
27
|
+
"numpy>=1.24",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
desktop = [
|
|
32
|
+
"aiohttp>=3.9",
|
|
33
|
+
]
|
|
34
|
+
sandbox = [
|
|
35
|
+
]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=7.0",
|
|
38
|
+
"pytest-asyncio>=0.21",
|
|
39
|
+
"pytest-timeout>=2.0",
|
|
40
|
+
"pytest-aiohttp>=1.0",
|
|
41
|
+
"aiohttp>=3.9",
|
|
42
|
+
"pyyaml>=6.0",
|
|
43
|
+
]
|
|
44
|
+
# Hermetic full-journey E2E (cli/tests/e2e): boots a LOCAL Hypha server + mock
|
|
45
|
+
# LLM + the real Guardian (see tests/e2e/hermetic_infra.py) and drives the real
|
|
46
|
+
# owner-serve → analyst-run journey end to end. `hypha[s3]` provides the
|
|
47
|
+
# `python -m hypha.server` the harness launches.
|
|
48
|
+
e2e = [
|
|
49
|
+
"hypha[s3]>=0.21",
|
|
50
|
+
"pytest>=8.0",
|
|
51
|
+
"pytest-asyncio>=0.23",
|
|
52
|
+
"httpx>=0.24.0",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.urls]
|
|
56
|
+
Homepage = "https://safe-colab.amun.ai"
|
|
57
|
+
Guardian = "https://guard.amun.ai"
|
|
58
|
+
|
|
59
|
+
[project.scripts]
|
|
60
|
+
safe-colab = "safe_colab.cli:main"
|
|
61
|
+
safe-colab-daemon = "safe_colab.daemon:main"
|
|
62
|
+
|
|
63
|
+
[tool.setuptools.packages.find]
|
|
64
|
+
where = ["."]
|
|
65
|
+
include = ["safe_colab*"]
|
|
66
|
+
|
|
67
|
+
[tool.setuptools.package-data]
|
|
68
|
+
safe_colab = ["*.html"]
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
asyncio_mode = "auto"
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
markers = [
|
|
74
|
+
"slow: marks tests that require micromamba and network access",
|
|
75
|
+
"sandbox: filesystem-isolation tests that run a real nono sandbox and assert enforcement; require a privileged sandbox runtime, not a generic CI runner",
|
|
76
|
+
]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Artifact manager integration for file sharing, logging, and auditing.
|
|
2
|
+
|
|
3
|
+
Creates a collection per user and a child artifact per session.
|
|
4
|
+
Files (plots, results, large datasets) are uploaded to the artifact manager
|
|
5
|
+
and shared via presigned URLs - no data copying needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import json
|
|
11
|
+
import hashlib
|
|
12
|
+
import secrets
|
|
13
|
+
import logging
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("safe_colab.artifacts")
|
|
20
|
+
|
|
21
|
+
COLLECTION_ALIAS = "safe-colab-sessions"
|
|
22
|
+
|
|
23
|
+
# Hypha rejects artifact aliases longer than 64 characters. A session_id minted
|
|
24
|
+
# with secrets.token_hex(32) is 64 hex chars, so the naive f"session-{id}" alias
|
|
25
|
+
# is 72 chars and the artifact fails to create — silently disabling file sharing
|
|
26
|
+
# and the audit log. Clamp defensively at the single construction chokepoint.
|
|
27
|
+
MAX_ALIAS_LEN = 64
|
|
28
|
+
_ALIAS_PREFIX = "session-"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def session_alias(session_id: str) -> str:
|
|
32
|
+
"""A valid (<=64 char), stable, collision-resistant alias for a session.
|
|
33
|
+
|
|
34
|
+
Short ids are used verbatim (`session-<id>`). When the full alias would
|
|
35
|
+
exceed Hypha's 64-char cap, we hash the WHOLE session_id — a prefix slice
|
|
36
|
+
would collide for ids that share a prefix — into a 48-char sha-256 digest,
|
|
37
|
+
keeping 192 bits of entropy well under the limit.
|
|
38
|
+
"""
|
|
39
|
+
alias = f"{_ALIAS_PREFIX}{session_id}"
|
|
40
|
+
if len(alias) <= MAX_ALIAS_LEN:
|
|
41
|
+
return alias
|
|
42
|
+
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:48]
|
|
43
|
+
return f"{_ALIAS_PREFIX}{digest}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionArtifactManager:
|
|
47
|
+
"""Manages artifacts for a single safe-colab session.
|
|
48
|
+
|
|
49
|
+
Creates a collection (if needed) and a session artifact within it.
|
|
50
|
+
Provides upload/download via presigned URLs and audit logging.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, artifact_manager, session_id: str, workspace: str, server_url: str = "https://hypha.amun.ai"):
|
|
54
|
+
self._am = artifact_manager
|
|
55
|
+
self.session_id = session_id
|
|
56
|
+
self.workspace = workspace
|
|
57
|
+
self.server_url = server_url.rstrip("/")
|
|
58
|
+
self.collection_id = None
|
|
59
|
+
self.artifact_id = None
|
|
60
|
+
self.artifact_alias = None
|
|
61
|
+
self.view_base_url = None
|
|
62
|
+
self._log_buffer = []
|
|
63
|
+
|
|
64
|
+
async def initialize(self):
|
|
65
|
+
"""Create the collection (idempotent) and session artifact."""
|
|
66
|
+
# Ensure collection exists
|
|
67
|
+
try:
|
|
68
|
+
collection = await self._am.read(artifact_id=COLLECTION_ALIAS)
|
|
69
|
+
self.collection_id = collection["id"]
|
|
70
|
+
except Exception:
|
|
71
|
+
collection = await self._am.create(
|
|
72
|
+
type="collection",
|
|
73
|
+
alias=COLLECTION_ALIAS,
|
|
74
|
+
manifest={
|
|
75
|
+
"name": "Safe Colab Sessions",
|
|
76
|
+
"description": "Collection of safe-colab session artifacts for auditing and file sharing",
|
|
77
|
+
},
|
|
78
|
+
config={
|
|
79
|
+
"permissions": {"*": "r", "@": "rw+"},
|
|
80
|
+
},
|
|
81
|
+
stage=True,
|
|
82
|
+
)
|
|
83
|
+
self.collection_id = collection["id"]
|
|
84
|
+
try:
|
|
85
|
+
await self._am.commit(self.collection_id)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass # Already committed or no staging needed
|
|
88
|
+
|
|
89
|
+
# Create session artifact with static hosting enabled
|
|
90
|
+
self.artifact_alias = session_alias(self.session_id)
|
|
91
|
+
artifact = await self._am.create(
|
|
92
|
+
type="dataset",
|
|
93
|
+
alias=self.artifact_alias,
|
|
94
|
+
parent_id=self.collection_id,
|
|
95
|
+
manifest={
|
|
96
|
+
"name": f"Session {self.session_id[:8]}",
|
|
97
|
+
"description": f"Safe Colab session started at {datetime.now(timezone.utc).isoformat()}",
|
|
98
|
+
"session_id": self.session_id,
|
|
99
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
100
|
+
},
|
|
101
|
+
config={
|
|
102
|
+
"permissions": {"*": "r", "@": "rw+"},
|
|
103
|
+
"view_config": {
|
|
104
|
+
"templates": [],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
stage=True,
|
|
108
|
+
)
|
|
109
|
+
self.artifact_id = artifact["id"]
|
|
110
|
+
self.view_base_url = f"{self.server_url}/{self.workspace}/view/{self.artifact_alias}"
|
|
111
|
+
logger.info(f"Session artifact created: {self.artifact_id}")
|
|
112
|
+
logger.info(f"View URL: {self.view_base_url}/")
|
|
113
|
+
|
|
114
|
+
# Write initial audit log entry
|
|
115
|
+
await self.log_event("session_start", {"session_id": self.session_id})
|
|
116
|
+
|
|
117
|
+
async def upload_file(
|
|
118
|
+
self,
|
|
119
|
+
local_path: str,
|
|
120
|
+
remote_path: Optional[str] = None,
|
|
121
|
+
data: Optional[bytes] = None,
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""Upload a file and return a permanent view URL.
|
|
124
|
+
|
|
125
|
+
The file is uploaded to the session artifact which has static hosting
|
|
126
|
+
enabled. The returned URL renders in the browser (HTML/images) rather
|
|
127
|
+
than triggering a download.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
local_path: Local file path to upload (used for the remote name
|
|
131
|
+
default and the audit-log entry).
|
|
132
|
+
remote_path: Path within the artifact (defaults to filename).
|
|
133
|
+
data: When provided, the EXACT bytes to upload — the caller has
|
|
134
|
+
already read (and, for safe-colab, Guardian-audited) them. This
|
|
135
|
+
avoids an independent second read of ``local_path``: re-reading
|
|
136
|
+
the path here would ship whatever the file holds at upload time,
|
|
137
|
+
not what was audited, opening a TOCTOU on the one channel that
|
|
138
|
+
ships bytes off-box (#0308). When ``None`` the legacy path-read is
|
|
139
|
+
used (no auditing caller).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Dict with 'view_url' (permanent, renders in browser) and
|
|
143
|
+
'download_url' (presigned S3, expires in 1h).
|
|
144
|
+
"""
|
|
145
|
+
if remote_path is None:
|
|
146
|
+
remote_path = os.path.basename(local_path)
|
|
147
|
+
|
|
148
|
+
# Re-enter staging mode if already committed (allows multiple uploads)
|
|
149
|
+
try:
|
|
150
|
+
await self._am.edit(artifact_id=self.artifact_id, stage=True)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # May already be in staging
|
|
153
|
+
|
|
154
|
+
put_url = await self._am.put_file(self.artifact_id, file_path=remote_path)
|
|
155
|
+
|
|
156
|
+
# Ship the caller-supplied (Guardian-audited) buffer verbatim; only fall
|
|
157
|
+
# back to reading the path when no buffer was given (#0308).
|
|
158
|
+
if data is None:
|
|
159
|
+
with open(local_path, "rb") as f:
|
|
160
|
+
data = f.read()
|
|
161
|
+
|
|
162
|
+
async with httpx.AsyncClient() as client:
|
|
163
|
+
resp = await client.put(put_url, content=data)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
|
|
166
|
+
# Commit so the file is visible via /view/
|
|
167
|
+
try:
|
|
168
|
+
await self._am.commit(self.artifact_id)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass # May already be committed
|
|
171
|
+
|
|
172
|
+
view_url = f"{self.view_base_url}/{remote_path}"
|
|
173
|
+
download_url = await self._am.get_file(self.artifact_id, file_path=remote_path)
|
|
174
|
+
|
|
175
|
+
await self.log_event("file_upload", {
|
|
176
|
+
"local_path": local_path,
|
|
177
|
+
"remote_path": remote_path,
|
|
178
|
+
"size_bytes": len(data),
|
|
179
|
+
"view_url": view_url,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return {"view_url": view_url, "download_url": download_url}
|
|
183
|
+
|
|
184
|
+
async def upload_bytes(self, data: bytes, remote_path: str, content_type: str = "application/octet-stream") -> str:
|
|
185
|
+
"""Upload raw bytes and return a presigned download URL."""
|
|
186
|
+
put_url = await self._am.put_file(self.artifact_id, file_path=remote_path)
|
|
187
|
+
|
|
188
|
+
async with httpx.AsyncClient() as client:
|
|
189
|
+
resp = await client.put(put_url, content=data, headers={"Content-Type": content_type})
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
|
|
192
|
+
get_url = await self._am.get_file(self.artifact_id, file_path=remote_path)
|
|
193
|
+
|
|
194
|
+
await self.log_event("bytes_upload", {
|
|
195
|
+
"remote_path": remote_path,
|
|
196
|
+
"size_bytes": len(data),
|
|
197
|
+
"content_type": content_type,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
return get_url
|
|
201
|
+
|
|
202
|
+
async def get_download_url(self, remote_path: str) -> str:
|
|
203
|
+
"""Get a presigned download URL for an existing file."""
|
|
204
|
+
return await self._am.get_file(self.artifact_id, file_path=remote_path)
|
|
205
|
+
|
|
206
|
+
async def list_files(self) -> list:
|
|
207
|
+
"""List all files in the session artifact."""
|
|
208
|
+
return await self._am.list_files(self.artifact_id)
|
|
209
|
+
|
|
210
|
+
async def log_event(self, event_type: str, details: dict):
|
|
211
|
+
"""Append an audit log entry to the session artifact."""
|
|
212
|
+
entry = {
|
|
213
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
214
|
+
"event": event_type,
|
|
215
|
+
**details,
|
|
216
|
+
}
|
|
217
|
+
self._log_buffer.append(entry)
|
|
218
|
+
|
|
219
|
+
# Write log file
|
|
220
|
+
log_content = "\n".join(json.dumps(e) for e in self._log_buffer) + "\n"
|
|
221
|
+
try:
|
|
222
|
+
try:
|
|
223
|
+
await self._am.edit(artifact_id=self.artifact_id, stage=True)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
put_url = await self._am.put_file(self.artifact_id, file_path="audit_log.jsonl")
|
|
227
|
+
async with httpx.AsyncClient() as client:
|
|
228
|
+
await client.put(put_url, content=log_content.encode())
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.warning(f"Failed to write audit log: {e}")
|
|
231
|
+
|
|
232
|
+
async def log_code_execution(self, code: str, result: dict):
|
|
233
|
+
"""Log a code execution event for auditing."""
|
|
234
|
+
await self.log_event("code_execution", {
|
|
235
|
+
"code_length": len(code),
|
|
236
|
+
"code_preview": code[:200] + ("..." if len(code) > 200 else ""),
|
|
237
|
+
"has_error": result.get("error") is not None,
|
|
238
|
+
"stdout_length": len(result.get("stdout", "")),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
async def log_command_execution(self, command: str, result: dict):
|
|
242
|
+
"""Log a shell command execution event."""
|
|
243
|
+
await self.log_event("command_execution", {
|
|
244
|
+
"command": command[:200],
|
|
245
|
+
"returncode": result.get("returncode"),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
async def commit(self, version: Optional[str] = None):
|
|
249
|
+
"""Commit the session artifact (makes files permanent)."""
|
|
250
|
+
await self._am.commit(
|
|
251
|
+
self.artifact_id,
|
|
252
|
+
version=version,
|
|
253
|
+
comment=f"Session {self.session_id[:8]} committed",
|
|
254
|
+
)
|
|
255
|
+
logger.info(f"Session artifact committed: {self.artifact_id}")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Authentication and credential management.
|
|
2
|
+
|
|
3
|
+
Stores credentials in ~/.safe-colab/.env (or $SAFE_COLAB_HOME/.env).
|
|
4
|
+
Supports interactive Hypha login with browser-based OAuth.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import json
|
|
10
|
+
import base64
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_dir() -> Path:
|
|
17
|
+
"""Get the safe-colab config directory (~/.safe-colab/)."""
|
|
18
|
+
home = os.environ.get("SAFE_COLAB_HOME", os.path.join(Path.home(), ".safe-colab"))
|
|
19
|
+
return Path(home)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_credentials_path() -> Path:
|
|
23
|
+
"""Get the credentials file path."""
|
|
24
|
+
return get_config_dir() / ".env"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_credentials(server_url: str, token: str, workspace: str):
|
|
28
|
+
"""Save credentials to ~/.safe-colab/.env"""
|
|
29
|
+
config_dir = get_config_dir()
|
|
30
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
cred_path = get_credentials_path()
|
|
32
|
+
|
|
33
|
+
# Read existing lines, preserve non-credential entries
|
|
34
|
+
existing = {}
|
|
35
|
+
if cred_path.exists():
|
|
36
|
+
for line in cred_path.read_text().splitlines():
|
|
37
|
+
line = line.strip()
|
|
38
|
+
if not line or line.startswith("#"):
|
|
39
|
+
continue
|
|
40
|
+
if "=" in line:
|
|
41
|
+
key, val = line.split("=", 1)
|
|
42
|
+
existing[key.strip()] = val.strip()
|
|
43
|
+
|
|
44
|
+
existing["HYPHA_SERVER_URL"] = server_url
|
|
45
|
+
existing["HYPHA_TOKEN"] = token
|
|
46
|
+
existing["HYPHA_WORKSPACE"] = workspace
|
|
47
|
+
|
|
48
|
+
lines = [f"{k}={v}" for k, v in existing.items()]
|
|
49
|
+
content = "\n".join(lines) + "\n"
|
|
50
|
+
# Write atomically with restrictive permissions from the start
|
|
51
|
+
fd = os.open(str(cred_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
52
|
+
try:
|
|
53
|
+
os.write(fd, content.encode())
|
|
54
|
+
finally:
|
|
55
|
+
os.close(fd)
|
|
56
|
+
return cred_path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_credentials() -> dict:
|
|
60
|
+
"""Load credentials from ~/.safe-colab/.env
|
|
61
|
+
|
|
62
|
+
Returns dict with keys: server_url, token, workspace (any may be None).
|
|
63
|
+
"""
|
|
64
|
+
result = {"server_url": None, "token": None, "workspace": None}
|
|
65
|
+
|
|
66
|
+
cred_path = get_credentials_path()
|
|
67
|
+
if not cred_path.exists():
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
for line in cred_path.read_text().splitlines():
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line or line.startswith("#"):
|
|
73
|
+
continue
|
|
74
|
+
if "=" not in line:
|
|
75
|
+
continue
|
|
76
|
+
key, val = line.split("=", 1)
|
|
77
|
+
key = key.strip()
|
|
78
|
+
val = val.strip().strip("\"'")
|
|
79
|
+
|
|
80
|
+
if key == "HYPHA_SERVER_URL":
|
|
81
|
+
result["server_url"] = val
|
|
82
|
+
elif key == "HYPHA_TOKEN":
|
|
83
|
+
result["token"] = val
|
|
84
|
+
elif key == "HYPHA_WORKSPACE":
|
|
85
|
+
result["workspace"] = val
|
|
86
|
+
|
|
87
|
+
# Validate token expiry
|
|
88
|
+
if result["token"] and is_token_expired(result["token"]):
|
|
89
|
+
result["token"] = None
|
|
90
|
+
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def decode_token(token: str) -> Optional[dict]:
|
|
95
|
+
"""Decode JWT payload without verification (for extracting claims)."""
|
|
96
|
+
try:
|
|
97
|
+
parts = token.split(".")
|
|
98
|
+
if len(parts) != 3:
|
|
99
|
+
return None
|
|
100
|
+
payload_b64 = parts[1]
|
|
101
|
+
# Add padding
|
|
102
|
+
pad = len(payload_b64) % 4
|
|
103
|
+
if pad:
|
|
104
|
+
payload_b64 += "=" * (4 - pad)
|
|
105
|
+
# JWTs are base64URL-encoded (RFC 7515): the alphabet uses - and _ , not
|
|
106
|
+
# +/. base64.b64decode silently DROPS -/_ then raises, so a valid token
|
|
107
|
+
# whose payload carries a base64url -/_ decodes to None here -> the user
|
|
108
|
+
# is wrongly logged out (is_token_expired -> True). Must use urlsafe.
|
|
109
|
+
# (#0158)
|
|
110
|
+
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_token_expired(token: str) -> bool:
|
|
116
|
+
"""Check if a JWT token has expired."""
|
|
117
|
+
payload = decode_token(token)
|
|
118
|
+
if not payload:
|
|
119
|
+
return True
|
|
120
|
+
exp = payload.get("exp")
|
|
121
|
+
if not exp:
|
|
122
|
+
return False
|
|
123
|
+
return time.time() > exp
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_workspace_from_token(token: str) -> Optional[str]:
|
|
127
|
+
"""Extract workspace ID from token's scope claim."""
|
|
128
|
+
payload = decode_token(token)
|
|
129
|
+
if not payload:
|
|
130
|
+
return None
|
|
131
|
+
scope = payload.get("scope", "")
|
|
132
|
+
# Match patterns like "ws:my-workspace#rw" or "wid:my-workspace"
|
|
133
|
+
m = re.search(r"ws:([\w\-|]+)#", scope)
|
|
134
|
+
if m:
|
|
135
|
+
return m.group(1)
|
|
136
|
+
m = re.search(r"wid:([\w\-|]+)", scope)
|
|
137
|
+
if m:
|
|
138
|
+
return m.group(1)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_email_from_token(token: str) -> Optional[str]:
|
|
143
|
+
"""Extract email from token claims."""
|
|
144
|
+
payload = decode_token(token)
|
|
145
|
+
if not payload:
|
|
146
|
+
return None
|
|
147
|
+
return payload.get("https://amun.ai/email")
|