icoa-cli 2.19.61 → 2.19.63

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.
@@ -308,11 +308,18 @@ export async function handleChatMessage(input) {
308
308
  // ═══════════════════════════════════════════════════════════════
309
309
  // Real-exam AI4CTF chat mode (Q31-38)
310
310
  // ═══════════════════════════════════════════════════════════════
311
- function printExamAi4ctfWelcome(q, qNum) {
311
+ function printExamAi4ctfWelcome(q, qNum, existingAnswer) {
312
312
  const config = getConfig();
313
313
  const modelName = config.geminiModel || 'gemma-4-31b-it';
314
+ const isResume = chatTokensUsed > 0;
314
315
  console.log();
315
316
  console.log(chalk.green.bold(` ═══ AI4CTF — Q${qNum}: ${q.category} ═══`));
317
+ if (isResume) {
318
+ console.log(chalk.gray(` (resuming — prior chat is not remembered, but tokens already used stay deducted)`));
319
+ }
320
+ if (existingAnswer) {
321
+ console.log(chalk.gray(` Current answer: `) + chalk.yellow(existingAnswer) + chalk.gray(` (submit again to change)`));
322
+ }
316
323
  console.log();
317
324
  console.log(chalk.cyan(' ┌─────────────────────────────────────────────'));
318
325
  console.log(chalk.cyan(' │ ') + chalk.bold.white(`Q${qNum} [${q.category}] · ${q.points || 6} pts`));
@@ -387,9 +394,27 @@ export async function startExamAi4ctfChat(qNum, question) {
387
394
  chatTokensUsed = usedSoFar;
388
395
  tokensLocked = false;
389
396
  examChatCtx = { qNum, question, usageField: 'ai4ctf' };
390
- printExamAi4ctfWelcome(question, qNum);
397
+ const existingAnswer = state.answers?.[qNum];
398
+ printExamAi4ctfWelcome(question, qNum, existingAnswer);
391
399
  return true;
392
400
  }
401
+ // Usage warnings shown once each when crossing thresholds. Keyed by cap.
402
+ function maybeWarnTokenUsage(used, cap, warnedSet) {
403
+ const pct = (used / cap) * 100;
404
+ if (pct >= 95 && !warnedSet.has('95')) {
405
+ console.log(chalk.red.bold(` ⚠ ${Math.round(pct)}% of section AI budget used — only ~${cap - used} tokens left.`));
406
+ warnedSet.add('95');
407
+ }
408
+ else if (pct >= 80 && !warnedSet.has('80')) {
409
+ console.log(chalk.yellow(` ⚠ 80% of section AI budget used.`));
410
+ warnedSet.add('80');
411
+ }
412
+ else if (pct >= 50 && !warnedSet.has('50')) {
413
+ console.log(chalk.gray(` Note: 50% of section AI budget used.`));
414
+ warnedSet.add('50');
415
+ }
416
+ }
417
+ const _ai4ctfWarned = new Set();
393
418
  async function handleExamAi4ctfMessage(input) {
394
419
  if (!examChatCtx || !chatSession)
395
420
  return 'exit';
@@ -426,6 +451,16 @@ async function handleExamAi4ctfMessage(input) {
426
451
  const submitMatch = trimmed.match(/^submit\s+(.+)/i);
427
452
  if (submitMatch) {
428
453
  const flag = submitMatch[1].trim();
454
+ // Letter-only footgun: reject 'A'/'B'/'C'/'D' as flags. Same defence we
455
+ // added at the REPL and exam-answer layers; duplicated here because chat
456
+ // bypasses both.
457
+ if (/^[A-Da-d]$/.test(flag)) {
458
+ console.log();
459
+ console.log(chalk.yellow(` "${flag}" looks like an MCQ letter, not a flag.`));
460
+ console.log(chalk.gray(' Flag format: ') + chalk.green('ICOA{your_flag}') + chalk.gray('. Try again inside this chat.'));
461
+ console.log();
462
+ return 'continue';
463
+ }
429
464
  const { getExamState, saveExamState } = await import('../lib/exam-state.js');
430
465
  const state = getExamState();
431
466
  if (!state)
@@ -435,7 +470,8 @@ async function handleExamAi4ctfMessage(input) {
435
470
  console.log(chalk.red(` Q${examChatCtx.qNum} not found in state.`));
436
471
  return 'continue';
437
472
  }
438
- // Store answer (validated on server at exam submit)
473
+ // Store answer (validated on server at exam submit). Stay in chat so the
474
+ // contestant can iterate on the flag without having to re-enter.
439
475
  const prevAnswer = state.answers[examChatCtx.qNum];
440
476
  if (!state.interactions)
441
477
  state.interactions = [];
@@ -447,33 +483,30 @@ async function handleExamAi4ctfMessage(input) {
447
483
  result: 'via ai4ctf chat',
448
484
  });
449
485
  state.answers[examChatCtx.qNum] = flag;
450
- state._lastQ = examChatCtx.qNum;
486
+ // Advance _lastQ to the next question in the section so that a subsequent
487
+ // `ai4ctf` (after exit) picks up Q+1 automatically, no `exam q N` needed.
488
+ const nextQ = examChatCtx.qNum + 1;
489
+ state._lastQ = nextQ <= 38 ? nextQ : examChatCtx.qNum;
451
490
  saveExamState(state);
452
491
  console.log();
453
- console.log(chalk.green.bold(` ✓ Answer for Q${examChatCtx.qNum} recorded: ${flag}`));
492
+ if (prevAnswer) {
493
+ console.log(chalk.green(` ✓ Q${examChatCtx.qNum} answer updated: `) + chalk.yellow(flag));
494
+ console.log(chalk.gray(` Previous: ${prevAnswer}`));
495
+ }
496
+ else {
497
+ console.log(chalk.green.bold(` ✓ Answer for Q${examChatCtx.qNum} recorded: ${flag}`));
498
+ }
454
499
  console.log(chalk.gray(' (Grading happens at exam submit — you cannot preview correctness during the exam.)'));
455
500
  console.log();
456
- // Auto-navigate to next unanswered AI4CTF question
457
- const nextQ = examChatCtx.qNum + 1;
458
- const savedQ = examChatCtx.qNum;
459
- chatActive = false;
460
- chatSession = null;
461
- examChatCtx = null;
501
+ console.log(chalk.gray(' ') + chalk.white('submit ICOA{...}') + chalk.gray(' again to change, or ') + chalk.white('exit') + chalk.gray(' to move on'));
462
502
  if (nextQ <= 38) {
463
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
464
- console.log(chalk.white(' Next: ') + chalk.bold.green(`Q${nextQ}`));
465
- console.log(chalk.gray(' → ') + chalk.bold.cyan(`exam q ${nextQ}`) + chalk.gray(' jump to question'));
466
- console.log(chalk.gray(' → ') + chalk.bold.cyan('ai4ctf') + chalk.gray(' start AI chat for Q') + String(nextQ));
467
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
503
+ console.log(chalk.gray(' After exit: ') + chalk.cyan('ai4ctf') + chalk.gray(` will open Q${nextQ}`));
468
504
  }
469
505
  else {
470
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
471
- console.log(chalk.bold.white(' AI4CTF section complete — Q39 begins CTF4AI.'));
472
- console.log(chalk.gray(' → ') + chalk.bold.red('ctf4ai') + chalk.gray(' start CTF4AI for Q39'));
473
- console.log(chalk.cyan(' ─────────────────────────────────────────────'));
506
+ console.log(chalk.gray(' After exit: AI4CTF section complete. Next: ') + chalk.red('ctf4ai') + chalk.gray(' on Q39'));
474
507
  }
475
508
  console.log();
476
- return 'exit';
509
+ return 'continue';
477
510
  }
478
511
  // Shell command
479
512
  if (input.startsWith('!')) {
@@ -497,13 +530,26 @@ async function handleExamAi4ctfMessage(input) {
497
530
  }
498
531
  // Exit chat
499
532
  if (lower === 'exit' || lower === 'back' || lower === 'quit') {
533
+ const savedQ = examChatCtx.qNum;
534
+ const { getExamState } = await import('../lib/exam-state.js');
535
+ const exitState = getExamState();
536
+ const hasAnswer = exitState && exitState.answers[savedQ] != null;
537
+ const lastQ = (exitState?._lastQ) || savedQ;
500
538
  chatActive = false;
501
539
  chatSession = null;
502
- const savedQ = examChatCtx.qNum;
503
540
  examChatCtx = null;
541
+ _ai4ctfWarned.clear();
504
542
  console.log();
505
543
  console.log(chalk.gray(` AI4CTF chat ended for Q${savedQ}.`));
506
- console.log(chalk.gray(' Resume answering: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter chat: ') + chalk.white('ai4ctf'));
544
+ if (hasAnswer && lastQ > savedQ && lastQ <= 38) {
545
+ console.log(chalk.white(' Next: ') + chalk.bold.green(`Q${lastQ}`) + chalk.gray(' — type ') + chalk.cyan('ai4ctf') + chalk.gray(' to continue'));
546
+ }
547
+ else if (hasAnswer && savedQ >= 38) {
548
+ console.log(chalk.white(' AI4CTF section complete — ') + chalk.bold.red('ctf4ai') + chalk.gray(' for Q39'));
549
+ }
550
+ else {
551
+ console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ai4ctf'));
552
+ }
507
553
  console.log();
508
554
  return 'exit';
509
555
  }
@@ -542,6 +588,7 @@ async function handleExamAi4ctfMessage(input) {
542
588
  console.log();
543
589
  printMarkdown(response.text);
544
590
  drawTokenBar();
591
+ maybeWarnTokenUsage(chatTokensUsed, EXAM_AI4CTF_CAP, _ai4ctfWarned);
545
592
  console.log();
546
593
  }
547
594
  catch (err) {
@@ -176,11 +176,18 @@ export async function handleCtf4aiMessage(input) {
176
176
  // ═══════════════════════════════════════════════════════════════
177
177
  // Real-exam CTF4AI chat mode (Q39-40)
178
178
  // ═══════════════════════════════════════════════════════════════
179
- function printExamCtf4aiWelcome(q, qNum) {
179
+ function printExamCtf4aiWelcome(q, qNum, existingAnswer) {
180
180
  const config = getConfig();
181
181
  const modelName = config.geminiModel || 'gemma-4-31b-it';
182
+ const isResume = ctf4aiTokens > 0;
182
183
  console.log();
183
184
  console.log(chalk.red.bold(` ═══ CTF4AI — Q${qNum}: ${q.category} (${q.points || 16} pts) ═══`));
185
+ if (isResume) {
186
+ console.log(chalk.gray(` (resuming — prior chat is not remembered, but tokens already used stay deducted)`));
187
+ }
188
+ if (existingAnswer) {
189
+ console.log(chalk.gray(` Current answer: `) + chalk.yellow(existingAnswer) + chalk.gray(` (submit again to change)`));
190
+ }
184
191
  console.log();
185
192
  console.log(chalk.red(' ┌─────────────────────────────────────────────'));
186
193
  console.log(chalk.red(' │ ') + chalk.bold.white(`Q${qNum} [${q.category}] · adversarial AI`));
@@ -259,9 +266,26 @@ export async function startExamCtf4aiChat(qNum, question) {
259
266
  ctf4aiActive = true;
260
267
  ctf4aiTokens = usedSoFar;
261
268
  examCtf4aiCtx = { qNum, question };
262
- printExamCtf4aiWelcome(question, qNum);
269
+ const existingAnswer = state.answers?.[qNum];
270
+ printExamCtf4aiWelcome(question, qNum, existingAnswer);
263
271
  return true;
264
272
  }
273
+ const _ctf4aiWarned = new Set();
274
+ function maybeWarnCtf4aiUsage(used, cap) {
275
+ const pct = (used / cap) * 100;
276
+ if (pct >= 95 && !_ctf4aiWarned.has('95')) {
277
+ console.log(chalk.red.bold(` ⚠ ${Math.round(pct)}% of CTF4AI budget used — only ~${cap - used} tokens left.`));
278
+ _ctf4aiWarned.add('95');
279
+ }
280
+ else if (pct >= 80 && !_ctf4aiWarned.has('80')) {
281
+ console.log(chalk.yellow(` ⚠ 80% of CTF4AI budget used.`));
282
+ _ctf4aiWarned.add('80');
283
+ }
284
+ else if (pct >= 50 && !_ctf4aiWarned.has('50')) {
285
+ console.log(chalk.gray(` Note: 50% of CTF4AI budget used.`));
286
+ _ctf4aiWarned.add('50');
287
+ }
288
+ }
265
289
  async function handleExamCtf4aiMessage(input) {
266
290
  if (!examCtf4aiCtx || !ctf4aiSession)
267
291
  return 'exit';
@@ -297,6 +321,13 @@ async function handleExamCtf4aiMessage(input) {
297
321
  const submitMatch = trimmed.match(/^submit\s+(.+)/i);
298
322
  if (submitMatch) {
299
323
  const flag = submitMatch[1].trim();
324
+ if (/^[A-Da-d]$/.test(flag)) {
325
+ console.log();
326
+ console.log(chalk.yellow(` "${flag}" looks like an MCQ letter, not a flag.`));
327
+ console.log(chalk.gray(' Flag format: ') + chalk.green('ICOA{your_flag}') + chalk.gray('. Try again.'));
328
+ console.log();
329
+ return 'continue';
330
+ }
300
331
  const { getExamState, saveExamState } = await import('../lib/exam-state.js');
301
332
  const state = getExamState();
302
333
  if (!state)
@@ -312,32 +343,29 @@ async function handleExamCtf4aiMessage(input) {
312
343
  result: 'via ctf4ai chat',
313
344
  });
314
345
  state.answers[examCtf4aiCtx.qNum] = flag;
315
- state._lastQ = examCtf4aiCtx.qNum;
346
+ // Advance _lastQ so `ctf4ai` after exit picks up Q40 automatically.
347
+ const nextQ = examCtf4aiCtx.qNum + 1;
348
+ state._lastQ = nextQ <= 40 ? nextQ : examCtf4aiCtx.qNum;
316
349
  saveExamState(state);
317
350
  console.log();
318
- console.log(chalk.green.bold(` ✓ Answer for Q${examCtf4aiCtx.qNum} recorded: ${flag}`));
351
+ if (prevAnswer) {
352
+ console.log(chalk.green(` ✓ Q${examCtf4aiCtx.qNum} answer updated: `) + chalk.yellow(flag));
353
+ console.log(chalk.gray(` Previous: ${prevAnswer}`));
354
+ }
355
+ else {
356
+ console.log(chalk.green.bold(` ✓ Answer for Q${examCtf4aiCtx.qNum} recorded: ${flag}`));
357
+ }
319
358
  console.log(chalk.gray(' (Grading happens at exam submit.)'));
320
359
  console.log();
321
- const savedQ = examCtf4aiCtx.qNum;
322
- ctf4aiActive = false;
323
- ctf4aiSession = null;
324
- examCtf4aiCtx = null;
325
- if (savedQ === 39) {
326
- console.log(chalk.red(' ─────────────────────────────────────────────'));
327
- console.log(chalk.white(' Next: ') + chalk.bold.red('Q40 — final question'));
328
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam q 40') + chalk.gray(' jump to Q40'));
329
- console.log(chalk.gray(' → ') + chalk.bold.red('ctf4ai') + chalk.gray(' start CTF4AI for Q40'));
330
- console.log(chalk.red(' ─────────────────────────────────────────────'));
360
+ console.log(chalk.gray(' ') + chalk.white('submit ICOA{...}') + chalk.gray(' again to change, or ') + chalk.white('exit') + chalk.gray(' to move on'));
361
+ if (nextQ <= 40) {
362
+ console.log(chalk.gray(' After exit: ') + chalk.red('ctf4ai') + chalk.gray(` will open Q${nextQ}`));
331
363
  }
332
364
  else {
333
- console.log(chalk.green(' ─────────────────────────────────────────────'));
334
- console.log(chalk.bold.green(' All 40 questions answered! Time to submit.'));
335
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam review') + chalk.gray(' sanity-check your answers'));
336
- console.log(chalk.gray(' → ') + chalk.bold.cyan('exam submit') + chalk.gray(' final submission'));
337
- console.log(chalk.green(' ─────────────────────────────────────────────'));
365
+ console.log(chalk.gray(' After exit: all 40 answered → ') + chalk.cyan('exam submit'));
338
366
  }
339
367
  console.log();
340
- return 'exit';
368
+ return 'continue';
341
369
  }
342
370
  // Shell
343
371
  if (input.startsWith('!')) {
@@ -362,12 +390,25 @@ async function handleExamCtf4aiMessage(input) {
362
390
  // Exit
363
391
  if (lower === 'exit' || lower === 'back' || lower === 'quit') {
364
392
  const savedQ = examCtf4aiCtx.qNum;
393
+ const { getExamState } = await import('../lib/exam-state.js');
394
+ const exitState = getExamState();
395
+ const hasAnswer = exitState && exitState.answers[savedQ] != null;
396
+ const lastQ = (exitState?._lastQ) || savedQ;
365
397
  ctf4aiActive = false;
366
398
  ctf4aiSession = null;
367
399
  examCtf4aiCtx = null;
400
+ _ctf4aiWarned.clear();
368
401
  console.log();
369
402
  console.log(chalk.gray(` CTF4AI chat ended for Q${savedQ}.`));
370
- console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ctf4ai'));
403
+ if (hasAnswer && savedQ === 39 && lastQ <= 40) {
404
+ console.log(chalk.white(' Next: ') + chalk.bold.red('Q40') + chalk.gray(' — type ') + chalk.red('ctf4ai') + chalk.gray(' to continue'));
405
+ }
406
+ else if (hasAnswer && savedQ === 40) {
407
+ console.log(chalk.bold.green(' All 40 answered! ') + chalk.gray('Review + submit: ') + chalk.cyan('exam review') + chalk.gray(' · ') + chalk.cyan('exam submit'));
408
+ }
409
+ else {
410
+ console.log(chalk.gray(' Resume: ') + chalk.white(`exam q ${savedQ}`) + chalk.gray(' · Re-enter: ') + chalk.white('ctf4ai'));
411
+ }
371
412
  console.log();
372
413
  return 'exit';
373
414
  }
@@ -398,6 +439,7 @@ async function handleExamCtf4aiMessage(input) {
398
439
  console.log();
399
440
  const pct = Math.round((ctf4aiTokens / EXAM_CTF4AI_CAP) * 100);
400
441
  console.log(chalk.gray(` [${ctf4aiTokens}/${EXAM_CTF4AI_CAP} CTF4AI tokens · ${pct}%]`));
442
+ maybeWarnCtf4aiUsage(ctf4aiTokens, EXAM_CTF4AI_CAP);
401
443
  console.log();
402
444
  }
403
445
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.61",
3
+ "version": "2.19.63",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {