icoa-cli 2.19.33 → 2.19.35

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.
@@ -17,6 +17,10 @@ function getChallengeContext() {
17
17
  let chatActive = false;
18
18
  let chatSession = null;
19
19
  let chatTokensUsed = 0;
20
+ // Set true when the user burns through DEMO_TOKEN_CAP without solving. The
21
+ // chat session stays alive so `!<shell>` and `submit <flag>` still work, but
22
+ // further AI messages are blocked. See the reveal path in handleChatMessage.
23
+ let tokensLocked = false;
20
24
  const DEMO_TOKEN_CAP = 5000;
21
25
  export function isChatActive() {
22
26
  return chatActive;
@@ -53,14 +57,46 @@ const DEMO_HINTS = {
53
57
  'For simple challenges like this one, hint c adds nothing extra:',
54
58
  'hint b already tells you everything you need.',
55
59
  '',
56
- 'If you do not remember how to decode Base64, ask your AI teammate —',
57
- 'just type a question like: how do I decode Base64 on the command line?',
60
+ 'Two ways forward from here:',
58
61
  '',
59
- 'To run a shell command inside ai4ctf, prefix it with "!". For example:',
62
+ ' 1. Chat with your AI teammate in natural language. Examples:',
63
+ ' what is the base64 command?',
64
+ ' how do I decode Base64 on macOS?',
65
+ ' Just type the question — anything without "!" goes to the AI.',
60
66
  '',
61
- ' !echo aWNvYXt3M2xjMG1lXzJfYWk0Y3RmfQ== | base64 -d',
67
+ ' 2. Run a shell command directly. Shell commands inside ai4ctf',
68
+ ' must start with "!", otherwise your text goes to the AI. Example:',
69
+ '',
70
+ ' !echo aWNvYXt3M2xjMG1lXzJfYWk0Y3RmfQ== | base64 -d',
62
71
  ],
63
72
  };
73
+ // Shown when the user hits the 5000-token demo cap without solving. Keeps
74
+ // the session alive (no chatActive=false) so they can still paste the shell
75
+ // command below and then `submit <flag>`.
76
+ function showTokenCapReveal() {
77
+ console.log();
78
+ console.log(chalk.yellow(' ─────────────────────────────────────────────'));
79
+ console.log(chalk.bold.yellow(' 💡 Out of AI tokens — here is the reveal'));
80
+ console.log(chalk.yellow(' ─────────────────────────────────────────────'));
81
+ console.log();
82
+ console.log(chalk.white(' Looks like you have not found the flag yet.'));
83
+ console.log(chalk.white(' No worries — for the demo we will just tell you.'));
84
+ console.log();
85
+ console.log(chalk.white(' Run this command to decode the Base64 string:'));
86
+ console.log();
87
+ console.log(chalk.cyan(' !echo aWNvYXt3M2xjMG1lXzJfYWk0Y3RmfQ== | base64 -d'));
88
+ console.log();
89
+ console.log(chalk.white(' You will see the flag:'));
90
+ console.log();
91
+ console.log(chalk.green(' icoa{w3lc0me_2_ai4ctf}'));
92
+ console.log();
93
+ console.log(chalk.white(' Then submit it:'));
94
+ console.log();
95
+ console.log(chalk.cyan(' submit icoa{w3lc0me_2_ai4ctf}'));
96
+ console.log();
97
+ console.log(chalk.gray(' (AI chat is locked — only shell commands and submit work now.)'));
98
+ console.log();
99
+ }
64
100
  function showDemoHint(tier) {
65
101
  const title = tier === 'a' ? t('ai4ctfHintA') : tier === 'b' ? t('ai4ctfHintB') : t('ai4ctfHintC');
66
102
  const tierLabel = `Hint ${tier.toUpperCase()}`;
@@ -88,6 +124,9 @@ function showDemoHint(tier) {
88
124
  export async function handleChatMessage(input) {
89
125
  if (!chatSession)
90
126
  return 'exit';
127
+ // Capture every input (including special commands like hint/submit/exit)
128
+ // so the full flow shows up in session.log even before any early return.
129
+ logCommand(`ai4ctf: ${input}`);
91
130
  // Scripted demo hints — intercept before the AI chat so that typing
92
131
  // `hint a` / `hint b` / `hint c` behaves like a real competition command
93
132
  // instead of becoming a generic AI chat turn.
@@ -211,12 +250,14 @@ export async function handleChatMessage(input) {
211
250
  console.log();
212
251
  return 'exit';
213
252
  }
214
- if (chatTokensUsed >= DEMO_TOKEN_CAP) {
215
- chatActive = false;
216
- chatSession = null;
217
- // Report the token-cap session so the server can count "hit the wall"
218
- // outcomes. Without this, a user who burned 5000 tokens without solving
219
- // was invisible to the admin dashboard.
253
+ // Token cap: first hit → reveal the answer, lock AI, but keep the session
254
+ // alive so the user can still paste the shell command and then submit.
255
+ // `tokensLocked` prevents any further sendMessage calls.
256
+ if (!tokensLocked && chatTokensUsed >= DEMO_TOKEN_CAP) {
257
+ tokensLocked = true;
258
+ // Report the token-cap session once (solved:false). If the user then
259
+ // submits the revealed flag, the submit-success path fires another POST
260
+ // with solved:true which is the canonical record.
220
261
  fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
221
262
  method: 'POST',
222
263
  headers: { 'Content-Type': 'application/json' },
@@ -226,18 +267,19 @@ export async function handleChatMessage(input) {
226
267
  console.log();
227
268
  console.log(chalk.yellow(` ${t('tokenLimit')}`));
228
269
  drawTokenBar();
270
+ showTokenCapReveal();
271
+ return 'continue';
272
+ }
273
+ // If AI is locked (post-reveal), bounce any non-shell, non-submit input
274
+ // back to the user with a reminder of what actually works now.
275
+ if (tokensLocked) {
229
276
  console.log();
230
- console.log(chalk.gray(' ─────────────────────────────────────────'));
231
- console.log(chalk.white(` ${t('ai4ctfReport')}`));
232
- console.log(chalk.gray(` ${t('ai4ctfTokens')}: ${chatTokensUsed}/${DEMO_TOKEN_CAP}`));
233
- console.log(chalk.gray(` ${t('ai4ctfModel')}: Google Gemma 4 (gemma-4-31b-it)`));
234
- console.log(chalk.gray(' ─────────────────────────────────────────'));
235
- console.log();
236
- console.log(chalk.white(` ${t('ai4ctfNext')}`));
277
+ console.log(chalk.yellow(' AI chat is locked — out of tokens.'));
278
+ console.log(chalk.gray(' Use: ') + chalk.cyan('!echo aWNvYXt3M2xjMG1lXzJfYWk0Y3RmfQ== | base64 -d'));
279
+ console.log(chalk.gray(' Then: ') + chalk.cyan('submit icoa{w3lc0me_2_ai4ctf}'));
237
280
  console.log();
238
- return 'exit';
281
+ return 'continue';
239
282
  }
240
- logCommand(`ai4ctf: ${input}`);
241
283
  console.log(chalk.gray(` ${t('ai4ctfThinking')}`));
242
284
  try {
243
285
  const response = await chatSession.sendMessage(input);
@@ -278,6 +320,7 @@ export function registerAi4ctfCommand(program) {
278
320
  }
279
321
  chatActive = true;
280
322
  chatTokensUsed = 0;
323
+ tokensLocked = false;
281
324
  // Guided welcome
282
325
  console.log();
283
326
  console.log(chalk.green.bold(` ═══ ${t('ai4ctfTitle')} ═══`));
@@ -89,6 +89,9 @@ export function isCtf4aiActive() {
89
89
  export async function handleCtf4aiMessage(input) {
90
90
  if (!ctf4aiSession)
91
91
  return 'exit';
92
+ // Capture every input (including special commands) for the audit trail
93
+ // before any early-return branches.
94
+ logCommand(`ctf4ai: ${input}`);
92
95
  if (input === 'exit' || input === 'back' || input === 'quit') {
93
96
  ctf4aiActive = false;
94
97
  ctf4aiSession = null;
@@ -118,7 +121,6 @@ export async function handleCtf4aiMessage(input) {
118
121
  printDemoReport(false, ctf4aiTokens);
119
122
  return 'exit';
120
123
  }
121
- logCommand(`ctf4ai: ${input}`);
122
124
  try {
123
125
  console.log(chalk.gray(` ${t('ctf4aiThinking')}`));
124
126
  const { text, tokensUsed } = await ctf4aiSession.sendMessage(input);
@@ -9,6 +9,23 @@ import { printSuccess, printError, printWarning, printInfo, printTable, printHea
9
9
  import { t } from '../lib/i18n.js';
10
10
  import { getDeviceFingerprint } from '../lib/access.js';
11
11
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ // Fire-and-forget event POST to /api/icoa/demo-stats. Used for demo-flow
13
+ // choice points (demo enter, retry, back) where we want a server-side
14
+ // timestamp of the user's decision but have nothing more to report.
15
+ function reportDemoEvent(type, extra = {}) {
16
+ const config = getConfig();
17
+ fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({
21
+ type,
22
+ lang: config.language || 'en',
23
+ timestamp: new Date().toISOString(),
24
+ ...extra,
25
+ }),
26
+ signal: AbortSignal.timeout(5000),
27
+ }).catch(() => { });
28
+ }
12
29
  /**
13
30
  * Skippable intro animation for first-time demo users.
14
31
  * Resolves immediately on any keypress. Max 30s auto-advance.
@@ -1077,6 +1094,7 @@ export function registerExamCommand(program) {
1077
1094
  .description('Try a free practice exam (no account needed)')
1078
1095
  .action(async () => {
1079
1096
  logCommand('exam demo');
1097
+ reportDemoEvent('demo-enter');
1080
1098
  const { pickDemoQuestions, getLocalizedDemoSession, DEMO_PICK_SIZE, DEMO_POOL_SIZE } = await import('../lib/demo-exam.js');
1081
1099
  // First-time intro animation (skippable)
1082
1100
  const cfg = getConfig();
@@ -1150,6 +1168,7 @@ export function registerExamCommand(program) {
1150
1168
  .description('Retry only the questions you got wrong last demo attempt')
1151
1169
  .action(async () => {
1152
1170
  logCommand('exam demo-retry');
1171
+ reportDemoEvent('post-report-retry');
1153
1172
  const { getLocalizedDemoSession } = await import('../lib/demo-exam.js');
1154
1173
  const retryQueue = getRetryQueue();
1155
1174
  if (!retryQueue || retryQueue.length === 0) {
@@ -1,7 +1,19 @@
1
1
  import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { getIcoaDir, getConfig } from './config.js';
4
- const SYNC_INTERVAL = 30_000; // 30 seconds
4
+ import { getDeviceFingerprint } from './access.js';
5
+ // Every 30s, post new lines from ~/.icoa/session.log to the server audit
6
+ // endpoint. Two auth modes:
7
+ //
8
+ // 1. Logged-in user (CTFd token present) → Authorization: Token <token>,
9
+ // stored on the server as /opt/CTFd/audit/user-<userId>.jsonl
10
+ // 2. Anonymous demo user (no token) → X-Device-Fingerprint: <hash>,
11
+ // stored as /opt/CTFd/audit/demo-<fingerprint>.jsonl
12
+ //
13
+ // The demo path is what lets us observe the full prompt/command history of
14
+ // users who never log in (the main demo audience).
15
+ const SYNC_INTERVAL = 30_000;
16
+ const DEFAULT_SERVER = 'https://practice.icoa2026.au';
5
17
  const SYNC_STATE_FILE = () => join(getIcoaDir(), 'sync-state.json');
6
18
  let syncTimer = null;
7
19
  function getSyncState() {
@@ -20,25 +32,37 @@ function saveSyncState(state) {
20
32
  }
21
33
  async function syncLogs() {
22
34
  const config = getConfig();
23
- if (!config.ctfdUrl)
24
- return; // Not connected, skip
35
+ const serverUrl = config.ctfdUrl || DEFAULT_SERVER;
25
36
  const logPath = join(getIcoaDir(), 'session.log');
26
37
  if (!existsSync(logPath))
27
38
  return;
28
39
  const state = getSyncState();
29
40
  const allLines = readFileSync(logPath, 'utf-8').trim().split('\n').filter(Boolean);
30
- // Only send new lines since last sync
31
41
  const newLines = allLines.slice(state.lastSyncedLine);
32
42
  if (newLines.length === 0)
33
43
  return;
44
+ // Pick auth mode: token for logged-in users, device fingerprint for anon.
45
+ const headers = { 'Content-Type': 'application/json' };
46
+ let identity;
47
+ if (config.token) {
48
+ headers['Authorization'] = `Token ${config.token}`;
49
+ identity = `user:${config.userId ?? 'unknown'}`;
50
+ }
51
+ else {
52
+ const fp = config.deviceFingerprint || getDeviceFingerprint();
53
+ headers['X-Device-Fingerprint'] = fp;
54
+ identity = `demo:${fp.slice(0, 12)}`;
55
+ }
34
56
  const payload = {
57
+ identity,
35
58
  userId: config.userId,
36
59
  userName: config.userName,
37
60
  teamId: config.teamId,
38
61
  sessionId: config.sessionId,
39
- deviceFingerprint: config.deviceFingerprint,
62
+ deviceFingerprint: config.deviceFingerprint || getDeviceFingerprint(),
63
+ lang: config.language || 'en',
40
64
  timestamp: new Date().toISOString(),
41
- entries: newLines.map(line => {
65
+ entries: newLines.map((line) => {
42
66
  try {
43
67
  return JSON.parse(line);
44
68
  }
@@ -48,16 +72,12 @@ async function syncLogs() {
48
72
  }),
49
73
  };
50
74
  try {
51
- // POST to CTFd server audit endpoint
52
- const url = new URL('/api/icoa/audit', config.ctfdUrl).href;
75
+ const url = new URL('/api/icoa/audit', serverUrl).href;
53
76
  const res = await fetch(url, {
54
77
  method: 'POST',
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- 'Authorization': `Token ${config.token}`,
58
- },
78
+ headers,
59
79
  body: JSON.stringify(payload),
60
- signal: AbortSignal.timeout(10_000), // 10s timeout
80
+ signal: AbortSignal.timeout(10_000),
61
81
  });
62
82
  if (res.ok) {
63
83
  state.lastSyncedLine = allLines.length;
@@ -77,9 +97,7 @@ async function syncLogs() {
77
97
  }
78
98
  }
79
99
  export function startLogSync() {
80
- // Initial sync after 5 seconds
81
100
  setTimeout(() => syncLogs(), 5_000);
82
- // Then every 30 seconds
83
101
  syncTimer = setInterval(() => syncLogs(), SYNC_INTERVAL);
84
102
  }
85
103
  export function stopLogSync() {
package/dist/repl.js CHANGED
@@ -360,6 +360,20 @@ export async function startRepl(program, resumeMode) {
360
360
  console.log();
361
361
  }
362
362
  else {
363
+ // No active exam — typically this is the "post-report back" choice
364
+ // after the user has finished the 3-stage demo flow. Report it as a
365
+ // distinct event so the admin can count retry vs back decisions.
366
+ const cfg = getConfig();
367
+ fetch('https://practice.icoa2026.au/api/icoa/demo-stats', {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({
371
+ type: 'post-report-back',
372
+ lang: cfg.language || 'en',
373
+ timestamp: new Date().toISOString(),
374
+ }),
375
+ signal: AbortSignal.timeout(5000),
376
+ }).catch(() => { });
363
377
  console.log(chalk.gray(' Already at main menu.'));
364
378
  }
365
379
  rl.prompt();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icoa-cli",
3
- "version": "2.19.33",
3
+ "version": "2.19.35",
4
4
  "description": "ICOA CLI — The world's first CLI-native CTF competition terminal",
5
5
  "type": "module",
6
6
  "bin": {