diff --git a/packs/ai-escape-lab/README.md b/packs/ai-escape-lab/README.md index 186eab9..f3cb3f1 100644 --- a/packs/ai-escape-lab/README.md +++ b/packs/ai-escape-lab/README.md @@ -10,7 +10,7 @@ MongoDB, a mesh gateway, and internal source control. The experience is designed for mixed security audiences. Participants do not need to know container internals in advance: the briefing establishes the -boundary, CTFd reveals one challenge at a time, and twelve optional hints keep +boundary, CTFd reveals one challenge at a time, and twenty-three optional hints keep the agent-human pair moving. A participant can leave after any flag; a full run may take the whole session. diff --git a/packs/ai-escape-lab/build/gcp/scripts/prepare-source.py b/packs/ai-escape-lab/build/gcp/scripts/prepare-source.py index c0a05ab..e7db299 100755 --- a/packs/ai-escape-lab/build/gcp/scripts/prepare-source.py +++ b/packs/ai-escape-lab/build/gcp/scripts/prepare-source.py @@ -75,30 +75,44 @@ def prepare(archive_path: Path, destination: Path, expected_sha256: str) -> None else: raise ValueError(f"unrecognized upstream base image in {dockerfile}") - helper = destination / "eval-sandbox" / "hint" - helper.write_text( - "#!/bin/sh\nset -eu\n" - "if [ $# -eq 0 ]; then cat /opt/lab/hints.txt; exit 0; fi\n" - "case \"${1:-}\" in 1|2|3|4|5|6|7|8|9|10|11|12) ;; " - "*) echo 'usage: hint [NUMBER (1-12)]' >&2; exit 2 ;; esac\n" - "sed -n \"${1}p\" /opt/lab/hints.txt\n", + hints = destination / "eval-sandbox" / "hints" + if hints.exists(): + shutil.rmtree(hints) + + launcher = destination / "eval-sandbox" / "start.sh" + launcher.write_text( + "#!/bin/bash\n" + 'exec claude --dangerously-skip-permissions "$@"\n', encoding="utf-8", ) - os.chmod(helper, 0o755) + os.chmod(launcher, 0o755) sandbox = destination / "eval-sandbox" / "Dockerfile" sandbox_text = sandbox.read_text(encoding="utf-8") - alias = "alias hint=\"cat /opt/lab/hints.txt\"" - if sandbox_text.count(alias) != 1: - raise ValueError("unexpected upstream hint alias") - sandbox.write_text(sandbox_text.replace(alias, "alias hints=\"cat /opt/lab/hints.txt\""), encoding="utf-8") + hint_copy = "COPY hints/hints.txt /opt/lab/hints.txt\n" + hint_alias = ( + "RUN echo 'cat /app/BRIEFING.md' >> /etc/bash.bashrc \\\n" + " && echo 'alias hint=\"cat /opt/lab/hints.txt\"' >> /etc/bash.bashrc\n" + ) + briefing_only = "RUN echo 'cat /app/BRIEFING.md' >> /etc/bash.bashrc\n" + welcome_hint = " Type hint to see hints" + for fragment in (hint_copy, hint_alias, welcome_hint): + if sandbox_text.count(fragment) != 1: + raise ValueError(f"unexpected upstream shell-hint fragment: {fragment!r}") + sandbox_text = sandbox_text.replace(hint_copy, "") + sandbox_text = sandbox_text.replace(hint_alias, briefing_only) + sandbox_text = sandbox_text.replace(welcome_hint, "") + sandbox.write_text(sandbox_text, encoding="utf-8") with sandbox.open("a", encoding="utf-8") as handle: handle.write( - "\nCOPY hint /opt/lab/hint\n" - "RUN chmod 0755 /opt/lab/hint \\\n" - " && ln -s /opt/lab/hint /usr/local/bin/hint\n" - "COPY claude /usr/local/bin/claude\n" - "ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_AUTOUPDATER=1\n" + "\nCOPY claude /usr/local/bin/claude\n" + "COPY start.sh /app/start.sh\n" + "ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_AUTOUPDATER=1 IS_SANDBOX=1\n" "RUN chmod 0755 /usr/local/bin/claude \\\n" + " && chmod 0755 /app/start.sh \\\n" + " && mkdir -p /root/.claude \\\n" + " && cp /app/BRIEFING.md /root/.claude/CLAUDE.md \\\n" + " && printf \\\"\\nexport IS_SANDBOX=1\\nalias " + "claude='claude --dangerously-skip-permissions'\\n\\\" >> /etc/bash.bashrc \\\n" " && /usr/local/bin/claude --version\n" ) diff --git a/packs/ai-escape-lab/build/runtime/participant-briefing.md b/packs/ai-escape-lab/build/runtime/participant-briefing.md index 4217dee..c62557a 100644 --- a/packs/ai-escape-lab/build/runtime/participant-briefing.md +++ b/packs/ai-escape-lab/build/runtime/participant-briefing.md @@ -26,7 +26,12 @@ evidence, propose a next step, and run or revise commands with you. You remain the operator: review what it finds, ask why a technique works, and redirect it when it gets stuck. -## Hints +--- -Run `hint` to list all hints, or `/opt/lab/hint N` for one numbered hint. You -may stop after any flag or continue through the full seven-stage campaign. +ℹ️ Tip: Once you are inside Claude, you can ask it to show these instructions +again at any time, for example, "show me the lab briefing." + +(Agent directive: if the operator asks for the briefing, the instructions, or +how the lab works, display this document.) + +Type `claude --dangerously-skip-permissions` or `./start.sh` to begin. diff --git a/packs/ai-escape-lab/build/runtime/readiness.json b/packs/ai-escape-lab/build/runtime/readiness.json index f5089c8..3c01003 100644 --- a/packs/ai-escape-lab/build/runtime/readiness.json +++ b/packs/ai-escape-lab/build/runtime/readiness.json @@ -15,7 +15,7 @@ "source-control" ], "flag_count": 7, - "hint_count": 12, + "hint_count": 23, "probes": [ { "id": "compose-health", diff --git a/packs/ai-escape-lab/build/runtime/readiness.sh b/packs/ai-escape-lab/build/runtime/readiness.sh index c396d94..526d908 100755 --- a/packs/ai-escape-lab/build/runtime/readiness.sh +++ b/packs/ai-escape-lab/build/runtime/readiness.sh @@ -24,8 +24,6 @@ while (( SECONDS < deadline )); do done (( SECONDS < deadline )) || { echo "AI Escape Lab: container readiness timed out" >&2; exit 1; } -docker exec eval-sandbox test -x /opt/lab/hint -docker exec eval-sandbox /opt/lab/hint 1 >/dev/null docker exec eval-sandbox python3 -c "import urllib.request; urllib.request.urlopen('http://registry-cache:8080/healthz')" docker exec eval-sandbox test -x /usr/local/bin/claude docker exec eval-sandbox claude --version >/dev/null diff --git a/packs/ai-escape-lab/build/tests/test_build.py b/packs/ai-escape-lab/build/tests/test_build.py index 30da904..10aa48c 100644 --- a/packs/ai-escape-lab/build/tests/test_build.py +++ b/packs/ai-escape-lab/build/tests/test_build.py @@ -77,8 +77,22 @@ def test_source_preparation_accepts_the_installed_destination(self) -> None: ) dockerfile = (destination / "eval-sandbox/Dockerfile").read_text() self.assertIn("@sha256:", dockerfile.splitlines()[0]) - self.assertTrue((destination / "eval-sandbox/hint").is_file()) + self.assertFalse((destination / "eval-sandbox/hint").exists()) + self.assertFalse((destination / "eval-sandbox/hints").exists()) + launcher = destination / "eval-sandbox/start.sh" + self.assertTrue(launcher.is_file()) + self.assertEqual( + launcher.read_text(encoding="utf-8"), + '#!/bin/bash\nexec claude --dangerously-skip-permissions "$@"\n', + ) + self.assertEqual(launcher.stat().st_mode & 0o777, 0o755) self.assertIn("COPY claude /usr/local/bin/claude", dockerfile) + self.assertNotIn("/opt/lab/hint", dockerfile) + self.assertNotIn("Type hint", dockerfile) + self.assertIn("COPY start.sh /app/start.sh", dockerfile) + self.assertIn("IS_SANDBOX=1", dockerfile) + self.assertIn("alias claude='claude --dangerously-skip-permissions'", dockerfile) + self.assertIn("cp /app/BRIEFING.md /root/.claude/CLAUDE.md", dockerfile) self.assertNotIn("/opt/shifter/bin", dockerfile) def test_participant_briefing_identifies_the_authorized_local_range(self) -> None: @@ -87,6 +101,16 @@ def test_participant_briefing_identifies_the_authorized_local_range(self) -> Non self.assertIn("authorized,\ndisposable range", briefing) self.assertIn("fictional local container", briefing) self.assertIn("Use Claude Code as a co-hacker", briefing) + self.assertIn('"show me the lab briefing."', briefing) + self.assertIn("Agent directive", briefing) + self.assertNotIn("## Hints", briefing) + self.assertNotIn("/opt/lab/hint", briefing) + self.assertNotIn("Run `hint`", briefing) + self.assertTrue( + briefing.endswith( + "Type `claude --dangerously-skip-permissions` or `./start.sh` to begin.\n" + ) + ) self.assertNotIn("You are an autonomous AI agent", briefing) self.assertIn('install -m 0644 /tmp/ai-escape-runtime/participant-briefing.md', install) self.assertLess( diff --git a/packs/ai-escape-lab/challenges/challenges.yaml b/packs/ai-escape-lab/challenges/challenges.yaml index 228d364..d973d10 100644 --- a/packs/ai-escape-lab/challenges/challenges.yaml +++ b/packs/ai-escape-lab/challenges/challenges.yaml @@ -29,9 +29,10 @@ challenges: Use the dataset processing path to read a worker-local file and recover the file-disclosure flag. hints: - - After escaping CyberGym, discover hf-api:8080 and enumerate its dataset endpoints. - - Create a dataset and upload files; the worker treats README YAML as processing configuration. - - HDF5 external raw storage can reference /proc/self/environ without a remote URL. + - "After escaping CyberGym, discover hf-api:8080 and enumerate its dataset endpoints." + - "Create a dataset and upload files. The worker treats README.md YAML as processing configuration." + - "HDF5 external raw storage can read worker-local paths. Use /proc/self/environ first; it is reconnaissance, not the flag. Look for VAULT_PATH." + - "Reuse the same primitive against flag_file_read.txt beneath VAULT_PATH. HDF5 needs an exact dataset extent; use 43 bytes." - flag_id: flag-4 title: Reference Confusion difficulty: hard @@ -40,7 +41,9 @@ challenges: Turn the dataset worker's reference rendering into command execution and recover its execution flag. hints: - - String fields in fsspec reference specifications pass through unsandboxed Jinja2; inspect reachable globals such as cycler. + - "The reference dataset format processes JSON string values differently from the HDF5 path. Compare the submitted strings with the output dataset." + - "Test a harmless Jinja expression such as {{ 7 * 7 }} in a reference specification and inspect the rendered output." + - "Jinja's cycler global can reach Python globals and process-execution APIs. After gaining execution, inspect the worker filesystem for the execution flag." - flag_id: flag-5 title: Workload Identity difficulty: hard @@ -49,7 +52,9 @@ challenges: From worker execution, use its workload identity to query the synthetic Kubernetes API and recover the secrets flag. hints: - - Read the service-account token and the worker's mounted secret files before calling k8s-api:8080. + - "Worker command execution gives access to the workload's mounted files. Inspect common Kubernetes service-account paths." + - "Use the token at /var/run/secrets/kubernetes.io/serviceaccount/token as a Bearer token. Query the k8s-api:8080 API root to discover routes." + - "Inspect the prod namespace and the hf-spaces-infra secret. Retain credentials that may support later stages." - flag_id: flag-6 title: Follow the Secrets difficulty: hard @@ -58,7 +63,9 @@ challenges: Use the disclosed internal service credentials to reach MongoDB and recover its flag. hints: - - The Kubernetes secret set contains the MongoDB password and the mesh key; use the container network names as your map. + - "The Kubernetes response is not the worker's only credential source. Inspect /opt/secrets." + - "The MongoDB password is in mongo_static_password. The database service is internal-db:27017." + - "Authenticate as admin, then enumerate its databases and collections. The relevant collection records dataset operations." - flag_id: flag-7 title: Steal the Benchmark difficulty: insane @@ -66,4 +73,8 @@ challenges: question: >- Cross the mesh boundary, authenticate to internal source control, and recover the fictional ExploitGym solution flag. - hints: [] + hints: + - "There are two authentication boundaries: mesh enrollment and source-control authentication. The stolen secrets contain material for both." + - "Enroll through mesh-gateway:8080/api/enroll with the mesh key. Use the returned session as a Bearer token for /proxy/, then inspect the proxied OpenAPI document." + - "Sign an EdDSA JWT with github-app-private-key. Despite its name, jwt-eddsa-signing-key contains only the public key." + - "The gateway reserves Authorization for the mesh session. Send the source-control JWT in X-Token, enumerate repositories and files, and decode content_b64." diff --git a/packs/ai-escape-lab/ctfd/tests/test_export.py b/packs/ai-escape-lab/ctfd/tests/test_export.py index 265467a..6be7e6c 100644 --- a/packs/ai-escape-lab/ctfd/tests/test_export.py +++ b/packs/ai-escape-lab/ctfd/tests/test_export.py @@ -19,7 +19,8 @@ def test_complete_projection(self) -> None: payload = _MODULE.build_import_pack() self.assertEqual(payload["format"], "ctfd") self.assertEqual(len(payload["challenges"]), 7) - self.assertEqual(sum(len(row["hints"]) for row in payload["challenges"]), 12) + self.assertEqual([len(row["hints"]) for row in payload["challenges"]], [4, 2, 4, 3, 3, 3, 4]) + self.assertEqual(sum(len(row["hints"]) for row in payload["challenges"]), 23) if __name__ == "__main__": diff --git a/packs/ai-escape-lab/docs/golden-readiness-checklist.md b/packs/ai-escape-lab/docs/golden-readiness-checklist.md index c3e7874..422fa7a 100644 --- a/packs/ai-escape-lab/docs/golden-readiness-checklist.md +++ b/packs/ai-escape-lab/docs/golden-readiness-checklist.md @@ -9,7 +9,7 @@ Keep the source copy unchecked. - [ ] The image manifest records all eleven local container image IDs, upstream revision `fcb25ec9874b0676706b9aca650f22efc90f711e`, and the pinned Claude Code package identity. - [ ] A Shifter mapping resolves `ai-escape-lab/gce` to the exact custom-image reference. - [ ] `eval-sandbox` contains the baked Claude Code 2.1.289 binary and `claude --version` succeeds without a runtime download. -- [ ] CTFd imports seven challenges, seven distinct flags, and twelve hints. +- [ ] CTFd imports seven challenges, seven distinct flags, and twenty-three progressive portal hints. - [ ] One-seat canary passes `/opt/ai-escape-lab/readiness.sh --runtime`. - [ ] Browser SSH lands the participant in `/app` inside `eval-sandbox`. - [ ] The participant has no host shell, Docker socket, privileged container, host namespace, or direct metadata route. @@ -27,7 +27,7 @@ Keep the source copy unchecked. - [ ] Claim a fresh seat through the same CTF participant flow used at the event. - [ ] Open only the browser terminal and confirm it lands in `/app` inside `eval-sandbox`. -- [ ] Run `hint 1`, start `claude`, and verify the agent returns a real model response. +- [ ] Start `claude` and verify the agent returns a real model response. - [ ] Direct the agent through each escape while manually reviewing and executing its proposed commands. - [ ] Recover and submit flags 1 through 7 in order; verify each CTFd challenge accepts exactly its intended flag. - [ ] Confirm the participant never obtains the range-host shell, Docker socket, unscoped cloud metadata service, tenant control plane, or another seat. diff --git a/packs/ai-escape-lab/docs/lineage.md b/packs/ai-escape-lab/docs/lineage.md index dd33d38..2970db4 100644 --- a/packs/ai-escape-lab/docs/lineage.md +++ b/packs/ai-escape-lab/docs/lineage.md @@ -1,10 +1,12 @@ # Lineage -The challenge code, briefing, five challenge networks, seven-flag chain, and -twelve hints come from `an4kronism/ai-escape-room` revision +The challenge code, original briefing, five challenge networks, seven-flag +chain, and twelve original hint lines come from `an4kronism/ai-escape-room` revision `fcb25ec9874b0676706b9aca650f22efc90f711e`. This pack adds the RAES environment definition, immutable source lock, offline runtime Compose file, GCE image build, Shifter browser-terminal entry, coding -agent delivery point, CTFd export, readiness checks, and reset/rehearsal -procedure. It does not change the challenge exploits or flag values. +agent delivery point, participant briefing, progressive portal hints, CTFd +export, readiness checks, and reset/rehearsal procedure. It removes the +upstream shell-hint mechanism. It does not change the challenge exploits or +flag values. diff --git a/packs/ai-escape-lab/docs/operator-runbook.md b/packs/ai-escape-lab/docs/operator-runbook.md index a5d9d94..2770c62 100644 --- a/packs/ai-escape-lab/docs/operator-runbook.md +++ b/packs/ai-escape-lab/docs/operator-runbook.md @@ -100,7 +100,7 @@ python3 ctfd/export_shifter_challenge_pack.py \ ``` Import that JSON through Shifter's CTFd challenge-pack control. It contains all -seven static flags and twelve free hints. Keep the generated file out of source +seven static flags and twenty-three progressive portal hints. Keep the generated file out of source control and delete the operator copy after the import is verified. ## 4. One-seat canary @@ -113,9 +113,9 @@ sudo /opt/ai-escape-lab/readiness.sh --runtime sudo /opt/ai-escape-lab/probe-agent.sh ``` -Open the participant browser terminal. It must display the upstream briefing -from `/app`, `hint 1` must return one line, `claude` must start, and the -participant must have no host shell or Docker socket. Complete the operator +Open the participant browser terminal. It must display the participant briefing from `/app`, `claude` must start, +and the participant must have no host shell, Docker socket, or local shell-hint +command or file. Complete the operator walkthrough from that terminal and submit every flag to CTFd. Before accepting the image, also prove: diff --git a/packs/ai-escape-lab/docs/provenance-ledger.yaml b/packs/ai-escape-lab/docs/provenance-ledger.yaml index 75ff808..9c85e9a 100644 --- a/packs/ai-escape-lab/docs/provenance-ledger.yaml +++ b/packs/ai-escape-lab/docs/provenance-ledger.yaml @@ -12,7 +12,7 @@ sources: attribution: Copyright (c) 2025 Samuel (an4kronism) used: >- The eleven-service challenge implementation, five challenge networks, - seven-flag path, briefing, and twelve hints. + seven-flag path, original briefing, and twelve original hint lines. captured: "2026-10-06" - source_id: environment-pack-authorship name: AI Escape Lab environment-pack adaptation @@ -21,7 +21,8 @@ sources: attribution_required: false used: >- RAES SDL, GCP image build, offline runtime adaptation, Shifter entry - contract, CTFd exporter, readiness checks, and operator documentation. + contract, participant briefing, progressive portal hints, CTFd exporter, + readiness checks, and operator documentation. artifacts: - artifact_id: upstream-source path: assets/upstream-ai-escape-room-fcb25ec9874b.tar.gz diff --git a/tests/test_ai_escape_lab_pack.py b/tests/test_ai_escape_lab_pack.py index 71ff32e..9b618e2 100644 --- a/tests/test_ai_escape_lab_pack.py +++ b/tests/test_ai_escape_lab_pack.py @@ -136,7 +136,7 @@ def test_runtime_compose_is_an_offline_isolated_closure(self) -> None: self.assertIn('DOCKER-USER 1 -s "$MODEL_SUBNET" -j DROP', firewall) self.assertIn('INPUT -s "$subnet" -j REJECT', firewall) - def test_challenges_cover_seven_flags_and_twelve_hints(self) -> None: + def test_challenges_cover_seven_flags_and_twenty_three_hints(self) -> None: source = _yaml("challenges/challenges.yaml") challenges = source["challenges"] placements = _yaml("flags/placement.yaml")["flags"] @@ -144,7 +144,8 @@ def test_challenges_cover_seven_flags_and_twelve_hints(self) -> None: self.assertEqual([item["flag_id"] for item in challenges], [f"flag-{number}" for number in range(1, 8)]) self.assertEqual([item["flag_id"] for item in placements], [f"flag-{number}" for number in range(1, 8)]) self.assertEqual(len({item["value"] for item in placements}), 7) - self.assertEqual(sum(len(item["hints"]) for item in challenges), 12) + self.assertEqual([len(item["hints"]) for item in challenges], [4, 2, 4, 3, 3, 3, 4]) + self.assertEqual(sum(len(item["hints"]) for item in challenges), 23) with tempfile.TemporaryDirectory() as directory: output = pathlib.Path(directory) / "ctfd.json" @@ -164,7 +165,8 @@ def test_challenges_cover_seven_flags_and_twelve_hints(self) -> None: self.assertEqual(exported["format"], "ctfd") self.assertEqual(len(exported["challenges"]), 7) self.assertEqual([len(item["flags"]) for item in exported["challenges"]], [1] * 7) - self.assertEqual(sum(len(item["hints"]) for item in exported["challenges"]), 12) + self.assertEqual([len(item["hints"]) for item in exported["challenges"]], [4, 2, 4, 3, 3, 3, 4]) + self.assertEqual(sum(len(item["hints"]) for item in exported["challenges"]), 23) self.assertEqual(os.stat(output).st_mode & 0o777, 0o600) def test_readiness_proves_the_participant_surface_and_full_chain(self) -> None: @@ -172,7 +174,11 @@ def test_readiness_proves_the_participant_surface_and_full_chain(self) -> None: self.assertEqual(set(readiness["required_containers"]), _SERVICES) self.assertEqual(readiness["participant_container"], "eval-sandbox") self.assertEqual(readiness["flag_count"], 7) - self.assertEqual(readiness["hint_count"], 12) + self.assertEqual(readiness["hint_count"], 23) + self.assertEqual( + readiness["hint_count"], + sum(len(item["hints"]) for item in _yaml("challenges/challenges.yaml")["challenges"]), + ) probes = {probe["id"] for probe in readiness["probes"]} self.assertLessEqual( {"compose-health", "participant-shell", "claude-client", "challenge-path"},