icoa-cli 2.19.58 → 2.19.60

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.
@@ -300,6 +300,33 @@ export function registerAi4ctfCommand(program) {
300
300
  .description('Chat with your AI teammate')
301
301
  .action(async () => {
302
302
  logCommand('ai4ctf');
303
+ // Block mid-exam: ai4ctf starts a scripted demo challenge, which would
304
+ // interrupt a real exam session. Redirect to the actual exam AI tool.
305
+ const { getRealExamState } = await import('../lib/exam-state.js');
306
+ const realExam = getRealExamState();
307
+ if (realExam) {
308
+ const currentQ = realExam._lastQ || 1;
309
+ const inAi4ctfRange = currentQ >= 31 && currentQ <= 38;
310
+ console.log();
311
+ console.log(chalk.yellow(' ⚠ You are in a real exam — ai4ctf demo is blocked here.'));
312
+ console.log();
313
+ if (inAi4ctfRange) {
314
+ console.log(chalk.white(` You are on Q${currentQ} (AI4CTF section).`));
315
+ console.log(chalk.white(' To ask the AI about this question:'));
316
+ console.log(chalk.gray(' → ') + chalk.bold.cyan(`hint "your question"`));
317
+ console.log(chalk.gray(' Example: ') + chalk.green(`hint "how do I decrypt AES-CBC in Python?"`));
318
+ }
319
+ else {
320
+ console.log(chalk.white(` You are on Q${currentQ}. The AI4CTF section is Q31–38.`));
321
+ console.log(chalk.gray(' Jump there: ') + chalk.bold.cyan('exam q 31'));
322
+ console.log(chalk.gray(' Then ask the AI with: ') + chalk.white('hint "your question"'));
323
+ }
324
+ console.log();
325
+ console.log(chalk.gray(' ai4ctf as a chat command is demo-only. In the real exam,'));
326
+ console.log(chalk.gray(' `hint` is the AI interface — 25K AI4CTF + 25K CTF4AI tokens.'));
327
+ console.log();
328
+ return;
329
+ }
303
330
  const config = getConfig();
304
331
  const modelName = config.geminiModel || 'gemma-4-31b-it';
305
332
  // Demo challenge context
@@ -170,6 +170,32 @@ export function registerCtf4aiDemoCommand(program) {
170
170
  .description('CTF4AI Demo — Prompt injection challenge')
171
171
  .action(async () => {
172
172
  logCommand('ctf4ai');
173
+ // Block mid-exam: ctf4ai starts a scripted koala demo, would derail
174
+ // a real exam session and burn AI tokens against the wrong budget.
175
+ const { getRealExamState } = await import('../lib/exam-state.js');
176
+ const realExam = getRealExamState();
177
+ if (realExam) {
178
+ const currentQ = realExam._lastQ || 1;
179
+ const inCtf4aiRange = currentQ >= 39;
180
+ console.log();
181
+ console.log(chalk.yellow(' ⚠ You are in a real exam — ctf4ai demo is blocked here.'));
182
+ console.log();
183
+ if (inCtf4aiRange) {
184
+ console.log(chalk.white(` You are on Q${currentQ} (CTF4AI section).`));
185
+ console.log(chalk.white(' The question scenario itself is the AI target — read Q39/Q40'));
186
+ console.log(chalk.white(' carefully. To ask the AI assistant for help:'));
187
+ console.log(chalk.gray(' → ') + chalk.bold.cyan(`hint "your question"`));
188
+ }
189
+ else {
190
+ console.log(chalk.white(` You are on Q${currentQ}. The CTF4AI section is Q39–40.`));
191
+ console.log(chalk.gray(' Jump there: ') + chalk.bold.cyan('exam q 39'));
192
+ }
193
+ console.log();
194
+ console.log(chalk.gray(' ctf4ai as a standalone command is demo-only. In the real exam,'));
195
+ console.log(chalk.gray(' each Q39/Q40 scenario contains its own AI target to attack.'));
196
+ console.log();
197
+ return;
198
+ }
173
199
  if (ctf4aiActive) {
174
200
  console.log(chalk.gray(` ${t('ctf4aiAlready')}`));
175
201
  return;
@@ -326,6 +326,21 @@ function printQuestionProgress(current, total, answered) {
326
326
  const pct = Math.round((current / total) * 100);
327
327
  console.log();
328
328
  console.log(` ${bar} ${chalk.white.bold(`${current}`)}${chalk.gray(`/${total}`)} ${chalk.gray(`(${answered} answered)`)} ${chalk.gray(`${pct}%`)}`);
329
+ // Time remaining — colour-coded countdown so contestants always see where
330
+ // they stand. Authoritative clock lives on the server (confirmedAt +
331
+ // duration enforced at submit time); this is a display helper.
332
+ const deadline = getExamDeadline();
333
+ if (deadline) {
334
+ const remainingSec = Math.max(0, Math.round((deadline.getTime() - Date.now()) / 1000));
335
+ const mm = Math.floor(remainingSec / 60);
336
+ const ss = remainingSec % 60;
337
+ const timeStr = `${mm}:${String(ss).padStart(2, '0')}`;
338
+ const color = remainingSec <= 60 ? chalk.red.bold
339
+ : remainingSec <= 300 ? chalk.red
340
+ : remainingSec <= 600 ? chalk.yellow
341
+ : chalk.gray;
342
+ console.log(` ${chalk.gray('⏱ Time remaining:')} ${color(timeStr)} ${chalk.gray('(server-authoritative)')}`);
343
+ }
329
344
  }
330
345
  // Help budget per exam.md §3: 10 base + 5 hidden bonus (unlocked via `more help`).
331
346
  // Demo still uses the lighter 5 + 3 set via _helpMax overrides at demo start.
@@ -967,12 +982,19 @@ export function registerExamCommand(program) {
967
982
  const isPractical = q.type === 'ai4ctf' || q.type === 'ctf4ai' || (q.options && !q.options.A && !q.options.B);
968
983
  let c;
969
984
  if (isPractical) {
970
- // Accept flag format: ICOA{...} or any string
971
985
  c = choice.trim();
972
986
  if (!c) {
973
987
  printError('Please provide your flag: exam answer <n> ICOA{your_flag}');
974
988
  return;
975
989
  }
990
+ // Reject letter-only answers on practical questions — almost certainly
991
+ // a user who typed `A` thinking MCQ was still in play. Submitting 'A'
992
+ // as the flag is a footgun that would waste an attempt silently.
993
+ if (/^[A-Da-d]$/.test(c)) {
994
+ printError(`Q${num} is a practical question — answer with a flag, not a letter.`);
995
+ console.log(chalk.gray(' Example: ') + chalk.green(`exam answer ${num} ICOA{your_flag}`));
996
+ return;
997
+ }
976
998
  }
977
999
  else {
978
1000
  c = choice.toUpperCase();
package/dist/repl.js CHANGED
@@ -597,14 +597,32 @@ export async function startRepl(program, resumeMode) {
597
597
  return;
598
598
  }
599
599
  // ─── Quick exam answer shortcuts ───
600
- // "A" / "B" / "C" / "D" → answer current question
601
- // "2 C" / "5 A" → answer specific question
600
+ // "A" / "B" / "C" / "D" → answer current question (MCQ only)
601
+ // "2 C" / "5 A" → answer specific question (MCQ only)
602
+ // Practical questions (Q31-40) require flag format (ICOA{...}) — single
603
+ // letters on those would be silently accepted as the wrong flag answer,
604
+ // which is a footgun. Block and nudge the user toward the flag syntax.
602
605
  const examState = getExamState();
603
606
  if (examState) {
604
607
  const upper = input.toUpperCase().trim();
605
- // Single letter: A, B, C, D answer current question
608
+ // Helper: is question N practical (no A/B/C/D options)?
609
+ const isPracticalQ = (n) => {
610
+ const q = examState.questions.find((qq) => qq.number === n);
611
+ if (!q)
612
+ return false;
613
+ return q.type === 'ai4ctf' || q.type === 'ctf4ai' || (q.options && !q.options.A && !q.options.B);
614
+ };
615
+ // Single letter: A, B, C, D → answer current question (only if MCQ)
606
616
  if (/^[ABCD]$/.test(upper)) {
607
617
  const currentQ = examState._lastQ || 1;
618
+ if (isPracticalQ(currentQ)) {
619
+ console.log();
620
+ console.log(chalk.yellow(` Q${currentQ} is a practical question — answer with a flag, not a letter.`));
621
+ console.log(chalk.gray(' Example: ') + chalk.green(`exam answer ${currentQ} ICOA{your_flag}`));
622
+ console.log();
623
+ rl.prompt();
624
+ return;
625
+ }
608
626
  processing = true;
609
627
  try {
610
628
  await program.parseAsync(['node', 'icoa', 'exam', 'answer', String(currentQ), upper]);
@@ -614,9 +632,18 @@ export async function startRepl(program, resumeMode) {
614
632
  rl.prompt();
615
633
  return;
616
634
  }
617
- // "N X" pattern: e.g. "2 C", "15 A"
635
+ // "N X" pattern: e.g. "2 C", "15 A" (MCQ only — same protection)
618
636
  const match = upper.match(/^(\d+)\s+([ABCD])$/);
619
637
  if (match) {
638
+ const targetQ = parseInt(match[1], 10);
639
+ if (isPracticalQ(targetQ)) {
640
+ console.log();
641
+ console.log(chalk.yellow(` Q${targetQ} is a practical question — answer with a flag, not a letter.`));
642
+ console.log(chalk.gray(' Example: ') + chalk.green(`exam answer ${targetQ} ICOA{your_flag}`));
643
+ console.log();
644
+ rl.prompt();
645
+ return;
646
+ }
620
647
  processing = true;
621
648
  try {
622
649
  await program.parseAsync(['node', 'icoa', 'exam', 'answer', match[1], match[2]]);
@@ -667,6 +694,7 @@ export async function startRepl(program, resumeMode) {
667
694
  'hint-budget', 'ref', 'shell', 'files', 'connect', 'note',
668
695
  'log', 'lang', 'setup', 'env', 'ai4ctf', 'model', 'ctf',
669
696
  'exam', 'demo', 'retry', 'nations', 'next', 'prev', 'continue', 'logout', 'ctf4ai',
697
+ 'mark', 'unmark', 'review', 'submit',
670
698
  ];
671
699
  if (!knownCommands.includes(cmd)) {
672
700
  // Block dangerous commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.58",
3
+ "version": "2.19.60",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {