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.
Files changed (80) hide show
  1. safe_colab-0.19.0/PKG-INFO +110 -0
  2. safe_colab-0.19.0/README.md +73 -0
  3. safe_colab-0.19.0/pyproject.toml +76 -0
  4. safe_colab-0.19.0/safe_colab/__init__.py +3 -0
  5. safe_colab-0.19.0/safe_colab/__main__.py +4 -0
  6. safe_colab-0.19.0/safe_colab/artifacts.py +255 -0
  7. safe_colab-0.19.0/safe_colab/auth.py +147 -0
  8. safe_colab-0.19.0/safe_colab/cli.py +1616 -0
  9. safe_colab-0.19.0/safe_colab/daemon.py +596 -0
  10. safe_colab-0.19.0/safe_colab/dashboard.html +219 -0
  11. safe_colab-0.19.0/safe_colab/env_manager.py +588 -0
  12. safe_colab-0.19.0/safe_colab/event_log.py +151 -0
  13. safe_colab-0.19.0/safe_colab/guardian.py +200 -0
  14. safe_colab-0.19.0/safe_colab/kernel.py +585 -0
  15. safe_colab-0.19.0/safe_colab/market.py +569 -0
  16. safe_colab-0.19.0/safe_colab/owner.py +462 -0
  17. safe_colab-0.19.0/safe_colab/remote.py +385 -0
  18. safe_colab-0.19.0/safe_colab/sandbox.py +858 -0
  19. safe_colab-0.19.0/safe_colab/service.py +1205 -0
  20. safe_colab-0.19.0/safe_colab/session_manager.py +455 -0
  21. safe_colab-0.19.0/safe_colab/upload.py +437 -0
  22. safe_colab-0.19.0/safe_colab.egg-info/PKG-INFO +110 -0
  23. safe_colab-0.19.0/safe_colab.egg-info/SOURCES.txt +78 -0
  24. safe_colab-0.19.0/safe_colab.egg-info/dependency_links.txt +1 -0
  25. safe_colab-0.19.0/safe_colab.egg-info/entry_points.txt +3 -0
  26. safe_colab-0.19.0/safe_colab.egg-info/requires.txt +31 -0
  27. safe_colab-0.19.0/safe_colab.egg-info/top_level.txt +1 -0
  28. safe_colab-0.19.0/setup.cfg +4 -0
  29. safe_colab-0.19.0/tests/test_artifacts_alias.py +52 -0
  30. safe_colab-0.19.0/tests/test_auth.py +130 -0
  31. safe_colab-0.19.0/tests/test_clamp_cmd_timeout.py +76 -0
  32. safe_colab-0.19.0/tests/test_cli_logs.py +137 -0
  33. safe_colab-0.19.0/tests/test_consent_hint.py +41 -0
  34. safe_colab-0.19.0/tests/test_daemon.py +338 -0
  35. safe_colab-0.19.0/tests/test_dataset_alias.py +134 -0
  36. safe_colab-0.19.0/tests/test_e2e.py +97 -0
  37. safe_colab-0.19.0/tests/test_env_manager.py +252 -0
  38. safe_colab-0.19.0/tests/test_execute_command_attribution.py +57 -0
  39. safe_colab-0.19.0/tests/test_guardian.py +467 -0
  40. safe_colab-0.19.0/tests/test_guardian_live.py +173 -0
  41. safe_colab-0.19.0/tests/test_host.py +227 -0
  42. safe_colab-0.19.0/tests/test_human_size.py +44 -0
  43. safe_colab-0.19.0/tests/test_import_env_extract_safe.py +58 -0
  44. safe_colab-0.19.0/tests/test_inspect_columns.py +75 -0
  45. safe_colab-0.19.0/tests/test_kernel.py +78 -0
  46. safe_colab-0.19.0/tests/test_kernel_timeout.py +98 -0
  47. safe_colab-0.19.0/tests/test_kernel_walltime_timeout.py +108 -0
  48. safe_colab-0.19.0/tests/test_market_auth_0326.py +101 -0
  49. safe_colab-0.19.0/tests/test_market_buy.py +84 -0
  50. safe_colab-0.19.0/tests/test_market_format.py +126 -0
  51. safe_colab-0.19.0/tests/test_market_launch.py +100 -0
  52. safe_colab-0.19.0/tests/test_market_parse.py +53 -0
  53. safe_colab-0.19.0/tests/test_marketplace_heartbeat.py +111 -0
  54. safe_colab-0.19.0/tests/test_meta_dataset_id.py +100 -0
  55. safe_colab-0.19.0/tests/test_micromamba_extract_safe.py +75 -0
  56. safe_colab-0.19.0/tests/test_output_audit_channels.py +331 -0
  57. safe_colab-0.19.0/tests/test_owner.py +191 -0
  58. safe_colab-0.19.0/tests/test_owner_earnings.py +55 -0
  59. safe_colab-0.19.0/tests/test_remote_collect.py +92 -0
  60. safe_colab-0.19.0/tests/test_remote_start.py +138 -0
  61. safe_colab-0.19.0/tests/test_run_code_image_gate_0296.py +108 -0
  62. safe_colab-0.19.0/tests/test_run_one.py +126 -0
  63. safe_colab-0.19.0/tests/test_sandbox.py +324 -0
  64. safe_colab-0.19.0/tests/test_sandbox_tiered.py +242 -0
  65. safe_colab-0.19.0/tests/test_service_failclosed.py +141 -0
  66. safe_colab-0.19.0/tests/test_service_logs.py +128 -0
  67. safe_colab-0.19.0/tests/test_session_cleanup.py +119 -0
  68. safe_colab-0.19.0/tests/test_session_guardian_probe.py +88 -0
  69. safe_colab-0.19.0/tests/test_session_manager.py +353 -0
  70. safe_colab-0.19.0/tests/test_start_guardian_probe.py +71 -0
  71. safe_colab-0.19.0/tests/test_test_guardian_classify.py +51 -0
  72. safe_colab-0.19.0/tests/test_test_guardian_cmd.py +119 -0
  73. safe_colab-0.19.0/tests/test_topup.py +176 -0
  74. safe_colab-0.19.0/tests/test_upload_audit_coverage.py +96 -0
  75. safe_colab-0.19.0/tests/test_upload_binary_failclosed_0291.py +108 -0
  76. safe_colab-0.19.0/tests/test_upload_binary_gate_0120.py +95 -0
  77. safe_colab-0.19.0/tests/test_upload_file_attribution.py +66 -0
  78. safe_colab-0.19.0/tests/test_upload_stream_0076.py +100 -0
  79. safe_colab-0.19.0/tests/test_upload_toctou_0308.py +108 -0
  80. 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,3 @@
1
+ """Safe Colab CLI - Sandboxed Jupyter kernel with remote Hypha service access."""
2
+
3
+ __version__ = "0.19.0"
@@ -0,0 +1,4 @@
1
+ """Allow running as python -m safe_colab."""
2
+ from .cli import main
3
+
4
+ main()
@@ -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")