From a7f83e610be9af90662567e3af75f1f14d42ac1c Mon Sep 17 00:00:00 2001 From: skjshr Date: Fri, 31 Jul 2026 14:16:16 +0900 Subject: [PATCH] Build the Debian open-world lab --- .gitignore | 5 + DESIGN.md | 17 +- PROJECT_CONSTITUTION.md | 35 +- README.md | 28 +- TASK_CONTRACT.md | 41 +- apps/lab-guide/AGENTS.md | 33 +- apps/lab-guide/cloud/README.md | 7 +- apps/lab-guide/cloud/http.mjs | 15 + apps/lab-guide/cloud/projection-state.mjs | 11 +- apps/lab-guide/cloud/projection.mjs | 118 +++- apps/lab-guide/cloud/session-service.mjs | 31 +- apps/lab-guide/index.html | 12 +- apps/lab-guide/src/App.test.tsx | 378 ++++++----- apps/lab-guide/src/App.tsx | 12 +- apps/lab-guide/src/api.test.ts | 4 +- apps/lab-guide/src/api.ts | 104 ++- apps/lab-guide/src/browse.ts | 146 +++- apps/lab-guide/src/components/AppHeader.tsx | 120 +++- .../src/components/ConnectionBanner.tsx | 2 +- .../src/components/EndSessionDialog.tsx | 2 +- .../src/components/ExplorationMap.tsx | 41 +- .../src/components/ManualFlagForm.tsx | 71 -- apps/lab-guide/src/components/MapCanvas.tsx | 5 +- apps/lab-guide/src/components/RouteUnlock.tsx | 29 + .../src/components/SituationConsultation.tsx | 45 +- apps/lab-guide/src/fixtures.ts | 621 +++++++++++------- apps/lab-guide/src/guidance.ts | 87 +++ apps/lab-guide/src/route-achievements.test.ts | 33 + apps/lab-guide/src/route-achievements.ts | 64 ++ apps/lab-guide/src/styles/calm-shell.css | 101 +++ apps/lab-guide/src/styles/responsive.css | 4 - apps/lab-guide/src/styles/shared.css | 96 --- apps/lab-guide/src/styles/themes.css | 2 - apps/lab-guide/src/types.ts | 27 +- apps/lab-guide/src/useLabSession.ts | 35 +- apps/lab-guide/tests/cloud-actions.test.mjs | 35 +- .../tests/cloud-bridge-contract.test.mjs | 48 ++ apps/lab-guide/tests/cloud-session.test.mjs | 14 + apps/lab-guide/tests/cloud-test-helpers.mjs | 27 +- codex_workflow/WORKORDER_TARGET_DECOUPLING.md | 2 +- codex_workflow/WORKORDER_TARGET_WORLD.md | 6 +- design-qa.md | 4 +- docs/LIVE_ARCHITECTURE.md | 12 +- docs/design/README.md | 2 +- docs/qa/target-web/diagnostics-1280x720.png | Bin 0 -> 65247 bytes docs/qa/target-web/diagnostics-375x844.png | Bin 0 -> 39136 bytes .../diagnostics-result-1280x720.png | Bin 0 -> 73167 bytes docs/qa/target-web/home-1280x720.png | Bin 0 -> 125730 bytes docs/qa/target-web/home-375x844.png | Bin 0 -> 54344 bytes docs/qa/target-web/inventory-1280x720.png | Bin 0 -> 65975 bytes docs/qa/target-web/vehicle-1280x720.png | Bin 0 -> 119027 bytes labs/open-world-target/bridge/README.md | 8 +- labs/open-world-target/bridge/src/actions.mjs | 37 +- labs/open-world-target/bridge/src/http.mjs | 5 +- .../bridge/src/projection.mjs | 116 +++- .../bridge/tests/actions.test.mjs | 17 + .../bridge/tests/helpers.mjs | 23 +- .../bridge/tests/http.test.mjs | 4 + .../bridge/tests/projection.test.mjs | 31 +- .../operator/CODEX-BOOTSTRAP.md | 136 ++++ .../operator/CODEX-SETUP-PROMPT.md | 48 ++ labs/open-world-target/operator/DAY-OF.md | 25 +- .../operator/PACKAGE-BOOTSTRAP.md | 79 +-- .../operator/PHYSICAL-VERIFICATION.md | 13 +- .../operator/PREPARE-TARGET.md | 292 +++----- labs/open-world-target/operator/README.md | 17 +- labs/open-world-target/operator/RECOVERY.md | 134 ++-- .../operator/build_target_bundle.py | 5 +- .../operator/evidence.example.json | 24 +- .../operator/tests/test_build_hygiene.py | 87 +++ .../operator/tests/test_operator_contract.py | 98 ++- labs/open-world-target/platform/README.md | 202 ++---- labs/open-world-target/platform/manifest.json | 23 +- .../platform/open_world_platform/cli.py | 6 - .../platform/open_world_platform/install.py | 6 +- .../platform/open_world_platform/inventory.py | 50 +- .../platform/open_world_platform/mode.py | 2 - .../platform/open_world_platform/model.py | 48 +- .../platform/open_world_platform/preflight.py | 36 +- .../platform/open_world_platform/recovery.py | 68 +- .../platform/open_world_platform/render.py | 3 +- .../open_world_platform/target_bundle.py | 200 ++++-- .../open_world_platform/target_install.py | 12 +- .../platform/profile.example.json | 6 +- .../system-preset/00-open-world-lab.preset | 1 - .../etc/systemd/system/mnt-windows.mount | 11 - .../system/open-world-vulnerable.target | 6 +- .../etc/tmpfiles.d/open-world-windows.conf | 1 - .../usr/local/sbin/open-world-build-hygiene | 134 ++++ .../platform/tests/helpers.py | 39 +- .../platform/tests/test_platform.py | 80 +-- .../platform/tests/test_recovery.py | 49 +- .../platform/tests/test_target_bundle.py | 194 ++++-- labs/open-world-target/telemetry/README.md | 45 +- .../telemetry/api-contract.json | 77 ++- labs/open-world-target/telemetry/package.json | 2 +- .../telemetry/src/http-server.mjs | 24 +- .../telemetry/src/projection.mjs | 159 +++-- .../telemetry/src/session-engine.mjs | 472 +++++++++---- .../telemetry/tests/http-api.test.mjs | 89 ++- .../telemetry/tests/session-engine.test.mjs | 176 +++-- .../telemetry/tests/state-store.test.mjs | 4 + .../telemetry/tests/static-fixtures.test.mjs | 164 ++++- .../telemetry/tests/world.test.mjs | 81 +-- labs/open-world-target/world/DESIGN.md | 132 ++++ labs/open-world-target/world/README.md | 29 +- labs/open-world-target/world/TASK_CONTRACT.md | 103 +++ labs/open-world-target/world/design-qa.md | 75 +++ .../world/fixtures/README.md | 6 +- .../srv/kazekiri/handover/SHIFT-HANDOVER.txt | 6 +- .../rootfs/var/www/kazekiri/article.php | 60 ++ .../kazekiri/assets/area-mountain-road.webp | Bin 0 -> 350354 bytes .../var/www/kazekiri/assets/catalog.css | 372 +++++++++++ .../var/www/kazekiri/assets/content.css | 383 +++++++++++ .../www/kazekiri/assets/inspection-brake.webp | Bin 0 -> 177336 bytes .../rootfs/var/www/kazekiri/assets/mobile.css | 416 ++++++++++++ .../var/www/kazekiri/assets/parts-shelf.webp | Bin 0 -> 154748 bytes .../rootfs/var/www/kazekiri/assets/public.css | 414 ++++++++++++ .../var/www/kazekiri/assets/responsive.css | 250 +++++++ .../kazekiri/assets/service-oil-bench.webp | Bin 0 -> 152168 bytes .../kazekiri/assets/service-tire-lift.webp | Bin 0 -> 151312 bytes .../assets/shop-exterior-morning.webp | Bin 0 -> 128626 bytes .../www/kazekiri/assets/showroom-floor.webp | Bin 0 -> 288858 bytes .../rootfs/var/www/kazekiri/assets/site.css | 453 +++++++++++++ .../rootfs/var/www/kazekiri/assets/staff.css | 294 +++++++++ .../var/www/kazekiri/assets/stock-01.webp | Bin 0 -> 159548 bytes .../var/www/kazekiri/assets/stock-02.webp | Bin 0 -> 200060 bytes .../var/www/kazekiri/assets/stock-03.webp | Bin 0 -> 189518 bytes .../var/www/kazekiri/assets/stock-04.webp | Bin 0 -> 155502 bytes .../var/www/kazekiri/assets/stock-05.webp | Bin 0 -> 221478 bytes .../var/www/kazekiri/assets/stock-06.webp | Bin 0 -> 205056 bytes .../var/www/kazekiri/assets/support.css | 390 +++++++++++ .../www/kazekiri/assets/waiting-counter.webp | Bin 0 -> 125074 bytes .../www/kazekiri/assets/workshop-hero.webp | Bin 0 -> 211040 bytes .../www/kazekiri/assets/workshop-rain.webp | Bin 0 -> 159852 bytes .../rootfs/var/www/kazekiri/contact.php | 250 +++++++ .../fixtures/rootfs/var/www/kazekiri/faq.php | 64 ++ .../rootfs/var/www/kazekiri/inc/data.php | 323 +++++++++ .../rootfs/var/www/kazekiri/inc/site.php | 259 ++++++++ .../rootfs/var/www/kazekiri/index.php | 220 ++++++- .../rootfs/var/www/kazekiri/inventory.php | 139 ++++ .../fixtures/rootfs/var/www/kazekiri/news.php | 51 ++ .../rootfs/var/www/kazekiri/service.php | 127 ++++ .../fixtures/rootfs/var/www/kazekiri/shop.php | 125 ++++ .../var/www/kazekiri/staff/diagnostics.php | 141 +++- .../rootfs/var/www/kazekiri/vehicle.php | 151 +++++ ...ls.json => synthetic-credential-spec.json} | 4 +- .../world/flag-verifiers.mjs | 68 -- .../world/materialize-flags.mjs | 33 +- .../world/private-answers.mjs | 42 -- .../world/validate-private-answers.mjs | 87 --- .../world/validate-world.mjs | 29 +- .../world/world-definition.mjs | 110 ++-- tests/repository-contract.test.mjs | 6 +- 154 files changed, 9344 insertions(+), 2469 deletions(-) delete mode 100644 apps/lab-guide/src/components/ManualFlagForm.tsx create mode 100644 apps/lab-guide/src/components/RouteUnlock.tsx create mode 100644 apps/lab-guide/src/guidance.ts create mode 100644 apps/lab-guide/src/route-achievements.test.ts create mode 100644 apps/lab-guide/src/route-achievements.ts create mode 100644 docs/qa/target-web/diagnostics-1280x720.png create mode 100644 docs/qa/target-web/diagnostics-375x844.png create mode 100644 docs/qa/target-web/diagnostics-result-1280x720.png create mode 100644 docs/qa/target-web/home-1280x720.png create mode 100644 docs/qa/target-web/home-375x844.png create mode 100644 docs/qa/target-web/inventory-1280x720.png create mode 100644 docs/qa/target-web/vehicle-1280x720.png create mode 100644 labs/open-world-target/operator/CODEX-BOOTSTRAP.md create mode 100644 labs/open-world-target/operator/CODEX-SETUP-PROMPT.md create mode 100644 labs/open-world-target/operator/tests/test_build_hygiene.py delete mode 100644 labs/open-world-target/platform/templates/etc/systemd/system/mnt-windows.mount delete mode 100644 labs/open-world-target/platform/templates/etc/tmpfiles.d/open-world-windows.conf create mode 100644 labs/open-world-target/platform/templates/usr/local/sbin/open-world-build-hygiene create mode 100644 labs/open-world-target/world/DESIGN.md create mode 100644 labs/open-world-target/world/TASK_CONTRACT.md create mode 100644 labs/open-world-target/world/design-qa.md create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/article.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/area-mountain-road.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/catalog.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/content.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/inspection-brake.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/mobile.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/parts-shelf.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/public.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/responsive.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/service-oil-bench.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/service-tire-lift.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/shop-exterior-morning.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/showroom-floor.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/site.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/staff.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-01.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-02.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-03.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-04.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-05.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/stock-06.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/support.css create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/waiting-counter.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/workshop-hero.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/assets/workshop-rain.webp create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/contact.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/faq.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/data.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/site.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inventory.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/news.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/service.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/shop.php create mode 100644 labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/vehicle.php rename labs/open-world-target/world/fixtures/{synthetic-credentials.json => synthetic-credential-spec.json} (68%) delete mode 100644 labs/open-world-target/world/flag-verifiers.mjs delete mode 100644 labs/open-world-target/world/private-answers.mjs delete mode 100644 labs/open-world-target/world/validate-private-answers.mjs diff --git a/.gitignore b/.gitignore index aa38a26..368ad39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ node_modules/ dist/ coverage/ +build/ +TARGET-BUNDLE.json +synthetic-credentials.json +*.flag +.open-world-target-*/ *.log .DS_Store __pycache__/ diff --git a/DESIGN.md b/DESIGN.md index 7c69d41..357abb3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -113,6 +113,13 @@ missionStage: 三つの見た目は配色プリセットではない。情報の意味、操作、状態を共有しながら、異なる構図・書体・境界・動きで同じ世界を演出する。 +画面は人物を追跡しない。標的の確定進行、参加者が選んだguidance設定、開放済みhintを +canonical stateとし、同じcanonical stateは常に同じ地図、現在地、選択肢、説明を生成する。 +時刻、event到着順、browser identity、themeは攻略位置を変えない。 + +安全と許可範囲は接続前とquiet help/legal surfaceに一度だけ置く。通常のmission、node、 +説明、完了表示へ「演習」「トレーニング」を反復せず、調査世界への没入をノイズで壊さない。 + ### Mission stage refinement 簡素化後の初期画面を、見出し、目標、地図、選択地点が別々の帯に分かれた管理画面にしない。これらは一枚の `mission stage` として読み、上から `mission / objective / route / action` の一方向へ因果をつなぐ。 @@ -196,6 +203,11 @@ missionStage: - 補助情報のdrawerは同時に一枚だけ開く。`Escape` と背景操作で閉じ、閉じた後は元の引き手へfocusを戻す。 - drawer内へkeyboard focusを閉じ込め、見えていない補助情報をtab順へ残さない。 - 新しい教材イベントを受けてもdrawerを勝手に開かない。閉じている間は主画面の目標と進捗だけを更新し、件数はdrawerを開いた後に見せる。 +- 個人プロフィール、ニックネーム、復旧コード、ランキング、管理画面を作らない。 +- EASYを初期値とし、Header menu内でEASY / NORMAL / HARD / CUSTOMを変更できる。専用settings画面は増やさない。 +- CUSTOMは次候補、道具、構文、実行例、不成功時の説明、技術説明量を個別に切り替える。途中変更で確定事実、選択、地図、hintを失わない。 +- 未発見nodeは公開可能なカテゴリと名前のないシルエットだけを描き、隠れた名称、本文、commandをDOM、accessibility tree、bundle fixtureへ先出ししない。 +- flagは任意の発見として静かに記録できるが、objective、route unlock、root完了を妨げない。 ## State language @@ -208,7 +220,8 @@ missionStage: - Selected: border、icon、label、地点briefの4点で示す。 - Locked hint: 開放条件を日常語で示す。 - Success: rootまでの一本の経路と権限変化の振り返りを主役にする。 -- Telemetry unavailable: 自動検出不能を明示する。flag手動提出は標的直結のローカル表示だけに出し、クラウド表示には出さない。 +- Telemetry unavailable: 自動検出不能を明示し、有線接続、Bridge、再読込の確認手順だけを出す。flagは任意の収集物であり、手動提出や進行判定には使わない。 +- No progress: 正誤判定やquizにせず、選択中の仮説について「まだ確定した変化がない」と示す。EASY/CUSTOMで許可された場合だけ観察を見直す説明を開く。 ## Live transition contract @@ -217,6 +230,7 @@ missionStage: - 複数イベントを受けてもrevision順だけを採用し、古いsnapshotで画面を巻き戻さない。 - `prefers-reduced-motion`では同じ情報を即時更新し、色だけに頼らずラベルと形状も変える。 - BrowseからLiveへの切替は同じ世界へ接続したと理解できる連続した遷移にし、別アプリへ飛んだ印象を作らない。 +- 同じevent集合を異なる到着順で受けてもworld順へ正規化し、同じcanonical stateと同じ表示を作る。 ## Responsive contract @@ -236,4 +250,5 @@ missionStage: - mission/mapのcomponent規則へ色やfont-sizeを直書きすること。`missionStage` のsemantic token宣言を唯一の入口にする - fake terminal、code rain、stock hacker photography - chat人格、自由文AI prompt、仮説より先に完全commandを見せること +- account/profile、復旧コード、leaderboard、admin dashboard、理解確認quiz - runtimeで生成画像を背景として読み込み、offline bundleを重くすること diff --git a/PROJECT_CONSTITUTION.md b/PROJECT_CONSTITUTION.md index fd7e4a4..c26564e 100644 --- a/PROJECT_CONSTITUTION.md +++ b/PROJECT_CONSTITUTION.md @@ -2,7 +2,7 @@ ## Thesis -IT初心者が、直結したDebian実機を観察し、複数の入口と権限昇格経路から自分で仮説を選び、30〜60分でrootまでの因果を説明できる、再挑戦可能なオープンワールド演習を作る。 +IT初心者が、直結したDebian実機を観察し、複数の入口と権限昇格経路から自分で仮説を選び、約90分でrootまでの因果を説明できる、再挑戦可能なオープンワールド演習を作る。 ## Moment of satisfaction @@ -10,7 +10,7 @@ IT初心者が、直結したDebian実機を観察し、複数の入口と権限 ## One-sentence spec -WindowsとのデュアルブートDebianノートをKali実機または隔離Kali VMから直結攻撃し、3つの入口、3つの実機root経路、14個のflagを探索する世界を通常時はExamServerの`/lab`で閲覧でき、演習時はKali Bridgeが許可済み教材イベントだけを`/api/lab`へ送り、探索地図、次の選択、説明を2秒以内に変化させるラボを提供する。 +公開GitHubとCodex CLIから再構築できる専用Debian Boot2Root標的をKaliから直結攻撃し、Kali上のFirefoxで状態に応じて解放される道、次の選択肢、説明が2秒以内に変化し、9経路を90分単位で繰り返し攻略できるラボを提供する。 ## Mechanism @@ -20,13 +20,18 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal 4. 学習サイトは未接続でも公開ブリーフィングを表示し、ペアリング後は発見済み事実だけをライブ投影する。未発見の正解や完全な世界定義をブラウザへ渡さない。 5. 状況相談は事実から仮説を選ばせ、観察点、道具、操作例の順でヒントを開く。 6. 初期画面は現在地、現在目標、地図/仮説、次の主要操作だけを見せる。補助情報は各画面に一本だけあるラベル付きの `ツール` を引いてから、接続、事実、調査/ヒント、履歴の一種類を選んで開く。画面移動、見た目、演習終了はHeaderの一本の `メニュー` に収納する。 -7. 3 footholdすべてから3 root経路へ到達でき、別セッションで別ルートを遊べる。 -8. root取得後はDebianを信頼せず、外した信頼済み復旧USBからgolden imageへ戻す。 +7. 進行の正本は個人プロフィールではなく、標的の確定状態、参加者が選んだ難易度、開放したヒントである。同じ正本状態は誰が開いても同じ現在地と地図を表示する。 +8. EASYを初期値にし、参加者は途中でも次候補、道具、構文、実行例、不成功理由、説明量を変更できる。変更は通常進行を失わせない。 +9. 未発見箇所はカテゴリと名前のないシルエットだけを見せ、Debianがallowlist済み達成イベントを検出した時だけ名前、事実、道を解放する。 +10. 3 footholdすべてから3 root経路へ到達でき、90分を上限とする別セッションで9組合せを遊べる。flagは任意収集物であり進行条件にしない。 +11. root取得後はDebian自身のresetを信頼しない。高速復旧は信頼済み外部media、再構築復旧はclean Debianから固定GitHub release/commitとCodexを使う。 +12. Codex CLIはmaintenance中の構築工具としてだけ使い、exerciseへ入る前に認証、session、履歴、checkoutの残留検査を通す。公開repositoryの取得にGitHub loginは要求しない。 +13. 風切モータースの標的サイトは攻略サイトと視覚・情報設計を共有せず、外部通信のない実在業務サイトとして、見えるページと操作を完成させる。 ## Architecture grammar - `apps/lab-guide` は探索地図、状況相談、セッション投影だけを所有する。 -- `apps/lab-guide/cloud` はペアリング、ライブ投影の永続化、閲覧者向け配信だけを所有する。 +- `apps/lab-guide/cloud` は短期ペアリング、セッション状態の単調更新、閲覧者向け配信だけを所有する。アカウント、個人プロフィール、ランキングを所有しない。 - `labs/open-world-target/world` は架空業務環境、意図的脆弱性、flag配置だけを所有する。 - `labs/open-world-target/telemetry` は教材イベントの正規化、状態遷移、公開可能な投影だけを所有する。 - `labs/open-world-target/bridge` はKaliからDebian投影を読み、クラウドへ外向き中継することだけを所有する。 @@ -45,6 +50,13 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal - 生コマンド、端末出力、資格情報、flag文字列のクラウド保存 - root取得後もDebian内のreset処理を信頼すること - 一つの完全手順だけで全員を完走させること +- CodexやGitHubの認証情報をgolden imageまたはexercise modeへ残すこと +- 画面、fixture、自動testだけをもって完成品と呼ぶこと +- Windows、dual boot、Windows flag、Windows recovery +- 名前、メール、復旧コードを持つ個人プロフィール +- 管理画面、ランキング、streak、永続的な個人実績 +- 理解確認クイズ、自由文AI相談、攻略の自動実行 +- 演習であることを標的サイトや攻略画面の本文へ繰り返し表示すること ## Failure modes @@ -52,14 +64,16 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal 2. 自動検出が生コマンドや秘密を集め、教材より監視システムになる。 3. 一つの入口またはroot経路だけが実用上の正解になり、再挑戦価値が消える。 4. root取得後の標的をそのまま再利用し、次の参加者へ状態や改変が残る。 -5. デュアルブート作業がWindows、EFI、実データを損なう。 +5. root取得済みDebianから内部resetやGitHub取得を行い、改変済み状態をcleanと誤認する。 6. KaliのWi-Fiと有線LANがルータ化し、隔離したDebianへ外部経路を与える。 7. ライブ接続がない通常閲覧で、故障画面または偽の進行データを表示する。 8. 接続、事実、調査、履歴、設定の入口を初期画面へ並べ、初心者が地図よりUIの解読に時間を使う。 +9. 個人プロフィールと標的状態が別々に進み、同じ標的状態なのに参加者ごとに違う道を表示する。 +10. 標的サイトが演習説明、動かないリンク、外部素材、サイバー風装飾で架空教材に見える。 ## Observable acceptance -- 完全初心者が段階ヒント込みで任意の一経路を30〜60分でrootまで完走できる。 +- ターミナル未経験に近い参加者がEASYと段階ヒントを使い、任意の一経路を90分以内にrootまで完走できる。 - 入口3種とroot経路3種の9組合せがfresh stateから成立する。 - セッション未接続でも、公開ブリーフィング、世界観、必要機材、安全境界をWebだけで閲覧できる。 - ペアリング済みセッションでは、教材イベントが2秒以内に探索地図または状況相談へ反映される。 @@ -67,6 +81,11 @@ WindowsとのデュアルブートDebianノートをKali実機または隔離Kal - Debian exercise modeにWi-Fi、外部DNS、default route、インターネット疎通がない。 - Kaliがクラウド中継用Wi-Fiを使う場合も、IP forwarding、NAT、EthernetからWi-Fiへの転送が無効である。 - Kali Bridge停止時は最後の確定状態と再接続表示を残し、復帰後に欠落なく追いつく。 -- 復旧USBからDebian、ラボ状態、Debian EFIをgolden stateへ戻し、Windowsが再起動できる。 +- 信頼済み外部mediaからDebian全体をgolden stateへ戻し、同じ経路を再度完走できる。 +- clean Debianから公開GitHubの固定release/commitを取得し、Codexで同じschema、配置、9経路を、buildごとのfreshなflag・synthetic credentialとともに再構築できる。secretを含むbundle hashは構築ごとに変わる。 - Firefox ESRの1366×768、1280×720、360pxで主要フローをキーボードだけでも完了できる。 - 初回表示では補助パネルの本文もカテゴリ一覧も見えず、一本の `ツール` を開いた後にラベルと件数から必要な情報を選び、一度に一種類だけ表示できる。 +- fresh Debianから別operatorがCodex手順を使って検証済みbundleとdry-run planを再生成でき、exercise開始前にCodex認証、session、履歴、checkoutが存在しない。 +- 参加者はKali上のFirefoxだけでpairing、現在地確認、次の仮説選択、段階説明、root完了確認まで進められる。 +- 同一の標的進行、難易度、ヒント状態を与えた二つのbrowserは、個人識別なしに同一のカテゴリ、シルエット、現在地、選択肢、説明を表示する。 +- 風切モータースの公開・在庫・整備・店舗・記事・FAQ・問い合わせ・staff導線が外部requestなしで動き、演習用装飾や反復説明を主情報にしない。 diff --git a/README.md b/README.md index 5b4d344..30a325e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # ExamServer 実践ラボ +これはデモではなく、繰り返し構築・演習・復旧できる完成品を目標とするプロジェクトです。 + 許可された直結環境だけで使う、初心者向けオープンワールド型セキュリティ教材です。 -標的はWindowsとDebian 13をデュアルブートするノートPCです。参加者はKali実機、または専用Ethernetだけを割り当てたKali VMから接続し、架空の中古バイク販売・整備チェーン「風切モータース」の業務サーバを調べます。 +標的は全diskを演習へ使える専用Debian 13ノートPCです。参加者はKali実機、または専用Ethernetだけを割り当てたKali VMから接続し、架空の中古バイク販売・整備チェーン「風切モータース」の業務サーバを調べます。 公開ガイド: @@ -12,9 +14,9 @@ - 発見した事実をExamServerの`/lab`にある探索地図でつなぐ - 状況に合う仮説を選び、必要時だけ段階ヒントを見る - 3つの異なる権限昇格経路からDebian本体のrootを取る -- 別セッションでは別ルートと14個のflagを探索する +- 別セッションでは別ルートと任意の13個のflagを探索する -標準の一経路は初心者が30〜60分で完走できる密度にします。公開Webは標的がなくても閲覧でき、演習中はKali Bridgeが許可済み教材イベントだけを送って地図、説明、次の選択をリアルタイムに更新します。生コマンド、端末出力、資格情報、flag文字列はクラウドへ送りません。 +標準の一経路は初心者が約90分で完走できる密度にします。公開Webは標的がなくても閲覧でき、演習中はKali Bridgeが許可済み教材イベントだけを送って地図、説明、次の選択をリアルタイムに更新します。生コマンド、端末出力、資格情報、flag文字列はクラウドへ送りません。 初期画面に出すのは現在目標、探索地図または仮説、主要操作だけです。接続、確定した事実、調査やヒント、履歴は件数付きの引き手に収納し、学習者が選んだ一種類だけをdesktopではside drawer、スマートフォンではbottom sheetで開きます。ライブ更新も勝手にパネルを開かず、目標、地図、引き手の件数だけを変えます。 @@ -34,7 +36,7 @@ ## ローカル確認 -Node.js 20以上とPython 3.11以上を使います。ガイドの依存関係を一度入れれば、UI、14 flags、 +Node.js 20以上とPython 3.11以上を使います。ガイドの依存関係を一度入れれば、UI、任意の13 flags、 9経路、テレメトリ、Debian制御、復旧kitをまとめて検証できます。 ```text @@ -72,8 +74,8 @@ codeを公開ガイドへ入力します。 - exercise modeは直結Ethernet以外の経路を持たない - 実在データ、実アカウント、実資格情報を標的へ置かない - root取得後のDebianを信頼せず、外部の信頼済み復旧USBから初期化する -- Windowsは空の捨て環境とし、裏flag以外の価値あるデータを置かない -- ディスク操作は機種、disk ID、partition UUID、image hash、確認語が一致しない限り実行しない +- GitHub再構築はroot取得済みOSから行わず、clean Debianから固定release/commitを使う +- ディスク操作は機種、disk ID、filesystem identity、image hash、確認語が一致しない限り実行しない ## 現在の検証境界 @@ -81,8 +83,18 @@ codeを公開ガイドへ入力します。 `/lab`と`/api/lab`は、`create → pair → replay拒否 → waiting → snapshot → live → SSE` の本番smokeを公開ごとに通します。次の項目は対象ノートで証跡を残すまで未完了です。 -- Windowsを保持したDebianデュアルブート導入 - Kali実機とKali VMからの9経路攻略 - exercise modeの物理NIC隔離 - root取得後の復旧USB初期化 -- DebianとWindowsの再起動 +- clean Debianと公開GitHubからの再構築 + +## Debianの再構築 + +Debian標的の初回構築・更新では、maintenance connectivity中だけCodex CLIと公開GitHubを使用できます。 +Codexは匿名cloneした固定release/commitを読み、test、platform overlay、target bundle、dry-run planを作成します。 +diskへの適用はoperator承認後に限り、exerciseへ移行する前にCodex認証、session、履歴、 +checkoutを除去して残留検査を通します。 + +正本手順は +[`labs/open-world-target/operator/CODEX-BOOTSTRAP.md`](labs/open-world-target/operator/CODEX-BOOTSTRAP.md) +です。 diff --git a/TASK_CONTRACT.md b/TASK_CONTRACT.md index 534ea63..6f1ef81 100644 --- a/TASK_CONTRACT.md +++ b/TASK_CONTRACT.md @@ -2,15 +2,15 @@ ## Goal -常時閲覧できるExamServerの公開ラボと、Kali Bridge経由でDebianの許可済み教材イベントに追従するライブ演習を、3入口、3root経路、14 flags、信頼済み復旧と一つの再現可能な体験として実装し、補助情報はユーザーがラベル付きの引き手を操作した時だけ一枚ずつ見せる。 +公開GitHubとCodex CLIから再構築できる専用Debian Boot2Root標的と、Kali上のFirefoxでDebianの許可済み教材イベントに追従する状態駆動のライブ攻略サイトを、3入口、3root経路、任意flags、二つの信頼済み復旧経路まで一つの再現可能な完成品として提供する。 ## Inputs -- Target: UEFI/GPT、Windowsとのデュアルブート、Debian用80GB以上の未割当領域を確保できるノートPC +- Target: 全diskを消去・復旧できる専用ノートPC、Debian 13 amd64 - Attacker: Kali実機、またはUSB Ethernetを専有し他NICを外したKali VM - Network: target `10.13.37.10/24`、直結Ethernet、target DHCP、Debianは外部経路なし。ライブ利用時だけKaliは別NICからVercelへ外向きHTTPS接続する。 - Audience: IT/ターミナル初心者、1チーム1〜3人 -- Session: 標準経路30〜60分、別ルート再挑戦あり +- Session: 標準経路約90分、別ルート再挑戦あり - Recovery: 演習中は外して保管する信頼済みUSBとbare-metal backup ## Removals @@ -19,6 +19,9 @@ - Live USBを通常起動方式として使わない。 - vanilla guideの出力貼付け中心モデルを削除する。 - Drupal、DVWA、既存OVAを新世界へ移植しない。 +- Windows、dual boot、Windows fixture、Windows flag、Windows mountをv1から削除する。 +- 個人プロフィール、メール、ニックネーム、復旧コード、管理画面、ランキングを追加しない。 +- クイズ、自由文AI相談、攻略自動実行を追加しない。 ## World contract @@ -32,7 +35,7 @@ 2. Group-writable root systemd timer payload 3. Training-only SUID helper with unsafe PATH resolution - All three footholds can reach all three root paths. -- Flags: entry 3、foothold 3、root clue 3、root route 3、common root 1、Windows 1。 +- Flags: entry 3、foothold 3、root clue 3、root route 3、common root 1。すべて任意収集で進行条件ではない。 ## Telemetry contract @@ -43,8 +46,11 @@ - Kali Bridge reads that public projection and sends monotonic snapshots to the cloud over outbound HTTPS. It never forwards a target port or arbitrary target traffic. - The public website has a no-session browse mode. A short-lived pairing flow switches it to the paired live projection. - SSE is the normal browser update path; state polling is the fallback. -- Manual flag submission remains local-only and is not sent through the public cloud. +- Flags are optional local collectibles. There is no manual submission route, and flag text never enters the guide or public cloud. - Root completion is the last trustworthy automatic event. +- Difficulty, hint disclosure, and optional flag collection are session state, not a person profile. +- EASY is the default. The learner may change next-choice, tool, syntax, example, failure-explanation, and explanation-detail visibility during a session without losing progress. +- Categories may be visible before discovery, but undiscovered names, facts, commands, and answers are absent from the browser projection and DOM. ## Platform contract @@ -52,9 +58,13 @@ - Public guide URL is `https://exam-server-one.vercel.app/lab`; its live API namespace is `/api/lab`. The reserved `.test` domain is not used publicly. The target is reached from Kali at `10.13.37.10` and is never the guide host. - Kali may use Wi-Fi for the Bridge, but IPv4/IPv6 forwarding, NAT, and cross-interface forwarding must remain disabled. - Maintenance mode keeps vulnerable services stopped before Wi-Fi or update tooling is enabled. -- Windows is offline, sacrificial, and contains only fixture data and its hidden flag. -- Disk-writing setup/recovery commands are dry-run or fail closed until exact disk identity, partition UUID, confirmation phrase, and image hash are supplied. +- The dedicated target disk contains Debian only. Disk-writing setup/recovery commands are dry-run or fail closed until exact disk identity, Debian filesystem identity, confirmation phrase, and image hash are supplied. - Root-acquired Debian is never reset from itself. +- Codex CLI and GitHub access are allowed only in fresh maintenance connectivity before exercise. +- Codex uses an isolated temporary `CODEX_HOME`, and the public repository is cloned anonymously under a disposable build root. +- Codex runs with `--ephemeral --sandbox workspace-write --ask-for-approval on-request`; bypassing approvals or the sandbox is forbidden. +- Before the golden state is sealed, Codex auth, session, history, secret environment variables, and the repository checkout must be absent and `open-world-build-hygiene` must pass. +- The convenience reset may be used only before root from a trusted maintenance state. After root, supported recovery is either external trusted-media restore or a clean Debian install followed by a pinned public GitHub/Codex reconstruction. ## Done criteria @@ -62,24 +72,31 @@ - Guide implements browse, waiting, live, loading, reconnecting, selected, hint, success, and local-only fallback states. - A visitor can understand the lab and view its public world without a target or session. - A Kali Bridge can create a short-lived session, upload a sanitized projection, and make a paired browser update without reload. -- PLAY、OPS、FOCUSの3テーマはruntimeで切替・保存でき、テーマ変更で教材状態を失わない。 +- PLAY、OPS、FOCUSの3テーマはruntimeで切替でき、テーマ変更で教材状態を失わない。 - 初期画面は現在目標、世界/仮説、主要操作だけを表示し、接続、事実、調査/ヒント、履歴、見た目は名称の分かる引き手からだけ開く。 - 補助情報は同時に一枚だけ表示し、Escape、背景操作、閉じる操作で収納でき、元の引き手へfocusが戻る。 - Telemetry state machine and API pass unit/integration tests without leaking forbidden data. -- Static target fixtures implement all 3 entrances, 3 root paths, and 14 logical flags. +- Static target fixtures implement all 3 entrances, 3 root paths, and 13 optional Debian flags. - Nine route combinations have automated contract tests and operator verification procedures. -- Platform includes deterministic install/config generation, exercise/maintenance mode controls, network isolation checks, and recovery media workflow. +- Platform includes deterministic non-secret install/config generation plus fresh build-time flags and synthetic credentials, exercise/maintenance mode controls, network isolation checks, and recovery media workflow. Secret-bearing bundle hashes are unique per build. +- A second operator can follow `CODEX-BOOTSTRAP.md` from a fresh maintenance state, anonymously clone a pinned public release/commit, regenerate the verified overlay and target bundle, review the dry-run plan, and pass the post-build hygiene check. +- The supported learner surface is Firefox on Kali. Public pairing and Kali loopback offline mode expose the same current state, next-step choices, and explanations without exposing target credentials to browser JavaScript. +- No account or profile is created. Identical target progress, difficulty, and hint state produce an identical browser projection. +- EASY is the default; all guidance controls can change mid-session; unknown route nodes expose only category and silhouette until automatic target evidence unlocks them. +- Optional flags never block the objective, route selection, explanation, or root completion. +- Kazekiri Motors and the guide use separate design contracts. Every visible target-site navigation and form path works without external requests, and photoreal local imagery contains no real logo, readable plate, real address, or identifying person. - Browser verification passes Firefox-compatible desktop/narrow flows with no console errors or horizontal overflow. - `design-qa.md` compares source and implementation at matching viewport and ends with `final result: passed`. -- Physical dual-boot, actual exploit execution, actual Windows boot, and actual recovery remain explicitly incomplete until recorded on the target notebook. +- The product is not complete until actual exploit execution, Kali live-guide operation, and trusted recovery are recorded on the target notebook. Any remaining physical gate stays explicitly incomplete. ## Surprise ledger - The old single-route lab is removed from active use instead of being migrated. -- Root is real Debian host root; Windows safety comes from sacrificial contents and trusted external recovery, not containment. +- Root is real Debian host root; safety comes from a dedicated disposable machine, network isolation, and trusted external recovery, not containment. - The guide is hosted publicly; only sanitized state projections and connection metadata reach the cloud. - Automatic detection is event-based and privacy-bounded, not full command monitoring. - Live mode requires Kali internet access while Debian remains isolated. Fully offline sessions use the local telemetry API without Vercel. +- The public repository makes route source inspectable. Runtime credentials, flags, tokens, and answer-bearing operator artifacts are generated or excluded, but source secrecy is not a product promise. - 旧三列レイアウトとモバイルでの全パネル縦積みは廃止し、desktopはside drawer、narrow画面はbottom sheetへ統一する。 ## UI refinement contract — 2026-07-29 diff --git a/apps/lab-guide/AGENTS.md b/apps/lab-guide/AGENTS.md index 7580bc0..81f5e6a 100644 --- a/apps/lab-guide/AGENTS.md +++ b/apps/lab-guide/AGENTS.md @@ -1,4 +1,9 @@ -# Prototype Instructions +# Product Instructions + +This is a finished operational product, not a demo or disposable prototype. +The release target is a repeatable dedicated Debian Boot2Root target plus a +public browser experience opened in Kali Firefox that shows the confirmed +current state, next-step choices, and beginner-safe explanations in real time. Run the local server yourself and open the preview in the browser available to this environment. Do not give the user server-start instructions when you can run it. @@ -8,12 +13,36 @@ When implementing from a selected generated mock, treat that image as the source The user explicitly rejected a subdued single-theme interface. Keep three first-class, runtime-selectable art directions—PLAY (pop game), OPS (hacker operations), and FOCUS (simple editorial)—without reducing them to color skins. All three must preserve the same learning hierarchy, accessibility, state, and beginner-safe language. The visual thesis is a playable operations board, not a generic dashboard. -The public site must remain useful without a target or session. Show an honest +The site must remain useful without a target or session. Show an honest public guide, not fixture progress or a connection error. During an exercise, pair the browser to a Kali Bridge and let sanitized learning events change the facts, graph, objective, choices, and recent-event strip without a reload. The Debian target never hosts the public guide and never receives an internet route. +The participant opens the guide in Firefox on Kali. A public Vercel deployment +is the normal transport when Kali has an independent internet connection; the +Kali loopback server is the supported offline transport. Both transports must +render the same learning state and must never expose the target telemetry token +to browser JavaScript. + +Do not create accounts, personal profiles, nicknames, recovery codes, +leaderboards, administrator screens, quizzes, or AI chat. The canonical display +is a pure projection of target progress, the learner-selected guidance +configuration, and unlocked hints. Identical canonical state must render the +same categories, silhouettes, current position, choices, and explanation for +every viewer. + +EASY is the default. Learners may change guidance during a session without +losing progress. Undiscovered routes expose only a category and an unnamed +silhouette; names, facts, command examples, and answers enter the browser +projection only after their unlock condition. Optional flags never gate +progress. + +Keep safety and authorization available at entry and in quiet legal/help +surfaces, but do not repeat “training”, “exercise”, or security disclaimers in +normal mission content. The guide should feel like a playable investigation, +and the Kazekiri target must feel like a separate, living business site. + Build app UI in `src/`. The canonical public surface is ExamServer `/lab`; the isolated Vercel deployment is an implementation origin only. Keep browser API calls under `/api/lab`, and verify `npm test`, `npm run typecheck`, and diff --git a/apps/lab-guide/cloud/README.md b/apps/lab-guide/cloud/README.md index 9bf2ba2..20e4ce4 100644 --- a/apps/lab-guide/cloud/README.md +++ b/apps/lab-guide/cloud/README.md @@ -36,8 +36,11 @@ one second, so no long-lived function or in-memory room is required. Redis conditional updates keep revisions monotonic, and pairing codes are deleted before a session is returned so they remain single-use. Browser actions are -limited to hypothesis selection and hint unlock. Manual flag submission is not -a cloud route. +limited to hypothesis selection, hint unlock, and exact allowlisted guidance +settings. No manual flag submission route exists; flag text stays outside the +projection and cloud relay. The relay stores no +profile, account, or personal progress record; the current projection is the +only browser-visible location. Run the repository checks before deployment: diff --git a/apps/lab-guide/cloud/http.mjs b/apps/lab-guide/cloud/http.mjs index 372b50c..0c370fb 100644 --- a/apps/lab-guide/cloud/http.mjs +++ b/apps/lab-guide/cloud/http.mjs @@ -352,6 +352,21 @@ export function createCloudHandler({ service, now = Date.now }) { ); } + const guidance = + /^\/api\/session\/guidance\/([^/]+)\/apply$/.exec(path); + if (guidance) { + method(request, "POST"); + await requireEmptyBody(request); + return jsonResponse( + await service.queueAction({ + cookieValue: sessionCookie(request), + type: "setGuidance", + targetId: decodedTargetId(guidance[1]), + }), + 202, + ); + } + throw new CloudError(404, "not_found", "このAPIはありません。"); } catch (error) { return errorResponse(error); diff --git a/apps/lab-guide/cloud/projection-state.mjs b/apps/lab-guide/cloud/projection-state.mjs index 4d4d329..9f18d91 100644 --- a/apps/lab-guide/cloud/projection-state.mjs +++ b/apps/lab-guide/cloud/projection-state.mjs @@ -37,7 +37,16 @@ export function waitingProjection(targetSessionId) { investigations: [], graph: { nodes: [], edges: [] }, hints: [], - progress: { discovered: 0, total: 14 }, + guidance: { + showNextChoices: true, + showToolNames: true, + showCommandSyntax: true, + showCommandExamples: true, + explainNoProgress: true, + explanationDepth: "full", + silhouetteDepth: 1, + }, + progress: { discovered: 0, total: 13 }, recentEvents: [], telemetry: { status: "waiting", diff --git a/apps/lab-guide/cloud/projection.mjs b/apps/lab-guide/cloud/projection.mjs index 2b7039b..eee983a 100644 --- a/apps/lab-guide/cloud/projection.mjs +++ b/apps/lab-guide/cloud/projection.mjs @@ -14,6 +14,26 @@ const ICONS = new Set([ "terminal", "user", ]); +const PUBLIC_CATEGORIES = new Set([ + "Web", + "共有", + "整備", + "権限獲得", + "権限昇格", + "root経路", + "最終地点", +]); +const ROUTE_IDS = new Set([ + "web-sudo", + "web-timer", + "web-suid", + "smb-sudo", + "smb-timer", + "smb-suid", + "nfs-sudo", + "nfs-timer", + "nfs-suid", +]); const SECRET_TEXT = [ /\b(?:FLAG|LAB)\{[^}\r\n]{0,512}\}/i, /-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----/i, @@ -180,6 +200,7 @@ function validateGraphNode(value, index) { const path = `projection.graph.nodes[${index}]`; const input = plainObject(value, path); exactKeys(input, path, ["id", "state"], [ + "category", "label", "detail", "icon", @@ -199,9 +220,21 @@ function validateGraphNode(value, index) { boundary(path, "must not reveal undiscovered node material"); } + const category = + input.category === undefined + ? undefined + : safeText(input.category, `${path}.category`, { maximum: 32 }); + if ( + (input.state === "undiscovered" && category === undefined) || + (category !== undefined && !PUBLIC_CATEGORIES.has(category)) + ) { + boundary(`${path}.category`, "is not an allowlisted public category"); + } + const output = { id: identifier(input.id, `${path}.id`), state: input.state, + ...(category === undefined ? {} : { category }), }; if (input.state !== "undiscovered") { output.label = safeText(input.label, `${path}.label`); @@ -266,8 +299,8 @@ function validateHint(value, index) { "body", "condition", ]); - if (![1, 2, 3].includes(input.step)) { - boundary(`${path}.step`, "must be 1, 2, or 3"); + if (![1, 2, 3, 4].includes(input.step)) { + boundary(`${path}.step`, "must be 1, 2, 3, or 4"); } if (!["unlocked", "available", "locked"].includes(input.state)) { boundary(`${path}.state`, "is not a public hint state"); @@ -313,6 +346,58 @@ function validateRecentEvent(value, index) { }; } +function validateGuidance(value) { + const path = "projection.guidance"; + const input = plainObject(value, path); + exactKeys(input, path, [ + "showNextChoices", + "showToolNames", + "showCommandSyntax", + "showCommandExamples", + "explainNoProgress", + "explanationDepth", + "silhouetteDepth", + ]); + for (const field of [ + "showNextChoices", + "showToolNames", + "showCommandSyntax", + "showCommandExamples", + "explainNoProgress", + ]) { + boolean(input[field], `${path}.${field}`); + } + if (!["brief", "full"].includes(input.explanationDepth)) { + boundary( + `${path}.explanationDepth`, + "is not a public explanation depth", + ); + } + if (![0, 1].includes(input.silhouetteDepth)) { + boundary(`${path}.silhouetteDepth`, "must be 0 or 1"); + } + return { + showNextChoices: input.showNextChoices, + showToolNames: input.showToolNames, + showCommandSyntax: input.showCommandSyntax, + showCommandExamples: input.showCommandExamples, + explainNoProgress: input.explainNoProgress, + explanationDepth: input.explanationDepth, + silhouetteDepth: input.silhouetteDepth, + }; +} + +function validateCompletion(value) { + const path = "projection.completion"; + const input = plainObject(value, path); + exactKeys(input, path, ["routeId"]); + const routeId = identifier(input.routeId, `${path}.routeId`); + if (!ROUTE_IDS.has(routeId)) { + boundary(`${path}.routeId`, "is not a public route id"); + } + return { routeId }; +} + export function validatePublicProjection(value) { let serialized; try { @@ -339,6 +424,7 @@ export function validatePublicProjection(value) { "hypotheses", "graph", "hints", + "guidance", "progress", "recentEvents", "telemetry", @@ -351,6 +437,7 @@ export function validatePublicProjection(value) { "consultationQuestion", "investigations", "capabilities", + "completion", ], ); if (input.experience !== undefined && input.experience !== "live") { @@ -360,7 +447,7 @@ export function validatePublicProjection(value) { boundary("projection.status", "is not a public session status"); } - const facts = array(input.facts, "projection.facts", 14).map(validateFact); + const facts = array(input.facts, "projection.facts", 13).map(validateFact); const hypotheses = array( input.hypotheses, "projection.hypotheses", @@ -380,7 +467,16 @@ export function validatePublicProjection(value) { const edges = array(graph.edges, "projection.graph.edges", 96).map( validateGraphEdge, ); - const hints = array(input.hints, "projection.hints", 3).map(validateHint); + const hints = array(input.hints, "projection.hints", 4).map(validateHint); + if ( + hints.length !== 4 || + hints.some((hint, index) => hint.step !== index + 1) + ) { + boundary( + "projection.hints", + "must contain the four ordered explanation steps", + ); + } const recentEvents = array( input.recentEvents, "projection.recentEvents", @@ -401,8 +497,8 @@ export function validatePublicProjection(value) { const progress = plainObject(input.progress, "projection.progress"); exactKeys(progress, "projection.progress", ["discovered", "total"]); const total = integer(progress.total, "projection.progress.total", { - minimum: 14, - maximum: 14, + minimum: 13, + maximum: 13, }); const discovered = integer( progress.discovered, @@ -431,6 +527,14 @@ export function validatePublicProjection(value) { "projection.capabilities.manualFlagSubmission", ); } + const guidance = validateGuidance(input.guidance); + const completion = + input.completion === undefined + ? undefined + : validateCompletion(input.completion); + if (completion && input.status !== "complete") { + boundary("projection.completion", "requires complete status"); + } return { experience: "live", @@ -470,7 +574,9 @@ export function validatePublicProjection(value) { ...(investigations === undefined ? {} : { investigations }), graph: { nodes, edges }, hints, + guidance, progress: { discovered, total }, + ...(completion === undefined ? {} : { completion }), recentEvents, telemetry: { status: telemetry.status, diff --git a/apps/lab-guide/cloud/session-service.mjs b/apps/lab-guide/cloud/session-service.mjs index ce0756c..cfade08 100644 --- a/apps/lab-guide/cloud/session-service.mjs +++ b/apps/lab-guide/cloud/session-service.mjs @@ -15,7 +15,30 @@ import { verifySessionCookie, } from "./security.mjs"; -const ACTION_TYPES = new Set(["selectHypothesis", "unlockHint"]); +const ACTION_TYPES = new Set([ + "selectHypothesis", + "unlockHint", + "setGuidance", +]); +const GUIDANCE_COMMAND_IDS = new Set([ + "preset.easy", + "preset.normal", + "preset.hard", + "showNextChoices.on", + "showNextChoices.off", + "showToolNames.on", + "showToolNames.off", + "showCommandSyntax.on", + "showCommandSyntax.off", + "showCommandExamples.on", + "showCommandExamples.off", + "explainNoProgress.on", + "explainNoProgress.off", + "explanationDepth.brief", + "explanationDepth.full", + "silhouetteDepth.0", + "silhouetteDepth.1", +]); const MAX_PENDING_ACTIONS = 32; const MAX_CAS_ATTEMPTS = 8; @@ -166,6 +189,12 @@ function ensureActionTarget(record, type, targetId) { } return; } + if (type === "setGuidance") { + if (!GUIDANCE_COMMAND_IDS.has(targetId)) { + fail(409, "action_unavailable", "この表示設定は利用できません。"); + } + return; + } const hint = record.projection.hints.find((item) => item.id === targetId); if (!hint || hint.state !== "available") { fail(409, "action_unavailable", "このヒントは現在利用できません。"); diff --git a/apps/lab-guide/index.html b/apps/lab-guide/index.html index 1805f05..8dfe568 100644 --- a/apps/lab-guide/index.html +++ b/apps/lab-guide/index.html @@ -5,29 +5,29 @@ - + - + - ExamServer 実践ラボ + ExamServer 調査ガイド
diff --git a/apps/lab-guide/src/App.test.tsx b/apps/lab-guide/src/App.test.tsx index 147f16e..9c29503 100644 --- a/apps/lab-guide/src/App.test.tsx +++ b/apps/lab-guide/src/App.test.tsx @@ -9,7 +9,6 @@ import { import { App } from "./App"; import { createBrowseClient } from "./browse"; import { ExplorationMap } from "./components/ExplorationMap"; -import { ManualFlagForm } from "./components/ManualFlagForm"; import { fallbackPositionFor, WORLD_POSITIONS, @@ -128,6 +127,86 @@ describe("Lab guide", () => { expect(screen.queryByLabelText("見つけたflag")).not.toBeInTheDocument(); }); + it("applies guidance to the public state without creating a profile", async () => { + const client = createBrowseClient(); + const easy = await client.getState(); + expect(easy.hints).toHaveLength(4); + expect(easy.hints.every((hint) => hint.state === "unlocked")).toBe(true); + expect( + easy.graph.nodes.filter((node) => node.state === "undiscovered"), + ).toHaveLength(3); + + const hard = await client.applyGuidance("preset.hard"); + expect(hard?.investigations).toEqual([]); + expect(hard?.hints[0].state).toBe("available"); + expect( + hard?.graph.nodes.some((node) => node.state === "undiscovered"), + ).toBe(false); + expect(hard?.telemetry.message).toBeUndefined(); + expect(JSON.stringify(hard)).not.toMatch(/profile|account/); + + const custom = await client.applyGuidance("showNextChoices.on"); + expect(custom?.investigations).toHaveLength(3); + expect(custom?.hints[0].state).toBe("unlocked"); + }); + + it("reprojects the live fixture when guidance changes", async () => { + const client = createFixtureClient("live"); + const easy = await client.getState(); + expect(easy.heading).toBe("風切モータースの業務環境を調べる"); + expect(easy.facts.map((fact) => fact.label)).toEqual([ + "スタッフ用の診断画面", + "引き継ぎ用の共有", + "整備場のNFS共有", + ]); + expect(easy.graph.nodes.map((node) => node.id)).toEqual([ + "map-01", + "map-02", + "map-03", + "map-04", + "map-05", + "map-06", + ]); + expect(JSON.stringify(easy)).toContain("10.13.37.10"); + expect(JSON.stringify(easy)).not.toContain("target.local"); + expect(easy.hints).toHaveLength(4); + expect(easy.hints.every((hint) => hint.state === "unlocked")).toBe(true); + expect(easy.investigations).toHaveLength(3); + expect( + easy.graph.nodes.filter((node) => node.state === "undiscovered"), + ).toHaveLength(3); + + const hard = await client.applyGuidance("preset.hard"); + expect(hard?.investigations).toEqual([]); + expect(hard?.hints[0].state).toBe("available"); + expect( + hard?.graph.nodes.some((node) => node.state === "undiscovered"), + ).toBe(false); + expect(hard?.graph.edges).toEqual([]); + + const easyAgain = await client.applyGuidance("preset.easy"); + expect(easyAgain?.investigations).toHaveLength(3); + expect( + easyAgain?.hints.every((hint) => hint.state === "unlocked"), + ).toBe(true); + expect( + easyAgain?.graph.nodes.filter( + (node) => node.state === "undiscovered", + ), + ).toHaveLength(3); + + const backup = await client.selectHypothesis("hyp-backup-trust"); + expect(backup?.objective).toBe( + "匿名共有のバックアップを運用上の手掛かりとして読む", + ); + expect(backup?.hints.map((hint) => hint.id)).toEqual([ + "hyp-backup-trust:1", + "hyp-backup-trust:2", + "hyp-backup-trust:3", + "hyp-backup-trust:4", + ]); + }); + it("traps focus in one requested drawer and returns it to its pull", async () => { const user = userEvent.setup(); render( @@ -167,9 +246,12 @@ describe("Lab guide", () => { expect( await screen.findByRole("heading", { - name: "標的との接続を確かめる", + name: "風切モータースの業務環境を調べる", }), ).toBeVisible(); + expect( + screen.getByRole("progressbar", { name: "13件中0件を発見" }), + ).toBeVisible(); expect( screen.getByRole("button", { name: "探索ツールを開く" }), ).toBeVisible(); @@ -177,8 +259,8 @@ describe("Lab guide", () => { expect( await screen.findByRole( - "heading", - { name: "Webの入口を発見" }, + "progressbar", + { name: "13件中1件を発見" }, { timeout: 2_000 }, ), ).toBeVisible(); @@ -212,7 +294,7 @@ describe("Lab guide", () => { expect(document.documentElement).toHaveAttribute("data-theme", "play"); await user.click( - screen.getByRole("button", { name: "状況相談" }), + screen.getByRole("button", { name: "次の手順" }), ); await user.click(screen.getByLabelText("メニュー")); const reopenedThemeGroup = screen.getByRole("group", { @@ -254,6 +336,53 @@ describe("Lab guide", () => { window.localStorage.removeItem(THEME_STORAGE_KEY); }); + it("starts in EASY and changes guidance mid-session without leaving the screen", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByTestId("next-action-map"), + ); + await user.click(screen.getByLabelText("メニュー")); + let guidanceGroup = screen.getByRole("group", { + name: "難易度とヒント表示", + }); + expect( + within(guidanceGroup).getByRole("button", { name: "EASY" }), + ).toHaveAttribute("aria-pressed", "true"); + + await user.click( + within(guidanceGroup).getByRole("button", { name: "HARD" }), + ); + await waitFor(() => + expect( + within(guidanceGroup).getByRole("button", { name: "HARD" }), + ).toHaveAttribute("aria-pressed", "true"), + ); + expect( + screen.getByRole("heading", { name: "次に確かめることを選ぶ" }), + ).toBeVisible(); + + await user.click(screen.getByLabelText("メニュー")); + guidanceGroup = screen.getByRole("group", { + name: "難易度とヒント表示", + }); + await user.click( + within(guidanceGroup).getByText("表示を細かく選ぶ"), + ); + await user.click( + within(guidanceGroup).getByRole("switch", { + name: /次の候補/, + }), + ); + await waitFor(() => { + guidanceGroup = screen.getByRole("group", { + name: "難易度とヒント表示", + }); + expect(within(guidanceGroup).getByText("CUSTOM")).toBeVisible(); + }); + }); + it("maps telemetry edge states onto tentative and known routes", () => { const projection = normalizeProjection({ graph: { @@ -272,12 +401,12 @@ describe("Lab guide", () => { }); it("gives every projected world node a unique fallback position", () => { - expect(WORLD_POSITIONS.size).toBe(14); + expect(WORLD_POSITIONS.size).toBe(13); expect( new Set( [...WORLD_POSITIONS.values()].map(({ x, y }) => `${x}:${y}`), ).size, - ).toBe(14); + ).toBe(13); }); it("keeps a subset on its stable topology rows", () => { @@ -354,10 +483,10 @@ describe("Lab guide", () => { ); }); - it("exercises all fourteen telemetry map IDs in the success fixture", async () => { + it("exercises all thirteen Debian telemetry map IDs in the success fixture", async () => { const projection = await createFixtureClient("success").getState(); expect(projection.graph.nodes.map((node) => node.id)).toEqual( - Array.from({ length: 14 }, (_, index) => + Array.from({ length: 13 }, (_, index) => `map-${String(index + 1).padStart(2, "0")}`, ), ); @@ -366,6 +495,22 @@ describe("Lab guide", () => { ).toBe(true); }); + it("shows only the route unlocked by the current completed session", async () => { + render(); + + const achievement = await screen.findByRole("region", { + name: "経路実績を解除", + }); + expect( + within(achievement).getByText("Web診断 × sudo保守hook"), + ).toBeVisible(); + expect(within(achievement).getByText(/ROUTE UNLOCKED/)).toBeVisible(); + expect(screen.queryByText("整備場NFS × SUID PATH")).not.toBeInTheDocument(); + expect(JSON.stringify(await createFixtureClient("success").getState())).not.toMatch( + /profile|account/, + ); + }); + it("publishes a live discovery without reloading the client", async () => { vi.useFakeTimers(); try { @@ -374,18 +519,18 @@ describe("Lab guide", () => { const listener = vi.fn(); const unsubscribe = client.subscribe(listener, vi.fn()); - expect(initial.heading).toBe("標的との接続を確かめる"); + expect(initial.heading).toBe("風切モータースの業務環境を調べる"); expect(initial.facts).toHaveLength(0); await vi.advanceTimersByTimeAsync(900); expect(listener).toHaveBeenCalledTimes(1); expect(listener.mock.calls[0][0]).toMatchObject({ - heading: "Webの入口を発見", - progress: { discovered: 1, total: 14 }, + heading: "風切モータースの業務環境を調べる", + progress: { discovered: 1, total: 13 }, }); expect(listener.mock.calls[0][0].facts[0].label).toBe( - "Webサイトが見える", + "スタッフ用の診断画面", ); unsubscribe(); } finally { @@ -399,11 +544,11 @@ describe("Lab guide", () => { expect( await screen.findByRole("heading", { - name: "中古バイク店の業務サーバを調べる", + name: "風切モータースの業務環境を調べる", }), ).toBeVisible(); expect( - screen.getByRole("progressbar", { name: "14件中3件を発見" }), + screen.getByRole("progressbar", { name: "13件中3件を発見" }), ).toBeVisible(); expect(screen.queryByLabelText("見つけたflag")).not.toBeInTheDocument(); @@ -414,7 +559,7 @@ describe("Lab guide", () => { ).toBeVisible(); expect( screen.getByRole("button", { - name: /スタッフ向け画面の入力を試す/, + name: /まず、直結先で応答するサービスを整理する/, }), ).toHaveAttribute("aria-pressed", "true"); @@ -431,12 +576,14 @@ describe("Lab guide", () => { await user.click(await screen.findByTestId("next-action-map")); await user.click( screen.getByRole("button", { - name: /公開ファイルに別の手掛かりがないか探す/, + name: /匿名共有のバックアップを運用上の手掛かりとして読む/, }), ); expect( - screen.getByText("公開範囲に置かれたファイル名と更新時刻を見比べます。"), + screen.getByText( + "ファイル名だけでなく、いつ・何のために残されたかを確認する。", + ), ).toBeVisible(); await user.click( @@ -448,103 +595,46 @@ describe("Lab guide", () => { ).toBeInTheDocument(); }); - it("unlocks hints in order", async () => { + it("shows the four EASY explanation layers", async () => { const user = userEvent.setup(); render(); await user.click(await screen.findByTestId("next-action-map")); await user.click( - screen.getByRole("button", { name: "相談ツールを開く" }), + screen.getByRole("button", { name: "手掛かりを開く" }), ); const hintDialog = screen.getByRole("dialog", { - name: "相談ツール", + name: "手掛かり", }); expect( within(hintDialog).getByText( - "入力した文字と、画面に返る結果の関係を見ます。", + "Kali側の有線IPと、10.13.37.10が応答するTCPサービスを見る。", ), ).toBeVisible(); - const operationHint = within(hintDialog).getByRole("button", { - name: /^操作例/, - }); - expect(operationHint).toBeDisabled(); - expect( - within(hintDialog).getByText("「使う道具」を確認すると開けます"), - ).toBeVisible(); - await user.click( within(hintDialog).getByRole("button", { name: /^使う道具/ }), ); expect( await within(hintDialog).findByText( - "ブラウザの開発者ツールで、送信された項目を確認します。", + "ip addr、ping、nmapの順で、接続とサービスを分けて確認する。", ), ).toBeVisible(); - expect(operationHint).toBeEnabled(); - await user.click(operationHint); - expect( - await within(hintDialog).findByText( - "無害な入力を一つずつ変え、返り方の差を記録します。", - ), - ).toBeVisible(); - }); - - it("offers manual flag submission only when telemetry is unavailable", async () => { - const user = userEvent.setup(); - render(); - - await user.click( - await screen.findByRole("button", { name: "探索ツールを開く" }), - ); - const investigationDialog = screen.getByRole("dialog", { - name: "探索ツール", - }); - await user.click( - within(investigationDialog).getByRole("button", { - name: /^次の調査\d+件$/, - }), - ); - const input = within(investigationDialog).getByLabelText("見つけたflag"); - await user.type(input, "manual-proof"); await user.click( - within(investigationDialog).getByRole("button", { name: "提出" }), + within(hintDialog).getByRole("button", { name: /^組み立て方/ }), ); - - expect( - await within(input.closest("form")!).findByText( - "提出を受け付けました。状態をもう一度確認します。", - ), - ).toBeInTheDocument(); expect( - screen.getAllByText( - "提出を受け付けました。状態をもう一度確認します。", + await within(hintDialog).findByText( + "対象IPを固定し、名前解決やping応答に依存せずサービス版を確認する。", ), - ).toHaveLength(1); - await waitFor(() => expect(input).toHaveValue("")); - }); - - it("always offers the post-root Windows bonus flag manually", async () => { - const user = userEvent.setup(); - render(); + ).toBeVisible(); await user.click( - await screen.findByRole("button", { name: "探索ツールを開く" }), - ); - const investigationDialog = screen.getByRole("dialog", { - name: "探索ツール", - }); - await user.click( - within(investigationDialog).getByRole("button", { - name: /^次の調査\d+件$/, - }), + within(hintDialog).getByRole("button", { name: /^操作例/ }), ); expect( - within(investigationDialog).getByLabelText("Windowsで見つけたflag"), - ).toBeVisible(); - expect( - within(investigationDialog).getByText( - "root取得後のWindows追加flagは手動で確認します。", + await within(hintDialog).findByText( + "nmap -sV -Pn 10.13.37.10", ), ).toBeVisible(); }); @@ -553,14 +643,14 @@ describe("Lab guide", () => { const user = userEvent.setup(); render(); await screen.findByRole("heading", { - name: "中古バイク店の業務サーバを調べる", + name: "風切モータースの業務環境を調べる", }); await user.click(screen.getByLabelText("メニュー")); - const endSession = screen.getByRole("button", { name: "演習を終了" }); + const endSession = screen.getByRole("button", { name: "表示を終了" }); await user.click(endSession); expect( - screen.getByRole("dialog", { name: "演習を終了しますか?" }), + screen.getByRole("dialog", { name: "調査の表示を終了しますか?" }), ).toBeVisible(); expect( screen.getByRole("button", { name: "探索を続ける" }), @@ -578,10 +668,10 @@ describe("Lab guide", () => { expect(screen.getByLabelText("メニュー")).toHaveFocus(); await user.click(screen.getByLabelText("メニュー")); - await user.click(screen.getByRole("button", { name: "演習を終了" })); + await user.click(screen.getByRole("button", { name: "表示を終了" })); await user.click(screen.getByRole("button", { name: "終了する" })); expect( - screen.getByRole("heading", { name: "演習の表示を終了しました" }), + screen.getByRole("heading", { name: "調査の表示を終了しました" }), ).toBeVisible(); }); @@ -618,15 +708,12 @@ describe("Lab guide", () => { }, selectHypothesis: async () => undefined, unlockHint: async () => undefined, - submitFlag: async () => ({ - accepted: false, - message: "not used", - }), + applyGuidance: async () => undefined, }; render(); await screen.findByRole("heading", { - name: "中古バイク店の業務サーバを調べる", + name: "風切モータースの業務環境を調べる", }); await waitFor(() => expect(publish).toBeTypeOf("function")); await user.click( @@ -635,7 +722,9 @@ describe("Lab guide", () => { const factsDialog = screen.getByRole("dialog", { name: "探索ツール", }); - expect(within(factsDialog).getByText("Webサイトが見える")).toBeVisible(); + expect( + within(factsDialog).getByText("スタッフ用の診断画面"), + ).toBeVisible(); const newer = structuredClone(initial); newer.revision = initial.revision + 2; @@ -678,7 +767,6 @@ describe("Lab guide", () => { pendingAction: null, onRefresh: () => undefined, onOpenConsultation: () => undefined, - onSubmitFlag: async () => undefined, experience: "live" as const, pairingPending: false, onPairSession: async () => true, @@ -688,7 +776,7 @@ describe("Lab guide", () => { ); const fileNode = screen - .getAllByRole("button", { name: /ファイル置き場/ }) + .getAllByRole("button", { name: /引き継ぎ用の共有/ }) .find((button) => button.hasAttribute("aria-pressed"))!; await user.click(fileNode); expect(fileNode).toHaveAttribute("aria-pressed", "true"); @@ -698,16 +786,16 @@ describe("Lab guide", () => { rerender(); expect( screen - .getAllByRole("button", { name: /ファイル置き場/ }) + .getAllByRole("button", { name: /引き継ぎ用の共有/ }) .find((button) => button.hasAttribute("aria-pressed")), ).toHaveAttribute("aria-pressed", "true"); const removedSelection = structuredClone(stillVisible); removedSelection.revision += 1; removedSelection.graph.nodes = removedSelection.graph.nodes.map((node) => - node.id === "file-drop" - ? { ...node, state: "undiscovered", label: "未発見" } - : node.id === "admin" + node.id === "map-02" + ? { ...node, state: "undiscovered", label: "共有" } + : node.id === "map-03" ? { ...node, state: "selected" } : { ...node, state: node.state === "selected" ? "discovered" : node.state }, ); @@ -717,7 +805,7 @@ describe("Lab guide", () => { await waitFor(() => expect( - screen.getByRole("button", { name: /Linux管理者/ }), + screen.getByRole("button", { name: /整備場のNFS共有/ }), ).toHaveAttribute("aria-pressed", "true"), ); }); @@ -731,7 +819,6 @@ describe("Lab guide", () => { onRefresh: () => undefined, onSelectHypothesis: async () => true, onUnlockHint: async () => true, - onSubmitFlag: async () => undefined, onBackToMap: () => undefined, }; const { rerender } = render( @@ -742,7 +829,7 @@ describe("Lab guide", () => { ); const userChoice = screen.getByRole("button", { - name: /公開ファイルに別の手掛かりがないか探す/, + name: /匿名共有のバックアップを運用上の手掛かりとして読む/, }); await user.click(userChoice); @@ -756,10 +843,10 @@ describe("Lab guide", () => { const removedChoice = structuredClone(refreshed); removedChoice.revision += 1; removedChoice.hypotheses = removedChoice.hypotheses - .filter((hypothesis) => hypothesis.id !== "hypothesis-public-files") + .filter((hypothesis) => hypothesis.id !== "hyp-backup-trust") .map((hypothesis) => ({ ...hypothesis, - selected: hypothesis.id === "hypothesis-login", + selected: hypothesis.id === "hyp-nfs-ownership", })); rerender( , @@ -767,7 +854,7 @@ describe("Lab guide", () => { await waitFor(() => expect( screen.getByRole("button", { - name: /ログイン用の情報が残っていないか調べる/, + name: /NFS上の所有者と書き込み可能範囲を確かめる/, }), ).toHaveAttribute("aria-pressed", "true"), ); @@ -775,55 +862,19 @@ describe("Lab guide", () => { rerender( , ); await waitFor(() => expect( screen.getByRole("button", { - name: /スタッフ向け画面の入力を試す/, + name: /診断入力がどこまでOSへ渡るか確かめる/, }), ).toHaveAttribute("aria-pressed", "true"), ); }); - it("shows rejected and accepted manual flag results beside the form", async () => { - const user = userEvent.setup(); - const submit = vi - .fn() - .mockResolvedValueOnce({ - accepted: false, - message: "flagを確認できませんでした。", - }) - .mockResolvedValueOnce({ - accepted: true, - message: "flagを確認しました。", - }); - render( - , - ); - - const input = screen.getByLabelText("見つけたflag"); - await user.type(input, "LAB-wrong"); - await user.click(screen.getByRole("button", { name: "提出" })); - expect( - await screen.findByText("flagを確認できませんでした。"), - ).toBeVisible(); - expect(input).toHaveValue("LAB-wrong"); - expect(input).toHaveAttribute("aria-invalid", "true"); - - await user.clear(input); - await user.type(input, "LAB-correct"); - await user.click(screen.getByRole("button", { name: "提出" })); - expect(await screen.findByText("flagを確認しました。")).toBeVisible(); - await waitFor(() => expect(input).toHaveValue("")); - expect(submit).toHaveBeenCalledTimes(2); - }); }); describe("projection boundary", () => { @@ -852,6 +903,7 @@ describe("projection boundary", () => { { id: "map-opaque", state: "undiscovered", + category: "権限昇格", label: "secret route name", detail: "secret route detail", }, @@ -867,15 +919,41 @@ describe("projection boundary", () => { body: "secret locked hint", }, ], - progress: { discovered: 0, total: 14 }, + progress: { discovered: 0, total: 13 }, telemetry: { status: "live" }, }); expect(projection.graph.nodes[0]).toMatchObject({ id: "map-opaque", - label: "未発見", + label: "権限昇格", + category: "権限昇格", detail: undefined, }); + expect(JSON.stringify(projection.graph.nodes[0])).not.toContain( + "secret route", + ); expect(projection.hints[0].body).toBeUndefined(); }); + + it("does not accept an arbitrary hidden label as a public category", () => { + const projection = normalizeProjection({ + graph: { + nodes: [ + { + id: "map-hidden-category", + state: "undiscovered", + category: "秘密のroot経路名", + label: "別の秘密", + }, + ], + edges: [], + }, + }); + + expect(projection.graph.nodes[0]).toMatchObject({ + label: "未発見", + category: undefined, + }); + expect(JSON.stringify(projection.graph.nodes[0])).not.toContain("秘密"); + }); }); diff --git a/apps/lab-guide/src/App.tsx b/apps/lab-guide/src/App.tsx index 1fdbf1e..b888379 100644 --- a/apps/lab-guide/src/App.tsx +++ b/apps/lab-guide/src/App.tsx @@ -9,6 +9,7 @@ import { pairLabSession, resolveLabClient, } from "./api"; +import { EASY_GUIDANCE } from "./guidance"; import { AppHeader } from "./components/AppHeader"; import { EndSessionDialog } from "./components/EndSessionDialog"; import { ExplorationMap } from "./components/ExplorationMap"; @@ -126,12 +127,17 @@ export function App({ experience={experience} theme={theme} onThemeChange={setTheme} + guidance={session.projection?.guidance ?? EASY_GUIDANCE} + guidancePending={session.pendingAction === "guidance"} + onGuidanceChange={(commandId) => + void session.applyGuidance(commandId) + } /> {sessionEnded ? (
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/faq.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/faq.php new file mode 100644 index 0000000..39414e4 --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/faq.php @@ -0,0 +1,64 @@ + +
+
+
+
+ "よくある質問"]]); ?> +

FAQ

+

よくある質問

+
+

在庫の確認、整備の預かり、来店前に聞かれることをまとめました。

+
+
+ +
+
+ +
+ $questions): ?> +
"> +

+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+

STILL UNSURE

+

車両を見ながら相談できます

+

在庫番号か整備内容を選び、来店予定を送ってください。

+
+ 来店相談へ +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/data.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/data.php new file mode 100644 index 0000000..1af2d55 --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/data.php @@ -0,0 +1,323 @@ + [ + "id" => "kz-250n-042", + "stock_number" => "KZ-250N-042", + "name" => "250cc ネイキッド", + "color" => "墨黒", + "category" => "road", + "use" => "commute", + "availability" => "available", + "status" => "商談受付中", + "year" => 2021, + "distance" => "12,460 km", + "price" => "47.8万円", + "total" => "54.2万円", + "image" => "stock-01.webp", + "alt" => "整備場の前に停めた墨黒の250ccネイキッド車", + "summary" => "通勤と週末の近距離ツーリングに使いやすい一台です。低速での扱いやすさを優先して整備しました。", + "condition" => [ + "外装" => "タンク右側に薄い擦れ。割れや補修跡はありません。", + "足まわり" => "前後タイヤを交換済み。フロントフォークに漏れなし。", + "電装" => "灯火、充電電圧、始動系を確認済み。", + ], + "history" => [ + ["date" => "2026.07.20", "work" => "入庫点検、圧縮と充電電圧を確認"], + ["date" => "2026.07.22", "work" => "前後タイヤ、エンジンオイルを交換"], + ["date" => "2026.07.25", "work" => "試走18km、増し締め"], + ], + "notes" => ["キー2本", "取扱メモあり", "次回オイル交換 15,000km目安"], + "featured" => true, + ], + "kz-400c-018" => [ + "id" => "kz-400c-018", + "stock_number" => "KZ-400C-018", + "name" => "400cc クラシック", + "color" => "深緑", + "category" => "road", + "use" => "touring", + "availability" => "hold", + "status" => "商談中", + "year" => 2019, + "distance" => "8,320 km", + "price" => "61.4万円", + "total" => "68.1万円", + "image" => "stock-02.webp", + "alt" => "木造の軒先に停めた深緑の400ccクラシック車", + "summary" => "急がず距離を走りたい方向けです。保管状態がよく、入庫時の消耗品交換も少なく済みました。", + "condition" => [ + "外装" => "メッキ部に細かな曇り。シート表皮は良好です。", + "足まわり" => "ブレーキ残量は前6mm、後4mm。タイヤは継続使用可。", + "電装" => "バッテリー交換済み。全灯火を確認済み。", + ], + "history" => [ + ["date" => "2026.07.08", "work" => "入庫点検、整備履歴を照合"], + ["date" => "2026.07.10", "work" => "バッテリー、ブレーキフルードを交換"], + ["date" => "2026.07.13", "work" => "冷間始動と80km/h巡航を確認"], + ], + "notes" => ["キー2本", "屋内保管車", "商談期限 2026.08.03"], + "featured" => true, + ], + "kz-125s-067" => [ + "id" => "kz-125s-067", + "stock_number" => "KZ-125S-067", + "name" => "125cc スクーター", + "color" => "象牙", + "category" => "scooter", + "use" => "commute", + "availability" => "available", + "status" => "商談受付中", + "year" => 2022, + "distance" => "4,180 km", + "price" => "28.6万円", + "total" => "33.8万円", + "image" => "stock-03.webp", + "alt" => "店舗脇の屋根下に停めた象牙色の125ccスクーター", + "summary" => "駅までの移動や買い物を想定した車両です。低いシートと広い足元を店頭で確認できます。", + "condition" => [ + "外装" => "左ミラー根元に小傷。樹脂部の白化は少なめです。", + "足まわり" => "前後空気圧とホイール振れを確認済み。", + "駆動系" => "ベルト点検済み。発進時の振動なし。", + ], + "history" => [ + ["date" => "2026.07.18", "work" => "入庫点検、駆動ケース清掃"], + ["date" => "2026.07.19", "work" => "エンジンオイル、エアエレメント交換"], + ["date" => "2026.07.21", "work" => "市街地想定の試走12km"], + ], + "notes" => ["キー2本", "シート下収納あり", "自賠責は別途案内"], + "featured" => true, + ], + "kz-250a-031" => [ + "id" => "kz-250a-031", + "stock_number" => "KZ-250A-031", + "name" => "250cc アドベンチャー", + "color" => "鈍青", + "category" => "adventure", + "use" => "touring", + "availability" => "available", + "status" => "商談受付中", + "year" => 2020, + "distance" => "19,640 km", + "price" => "52.3万円", + "total" => "59.0万円", + "image" => "stock-04.webp", + "alt" => "山道沿いの停車場に置いた鈍青の250ccアドベンチャー車", + "summary" => "荒れた舗装路と長距離移動に向いた車高です。足つきは来店時に必ず確認してください。", + "condition" => [ + "外装" => "ハンドガードと右ケースに使用傷があります。", + "足まわり" => "リアタイヤ交換済み。スポーク張力を調整済み。", + "機関" => "冷却水、プラグ、エアフィルターを交換済み。", + ], + "history" => [ + ["date" => "2026.06.29", "work" => "入庫点検、下回り洗浄"], + ["date" => "2026.07.02", "work" => "リアタイヤと冷却水を交換"], + ["date" => "2026.07.05", "work" => "郊外路を含む試走32km"], + ], + "notes" => ["キー1本", "社外ケース付属", "シート高を要確認"], + "featured" => false, + ], + "kz-400t-024" => [ + "id" => "kz-400t-024", + "stock_number" => "KZ-400T-024", + "name" => "400cc ツアラー", + "color" => "臙脂", + "category" => "road", + "use" => "touring", + "availability" => "service", + "status" => "整備中", + "year" => 2018, + "distance" => "22,410 km", + "price" => "58.9万円", + "total" => "66.5万円", + "image" => "stock-05.webp", + "alt" => "整備リフト横に停めた臙脂色の400ccツアラー", + "summary" => "防風性と積載を優先した車両です。現在は前ブレーキの部品入荷を待っています。", + "condition" => [ + "外装" => "スクリーンに細かな線傷。カウル固定部は良好です。", + "足まわり" => "前ブレーキ整備中。タイヤ残量は前後とも継続使用可。", + "機関" => "始動、アイドリング、冷却ファン作動を確認済み。", + ], + "history" => [ + ["date" => "2026.07.14", "work" => "入庫点検、前ブレーキ固着を確認"], + ["date" => "2026.07.15", "work" => "キャリパー分解、交換部品を手配"], + ["date" => "2026.07.27", "work" => "部品入荷予定を8月上旬へ更新"], + ], + "notes" => ["キー2本", "左右ケース付属", "整備完了後に商談受付"], + "featured" => false, + ], + "kz-125m-073" => [ + "id" => "kz-125m-073", + "stock_number" => "KZ-125M-073", + "name" => "125cc ミニ", + "color" => "銀鼠", + "category" => "mini", + "use" => "weekend", + "availability" => "available", + "status" => "商談受付中", + "year" => 2023, + "distance" => "2,750 km", + "price" => "33.5万円", + "total" => "38.7万円", + "image" => "stock-06.webp", + "alt" => "小さな展示台に置いた銀鼠色の125ccミニバイク", + "summary" => "短い距離を気軽に走りたい方向けです。小柄ですが、膝まわりの余裕は現車で確認してください。", + "condition" => [ + "外装" => "目立つ転倒傷なし。保管時の細かな擦れがあります。", + "足まわり" => "チェーン清掃と張り調整済み。タイヤ状態は良好です。", + "電装" => "バッテリー電圧と灯火を確認済み。", + ], + "history" => [ + ["date" => "2026.07.23", "work" => "入庫点検、車体各部を清掃"], + ["date" => "2026.07.24", "work" => "チェーン調整、オイル交換"], + ["date" => "2026.07.26", "work" => "低速域を中心に試走10km"], + ], + "notes" => ["キー2本", "純正工具あり", "店内展示中"], + "featured" => false, + ], + ]; +} + +function kazekiri_vehicle_filter_options(): array +{ + return [ + "category" => [ + "" => "すべての車種", + "road" => "ロード", + "scooter" => "スクーター", + "adventure" => "アドベンチャー", + "mini" => "ミニ", + ], + "use" => [ + "" => "用途を指定しない", + "commute" => "通勤・日常", + "touring" => "ツーリング", + "weekend" => "近距離・週末", + ], + "availability" => [ + "" => "すべての状態", + "available" => "商談受付中", + "hold" => "商談中", + "service" => "整備中", + ], + ]; +} + +function kazekiri_services(): array +{ + return [ + [ + "id" => "inspection", + "title" => "定期点検", + "image" => "inspection-brake.webp", + "alt" => "整備士の作業台でブレーキ部品を点検している様子", + "lead" => "距離だけで区切らず、保管場所と走り方を聞いて確認箇所を決めます。", + "items" => ["灯火と充電電圧", "タイヤとブレーキ残量", "漏れと締結部", "試走後の再確認"], + "time" => "半日から1日", + ], + [ + "id" => "oil", + "title" => "油脂類の交換", + "image" => "service-oil-bench.webp", + "alt" => "オイル容器と計量器を並べた整備作業台", + "lead" => "前回記録が分からない場合は、抜いた油の状態も見て次の交換目安を決めます。", + "items" => ["エンジンオイル", "ブレーキフルード", "冷却水", "交換記録の記入"], + "time" => "40分から", + ], + [ + "id" => "tire", + "title" => "タイヤと足まわり", + "image" => "service-tire-lift.webp", + "alt" => "リフト上で前輪とブレーキを点検している車両", + "lead" => "溝だけでなく、製造時期、ひび、空気圧の減り方まで確認します。", + "items" => ["タイヤ交換", "ホイール振れ確認", "チェーン調整", "フォーク漏れ確認"], + "time" => "1時間半から", + ], + [ + "id" => "parts", + "title" => "部品と預かり整備", + "image" => "parts-shelf.webp", + "alt" => "箱と小部品を分類して保管した整備工場の棚", + "lead" => "入荷待ちになる部品は、預かり期間と保管場所を先にお伝えします。", + "items" => ["部品の適合確認", "入荷予定の連絡", "屋内保管", "作業後の古部品確認"], + "time" => "内容を確認後に案内", + ], + ]; +} + +function kazekiri_articles(): array +{ + return [ + "summer-inspection" => [ + "id" => "summer-inspection", + "date" => "2026.07.24", + "title" => "夏の点検受付と、冷却まわりの確認", + "summary" => "長い上り坂を走る前に、冷却水とファンの作動を見ています。", + "image" => "inspection-brake.webp", + "alt" => "点検用の工具と部品を広げた整備台", + "body" => [ + "気温が上がる時期は、冷却水の量だけでなく、ホースのにじみと電動ファンの作動まで確認します。停車後に甘いにおいがする、いつもよりファンが長く回る、といった変化があれば受付時に教えてください。", + "点検は予約車を優先しています。朝に預かり、夕方に返せる台数には限りがあります。遠出の予定日が決まっている場合は、部品手配の時間も含めて一週間ほど余裕をみてください。", + ], + ], + "new-arrivals-july" => [ + "id" => "new-arrivals-july", + "date" => "2026.07.11", + "title" => "7月入庫分の掲載を始めました", + "summary" => "125ccから400ccまで4台の点検記録を更新しました。", + "image" => "showroom-floor.webp", + "alt" => "小規模な展示場に間隔を空けて並べた中古バイク", + "body" => [ + "今月は日常の移動に向く125ccと、郊外を走りやすい250cc、400ccが入りました。店頭へ並べる前に、冷間始動、充電電圧、ブレーキ残量を記録しています。", + "在庫ページの走行距離と整備履歴は、作業が終わるたびに更新します。整備中の車両は現車確認だけ受け付ける場合があります。", + ], + ], + "rainy-day-storage" => [ + "id" => "rainy-day-storage", + "date" => "2026.06.28", + "title" => "雨天時の車両預かりについて", + "summary" => "濡れた車両は水気を落としてから整備棟へ移します。", + "image" => "workshop-rain.webp", + "alt" => "雨の日の整備工場搬入口と屋根下のバイク", + "body" => [ + "雨の日に入庫した車両は、搬入口で水気と泥を落としてから屋内へ移します。電装の症状を確認する車両は、乾かしすぎる前に状態を見たいことがあります。症状が出た時刻と場所を受付で伝えてください。", + "引き取り時間に強い雨が予想される場合は、翌営業日まで屋内で預かれます。保管場所の都合があるため、当日の受付時に相談してください。", + ], + ], + "mountain-route-check" => [ + "id" => "mountain-route-check", + "date" => "2026.06.09", + "title" => "山道へ出る前の空気圧", + "summary" => "荷物を積む日は、普段の近距離走行と同じ空気圧にしないことがあります。", + "image" => "area-mountain-road.webp", + "alt" => "山と川の間を通る交通量の少ない舗装路", + "body" => [ + "荷物を積んで長い距離を走る日は、車両の指定値と積載量を見ながら空気圧を合わせます。低すぎると熱を持ちやすく、高すぎると荒れた路面で落ち着かないことがあります。", + "店頭の空気入れとゲージは営業時間中に使えます。数値が分からない場合は、車両の表示を一緒に確認します。", + ], + ], + ]; +} + +function kazekiri_faq_groups(): array +{ + return [ + "在庫車両" => [ + ["q" => "掲載中の車両はすぐに見られますか", "a" => "商談受付中の車両は店頭で確認できます。整備中の車両は作業場所から動かせない場合があるため、来店前に相談してください。"], + ["q" => "取り置きはできますか", "a" => "現車確認後に期限を決めて受け付けます。電話やWebだけでの長期取り置きは行っていません。"], + ["q" => "価格には何が含まれますか", "a" => "表示は車両本体の税込価格です。登録や保険に必要な費用は、使い方と登録地域を確認して別にお伝えします。"], + ], + "整備" => [ + ["q" => "他店で購入した車両も見てもらえますか", "a" => "排気量と部品の入手状況を確認してから受け付けます。改造内容によっては作業できない場合があります。"], + ["q" => "当日に終わりますか", "a" => "点検と油脂交換は当日返却できることがあります。異音や電装不良は再現確認に時間がかかるため、預かりをお願いしています。"], + ["q" => "部品を持ち込めますか", "a" => "品番と状態を事前に確認します。適合を確認できない部品、安全に関わる中古部品は取り付けできません。"], + ], + "来店" => [ + ["q" => "予約なしでも相談できますか", "a" => "車両を見るだけなら予約は不要です。整備担当との相談や試走確認を希望する場合は、来店日を先に知らせてください。"], + ["q" => "雨の日でも車両を預けられますか", "a" => "搬入口から受け付けます。濡れた状態で確認したい症状がある場合は、先に整備担当へ伝えてください。"], + ], + ]; +} diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/site.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/site.php new file mode 100644 index 0000000..b7a735c --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inc/site.php @@ -0,0 +1,259 @@ + "image-fallback--three-two", + $width * 3 === $height * 4 => "image-fallback--four-three", + $width * 9 === $height * 16 => "image-fallback--sixteen-nine", + default => "image-fallback--wide", + }; + return sprintf( + '', + h($class), + h($ratio), + h($alt) + ); + } + + $loading = $priority + ? ' fetchpriority="high"' + : ' loading="lazy" decoding="async"'; + return sprintf( + '%s', + h($class), + h($filename), + $width, + $height, + h($alt), + $loading + ); +} + +function kazekiri_public_navigation(): array +{ + return [ + "inventory" => ["/inventory.php", "在庫車両"], + "service" => ["/service.php", "整備・点検"], + "shop" => ["/shop.php", "店舗案内"], + "news" => ["/news.php", "お知らせ"], + "faq" => ["/faq.php", "よくある質問"], + "contact" => ["/contact.php", "来店相談"], + ]; +} + +function kazekiri_site_header( + string $title, + string $description, + string $current = "", + bool $staff = false +): void { + $fullTitle = $current === "home" + ? "風切モータース | 中古バイク・整備" + : $title . " | 風切モータース"; + ?> + + + + + + + + + + <?= h($fullTitle) ?> + + + + + + + + + + + + +"> + + +
+
+ +
+ SYSTEM ONLINE + 公開サイトへ +
+
+
+ +
+
+

本日の整備受付 17:00まで

+ +
+
+ + + + + + + 風切モータース + + + + +
"> + + +
+ + + + + +
+

METHOD NOT ALLOWED

+

この操作は受け付けていません

+

ページのリンクから開き直してください。入力した内容は保存されていません。

+ ホームへ戻る +
+ +
+

NOT FOUND

+

+

+ +
+ - - - - - 風切モータース | 在庫・整備受付 - - - -
-

演習専用の架空サイト

-

風切モータース

-
-
-

中古バイク販売・整備受付

-

販売車両の入庫確認と、整備予約の社内受付を行っています。

-

スタッフ向け接続診断

-
- - + +require_once __DIR__ . "/inc/site.php"; + +kazekiri_guard_method(["GET"], "home"); +$vehicles = array_values(array_filter( + kazekiri_vehicles(), + static fn(array $vehicle): bool => $vehicle["featured"] === true +)); +$articles = array_slice(array_values(kazekiri_articles()), 0, 3); + +kazekiri_site_header( + "風切モータース", + "中古バイク選びから納車後の点検まで、同じ整備ピットで支える地域の二輪店です。", + "home" +); +?> +
+
+
+
+

USED & SERVICE

+

+ 走り出す前も、 + 走り続ける先も。 +

+

+ 中古車選びから納車後の点検まで、同じ整備ピットで見届けます。 + 入庫時の状態を記録し、必要な作業を済ませてから店頭へ並べています。 +

+ +
+
+ +
+
+
+ +
+
+
+

01 / CONDITION

+

入庫時の状態と
気になる箇所を記録

+
+
+

02 / BEFORE DELIVERY

+

点検と試走を済ませ
作業内容を引渡し時に確認

+
+
+

03 / AFTER SERVICE

+

納車後の点検も
同じ整備窓口で受付

+
+
+
+ +
+
+
+

+ CURRENT STOCK + 最近、整備を終えた車両 +

+
+

写真だけでは分からない傷や交換部品も、車両ごとの記録へ載せています。

+ 6台の在庫をすべて見る +
+
+ +
+
+ +
+
+
+ +
+
+ WORKSHOP +

納車前に、何を見たかまで渡します。

+

+ 消耗品を替えた、だけでは終わらせません。残量、にじみ、始動時の様子、 + 試走で気づいた点を記録し、次の点検時期と一緒に説明します。 +

+
    +
  1. 01入庫時の状態確認
  2. +
  3. 02必要な整備と部品交換
  4. +
  5. 03試走、増し締め、引渡し説明
  6. +
+ 整備の受付内容を見る +
+
+
+ +
+
+
+ SHOP +

風切川の近くで、販売と整備を同じ建物に。

+

+ 展示場の奥が整備棟です。気になる車両があれば、リフトへ上げる前の状態や + 交換した部品を整備担当が説明します。 +

+
+
営業時間
10:00-18:00
+
整備受付
17:00まで
+
定休日
火曜・水曜
+
+ 来店方法と店内を見る +
+
+ +
+
+
+ +
+
+
+

+ SHOP NOTE + 店からのお知らせ +

+ お知らせ一覧 +
+
+ +
+ +

">

+

+
+ +
+
+
+ +
+
+
+

VISIT & CONSULTATION

+

現車確認の日を決める

+

見たい車両と来店予定を送ると、この画面で仮受付番号を発行します。

+
+ 来店相談を始める +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inventory.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inventory.php new file mode 100644 index 0000000..95c217e --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/inventory.php @@ -0,0 +1,139 @@ + kazekiri_value($_GET, "category"), + "use" => kazekiri_value($_GET, "use"), + "availability" => kazekiri_value($_GET, "availability"), +]; +$invalidFilters = []; +foreach ($selected as $name => $value) { + if (!array_key_exists($value, $options[$name])) { + $invalidFilters[] = $name; + } +} + +$vehicles = []; +if ($invalidFilters === []) { + foreach (kazekiri_vehicles() as $vehicle) { + $matches = true; + foreach ($selected as $name => $value) { + if ($value !== "" && $vehicle[$name] !== $value) { + $matches = false; + break; + } + } + if ($matches) { + $vehicles[] = $vehicle; + } + } +} else { + http_response_code(400); +} + +kazekiri_site_header( + "在庫車両", + "風切モータースが点検と整備を進めている中古バイクの一覧です。", + "inventory" +); +?> +
+
+
+
+ "在庫車両"]]); ?> +

INVENTORY

+

現在の在庫車両

+
+

+ 写真、走行距離、整備記録を車両ごとに掲載しています。 + 商談中や整備中の車両は、状態が変わり次第ここへ反映します。 +

+
+
+ +
+
+
+
+

条件を絞る

+

6台の中から、車種と使い方、商談状況で絞れます。

+
+ $values): ?> + + +
+ + 条件を戻す +
+
+ + + + +
+

NO MATCH

+

この条件に合う車両はありません

+

商談状況を外すか、車種を指定せずにもう一度確認してください。

+ 条件を戻す +
+ +
+

を表示しています

+
+
+ + + +
+ +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/news.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/news.php new file mode 100644 index 0000000..801ee2b --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/news.php @@ -0,0 +1,51 @@ + +
+
+
+
+ "お知らせ"]]); ?> +

SHOP NOTE

+

店からのお知らせ

+
+

入庫作業と季節の点検、車両預かりについて、整備棟から必要なことだけお知らせします。

+
+
+ +
+
+ $article): ?> + + +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/service.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/service.php new file mode 100644 index 0000000..56ab76d --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/service.php @@ -0,0 +1,127 @@ + +
+
+
+
+ "整備・点検"]]); ?> +

SERVICE & WORKSHOP

+

次に困るところまで、点検で見ておく。

+

+ 故障した箇所だけを直すと、別の消耗品がすぐ交換時期になることがあります。 + 普段の距離と保管場所を聞き、今やる作業と次回でよい作業を分けて説明します。 +

+ 整備の来店相談 +
+
+ +
+
+
+ +
+
+
+ +

+ 作業時間は車種と状態で変わります。入庫後に追加作業が必要になった場合は、 + 手を付ける前に内容を確認します。 +

+
+
+ $service): ?> +
"> +
+ +
+
+

+

+

+
    + +
  • + +
+

預かり目安

+
+
+ +
+
+
+ +
+
+
+ WORK FLOW +

受付から返却まで

+

症状が再現しない時は、推測だけで部品を替えず、確認できた範囲を記録します。

+
+
    +
  1. + 01 +

    受付

    いつ、どの速度や天候で症状が出たかを聞きます。

    +
  2. +
  3. + 02 +

    確認

    見積り前に、漏れ、摩耗、始動、灯火の状態を確認します。

    +
  4. +
  5. + 03 +

    作業

    追加作業は先に連絡し、交換した部品を分けて保管します。

    +
  6. +
  7. + 04 +

    返却

    行った作業と、次に見る時期を整備記録へ残します。

    +
  8. +
+
+
+ +
+
+
+ BEFORE YOU VISIT +

持ち込み部品や当日返却について

+
+

+ 適合が確認できない部品、安全に関わる中古部品は取り付けできません。 + 当日返却を希望する場合は、来店前に作業内容を知らせてください。 +

+ +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/shop.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/shop.php new file mode 100644 index 0000000..d6cd44f --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/shop.php @@ -0,0 +1,125 @@ + +
+
+
+ "店舗案内"]]); ?> +
+

SHOP

+

展示場の奥に、
いつもの整備ピット。

+

+ 風切川沿いの県道から一本入った場所にあります。 + 展示車両の確認は予約不要。整備相談は担当を空けるため、来店日を先に知らせてください。 +

+
+
+ +
+
+
+ +
+
+
+

+ INSIDE THE SHOP + 店内と整備棟 +

+

展示場、受付、整備棟は同じ建物です。車両を預ける時は東側の搬入口へ回ってください。

+
+
+
+ +
01

展示場

在庫車両と整備記録を一緒に確認します。商談中の車両には受付票を付けています。

+
+
+ +
02

受付と待合

整備の受付票を記入し、症状が出た時の状況を担当へ伝えます。

+
+
+ +
03

部品保管

車両ごとに棚を分け、交換前の部品は返却説明まで保管します。

+
+
+
+
+ +
+
+
+ +
+
+ VISIT +

来店時の入口

+
+
二輪で来店
正面の展示場前へ。砂利側には停めないでください。
+
車で来店
西側に2台分あります。満車時は受付へ声をかけてください。
+
整備車両の搬入
東側の屋根付き搬入口で受け付けます。
+
公共交通
風切中央駅から川沿いを徒歩18分です。
+
+

道順が分かりにくい場合は、来店相談で車種と到着予定を知らせてください。

+
+
+
+ +
+
+
+ OPENING HOURS +

営業時間

+

部品の受け取りだけの場合も、閉店30分前までに来店してください。

+
+
+
月・木・金
10:00-18:00
+
土・日
10:00-18:00
+
火・水
定休日
+
整備受付
17:00まで
+
+ 来店日を相談する +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/staff/diagnostics.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/staff/diagnostics.php index 7c88b9a..f3aaf41 100644 --- a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/staff/diagnostics.php +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/staff/diagnostics.php @@ -1,6 +1,10 @@ - - - - - 接続診断 | 風切モータース - - - -
-

演習専用・スタッフ向け

-

接続診断

-

確認コード:

-
- - " placeholder="127.0.0.1"> - -
- -

結果

-
- -
- - +$verificationCode = trim((string)$entryFlag); + +kazekiri_site_header( + "接続先の疎通確認", + "整備端末、プリンター、保管サーバーへの基本接続を確認するスタッフ用画面です。", + "staff", + true +); +?> +
+ + +
+
+

NETWORK UTILITY

+

接続先の疎通確認

+

+ 整備端末、プリンター、保管サーバーへの基本接続を確認します。 + 対象のIPv4アドレスまたはホスト名を入力してください。 +

+
+

UTILITY ID
KZ-NETCHECK 2.4

+
+ +
+
+
+

疎通診断

+

ICMP echoを1回送信し、到達結果を表示します。

+
+
+ +
+ + +
+

+ 応答まで数秒かかる場合があります。対象名は管理台帳を確認してください。 +

+
+ + +
+
+ 診断結果 / OUTPUT + COMPLETE +
+
+
+ +
+ + +
+
+ diff --git a/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/vehicle.php b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/vehicle.php new file mode 100644 index 0000000..beccdf8 --- /dev/null +++ b/labs/open-world-target/world/fixtures/rootfs/var/www/kazekiri/vehicle.php @@ -0,0 +1,151 @@ + +
+
+ "在庫車両", "href" => "/inventory.php"], + ["label" => $vehicle["stock_number"]], + ]); ?> +
+ +
+
+
+ +
+
+
+ + "> +
+

+

+
+
年式
+
走行距離
+
車両価格
+
支払総額目安
+
+

支払総額は店頭受取を前提にした目安です。登録地域や保険の選択で変わります。

+ +
+ 整備完了前のため、現車確認のみ受け付けています。作業予定は来店相談で確認できます。 +
+ + ">この車両の来店相談 +
+
+ +
+
+
+ CONDITION +

入庫時の状態

+
+
+ $description): ?> +
+
+
+
+ +
+
+ +
+
+ WORK LOG +

整備記録

+
+
    + +
  1. + + +
  2. + +
+
+ + +
+
+ +
+
+
+ + 在庫一覧へ +
+ +
+
+
+ diff --git a/labs/open-world-target/world/fixtures/synthetic-credentials.json b/labs/open-world-target/world/fixtures/synthetic-credential-spec.json similarity index 68% rename from labs/open-world-target/world/fixtures/synthetic-credentials.json rename to labs/open-world-target/world/fixtures/synthetic-credential-spec.json index 8754cca..cd6d0cb 100644 --- a/labs/open-world-target/world/fixtures/synthetic-credentials.json +++ b/labs/open-world-target/world/fixtures/synthetic-credential-spec.json @@ -1,9 +1,11 @@ { + "version": 1, "trainingOnly": true, "accounts": [ { "username": "sales", - "password": "KazeOnly-Training-27", + "passwordPrefix": "Kaze-", + "randomBytes": 18, "purpose": "anonymous SMB backup disclosure and SSH credential reuse" } ] diff --git a/labs/open-world-target/world/flag-verifiers.mjs b/labs/open-world-target/world/flag-verifiers.mjs deleted file mode 100644 index b453eb1..0000000 --- a/labs/open-world-target/world/flag-verifiers.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import { createHash, timingSafeEqual } from "node:crypto"; - -// Public, one-way verifier data. `private-answers.mjs` is build-only and must -// never be copied to the Debian target. -const ANSWER_DIGESTS = Object.freeze({ - "flag-entry-web": - "651ec454239bdc21138fee91d0f241b9ff0cd498fe45281759e0cbae8ea685f1", - "flag-entry-smb": - "99104acca2eca3d44d165276a2f78a6f66ef7c5ffe95305bb29a763d363017fa", - "flag-entry-nfs": - "95acb2b846b24163551a73a39c2267e2c147d22ba9a9500115add1901ba3e465", - "flag-foothold-www-data": - "71a766b195212a3cc2c2e9a806b0144ecef6941806766bcc4dc90ea0cfab0aee", - "flag-foothold-sales": - "399300180c8b80dcfd2c158dcbe031b667be3db49179d99777d811e6ad58b8b0", - "flag-foothold-mechanic": - "15fab0f7cb45417fe57035395e6361f30d7e09e880a23f5398884a80cc2b191d", - "flag-clue-sudo": - "196f745d5b135864e43ca38b97539677e18ab4c836f4d14423d74aeac596165e", - "flag-clue-timer": - "c8eb2a5c97a74c1c09a698f802e6cf8a01df73351fefb8ec3a0709332c0d44f5", - "flag-clue-suid": - "c02b199d5ca4448265062e530364dbbee1e34d322682245eb0c147a6747d87f8", - "flag-route-sudo": - "0e8f737108c4a6806ae5dfd74bb4b4424ae9431c5fa4ac2dd3138f60eb58f2eb", - "flag-route-timer": - "7370d03c2f5ee921b66094d4fc475f6ca74bda39f3675858c2bd97f8a0331165", - "flag-route-suid": - "a477809271c6e81f34eb00d4d2020e302ec2a0a75f9503c5a1dd03da32f4d73a", - "flag-root-common": - "b48126d2fd87ba584522180ef46f6a0703b9389ab759e584d7b662b21606acaf", - "flag-windows": - "5e3f88bdba6439fc7599e69a3512c3c2053c11ea4925718ddc1ab267dd68c8cf", -}); - -const ANSWER_HASHES = new Map( - Object.entries(ANSWER_DIGESTS).map(([flagId, digest]) => { - if (!/^[a-f0-9]{64}$/.test(digest)) { - throw new Error(`invalid verifier digest for ${flagId}`); - } - return [flagId, Buffer.from(digest, "hex")]; - }), -); - -export function getVerifierFlagIds() { - return Object.keys(ANSWER_DIGESTS); -} - -export function verifyFlagAnswer(candidate) { - if ( - typeof candidate !== "string" || - candidate.length < 12 || - candidate.length > 128 - ) { - return null; - } - - const candidateHash = createHash("sha256") - .update(candidate, "utf8") - .digest(); - - for (const [flagId, expectedHash] of ANSWER_HASHES) { - if (timingSafeEqual(candidateHash, expectedHash)) { - return flagId; - } - } - return null; -} diff --git a/labs/open-world-target/world/materialize-flags.mjs b/labs/open-world-target/world/materialize-flags.mjs index ece3780..e6e7a0d 100644 --- a/labs/open-world-target/world/materialize-flags.mjs +++ b/labs/open-world-target/world/materialize-flags.mjs @@ -1,11 +1,13 @@ +import { randomBytes } from "node:crypto"; import { chmod, mkdir, open } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { getPrivateFlagAnswer } from "./private-answers.mjs"; -import { validatePrivateAnswers } from "./validate-private-answers.mjs"; +import { validateWorld } from "./validate-world.mjs"; import { WORLD } from "./world-definition.mjs"; +const FLAG_RANDOM_BYTES = 24; + function assertSafeDestination(destinationRoot) { if (!path.isAbsolute(destinationRoot)) { throw new Error("destination root must be an absolute path"); @@ -17,12 +19,21 @@ function assertSafeDestination(destinationRoot) { return resolved; } -export async function materializeFlags(destinationRoot, { force = false } = {}) { - validatePrivateAnswers(); +function generateFlagAnswer() { + const random = randomBytes(FLAG_RANDOM_BYTES); + return `FLAG{ow_${random.toString("hex")}}`; +} + +export async function materializeFlags(destinationRoot) { + validateWorld(); const resolvedRoot = assertSafeDestination(destinationRoot); const written = []; + const answers = WORLD.flags.map(() => generateFlagAnswer()); + if (new Set(answers).size !== answers.length) { + throw new Error("flag entropy source generated a duplicate answer"); + } - for (const flag of WORLD.flags) { + for (const [index, flag] of WORLD.flags.entries()) { const destination = path.resolve(resolvedRoot, flag.location); const relative = path.relative(resolvedRoot, destination); if ( @@ -34,9 +45,9 @@ export async function materializeFlags(destinationRoot, { force = false } = {}) } await mkdir(path.dirname(destination), { recursive: true }); - const handle = await open(destination, force ? "w" : "wx", flag.mode); + const handle = await open(destination, "wx", flag.mode); try { - await handle.writeFile(`${getPrivateFlagAnswer(flag.id)}\n`, "utf8"); + await handle.writeFile(`${answers[index]}\n`, "utf8"); } finally { await handle.close(); } @@ -54,13 +65,11 @@ if (invokedPath === import.meta.url) { const destinationRoot = process.argv[2]; if (!destinationRoot) { process.stderr.write( - "usage: node materialize-flags.mjs /absolute/staging/root [--force]\n", + "usage: node materialize-flags.mjs /absolute/new-staging/root\n", ); process.exitCode = 2; } else { - const written = await materializeFlags(destinationRoot, { - force: process.argv.includes("--force"), - }); - process.stdout.write(`materialized ${written.length} training flags\n`); + const written = await materializeFlags(destinationRoot); + process.stdout.write(`generated ${written.length} optional flags\n`); } } diff --git a/labs/open-world-target/world/private-answers.mjs b/labs/open-world-target/world/private-answers.mjs deleted file mode 100644 index 39117b6..0000000 --- a/labs/open-world-target/world/private-answers.mjs +++ /dev/null @@ -1,42 +0,0 @@ -const ANSWERS = Object.freeze({ - "flag-entry-web": - "FLAG{ow_web_diagnostic_c8ce86ed0d7ff1774f694c82a7aab6b0}", - "flag-entry-smb": - "FLAG{ow_smb_handover_722e7203ff0475cbecb27d39729c9fd8}", - "flag-entry-nfs": - "FLAG{ow_nfs_ownership_e33a5e460c417f2c30c5afa984a58dcb}", - "flag-foothold-www-data": - "FLAG{ow_www_data_foothold_c991f8fa92ce878ee7937c0cd5c701bb}", - "flag-foothold-sales": - "FLAG{ow_sales_foothold_e8d055cf8eeb409bd2b82736319b2daa}", - "flag-foothold-mechanic": - "FLAG{ow_mechanic_foothold_e119f66569bb885a09a8102b1621fd18}", - "flag-clue-sudo": - "FLAG{ow_clue_sudo_hook_2d1c474a373fee7cbdb7adff80e260a3}", - "flag-clue-timer": - "FLAG{ow_clue_timer_payload_c7e1cb22cfee593c8dbb23f6b80a6aba}", - "flag-clue-suid": - "FLAG{ow_clue_suid_path_45b76bde29ca3078b6f50d1905bdaa79}", - "flag-route-sudo": - "FLAG{ow_route_sudo_hook_94b3d31b805de29ed55df35a7c06c4fa}", - "flag-route-timer": - "FLAG{ow_route_timer_payload_f3b48ba001b34c24bb175e408f670c28}", - "flag-route-suid": - "FLAG{ow_route_suid_path_357f9b3df637c104ac13934621e9f973}", - "flag-root-common": - "FLAG{ow_debian_root_67be4b714bbafc79b6def31d68cd7823}", - "flag-windows": - "FLAG{ow_windows_archive_d9f5fb64e9bcbeccc0471e74f3468fa09303affe612713c1}", -}); - -export function getPrivateFlagAnswer(flagId) { - const answer = ANSWERS[flagId]; - if (answer === undefined) { - throw new Error(`Unknown flag id: ${flagId}`); - } - return answer; -} - -export function getPrivateFlagIds() { - return Object.keys(ANSWERS); -} diff --git a/labs/open-world-target/world/validate-private-answers.mjs b/labs/open-world-target/world/validate-private-answers.mjs deleted file mode 100644 index cca4163..0000000 --- a/labs/open-world-target/world/validate-private-answers.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { pathToFileURL } from "node:url"; - -import { - getPrivateFlagAnswer, - getPrivateFlagIds, -} from "./private-answers.mjs"; -import { - getVerifierFlagIds, - verifyFlagAnswer, -} from "./flag-verifiers.mjs"; -import { validateWorld } from "./validate-world.mjs"; -import { WORLD } from "./world-definition.mjs"; - -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} - -function extractRandomSuffix(answer, flagId) { - assert( - typeof answer === "string" && - answer.startsWith("FLAG{ow_") && - answer.endsWith("}"), - `flag answer has an invalid envelope: ${flagId}`, - ); - const separator = answer.lastIndexOf("_"); - const suffix = answer.slice(separator + 1, -1); - assert( - /^[a-f0-9]+$/.test(suffix) && - suffix.length >= 32 && - suffix.length % 2 === 0, - `flag answer must contain at least 128 bits of random hex: ${flagId}`, - ); - return suffix; -} - -export function validatePrivateAnswers(world = WORLD) { - const result = validateWorld(world); - const worldFlagIds = new Set(world.flags.map((flag) => flag.id)); - const privateFlagIds = getPrivateFlagIds(); - const verifierFlagIds = getVerifierFlagIds(); - - assert( - new Set(privateFlagIds).size === privateFlagIds.length, - "private flag ids must be unique", - ); - assert( - privateFlagIds.length === worldFlagIds.size && - privateFlagIds.every((flagId) => worldFlagIds.has(flagId)), - "private answers must match the 14 logical flags exactly", - ); - assert( - privateFlagIds.length === verifierFlagIds.length && - privateFlagIds.every((flagId) => verifierFlagIds.includes(flagId)), - "private answers and public verifiers must have identical ids", - ); - const randomSuffixes = []; - for (const flagId of privateFlagIds) { - const answer = getPrivateFlagAnswer(flagId); - assert( - verifyFlagAnswer(answer) === flagId, - `verifier digest is stale for ${flagId}`, - ); - randomSuffixes.push(extractRandomSuffix(answer, flagId)); - } - assert( - new Set(randomSuffixes).size === randomSuffixes.length, - "flag random suffixes must be unique", - ); - assert( - extractRandomSuffix( - getPrivateFlagAnswer("flag-windows"), - "flag-windows", - ).length === 48, - "Windows flag must contain a 192-bit random suffix", - ); - return result; -} - -const invokedPath = process.argv[1] - ? pathToFileURL(process.argv[1]).href - : null; -if (invokedPath === import.meta.url) { - const result = validatePrivateAnswers(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); -} diff --git a/labs/open-world-target/world/validate-world.mjs b/labs/open-world-target/world/validate-world.mjs index edfcc57..d7277a0 100644 --- a/labs/open-world-target/world/validate-world.mjs +++ b/labs/open-world-target/world/validate-world.mjs @@ -1,8 +1,5 @@ import { pathToFileURL } from "node:url"; -import { - getVerifierFlagIds, -} from "./flag-verifiers.mjs"; import { WORLD } from "./world-definition.mjs"; const EXPECTED_FLAG_COUNTS = Object.freeze({ @@ -11,7 +8,6 @@ const EXPECTED_FLAG_COUNTS = Object.freeze({ "root-clue": 3, "root-route": 3, "common-root": 1, - windows: 1, }); const ALLOWED_ICONS = new Set([ "browser", @@ -57,8 +53,8 @@ export function validateWorld(world = WORLD) { assert(world.entranceIds.length === 3, "world must define 3 entrances"); assert(world.footholdIds.length === 3, "world must define 3 footholds"); assert(world.rootPathIds.length === 3, "world must define 3 root paths"); - assert(world.nodes.length === 14, "world must define 14 flag-bearing nodes"); - assert(world.flags.length === 14, "world must define 14 flags"); + assert(world.nodes.length === 13, "world must define 13 flag-bearing nodes"); + assert(world.flags.length === 13, "world must define 13 flags"); const nodeIds = world.nodes.map((node) => node.id); const mapIds = world.nodes.map((node) => node.mapId); @@ -107,14 +103,6 @@ export function validateWorld(world = WORLD) { ); } - const verifierFlagIds = getVerifierFlagIds(); - assertUnique(verifierFlagIds, "verifier flag ids"); - assert( - verifierFlagIds.length === flagIds.length && - verifierFlagIds.every((flagId) => flagIdSet.has(flagId)), - "public verifiers must match the 14 logical flags exactly", - ); - for (const [category, expectedCount] of Object.entries( EXPECTED_FLAG_COUNTS, )) { @@ -129,12 +117,12 @@ export function validateWorld(world = WORLD) { for (const hypothesis of world.hypotheses) { assert( - hypothesis.hints.length === 3, - `hypothesis ${hypothesis.id} must have exactly 3 hint stages`, + hypothesis.hints.length === 4, + `hypothesis ${hypothesis.id} must have exactly 4 hint stages`, ); assert( hypothesis.hints.map((hint) => hint.title).join("|") === - "見る場所|使う道具|操作例", + "確かめること|使う道具|組み立て方|操作例", `hypothesis ${hypothesis.id} must use the accepted hint order`, ); if (hypothesis.anchorNodeId !== null) { @@ -194,11 +182,8 @@ export function validateWorld(world = WORLD) { const eventCount = world.eventRoutes.filter( (route) => route.nodeId === flag.nodeId, ).length; - if (flag.manualOnly) { - assert(eventCount === 0, `${flag.id} must remain manual-only`); - } else { - assert(eventCount >= 1, `${flag.id} needs an automatic event route`); - } + assert(!flag.manualOnly, `${flag.id} must not require manual submission`); + assert(eventCount >= 1, `${flag.id} needs an automatic event route`); } return Object.freeze({ diff --git a/labs/open-world-target/world/world-definition.mjs b/labs/open-world-target/world/world-definition.mjs index 94e948c..8be35a6 100644 --- a/labs/open-world-target/world/world-definition.mjs +++ b/labs/open-world-target/world/world-definition.mjs @@ -3,6 +3,7 @@ const nodes = [ id: "entrance-web-diagnostics", mapId: "map-01", kind: "entrance", + publicCategory: "Web", icon: "browser", label: "スタッフ用の診断画面", detail: "公開Webの奥に、入力値をOSコマンドへ渡す診断機能がある。", @@ -13,6 +14,7 @@ const nodes = [ id: "entrance-smb-handover", mapId: "map-02", kind: "entrance", + publicCategory: "共有", icon: "folder", label: "引き継ぎ用の共有", detail: "匿名で読める共有に、古いバックアップと運用メモが残っている。", @@ -23,6 +25,7 @@ const nodes = [ id: "entrance-nfs-workshop", mapId: "map-03", kind: "entrance", + publicCategory: "整備", icon: "network", label: "整備場のNFS共有", detail: "書き込み権限と所有者の対応が、利用者の想定より広い。", @@ -33,6 +36,7 @@ const nodes = [ id: "foothold-www-data", mapId: "map-04", kind: "foothold", + publicCategory: "権限獲得", icon: "server", label: "Webサービスの権限", detail: "www-dataとしてDebian内を観察できる状態になった。", @@ -47,6 +51,7 @@ const nodes = [ id: "foothold-sales", mapId: "map-05", kind: "foothold", + publicCategory: "権限獲得", icon: "user", label: "販売担当のログイン", detail: "バックアップ由来の訓練用資格情報が別サービスでも使えた。", @@ -61,6 +66,7 @@ const nodes = [ id: "foothold-mechanic", mapId: "map-06", kind: "foothold", + publicCategory: "権限獲得", icon: "user", label: "整備担当のログイン", detail: "共有の所有権対応を利用してmechanicとして接続できた。", @@ -75,6 +81,7 @@ const nodes = [ id: "clue-sudo-helper", mapId: "map-07", kind: "root-clue", + publicCategory: "権限昇格", icon: "file", label: "sudo保守コマンドの手掛かり", detail: "共同作業者が編集できるhookをrootの保守処理が実行する。", @@ -85,6 +92,7 @@ const nodes = [ id: "clue-writable-timer", mapId: "map-08", kind: "root-clue", + publicCategory: "権限昇格", icon: "calendar", label: "定期処理の手掛かり", detail: "rootのtimerが、共同作業者に変更可能なpayloadを呼び出す。", @@ -95,6 +103,7 @@ const nodes = [ id: "clue-unsafe-path", mapId: "map-09", kind: "root-clue", + publicCategory: "権限昇格", icon: "terminal", label: "PATH解決の手掛かり", detail: "教材用SUIDヘルパーが実行ファイルを絶対パスで指定していない。", @@ -105,6 +114,7 @@ const nodes = [ id: "root-path-sudo", mapId: "map-10", kind: "root-path", + publicCategory: "root経路", icon: "terminal", label: "sudo保守hook経路", detail: "書き換え可能なhookがrootとして実行された。", @@ -115,6 +125,7 @@ const nodes = [ id: "root-path-timer", mapId: "map-11", kind: "root-path", + publicCategory: "root経路", icon: "calendar", label: "定期処理payload経路", detail: "書き換え可能なpayloadがroot timerから実行された。", @@ -125,6 +136,7 @@ const nodes = [ id: "root-path-suid", mapId: "map-12", kind: "root-path", + publicCategory: "root経路", icon: "terminal", label: "SUID PATH経路", detail: "PATH上の訓練用rendererがroot権限で解決された。", @@ -135,20 +147,11 @@ const nodes = [ id: "root-common", mapId: "map-13", kind: "root", + publicCategory: "最終地点", icon: "door", label: "Debian root", detail: "選んだ経路の因果を説明できるroot到達点。", flagId: "flag-root-common", - unlockHypothesisIds: ["hyp-windows-remnant"], - }, - { - id: "windows-remnant", - mapId: "map-14", - kind: "windows", - icon: "folder", - label: "Windows側の保管記録", - detail: "root到達後だけ調べられる、オフラインの追加flag。", - flagId: "flag-windows", unlockHypothesisIds: [], }, ]; @@ -172,7 +175,6 @@ const edges = [ ["root-path-sudo", "root-common"], ["root-path-timer", "root-common"], ["root-path-suid", "root-common"], - ["root-common", "windows-remnant"], ].map(([from, to]) => ({ from, to })); const flags = [ @@ -267,15 +269,6 @@ const flags = [ location: "root/ROOT.flag", mode: 0o400, }, - { - id: "flag-windows", - nodeId: "windows-remnant", - category: "windows", - location: - "windows-fixture/Users/Public/Documents/KazekiriArchive/WINDOWS.flag", - mode: 0o444, - manualOnly: true, - }, ]; const hypotheses = [ @@ -286,13 +279,17 @@ const hypotheses = [ anchorNodeId: null, hints: [ { - title: "見る場所", + title: "確かめること", body: "Kali側の有線IPと、10.13.37.10が応答するTCPサービスを見る。", }, { title: "使う道具", body: "ip addr、ping、nmapの順で、接続とサービスを分けて確認する。", }, + { + title: "組み立て方", + body: "対象IPを固定し、名前解決やping応答に依存せずサービス版を確認する。", + }, { title: "操作例", body: "nmap -sV -Pn 10.13.37.10", @@ -306,13 +303,17 @@ const hypotheses = [ anchorNodeId: "entrance-web-diagnostics", hints: [ { - title: "見る場所", + title: "確かめること", body: "診断対象を入力したとき、結果欄へ何が返るかを見る。", }, { title: "使う道具", body: "まずブラウザで通常入力と区切り記号を含む入力の差を比べる。", }, + { + title: "組み立て方", + body: "正常な診断対象の後ろへ区切り記号と、結果を確認できる短いコマンドを続ける。", + }, { title: "操作例", body: "127.0.0.1; id", @@ -326,13 +327,17 @@ const hypotheses = [ anchorNodeId: "entrance-smb-handover", hints: [ { - title: "見る場所", + title: "確かめること", body: "handover共有にある引き継ぎ文書とバックアップ一覧を見る。", }, { title: "使う道具", body: "smbclientで共有一覧を確認し、匿名で読める範囲だけ調べる。", }, + { + title: "組み立て方", + body: "対象、共有名、匿名接続の順に指定し、まず一覧だけを確認する。", + }, { title: "操作例", body: "smbclient //10.13.37.10/handover -N", @@ -346,13 +351,17 @@ const hypotheses = [ anchorNodeId: "entrance-nfs-workshop", hints: [ { - title: "見る場所", + title: "確かめること", body: "NFSv4の公開rootと、mount後の所有者・権限・隠しファイルを見る。", }, { title: "使う道具", body: "NFSv4を直接mountし、ls -laで所有者と書き込み範囲を観察する。", }, + { + title: "組み立て方", + body: "sudo mount -t nfs4 -o vers=4,proto=tcp 10.13.37.10:/ <ローカルの空ディレクトリ>", + }, { title: "操作例", body: "sudo mount -t nfs4 -o vers=4,proto=tcp 10.13.37.10:/ /mnt/workshop", @@ -366,13 +375,17 @@ const hypotheses = [ anchorNodeId: "clue-sudo-helper", hints: [ { - title: "見る場所", + title: "確かめること", body: "sudo -lの許可内容と、保守helperが読むhookの所有者・groupを見る。", }, { title: "使う道具", body: "sudo -l、ls -l、読み取り専用でのスクリプト確認を使う。", }, + { + title: "組み立て方", + body: "許可されたコマンドから順に、root処理が後から読む編集可能なファイルまで追う。", + }, { title: "操作例", body: "sudo -l", @@ -386,13 +399,17 @@ const hypotheses = [ anchorNodeId: "clue-writable-timer", hints: [ { - title: "見る場所", + title: "確かめること", body: "open-world-root-timer.timerの次回実行とpayloadのgroup権限を見る。", }, { title: "使う道具", body: "systemctl list-timers、systemctl cat、ls -lを使う。", }, + { + title: "組み立て方", + body: "timerからservice、serviceからpayloadへ順にたどり、実行者と編集者を比較する。", + }, { title: "操作例", body: "systemctl cat open-world-root-timer.service", @@ -406,7 +423,7 @@ const hypotheses = [ anchorNodeId: "clue-unsafe-path", hints: [ { - title: "見る場所", + title: "確かめること", body: "教材用report helperの権限と、実行時に探すrenderer名を見る。", }, { @@ -414,28 +431,12 @@ const hypotheses = [ body: "find、ls -l、stringsを使い、既知CVE探しはしない。", }, { - title: "操作例", - body: "strings /usr/local/bin/kazekiri-report | grep renderer", - }, - ], - }, - { - id: "hyp-windows-remnant", - label: "root取得後の追加記録をオフライン領域から探す", - summary: "自動検出を信頼せず、見つけたflagだけを手動提出する。", - anchorNodeId: "root-common", - hints: [ - { - title: "見る場所", - body: "Windowsの公開ドキュメントにあるKazekiriArchiveを見る。", - }, - { - title: "使う道具", - body: "読み取り専用mountとfindを使い、Windows設定は変更しない。", + title: "組み立て方", + body: "SUID bit、所有者、絶対パスでない実行名を確認し、PATHの探索順と結びつける。", }, { title: "操作例", - body: "find /mnt/windows/Users/Public/Documents -name '*.flag' -print", + body: "strings /usr/local/bin/kazekiri-report | grep renderer", }, ], }, @@ -548,6 +549,22 @@ const eventRoutes = [ }, ]; +const routeAchievements = [ + ["web-sudo", "foothold-www-data", "root-path-sudo"], + ["web-timer", "foothold-www-data", "root-path-timer"], + ["web-suid", "foothold-www-data", "root-path-suid"], + ["smb-sudo", "foothold-sales", "root-path-sudo"], + ["smb-timer", "foothold-sales", "root-path-timer"], + ["smb-suid", "foothold-sales", "root-path-suid"], + ["nfs-sudo", "foothold-mechanic", "root-path-sudo"], + ["nfs-timer", "foothold-mechanic", "root-path-timer"], + ["nfs-suid", "foothold-mechanic", "root-path-suid"], +].map(([id, footholdId, rootPathId]) => ({ + id, + footholdId, + rootPathId, +})); + export const WORLD = Object.freeze({ id: "kazekiri-motors-open-world-v1", version: 1, @@ -571,4 +588,5 @@ export const WORLD = Object.freeze({ flags, hypotheses, eventRoutes, + routeAchievements, }); diff --git a/tests/repository-contract.test.mjs b/tests/repository-contract.test.mjs index 19bf954..019614e 100644 --- a/tests/repository-contract.test.mjs +++ b/tests/repository-contract.test.mjs @@ -13,9 +13,9 @@ test("active contracts describe the open-world lab", () => { for (const required of [ "3つの入口", - "3つの実機root経路", - "14個のflag", - "30〜60分", + "3 root経路", + "任意の13個のflag", + "約90分", ]) { assert.match( `${constitution}\n${task}\n${readme}`,