diff --git a/.claude/docs/mission-variations.md b/.claude/docs/mission-variations.md index 135f61f..4be467a 100644 --- a/.claude/docs/mission-variations.md +++ b/.claude/docs/mission-variations.md @@ -109,7 +109,7 @@ Key files have ~25% chance of binary wrapping (using `binaryKeyPaths` per role). ## Script Fix Objective -A 4th objective type where the player finds a broken JavaScript script on the target machine, fixes it with `nano()`, and runs it with `node()`. The script computes a checksum from its filtered data and passes it to `_decode(checksum)` — a function only available inside `node()`'s execution context. If the checksum is correct (script was properly fixed), `_decode()` returns the ACCESS-KEY. The player then mails it to the client (consistent with exfiltrate flow). The ACCESS-KEY never appears in the script source (anti-cheat). Seed keyword: `script-fix`. +A white-hat objective type where the player is hired as an authorized contractor to fix a broken JavaScript script on the target machine. The player SSHs in with root credentials (provided in the briefing), fixes the script with `nano()`, and tests it with `node()`. The script computes a checksum from its filtered data and passes it to `_system(checksum)` — a function available inside `node()`'s execution context during script_fix missions. When the player runs `node()` to test, `_system()` returns "System check: PASS" or "System check: FAIL". To complete the mission, the player mails "done" to the client — the `mail()` command internally re-executes the script and verifies `_system()` was called with the correct checksum. Seed keyword: `script-fix`. ### Bug Types (3, ~33% each) @@ -119,32 +119,27 @@ A 4th objective type where the player finds a broken JavaScript script on the ta | logic | Wrong comparison value or filter condition — script outputs "ERROR" | | corrupted | Data line replaced with `???` — correct value in a nearby hint file | -### Script Ownership - -| Owner | Chance | Effect | -| ----- | ------ | ---------------------------------------------------------------- | -| user | 60% | Anyone can read/write/execute — no privilege escalation needed | -| root | 40% | Anyone can read, but only root can write/execute — must su first | +### Script Fix Templates (18 — 3 per main role + 2 router + 1 switch) -### Script Fix Templates (12 + 2 router) +3 templates per main role (fileserver, database, webserver, mailserver, iot, workstation) + 2 for router + 1 for switch (unused — infrastructure-only roles). -2 templates per main role (fileserver, database, webserver, mailserver, iot, workstation) + 2 for router (unused). - -Each template is a short script that filters/counts array data and conditionally calls `echo(_decode())` on success. `_decode(checksum)` is injected into `node()`'s execution context only during script_fix missions — it compares the checksum against the expected value and returns the ACCESS-KEY on match (or an error string otherwise). Each template has an `expectedChecksum` field. Bug variants introduce syntax errors, logic errors, or corrupted data lines. Corrupted variants have a hint file at a nearby path on the same machine containing the correct value. +Each template is a short script that filters/counts array data and conditionally calls `_system()` on success. `_system(checksum)` is injected into `node()`'s execution context during script_fix missions — when testing, it returns "System check: PASS" or "System check: FAIL". During mail verification, the script is re-executed and the value passed to `_system()` is checked against `expectedChecksum`. Bug variants introduce syntax errors, logic errors, or corrupted data lines. Corrupted variants have a hint file at a nearby path on the same machine containing the correct value. ### Key Design Decisions +- White-hat mission: player is an authorized contractor (like forensics) +- SSH entry forced, root password in briefing (no infiltration required) - No binary wrapping (scripts must be readable/editable with nano) - No encryption (scripts must be directly editable) -- Dummy PRNG rolls consumed for binary + encrypt to preserve sequence alignment +- No dummy PRNG rolls needed (no backwards compatibility concerns) - Corrupted hints placed on same target machine (not a different machine) -- `_decode(checksum)` returns ACCESS-KEY on correct checksum — player mails it to client -- ACCESS-KEY never appears in script source (anti-cheat: can't `cat` to find it) -- `_decode()` only exists in `node()`'s execution context, not the terminal +- `_system(checksum)` provides PASS/FAIL feedback during `node()` testing +- `mail()` re-executes the script to verify correctness (no ACCESS-KEY exchange) +- `_system()` only exists in `node()`'s execution context, not the terminal ## Script Auto Objective -A 5th objective type where the player writes an automated script from scratch based on instructions in a stub file. The stub is placed in an automation location (cron, init, or network-up hook) with comment instructions describing what data to read and extract. The player writes the script body using `nano()`, runs it with `node()`, and gets the ACCESS-KEY from `_decode()`. Same verification as script_fix. Seed keyword: `script-auto`. +A 5th objective type where the player writes an automated script from scratch based on instructions in a stub file. The stub is placed in an automation location (cron, init, or network-up hook) with comment instructions describing what data to read and extract. The player writes the script body using `nano()`, runs it with `node()`, and gets the ACCESS-KEY from `_decode()`. Seed keyword: `script-auto`. ### Two Flavors @@ -176,12 +171,12 @@ Each role (fileserver, database, webserver, mailserver, iot, workstation, router ### Key Design Decisions -- Same `_decode()` / ACCESS-KEY mechanism as script_fix +- Uses `_decode()` / ACCESS-KEY mechanism (will be migrated to `_system()` like script_fix in a future PR) - No binary wrapping, no encryption - Port closures skipped (needs SSH shell access) - Remote flavor falls back to local if no peer machine available - Player writes the script from scratch (not fixing bugs) -- `_decode()` injected for both `script_fix` and `script_auto` missions +- `_decode()` injected for `script_auto` missions only; `_system()` is used for `script_fix` ## Backdoor Objective @@ -447,7 +442,7 @@ Used when entry variant is `exploit`. Matched by port/service. Multiple template | exfiltrate | Find ACCESS-KEY in target file, mail to client | `mail(email, "ACCESS-XXXX-XXXX-XXXX")` | | tamper | Modify a target file, mail client to confirm | `mail(email, "done")` | | credential_theft | Discover root password, mail to client | `mail(email, "")` | -| script_fix | Fix broken script, run with node(), mail ACCESS-KEY | `mail(email, "ACCESS-XXXX-XXXX-XXXX")` | +| script_fix | Fix broken script, test with node(), confirm to client | `mail(email, "done")` | | script_auto | Write automated script from scratch, run with node(), mail key | `mail(email, "ACCESS-XXXX-XXXX-XXXX")` | | sabotage | Destroy target machine, confirm the kill | `mail(email, "done")` | | backdoor | Open nc listener on target machine, confirm | `mail(email, "done")` | diff --git a/package-lock.json b/package-lock.json index 8384688..21ec61c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jshack-me", - "version": "0.56.0", + "version": "0.57.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jshack-me", - "version": "0.56.0", + "version": "0.57.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" @@ -139,6 +139,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -498,6 +499,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -538,6 +540,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2068,8 +2071,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2154,6 +2156,7 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2171,6 +2174,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2182,6 +2186,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2231,6 +2236,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -2626,6 +2632,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2676,7 +2683,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2801,6 +2807,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3057,8 +3064,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.278", @@ -3172,6 +3178,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3766,6 +3773,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -4183,7 +4191,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4436,6 +4443,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4551,7 +4559,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4567,7 +4574,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4590,6 +4596,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4602,6 +4609,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4615,8 +4623,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.17.0", @@ -5496,6 +5503,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5582,6 +5590,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5657,6 +5666,7 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", diff --git a/package.json b/package.json index 0a20103..9bfe8cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jshack-me", "private": true, - "version": "0.56.0", + "version": "0.57.0", "type": "module", "scripts": { "encode": "tsx scripts/encode.ts", diff --git a/src/commands/accept.test.ts b/src/commands/accept.test.ts index 2d7e2c4..aaa08d0 100644 --- a/src/commands/accept.test.ts +++ b/src/commands/accept.test.ts @@ -51,7 +51,15 @@ describe('accept command', () => { expect(result).toContain('mail('); expect(result).toContain('nano()'); expect(result).toContain('node()'); - expect(result).toContain('ACCESS-KEY'); + expect(result).toContain('done'); + }); + + it('shows root password in script_fix briefing', () => { + const startMission = vi.fn(); + const accept = createAcceptCommand({ startMission, isMissionActive: () => false }); + const result = accept.fn('test-script-fix-easy') as string; + + expect(result).toContain('Root password:'); }); it('shows domain instead of IP for domain entry missions', () => { diff --git a/src/commands/accept.ts b/src/commands/accept.ts index 440a406..546e8a0 100644 --- a/src/commands/accept.ts +++ b/src/commands/accept.ts @@ -38,9 +38,8 @@ export const formatObjectiveHint = (mission: MissionNetwork): string => { if (objective.type === 'script_fix') { const lines = [ ' Find the broken script on the target machine. Fix it with nano()', - ' and run it with node(). The script will output an ACCESS-KEY if fixed correctly.', - ` Mail the code to the client to complete the mission.`, - ` Example: mail("${email}", "")`, + ' and test it with node(). When fixed, confirm to the client.', + ` Example: mail("${email}", "done")`, ]; if (objective.scriptBugType === 'corrupted') { lines.push(' Look around the machine for the correct values.'); diff --git a/src/commands/mail.test.ts b/src/commands/mail.test.ts index 067ae8f..fdad561 100644 --- a/src/commands/mail.test.ts +++ b/src/commands/mail.test.ts @@ -561,6 +561,142 @@ describe('mail command', () => { expect(() => mail.fn('xR0gu3x@darkmail.onion', 'xR0gu3x')).toThrow('delivery failed'); }); + it('completes a script_fix mission when script calls _system with correct value', () => { + const completeMission = vi.fn(); + const fixedScript = [ + 'const backups = ["db_full", "db_diff", "logs", "config"]', + 'const critical = backups.filter(b => b.startsWith("db"))', + 'if (critical.length === 2) {', + ' _system(critical.join("-"))', + '} else {', + ' echo("ERROR: backup validation failed")', + '}', + ].join('\n'); + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'db_full-db_diff', + targetPath: '/srv/scripts/validate_backups.js', + targetContent: fixedScript, + scriptBugType: 'syntax', + }); + const readFileFromMachine = vi.fn().mockReturnValue(fixedScript); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission, + readFileFromMachine, + isMachineBricked: () => false, + }); + + const result = mail.fn('xR0gu3x@darkmail.onion', 'done') as AsyncOutput; + const lines = runAsync(result); + expect(completeMission).toHaveBeenCalled(); + expect(lines.join('\n')).toContain('MISSION COMPLETE'); + }); + + it('completes a script_fix mission when called without content', () => { + const completeMission = vi.fn(); + const fixedScript = '_system("ok")'; + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'ok', + targetPath: '/srv/scripts/test.js', + targetContent: fixedScript, + }); + const readFileFromMachine = vi.fn().mockReturnValue(fixedScript); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission, + readFileFromMachine, + isMachineBricked: () => false, + }); + + const result = mail.fn('xR0gu3x@darkmail.onion') as AsyncOutput; + const lines = runAsync(result); + expect(completeMission).toHaveBeenCalled(); + expect(lines.join('\n')).toContain('MISSION COMPLETE'); + }); + + it('rejects script_fix when script output is wrong', () => { + const brokenScript = '_system("wrong-value")'; + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'correct-value', + targetPath: '/srv/scripts/test.js', + targetContent: brokenScript, + }); + const readFileFromMachine = vi.fn().mockReturnValue(brokenScript); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission: vi.fn(), + readFileFromMachine, + isMachineBricked: () => false, + }); + + expect(() => mail.fn('xR0gu3x@darkmail.onion', 'done')).toThrow('Script output is incorrect'); + }); + + it('rejects script_fix when script does not call _system', () => { + const brokenScript = 'echo("hello")'; + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'expected', + targetPath: '/srv/scripts/test.js', + targetContent: brokenScript, + }); + const readFileFromMachine = vi.fn().mockReturnValue(brokenScript); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission: vi.fn(), + readFileFromMachine, + isMachineBricked: () => false, + }); + + expect(() => mail.fn('xR0gu3x@darkmail.onion', 'done')).toThrow('did not call _system'); + }); + + it('rejects script_fix when script file is missing', () => { + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'expected', + targetPath: '/srv/scripts/test.js', + targetContent: '', + }); + const readFileFromMachine = vi.fn().mockReturnValue(null); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission: vi.fn(), + readFileFromMachine, + isMachineBricked: () => false, + }); + + expect(() => mail.fn('xR0gu3x@darkmail.onion', 'done')).toThrow('Script not found'); + }); + + it('rejects script_fix when script has syntax error', () => { + const brokenScript = 'function('; + const mission = makeMission({ + type: 'script_fix', + expectedProof: '', + expectedChecksum: 'expected', + targetPath: '/srv/scripts/test.js', + targetContent: brokenScript, + }); + const readFileFromMachine = vi.fn().mockReturnValue(brokenScript); + const mail = createMailCommand({ + getActiveMission: () => mission, + completeMission: vi.fn(), + readFileFromMachine, + isMachineBricked: () => false, + }); + + expect(() => mail.fn('xR0gu3x@darkmail.onion', 'done')).toThrow('Script execution failed'); + }); + it('completes a script_auto mission with correct ACCESS-KEY', () => { const completeMission = vi.fn(); const mission = makeMission({ diff --git a/src/commands/mail.ts b/src/commands/mail.ts index cea9428..c0f9b72 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -3,6 +3,7 @@ import type { MissionNetwork } from '../generation/types'; import type { MachineFileOp } from '../filesystem/types'; import { createCancellationToken, jitter } from '../utils/asyncCommand'; import { parseIptablesRules } from '../network/iptablesParser'; +import { runScriptWithSystem } from '../utils/scriptRunner'; type MailCommandContext = { readonly getActiveMission: () => MissionNetwork | null; @@ -63,9 +64,37 @@ const verifyTamper = ( return null; }; -const verifyScriptFix = (proof: string, mission: MissionNetwork): string | null => { - if (proof === mission.objective.expectedProof) return null; - return 'Incorrect proof. Fix and run the script to get the ACCESS-KEY.'; +const verifyScriptFix = ( + mission: MissionNetwork, + readFileFromMachine: MailCommandContext['readFileFromMachine'], +): string | null => { + const { objective } = mission; + const scriptContent = readFileFromMachine({ + machineId: objective.targetMachine, + path: objective.targetPath, + cwd: '/', + userType: 'root', + }); + + if (scriptContent === null) { + return 'Script not found. The script file may have been deleted.'; + } + + const { systemValue, error } = runScriptWithSystem(scriptContent); + + if (error) { + return `Script execution failed: ${error}`; + } + + if (systemValue === null) { + return 'Script did not call _system(). Fix the script so it produces the correct output.'; + } + + if (systemValue !== objective.expectedChecksum) { + return 'Script output is incorrect. Check your fix and try again.'; + } + + return null; }; const verifyScriptAuto = (proof: string, mission: MissionNetwork): string | null => { @@ -180,7 +209,7 @@ const verifyProof = ( if (type === 'exfiltrate') return verifyExfiltrate(proof, mission); if (type === 'credential_theft') return verifyCredentialTheft(proof, mission); if (type === 'tamper') return verifyTamper(mission, readFileFromMachine); - if (type === 'script_fix') return verifyScriptFix(proof, mission); + if (type === 'script_fix') return verifyScriptFix(mission, readFileFromMachine); if (type === 'script_auto') return verifyScriptAuto(proof, mission); if (type === 'sabotage') return verifySabotage(mission, isMachineBricked); if (type === 'backdoor') return verifyBackdoor(mission, readFileFromMachine); @@ -194,12 +223,16 @@ export const createMailCommand = (context: MailCommandContext): Command => ({ category: 'mission', description: 'Send proof to a darknet client to complete a mission', manual: { - synopsis: 'mail(recipient, content)', + synopsis: 'mail(recipient[, content])', description: - 'Send a message to a darknet client. Used to submit mission proof and complete contracts. The recipient must match the client email shown in the mission briefing.', + 'Send a message to a darknet client. Used to submit mission proof and complete contracts. The recipient must match the client email shown in the mission briefing. Content is optional for missions that verify by inspecting machine state.', arguments: [ { name: 'recipient', description: 'Client email address (e.g., "handle@darkmail.onion")' }, - { name: 'content', description: 'Proof content (ACCESS-KEY, password, or confirmation)' }, + { + name: 'content', + description: + 'Proof content (ACCESS-KEY, password, or confirmation). Optional for script_fix, tamper, sabotage, backdoor, and portforward missions.', + }, ], examples: [ { @@ -220,11 +253,20 @@ export const createMailCommand = (context: MailCommandContext): Command => ({ if (typeof recipient !== 'string' || !recipient.trim()) { throw new Error('mail: missing recipient\nUsage: mail("recipient@darkmail.onion", "proof")'); } - if (typeof content !== 'string') { + const mission = context.getActiveMission(); + + // Content is optional for objectives that verify by inspecting machine state + const contentOptional = + mission?.objective.type === 'script_fix' || + mission?.objective.type === 'tamper' || + mission?.objective.type === 'sabotage' || + mission?.objective.type === 'backdoor' || + mission?.objective.type === 'portforward'; + + if (typeof content !== 'string' && !contentOptional) { throw new Error('mail: missing content\nUsage: mail("recipient@darkmail.onion", "proof")'); } - const mission = context.getActiveMission(); if (!mission) { throw new Error('No active mission. Use accept("SEED") to start one.'); } @@ -236,7 +278,7 @@ export const createMailCommand = (context: MailCommandContext): Command => ({ ); } - const proof = content.trim(); + const proof = typeof content === 'string' ? content.trim() : ''; // Verify proof synchronously so errors throw immediately const error = verifyProof( diff --git a/src/commands/node.test.ts b/src/commands/node.test.ts index 822ef42..3ddba71 100644 --- a/src/commands/node.test.ts +++ b/src/commands/node.test.ts @@ -94,6 +94,7 @@ type NodeContextOverrides = { readonly resolvePath?: (path: string) => string; readonly getExecutionContext?: () => Record unknown>; readonly getDecodeFn?: () => ((value: unknown) => string) | undefined; + readonly getSystemFn?: () => ((value: unknown) => string) | undefined; }; const createNodeContext = (overrides: NodeContextOverrides = {}) => ({ @@ -103,6 +104,7 @@ const createNodeContext = (overrides: NodeContextOverrides = {}) => ({ getUserType: overrides.getUserType ?? (() => 'user' as UserType), getExecutionContext: overrides.getExecutionContext ?? (() => ({})), getDecodeFn: overrides.getDecodeFn, + getSystemFn: overrides.getSystemFn, canTraverse: () => ({ allowed: true }), }); @@ -406,6 +408,82 @@ describe('node command', () => { }); }); + describe('_system() injection', () => { + it('injects _system into execution context when getSystemFn returns a function', () => { + const systemFn = vi.fn((value: unknown) => + String(value) === 'correct' ? 'System check: PASS' : 'System check: FAIL', + ); + const mockEcho = vi.fn((val: unknown) => String(val)); + const file = getMockFile({ + content: 'echo(_system("correct"))', + }); + const context = createNodeContext({ + getNode: () => file, + getExecutionContext: () => ({ echo: mockEcho }), + getSystemFn: () => systemFn, + }); + + const node = createNodeCommand(context); + const result = node.fn('/script.js'); + + expect(systemFn).toHaveBeenCalledWith('correct'); + expect(result).toBe('System check: PASS'); + }); + + it('returns FAIL string when _system value does not match', () => { + const systemFn = vi.fn((value: unknown) => + String(value) === 'correct' ? 'System check: PASS' : 'System check: FAIL', + ); + const mockEcho = vi.fn((val: unknown) => String(val)); + const file = getMockFile({ + content: 'echo(_system("wrong"))', + }); + const context = createNodeContext({ + getNode: () => file, + getExecutionContext: () => ({ echo: mockEcho }), + getSystemFn: () => systemFn, + }); + + const node = createNodeCommand(context); + const result = node.fn('/script.js'); + + expect(systemFn).toHaveBeenCalledWith('wrong'); + expect(result).toBe('System check: FAIL'); + }); + + it('does not inject _system when getSystemFn returns undefined', () => { + const file = getMockFile({ + content: 'typeof _system', + }); + const context = createNodeContext({ + getNode: () => file, + getSystemFn: () => undefined, + }); + + const node = createNodeCommand(context); + const result = node.fn('/script.js'); + + expect(result).toBe('undefined'); + }); + + it('_system works standalone without echo wrapper', () => { + const systemFn = vi.fn(() => 'System check: PASS'); + const file = getMockFile({ + content: '_system("value")', + }); + const context = createNodeContext({ + getNode: () => file, + getSystemFn: () => systemFn, + }); + + const node = createNodeCommand(context); + const result = node.fn('/script.js'); + + expect(systemFn).toHaveBeenCalledWith('value'); + expect(result).toBe('System check: PASS'); + }); + }); + describe('process.argv', () => { it('exposes extra arguments as process.argv in sync scripts', () => { const file = getMockFile({ content: 'process.argv' }); diff --git a/src/commands/node.ts b/src/commands/node.ts index 1bbe51d..bb951d6 100644 --- a/src/commands/node.ts +++ b/src/commands/node.ts @@ -9,6 +9,7 @@ type NodeContext = { readonly getUserType: () => UserType; readonly getExecutionContext: () => Record unknown>; readonly getDecodeFn?: () => ((value: unknown) => string) | undefined; + readonly getSystemFn?: () => ((value: unknown) => string) | undefined; readonly canTraverse: (path: string) => PermissionResult; }; @@ -102,10 +103,12 @@ const validateAndReadFile = (path: string, context: NodeContext): string | undef const buildSyncContext = ( executionContext: Record unknown>, decodeFn: ((value: unknown) => string) | undefined, + systemFn: ((value: unknown) => string) | undefined, mutableBuffer: string[], ): Record => ({ ...executionContext, ...(decodeFn ? { _decode: decodeFn } : {}), + ...(systemFn ? { _system: systemFn } : {}), ...(executionContext.echo ? { echo: (...args: readonly unknown[]): string => { @@ -121,6 +124,7 @@ const buildSyncContext = ( const buildAsyncContext = ( executionContext: Record unknown>, decodeFn: ((value: unknown) => string) | undefined, + systemFn: ((value: unknown) => string) | undefined, onLine: (line: string) => void, cancellation: CancellationState, ): Record => { @@ -141,6 +145,7 @@ const buildAsyncContext = ( return { ...wrapped, ...(decodeFn ? { _decode: decodeFn } : {}), + ...(systemFn ? { _system: systemFn } : {}), console: { log: (...args: readonly unknown[]) => onLine(args.join(' ')) }, sleep: (ms: number) => new Promise((resolve, reject) => { @@ -187,6 +192,7 @@ const executeAsyncScript = ( content: string, executionContext: Record unknown>, decodeFn: ((value: unknown) => string) | undefined, + systemFn: ((value: unknown) => string) | undefined, scriptArgs: readonly unknown[], ): AsyncOutput => { const cancellation: CancellationState = { @@ -199,7 +205,7 @@ const executeAsyncScript = ( __type: 'async', start: (onLine, onComplete) => { const asyncContext = { - ...buildAsyncContext(executionContext, decodeFn, onLine, cancellation), + ...buildAsyncContext(executionContext, decodeFn, systemFn, onLine, cancellation), process: { argv: scriptArgs }, }; const contextKeys = Object.keys(asyncContext); @@ -274,12 +280,13 @@ export const createNodeCommand = (context: NodeContext): Command => ({ const executionContext = context.getExecutionContext(); const decodeFn = context.getDecodeFn?.(); + const systemFn = context.getSystemFn?.(); const scriptArgs = args.slice(1); // Scripts with await use the async execution path — returns AsyncOutput // so Terminal can stream lines in real time. if (HAS_AWAIT.test(content)) { - return executeAsyncScript(content, executionContext, decodeFn, scriptArgs); + return executeAsyncScript(content, executionContext, decodeFn, systemFn, scriptArgs); } // Captures echo() output during script execution so multiple echo calls @@ -288,7 +295,7 @@ export const createNodeCommand = (context: NodeContext): Command => ({ // on arrays — push() mutates in place, but bracket access on a local is tolerated. const mutableBuffer: string[] = []; const wrappedContext = { - ...buildSyncContext(executionContext, decodeFn, mutableBuffer), + ...buildSyncContext(executionContext, decodeFn, systemFn, mutableBuffer), process: { argv: scriptArgs }, }; diff --git a/src/generation/attackChain.ts b/src/generation/attackChain.ts index a5f7709..401ab2b 100644 --- a/src/generation/attackChain.ts +++ b/src/generation/attackChain.ts @@ -354,27 +354,23 @@ const buildObjective = ( } if (objectiveType === 'script_fix') { - const accessKey = generateAccessKey(prng); const { targetPath, targetContent, bugType, hintPath, hintContent, expectedChecksum } = selectScriptFixFile(prng, targetMachine); - // ~60% user-owned (anyone can edit/run), ~40% root-owned (must su first) - const scriptOwner: 'root' | 'user' = prng.next() < 0.6 ? 'user' : 'root'; - - // Consume dummy PRNG rolls for binary + encrypt to preserve sequence alignment - prng.next(); - prng.next(); + // Get root password for briefing (player is an authorized contractor) + const targetCreds = credentials[targetMachine.ip] ?? []; + const rootCred = targetCreds.find((c) => c.username === 'root'); + const rootPassword = rootCred?.password ?? 'unknown'; return { type: 'script_fix', - description: `Fix and run the broken script on ${targetMachine.hostname}`, + description: `Fix the broken script on ${targetMachine.hostname}. Root password: ${rootPassword}`, targetMachine: targetMachine.ip, targetPath, targetContent, clientEmail, - expectedProof: accessKey, + expectedProof: '', scriptBugType: bugType, - scriptOwner, scriptHintPath: bugType === 'corrupted' ? hintPath : undefined, scriptHintContent: bugType === 'corrupted' ? hintContent : undefined, expectedChecksum, diff --git a/src/generation/generateMission.test.ts b/src/generation/generateMission.test.ts index c571ade..b9145db 100644 --- a/src/generation/generateMission.test.ts +++ b/src/generation/generateMission.test.ts @@ -409,14 +409,14 @@ describe('generateMissionNetwork', () => { } }); - it('script_fix objective content uses _decode() instead of ACCESS-KEY', () => { + it('script_fix objective content uses _system() instead of _decode()', () => { let found = false; for (let i = 0; i < 100; i++) { - const result = generateMissionNetwork(`script-fix-decode-${i}`); + const result = generateMissionNetwork(`script-fix-system-${i}`); if (result.objective.type !== 'script_fix') continue; - expect(result.objective.targetContent).not.toMatch(/ACCESS-/); - expect(result.objective.targetContent).toContain('_decode('); + expect(result.objective.targetContent).not.toContain('_decode('); + expect(result.objective.targetContent).toContain('_system('); expect(result.objective.expectedChecksum).toBeTruthy(); found = true; break; @@ -424,14 +424,27 @@ describe('generateMissionNetwork', () => { expect(found).toBe(true); }); - it('script_fix with keyword always uses _decode()', () => { + it('script_fix with keyword always uses _system()', () => { const result = generateMissionNetwork('test-script-fix-easy'); expect(result.objective.type).toBe('script_fix'); - expect(result.objective.targetContent).toContain('_decode('); - expect(result.objective.targetContent).not.toMatch(/ACCESS-/); + expect(result.objective.targetContent).toContain('_system('); + expect(result.objective.targetContent).not.toContain('_decode('); expect(result.objective.expectedChecksum).toBeTruthy(); }); + it('script_fix forces SSH entry and includes root password in description', () => { + const result = generateMissionNetwork('test-script-fix-easy'); + expect(result.objective.type).toBe('script_fix'); + expect(result.entryVariant).toBe('ssh'); + expect(result.objective.description).toContain('Root password:'); + }); + + it('script_fix does not generate expectedProof (no ACCESS-KEY)', () => { + const result = generateMissionNetwork('test-script-fix-easy'); + expect(result.objective.type).toBe('script_fix'); + expect(result.objective.expectedProof).toBe(''); + }); + describe('port closures', () => { it('SSH closure occurs for some seeds (statistical)', () => { let sshClosureCount = 0; diff --git a/src/generation/generateMission.ts b/src/generation/generateMission.ts index b0edb65..1c75576 100644 --- a/src/generation/generateMission.ts +++ b/src/generation/generateMission.ts @@ -96,9 +96,11 @@ export const generateMissionNetwork = ( const effectiveForwarded = overrides.objectiveType === 'portforward' ? false : overrides.forwarded; - // forensics always uses SSH entry (player is an authorized investigator) + // forensics and script_fix always use SSH entry (player is an authorized contractor) const effectiveEntryVariant = - overrides.objectiveType === 'forensics' ? 'ssh' : overrides.entryVariant; + overrides.objectiveType === 'forensics' || overrides.objectiveType === 'script_fix' + ? 'ssh' + : overrides.entryVariant; const topology = generateTopology(prng, difficulty, { entryVariantOverride: effectiveEntryVariant, diff --git a/src/generation/pools/scriptFix.ts b/src/generation/pools/scriptFix.ts index a195ec7..3d5903b 100644 --- a/src/generation/pools/scriptFix.ts +++ b/src/generation/pools/scriptFix.ts @@ -18,7 +18,7 @@ export const scriptFixTemplatesByRole: Readonly b.startsWith("db")', 'if (critical.length === 2) {', - ' echo(_decode(critical.join("-")))', + ' _system(critical.join("-"))', '} else {', ' echo("ERROR: backup validation failed")', '}', @@ -27,7 +27,7 @@ export const scriptFixTemplatesByRole: Readonly b.startsWith("db"))', 'if (critical.length === 3) {', - ' echo(_decode(critical.join("-")))', + ' _system(critical.join("-"))', '} else {', ' echo("ERROR: backup validation failed")', '}', @@ -36,7 +36,7 @@ export const scriptFixTemplatesByRole: Readonly b.startsWith("db"))', 'if (critical.length === 2) {', - ' echo(_decode(critical.join("-")))', + ' _system(critical.join("-"))', '} else {', ' echo("ERROR: backup validation failed")', '}', @@ -53,7 +53,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith("report"))', 'if (reports.length === 2) {', - ' echo(_decode(reports.join("-")))', + ' _system(reports.join("-"))', '} else {', ' echo("ERROR: export check failed")', '', @@ -62,7 +62,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith("report"))', 'if (reports.length === 1) {', - ' echo(_decode(reports.join("-")))', + ' _system(reports.join("-"))', '} else {', ' echo("ERROR: export check failed")', '}', @@ -71,7 +71,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith(???))', 'if (reports.length === 2) {', - ' echo(_decode(reports.join("-")))', + ' _system(reports.join("-"))', '} else {', ' echo("ERROR: export check failed")', '}', @@ -93,7 +93,7 @@ export const scriptFixTemplatesByRole: Readonly s.mode > 755)', 'if (unsafe.length === 1) {', - ' echo(_decode(unsafe.map(s => s.path).join("-"))', + ' _system(unsafe.map(s => s.path).join("-")', '} else {', ' echo("ERROR: permission check failed")', '}', @@ -107,7 +107,7 @@ export const scriptFixTemplatesByRole: Readonly s.mode > 777)', 'if (unsafe.length === 1) {', - ' echo(_decode(unsafe.map(s => s.path).join("-")))', + ' _system(unsafe.map(s => s.path).join("-"))', '} else {', ' echo("ERROR: permission check failed")', '}', @@ -121,7 +121,7 @@ export const scriptFixTemplatesByRole: Readonly s.mode > 755)', 'if (unsafe.length === 1) {', - ' echo(_decode(unsafe.map(s => s.path).join("-")))', + ' _system(unsafe.map(s => s.path).join("-"))', '} else {', ' echo("ERROR: permission check failed")', '}', @@ -145,7 +145,7 @@ export const scriptFixTemplatesByRole: Readonly r.status === "active")', 'if (active.length === 3) {', - ' echo(_decode(active.map(r => r.id).join("-"))', + ' _system(active.map(r => r.id).join("-")', '} else {', ' echo("ERROR: record verification failed")', '}', @@ -159,7 +159,7 @@ export const scriptFixTemplatesByRole: Readonly r.status === "inactive")', 'if (active.length === 3) {', - ' echo(_decode(active.map(r => r.id).join("-")))', + ' _system(active.map(r => r.id).join("-"))', '} else {', ' echo("ERROR: record verification failed")', '}', @@ -173,7 +173,7 @@ export const scriptFixTemplatesByRole: Readonly r.status === "active")', 'if (active.length === 3) {', - ' echo(_decode(active.map(r => r.id).join("-")))', + ' _system(active.map(r => r.id).join("-"))', '} else {', ' echo("ERROR: record verification failed")', '}', @@ -191,7 +191,7 @@ export const scriptFixTemplatesByRole: Readonly e === "login")', 'const queries = entries.filter(e => e === "query)', 'if (logins.length === 2 && queries.length === 2) {', - ' echo(_decode(logins.length + "-" + queries.length))', + ' _system(logins.length + "-" + queries.length)', '} else {', ' echo("ERROR: audit check failed")', '}', @@ -201,7 +201,7 @@ export const scriptFixTemplatesByRole: Readonly e === "login")', 'const queries = entries.filter(e => e === "query")', 'if (logins.length === 2 && queries.length === 3) {', - ' echo(_decode(logins.length + "-" + queries.length))', + ' _system(logins.length + "-" + queries.length)', '} else {', ' echo("ERROR: audit check failed")', '}', @@ -211,7 +211,7 @@ export const scriptFixTemplatesByRole: Readonly e === "login")', 'const queries = entries.filter(e => e === "query")', 'if (logins.length === 2 && queries.length === 2) {', - ' echo(_decode(logins.length + "-" + queries.length))', + ' _system(logins.length + "-" + queries.length)', '} else {', ' echo("ERROR: audit check failed")', '}', @@ -233,7 +233,7 @@ export const scriptFixTemplatesByRole: Readonly n.lag < 60)', 'if (healthy.length === 3) {', - ' echo(_decode(healthy.map(n => n.host).join("-")))', + ' _system(healthy.map(n => n.host).join("-"))', '} else {', ' echo("ERROR: replication check failed")', '', @@ -247,7 +247,7 @@ export const scriptFixTemplatesByRole: Readonly n.lag < 60)', 'if (healthy.length === 4) {', - ' echo(_decode(healthy.map(n => n.host).join("-")))', + ' _system(healthy.map(n => n.host).join("-"))', '} else {', ' echo("ERROR: replication check failed")', '}', @@ -261,7 +261,7 @@ export const scriptFixTemplatesByRole: Readonly n.lag < 60)', 'if (healthy.length === 3) {', - ' echo(_decode(healthy.map(n => n.host).join("-")))', + ' _system(healthy.map(n => n.host).join("-"))', '} else {', ' echo("ERROR: replication check failed")', '}', @@ -280,7 +280,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith("/api/"))', 'if (apiRoutes.length === 3) {', - ' echo(_decode(apiRoutes.join("-")))', + ' _system(apiRoutes.join("-"))', ' else {', ' echo("ERROR: endpoint check failed")', '}', @@ -289,7 +289,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith("/api/"))', 'if (apiRoutes.length === 4) {', - ' echo(_decode(apiRoutes.join("-")))', + ' _system(apiRoutes.join("-"))', '} else {', ' echo("ERROR: endpoint check failed")', '}', @@ -298,7 +298,7 @@ export const scriptFixTemplatesByRole: Readonly e.startsWith("/api/"))', 'if (apiRoutes.length === 3) {', - ' echo(_decode(apiRoutes.join("-")))', + ' _system(apiRoutes.join("-"))', '} else {', ' echo("ERROR: endpoint check failed")', '}', @@ -315,7 +315,7 @@ export const scriptFixTemplatesByRole: Readonly c.endsWith(".pem"))', 'if (valid.length === 3 {', - ' echo(_decode(valid.join("-")))', + ' _system(valid.join("-"))', '} else {', ' echo("ERROR: cert validation failed")', '}', @@ -324,7 +324,7 @@ export const scriptFixTemplatesByRole: Readonly c.endsWith(".pem"))', 'if (valid.length === 2) {', - ' echo(_decode(valid.join("-")))', + ' _system(valid.join("-"))', '} else {', ' echo("ERROR: cert validation failed")', '}', @@ -333,7 +333,7 @@ export const scriptFixTemplatesByRole: Readonly c.endsWith(".pem"))', 'if (valid.length === 3) {', - ' echo(_decode(valid.join("-")))', + ' _system(valid.join("-"))', '} else {', ' echo("ERROR: cert validation failed")', '}', @@ -354,7 +354,7 @@ export const scriptFixTemplatesByRole: Readonly v.ssl === true && v.port === 443)', 'if (secure.length === 2) {', - ' echo(_decode(secure.map(v => v.domain).join("-")))', + ' _system(secure.map(v => v.domain).join("-"))', '} else {', ' echo("ERROR: vhost check failed)', '}', @@ -367,7 +367,7 @@ export const scriptFixTemplatesByRole: Readonly v.ssl === true && v.port === 8080)', 'if (secure.length === 2) {', - ' echo(_decode(secure.map(v => v.domain).join("-")))', + ' _system(secure.map(v => v.domain).join("-"))', '} else {', ' echo("ERROR: vhost check failed")', '}', @@ -380,7 +380,7 @@ export const scriptFixTemplatesByRole: Readonly v.ssl === true && v.port === 443)', 'if (secure.length === 2) {', - ' echo(_decode(secure.map(v => v.domain).join("-")))', + ' _system(secure.map(v => v.domain).join("-"))', '} else {', ' echo("ERROR: vhost check failed")', '}', @@ -399,7 +399,7 @@ export const scriptFixTemplatesByRole: Readonly u === "admin" || u === "operator")', 'if (privileged.length === 2) {', - ' echo(_decode(privileged.join("-")))', + ' _system(privileged.join("-"))', '} else {', ' echo("ERROR: access verification failed)', '}', @@ -408,7 +408,7 @@ export const scriptFixTemplatesByRole: Readonly u === "admin")', 'if (privileged.length === 2) {', - ' echo(_decode(privileged.join("-")))', + ' _system(privileged.join("-"))', '} else {', ' echo("ERROR: access verification failed")', '}', @@ -417,7 +417,7 @@ export const scriptFixTemplatesByRole: Readonly u === "admin" || u === "operator")', 'if (privileged.length === 2) {', - ' echo(_decode(privileged.join("-")))', + ' _system(privileged.join("-"))', '} else {', ' echo("ERROR: access verification failed")', '}', @@ -438,7 +438,7 @@ export const scriptFixTemplatesByRole: Readonly p.priority === "high")', 'if (urgent.length === 2) {', - ' echo(_decode(urgent.map(p => p.name).join("-"))', + ' _system(urgent.map(p => p.name).join("-")', '} else {', ' echo("ERROR: project check failed")', '}', @@ -451,7 +451,7 @@ export const scriptFixTemplatesByRole: Readonly p.priority === "low")', 'if (urgent.length === 2) {', - ' echo(_decode(urgent.map(p => p.name).join("-")))', + ' _system(urgent.map(p => p.name).join("-"))', '} else {', ' echo("ERROR: project check failed")', '}', @@ -464,7 +464,7 @@ export const scriptFixTemplatesByRole: Readonly p.priority === "high")', 'if (urgent.length === 2) {', - ' echo(_decode(urgent.map(p => p.name).join("-")))', + ' _system(urgent.map(p => p.name).join("-"))', '} else {', ' echo("ERROR: project check failed")', '}', @@ -486,7 +486,7 @@ export const scriptFixTemplatesByRole: Readonly s.state === "open")', 'if (exposed.length === 3 {', - ' echo(_decode(exposed.map(s => s.svc).join("-")))', + ' _system(exposed.map(s => s.svc).join("-"))', '} else {', ' echo("ERROR: port scan failed")', '}', @@ -500,7 +500,7 @@ export const scriptFixTemplatesByRole: Readonly s.state === "closed")', 'if (exposed.length === 3) {', - ' echo(_decode(exposed.map(s => s.svc).join("-")))', + ' _system(exposed.map(s => s.svc).join("-"))', '} else {', ' echo("ERROR: port scan failed")', '}', @@ -514,7 +514,7 @@ export const scriptFixTemplatesByRole: Readonly s.state === "open")', 'if (exposed.length === 3) {', - ' echo(_decode(exposed.map(s => s.svc).join("-")))', + ' _system(exposed.map(s => s.svc).join("-"))', '} else {', ' echo("ERROR: port scan failed")', '}', @@ -533,7 +533,7 @@ export const scriptFixTemplatesByRole: Readonly r >= 18 && r <= 25)', 'if (normal.length === 4) {', - ' echo(_decode(normal.join("-"))', + ' _system(normal.join("-")', '} else {', ' echo("ERROR: sensor check failed")', '}', @@ -542,7 +542,7 @@ export const scriptFixTemplatesByRole: Readonly r >= 18 && r <= 25)', 'if (normal.length === 5) {', - ' echo(_decode(normal.join("-")))', + ' _system(normal.join("-"))', '} else {', ' echo("ERROR: sensor check failed")', '}', @@ -551,7 +551,7 @@ export const scriptFixTemplatesByRole: Readonly r >= 18 && r <= 25)', 'if (normal.length === 4) {', - ' echo(_decode(normal.join("-")))', + ' _system(normal.join("-"))', '} else {', ' echo("ERROR: sensor check failed")', '}', @@ -572,7 +572,7 @@ export const scriptFixTemplatesByRole: Readonly d.status === "online")', 'if (active.length === 2) {', - ' echo(_decode(active.map(d => d.name).join("-")))', + ' _system(active.map(d => d.name).join("-"))', '} else {', ' echo("ERROR: device health check failed")', '', @@ -585,7 +585,7 @@ export const scriptFixTemplatesByRole: Readonly d.status === "offline")', 'if (active.length === 2) {', - ' echo(_decode(active.map(d => d.name).join("-")))', + ' _system(active.map(d => d.name).join("-"))', '} else {', ' echo("ERROR: device health check failed")', '}', @@ -598,7 +598,7 @@ export const scriptFixTemplatesByRole: Readonly d.status === "online")', 'if (active.length === 2) {', - ' echo(_decode(active.map(d => d.name).join("-")))', + ' _system(active.map(d => d.name).join("-"))', '} else {', ' echo("ERROR: device health check failed")', '}', @@ -619,7 +619,7 @@ export const scriptFixTemplatesByRole: Readonly d.ver !== d.latest', 'if (outdated.length === 1) {', - ' echo(_decode(outdated.map(d => d.id + ":" + d.ver).join("-")))', + ' _system(outdated.map(d => d.id + ":" + d.ver).join("-"))', '} else {', ' echo("ERROR: firmware check failed")', '}', @@ -632,7 +632,7 @@ export const scriptFixTemplatesByRole: Readonly d.ver === d.latest)', 'if (outdated.length === 1) {', - ' echo(_decode(outdated.map(d => d.id + ":" + d.ver).join("-")))', + ' _system(outdated.map(d => d.id + ":" + d.ver).join("-"))', '} else {', ' echo("ERROR: firmware check failed")', '}', @@ -645,7 +645,7 @@ export const scriptFixTemplatesByRole: Readonly d.ver !== d.latest)', 'if (outdated.length === 1) {', - ' echo(_decode(outdated.map(d => d.id + ":" + d.ver).join("-")))', + ' _system(outdated.map(d => d.id + ":" + d.ver).join("-"))', '} else {', ' echo("ERROR: firmware check failed")', '}', @@ -664,7 +664,7 @@ export const scriptFixTemplatesByRole: Readonly m === "spam")', 'if (spam.length === 3) {', - ' echo(_decode(spam.join("-"))', + ' _system(spam.join("-")', '} else {', ' echo("ERROR: spam filter check failed")', '}', @@ -673,7 +673,7 @@ export const scriptFixTemplatesByRole: Readonly m === "spam")', 'if (spam.length === 2) {', - ' echo(_decode(spam.join("-")))', + ' _system(spam.join("-"))', '} else {', ' echo("ERROR: spam filter check failed")', '}', @@ -682,7 +682,7 @@ export const scriptFixTemplatesByRole: Readonly m === "spam")', 'if (spam.length === 3) {', - ' echo(_decode(spam.join("-")))', + ' _system(spam.join("-"))', '} else {', ' echo("ERROR: spam filter check failed")', '}', @@ -703,7 +703,7 @@ export const scriptFixTemplatesByRole: Readonly m.quota === "full")', 'if (overQuota.length === 2) {', - ' echo(_decode(overQuota.map(m => m.user).join("-")))', + ' _system(overQuota.map(m => m.user).join("-"))', '} else {', ' echo("ERROR: mailbox validation failed")', '', @@ -716,7 +716,7 @@ export const scriptFixTemplatesByRole: Readonly m.quota === "ok")', 'if (overQuota.length === 2) {', - ' echo(_decode(overQuota.map(m => m.user).join("-")))', + ' _system(overQuota.map(m => m.user).join("-"))', '} else {', ' echo("ERROR: mailbox validation failed")', '}', @@ -729,7 +729,7 @@ export const scriptFixTemplatesByRole: Readonly m.quota === "full")', 'if (overQuota.length === 2) {', - ' echo(_decode(overQuota.map(m => m.user).join("-")))', + ' _system(overQuota.map(m => m.user).join("-"))', '} else {', ' echo("ERROR: mailbox validation failed")', '}', @@ -751,7 +751,7 @@ export const scriptFixTemplatesByRole: Readonly q.count > 0 && q.name !== "inbox")', 'if (stuck.length === 2) {', - ' echo(_decode(stuck.map(q => q.name + ":" + q.count).join("-")))', + ' _system(stuck.map(q => q.name + ":" + q.count).join("-"))', '} else {', ' echo("ERROR: queue check failed")', '', @@ -765,7 +765,7 @@ export const scriptFixTemplatesByRole: Readonly q.count > 0 && q.name !== "inbox")', 'if (stuck.length === 3) {', - ' echo(_decode(stuck.map(q => q.name + ":" + q.count).join("-")))', + ' _system(stuck.map(q => q.name + ":" + q.count).join("-"))', '} else {', ' echo("ERROR: queue check failed")', '}', @@ -779,7 +779,7 @@ export const scriptFixTemplatesByRole: Readonly q.count > 0 && q.name !== "inbox")', 'if (stuck.length === 2) {', - ' echo(_decode(stuck.map(q => q.name + ":" + q.count).join("-")))', + ' _system(stuck.map(q => q.name + ":" + q.count).join("-"))', '} else {', ' echo("ERROR: queue check failed")', '}', @@ -800,7 +800,7 @@ export const scriptFixTemplatesByRole: Readonly r.startsWith("10.") || r.startsWith("172.")', 'if (internal.length === 2) {', - ' echo(_decode(internal.join("-")))', + ' _system(internal.join("-"))', '} else {', ' echo("ERROR: route check failed")', '}', @@ -809,7 +809,7 @@ export const scriptFixTemplatesByRole: Readonly r.startsWith("10.") || r.startsWith("172."))', 'if (internal.length === 3) {', - ' echo(_decode(internal.join("-")))', + ' _system(internal.join("-"))', '} else {', ' echo("ERROR: route check failed")', '}', @@ -818,7 +818,7 @@ export const scriptFixTemplatesByRole: Readonly r.startsWith("10.") || r.startsWith("172."))', 'if (internal.length === 2) {', - ' echo(_decode(internal.join("-")))', + ' _system(internal.join("-"))', '} else {', ' echo("ERROR: route check failed")', '}', @@ -835,7 +835,7 @@ export const scriptFixTemplatesByRole: Readonly r === "ACCEPT")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: firewall verification failed)', '}', @@ -844,7 +844,7 @@ export const scriptFixTemplatesByRole: Readonly r === "DROP")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: firewall verification failed")', '}', @@ -853,7 +853,7 @@ export const scriptFixTemplatesByRole: Readonly r === "ACCEPT")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: firewall verification failed")', '}', @@ -874,7 +874,7 @@ export const scriptFixTemplatesByRole: Readonly a === "allow")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: ACL verification failed)', '}', @@ -883,7 +883,7 @@ export const scriptFixTemplatesByRole: Readonly a === "deny")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: ACL verification failed")', '}', @@ -892,7 +892,7 @@ export const scriptFixTemplatesByRole: Readonly a === "allow")', 'if (allowed.length === 3) {', - ' echo(_decode(allowed.join("-")))', + ' _system(allowed.join("-"))', '} else {', ' echo("ERROR: ACL verification failed")', '}', diff --git a/src/hooks/useCommands.ts b/src/hooks/useCommands.ts index 537ebd0..7bafbf9 100644 --- a/src/hooks/useCommands.ts +++ b/src/hooks/useCommands.ts @@ -185,13 +185,23 @@ export const useCommands = (): UseCommandsResult => { getDecodeFn: () => { if (!activeMission) return undefined; const missionType = activeMission.objective.type; - if (missionType !== 'script_fix' && missionType !== 'script_auto') return undefined; + if (missionType !== 'script_auto') return undefined; const { expectedChecksum, expectedProof } = activeMission.objective; return (value: unknown) => { if (String(value) === expectedChecksum) return expectedProof; return 'ERROR: checksum mismatch — script output is incorrect'; }; }, + getSystemFn: () => { + if (!activeMission) return undefined; + const missionType = activeMission.objective.type; + if (missionType !== 'script_fix') return undefined; + const { expectedChecksum } = activeMission.objective; + return (value: unknown) => { + if (String(value) === expectedChecksum) return 'System check: PASS'; + return 'System check: FAIL — script output is incorrect'; + }; + }, }), ); diff --git a/src/mission/missionBoard.ts b/src/mission/missionBoard.ts index 9530790..81e2f23 100644 --- a/src/mission/missionBoard.ts +++ b/src/mission/missionBoard.ts @@ -33,9 +33,9 @@ export const MISSION_BOARD: readonly MissionListing[] = [ client: 'cyph3rpunk', clientEmail: 'cyph3rpunk@darkmail.onion', target: 'QuickShip Logistics — warehouse workstation', - objective: 'Fix a broken inventory script and retrieve the access code', + objective: 'Repair a broken inventory validation script on their workstation', difficulty: 'EASY', - seed: 'QUICKSHIP-nc-easy-script-fix', + seed: 'QUICKSHIP-ssh-easy-script-fix', }, { id: 'DKC-004', @@ -97,9 +97,9 @@ export const MISSION_BOARD: readonly MissionListing[] = [ client: 'v0id_agent', clientEmail: 'v0id_agent@darkmail.onion', target: 'Sentinel Security — surveillance server cluster', - objective: 'Fix a broken audit script to extract the access code', + objective: 'Repair a broken audit script on their monitoring infrastructure', difficulty: 'MEDIUM', - seed: 'SENTINEL-exploit-medium-script-fix', + seed: 'SENTINEL-ssh-medium-script-fix', }, { id: 'DKC-010', @@ -143,9 +143,9 @@ export const MISSION_BOARD: readonly MissionListing[] = [ client: 'cyph3rpunk', clientEmail: 'cyph3rpunk@darkmail.onion', target: 'Nexus Pharma — drug trial data processing cluster', - objective: 'Fix the corrupted validation script deep in their network', + objective: 'Repair a corrupted validation script deep in their network', difficulty: 'HARD', - seed: 'NEXUS-ftp-hard-script-fix', + seed: 'NEXUS-ssh-hard-script-fix', }, { id: 'DKC-014', diff --git a/src/utils/scriptRunner.test.ts b/src/utils/scriptRunner.test.ts new file mode 100644 index 0000000..5482f13 --- /dev/null +++ b/src/utils/scriptRunner.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { runScriptWithSystem } from './scriptRunner'; + +describe('runScriptWithSystem', () => { + it('captures value passed to _system', () => { + const result = runScriptWithSystem('_system("hello")'); + expect(result.systemValue).toBe('hello'); + expect(result.error).toBeNull(); + }); + + it('returns null systemValue when _system is never called', () => { + const result = runScriptWithSystem('const x = 1 + 2'); + expect(result.systemValue).toBeNull(); + expect(result.error).toBeNull(); + }); + + it('captures the last _system call value', () => { + const result = runScriptWithSystem('_system("first"); _system("second")'); + expect(result.systemValue).toBe('second'); + }); + + it('returns error for syntax errors', () => { + const result = runScriptWithSystem('function('); + expect(result.systemValue).toBeNull(); + expect(result.error).toBeTruthy(); + }); + + it('returns error for runtime errors', () => { + const result = runScriptWithSystem('throw new Error("boom")'); + expect(result.systemValue).toBeNull(); + expect(result.error).toBe('boom'); + }); + + it('provides no-op echo that does not throw', () => { + const result = runScriptWithSystem('echo("info"); _system("ok")'); + expect(result.systemValue).toBe('ok'); + expect(result.error).toBeNull(); + }); + + it('converts non-string values to string', () => { + const result = runScriptWithSystem('_system(42)'); + expect(result.systemValue).toBe('42'); + }); + + it('works with typical script_fix script pattern', () => { + const script = [ + 'const backups = ["db_full", "db_diff", "logs", "config"]', + 'const critical = backups.filter(b => b.startsWith("db"))', + 'if (critical.length === 2) {', + ' _system(critical.join("-"))', + '} else {', + ' echo("ERROR: backup validation failed")', + '}', + ].join('\n'); + const result = runScriptWithSystem(script); + expect(result.systemValue).toBe('db_full-db_diff'); + expect(result.error).toBeNull(); + }); + + it('does not capture _system when error branch is taken', () => { + const script = [ + 'const data = [1, 2]', + 'if (data.length === 5) {', + ' _system("ok")', + '} else {', + ' echo("ERROR: check failed")', + '}', + ].join('\n'); + const result = runScriptWithSystem(script); + expect(result.systemValue).toBeNull(); + expect(result.error).toBeNull(); + }); +}); diff --git a/src/utils/scriptRunner.ts b/src/utils/scriptRunner.ts new file mode 100644 index 0000000..f15ae14 --- /dev/null +++ b/src/utils/scriptRunner.ts @@ -0,0 +1,39 @@ +// Lightweight script runner for mail verification. +// Executes a script in a sandboxed context with _system() captured. +// Returns the value passed to _system(), or null if _system was never called. + +type ScriptRunnerResult = { + readonly systemValue: string | null; + readonly error: string | null; +}; + +export const runScriptWithSystem = (content: string): ScriptRunnerResult => { + let captured: string | null = null; + + const systemFn = (value: unknown): string => { + captured = String(value); + return `System check: PASS`; + }; + + // No-op echo — scripts may call echo() for error branches, we ignore it during verification + const echoFn = (): string => ''; + + try { + const contextKeys = ['_system', 'echo']; + const contextValues = [systemFn, echoFn]; + + // Try expression-first, fall back to statement mode (same strategy as node.ts) + try { + const fn = new Function(...contextKeys, `return (${content})`); + fn(...contextValues); + } catch { + const fn = new Function(...contextKeys, content); + fn(...contextValues); + } + + return { systemValue: captured, error: null }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { systemValue: null, error: message }; + } +};