icoa-cli 2.19.356 → 2.19.357

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 (75) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +787 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1502 -1
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +59 -1
  8. package/dist/commands/lang.js +202 -1
  9. package/dist/commands/log.js +171 -1
  10. package/dist/commands/shell.js +151 -1
  11. package/dist/commands/sim.js +389 -1
  12. package/dist/index.js +355 -1
  13. package/dist/lib/access.js +184 -1
  14. package/dist/lib/aienv.js +205 -1
  15. package/dist/lib/arena-submit.js +21 -1
  16. package/dist/lib/banner.js +31 -1
  17. package/dist/lib/budget.js +6 -1
  18. package/dist/lib/challenge-dir.js +16 -1
  19. package/dist/lib/colors.js +17 -1
  20. package/dist/lib/comms.js +212 -1
  21. package/dist/lib/config.js +93 -1
  22. package/dist/lib/countdown.js +43 -1
  23. package/dist/lib/country-lang.js +39 -1
  24. package/dist/lib/ctfd-client.js +417 -1
  25. package/dist/lib/demo-exam.js +478 -1
  26. package/dist/lib/demo-flags.js +27 -1
  27. package/dist/lib/demo-stats.js +62 -1
  28. package/dist/lib/demo2-progress.js +102 -1
  29. package/dist/lib/docker-probe.d.ts +45 -0
  30. package/dist/lib/docker-probe.js +118 -0
  31. package/dist/lib/editor-spawn.js +53 -1
  32. package/dist/lib/exam-client.js +54 -1
  33. package/dist/lib/exam-sandbox.js +201 -1
  34. package/dist/lib/exam-setup.js +36 -1
  35. package/dist/lib/exam-state.js +273 -1
  36. package/dist/lib/gemini.js +247 -1
  37. package/dist/lib/i18n.js +302 -1
  38. package/dist/lib/integrity-snapshot.js +88 -1
  39. package/dist/lib/interactive-spawn.js +55 -1
  40. package/dist/lib/ipynb-input.js +65 -1
  41. package/dist/lib/kernel-protocol.js +88 -1
  42. package/dist/lib/kernel.js +146 -2
  43. package/dist/lib/learn-curricula.js +309 -1
  44. package/dist/lib/learn-i18n.js +184 -1
  45. package/dist/lib/learn-input.js +101 -1
  46. package/dist/lib/learn-render.js +863 -1
  47. package/dist/lib/learn-state.js +103 -1
  48. package/dist/lib/log-sync.js +155 -1
  49. package/dist/lib/logger.js +49 -1
  50. package/dist/lib/main-rl.js +7 -1
  51. package/dist/lib/menu-nav.js +105 -1
  52. package/dist/lib/notebook-doc.js +137 -1
  53. package/dist/lib/open-file.js +55 -1
  54. package/dist/lib/paper-upgrade.js +119 -1
  55. package/dist/lib/platform.js +99 -1
  56. package/dist/lib/render-card.js +112 -1
  57. package/dist/lib/repl-asker.js +67 -1
  58. package/dist/lib/sample-runner.js +227 -1
  59. package/dist/lib/sandbox.d.ts +7 -1
  60. package/dist/lib/sandbox.js +144 -1
  61. package/dist/lib/shell-split.js +69 -1
  62. package/dist/lib/sim-cooldown.js +75 -1
  63. package/dist/lib/theme.js +119 -1
  64. package/dist/lib/token-format.js +74 -1
  65. package/dist/lib/tool-man.js +418 -1
  66. package/dist/lib/toolset-hash.js +48 -1
  67. package/dist/lib/translation.js +80 -1
  68. package/dist/lib/translations-fetcher.js +95 -1
  69. package/dist/lib/ui.js +99 -1
  70. package/dist/lib/update-check.js +114 -1
  71. package/dist/lib/version.js +24 -1
  72. package/dist/postinstall.js +48 -1
  73. package/dist/repl.js +2391 -1
  74. package/dist/types/index.js +63 -1
  75. package/package.json +1 -1
