icoa-cli 2.19.357 → 2.19.358

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +1 -787
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1 -1502
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +1 -59
  8. package/dist/commands/lang.js +1 -202
  9. package/dist/commands/log.js +1 -171
  10. package/dist/commands/shell.js +1 -151
  11. package/dist/commands/sim.js +1 -389
  12. package/dist/index.js +1 -355
  13. package/dist/lib/access.js +1 -184
  14. package/dist/lib/aienv.js +1 -205
  15. package/dist/lib/arena-submit.js +1 -21
  16. package/dist/lib/banner.js +1 -31
  17. package/dist/lib/budget.js +1 -6
  18. package/dist/lib/challenge-dir.js +1 -16
  19. package/dist/lib/colors.js +1 -17
  20. package/dist/lib/comms.js +1 -212
  21. package/dist/lib/config.js +1 -93
  22. package/dist/lib/countdown.js +1 -43
  23. package/dist/lib/country-lang.js +1 -39
  24. package/dist/lib/ctfd-client.js +1 -417
  25. package/dist/lib/demo-exam.js +1 -478
  26. package/dist/lib/demo-flags.js +1 -27
  27. package/dist/lib/demo-stats.js +1 -62
  28. package/dist/lib/demo2-progress.js +1 -102
  29. package/dist/lib/docker-probe.js +1 -118
  30. package/dist/lib/editor-spawn.js +1 -53
  31. package/dist/lib/exam-client.js +1 -54
  32. package/dist/lib/exam-sandbox.js +1 -201
  33. package/dist/lib/exam-setup.js +1 -36
  34. package/dist/lib/exam-state.js +1 -273
  35. package/dist/lib/gemini.js +1 -247
  36. package/dist/lib/i18n.js +1 -302
  37. package/dist/lib/integrity-snapshot.js +1 -88
  38. package/dist/lib/interactive-spawn.js +1 -55
  39. package/dist/lib/ipynb-input.js +1 -65
  40. package/dist/lib/kernel-protocol.js +1 -88
  41. package/dist/lib/kernel.js +2 -146
  42. package/dist/lib/learn-curricula.js +1 -309
  43. package/dist/lib/learn-i18n.js +1 -184
  44. package/dist/lib/learn-input.js +1 -101
  45. package/dist/lib/learn-render.js +1 -863
  46. package/dist/lib/learn-state.js +1 -103
  47. package/dist/lib/log-sync.js +1 -155
  48. package/dist/lib/logger.js +1 -49
  49. package/dist/lib/main-rl.js +1 -7
  50. package/dist/lib/menu-nav.js +1 -105
  51. package/dist/lib/notebook-doc.js +1 -137
  52. package/dist/lib/open-file.js +1 -55
  53. package/dist/lib/paper-upgrade.js +1 -119
  54. package/dist/lib/platform.js +1 -99
  55. package/dist/lib/render-card.js +1 -112
  56. package/dist/lib/repl-asker.js +1 -67
  57. package/dist/lib/sample-runner.js +1 -227
  58. package/dist/lib/sandbox.js +1 -144
  59. package/dist/lib/shell-split.js +1 -69
  60. package/dist/lib/sim-cooldown.js +1 -75
  61. package/dist/lib/theme.js +1 -119
  62. package/dist/lib/token-format.js +1 -74
  63. package/dist/lib/tool-man.js +1 -418
  64. package/dist/lib/toolset-hash.js +1 -48
  65. package/dist/lib/translation.js +1 -80
  66. package/dist/lib/translations-fetcher.js +1 -95
  67. package/dist/lib/ui.js +1 -99
  68. package/dist/lib/update-check.js +1 -114
  69. package/dist/lib/version.js +1 -24
  70. package/dist/postinstall.js +1 -48
  71. package/dist/repl.js +1 -2391
  72. package/dist/types/index.js +1 -63
  73. package/package.json +1 -1
