lightcone-cli 0.4.2__tar.gz → 0.5.0rc2__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.
- lightcone_cli-0.5.0rc2/PKG-INFO +83 -0
- lightcone_cli-0.5.0rc2/README.md +54 -0
- lightcone_cli-0.5.0rc2/pyproject.toml +111 -0
- lightcone_cli-0.5.0rc2/src/lightcone/_sandbox_exec.py +328 -0
- {lightcone_cli-0.4.2 → lightcone_cli-0.5.0rc2}/src/lightcone/cli/__init__.py +5 -7
- lightcone_cli-0.5.0rc2/src/lightcone/cli/commands.py +448 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/__init__.py +9 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/assets.py +547 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/container.py +693 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/crate.py +688 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/dataset.py +497 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/identity.py +348 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/image.py +386 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/materialize.py +1097 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/plan.py +349 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/project.py +906 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/run.py +140 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/__init__.py +38 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/boundary.py +297 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/denial.py +204 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/landlock.py +117 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/model.py +164 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/oci.py +128 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/policy.py +471 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/profiles/base.sbpl +139 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/profiles/network.sbpl +48 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/profiles/platform-defaults.sbpl +211 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/sandbox/seatbelt.py +273 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/__init__.py +329 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/data-README.md.tmpl +10 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/datalad-config.tmpl +2 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/gitattributes.tmpl +15 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/gitignore.tmpl +8 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/index.md.tmpl +29 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/myst.yml.tmpl +12 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/pyproject.toml.tmpl +8 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/templates/files/results-README.md.tmpl +9 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/venue.py +326 -0
- lightcone_cli-0.5.0rc2/src/lightcone/engine/worker.py +470 -0
- lightcone_cli-0.4.2/PKG-INFO +0 -88
- lightcone_cli-0.4.2/README.md +0 -52
- lightcone_cli-0.4.2/claude/lightcone/agents/lc-extractor.md +0 -113
- lightcone_cli-0.4.2/claude/lightcone/hooks.json +0 -30
- lightcone_cli-0.4.2/claude/lightcone/scripts/activate-venv.sh +0 -23
- lightcone_cli-0.4.2/claude/lightcone/scripts/session-start.sh +0 -80
- lightcone_cli-0.4.2/claude/lightcone/scripts/validate-on-save.sh +0 -44
- lightcone_cli-0.4.2/claude/lightcone/skills/README.md +0 -42
- lightcone_cli-0.4.2/claude/lightcone/skills/astra/SKILL.md +0 -397
- lightcone_cli-0.4.2/claude/lightcone/skills/check-sentence-by-sentence/SKILL.md +0 -371
- lightcone_cli-0.4.2/claude/lightcone/skills/figure-comparison/SKILL.md +0 -591
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-cli/SKILL.md +0 -110
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-feedback/SKILL.md +0 -93
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-code/SKILL.md +0 -132
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/SKILL.md +0 -164
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/architect.md +0 -115
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/compare.md +0 -118
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/implement.md +0 -109
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/literature.md +0 -199
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/orient.md +0 -235
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/review.md +0 -108
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/run.md +0 -57
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/references/specify.md +0 -195
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/templates/CLAUDE.md +0 -36
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-from-paper/templates/constitution.md +0 -45
- lightcone_cli-0.4.2/claude/lightcone/skills/lc-new/SKILL.md +0 -179
- lightcone_cli-0.4.2/claude/lightcone/skills/paper-extraction/SKILL.md +0 -238
- lightcone_cli-0.4.2/claude/lightcone/skills/paper-extraction/references/arxiv-source.md +0 -47
- lightcone_cli-0.4.2/claude/lightcone/skills/paper-extraction/references/pdf-fallback.md +0 -66
- lightcone_cli-0.4.2/claude/lightcone/skills/paper-extraction/scripts/extract-paper-substrate.py +0 -1473
- lightcone_cli-0.4.2/claude/lightcone/skills/ralph/SKILL.md +0 -195
- lightcone_cli-0.4.2/claude/lightcone/skills/ralph/references/constitution.md +0 -133
- lightcone_cli-0.4.2/claude/lightcone/skills/ralph/references/crafting.md +0 -181
- lightcone_cli-0.4.2/claude/lightcone/skills/ralph/scripts/ralph +0 -145
- lightcone_cli-0.4.2/claude/lightcone/templates/CLAUDE.md +0 -22
- lightcone_cli-0.4.2/pyproject.toml +0 -140
- lightcone_cli-0.4.2/src/lightcone/cli/commands.py +0 -1254
- lightcone_cli-0.4.2/src/lightcone/cli/plugin.py +0 -34
- lightcone_cli-0.4.2/src/lightcone/engine/__init__.py +0 -6
- lightcone_cli-0.4.2/src/lightcone/engine/cloudbuild.py +0 -415
- lightcone_cli-0.4.2/src/lightcone/engine/container.py +0 -896
- lightcone_cli-0.4.2/src/lightcone/engine/dask_cluster.py +0 -516
- lightcone_cli-0.4.2/src/lightcone/engine/manifest.py +0 -216
- lightcone_cli-0.4.2/src/lightcone/engine/runner.py +0 -110
- lightcone_cli-0.4.2/src/lightcone/engine/scratch.py +0 -208
- lightcone_cli-0.4.2/src/lightcone/engine/site_registry.py +0 -205
- lightcone_cli-0.4.2/src/lightcone/engine/snakefile.py +0 -487
- lightcone_cli-0.4.2/src/lightcone/engine/status.py +0 -170
- lightcone_cli-0.4.2/src/lightcone/engine/tree.py +0 -375
- lightcone_cli-0.4.2/src/lightcone/engine/validation.py +0 -180
- lightcone_cli-0.4.2/src/lightcone/engine/verify.py +0 -140
- lightcone_cli-0.4.2/src/lightcone/engine/wrroc.py +0 -885
- lightcone_cli-0.4.2/src/lightcone/eval/__init__.py +0 -25
- lightcone_cli-0.4.2/src/lightcone/eval/build.py +0 -111
- lightcone_cli-0.4.2/src/lightcone/eval/cli.py +0 -176
- lightcone_cli-0.4.2/src/lightcone/eval/graders.py +0 -197
- lightcone_cli-0.4.2/src/lightcone/eval/harness.py +0 -285
- lightcone_cli-0.4.2/src/lightcone/eval/models.py +0 -117
- lightcone_cli-0.4.2/src/lightcone/eval/report.py +0 -214
- lightcone_cli-0.4.2/src/lightcone/eval/sandbox.py +0 -406
- lightcone_cli-0.4.2/src/snakemake_executor_plugin_dask/__init__.py +0 -29
- lightcone_cli-0.4.2/src/snakemake_executor_plugin_dask/executor.py +0 -229
- {lightcone_cli-0.4.2 → lightcone_cli-0.5.0rc2}/.gitignore +0 -0
- {lightcone_cli-0.4.2 → lightcone_cli-0.5.0rc2}/LICENSE +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lightcone-cli
|
|
3
|
+
Version: 0.5.0rc2
|
|
4
|
+
Summary: Lightcone Research's agentic execution layer
|
|
5
|
+
Project-URL: Repository, https://github.com/LightconeResearch/lightcone-cli
|
|
6
|
+
Author: Lightcone Research
|
|
7
|
+
License: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: astra-tools==0.2.17
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: distributed>=2026.7
|
|
25
|
+
Requires-Dist: git-annex>=10.2026
|
|
26
|
+
Requires-Dist: rich>=13.0
|
|
27
|
+
Requires-Dist: rocrate>=0.15
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# lightcone-cli
|
|
31
|
+
|
|
32
|
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
|
33
|
+
[](https://pypi.org/project/lightcone-cli/)
|
|
34
|
+
[](https://pypi.org/project/lightcone-cli/)
|
|
35
|
+
[](https://github.com/LightconeResearch/lightcone-cli/actions/workflows/tests.yml)
|
|
36
|
+
|
|
37
|
+
**lightcone-cli** (`lc`) is the execution layer for
|
|
38
|
+
[ASTRA](https://astra-spec.org/latest/) (Agentic Schema for Transparent
|
|
39
|
+
Research Analysis). Describe your analysis in an `astra.yaml`
|
|
40
|
+
specification and `lc` takes care of the rest — execution, environments,
|
|
41
|
+
and provenance.
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
**lightcone-cli** only requires you to have `uv` installed on your environment, and will take care of everything else. See how to install uv here: [https://docs.astral.sh/uv/getting-started/installation](https://docs.astral.sh/uv/getting-started/installation).
|
|
46
|
+
|
|
47
|
+
Then to install **lightcone-cli**:
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install lightcone-cli
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Once the CLI is installed, you can use it to create an ASTRA project and generate outputs like so:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
lc init my-analysis
|
|
56
|
+
cd my-analysis
|
|
57
|
+
# describe your analysis in astra.yaml, write your scripts,
|
|
58
|
+
# declare what they import through normal uv interactions:
|
|
59
|
+
uv add numpy
|
|
60
|
+
# When you are done with your edits, commit:
|
|
61
|
+
git add -A && git commit -m "First analysis"
|
|
62
|
+
# Use the lightcone CLI to generate your outputs with full provenance tracking
|
|
63
|
+
lc materialize
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
ASTRA specs are plain, structured YAML — they work well hand-written or
|
|
67
|
+
drafted with any AI coding assistant.
|
|
68
|
+
|
|
69
|
+
→ [Full getting-started guide](https://docs.lightconeresearch.org/user/getting-started/)
|
|
70
|
+
|
|
71
|
+
## Capabilities
|
|
72
|
+
|
|
73
|
+
- **Multiverse analysis** — declare methodological decisions with multiple defensible options; `lc` materializes your analysis across every universe you define
|
|
74
|
+
- **Provenance by construction** — every output is committed to git together with a content-addressed manifest and a re-runnable run record; git-annex carries the bytes, so results travel with the repository
|
|
75
|
+
- **Locked, isolated execution** — a project's environment is `pyproject.toml` + `uv.lock`; recipes run in it under a sandbox (Landlock on Linux, Seatbelt on macOS) that keeps undeclared files out and stray writes contained
|
|
76
|
+
- **Containers and HPC** — declare `[tool.lightcone.image]` and recipes run in a content-addressed image archived in the repository itself; a SLURM allocation is detected and used automatically, every node included
|
|
77
|
+
- **Publication view** — declare a license and `lc materialize` maintains an [RO-Crate](https://www.researchobject.org/ro-crate/) of the project and its provenance, ready to archive or deposit
|
|
78
|
+
|
|
79
|
+
→ [Full documentation](https://docs.lightconeresearch.org)
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
BSD 3-Clause — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# lightcone-cli
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/BSD-3-Clause)
|
|
4
|
+
[](https://pypi.org/project/lightcone-cli/)
|
|
5
|
+
[](https://pypi.org/project/lightcone-cli/)
|
|
6
|
+
[](https://github.com/LightconeResearch/lightcone-cli/actions/workflows/tests.yml)
|
|
7
|
+
|
|
8
|
+
**lightcone-cli** (`lc`) is the execution layer for
|
|
9
|
+
[ASTRA](https://astra-spec.org/latest/) (Agentic Schema for Transparent
|
|
10
|
+
Research Analysis). Describe your analysis in an `astra.yaml`
|
|
11
|
+
specification and `lc` takes care of the rest — execution, environments,
|
|
12
|
+
and provenance.
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
**lightcone-cli** only requires you to have `uv` installed on your environment, and will take care of everything else. See how to install uv here: [https://docs.astral.sh/uv/getting-started/installation](https://docs.astral.sh/uv/getting-started/installation).
|
|
17
|
+
|
|
18
|
+
Then to install **lightcone-cli**:
|
|
19
|
+
```bash
|
|
20
|
+
uv tool install lightcone-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Once the CLI is installed, you can use it to create an ASTRA project and generate outputs like so:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
lc init my-analysis
|
|
27
|
+
cd my-analysis
|
|
28
|
+
# describe your analysis in astra.yaml, write your scripts,
|
|
29
|
+
# declare what they import through normal uv interactions:
|
|
30
|
+
uv add numpy
|
|
31
|
+
# When you are done with your edits, commit:
|
|
32
|
+
git add -A && git commit -m "First analysis"
|
|
33
|
+
# Use the lightcone CLI to generate your outputs with full provenance tracking
|
|
34
|
+
lc materialize
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
ASTRA specs are plain, structured YAML — they work well hand-written or
|
|
38
|
+
drafted with any AI coding assistant.
|
|
39
|
+
|
|
40
|
+
→ [Full getting-started guide](https://docs.lightconeresearch.org/user/getting-started/)
|
|
41
|
+
|
|
42
|
+
## Capabilities
|
|
43
|
+
|
|
44
|
+
- **Multiverse analysis** — declare methodological decisions with multiple defensible options; `lc` materializes your analysis across every universe you define
|
|
45
|
+
- **Provenance by construction** — every output is committed to git together with a content-addressed manifest and a re-runnable run record; git-annex carries the bytes, so results travel with the repository
|
|
46
|
+
- **Locked, isolated execution** — a project's environment is `pyproject.toml` + `uv.lock`; recipes run in it under a sandbox (Landlock on Linux, Seatbelt on macOS) that keeps undeclared files out and stray writes contained
|
|
47
|
+
- **Containers and HPC** — declare `[tool.lightcone.image]` and recipes run in a content-addressed image archived in the repository itself; a SLURM allocation is detected and used automatically, every node included
|
|
48
|
+
- **Publication view** — declare a license and `lc materialize` maintains an [RO-Crate](https://www.researchobject.org/ro-crate/) of the project and its provenance, ready to archive or deposit
|
|
49
|
+
|
|
50
|
+
→ [Full documentation](https://docs.lightconeresearch.org)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
BSD 3-Clause — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lightcone-cli"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Lightcone Research's agentic execution layer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "BSD-3-Clause" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Lightcone Research" }
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"License :: OSI Approved :: BSD License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Topic :: Scientific/Engineering",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
dependencies = [
|
|
27
|
+
"astra-tools==0.2.17",
|
|
28
|
+
"click>=8.0",
|
|
29
|
+
"rich>=13.0",
|
|
30
|
+
"git-annex>=10.2026",
|
|
31
|
+
"distributed>=2026.7",
|
|
32
|
+
"rocrate>=0.15",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"pytest-cov",
|
|
39
|
+
"ruff",
|
|
40
|
+
"mypy",
|
|
41
|
+
"datalad",
|
|
42
|
+
"roc-validator>=0.11.3",
|
|
43
|
+
]
|
|
44
|
+
docs = [
|
|
45
|
+
"zensical>=0.0.33",
|
|
46
|
+
# squidfunk's mike fork — required by zensical's versioning provider.
|
|
47
|
+
# Not on PyPI; install from git.
|
|
48
|
+
"mike @ git+https://github.com/squidfunk/mike.git ; python_version >= '3.10'",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Repository = "https://github.com/LightconeResearch/lightcone-cli"
|
|
54
|
+
|
|
55
|
+
[project.scripts]
|
|
56
|
+
lc = "lightcone.cli:main"
|
|
57
|
+
# The git-annex wheel's own entry points, re-declared verbatim: an
|
|
58
|
+
# installer links only the requested package's executables, and a plain
|
|
59
|
+
# `uv tool install lightcone-cli` must put git-annex on the shell's PATH
|
|
60
|
+
# — `filter=annex` makes the researcher's own `git add` need it. The
|
|
61
|
+
# suite pins these against the wheel's, so an upstream change fails
|
|
62
|
+
# loudly here rather than silently at install time.
|
|
63
|
+
git-annex = "git_annex:cli"
|
|
64
|
+
git-annex-shell = "git_annex:cli"
|
|
65
|
+
git-remote-annex = "git_annex:cli"
|
|
66
|
+
git-remote-tor-annex = "git_annex:cli"
|
|
67
|
+
|
|
68
|
+
[build-system]
|
|
69
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
70
|
+
build-backend = "hatchling.build"
|
|
71
|
+
|
|
72
|
+
[tool.hatch.version]
|
|
73
|
+
source = "vcs"
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.wheel]
|
|
76
|
+
packages = ["src/lightcone"]
|
|
77
|
+
|
|
78
|
+
[tool.hatch.build.targets.sdist]
|
|
79
|
+
include = ["src/lightcone"]
|
|
80
|
+
|
|
81
|
+
[tool.ruff]
|
|
82
|
+
target-version = "py311"
|
|
83
|
+
line-length = 100
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
87
|
+
|
|
88
|
+
[tool.mypy]
|
|
89
|
+
python_version = "3.11"
|
|
90
|
+
strict = true
|
|
91
|
+
namespace_packages = true
|
|
92
|
+
explicit_package_bases = true
|
|
93
|
+
mypy_path = "src"
|
|
94
|
+
|
|
95
|
+
[[tool.mypy.overrides]]
|
|
96
|
+
module = ["astra.*", "rocrate.*"]
|
|
97
|
+
ignore_missing_imports = true
|
|
98
|
+
follow_untyped_imports = true
|
|
99
|
+
|
|
100
|
+
# rocrate ships no type information, and engine/crate.py is a client of
|
|
101
|
+
# little else — one module-scoped relaxation beats an ignore on every call.
|
|
102
|
+
[[tool.mypy.overrides]]
|
|
103
|
+
module = ["lightcone.engine.crate"]
|
|
104
|
+
disallow_untyped_calls = false
|
|
105
|
+
|
|
106
|
+
[tool.pytest.ini_options]
|
|
107
|
+
testpaths = ["tests"]
|
|
108
|
+
# The container smoke tests leave ~200 MB image archives in each test's
|
|
109
|
+
# tmp_path; keeping every passing run's directories exhausts a tmpfs.
|
|
110
|
+
tmp_path_retention_policy = "failed"
|
|
111
|
+
tmp_path_retention_count = 1
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""The Landlock exec shim — ``python -m lightcone._sandbox_exec``.
|
|
2
|
+
|
|
3
|
+
Landlock is a *self*-restriction: a process narrows its own access rights
|
|
4
|
+
and can never widen them again, and the restriction is inherited by
|
|
5
|
+
everything it execs. There is no way to restrict *another* process, so
|
|
6
|
+
there is no ``sandbox-exec``-shaped command to wrap a recipe in. This
|
|
7
|
+
module is that missing command: it reads a policy, restricts itself,
|
|
8
|
+
and ``execvp``'s the real command. That is what lets the engine treat
|
|
9
|
+
Landlock and Seatbelt as the same thing — a pure argv rewrite (see
|
|
10
|
+
:mod:`lightcone.engine.sandbox`).
|
|
11
|
+
|
|
12
|
+
The policy arrives as **JSON on argv**, not as an inherited ruleset FD —
|
|
13
|
+
the shape the codex CLI uses. A Landlock FD cannot be reopened, so
|
|
14
|
+
whether one would survive ``uv run``'s spawn/exec chain is a question
|
|
15
|
+
serializing the policy never has to answer, and it is what keeps the
|
|
16
|
+
engine's argv rewrite a pure function.
|
|
17
|
+
|
|
18
|
+
Two properties of this module are load-bearing and pinned by tests:
|
|
19
|
+
|
|
20
|
+
- **Stdlib only, and no lightcone imports.** ``lightcone`` is a PEP 420
|
|
21
|
+
namespace package with no ``__init__``, so ``-m lightcone._sandbox_exec``
|
|
22
|
+
executes this file and nothing else. It runs on every sandboxed exec;
|
|
23
|
+
it must not drag the engine in.
|
|
24
|
+
- **It never proceeds unsandboxed.** Every setup failure exits
|
|
25
|
+
:data:`SETUP_FAILURE_EXIT` with a distinguishable message, so a broken
|
|
26
|
+
sandbox can never be mistaken for a working one (the "silent
|
|
27
|
+
best-effort" trap this design exists to avoid).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import ctypes
|
|
33
|
+
import errno
|
|
34
|
+
import functools
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import stat
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
# --- the kernel interface --------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# asm-generic syscall numbers — identical on x86_64 and aarch64.
|
|
43
|
+
SYS_LANDLOCK_CREATE_RULESET = 444
|
|
44
|
+
SYS_LANDLOCK_ADD_RULE = 445
|
|
45
|
+
SYS_LANDLOCK_RESTRICT_SELF = 446
|
|
46
|
+
|
|
47
|
+
_LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
|
|
48
|
+
_RULE_PATH_BENEATH = 1
|
|
49
|
+
_PR_SET_NO_NEW_PRIVS = 38
|
|
50
|
+
|
|
51
|
+
# Access-right bits, grouped by the ABI that introduced them.
|
|
52
|
+
ACCESS_FS_EXECUTE = 1 << 0
|
|
53
|
+
ACCESS_FS_WRITE_FILE = 1 << 1
|
|
54
|
+
ACCESS_FS_READ_FILE = 1 << 2
|
|
55
|
+
ACCESS_FS_READ_DIR = 1 << 3
|
|
56
|
+
_ABI1_ALL = (1 << 13) - 1 # bits 0..12: read, write, and every make/remove
|
|
57
|
+
ACCESS_FS_REFER = 1 << 13 # ABI ≥ 2
|
|
58
|
+
ACCESS_FS_TRUNCATE = 1 << 14 # ABI ≥ 3
|
|
59
|
+
|
|
60
|
+
#: Rights the kernel accepts on a rule whose parent is a regular file.
|
|
61
|
+
#: Everything else is directory-only and makes ``add_rule`` fail EINVAL.
|
|
62
|
+
_FILE_ONLY_BITS = (
|
|
63
|
+
ACCESS_FS_EXECUTE | ACCESS_FS_WRITE_FILE | ACCESS_FS_READ_FILE | ACCESS_FS_TRUNCATE
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
READ_BITS = ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR
|
|
67
|
+
EXEC_BITS = ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE
|
|
68
|
+
|
|
69
|
+
#: Landlock's syscall numbers are asm-generic, but the ABI is not defined
|
|
70
|
+
#: for every architecture; refuse rather than issue syscall 444 blind.
|
|
71
|
+
_SUPPORTED_ARCHES = frozenset({"x86_64", "aarch64", "arm64"})
|
|
72
|
+
|
|
73
|
+
#: Reserved exit code for a failure to *set up* the sandbox — never a
|
|
74
|
+
#: command's own. The engine renders it as an lc problem, not a recipe's.
|
|
75
|
+
SETUP_FAILURE_EXIT = 97
|
|
76
|
+
|
|
77
|
+
class _RulesetAttr(ctypes.Structure):
|
|
78
|
+
_fields_ = [
|
|
79
|
+
("handled_access_fs", ctypes.c_uint64),
|
|
80
|
+
("handled_access_net", ctypes.c_uint64),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _PathBeneathAttr(ctypes.Structure):
|
|
85
|
+
# The kernel struct is packed (u64 followed by s32). `_layout_` is the
|
|
86
|
+
# 3.14 spelling and is ignored by older ctypes, which honor `_pack_`.
|
|
87
|
+
_pack_ = 1
|
|
88
|
+
_layout_ = "ms"
|
|
89
|
+
_fields_ = [
|
|
90
|
+
("allowed_access", ctypes.c_uint64),
|
|
91
|
+
("parent_fd", ctypes.c_int32),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@functools.cache
|
|
96
|
+
def _libc() -> ctypes.CDLL:
|
|
97
|
+
"""The already-loaded libc.
|
|
98
|
+
|
|
99
|
+
Cached, and `CDLL(None)` rather than `find_library("c")`: this is
|
|
100
|
+
called once per rule, and `find_library` forks `ldconfig -p` every
|
|
101
|
+
time — 74 subprocesses for a 71-path policy, which measured at ~740 ms
|
|
102
|
+
of the shim's ~780 ms. `dlopen(NULL)` reaches the libc already mapped
|
|
103
|
+
into this process, needs no search, and drops `ctypes.util` (and the
|
|
104
|
+
`tempfile`/`random` chain behind it) off the import path.
|
|
105
|
+
"""
|
|
106
|
+
return ctypes.CDLL(None, use_errno=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def abi() -> int:
|
|
110
|
+
"""Probe the Landlock ABI this kernel supports.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The ABI level, or 0 if the kernel supports none.
|
|
114
|
+
"""
|
|
115
|
+
if os.uname().machine not in _SUPPORTED_ARCHES:
|
|
116
|
+
return 0
|
|
117
|
+
try:
|
|
118
|
+
result = _libc().syscall(
|
|
119
|
+
SYS_LANDLOCK_CREATE_RULESET, None, ctypes.c_size_t(0), ctypes.c_uint32(
|
|
120
|
+
_LANDLOCK_CREATE_RULESET_VERSION
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
except OSError: # pragma: no cover - no libc to load
|
|
124
|
+
return 0
|
|
125
|
+
return int(result) if result > 0 else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def handled_access(abi_level: int) -> int:
|
|
129
|
+
"""Build the mask of rights the ruleset declares it governs.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
abi_level: The ABI reported by :func:`abi`.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Every right this ABI knows about. A right the kernel does not
|
|
136
|
+
know is a ruleset it rejects outright.
|
|
137
|
+
"""
|
|
138
|
+
handled = _ABI1_ALL
|
|
139
|
+
if abi_level >= 2:
|
|
140
|
+
handled |= ACCESS_FS_REFER
|
|
141
|
+
if abi_level >= 3:
|
|
142
|
+
handled |= ACCESS_FS_TRUNCATE
|
|
143
|
+
return handled
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def write_bits(abi_level: int) -> int:
|
|
147
|
+
"""Build the mask a writable root is granted.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
abi_level: The ABI reported by :func:`abi`.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Everything but EXECUTE, which is granted per file.
|
|
154
|
+
"""
|
|
155
|
+
return (handled_access(abi_level) & ~ACCESS_FS_EXECUTE) | READ_BITS
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_ruleset(handled_fs: int) -> int:
|
|
159
|
+
"""Create an empty Landlock ruleset.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
handled_fs: The rights the ruleset governs.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A ruleset file descriptor.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
OSError: If the kernel refuses the ruleset.
|
|
169
|
+
"""
|
|
170
|
+
attr = _RulesetAttr(handled_fs, 0)
|
|
171
|
+
fd = _libc().syscall(
|
|
172
|
+
SYS_LANDLOCK_CREATE_RULESET,
|
|
173
|
+
ctypes.byref(attr),
|
|
174
|
+
ctypes.c_size_t(ctypes.sizeof(attr)),
|
|
175
|
+
ctypes.c_uint32(0),
|
|
176
|
+
)
|
|
177
|
+
if fd < 0:
|
|
178
|
+
raise OSError(ctypes.get_errno(), "landlock_create_ruleset failed")
|
|
179
|
+
return int(fd)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def add_path_rule(ruleset_fd: int, path: str, access: int) -> None:
|
|
183
|
+
"""Grant access beneath a path.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
ruleset_fd: The ruleset to add to.
|
|
187
|
+
path: The directory or file to grant on.
|
|
188
|
+
access: The rights to grant, masked to what the path can bear.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
OSError: If the path cannot be opened or the rule is refused.
|
|
192
|
+
"""
|
|
193
|
+
parent_fd = os.open(path, os.O_PATH | os.O_CLOEXEC)
|
|
194
|
+
try:
|
|
195
|
+
# `stat.S_ISDIR`, not a bitwise test: S_IFSOCK (0o140000) and
|
|
196
|
+
# S_IFBLK (0o060000) both carry the S_IFDIR bit, so a hand-rolled
|
|
197
|
+
# mask calls a socket a directory, leaves directory-only rights
|
|
198
|
+
# unmasked, and turns a declared unix-socket input into EINVAL —
|
|
199
|
+
# surfacing as a sandbox *setup* failure rather than a grant.
|
|
200
|
+
if not stat.S_ISDIR(os.fstat(parent_fd).st_mode):
|
|
201
|
+
access &= _FILE_ONLY_BITS
|
|
202
|
+
if not access:
|
|
203
|
+
return
|
|
204
|
+
attr = _PathBeneathAttr(access, parent_fd)
|
|
205
|
+
result = _libc().syscall(
|
|
206
|
+
SYS_LANDLOCK_ADD_RULE,
|
|
207
|
+
ctypes.c_int(ruleset_fd),
|
|
208
|
+
ctypes.c_uint32(_RULE_PATH_BENEATH),
|
|
209
|
+
ctypes.byref(attr),
|
|
210
|
+
ctypes.c_uint32(0),
|
|
211
|
+
)
|
|
212
|
+
if result != 0:
|
|
213
|
+
code = ctypes.get_errno()
|
|
214
|
+
raise OSError(code, f"landlock_add_rule({path}): {errno.errorcode.get(code, code)}")
|
|
215
|
+
finally:
|
|
216
|
+
os.close(parent_fd)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_ruleset(policy: dict[str, object], abi_level: int) -> int:
|
|
220
|
+
"""Build a ruleset realizing a policy document.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
policy: The read, write and execute path lists from argv.
|
|
224
|
+
abi_level: The ABI reported by :func:`abi`.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
A ruleset file descriptor.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
OSError: If the ruleset cannot be created or a rule refused.
|
|
231
|
+
"""
|
|
232
|
+
fd = create_ruleset(handled_access(abi_level))
|
|
233
|
+
try:
|
|
234
|
+
for key, access in (
|
|
235
|
+
("read", READ_BITS),
|
|
236
|
+
("write", write_bits(abi_level)),
|
|
237
|
+
("execute", EXEC_BITS),
|
|
238
|
+
):
|
|
239
|
+
for path in _paths(policy, key):
|
|
240
|
+
try:
|
|
241
|
+
add_path_rule(fd, path, access)
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
continue
|
|
244
|
+
except BaseException:
|
|
245
|
+
os.close(fd)
|
|
246
|
+
raise
|
|
247
|
+
return fd
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def restrict_self(ruleset_fd: int) -> None:
|
|
251
|
+
"""Apply a ruleset to this process, irreversibly.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
ruleset_fd: The ruleset to enter.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
OSError: If ``no_new_privs`` or the restriction is refused.
|
|
258
|
+
"""
|
|
259
|
+
libc = _libc()
|
|
260
|
+
if libc.prctl(_PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0:
|
|
261
|
+
raise OSError(ctypes.get_errno(), "prctl(PR_SET_NO_NEW_PRIVS) failed")
|
|
262
|
+
if libc.syscall(SYS_LANDLOCK_RESTRICT_SELF, ctypes.c_int(ruleset_fd), ctypes.c_uint32(0)) != 0:
|
|
263
|
+
raise OSError(ctypes.get_errno(), "landlock_restrict_self failed")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --- the entry point -------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _paths(policy: dict[str, object], key: str) -> list[str]:
|
|
270
|
+
value = policy.get(key, [])
|
|
271
|
+
if not isinstance(value, list) or not all(isinstance(p, str) for p in value):
|
|
272
|
+
raise ValueError(f"policy field {key!r} must be a list of strings")
|
|
273
|
+
return list(value)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _fail(message: str) -> None:
|
|
277
|
+
sys.stderr.write(f"lc sandbox setup failed: {message}\n")
|
|
278
|
+
raise SystemExit(SETUP_FAILURE_EXIT)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main(argv: list[str] | None = None) -> None:
|
|
282
|
+
"""Restrict this process, then exec the command it was given.
|
|
283
|
+
|
|
284
|
+
``--policy <json> -- <command>...``. Never falls through to running
|
|
285
|
+
the command unsandboxed: a setup failure exits with a reserved code
|
|
286
|
+
that nothing a command could return will collide with.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
argv: Defaults to the process arguments.
|
|
290
|
+
"""
|
|
291
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
292
|
+
if len(args) < 2 or args[0] != "--policy":
|
|
293
|
+
_fail("usage: -m lightcone._sandbox_exec --policy <json> -- <command>...")
|
|
294
|
+
raw = args[1]
|
|
295
|
+
rest = args[2:]
|
|
296
|
+
if not rest or rest[0] != "--":
|
|
297
|
+
_fail("missing `--` before the command")
|
|
298
|
+
command = rest[1:]
|
|
299
|
+
if not command:
|
|
300
|
+
_fail("no command after `--`")
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
policy = json.loads(raw)
|
|
304
|
+
if not isinstance(policy, dict):
|
|
305
|
+
raise ValueError("policy must be a JSON object")
|
|
306
|
+
abi_level = abi()
|
|
307
|
+
if abi_level == 0:
|
|
308
|
+
raise ValueError("landlock unavailable (kernel < 5.13, or blocked by seccomp)")
|
|
309
|
+
fd = build_ruleset(policy, abi_level)
|
|
310
|
+
restrict_self(fd)
|
|
311
|
+
os.close(fd)
|
|
312
|
+
except (ValueError, OSError) as e:
|
|
313
|
+
_fail(str(e))
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
os.execvp(command[0], command)
|
|
317
|
+
except OSError as e:
|
|
318
|
+
# Not a setup failure: we are already restricted, so this is very
|
|
319
|
+
# likely the sandbox denying the exec. Report it the way a shell
|
|
320
|
+
# would — 126 "cannot execute", 127 "not found" — so the denial
|
|
321
|
+
# classifier sees a message and an exit code it recognizes, and
|
|
322
|
+
# exit 97 keeps meaning "lc's own sandbox setup broke".
|
|
323
|
+
sys.stderr.write(f"lc sandbox: {command[0]}: {e.strerror}\n")
|
|
324
|
+
raise SystemExit(127 if e.errno == errno.ENOENT else 126) from e
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
try:
|
|
6
|
-
from importlib.metadata import version
|
|
7
|
-
|
|
8
|
-
__version__ = version("lightcone-cli")
|
|
9
|
-
except Exception:
|
|
10
|
-
__version__ = "0.0.0.dev"
|
|
11
|
-
|
|
12
5
|
|
|
13
6
|
def main() -> None:
|
|
7
|
+
"""Run the CLI.
|
|
8
|
+
|
|
9
|
+
The console-script entry point. Imports the command module lazily, so
|
|
10
|
+
the cost of click and the engine is paid only once a command runs.
|
|
11
|
+
"""
|
|
14
12
|
from lightcone.cli.commands import main as _main
|
|
15
13
|
|
|
16
14
|
_main()
|