Skip to content

Commit 22a14b9

Browse files
authored
runtime: add workspace runtime (#228)
## Summary - split the challenge runtime Nix package into `runtime/platform` - add a `runtime/workspace` build env with a Go workspace entrypoint, supervisor, and uid 1000 agent - add dynamic workspace services for tty, desktop, and code, proxied over Unix sockets - package `pwnshop` as a proper Python application in the Nix store and remove the root wrapper ## Validation - `tools/git-hooks/pre-commit` - `nix fmt -- --fail-on-change` - `nix shell nixpkgs#go nixpkgs#gcc --command go test ./...` in `runtime/workspace/agent` - `python -m py_compile tools/pwnshop/src/pwnshop/commands/run.py tools/pwnshop/src/pwnshop/lib/__init__.py` - Nix builds for `runtime/platform`, `runtime/workspace`, and `tools/pwnshop` - packaged `pwnshop --help` and `pwnshop list challenges/program-misuse`
1 parent 0d1bdd3 commit 22a14b9

38 files changed

Lines changed: 2007 additions & 149 deletions

File tree

.github/actions/determine-modified-challenges/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ runs:
4848

4949
- name: List modified challenges
5050
id: list-challenges
51-
shell: 'nix shell nixpkgs#uv nixpkgs#git --command bash --noprofile --norc -euo pipefail {0}'
51+
shell: 'nix shell .#pwnshop --command bash --noprofile --norc -euo pipefail {0}'
5252
env:
5353
MODIFIED_SINCE: ${{ steps.resolve-ref.outputs.ref }}
5454
run: |
5555
challenges_file="$RUNNER_TEMP/modified-challenges.txt"
56-
./pwnshop list ./challenges --modified-since="$MODIFIED_SINCE" > "$challenges_file"
56+
pwnshop list ./challenges --modified-since="$MODIFIED_SINCE" > "$challenges_file"
5757
echo "challenges_file=$challenges_file" >> "$GITHUB_OUTPUT"
5858
5959
- uses: actions/github-script@v8

.github/actions/setup-nix/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: Install Nix and configure binary caches for this repo.
33
runs:
44
using: composite
55
steps:
6-
- uses: DeterminateSystems/determinate-nix-action@v3.17.1
6+
- uses: DeterminateSystems/determinate-nix-action@v3.21.1
77
with:
88
summarize: false
99
extra-conf: |

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ per archetype (templated web/service, interpreted checker, compiled SUID binary,
7777

7878
### Dev Environment (Nix)
7979

80-
`nix develop` is required for `pwnshop`. Always enter the dev shell before invoking `pwnshop` (or `./pwnshop`) -- if anything fails unexpectedly, your first check should be whether you are inside the dev shell.
80+
`nix develop` is required for `pwnshop`. Always enter the dev shell before invoking `pwnshop` -- if anything fails unexpectedly, your first check should be whether you are inside the dev shell.
8181

8282
```bash
8383
nix develop

README.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,21 @@ git crypt unlock
3232

3333
# Challenge CLI
3434

35-
All developer workflows now run through the `./pwnshop` command. The legacy `./build` helper has been removed, so every rendered, build, run, or test action should use the new CLI instead.
35+
All developer workflows now run through the `pwnshop` command provided by `nix develop`. The legacy `./build` helper and root `./pwnshop` wrapper have been removed, so every rendered, build, run, or test action should use the packaged CLI instead.
3636

3737
The CLI is implemented with Click and Rich (`tools/pwnshop/src/pwnshop/commands/*.py`) on top of the core helper library in `tools/pwnshop/src/pwnshop/lib/__init__.py`. Keeping formatting/terminal logic in the commands and reusable challenge logic in `tools/pwnshop/src/pwnshop/lib` makes it easy to add or modify commands without duplicating functionality.
3838

3939
All CLI subcommands accept either a direct filesystem path or a challenge slug. Slugs must include the module (e.g., `web-security/path-traversal-1`); the tool searches under `./challenges` for that module/challenge pair and errors if nothing matches.
4040

4141
Primary entry points:
4242

43-
- `./pwnshop list` — enumerate challenges, optionally filtered via `--modified-since`.
44-
- `./pwnshop render` — materialize a challenge directory or individual template (`--output` defaults to stdout and Rich labels each file when multiple outputs are rendered).
45-
- `./pwnshop build` — render and build a challenge, returning the Docker image ID.
46-
- `./pwnshop test` — render, build, and run all `test*/test_*` files inside the challenge.
47-
- `./pwnshop run` — render, build, and drop into an interactive shell inside the challenge container (use `--user=<uid>` to control the interactive user, default `1000`, `--volume <path>` to mount host paths read-only, or append a command after the challenge to run it instead of `/bin/bash`).
43+
- `pwnshop list` — enumerate challenges, optionally filtered via `--modified-since`.
44+
- `pwnshop render` — materialize a challenge directory or individual template (`--output` defaults to stdout and Rich labels each file when multiple outputs are rendered).
45+
- `pwnshop build` — render and build a challenge, returning the Docker image ID.
46+
- `pwnshop test` — render, build, and run all `test*/test_*` files inside the challenge.
47+
- `pwnshop run` — render, build, and drop into an interactive shell inside the challenge container (use `--user=<uid>` to control the interactive user, default `1000`, `--volume <path>` to mount host paths read-only, or append a command after the challenge to run it instead of `/bin/bash`).
4848

49-
Any future automation that renders, builds, runs, or tests challenges should shell out to `./pwnshop ...` rather than reimplementing those pieces of the workflow.
49+
Any future automation that renders, builds, runs, or tests challenges should shell out to `pwnshop ...` rather than reimplementing those pieces of the workflow.
5050

5151
The standalone `discord-feedback` helper is available in `nix develop` for Discord-derived curriculum feedback automation.
5252

@@ -122,7 +122,7 @@ nix develop
122122
More details: `docs/development.md`.
123123

124124
If you are not using Nix, you will need a working Docker setup and the Python tooling for `pwnshop`.
125-
At minimum, install `uv` and then use `./pwnshop ...` (it will manage Python deps from `tools/pwnshop/uv.lock`).
125+
At minimum, install the Python dependencies for `tools/pwnshop` or run it with `uv run --project tools/pwnshop --with-editable tools/pwnshop pwnshop ...`.
126126

127127
For legacy/manual environments, you can still install a basic set of Python packages in a virtual environment:
128128

@@ -134,19 +134,19 @@ pip install black click jinja2 pyastyle pwntools rich
134134

135135
```bash
136136
# run the full test suite for a challenge
137-
./pwnshop test web-security/path-traversal-1
137+
pwnshop test web-security/path-traversal-1
138138

139139
# build the Docker image without testing
140-
./pwnshop build web-security/path-traversal-1
140+
pwnshop build web-security/path-traversal-1
141141

142142
# render the challenge into a directory for inspection
143-
./pwnshop render web-security/path-traversal-1 --output /tmp/output
143+
pwnshop render web-security/path-traversal-1 --output /tmp/output
144144

145145
# render a single template file to stdout (or write to a file)
146-
./pwnshop render web-security/path-traversal-1/tests_public/test_normal.py.j2 --output /tmp/output-file
146+
pwnshop render web-security/path-traversal-1/tests_public/test_normal.py.j2 --output /tmp/output-file
147147

148148
# list challenges, optionally filtered by git history
149-
./pwnshop list --modified-since origin/main
149+
pwnshop list --modified-since origin/main
150150
```
151151

152152
## Important Notes / Common Gotchas
@@ -181,7 +181,7 @@ The process of porting is:
181181
6. If using templates, use `{% extends %}` and `{% block setup %}` for customization
182182
7. Ensure all executable files are marked as such: `chmod +x ./$MODULE_ID/$CHALLENGE_ID/**/*.j2`. Rendered files inherit permissions from the template.
183183
8. Port verification logic to `./$MODULE_ID/$CHALLENGE_ID/tests_public` (functionality) and `./$MODULE_ID/$CHALLENGE_ID/tests_private` (exploitation)
184-
9. Test thoroughly: `./pwnshop test $MODULE_ID/$CHALLENGE_ID`
184+
9. Test thoroughly: `pwnshop test $MODULE_ID/$CHALLENGE_ID`
185185
10. Once testcases pass, double-check the template (both rendered and at rest) against the legacy challenge to ensure that the challenge has been ported without any functionality change.
186186

187187
# Adding a new key

challenges/linux-luminarium/destruction/rm-rf-root-read/challenge/check

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import time
44
import os
55

6+
def entry_count(path):
7+
try:
8+
return len(os.listdir(path))
9+
except FileNotFoundError:
10+
return 0
11+
612
flag = open("/flag").read()
713

814
while True:
9-
if len(os.listdir("/")) > 10 or len(os.listdir("/usr")) > 3 or len(os.listdir("/etc")) > 5 or os.path.exists("/flag"):
15+
if entry_count("/") > 10 or entry_count("/usr") > 3 or entry_count("/etc") > 5 or os.path.exists("/flag"):
1016
print("Looks like you haven't wiped the system! We'll check again in 5 seconds...")
1117
else:
1218
print("YES! You did it again! Go read the flag!")

challenges/linux-luminarium/destruction/rm-rf-root-search/challenge/check

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import time
44
import os
55

6+
def entry_count(path):
7+
try:
8+
return len(os.listdir(path))
9+
except FileNotFoundError:
10+
return 0
11+
612
flag = open("/flag").read()
713

814
while True:
9-
if len(os.listdir("/")) > 10 or len(os.listdir("/usr")) > 3 or len(os.listdir("/etc")) > 5 or os.path.exists("/flag"):
15+
if entry_count("/") > 10 or entry_count("/usr") > 3 or entry_count("/etc") > 5 or os.path.exists("/flag"):
1016
print("Looks like you haven't wiped the system! We'll check again in 5 seconds...")
1117
else:
1218
print("YES! You did it again! Go read the flag!")

challenges/linux-luminarium/destruction/rm-rf-root/challenge/check

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
import time
44
import os
55

6+
def entry_count(path):
7+
try:
8+
return len(os.listdir(path))
9+
except FileNotFoundError:
10+
return 0
11+
612
flag = open("/flag").read()
713

814
while True:
9-
if len(os.listdir("/")) > 10 or len(os.listdir("/usr")) > 3 or len(os.listdir("/etc")) > 5 or os.path.exists("/flag"):
15+
if entry_count("/") > 10 or entry_count("/usr") > 3 or entry_count("/etc") > 5 or os.path.exists("/flag"):
1016
print("Looks like you haven't wiped the system! We'll check again in 5 seconds...")
1117
else:
1218
print("YES! You wiped it, you wild hacker! The flag is yours:")

docs/development.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ On shell entry, the dev shell will start the challenge runtime (using sudo). Thi
3030
Then use `pwnshop` for all workflows:
3131

3232
```bash
33-
./pwnshop list
34-
./pwnshop test web-security/path-traversal-1
33+
pwnshop list
34+
pwnshop test web-security/path-traversal-1
3535
```
3636

3737
## Community Dojo Workflow
@@ -66,15 +66,15 @@ Shared Dockerfiles and packages reuse Docker's layer cache, so the monorepo does
6666
- Check unit status/logs:
6767

6868
```bash
69-
systemctl status pwn-challenge-runtime.service
70-
journalctl -u pwn-challenge-runtime.service -b --no-pager
69+
systemctl status pwn-platform-runtime-docker.service
70+
journalctl -u pwn-platform-runtime-docker.service -b --no-pager
7171
```
7272

7373
## Non-Nix Setup (Not Recommended)
7474

7575
You can run `pwnshop` without Nix, but you must supply:
7676

77-
- `uv` (the `./pwnshop` wrapper uses `uv run`)
77+
- Python dependencies for `tools/pwnshop` (or use `uv run --project tools/pwnshop --with-editable tools/pwnshop pwnshop ...`)
7878
- a working Docker environment (daemon + client)
7979

8080
Private tests/solutions are encrypted with `git-crypt`; you will need access to the relevant module key(s) to unlock them.

flake.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flake.nix

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
};
1010

1111
inputs = {
12-
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
12+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
1313
};
1414

1515
outputs =
16-
{ self, nixpkgs }:
16+
{
17+
self,
18+
nixpkgs,
19+
}:
1720
let
1821
lib = nixpkgs.lib;
1922
systems = [ "x86_64-linux" ];
@@ -39,55 +42,57 @@
3942
}
4043
);
4144

42-
devShells = forAllSystems (
45+
packages = forAllSystems (
4346
system:
4447
let
4548
pkgs = import nixpkgs { inherit system; };
4649

47-
pwn-challenge-runtime = import ./runtime { inherit pkgs lib; };
50+
pwn-workspace-runtime = import ./runtime/workspace { inherit pkgs; };
51+
pwn-platform-runtime = import ./runtime/platform { inherit pkgs lib; };
4852

49-
pwnshop = pkgs.writeShellApplication {
50-
name = "pwnshop";
51-
runtimeInputs = with pkgs; [
52-
git
53-
uv
54-
];
55-
text = ''
56-
set -euo pipefail
57-
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
58-
exec "$root/pwnshop" "$@"
59-
'';
60-
};
53+
pwnshop = import ./tools/pwnshop { inherit pkgs pwn-workspace-runtime; };
54+
discord-feedback = import ./tools/feedback { inherit pkgs; };
55+
in
56+
{
57+
default = pwnshop;
58+
inherit
59+
discord-feedback
60+
pwn-platform-runtime
61+
pwn-workspace-runtime
62+
pwnshop
63+
;
64+
}
65+
);
6166

62-
discord-feedback = pkgs.writeShellApplication {
63-
name = "discord-feedback";
64-
runtimeInputs = with pkgs; [
65-
git
66-
uv
67-
];
68-
text = ''
69-
set -euo pipefail
70-
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
71-
exec "$root/tools/feedback/discord-feedback" "$@"
72-
'';
73-
};
67+
devShells = forAllSystems (
68+
system:
69+
let
70+
pkgs = import nixpkgs { inherit system; };
71+
72+
pwn-workspace-runtime = import ./runtime/workspace { inherit pkgs; };
73+
pwn-platform-runtime = import ./runtime/platform { inherit pkgs lib; };
74+
75+
pwnshop = import ./tools/pwnshop { inherit pkgs pwn-workspace-runtime; };
76+
discord-feedback = import ./tools/feedback { inherit pkgs; };
7477
in
7578
{
7679
default = pkgs.mkShell {
7780
packages = with pkgs; [
7881
asciinema
79-
clang-tools
82+
discord-feedback
8083
docker
8184
git
8285
git-crypt
8386
jq
84-
discord-feedback
85-
pwn-challenge-runtime
87+
pwn-platform-runtime
88+
pwn-workspace-runtime
8689
pwnshop
8790
tomlq
8891
uv
8992
];
9093
shellHook = ''
94+
export PWN_WORKSPACE="${pwn-workspace-runtime}"
95+
9196
# Install the secret-test encryption pre-commit hook (idempotent,
9297
# non-destructive). Resolve the path Git actually runs the hook from
9398
# -- honoring core.hooksPath and the shared hooks dir of a linked
@@ -107,14 +112,21 @@
107112
fi
108113
fi
109114
110-
if [ "$(id -u)" -eq 0 ]; then
111-
export DOCKER_HOST="$(${lib.getExe pwn-challenge-runtime})"
112-
elif command -v sudo >/dev/null 2>&1; then
113-
export DOCKER_HOST="$(sudo ${lib.getExe pwn-challenge-runtime})"
114-
else
115-
echo "error: cannot start the challenge runtime without root privileges" >&2
115+
sudo=
116+
if [ "$(id -u)" -ne 0 ]; then
117+
if command -v sudo >/dev/null 2>&1; then
118+
sudo=sudo
119+
else
120+
echo "error: cannot start the challenge runtime without root privileges" >&2
121+
return 1
122+
fi
123+
fi
124+
125+
if ! runtime_environment="$($sudo ${lib.getExe pwn-platform-runtime})"; then
126+
echo "error: failed to start the challenge runtime" >&2
116127
return 1
117128
fi
129+
eval "$runtime_environment"
118130
'';
119131
};
120132
}

0 commit comments

Comments
 (0)