appjail 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appjail/__init__.py +2 -0
- appjail/__main__.py +8 -0
- appjail/appimage.py +278 -0
- appjail/attest.py +103 -0
- appjail/cli.py +137 -0
- appjail/cmd_add.py +230 -0
- appjail/cmd_apps.py +159 -0
- appjail/cmd_index.py +84 -0
- appjail/cmd_update.py +156 -0
- appjail/console.py +47 -0
- appjail/dbusproxy.py +110 -0
- appjail/fetcher.py +64 -0
- appjail/github.py +295 -0
- appjail/history.py +90 -0
- appjail/index.py +149 -0
- appjail/indexsync.py +216 -0
- appjail/jail.py +478 -0
- appjail/manifest.py +418 -0
- appjail/permargs.py +110 -0
- appjail/portal.py +137 -0
- appjail/runner.py +227 -0
- appjail/seccomp.py +123 -0
- appjail/snappy.py +66 -0
- appjail/store.py +66 -0
- appjail/textsafe.py +32 -0
- appjail/tools.py +118 -0
- appjail/updater.py +133 -0
- appjail/wayland.py +185 -0
- appjail-0.1.0.dist-info/METADATA +194 -0
- appjail-0.1.0.dist-info/RECORD +33 -0
- appjail-0.1.0.dist-info/WHEEL +4 -0
- appjail-0.1.0.dist-info/entry_points.txt +2 -0
- appjail-0.1.0.dist-info/licenses/LICENSES/MPL-2.0.txt +373 -0
appjail/__init__.py
ADDED
appjail/__main__.py
ADDED
appjail/appimage.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Alberto P
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""AppImage inspection, jailed extraction and validation of the extracted tree."""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import struct
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import jail
|
|
14
|
+
from .textsafe import clean
|
|
15
|
+
|
|
16
|
+
ELF_MAGIC = b"\x7fELF"
|
|
17
|
+
MAX_EXTRACTED_BYTES = 4 * 1024 ** 3
|
|
18
|
+
MAX_EXTRACTED_ENTRIES = 500_000
|
|
19
|
+
MAX_SYMLINK_HOPS = 40
|
|
20
|
+
UNSQUASHFS = "/usr/bin/unsquashfs"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotAnAppImage(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnsupportedImage(Exception):
|
|
28
|
+
"""A valid image appjail does not handle (DwarFS, unknown filesystem)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsafeTree(Exception):
|
|
32
|
+
"""The extracted tree violates a rule (escaping links, special files, no entry point)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExtractError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# What a caller of extract() or locate_fs() has to handle (jail failures are JailError and are separate).
|
|
40
|
+
EXTRACT_ERRORS = (UnsupportedImage, ExtractError, UnsafeTree)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sha256_file(path):
|
|
44
|
+
h = hashlib.sha256()
|
|
45
|
+
with open(path, "rb") as f:
|
|
46
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
47
|
+
h.update(chunk)
|
|
48
|
+
return h.hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tree_digest(root):
|
|
52
|
+
"""sha256 over every path, type, permission bits, file content and link target of a tree.
|
|
53
|
+
|
|
54
|
+
Nothing is followed: a symlink contributes its target text only. Detects any later change
|
|
55
|
+
to the extracted tree, including added, removed or re-linked entries.
|
|
56
|
+
"""
|
|
57
|
+
h = hashlib.sha256()
|
|
58
|
+
root = str(root)
|
|
59
|
+
stack = [""]
|
|
60
|
+
while stack:
|
|
61
|
+
rel = stack.pop()
|
|
62
|
+
with os.scandir(os.path.join(root, rel)) as it:
|
|
63
|
+
entries = sorted(it, key=lambda e: e.name)
|
|
64
|
+
for e in entries:
|
|
65
|
+
path = os.path.join(rel, e.name)
|
|
66
|
+
st = e.stat(follow_symlinks=False)
|
|
67
|
+
if stat.S_ISLNK(st.st_mode):
|
|
68
|
+
h.update(f"L {path}\0{os.readlink(e.path)}\0".encode("utf-8", "surrogateescape"))
|
|
69
|
+
elif stat.S_ISDIR(st.st_mode):
|
|
70
|
+
h.update(f"D {path}\0{stat.S_IMODE(st.st_mode):o}\0".encode("utf-8", "surrogateescape"))
|
|
71
|
+
stack.append(path)
|
|
72
|
+
else:
|
|
73
|
+
h.update(f"F {path}\0{stat.S_IMODE(st.st_mode):o}\0{st.st_size}\0".encode("utf-8", "surrogateescape"))
|
|
74
|
+
if stat.S_ISREG(st.st_mode):
|
|
75
|
+
fd = os.open(e.path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
76
|
+
with os.fdopen(fd, "rb") as f:
|
|
77
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
78
|
+
h.update(chunk)
|
|
79
|
+
return h.hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def appimage_type(path):
|
|
83
|
+
"""Return 2 for a type-2 AppImage; raise NotAnAppImage otherwise (type 1 is unsupported)."""
|
|
84
|
+
with open(path, "rb") as f:
|
|
85
|
+
head = f.read(11)
|
|
86
|
+
if len(head) < 11 or head[:4] != ELF_MAGIC:
|
|
87
|
+
raise NotAnAppImage("not an ELF executable")
|
|
88
|
+
if head[8:10] != b"AI":
|
|
89
|
+
raise NotAnAppImage("no AppImage marker (not an AppImage)")
|
|
90
|
+
if head[10] == 2:
|
|
91
|
+
return 2
|
|
92
|
+
raise NotAnAppImage(f"AppImage type {head[10]} is not supported (only type 2)")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def locate_fs(path):
|
|
96
|
+
"""(kind, offset) of the embedded filesystem: "squashfs" or "dwarfs", right after the ELF runtime."""
|
|
97
|
+
size = os.path.getsize(path)
|
|
98
|
+
with open(path, "rb") as f:
|
|
99
|
+
head = f.read(64)
|
|
100
|
+
if len(head) < 64 or head[:4] != ELF_MAGIC or head[4] != 2 or head[5] != 1:
|
|
101
|
+
raise UnsupportedImage("only 64-bit little-endian ELF runtimes are supported")
|
|
102
|
+
shoff, = struct.unpack_from("<Q", head, 0x28)
|
|
103
|
+
shentsize, shnum = struct.unpack_from("<HH", head, 0x3A)
|
|
104
|
+
offset = shoff + shentsize * shnum
|
|
105
|
+
if not 64 <= offset < size:
|
|
106
|
+
raise UnsupportedImage("cannot locate the embedded filesystem")
|
|
107
|
+
f.seek(offset)
|
|
108
|
+
magic = f.read(8)
|
|
109
|
+
if magic[:4] == b"hsqs":
|
|
110
|
+
return "squashfs", offset
|
|
111
|
+
if magic[:6] == b"DWARFS":
|
|
112
|
+
return "dwarfs", offset
|
|
113
|
+
raise UnsupportedImage("unknown embedded filesystem")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_tree(root):
|
|
117
|
+
"""Validate an extracted tree and make it safe to handle. Never follows symlinks.
|
|
118
|
+
|
|
119
|
+
Only regular files, directories and symlinks are allowed. Set-id and sticky bits are
|
|
120
|
+
cleared and directories are made owner-accessible so the tree can always be removed.
|
|
121
|
+
Sizes are apparent sizes, so sparse files cannot hide a huge tree. Returns (entries, bytes).
|
|
122
|
+
"""
|
|
123
|
+
root = Path(root)
|
|
124
|
+
count = total = 0
|
|
125
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
126
|
+
os.chmod(dirpath, (os.lstat(dirpath).st_mode & 0o777) | 0o700)
|
|
127
|
+
for name in dirnames + filenames:
|
|
128
|
+
p = os.path.join(dirpath, name)
|
|
129
|
+
st = os.lstat(p)
|
|
130
|
+
count += 1
|
|
131
|
+
if stat.S_ISLNK(st.st_mode) or stat.S_ISDIR(st.st_mode):
|
|
132
|
+
continue
|
|
133
|
+
if not stat.S_ISREG(st.st_mode):
|
|
134
|
+
raise UnsafeTree(f"special file in image: {clean(os.path.relpath(p, root), 200)}")
|
|
135
|
+
total += st.st_size
|
|
136
|
+
if st.st_mode & 0o7000:
|
|
137
|
+
os.chmod(p, st.st_mode & 0o777)
|
|
138
|
+
if total > MAX_EXTRACTED_BYTES or count > MAX_EXTRACTED_ENTRIES:
|
|
139
|
+
raise UnsafeTree("extracted tree is too large")
|
|
140
|
+
return count, total
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_entry(root, rel="AppRun"):
|
|
144
|
+
"""Resolve rel inside root as the jail would see it, refusing anything that leaves root.
|
|
145
|
+
|
|
146
|
+
Relative symlinks are followed; absolute targets and '..' above the root are rejected.
|
|
147
|
+
Returns the path of an executable regular file.
|
|
148
|
+
"""
|
|
149
|
+
root = os.path.realpath(root)
|
|
150
|
+
parts = [p for p in rel.split("/") if p]
|
|
151
|
+
resolved = []
|
|
152
|
+
hops = 0
|
|
153
|
+
while parts:
|
|
154
|
+
part = parts.pop(0)
|
|
155
|
+
if part == ".":
|
|
156
|
+
continue
|
|
157
|
+
if part == "..":
|
|
158
|
+
if not resolved:
|
|
159
|
+
raise UnsafeTree("entry point path escapes the image")
|
|
160
|
+
resolved.pop()
|
|
161
|
+
continue
|
|
162
|
+
candidate = os.path.join(root, *resolved, part)
|
|
163
|
+
try:
|
|
164
|
+
st = os.lstat(candidate)
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
raise UnsafeTree(f"{rel} not found in the image") from None
|
|
167
|
+
if stat.S_ISLNK(st.st_mode):
|
|
168
|
+
hops += 1
|
|
169
|
+
target = os.readlink(candidate)
|
|
170
|
+
if hops > MAX_SYMLINK_HOPS:
|
|
171
|
+
raise UnsafeTree("too many symlinks resolving the entry point")
|
|
172
|
+
if target.startswith("/"):
|
|
173
|
+
raise UnsafeTree(f"{rel} resolves through an absolute symlink ({clean(target, 100)})")
|
|
174
|
+
parts = [p for p in target.split("/") if p] + parts
|
|
175
|
+
else:
|
|
176
|
+
resolved.append(part)
|
|
177
|
+
final = os.path.join(root, *resolved)
|
|
178
|
+
st = os.lstat(final)
|
|
179
|
+
if not stat.S_ISREG(st.st_mode) or not st.st_mode & 0o111:
|
|
180
|
+
raise UnsafeTree(f"{rel} is not an executable file")
|
|
181
|
+
return final
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def remove_tree(path):
|
|
185
|
+
"""Delete a tree that may contain read-only directories. Never follows symlinks."""
|
|
186
|
+
path = str(path)
|
|
187
|
+
for dirpath, _, _ in os.walk(path, followlinks=False):
|
|
188
|
+
try:
|
|
189
|
+
if stat.S_ISDIR(os.lstat(dirpath).st_mode):
|
|
190
|
+
os.chmod(dirpath, 0o700)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def extract_dwarfs(appimage, dest, offset):
|
|
197
|
+
"""DwarFS image: extract with the pinned, hash-checked dwarfs-universal tool, inside the jail."""
|
|
198
|
+
from . import tools
|
|
199
|
+
try:
|
|
200
|
+
tool = tools.require("dwarfs")
|
|
201
|
+
except tools.ToolError as e:
|
|
202
|
+
raise ExtractError(str(e)) from e
|
|
203
|
+
dest = Path(dest)
|
|
204
|
+
if dest.exists():
|
|
205
|
+
raise ExtractError(f"{dest} already exists")
|
|
206
|
+
work = dest.with_name(dest.name + ".tmp")
|
|
207
|
+
remove_tree(work)
|
|
208
|
+
work.mkdir(mode=0o700)
|
|
209
|
+
try:
|
|
210
|
+
(work / "root").mkdir()
|
|
211
|
+
cmd = ["/dwarfs-tool", "--tool=dwarfsextract", "-i", "/app.AppImage", "-O", str(offset),
|
|
212
|
+
"-o", "/out/root", "--skip-devices", "--skip-specials"]
|
|
213
|
+
proc = _run_extraction(jail.extract_args(appimage, work, cmd, tools=((tool, "/dwarfs-tool"),)), work)
|
|
214
|
+
if proc.returncode != 0:
|
|
215
|
+
raise ExtractError("extraction failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 400))
|
|
216
|
+
tree = work / "root"
|
|
217
|
+
stats = check_tree(tree)
|
|
218
|
+
resolve_entry(tree, "AppRun")
|
|
219
|
+
os.replace(tree, dest)
|
|
220
|
+
return stats
|
|
221
|
+
finally:
|
|
222
|
+
remove_tree(work)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _run_extraction(args, work):
|
|
226
|
+
"""Run an extraction jail, watching its output. Killing it for size and finding the finished tree too large
|
|
227
|
+
are the same verdict, so both raise UnsafeTree whichever the watcher's poll happens to catch."""
|
|
228
|
+
try:
|
|
229
|
+
return jail.run(args, timeout=900, watch=(work, MAX_EXTRACTED_BYTES, MAX_EXTRACTED_ENTRIES))
|
|
230
|
+
except jail.OutputTooLarge:
|
|
231
|
+
raise UnsafeTree("extracted tree is too large") from None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def have_unsquashfs():
|
|
235
|
+
return os.access(UNSQUASHFS, os.X_OK)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def extract(appimage, dest, runtime_fallback=False):
|
|
239
|
+
"""Extract appimage into dest (must not exist) inside a no-network jail, then validate it.
|
|
240
|
+
|
|
241
|
+
Prefers unsquashfs on the filesystem offset, so the AppImage runtime never runs. With
|
|
242
|
+
runtime_fallback the image's own --appimage-extract is run, still inside the jail.
|
|
243
|
+
Returns (entries, bytes). On any failure dest is not left behind.
|
|
244
|
+
"""
|
|
245
|
+
appimage, dest = Path(appimage), Path(dest)
|
|
246
|
+
kind, offset = locate_fs(appimage)
|
|
247
|
+
if kind == "dwarfs":
|
|
248
|
+
return extract_dwarfs(appimage, dest, offset)
|
|
249
|
+
if not have_unsquashfs() and not runtime_fallback:
|
|
250
|
+
raise ExtractError("unsquashfs is not installed (apt install squashfs-tools)")
|
|
251
|
+
if dest.exists():
|
|
252
|
+
raise ExtractError(f"{dest} already exists")
|
|
253
|
+
work = dest.with_name(dest.name + ".tmp")
|
|
254
|
+
remove_tree(work)
|
|
255
|
+
work.mkdir(mode=0o700)
|
|
256
|
+
try:
|
|
257
|
+
if have_unsquashfs():
|
|
258
|
+
cmd = [UNSQUASHFS, "-no-progress", "-no-xattrs", "-p", "2", "-o", str(offset),
|
|
259
|
+
"-d", "/out/root", "/app.AppImage"]
|
|
260
|
+
tree = work / "root"
|
|
261
|
+
else:
|
|
262
|
+
# The runtime must be executable inside the jail: use a private copy.
|
|
263
|
+
copy = work / "app.AppImage"
|
|
264
|
+
shutil.copyfile(appimage, copy)
|
|
265
|
+
copy.chmod(0o700)
|
|
266
|
+
cmd = ["/out/app.AppImage", "--appimage-extract"]
|
|
267
|
+
tree = work / "squashfs-root"
|
|
268
|
+
proc = _run_extraction(jail.extract_args(appimage, work, cmd), work)
|
|
269
|
+
if proc.returncode != 0:
|
|
270
|
+
raise ExtractError("extraction failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 400))
|
|
271
|
+
if not tree.is_dir():
|
|
272
|
+
raise ExtractError("extraction produced no files")
|
|
273
|
+
stats = check_tree(tree)
|
|
274
|
+
resolve_entry(tree, "AppRun")
|
|
275
|
+
os.replace(tree, dest)
|
|
276
|
+
return stats
|
|
277
|
+
finally:
|
|
278
|
+
remove_tree(work)
|
appjail/attest.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Alberto P
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""Build-attestation verification (evidence tier 1) with the pinned GitHub CLI, inside a jail.
|
|
5
|
+
|
|
6
|
+
appjail fetches the Sigstore bundles itself (`github.Client.attestation_bundles`) and hands them to
|
|
7
|
+
`gh attestation verify --bundle`, which checks the signature chain against the Sigstore trusted root
|
|
8
|
+
(fetched over TUF, hence the network), that the bundle covers the exact downloaded bytes, and that the
|
|
9
|
+
signing identity belongs to the repository. `gh` runs from `appjail.tools` (size and sha256 pinned),
|
|
10
|
+
sees only the subject, the bundles and a scratch directory, and receives no token (it requires one to
|
|
11
|
+
be set but never uses it with `--bundle`). On top of gh's verdict appjail re-reads the certificate
|
|
12
|
+
fields and requires GitHub Actions as issuer, the exact repository, and, when known, the pinned
|
|
13
|
+
owner account id: a name is not enough, an account id is.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import secrets
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
from . import jail, store, tools
|
|
22
|
+
from .textsafe import clean
|
|
23
|
+
|
|
24
|
+
ISSUER = "https://token.actions.githubusercontent.com"
|
|
25
|
+
TIMEOUT = 180
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AttestError(Exception):
|
|
29
|
+
"""The attestation could not be verified (tool missing, no valid bundle, identity mismatch)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Verified:
|
|
34
|
+
repo: str # https://github.com/owner/repo
|
|
35
|
+
workflow: str # signing workflow with ref, from the certificate
|
|
36
|
+
ref: str
|
|
37
|
+
run_url: str
|
|
38
|
+
owner_id: str
|
|
39
|
+
repo_id: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _args(gh, subject, bundles, scratch):
|
|
43
|
+
return (jail.common_args() + ["--share-net"] + jail.etc_args(private_net=True)
|
|
44
|
+
+ ["--ro-bind", str(gh), "/gh", "--ro-bind", str(subject), "/subject",
|
|
45
|
+
"--ro-bind", str(bundles), "/bundle.jsonl", "--bind", str(scratch), "/scratch",
|
|
46
|
+
"--remount-ro", "/", "--chdir", "/scratch",
|
|
47
|
+
"--setenv", "HOME", "/scratch", "--setenv", "GH_TOKEN", "unused",
|
|
48
|
+
"--setenv", "GH_PROMPT_DISABLED", "1", "--setenv", "GH_NO_UPDATE_NOTIFIER", "1", "--"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_results(results, full_name, owner_id=None):
|
|
52
|
+
"""First verified result whose certificate matches the repository (and owner id); else AttestError."""
|
|
53
|
+
wanted = f"https://github.com/{full_name}".lower()
|
|
54
|
+
problems = []
|
|
55
|
+
for item in results if isinstance(results, list) else []:
|
|
56
|
+
try:
|
|
57
|
+
cert = item["verificationResult"]["signature"]["certificate"]
|
|
58
|
+
repo = str(cert["sourceRepositoryURI"])
|
|
59
|
+
if repo.lower() != wanted:
|
|
60
|
+
problems.append(f"signed for {repo}")
|
|
61
|
+
elif cert.get("issuer") != ISSUER:
|
|
62
|
+
problems.append(f"unexpected issuer {cert.get('issuer')}")
|
|
63
|
+
elif owner_id is not None and str(cert.get("sourceRepositoryOwnerIdentifier")) != str(owner_id):
|
|
64
|
+
problems.append("the signing repository's owner account differs from the pinned owner")
|
|
65
|
+
else:
|
|
66
|
+
return Verified(repo, str(cert.get("buildSignerURI", "")), str(cert.get("sourceRepositoryRef", "")),
|
|
67
|
+
str(cert.get("runInvocationURI", "")), str(cert.get("sourceRepositoryOwnerIdentifier", "")),
|
|
68
|
+
str(cert.get("sourceRepositoryIdentifier", "")))
|
|
69
|
+
except (KeyError, TypeError):
|
|
70
|
+
problems.append("unreadable verification result")
|
|
71
|
+
raise AttestError("no attestation matches this repository: " + ("; ".join(sorted(set(problems))) or "none verified"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify(subject, full_name, bundles, owner_id=None, run=jail.run):
|
|
75
|
+
"""Verify the downloaded file against its bundles. Returns Verified or raises AttestError."""
|
|
76
|
+
if not bundles:
|
|
77
|
+
raise AttestError("GitHub holds no attestation for this file")
|
|
78
|
+
try:
|
|
79
|
+
gh = tools.require("gh")
|
|
80
|
+
except tools.ToolError as e:
|
|
81
|
+
raise AttestError(str(e)) from e
|
|
82
|
+
work = store.staging_root() / ("attest-" + secrets.token_hex(8))
|
|
83
|
+
work.mkdir(parents=True, mode=0o700)
|
|
84
|
+
try:
|
|
85
|
+
bundle_file = work / "bundle.jsonl"
|
|
86
|
+
bundle_file.write_text("".join(json.dumps(b) + "\n" for b in bundles), encoding="utf-8")
|
|
87
|
+
scratch = work / "scratch"
|
|
88
|
+
scratch.mkdir(mode=0o700)
|
|
89
|
+
cmd = ["/gh", "attestation", "verify", "/subject", "--repo", full_name, "--bundle", "/bundle.jsonl",
|
|
90
|
+
"--format", "json"]
|
|
91
|
+
try:
|
|
92
|
+
proc = run(_args(gh, subject, bundle_file, scratch) + cmd, timeout=TIMEOUT)
|
|
93
|
+
except jail.JailError as e:
|
|
94
|
+
raise AttestError(str(e)) from e
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
raise AttestError("verification failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 300))
|
|
97
|
+
try:
|
|
98
|
+
results = json.loads(proc.stdout)
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
raise AttestError("verification output was not understood") from e
|
|
101
|
+
return check_results(results, full_name, owner_id)
|
|
102
|
+
finally:
|
|
103
|
+
shutil.rmtree(work, ignore_errors=True)
|
appjail/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Alberto P
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
|
|
4
|
+
"""Command line interface: the parser and the dispatcher. The commands live in cmd_*.py.
|
|
5
|
+
|
|
6
|
+
The names re-exported here are the ones tests and the `appjail` console script (pyproject.toml) import from `appjail.cli`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import github, index, indexsync, jail, manifest, runner, tools, updater
|
|
13
|
+
from .cmd_add import cmd_add
|
|
14
|
+
from .cmd_apps import cmd_history, cmd_list, cmd_perms, cmd_remove, cmd_tools
|
|
15
|
+
from .cmd_index import cmd_index_rollback, cmd_index_status, cmd_index_update, cmd_search
|
|
16
|
+
from .cmd_update import cmd_update
|
|
17
|
+
from .console import Abort, Console
|
|
18
|
+
from .permargs import PERMISSION_FLAGS, dirs_from_args, files_from_args
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser():
|
|
22
|
+
p = argparse.ArgumentParser(prog="appjail", description="Sandboxed AppImage manager")
|
|
23
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
24
|
+
add = sub.add_parser("add", help="add an app from a GitHub owner/repo")
|
|
25
|
+
add.add_argument("source", help="GitHub owner/repo")
|
|
26
|
+
add.add_argument("--name", help="local app name (default: repo name)")
|
|
27
|
+
add.add_argument("--prerelease", action="store_true", help="allow pre-releases")
|
|
28
|
+
add.add_argument("--tag", help="use this release tag instead of the latest release")
|
|
29
|
+
add.add_argument("--dir", action="append", default=[], metavar="PATH[:ro|:rw]",
|
|
30
|
+
help="let the app see this directory (read-only unless :rw); repeatable")
|
|
31
|
+
add.add_argument("--dbus", action="append", default=[], metavar="NAME",
|
|
32
|
+
help="let the app talk to this session-bus name through a filtering proxy; repeatable")
|
|
33
|
+
add.add_argument("--dbus-own", action="append", default=[], metavar="NAME",
|
|
34
|
+
help="let the app own this session-bus name (MPRIS player, single-instance name); repeatable")
|
|
35
|
+
add.add_argument("--portal", action="append", default=[], metavar="NAME",
|
|
36
|
+
help="also allow this privileged portal interface (needs --allow portals); repeatable")
|
|
37
|
+
add.add_argument("--allow", action="append", default=[], choices=[f for f in PERMISSION_FLAGS if f not in ("audio", "gpu")],
|
|
38
|
+
help="grant a permission (repeatable); audio and gpu are on by default")
|
|
39
|
+
add.add_argument("--no-audio", action="store_true", help="do not grant audio")
|
|
40
|
+
add.add_argument("--no-gpu", action="store_true", help="do not grant GPU access")
|
|
41
|
+
add.add_argument("--display", choices=manifest.DISPLAYS, default="wayland")
|
|
42
|
+
search = sub.add_parser("search", help="search the local AM index (suggestions only, never installs)")
|
|
43
|
+
search.add_argument("keywords", nargs="+")
|
|
44
|
+
search.add_argument("--limit", type=int, default=20, help="maximum results (default 20)")
|
|
45
|
+
search.add_argument("--all", action="store_true", help="show every match")
|
|
46
|
+
idx = sub.add_parser("index", help="manage the local AM search index")
|
|
47
|
+
isub = idx.add_subparsers(dest="index_command", required=True)
|
|
48
|
+
upd = isub.add_parser("update", help="fetch the AM index, review the changes, then pin it")
|
|
49
|
+
upd.add_argument("--commit", default="HEAD", help="fetch this commit (40 hex digits) instead of the remote HEAD")
|
|
50
|
+
isub.add_parser("rollback", help="go back to the previous index snapshot")
|
|
51
|
+
isub.add_parser("status", help="show the pinned index")
|
|
52
|
+
upd_app = sub.add_parser("update", help="check for a newer release and install it after review and quarantine")
|
|
53
|
+
upd_app.add_argument("app", nargs="?", help="app name (default: every installed app)")
|
|
54
|
+
upd_app.add_argument("--prerelease", action="store_true", help="follow pre-releases for this run (see --channel to make it permanent)")
|
|
55
|
+
upd_app.add_argument("--channel", choices=manifest.CHANNELS, help="set which releases this app follows from now on: stable, prerelease, or pinned (never updated)")
|
|
56
|
+
upd_app.add_argument("--quarantine", type=int, default=updater.DEFAULT_QUARANTINE_DAYS, metavar="DAYS",
|
|
57
|
+
help="days a new release must wait after it was first seen (default %(default)s; 0 = none)")
|
|
58
|
+
sub.add_parser("list", help="list installed apps")
|
|
59
|
+
tl = sub.add_parser("tools", help="pinned helper binaries (e.g. for DwarFS AppImages)")
|
|
60
|
+
tsub = tl.add_subparsers(dest="tools_command", required=True)
|
|
61
|
+
tsub.add_parser("status", help="show which helpers are installed and intact")
|
|
62
|
+
tinst = tsub.add_parser("install", help="download a helper whose size and sha256 are pinned in appjail's source")
|
|
63
|
+
tinst.add_argument("tool", choices=sorted(tools.TOOLS))
|
|
64
|
+
hist = sub.add_parser("history", help="show the recorded changes to the manifests")
|
|
65
|
+
hist.add_argument("app", nargs="?")
|
|
66
|
+
hist.add_argument("--limit", type=int, default=20)
|
|
67
|
+
rm = sub.add_parser("remove", help="delete an app, including its private home")
|
|
68
|
+
rm.add_argument("app")
|
|
69
|
+
rm.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
70
|
+
perms = sub.add_parser("perms", help="show or change an app's permissions (the pinned release is unchanged)")
|
|
71
|
+
perms.add_argument("app")
|
|
72
|
+
perms.add_argument("--allow", action="append", default=[], choices=PERMISSION_FLAGS)
|
|
73
|
+
perms.add_argument("--deny", action="append", default=[], choices=PERMISSION_FLAGS)
|
|
74
|
+
perms.add_argument("--display", choices=manifest.DISPLAYS)
|
|
75
|
+
perms.add_argument("--add-dir", action="append", default=[], metavar="PATH[:ro|:rw]")
|
|
76
|
+
perms.add_argument("--rm-dir", action="append", default=[], metavar="PATH")
|
|
77
|
+
perms.add_argument("--add-dbus", action="append", default=[], metavar="NAME", help="allow talking to this session-bus name")
|
|
78
|
+
perms.add_argument("--rm-dbus", action="append", default=[], metavar="NAME")
|
|
79
|
+
perms.add_argument("--add-dbus-own", action="append", default=[], metavar="NAME", help="allow owning this session-bus name")
|
|
80
|
+
perms.add_argument("--rm-dbus-own", action="append", default=[], metavar="NAME")
|
|
81
|
+
perms.add_argument("--add-portal", action="append", default=[], metavar="NAME", help="also allow this privileged portal interface")
|
|
82
|
+
perms.add_argument("--rm-portal", action="append", default=[], metavar="NAME")
|
|
83
|
+
perms.add_argument("--yes", action="store_true", help="do not ask for confirmation")
|
|
84
|
+
run = sub.add_parser("run", help="run an installed app in its jail")
|
|
85
|
+
run.add_argument("name")
|
|
86
|
+
run.add_argument("--dir", action="append", default=[], metavar="PATH[:ro|:rw]",
|
|
87
|
+
help="grant this directory for this run only (manifest unchanged); repeatable")
|
|
88
|
+
run.add_argument("--file", action="append", default=[], metavar="PATH[:rw]",
|
|
89
|
+
help="bind just this file for this run (read-only unless :rw) and pass its in-jail path to the app; repeatable")
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv=None, client=None, console=None):
|
|
94
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
95
|
+
app_args = []
|
|
96
|
+
if "--" in argv: # everything after -- belongs to the app
|
|
97
|
+
i = argv.index("--")
|
|
98
|
+
argv, app_args = argv[:i], argv[i + 1:]
|
|
99
|
+
args = build_parser().parse_args(argv)
|
|
100
|
+
console = console or Console()
|
|
101
|
+
try:
|
|
102
|
+
if args.command == "add":
|
|
103
|
+
if not manifest.SOURCE_RE.fullmatch(args.source):
|
|
104
|
+
raise Abort("source must be a GitHub owner/repo")
|
|
105
|
+
cmd_add(args, client or github.Client(), console)
|
|
106
|
+
elif args.command == "index":
|
|
107
|
+
if args.index_command == "update":
|
|
108
|
+
cmd_index_update(args, console)
|
|
109
|
+
elif args.index_command == "rollback":
|
|
110
|
+
cmd_index_rollback(console)
|
|
111
|
+
else:
|
|
112
|
+
cmd_index_status(console)
|
|
113
|
+
elif args.command == "search":
|
|
114
|
+
cmd_search(args, console)
|
|
115
|
+
elif args.command == "update":
|
|
116
|
+
return cmd_update(args, client or github.Client(), console)
|
|
117
|
+
elif args.command == "list":
|
|
118
|
+
cmd_list(console)
|
|
119
|
+
elif args.command == "tools":
|
|
120
|
+
cmd_tools(args, console)
|
|
121
|
+
elif args.command == "history":
|
|
122
|
+
cmd_history(args, console)
|
|
123
|
+
elif args.command == "remove":
|
|
124
|
+
cmd_remove(args, console)
|
|
125
|
+
elif args.command == "perms":
|
|
126
|
+
cmd_perms(args, console)
|
|
127
|
+
elif args.command == "run":
|
|
128
|
+
extra_dirs = dirs_from_args(args.dir)
|
|
129
|
+
extra_files = files_from_args(args.file)
|
|
130
|
+
return runner.launch(args.name, app_args, say=console.out, extra_dirs=extra_dirs, extra_files=extra_files)
|
|
131
|
+
except Abort as e:
|
|
132
|
+
console.out(str(e))
|
|
133
|
+
return 1
|
|
134
|
+
except (github.GitHubError, jail.JailError, jail.RunError, manifest.ManifestError, index.IndexProblem, indexsync.IndexRefused, OSError) as e:
|
|
135
|
+
console.out(f"error: {e}")
|
|
136
|
+
return 1
|
|
137
|
+
return 0
|