@@ -1,102 +1 @@
1
- /**
2
- * demo2 progress persistence — opt-in resume + replay hooks.
3
- *
4
- * State lives in `~/.icoa/demo2-progress.json`. Records the last card
5
- * index the user finished plus a completedAt timestamp when they reach
6
- * the outro. Auto-clears anything older than 7 days so re-runs after a
7
- * gap feel like a fresh start (no manual cleanup needed — see
8
- * feedback_no_manual_rm).
9
- *
10
- * Out of scope: scores, answers, language history. Those are run-scoped
11
- * and re-initialised every call to runDemo2Once().
12
- */
13
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
14
- import { join } from 'node:path';
15
- import { homedir } from 'node:os';
16
- const PROGRESS_FILE = join(homedir(), '.icoa', 'demo2-progress.json');
17
- // Auto-expire stale partial progress so coming back next month re-greets
18
- // the user fresh instead of dangling on card 5/10.
19
- const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
20
- function ensureDir() {
21
- try {
22
- mkdirSync(join(homedir(), '.icoa'), { recursive: true });
23
- }
24
- catch {
25
- // Non-fatal — caller's writeFileSync will surface the real failure
26
- }
27
- }
28
- /**
29
- * Load progress, auto-clearing anything stale. Returns null if no usable
30
- * record exists.
31
- */
32
- export function loadDemo2Progress() {
33
- let raw;
34
- try {
35
- raw = readFileSync(PROGRESS_FILE, 'utf-8');
36
- }
37
- catch {
38
- return null;
39
- }
40
- let parsed;
41
- try {
42
- parsed = JSON.parse(raw);
43
- }
44
- catch {
45
- clearDemo2Progress();
46
- return null;
47
- }
48
- if (typeof parsed.nextCardIndex !== 'number' ||
49
- typeof parsed.totalCards !== 'number' ||
50
- typeof parsed.lang !== 'string' ||
51
- typeof parsed.startedAt !== 'number') {
52
- clearDemo2Progress();
53
- return null;
54
- }
55
- const anchor = parsed.completedAt ?? parsed.startedAt;
56
- if (typeof anchor === 'number' && Date.now() - anchor > STALE_AFTER_MS) {
57
- clearDemo2Progress();
58
- return null;
59
- }
60
- return {
61
- nextCardIndex: parsed.nextCardIndex,
62
- totalCards: parsed.totalCards,
63
- lang: parsed.lang,
64
- startedAt: parsed.startedAt,
65
- completedAt: typeof parsed.completedAt === 'number' ? parsed.completedAt : undefined,
66
- };
67
- }
68
- export function saveDemo2Progress(p) {
69
- ensureDir();
70
- try {
71
- writeFileSync(PROGRESS_FILE, JSON.stringify(p));
72
- }
73
- catch {
74
- // Non-fatal — progress is convenience, not correctness
75
- }
76
- }
77
- export function clearDemo2Progress() {
78
- try {
79
- unlinkSync(PROGRESS_FILE);
80
- }
81
- catch {
82
- // Already absent — fine
83
- }
84
- }
85
- /** Convenience: record that the user finished card N (0-based). */
86
- export function markCardDone(cardIndex, totalCards, lang, startedAt) {
87
- saveDemo2Progress({
88
- nextCardIndex: cardIndex + 1,
89
- totalCards,
90
- lang,
91
- startedAt,
92
- });
93
- }
94
- export function markDemo2Complete(totalCards, lang, startedAt) {
95
- saveDemo2Progress({
96
- nextCardIndex: totalCards,
97
- totalCards,
98
- lang,
99
- startedAt,
100
- completedAt: Date.now(),
101
- });
102
- }
1
+ import{mkdirSync as t,readFileSync as e,unlinkSync as r,writeFileSync as o}from"node:fs";import{join as n}from"node:path";import{homedir as a}from"node:os";const s=n(a(),".icoa","demo2-progress.json");export function loadDemo2Progress(){let t,r;try{t=e(s,"utf-8")}catch{return null}try{r=JSON.parse(t)}catch{return clearDemo2Progress(),null}if("number"!=typeof r.nextCardIndex||"number"!=typeof r.totalCards||"string"!=typeof r.lang||"number"!=typeof r.startedAt)return clearDemo2Progress(),null;const o=r.completedAt??r.startedAt;return"number"==typeof o&&Date.now()-o>6048e5?(clearDemo2Progress(),null):{nextCardIndex:r.nextCardIndex,totalCards:r.totalCards,lang:r.lang,startedAt:r.startedAt,completedAt:"number"==typeof r.completedAt?r.completedAt:void 0}}export function saveDemo2Progress(e){!function(){try{t(n(a(),".icoa"),{recursive:!0})}catch{}}();try{o(s,JSON.stringify(e))}catch{}}export function clearDemo2Progress(){try{r(s)}catch{}}export function markCardDone(t,e,r,o){saveDemo2Progress({nextCardIndex:t+1,totalCards:e,lang:r,startedAt:o})}export function markDemo2Complete(t,e,r){saveDemo2Progress({nextCardIndex:t,totalCards:t,lang:e,startedAt:r,completedAt:Date.now()})}
@@ -1,118 +1 @@
1
- /**
2
- * Detect whether the Docker daemon is running WITHOUT waking it.
3
- *
4
- * The `docker` CLI is the trigger: on macOS, running any `docker` command
5
- * (including `docker info`) can auto-launch Docker Desktop. The old probe
6
- * shelled out to `docker info`, so every REPL shell-out and every `shell`
7
- * invocation kept starting Docker on the user's Mac even though the sandbox is
8
- * OPTIONAL and the host-shell fallback is the norm (2026-07 feedback).
9
- *
10
- * This probes the daemon's listening SOCKET directly (Node `net`) and never
11
- * invokes the `docker` binary. Docker already up → the socket connects → we use
12
- * the sandbox. Docker down → connection refused → we silently use the host
13
- * shell, and Docker is never started.
14
- */
15
- import net from 'node:net';
16
- /**
17
- * Ordered daemon endpoints to probe. A `DOCKER_HOST` (unix://, tcp://, npipe://)
18
- * overrides everything — it's the user's explicit daemon. Otherwise fall back to
19
- * the well-known per-platform sockets, most-specific first. Pure + testable:
20
- * platform/env/home are all injected.
21
- */
22
- export function dockerProbeTargets(platform, env, home) {
23
- const host = env.DOCKER_HOST?.trim();
24
- if (host) {
25
- if (host.startsWith('unix://'))
26
- return [{ kind: 'unix', target: host.slice('unix://'.length) }];
27
- if (host.startsWith('tcp://'))
28
- return [{ kind: 'tcp', target: host.slice('tcp://'.length) }];
29
- if (host.startsWith('npipe://'))
30
- return [{ kind: 'npipe', target: host.slice('npipe://'.length) }];
31
- // Unknown scheme — ignore and fall through to platform defaults.
32
- }
33
- if (platform === 'win32') {
34
- return [{ kind: 'npipe', target: '\\\\.\\pipe\\docker_engine' }];
35
- }
36
- if (platform === 'darwin') {
37
- return [
38
- { kind: 'unix', target: `${home}/.docker/run/docker.sock` },
39
- { kind: 'unix', target: '/var/run/docker.sock' },
40
- ];
41
- }
42
- // linux / wsl / crostini
43
- const targets = [{ kind: 'unix', target: '/var/run/docker.sock' }];
44
- const xdg = env.XDG_RUNTIME_DIR?.trim();
45
- if (xdg)
46
- targets.push({ kind: 'unix', target: `${xdg}/docker.sock` }); // rootless docker
47
- targets.push({ kind: 'unix', target: `${home}/.docker/run/docker.sock` }); // Docker Desktop for Linux
48
- return targets;
49
- }
50
- /**
51
- * True if ANY target accepts a connection, probing in order and stopping at the
52
- * first hit. `connect` is injected (returns whether a target is reachable) so
53
- * the ordering/short-circuit is unit-tested without real sockets.
54
- */
55
- export async function anyReachable(targets, connect) {
56
- for (const t of targets) {
57
- if (await connect(t))
58
- return true;
59
- }
60
- return false;
61
- }
62
- /**
63
- * True iff `raw` is a Docker Engine `/_ping` response from a LIVE daemon — an
64
- * HTTP 200. A bare connect isn't enough: after Docker Desktop quits, a lingering
65
- * helper can keep the socket connectable while the daemon is down, so we require
66
- * the daemon to actually answer the API. Pure + tested.
67
- */
68
- export function isDockerPingResponse(raw) {
69
- return /^HTTP\/1\.[01] 200\b/.test(raw.trimStart());
70
- }
71
- // A minimal, version-less Engine API ping. Connection: close so the daemon ends
72
- // the response and we don't hold the socket.
73
- const PING_REQUEST = 'GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n';
74
- /**
75
- * Real socket probe: connect, ask the Engine API `/_ping`, resolve true only on
76
- * a 200. Uses `net.connect` (NOT the docker CLI), so a stopped Docker Desktop is
77
- * NOT auto-started. Short timeout — the socket is local, a live daemon answers
78
- * in milliseconds.
79
- */
80
- function connectTarget(t, timeoutMs = 600) {
81
- return new Promise((resolve) => {
82
- let settled = false;
83
- let buf = '';
84
- const finish = (v) => {
85
- if (settled)
86
- return;
87
- settled = true;
88
- try {
89
- socket.destroy();
90
- }
91
- catch {
92
- /* ignore */
93
- }
94
- resolve(v);
95
- };
96
- const socket = t.kind === 'tcp'
97
- ? net.connect({ host: t.target.split(':')[0], port: Number(t.target.split(':')[1]) })
98
- : net.connect(t.target); // unix socket path or windows named pipe
99
- socket.setTimeout(timeoutMs);
100
- socket.once('connect', () => socket.write(PING_REQUEST));
101
- socket.on('data', (chunk) => {
102
- buf += chunk.toString('utf8');
103
- // The status line arrives in the first packet; decide as soon as we have it.
104
- if (buf.includes('\n') || buf.length > 15)
105
- finish(isDockerPingResponse(buf));
106
- });
107
- socket.once('timeout', () => finish(false));
108
- socket.once('error', () => finish(false));
109
- socket.once('close', () => finish(isDockerPingResponse(buf)));
110
- });
111
- }
112
- /**
113
- * The public check: is a Docker daemon reachable right now, without starting it?
114
- * platform/env/home are injectable for tests; production uses process defaults.
115
- */
116
- export async function isDockerRunning(platform = process.platform, env = process.env, home = process.env.HOME || process.env.USERPROFILE || '') {
117
- return anyReachable(dockerProbeTargets(platform, env, home), connectTarget);
118
- }
1
+ import t from"node:net";export function dockerProbeTargets(t,e,n){const r=e.DOCKER_HOST?.trim();if(r){if(r.startsWith("unix://"))return[{kind:"unix",target:r.slice(7)}];if(r.startsWith("tcp://"))return[{kind:"tcp",target:r.slice(6)}];if(r.startsWith("npipe://"))return[{kind:"npipe",target:r.slice(8)}]}if("win32"===t)return[{kind:"npipe",target:"\\\\.\\pipe\\docker_engine"}];if("darwin"===t)return[{kind:"unix",target:`${n}/.docker/run/docker.sock`},{kind:"unix",target:"/var/run/docker.sock"}];const o=[{kind:"unix",target:"/var/run/docker.sock"}],i=e.XDG_RUNTIME_DIR?.trim();return i&&o.push({kind:"unix",target:`${i}/docker.sock`}),o.push({kind:"unix",target:`${n}/.docker/run/docker.sock`}),o}export async function anyReachable(t,e){for(const n of t)if(await e(n))return!0;return!1}export function isDockerPingResponse(t){return/^HTTP\/1\.[01] 200\b/.test(t.trimStart())}function e(e,n=600){return new Promise(r=>{let o=!1,i="";const c=t=>{if(!o){o=!0;try{s.destroy()}catch{}r(t)}},s="tcp"===e.kind?t.connect({host:e.target.split(":")[0],port:Number(e.target.split(":")[1])}):t.connect(e.target);s.setTimeout(n),s.once("connect",()=>s.write("GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")),s.on("data",t=>{i+=t.toString("utf8"),(i.includes("\n")||i.length>15)&&c(isDockerPingResponse(i))}),s.once("timeout",()=>c(!1)),s.once("error",()=>c(!1)),s.once("close",()=>c(isDockerPingResponse(i)))})}export async function isDockerRunning(t=process.platform,n=process.env,r=process.env.HOME||process.env.USERPROFILE||""){return anyReachable(dockerProbeTargets(t,n,r),e)}
@@ -1,53 +1 @@
1
- /**
2
- * editor-spawn — hand the terminal to the user's $EDITOR for one text
3
- * fragment, then take it back. The `git commit` / `crontab -e` idiom: write a
4
- * temp file, block on the editor, read the file back.
5
- *
6
- * BUG-008 note: this must NOT create any readline. `spawnSync` blocks the
7
- * event loop, so the main REPL's readline cannot fire while the editor owns
8
- * the TTY; we only save/drop/restore raw mode around the child (editors expect
9
- * a cooked terminal and manage their own termios).
10
- */
11
- import { spawnSync } from 'node:child_process';
12
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
- import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
15
- /** $VISUAL → $EDITOR → nano (notepad on native Windows). Values like
16
- * "code -w" split on whitespace into cmd + args. Pure for testability. */
17
- export function resolveEditor(env = process.env, platform = process.platform) {
18
- const raw = (env.VISUAL ?? '').trim() || (env.EDITOR ?? '').trim();
19
- if (raw) {
20
- const parts = raw.split(/\s+/);
21
- return { cmd: parts[0], args: parts.slice(1) };
22
- }
23
- return platform === 'win32' ? { cmd: 'notepad', args: [] } : { cmd: 'nano', args: [] };
24
- }
25
- /**
26
- * Open `initial` in the user's editor; return the edited text, or null if the
27
- * editor failed to launch or exited non-zero (a deliberate cancel, vi `:cq`).
28
- * Caller is expected to `rl.pause()` before and `rl.resume()` after.
29
- */
30
- export function editTextInEditor(initial, suffix = '.py') {
31
- const dir = mkdtempSync(join(tmpdir(), 'icoa-cell-'));
32
- const file = join(dir, `cell${suffix}`);
33
- writeFileSync(file, initial === '' || initial.endsWith('\n') ? initial : `${initial}\n`);
34
- const ed = resolveEditor();
35
- const stdin = process.stdin;
36
- const wasRaw = stdin.isTTY ? stdin.isRaw : false;
37
- try {
38
- if (stdin.isTTY)
39
- stdin.setRawMode(false);
40
- const r = spawnSync(ed.cmd, [...ed.args, file], { stdio: 'inherit' });
41
- if (r.error || r.status !== 0)
42
- return null;
43
- return readFileSync(file, 'utf8');
44
- }
45
- catch {
46
- return null;
47
- }
48
- finally {
49
- if (stdin.isTTY && wasRaw)
50
- stdin.setRawMode(true);
51
- rmSync(dir, { recursive: true, force: true });
52
- }
53
- }
1
+ import{spawnSync as r}from"node:child_process";import{mkdtempSync as o,readFileSync as t,rmSync as s,writeFileSync as e}from"node:fs";import{tmpdir as n}from"node:os";import{join as i}from"node:path";export function resolveEditor(r=process.env,o=process.platform){const t=(r.VISUAL??"").trim()||(r.EDITOR??"").trim();if(t){const r=t.split(/\s+/);return{cmd:r[0],args:r.slice(1)}}return"win32"===o?{cmd:"notepad",args:[]}:{cmd:"nano",args:[]}}export function editTextInEditor(c,d=".py"){const l=o(i(n(),"icoa-cell-")),a=i(l,`cell${d}`);e(a,""===c||c.endsWith("\n")?c:`${c}\n`);const m=resolveEditor(),p=process.stdin,f=!!p.isTTY&&p.isRaw;try{p.isTTY&&p.setRawMode(!1);const o=r(m.cmd,[...m.args,a],{stdio:"inherit"});return o.error||0!==o.status?null:t(a,"utf8")}catch{return null}finally{p.isTTY&&f&&p.setRawMode(!0),s(l,{recursive:!0,force:!0})}}
@@ -1,54 +1 @@
1
- export class ExamClient {
2
- baseUrl;
3
- token;
4
- constructor(baseUrl, token) {
5
- this.baseUrl = baseUrl.replace(/\/+$/, '');
6
- this.token = token;
7
- }
8
- async request(method, path, body) {
9
- // Try nginx proxy first, fallback to direct port
10
- const urls = [`${this.baseUrl}/api/icoa/exams${path}`, `${this.baseUrl}:9090/api/icoa/exams${path}`];
11
- let lastError = null;
12
- for (const url of urls) {
13
- try {
14
- return await this._fetch(method, url, body);
15
- }
16
- catch (e) {
17
- lastError = e;
18
- }
19
- }
20
- throw lastError || new Error('Exam API unreachable');
21
- }
22
- async _fetch(method, url, body) {
23
- const res = await fetch(url, {
24
- method,
25
- headers: {
26
- Authorization: `Token ${this.token}`,
27
- 'Content-Type': 'application/json',
28
- },
29
- body: body ? JSON.stringify(body) : undefined,
30
- signal: AbortSignal.timeout(10000),
31
- });
32
- if (!res.ok) {
33
- const text = await res.text().catch(() => 'Unknown error');
34
- throw new Error(`Exam API error (${res.status}): ${text}`);
35
- }
36
- const json = (await res.json());
37
- if (json.success === false) {
38
- throw new Error(json.message || 'Exam API error');
39
- }
40
- return json.data;
41
- }
42
- async getExams() {
43
- return this.request('GET', '');
44
- }
45
- async startExam(examId) {
46
- return this.request('POST', `/${examId}/start`);
47
- }
48
- async submitExam(examId, answers) {
49
- return this.request('POST', `/${examId}/submit`, { answers });
50
- }
51
- async getResult(examId) {
52
- return this.request('GET', `/${examId}/result`);
53
- }
54
- }
1
+ export class ExamClient{baseUrl;token;constructor(t,e){this.baseUrl=t.replace(/\/+$/,""),this.token=e}async request(t,e,r){const s=[`${this.baseUrl}/api/icoa/exams${e}`,`${this.baseUrl}:9090/api/icoa/exams${e}`];let a=null;for(const e of s)try{return await this._fetch(t,e,r)}catch(t){a=t}throw a||new Error("Exam API unreachable")}async _fetch(t,e,r){const s=await fetch(e,{method:t,headers:{Authorization:`Token ${this.token}`,"Content-Type":"application/json"},body:r?JSON.stringify(r):void 0,signal:AbortSignal.timeout(1e4)});if(!s.ok){const t=await s.text().catch(()=>"Unknown error");throw new Error(`Exam API error (${s.status}): ${t}`)}const a=await s.json();if(!1===a.success)throw new Error(a.message||"Exam API error");return a.data}async getExams(){return this.request("GET","")}async startExam(t){return this.request("POST",`/${t}/start`)}async submitExam(t,e){return this.request("POST",`/${t}/submit`,{answers:e})}async getResult(t){return this.request("GET",`/${t}/result`)}}
@@ -1,201 +1 @@
1
- import { mkdtempSync, mkdirSync, existsSync, appendFileSync, statSync } from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
3
- import { tmpdir, homedir, platform } from 'node:os';
4
- import { join, delimiter } from 'node:path';
5
- import { getIcoaDir, getConfig } from './config.js';
6
- import { getRealExamState } from './exam-state.js';
7
- // L3 — AI coding-assistant CLIs that can read terminal output / clipboard
8
- // and answer exam questions. Detection at exam start makes their presence a
9
- // knowing, logged choice rather than a silent cheat surface.
10
- const AI_BINARIES = [
11
- 'claude',
12
- 'cursor-agent',
13
- 'aider',
14
- 'codex',
15
- 'ollama',
16
- 'llm',
17
- 'cody',
18
- 'continue',
19
- 'windsurf',
20
- 'mods',
21
- 'gemini',
22
- 'q', // Amazon Q CLI
23
- // 2026-06-19 — kept in sync with the L2 typed-command regex below (RISK_PATTERNS
24
- // 'invokes AI agent CLI'). Any name added here must also appear there and vice versa.
25
- 'chatgpt',
26
- 'sgpt', // shell-gpt
27
- 'aichat',
28
- 'copilot', // GitHub Copilot CLI (standalone; `gh copilot` handled separately)
29
- ];
30
- function whichBinary(bin) {
31
- // PATH-scan implementation — avoids invoking a shell with bin in it.
32
- const PATH = process.env.PATH || '';
33
- const exts = platform() === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
34
- for (const dir of PATH.split(delimiter)) {
35
- if (!dir)
36
- continue;
37
- for (const ext of exts) {
38
- const p = join(dir, bin + ext);
39
- try {
40
- if (statSync(p).isFile())
41
- return true;
42
- }
43
- catch {
44
- /* not present */
45
- }
46
- }
47
- }
48
- return false;
49
- }
50
- export function scanForAIBinaries() {
51
- const detected = [];
52
- for (const bin of AI_BINARIES) {
53
- if (whichBinary(bin))
54
- detected.push(bin);
55
- }
56
- // `gh copilot` is a subcommand of GitHub CLI — only flag `gh` when the
57
- // copilot extension is also installed (avoids dinging every gh user).
58
- if (whichBinary('gh')) {
59
- try {
60
- const out = execFileSync('gh', ['extension', 'list'], {
61
- encoding: 'utf-8',
62
- timeout: 2000,
63
- stdio: ['ignore', 'pipe', 'ignore'],
64
- });
65
- if (/copilot/i.test(out))
66
- detected.push('gh-copilot');
67
- }
68
- catch {
69
- /* gh has no extensions or call failed */
70
- }
71
- }
72
- return detected;
73
- }
74
- // L2 — patterns whose presence in a `!cmd` strongly suggests reaching
75
- // outside the exam sandbox for cheat material. Not a block: we log + warn.
76
- const RISK_PATTERNS = [
77
- {
78
- pattern: /(?:^|[\s|;&`$(!])(?:cat|less|more|head|tail|bat)\s+(?:~|\$HOME|\/home\/[^/\s]+|\/Users\/[^/\s]+)/,
79
- label: 'reads home directory',
80
- },
81
- { pattern: /\.bash_history|\.zsh_history|\.fish_history|\.python_history/, label: 'reads shell history' },
82
- {
83
- pattern: /(?:^|[\s|;&`$(!])(?:find|grep\s+-[rR]\S*|rg|fd|ack)\s+(?:~|\$HOME|\/home|\/Users)/,
84
- label: 'searches home directory',
85
- },
86
- {
87
- // Multi-char AI CLIs — kept in sync with AI_BINARIES above (the L3 PATH scan).
88
- pattern: /(?:^|[\s|;&`$(!])(?:claude|cursor-agent|aider|codex|ollama|llm|cody|continue|windsurf|mods|gemini|chatgpt|sgpt|aichat|copilot)\b/,
89
- label: 'invokes AI agent CLI',
90
- },
91
- {
92
- // Amazon Q (`q`) — single-char, so only flag it at a command boundary
93
- // (start, or after ; & |, with an optional `!` bang) to avoid 误伤 on a
94
- // stray `q` elsewhere in a line (e.g. `grep q file`). Bare `q` at the REPL
95
- // is intercepted as "quit" before reaching here.
96
- pattern: /(?:^|[;&|]\s*)!?\s*q\b/,
97
- label: 'invokes AI agent CLI',
98
- },
99
- { pattern: /(?:^|[\s|;&`$(!])gh\s+copilot\b/, label: 'invokes gh copilot' },
100
- { pattern: /(?:^|[\s|;&`$(!])history\b/, label: 'inspects shell history' },
101
- { pattern: /(?:^|[\s|;&`$(!])cd\s+(?:~|\$HOME|\/home|\/Users|\/etc|\/var)/, label: 'cd outside exam workspace' },
102
- ];
103
- export function checkShellRisk(input) {
104
- const flags = [];
105
- for (const { pattern, label } of RISK_PATTERNS) {
106
- if (pattern.test(input))
107
- flags.push(label);
108
- }
109
- return flags;
110
- }
111
- function auditLogPath() {
112
- return join(getIcoaDir(), 'exam-audit.log');
113
- }
114
- // Identity attached to every audit POST so the server (and post-competition
115
- // review) can attribute a flagged action to a PERSON — not just an exam id.
116
- // Without this the server can only record examId+country, and a shared-NAT
117
- // room collapses to one row. Mirrors the AI-chat account binding in gemini.ts:
118
- // 1. a proctored exam token wins when a real exam is active (un-forgeable),
119
- // 2. else the CTFd-join logged-in username (self-reported),
120
- // 3. the device fingerprint is always included as a last-resort anon key.
121
- // Self-reported fields are audit signal, not proof — the un-forgeable boundary
122
- // stays server-side, same as the AI budget. NOTE: this only carries the
123
- // SANCTIONED-AI-orthogonal external signals (shell-escape risk flags, AI
124
- // binaries in PATH); the in-CLI ai4ctf AI never routes through here.
125
- export function auditIdentity() {
126
- const id = {};
127
- const cfg = getConfig();
128
- if (cfg.deviceFingerprint)
129
- id.deviceFingerprint = cfg.deviceFingerprint;
130
- const realExam = getRealExamState();
131
- if (realExam?.session?.token) {
132
- id.examToken = realExam.session.token;
133
- }
134
- else if (cfg.ctfdUrl && cfg.token && cfg.userName) {
135
- // CTFd-join session mode (no exam token): attribute to the logged-in account.
136
- id.account = cfg.userName;
137
- }
138
- return id;
139
- }
140
- export function logShellAudit(entry) {
141
- const state = getRealExamState();
142
- if (!state)
143
- return; // audit only fires inside a real exam
144
- const line = JSON.stringify({
145
- ts: new Date().toISOString(),
146
- examId: state.session.examId,
147
- country: state.session.country,
148
- ...auditIdentity(),
149
- cwd: entry.cwd,
150
- input: entry.input.slice(0, 500),
151
- riskFlags: entry.riskFlags,
152
- });
153
- try {
154
- appendFileSync(auditLogPath(), `${line}\n`);
155
- }
156
- catch {
157
- /* local log is best-effort */
158
- }
159
- fetch('https://practice.icoa2026.au/api/icoa/exam-audit', {
160
- method: 'POST',
161
- headers: { 'Content-Type': 'application/json' },
162
- body: line,
163
- signal: AbortSignal.timeout(3000),
164
- }).catch(() => { });
165
- }
166
- export function reportAIBinaryDetection(binaries) {
167
- if (binaries.length === 0)
168
- return;
169
- const state = getRealExamState();
170
- const body = JSON.stringify({
171
- ts: new Date().toISOString(),
172
- examId: state?.session.examId ?? '(pre-start)',
173
- country: state?.session.country ?? '(pre-start)',
174
- ...auditIdentity(),
175
- binaries,
176
- platform: process.platform,
177
- });
178
- fetch('https://practice.icoa2026.au/api/icoa/exam-ai-binaries', {
179
- method: 'POST',
180
- headers: { 'Content-Type': 'application/json' },
181
- body,
182
- signal: AbortSignal.timeout(3000),
183
- }).catch(() => { });
184
- }
185
- // L1 — per-exam tmpdir. Replaces ~/icoa-workspace during a real exam so the
186
- // contestant lands in a clean slate; leaving this dir trips a cwd risk flag
187
- // via L2. Persists in ExamSession so subsequent REPL invocations reuse it.
188
- export function createExamWorkspace(examId) {
189
- const safeId = examId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32);
190
- return mkdtempSync(join(tmpdir(), `icoa-exam-${safeId}-`));
191
- }
192
- const LEGACY_WORKSPACE = join(homedir(), 'icoa-workspace');
193
- export function getActiveCwd() {
194
- const state = getRealExamState();
195
- const w = state?.session?.workspaceDir;
196
- if (w && existsSync(w))
197
- return w;
198
- if (!existsSync(LEGACY_WORKSPACE))
199
- mkdirSync(LEGACY_WORKSPACE, { recursive: true });
200
- return LEGACY_WORKSPACE;
201
- }
1
+ import{mkdtempSync as t,mkdirSync as e,existsSync as o,appendFileSync as s,statSync as i}from"node:fs";import{execFileSync as n}from"node:child_process";import{tmpdir as r,homedir as a,platform as c}from"node:os";import{join as p,delimiter as l}from"node:path";import{getIcoaDir as d,getConfig as h}from"./config.js";import{getRealExamState as u}from"./exam-state.js";const m=["claude","cursor-agent","aider","codex","ollama","llm","cody","continue","windsurf","mods","gemini","q","chatgpt","sgpt","aichat","copilot"];function f(t){const e=process.env.PATH||"",o="win32"===c()?(process.env.PATHEXT||".EXE;.CMD;.BAT").split(";"):[""];for(const s of e.split(l))if(s)for(const e of o){const o=p(s,t+e);try{if(i(o).isFile())return!0}catch{}}return!1}export function scanForAIBinaries(){const t=[];for(const e of m)f(e)&&t.push(e);if(f("gh"))try{const e=n("gh",["extension","list"],{encoding:"utf-8",timeout:2e3,stdio:["ignore","pipe","ignore"]});/copilot/i.test(e)&&t.push("gh-copilot")}catch{}return t}const g=[{pattern:/(?:^|[\s|;&`$(!])(?:cat|less|more|head|tail|bat)\s+(?:~|\$HOME|\/home\/[^/\s]+|\/Users\/[^/\s]+)/,label:"reads home directory"},{pattern:/\.bash_history|\.zsh_history|\.fish_history|\.python_history/,label:"reads shell history"},{pattern:/(?:^|[\s|;&`$(!])(?:find|grep\s+-[rR]\S*|rg|fd|ack)\s+(?:~|\$HOME|\/home|\/Users)/,label:"searches home directory"},{pattern:/(?:^|[\s|;&`$(!])(?:claude|cursor-agent|aider|codex|ollama|llm|cody|continue|windsurf|mods|gemini|chatgpt|sgpt|aichat|copilot)\b/,label:"invokes AI agent CLI"},{pattern:/(?:^|[;&|]\s*)!?\s*q\b/,label:"invokes AI agent CLI"},{pattern:/(?:^|[\s|;&`$(!])gh\s+copilot\b/,label:"invokes gh copilot"},{pattern:/(?:^|[\s|;&`$(!])history\b/,label:"inspects shell history"},{pattern:/(?:^|[\s|;&`$(!])cd\s+(?:~|\$HOME|\/home|\/Users|\/etc|\/var)/,label:"cd outside exam workspace"}];export function checkShellRisk(t){const e=[];for(const{pattern:o,label:s}of g)o.test(t)&&e.push(s);return e}export function auditIdentity(){const t={},e=h();e.deviceFingerprint&&(t.deviceFingerprint=e.deviceFingerprint);const o=u();return o?.session?.token?t.examToken=o.session.token:e.ctfdUrl&&e.token&&e.userName&&(t.account=e.userName),t}export function logShellAudit(t){const e=u();if(!e)return;const o=JSON.stringify({ts:(new Date).toISOString(),examId:e.session.examId,country:e.session.country,...auditIdentity(),cwd:t.cwd,input:t.input.slice(0,500),riskFlags:t.riskFlags});try{s(p(d(),"exam-audit.log"),`${o}\n`)}catch{}fetch("https://practice.icoa2026.au/api/icoa/exam-audit",{method:"POST",headers:{"Content-Type":"application/json"},body:o,signal:AbortSignal.timeout(3e3)}).catch(()=>{})}export function reportAIBinaryDetection(t){if(0===t.length)return;const e=u(),o=JSON.stringify({ts:(new Date).toISOString(),examId:e?.session.examId??"(pre-start)",country:e?.session.country??"(pre-start)",...auditIdentity(),binaries:t,platform:process.platform});fetch("https://practice.icoa2026.au/api/icoa/exam-ai-binaries",{method:"POST",headers:{"Content-Type":"application/json"},body:o,signal:AbortSignal.timeout(3e3)}).catch(()=>{})}export function createExamWorkspace(e){const o=e.replace(/[^a-zA-Z0-9_-]/g,"_").slice(0,32);return t(p(r(),`icoa-exam-${o}-`))}const y=p(a(),"icoa-workspace");export function getActiveCwd(){const t=u(),s=t?.session?.workspaceDir;return s&&o(s)?s:(o(y)||e(y,{recursive:!0}),y)}
@@ -1,36 +1 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { getIcoaDir } from './config.js';
4
- const SETUP_FILE = () => join(getIcoaDir(), 'exam-setup.json');
5
- export function getExamSetup() {
6
- try {
7
- if (!existsSync(SETUP_FILE()))
8
- return null;
9
- return JSON.parse(readFileSync(SETUP_FILE(), 'utf-8'));
10
- }
11
- catch {
12
- return null;
13
- }
14
- }
15
- export function saveExamSetup(state) {
16
- try {
17
- writeFileSync(SETUP_FILE(), JSON.stringify(state, null, 2));
18
- }
19
- catch { }
20
- }
21
- export function isExamSetupComplete() {
22
- return getExamSetup() !== null;
23
- }
24
- /**
25
- * Papers that are MCQ / short-answer only (no Python, no Unix tooling, no
26
- * practical/CTF questions) skip the `exam setup` gate entirely — running it
27
- * would be pointless and could block a valid token.
28
- *
29
- * - Paper C: exam_id ends in `-c` (e.g. `zz-2026-c`)
30
- * - SIMOC / Paper M: exam_id starts `simoc-` (e.g. `simoc-2026-g6`)
31
- */
32
- export function examIdSkipsSetupGate(examId) {
33
- if (!examId)
34
- return false;
35
- return examId.endsWith('-c') || examId.startsWith('simoc-');
36
- }
1
+ import{existsSync as t,readFileSync as e,writeFileSync as n}from"node:fs";import{join as r}from"node:path";import{getIcoaDir as o}from"./config.js";const u=()=>r(o(),"exam-setup.json");export function getExamSetup(){try{return t(u())?JSON.parse(e(u(),"utf-8")):null}catch{return null}}export function saveExamSetup(t){try{n(u(),JSON.stringify(t,null,2))}catch{}}export function isExamSetupComplete(){return null!==getExamSetup()}export function examIdSkipsSetupGate(t){return!!t&&(t.endsWith("-c")||t.startsWith("simoc-"))}