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.
- package/dist/commands/ai4ctf.js +62 -19
- package/dist/commands/ctf4ai-demo.js +3 -1
- package/dist/commands/exam.js +19 -0
- package/dist/lib/log-sync.js +33 -15
- package/dist/repl.js +14 -0
- package/package.json +1 -1
package/dist/commands/ai4ctf.js
CHANGED
|
@@ -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
|
-
'
|
|
57
|
-
'just type a question like: how do I decode Base64 on the command line?',
|
|
60
|
+
'Two ways forward from here:',
|
|
58
61
|
'',
|
|
59
|
-
'
|
|
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
|
-
'
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
//
|
|
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.
|
|
231
|
-
console.log(chalk.
|
|
232
|
-
console.log(chalk.gray(
|
|
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 '
|
|
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);
|
package/dist/commands/exam.js
CHANGED
|
@@ -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) {
|
package/dist/lib/log-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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();
|