@@ -1 +1,273 @@
1
- import{readFileSync as t,writeFileSync as e,existsSync as n,unlinkSync as a,mkdirSync as r,chmodSync as o}from"node:fs";import{execFileSync as i}from"node:child_process";import{join as s}from"node:path";import{homedir as c}from"node:os";import{getIcoaDir as u}from"./config.js";export function practicalChatCommand(t,e){return e?"ctf4ai"===t?"ctf4ai":"ai4ctf"===t?"ai4ctf":"vla"===t?"ctf4eai":null:null}export function materializeAttachedData(t,a){if(!a)return null;let o;try{const i=getRealExamState(),u=i?.session?.workspaceDir,f=u&&n(u)?u:s(c(),"icoa-workspace");r(f,{recursive:!0}),o=s(f,`q${t}.txt`),e(o,a,"utf-8")}catch{return null}try{return i("docker",["cp",o,`icoa-sandbox:/home/competitor/challenges/q${t}.txt`],{stdio:"ignore",timeout:3e3}),`challenges/q${t}.txt`}catch{return o}}function f(){return s(u(),"exam-state.json")}function l(){return s(u(),"demo-state.json")}function m(t){return"demo-free"===t?l():f()}export function getRealExamState(){const e=f();if(!n(e))return null;try{const n=JSON.parse(t(e,"utf-8"));if("demo-free"===n?.session?.examId){try{a(e)}catch{}return null}return n}catch{return null}}export function getDemoState(){const e=l();if(!n(e))return null;try{return JSON.parse(t(e,"utf-8"))}catch{return null}}export function getExamState(){return getRealExamState()||getDemoState()}export function saveExamState(t){const n=m(t.session.examId);e(n,JSON.stringify(t,null,2));try{o(n,384)}catch{}}export function clearExamState(t){if(t){const e=m(t);n(e)&&a(e)}else{const t=getExamState();if(t){const e=m(t.session.examId);n(e)&&a(e)}}}function d(t){if(!t.session.durationMinutes)return null;const{deadlineServerMs:e,clockOffsetMs:n}=t.session;if("number"==typeof e&&"number"==typeof n)return new Date(e-n);const a=t.session.confirmedAt||t.session.startedAt,r=new Date(a).getTime();return new Date(r+60*t.session.durationMinutes*1e3)}export function getExamDeadline(){const t=getExamState();return t?d(t):null}export function getRealExamDeadline(){const t=getRealExamState();return t?d(t):null}let p=!1;export async function refetchQuestionDataIfStale(){const t=getRealExamState();if(!t)return null;if(p)return t;const e=t.session.token;if(!e)return t;if(!t.questions.some(t=>{if("ai4ctf"!==t.type&&"ctf4ai"!==t.type)return!1;const e=String(t.description||"");return(e.includes("attachedData")||/base64 blob/i.test(e))&&!t.attachedData}))return t;p=!0;try{const{getConfig:n}=await import("./config.js"),a=n(),r=a.ctfdUrl||"https://practice.icoa2026.au",o=a.language||"en",i=await fetch(`${r}/api/icoa/exam-token`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,deviceHash:"",lang:o}),signal:AbortSignal.timeout(8e3)});if(!i.ok)return t;const s=await i.json(),c=s?.data?.questions||[];if(0===c.length)return t;const u=new Map;for(const t of c)u.set(t.number,t);let f=0;for(const e of t.questions){const t=u.get(e.number);t&&(t.attachedData&&!e.attachedData&&(e.attachedData=t.attachedData,f++),t.description&&(!e.description||t.description.length>e.description.length)&&(e.description=t.description))}return f>0&&saveExamState(t),t}catch{return t}}
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { getIcoaDir } from './config.js';
6
+ /**
7
+ * Write a practical question's attachedData blob to the contestant's
8
+ * workspace so they can use shell / python / editor tooling on it without
9
+ * pasting the whole base64 back in on the command line.
10
+ *
11
+ * Because `!shell` / `!python3` inside the REPL runs in the sandbox
12
+ * container (which only mounts its own challenges volume, not the host's
13
+ * workspace), we also mirror the file into the sandbox at
14
+ * /home/competitor/challenges/q<n>.txt via `docker cp` when the sandbox
15
+ * is running. Returns the path students should reference —
16
+ * sandbox-visible when the sandbox is up, else host — so the
17
+ * "Ways to solve" block points at a file that actually exists wherever
18
+ * `!command` executes.
19
+ *
20
+ * Silent on failure: null means no workspace file was materialized, and
21
+ * callers skip the solve-paths guidance.
22
+ */
23
+ /**
24
+ * Map a practical question's TYPE to the AI chat command a contestant should
25
+ * enter for it. The practical section is interleaved (standard paper:
26
+ * Q38 ctf4ai, Q39 ai4ctf, Q40 ctf4ai; Paper E: Q37-46 ai4ctf, Q47-48 ctf4ai),
27
+ * so routing by question NUMBER misroutes questions to the wrong engine — the
28
+ * root cause behind both the old printSectionIntro bug and the practical
29
+ * answer-nudge in repl.ts. Always route by type.
30
+ *
31
+ * Returns null for MCQ / short-answer / unknown types, and (when not a real
32
+ * exam, i.e. the demo) since the demo has no live AI chat sections.
33
+ */
34
+ export function practicalChatCommand(qType, isReal) {
35
+ if (!isReal)
36
+ return null;
37
+ if (qType === 'ctf4ai')
38
+ return 'ctf4ai';
39
+ if (qType === 'ai4ctf')
40
+ return 'ai4ctf';
41
+ if (qType === 'vla')
42
+ return 'ctf4eai';
43
+ return null;
44
+ }
45
+ export function materializeAttachedData(qNum, data) {
46
+ if (!data)
47
+ return null;
48
+ let hostPath;
49
+ try {
50
+ // Prefer the active exam's per-exam tmpdir (L1 anti-cheat workspace) so
51
+ // contestants see their question file in the same dir their `!cmd`
52
+ // invocations land in. Falls back to ~/icoa-workspace pre-start.
53
+ const state = getRealExamState();
54
+ const examWorkspace = state?.session?.workspaceDir;
55
+ const workspace = examWorkspace && existsSync(examWorkspace) ? examWorkspace : join(homedir(), 'icoa-workspace');
56
+ mkdirSync(workspace, { recursive: true });
57
+ hostPath = join(workspace, `q${qNum}.txt`);
58
+ writeFileSync(hostPath, data, 'utf-8');
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ // If the sandbox container is up, copy the file inside so `!command`
64
+ // invocations can reach it at a stable path. Uses execFileSync (no
65
+ // shell) because qNum is numeric and hostPath is trusted, but we still
66
+ // avoid string concatenation into a shell. A no-op when the container
67
+ // isn't running — we just swallow any error and fall back to host.
68
+ try {
69
+ execFileSync('docker', ['cp', hostPath, `icoa-sandbox:/home/competitor/challenges/q${qNum}.txt`], {
70
+ stdio: 'ignore',
71
+ timeout: 3000,
72
+ });
73
+ return `challenges/q${qNum}.txt`;
74
+ }
75
+ catch {
76
+ return hostPath;
77
+ }
78
+ }
79
+ // Demo and real exam use separate state files so they don't block each other
80
+ function stateFile() {
81
+ return join(getIcoaDir(), 'exam-state.json');
82
+ }
83
+ function demoStateFile() {
84
+ return join(getIcoaDir(), 'demo-state.json');
85
+ }
86
+ // Internal: pick the right file based on examId
87
+ function resolveStateFile(examId) {
88
+ return examId === 'demo-free' ? demoStateFile() : stateFile();
89
+ }
90
+ export function getRealExamState() {
91
+ const f = stateFile();
92
+ if (!existsSync(f))
93
+ return null;
94
+ try {
95
+ const state = JSON.parse(readFileSync(f, 'utf-8'));
96
+ // Pre-v2.19.45 versions stored demo state in exam-state.json with
97
+ // examId='demo-free'. After v2.19.45 demo moved to demo-state.json.
98
+ // Ignore stale demo-tagged content found in the real-exam file —
99
+ // it's contamination from an old install. Also auto-clean the file.
100
+ if (state?.session?.examId === 'demo-free') {
101
+ try {
102
+ unlinkSync(f);
103
+ }
104
+ catch { }
105
+ return null;
106
+ }
107
+ return state;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ export function getDemoState() {
114
+ const f = demoStateFile();
115
+ if (!existsSync(f))
116
+ return null;
117
+ try {
118
+ return JSON.parse(readFileSync(f, 'utf-8'));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ export function getExamState() {
125
+ // Real exam ALWAYS takes priority over demo when both exist.
126
+ // Reasoning: real exam is token-gated, has a timer, and represents a serious
127
+ // commitment. Demo is casual practice. If a real exam is in progress, all
128
+ // shared commands (exam q N, exam answer, ai4ctf, ctf4ai) must target the
129
+ // real exam — never silently fall back to demo questions, even if demo was
130
+ // run more recently. (Demo→Exam→Finals progression principle, exam.md §0)
131
+ const real = getRealExamState();
132
+ if (real)
133
+ return real;
134
+ return getDemoState();
135
+ }
136
+ export function saveExamState(state) {
137
+ const f = resolveStateFile(state.session.examId);
138
+ writeFileSync(f, JSON.stringify(state, null, 2));
139
+ // V16 (v2.19.181): exam-state.json holds session.token in plaintext.
140
+ // 0o600 so other users on shared hosts can't read it.
141
+ try {
142
+ chmodSync(f, 0o600);
143
+ }
144
+ catch {
145
+ /* ignore Windows / restricted FS */
146
+ }
147
+ }
148
+ export function clearExamState(examId) {
149
+ if (examId) {
150
+ const f = resolveStateFile(examId);
151
+ if (existsSync(f))
152
+ unlinkSync(f);
153
+ }
154
+ else {
155
+ // Clear the currently active exam
156
+ const state = getExamState();
157
+ if (state) {
158
+ const f = resolveStateFile(state.session.examId);
159
+ if (existsSync(f))
160
+ unlinkSync(f);
161
+ }
162
+ }
163
+ }
164
+ /** Pure: compute the deadline for a given exam state, or null if the
165
+ * session has no time limit. Shared by getExamDeadline (priority-aware)
166
+ * and getRealExamDeadline (real-exam file only). */
167
+ function computeDeadline(state) {
168
+ if (!state.session.durationMinutes)
169
+ return null; // 0 = no time limit
170
+ // Preferred path: server-time sync succeeded at exam start. deadlineServerMs
171
+ // is the authoritative server-time moment of expiry. Convert back to local
172
+ // clock domain so existing callers (which compare against Date.now()) stay
173
+ // correct even if the local clock drifts from server.
174
+ const { deadlineServerMs, clockOffsetMs } = state.session;
175
+ if (typeof deadlineServerMs === 'number' && typeof clockOffsetMs === 'number') {
176
+ return new Date(deadlineServerMs - clockOffsetMs);
177
+ }
178
+ // Fallback: local-clock-only (pre-v2.19.85 behavior). Accurate as long as
179
+ // client and server clocks agree; off by the clock skew otherwise.
180
+ const startTime = state.session.confirmedAt || state.session.startedAt;
181
+ const start = new Date(startTime).getTime();
182
+ return new Date(start + state.session.durationMinutes * 60 * 1000);
183
+ }
184
+ export function getExamDeadline() {
185
+ const state = getExamState();
186
+ return state ? computeDeadline(state) : null;
187
+ }
188
+ /** Deadline read from the real-exam state file specifically, bypassing
189
+ * getExamState()'s real-over-demo priority. Used by `exam demo` to
190
+ * detect a parked expired real exam that would otherwise block demo. */
191
+ export function getRealExamDeadline() {
192
+ const state = getRealExamState();
193
+ return state ? computeDeadline(state) : null;
194
+ }
195
+ /**
196
+ * Back-fill missing attachedData / description on practical questions by
197
+ * re-fetching from the server. Older state files (saved before v2.19.104)
198
+ * may have questions whose attachedData was dropped or never stored, leaving
199
+ * the contestant stuck on Q31-37 ("where is the attached file?"). This runs
200
+ * at most once per REPL session and is a no-op if:
201
+ * - no session token is stored (can't authenticate the refetch)
202
+ * - all practical questions already have attachedData
203
+ * - the network is unreachable
204
+ * Returns the (possibly updated) state so callers can use it directly.
205
+ */
206
+ let _resyncAttempted = false;
207
+ export async function refetchQuestionDataIfStale() {
208
+ const state = getRealExamState();
209
+ if (!state)
210
+ return null;
211
+ if (_resyncAttempted)
212
+ return state;
213
+ const token = state.session.token;
214
+ if (!token)
215
+ return state;
216
+ // Only refetch if at least one practical question references attachedData
217
+ // but doesn't have it locally — otherwise we'd burn a server round-trip
218
+ // on every ai4ctf entry.
219
+ const needsRefetch = state.questions.some((q) => {
220
+ const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai';
221
+ if (!isPractical)
222
+ return false;
223
+ const desc = String(q.description || '');
224
+ const mentionsAttached = desc.includes('attachedData') || /base64 blob/i.test(desc);
225
+ return mentionsAttached && !q.attachedData;
226
+ });
227
+ if (!needsRefetch)
228
+ return state;
229
+ _resyncAttempted = true;
230
+ try {
231
+ const { getConfig } = await import('./config.js');
232
+ const config = getConfig();
233
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
234
+ const lang = config.language || 'en';
235
+ const res = await fetch(`${serverUrl}/api/icoa/exam-token`, {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ token, deviceHash: '', lang }),
239
+ signal: AbortSignal.timeout(8000),
240
+ });
241
+ if (!res.ok)
242
+ return state;
243
+ const json = (await res.json());
244
+ const fresh = json?.data?.questions || [];
245
+ if (fresh.length === 0)
246
+ return state;
247
+ // Merge: for every local question, if the fresh copy has attachedData or
248
+ // a longer description, copy those fields in. Keep local answer state /
249
+ // eliminated options etc. untouched — we only patch read-only content.
250
+ const byNum = new Map();
251
+ for (const fq of fresh)
252
+ byNum.set(fq.number, fq);
253
+ let patched = 0;
254
+ for (const q of state.questions) {
255
+ const fq = byNum.get(q.number);
256
+ if (!fq)
257
+ continue;
258
+ if (fq.attachedData && !q.attachedData) {
259
+ q.attachedData = fq.attachedData;
260
+ patched++;
261
+ }
262
+ if (fq.description && (!q.description || fq.description.length > q.description.length)) {
263
+ q.description = fq.description;
264
+ }
265
+ }
266
+ if (patched > 0)
267
+ saveExamState(state);
268
+ return state;
269
+ }
270
+ catch {
271
+ return state;
272
+ }
273
+ }
@@ -1 +1,247 @@
1
- import{GoogleGenAI as e}from"@google/genai";import chalk from"chalk";import{readFileSync as t}from"node:fs";import{dirname as n,join as o}from"node:path";import{fileURLToPath as a}from"node:url";import{getConfig as r,saveConfig as s}from"./config.js";import{getRealExamState as i}from"./exam-state.js";const c=n(a(import.meta.url));let l=null;function u(){if(l)return l;try{const e=JSON.parse(t(o(c,"..","..","package.json"),"utf-8"));l=e.version||"unknown"}catch{l="unknown"}return l}export async function translateText(e,t){throw new Error("Local AI model path has been disabled (2026-04-24). ICOA provides all AI server-side — no API key setup needed. Use `ai4ctf` (chat) or `exam` (with integrated help/hint) instead.")}export function setApiKey(e){s({geminiApiKey:e})}export async function createChatSession(e,t){const n=r();let o=t||"You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).\n\nYou're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.\n\nRULES:\n- Help the competitor think through challenges, brainstorm approaches, explain concepts\n- You MAY discuss vulnerability types, tools, techniques, and methodologies\n- You MAY suggest approaches and help debug code\n- Do NOT provide complete working exploits or full solution scripts\n- Do NOT provide flags or flag fragments\n- Never output anything matching flag format: icoa{...}\n- If you don't know something, say so honestly\n- Keep responses concise unless the user asks for detail\n- When the user opens a challenge, use the context to give relevant advice";e&&(o+=`\n\nThe competitor is currently working on:\nChallenge: ${e.name}\nCategory: ${e.category}`),o+='\n\nINTEGRITY — give method, not the produced answer:\n- You may name the tool/command/technique (e.g. "this is base64; run base64 -d on the file").\n- Do NOT perform a solution step for the competitor and hand back its result: never decode,\n decrypt, deobfuscate, run, execute, or compute over data they paste or that is attached,\n and then reveal the produced value. They must run it themselves and read the output.\n- Do NOT reveal the exact final answer, and never output icoa{...} / flag{...} in any case.\n- If asked to "just give the answer" / "solve it" / "decode this and tell me", refuse and\n nudge them to do the step themselves.';const a=n.ctfdUrl||"https://practice.icoa2026.au",s=n.deviceFingerprint||"",c=n.geminiModel||"gemini-2.5-flash-lite",l=[];return{async sendMessage(e){l.push({role:"user",text:e});const t=i(),r={systemPrompt:o,messages:l,model:c,maxTokens:2048,deviceFingerprint:s};t?.session?.token&&(r.examToken=t.session.token);const p={"Content-Type":"application/json","User-Agent":`icoa-cli/${u()}`,"X-Device-Fingerprint":s};!r.examToken&&n.ctfdUrl&&n.token&&n.userName&&(r.account=n.userName,p.Authorization=`Token ${n.token}`);const d=e=>new Promise(t=>setTimeout(t,e)),h=[2e3,5e3];let m=null,g=null;for(let e=0;e<=h.length&&(m=await fetch(`${a}/api/icoa/ai/chat`,{method:"POST",headers:p,body:JSON.stringify(r),signal:AbortSignal.timeout(6e4)}),!m.ok&&429===m.status);e++)e<h.length?await d(h[e]):g=m;if(!m)throw new Error("AI proxy: no response");if(!m.ok){const e=(await m.json().catch(()=>({message:"AI proxy error"}))).message||`AI proxy returned ${m.status}`;if(401===m.status)throw new Error(`${chalk.yellow("⚠ ")}Exam token expired. Re-enter via \`exam <token>\`.`);if(403===m.status)throw new Error(chalk.yellow("⚠ ")+e);if(429===m.status)throw new Error(chalk.yellow("⏳ ")+e+chalk.gray(" (retried twice — quota burst, try again in ~30s)"));throw new Error(e)}const f=await m.json(),y=function(e){return e.replace(/icoa\{[^}]*\}/gi,"[FLAG REDACTED]")}(f.data?.text||""),w=f.data?.tokensUsed||0,v="number"==typeof f.data?.accountSpent?f.data.accountSpent:void 0,k="number"==typeof f.data?.accountCap?f.data.accountCap:void 0;return l.push({role:"model",text:y}),{text:y,tokensUsed:w,accountSpent:v,accountCap:k}}}}
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import chalk from 'chalk';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getConfig, saveConfig } from './config.js';
7
+ import { getRealExamState } from './exam-state.js';
8
+ const __dirname_gemini = dirname(fileURLToPath(import.meta.url));
9
+ let _cachedVersion = null;
10
+ function getCliVersion() {
11
+ if (_cachedVersion)
12
+ return _cachedVersion;
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(join(__dirname_gemini, '..', '..', 'package.json'), 'utf-8'));
15
+ _cachedVersion = pkg.version || 'unknown';
16
+ }
17
+ catch {
18
+ _cachedVersion = 'unknown';
19
+ }
20
+ return _cachedVersion;
21
+ }
22
+ const SYSTEM_PROMPTS = {
23
+ A: `You are an AI assistant in a cybersecurity CTF competition called ICOA.
24
+ You are providing Level A (General Guidance) to a competitor.
25
+
26
+ STRICT RULES:
27
+ - Only answer conceptual questions
28
+ - Do NOT mention specific vulnerability names
29
+ - Do NOT provide any code, commands, or tool usage
30
+ - Do NOT mention specific attack techniques
31
+ - Use questions to guide the competitor toward their own discovery
32
+ - If the competitor asks you to solve the challenge, refuse and redirect
33
+ - Never output anything matching flag format: icoa{...}`,
34
+ B: `You are an AI assistant in ICOA CTF, providing Level B (Deep Analysis).
35
+
36
+ RULES:
37
+ - You MAY identify specific vulnerability types (e.g., "buffer overflow")
38
+ - You MAY suggest which category of tool to use (e.g., "a debugger")
39
+ - Do NOT provide complete commands or working code
40
+ - Do NOT provide exploit code or payloads
41
+ - Do NOT provide flags or flag fragments
42
+ - Never output anything matching: icoa{...}`,
43
+ C: `You are an AI assistant in ICOA CTF, providing Level C (Critical Assist).
44
+
45
+ RULES:
46
+ - You MAY provide the key conceptual breakthrough
47
+ - You MAY name specific algorithms or approaches
48
+ - Do NOT provide complete exploit code
49
+ - Do NOT provide the flag
50
+ - Never output anything matching: icoa{...}`,
51
+ };
52
+ function _buildSystemPrompt(level, context) {
53
+ let prompt = SYSTEM_PROMPTS[level];
54
+ if (context) {
55
+ prompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
56
+ }
57
+ prompt += METHOD_NOT_ANSWER_RULE;
58
+ return prompt;
59
+ }
60
+ function filterFlagPatterns(text) {
61
+ return text.replace(/icoa\{[^}]*\}/gi, '[FLAG REDACTED]');
62
+ }
63
+ // No DEFAULT_API_KEY in client code — keys stay server-side only.
64
+ // 2026-04-24: **Local model path disabled**. All AI routes through the
65
+ // server proxy at /api/icoa/ai/chat regardless of whether the user has
66
+ // their own GEMINI_API_KEY. See reference_ai_models.md / feedback_icoa_all_keys.md.
67
+ // ICOA centrally owns all AI keys; students do not need to configure anything.
68
+ const LOCAL_MODEL_DISABLED_MESSAGE = 'Local AI model path has been disabled (2026-04-24). ' +
69
+ 'ICOA provides all AI server-side — no API key setup needed. ' +
70
+ 'Use `ai4ctf` (chat) or `exam` (with integrated help/hint) instead.';
71
+ function getApiKey() {
72
+ // Returns empty string to disable Path A in createChatSession / generateHint.
73
+ // Preserved as a function to keep the call sites stable for future refactors.
74
+ return '';
75
+ }
76
+ function getClient(apiKey) {
77
+ return new GoogleGenAI({ apiKey });
78
+ }
79
+ /**
80
+ * @deprecated Runtime translation is disabled. Translations are pre-baked
81
+ * at build time via `panda/translate-*.js` scripts and shipped in
82
+ * `translations/<lang>/` — CLI never translates at runtime.
83
+ */
84
+ export async function translateText(_text, _targetLang) {
85
+ throw new Error(LOCAL_MODEL_DISABLED_MESSAGE);
86
+ }
87
+ export function setApiKey(key) {
88
+ saveConfig({ geminiApiKey: key });
89
+ }
90
+ const CHAT_SYSTEM_PROMPT = `You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).
91
+
92
+ You're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.
93
+
94
+ RULES:
95
+ - Help the competitor think through challenges, brainstorm approaches, explain concepts
96
+ - You MAY discuss vulnerability types, tools, techniques, and methodologies
97
+ - You MAY suggest approaches and help debug code
98
+ - Do NOT provide complete working exploits or full solution scripts
99
+ - Do NOT provide flags or flag fragments
100
+ - Never output anything matching flag format: icoa{...}
101
+ - If you don't know something, say so honestly
102
+ - Keep responses concise unless the user asks for detail
103
+ - When the user opens a challenge, use the context to give relevant advice`;
104
+ // Shared "method, not answer" rule appended to every system prompt. The
105
+ // server (token-api.py) enforces the authoritative copy of these rules so a
106
+ // modified/curl client can't strip them; this client copy keeps the stock CLI
107
+ // well-behaved without a server round-trip. Phrased to allow naming the
108
+ // command but forbid performing the step and returning its result.
109
+ const METHOD_NOT_ANSWER_RULE = `
110
+
111
+ INTEGRITY — give method, not the produced answer:
112
+ - You may name the tool/command/technique (e.g. "this is base64; run base64 -d on the file").
113
+ - Do NOT perform a solution step for the competitor and hand back its result: never decode,
114
+ decrypt, deobfuscate, run, execute, or compute over data they paste or that is attached,
115
+ and then reveal the produced value. They must run it themselves and read the output.
116
+ - Do NOT reveal the exact final answer, and never output icoa{...} / flag{...} in any case.
117
+ - If asked to "just give the answer" / "solve it" / "decode this and tell me", refuse and
118
+ nudge them to do the step themselves.`;
119
+ export async function createChatSession(context, customSystemPrompt) {
120
+ const config = getConfig();
121
+ const apiKey = getApiKey();
122
+ let systemPrompt = customSystemPrompt || CHAT_SYSTEM_PROMPT;
123
+ if (context) {
124
+ systemPrompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
125
+ }
126
+ // Always append the integrity rule (covers chat + exam custom prompts).
127
+ systemPrompt += METHOD_NOT_ANSWER_RULE;
128
+ // ─── Path A: user has their own key → direct Gemini SDK ───
129
+ if (apiKey) {
130
+ const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
131
+ const ai = getClient(apiKey);
132
+ const chat = ai.chats.create({
133
+ model: modelName,
134
+ config: { systemInstruction: systemPrompt },
135
+ });
136
+ return {
137
+ async sendMessage(msg) {
138
+ const response = await chat.sendMessage({ message: msg });
139
+ const text = filterFlagPatterns(response.text ?? '');
140
+ const usage = response.usageMetadata;
141
+ const tokensUsed = usage?.totalTokenCount || (usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0);
142
+ return { text, tokensUsed };
143
+ },
144
+ };
145
+ }
146
+ // ─── Path B: no key → server proxy (key stays server-side) ───
147
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
148
+ const fp = config.deviceFingerprint || '';
149
+ const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
150
+ const messages = [];
151
+ return {
152
+ async sendMessage(msg) {
153
+ messages.push({ role: 'user', text: msg });
154
+ // Attach exam token if contestant is in a real exam — grants full
155
+ // 2048 maxTokens and higher rate limit. Demo/anonymous users rely on
156
+ // the User-Agent gate on the server side.
157
+ const realExam = getRealExamState();
158
+ const payload = {
159
+ systemPrompt,
160
+ messages,
161
+ model: modelName,
162
+ maxTokens: 2048,
163
+ deviceFingerprint: fp,
164
+ };
165
+ if (realExam?.session?.token) {
166
+ payload.examToken = realExam.session.token;
167
+ }
168
+ // CTFd-join account binding (2026-06-17, AU-camp audit). When connected
169
+ // to a CTFd event (no exam token), attribute AI usage to the logged-in
170
+ // account so the server can (a) audit per-student and (b) meter a
171
+ // per-account budget — a shared-NAT room can no longer pool unlimited
172
+ // AI. Ignored server-side when an exam token is present (exam-token
173
+ // budget wins); demo/practice has no CTFd login → no account → stays on
174
+ // the client 5k cap.
175
+ //
176
+ // 2026-06-20 (Olympic audit upgrade): also send the CTFd API token in an
177
+ // Authorization header so the server can VERIFY the account against CTFd's
178
+ // own tokens→users tables instead of trusting the self-reported username
179
+ // below. The header turns attribution from a forgeable signal into
180
+ // evidence (account_verified=1 server-side). payload.account stays as a
181
+ // fallback for any pre-upgrade server. Mirrors the /api/icoa/audit auth.
182
+ const reqHeaders = {
183
+ 'Content-Type': 'application/json',
184
+ 'User-Agent': `icoa-cli/${getCliVersion()}`,
185
+ 'X-Device-Fingerprint': fp,
186
+ };
187
+ if (!payload.examToken && config.ctfdUrl && config.token && config.userName) {
188
+ payload.account = config.userName;
189
+ reqHeaders.Authorization = `Token ${config.token}`;
190
+ }
191
+ // V2.19.185: 429 retry-with-backoff. UZ Paper A (2026-05-17) had a
192
+ // sustained Gemini upstream quota burst that caused 45 client-visible
193
+ // 429s. Server-side key rotation eventually recovers, so a short
194
+ // backoff before retrying transparently absorbs most transient dips
195
+ // without the contestant ever seeing an error. We retry up to twice
196
+ // with 2s and 5s backoff (max ~7s added latency on a worst-case path).
197
+ // 401/403 are surfaced immediately — they need contestant action.
198
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
199
+ const BACKOFFS_MS = [2000, 5000];
200
+ let res = null;
201
+ let lastErr = null;
202
+ for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
203
+ res = await fetch(`${serverUrl}/api/icoa/ai/chat`, {
204
+ method: 'POST',
205
+ headers: reqHeaders,
206
+ body: JSON.stringify(payload),
207
+ signal: AbortSignal.timeout(60_000),
208
+ });
209
+ if (res.ok || res.status !== 429)
210
+ break;
211
+ // 429 — try again after backoff if we have budget left
212
+ if (attempt < BACKOFFS_MS.length) {
213
+ await sleepMs(BACKOFFS_MS[attempt]);
214
+ continue;
215
+ }
216
+ // Out of retries — fall through to error handling below
217
+ lastErr = res;
218
+ }
219
+ if (!res)
220
+ throw new Error('AI proxy: no response');
221
+ if (!res.ok) {
222
+ const err = (await res.json().catch(() => ({ message: 'AI proxy error' })));
223
+ const msg = err.message || `AI proxy returned ${res.status}`;
224
+ if (res.status === 401) {
225
+ throw new Error(`${chalk.yellow('⚠ ')}Exam token expired. Re-enter via \`exam <token>\`.`);
226
+ }
227
+ if (res.status === 403) {
228
+ throw new Error(chalk.yellow('⚠ ') + msg);
229
+ }
230
+ if (res.status === 429) {
231
+ throw new Error(chalk.yellow('⏳ ') + msg + chalk.gray(' (retried twice — quota burst, try again in ~30s)'));
232
+ }
233
+ throw new Error(msg);
234
+ }
235
+ const json = (await res.json());
236
+ const text = filterFlagPatterns(json.data?.text || '');
237
+ const tokensUsed = json.data?.tokensUsed || 0;
238
+ // Server-authoritative cumulative per-account spend (CTFd-join comp mode).
239
+ // Present only when the server metered a verified account; the client uses
240
+ // it to render a TRUE budget bar instead of a per-session counter.
241
+ const accountSpent = typeof json.data?.accountSpent === 'number' ? json.data.accountSpent : undefined;
242
+ const accountCap = typeof json.data?.accountCap === 'number' ? json.data.accountCap : undefined;
243
+ messages.push({ role: 'model', text });
244
+ return { text, tokensUsed, accountSpent, accountCap };
245
+ },
246
+ };
247
+ }