diff --git a/.env.example b/.env.example
index 3ea7f45..9147f47 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,9 @@ REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
SWEEP_TOKEN=
+TERMINAL_ACTIONS_ENABLED=true
+TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
+TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
# LLM layer
diff --git a/README.md b/README.md
index e476fd8..4d52481 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,9 @@ REFRESH_INTERVAL_MINUTES=15
AUTO_OPEN_BROWSER=false
STALE_DATA_MAX_AGE_MINUTES=60
SWEEP_TOKEN=
+TERMINAL_ACTIONS_ENABLED=true
+TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
+TERMINAL_ACTION_RATE_LIMIT_MAX=10
BRIEF_VERBOSITY=standard
LLM_PROVIDER=openrouter
@@ -187,6 +190,21 @@ LLM_MODEL=your-model
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
+#### Terminal Action Exposure
+
+`POST /api/action` and `POST /api/sweep` can trigger operational actions such as manual sweeps. The dashboard has a **SET TOKEN** control that stores your `SWEEP_TOKEN` in browser local storage and sends it as the `x-crucix-token` header; do not put action tokens in URLs.
+
+Recommended settings:
+
+| Deployment | Settings |
+| --- | --- |
+| Private local machine | `NODE_ENV=development`, optional `SWEEP_TOKEN`, optional `TERMINAL_ACTIONS_ENABLED=true`. Localhost can run actions without a token for development. |
+| Private LAN / Dockge | Set a strong `SWEEP_TOKEN`, keep `TERMINAL_ACTIONS_ENABLED=true`, expose only to trusted clients. |
+| Pangolin-authenticated reverse proxy | Set a strong `SWEEP_TOKEN`, keep Pangolin auth in front, use the dashboard **SET TOKEN** flow once per browser. |
+| Public internet | Do not expose Terminal Actions directly. If exposure is unavoidable, require `SWEEP_TOKEN`, keep proxy authentication enabled, lower `TERMINAL_ACTION_RATE_LIMIT_MAX`, and monitor server audit logs. |
+
+Action endpoints reject cross-origin POST origins, apply a small in-memory per-IP rate limit, and write sanitized audit lines without logging the token.
+
#### Build And Publish Your Gitea Image
```bash
diff --git a/crucix.config.mjs b/crucix.config.mjs
index 8792df3..3c15e48 100644
--- a/crucix.config.mjs
+++ b/crucix.config.mjs
@@ -24,6 +24,9 @@ export default {
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
sweepToken: process.env.SWEEP_TOKEN || null,
+ terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
+ terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
+ terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html
index 921b3fd..5f03888 100644
--- a/dashboard/public/jarvis.html
+++ b/dashboard/public/jarvis.html
@@ -406,6 +406,7 @@ let layerModes = JSON.parse(localStorage.getItem('crucix_layer_modes') || '{}');
let spaceDisplayMode = localStorage.getItem('crucix_space_display') || 'icons';
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
+const terminalActionTokenKey = 'crucix_terminal_action_token';
const layerTypeMap = {
air: ['air'],
@@ -606,6 +607,7 @@ function renderTopbar(){
const ts = new Date(D.meta.timestamp);
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
+ const hasActionToken = !!getTerminalActionToken();
document.getElementById('topbar').innerHTML=`
CRUCIX MONITOR
@@ -618,12 +620,56 @@ function renderTopbar(){
${d} ${timeStr}
${t('dashboard.sources','SOURCES')} ${D.meta.sourcesOk}/${D.meta.sourcesQueried}
${D.delta?.summary ? `${t('dashboard.delta','DELTA')} ${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}` : ''}
+
+
+
${t('dashboard.highAlert','HIGH ALERT')}
`;
renderRegionControls();
}
+function getTerminalActionToken(){
+ return localStorage.getItem(terminalActionTokenKey) || '';
+}
+
+function configureTerminalActionToken(){
+ const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken());
+ if(next === null) return;
+ const clean = next.trim();
+ if(clean) localStorage.setItem(terminalActionTokenKey, clean);
+ else localStorage.removeItem(terminalActionTokenKey);
+ renderTopbar();
+}
+
+async function runTerminalAction(action){
+ let token = getTerminalActionToken();
+ if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){
+ configureTerminalActionToken();
+ token = getTerminalActionToken();
+ if(!token) return;
+ }
+
+ const headers = {'Content-Type':'application/json'};
+ if(token) headers['x-crucix-token'] = token;
+ try{
+ const response = await fetch('/api/action', {
+ method:'POST',
+ headers,
+ body:JSON.stringify({action})
+ });
+ const payload = await response.json().catch(() => ({}));
+ if(!response.ok) throw new Error(payload.error || `Action failed (${response.status})`);
+ if(action === 'status'){
+ window.alert(`Terminal actions OK. Health: ${payload.health?.status || 'unknown'}`);
+ } else if(action === 'sweep'){
+ window.alert(payload.status === 'accepted' ? 'Sweep accepted.' : `Sweep status: ${payload.status || 'unknown'}`);
+ }
+ }catch(err){
+ window.alert(`Terminal action failed: ${err.message}`);
+ }
+}
+
// === LEFT RAIL ===
function layerMode(key){ return layerModes[key] || 'normal'; }
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
diff --git a/server.mjs b/server.mjs
index 8f46df8..995ea10 100644
--- a/server.mjs
+++ b/server.mjs
@@ -39,6 +39,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
let sweepInProgress = false;
const startTime = Date.now();
const sseClients = new Set();
+const terminalActionBuckets = new Map();
// === Delta/Memory ===
const memory = new MemoryManager(RUNS_DIR);
@@ -289,14 +290,34 @@ app.get('/api/metrics', (req, res) => {
});
app.post('/api/sweep', express.json(), (req, res) => {
- const remote = req.ip || '';
- const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
- const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
- if (config.sweepToken && token !== config.sweepToken) return res.status(401).json({ error: 'Invalid sweep token' });
- if (!config.sweepToken && !local) return res.status(403).json({ error: 'Manual sweep is local-only unless SWEEP_TOKEN is set' });
- if (sweepInProgress) return res.status(409).json({ status: 'already_running', sweepStartedAt });
- runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
- res.status(202).json({ status: 'accepted' });
+ const guard = authorizeTerminalAction(req, res, 'sweep');
+ if (!guard.ok) return;
+ triggerSweepAction(req, res, 'sweep');
+});
+
+app.post('/api/action', express.json(), (req, res) => {
+ const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase();
+ const guard = authorizeTerminalAction(req, res, action || 'unknown');
+ if (!guard.ok) return;
+
+ if (action === 'status') {
+ auditTerminalAction(req, 'status', 'ok');
+ return res.json({ status: 'ok', health: buildHealth() });
+ }
+
+ if (action === 'brief') {
+ if (!currentData) {
+ auditTerminalAction(req, 'brief', 'rejected', 'no_data');
+ return res.status(503).json({ error: 'No data yet - first sweep in progress' });
+ }
+ auditTerminalAction(req, 'brief', 'ok');
+ return res.json({ status: 'ok', brief: buildBrief(currentData) });
+ }
+
+ if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
+
+ auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action');
+ return res.status(400).json({ error: 'Unknown action', allowed: ['status', 'brief', 'sweep'] });
});
// API: available locales
@@ -327,6 +348,108 @@ function broadcast(data) {
}
}
+function requestIp(req) {
+ return req.ip || req.socket?.remoteAddress || 'unknown';
+}
+
+function isLocalRequest(req) {
+ const remote = requestIp(req);
+ return remote === '::1'
+ || remote === '127.0.0.1'
+ || remote === '::ffff:127.0.0.1'
+ || remote.startsWith('127.')
+ || remote === 'localhost';
+}
+
+function sameOriginPost(req) {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try {
+ const originUrl = new URL(origin);
+ const host = req.get('host');
+ return host && originUrl.host === host;
+ } catch {
+ return false;
+ }
+}
+
+function actionToken(req) {
+ return req.get('x-crucix-token') || req.body?.token || null;
+}
+
+function auditTerminalAction(req, action, outcome, detail = null) {
+ const suffix = detail ? ` detail=${detail}` : '';
+ console.log(`[Crucix][audit] terminal_action action=${action || 'unknown'} outcome=${outcome} ip=${requestIp(req)}${suffix}`);
+}
+
+function rateLimitTerminalAction(req, action) {
+ const now = Date.now();
+ const windowMs = Math.max(1000, config.terminalActionRateLimitWindowMs || 60_000);
+ const max = Math.max(1, config.terminalActionRateLimitMax || 10);
+ const key = `${requestIp(req)}:${action}`;
+ const bucket = terminalActionBuckets.get(key);
+ if (!bucket || now > bucket.resetAt) {
+ terminalActionBuckets.set(key, { count: 1, resetAt: now + windowMs });
+ return { ok: true };
+ }
+ bucket.count += 1;
+ if (bucket.count > max) {
+ return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
+ }
+ return { ok: true };
+}
+
+function authorizeTerminalAction(req, res, action) {
+ const rate = rateLimitTerminalAction(req, action);
+ if (!rate.ok) {
+ auditTerminalAction(req, action, 'rejected', 'rate_limited');
+ res.set('Retry-After', String(rate.retryAfterSeconds));
+ res.status(429).json({ error: 'Too many terminal actions', retryAfterSeconds: rate.retryAfterSeconds });
+ return { ok: false };
+ }
+
+ if (!sameOriginPost(req)) {
+ auditTerminalAction(req, action, 'rejected', 'csrf_origin');
+ res.status(403).json({ error: 'Origin mismatch' });
+ return { ok: false };
+ }
+
+ const local = isLocalRequest(req);
+ const token = actionToken(req);
+ if (!config.terminalActionsEnabled) {
+ auditTerminalAction(req, action, 'rejected', 'disabled');
+ res.status(403).json({ error: 'Terminal actions are disabled' });
+ return { ok: false };
+ }
+
+ if (config.sweepToken) {
+ if (token !== config.sweepToken) {
+ auditTerminalAction(req, action, 'rejected', 'invalid_token');
+ res.status(401).json({ error: 'Invalid terminal action token' });
+ return { ok: false };
+ }
+ return { ok: true };
+ }
+
+ if (!local) {
+ auditTerminalAction(req, action, 'rejected', 'missing_token');
+ res.status(403).json({ error: 'Terminal actions are local-only unless SWEEP_TOKEN is set' });
+ return { ok: false };
+ }
+
+ return { ok: true };
+}
+
+function triggerSweepAction(req, res, auditAction) {
+ if (sweepInProgress) {
+ auditTerminalAction(req, auditAction, 'rejected', 'already_running');
+ return res.status(409).json({ status: 'already_running', sweepStartedAt });
+ }
+ auditTerminalAction(req, auditAction, 'accepted');
+ runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
+ return res.status(202).json({ status: 'accepted' });
+}
+
function dataAgeMs() {
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
@@ -376,6 +499,8 @@ function buildHealth() {
llm: getLLMStatus(),
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
+ terminalActionsEnabled: config.terminalActionsEnabled,
+ terminalActionsTokenRequired: !!config.sweepToken,
refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
memory: intelligenceStore.status(),
diff --git a/test/fetch-utils.test.mjs b/test/fetch-utils.test.mjs
index 2dcee45..89c5a79 100644
--- a/test/fetch-utils.test.mjs
+++ b/test/fetch-utils.test.mjs
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
test('safeFetch reports HTML as degraded JSON response', async () => {
@@ -34,3 +35,22 @@ test('safeFetchText returns text and byte count', async () => {
globalThis.fetch = originalFetch;
}
});
+
+test('terminal action endpoints avoid URL tokens and include hardening gates', () => {
+ const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
+ assert.match(server, /app\.post\('\/api\/action'/);
+ assert.match(server, /app\.post\('\/api\/sweep'/);
+ assert.match(server, /x-crucix-token/);
+ assert.match(server, /sameOriginPost/);
+ assert.match(server, /rateLimitTerminalAction/);
+ assert.match(server, /auditTerminalAction/);
+ assert.doesNotMatch(server, /req\.query\.token/);
+});
+
+test('dashboard exposes token configuration flow without devtools edits', () => {
+ const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
+ assert.match(html, /configureTerminalActionToken/);
+ assert.match(html, /crucix_terminal_action_token/);
+ assert.match(html, /x-crucix-token/);
+ assert.match(html, /SET TOKEN/);
+});