feat(opax-web): add fail-closed agent boundary
This commit is contained in:
parent
df330978ff
commit
8e89c4671a
41
opax-web/README.md
Normal file
41
opax-web/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# OPAX Web TUI
|
||||||
|
|
||||||
|
This project provides a starter scaffold for a Google-login-gated web control plane. It consists of a React frontend and a Node.js backend.
|
||||||
|
|
||||||
|
The frontend provides a terminal-like interface for interacting with the system. The backend handles user authentication via Google OAuth2 and provides a secure boundary for potential agent integrations.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
To run the application locally, first install the dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The backend requires several environment variables for configuration. For local development, you can create a `.env` file in the `opax-web/backend/` directory.
|
||||||
|
|
||||||
|
- Do not commit `.env` files to Git.
|
||||||
|
- **NEVER** use or copy production secrets into a local `.env` file.
|
||||||
|
- For configuration that requires secrets, use local, rotatable test values.
|
||||||
|
- Production secrets must be stored and delivered via the dedicated secrets solution described in `SECURITY.md`.
|
||||||
|
|
||||||
|
Required configuration keys:
|
||||||
|
- `FRONTEND_URL`
|
||||||
|
- `ALLOWED_EMAILS`
|
||||||
|
- `GOOGLE_CLIENT_ID`
|
||||||
|
- `GOOGLE_CLIENT_SECRET`
|
||||||
|
- `SESSION_SECRET`
|
||||||
|
|
||||||
|
**Note:** The outbound agent integration is currently disabled by default (fail-closed). The backend will not attempt to connect to any external agent services.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**IMPORTANT:** Never commit passwords, tokens, OAuth client secrets, or service account keys to the Git repository. If any credential is accidentally exposed, it **MUST** be revoked and rotated immediately.
|
||||||
|
|
||||||
|
For more details, see `SECURITY.md`.
|
||||||
14
opax-web/SECURITY.md
Normal file
14
opax-web/SECURITY.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Responsible Disclosure
|
||||||
|
|
||||||
|
To report a security vulnerability, please send a private email to `chris.christiansen@vauco.no`.
|
||||||
|
|
||||||
|
Do not include sensitive information, customer data, or secrets in public GitHub issues or chat discussions.
|
||||||
|
|
||||||
|
## Credential Security
|
||||||
|
|
||||||
|
- **NEVER** commit credentials, secrets, or tokens to the Git repository.
|
||||||
|
- **NEVER** include credentials in logs, frontend code, or any other uncontrolled files.
|
||||||
|
- If a credential is accidentally exposed, it **MUST** be rotated immediately.
|
||||||
|
- For production environments, all secrets must be stored in a dedicated secret management service (e.g., Google Secret Manager) and accessed using least-privilege IAM principles.
|
||||||
109
opax-web/backend/server.js
Normal file
109
opax-web/backend/server.js
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { callAgent, AgentNotConfiguredError } from './agentClient.js';
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import session from 'express-session';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const http = createServer(app);
|
||||||
|
const front = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean));
|
||||||
|
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionSecret = process.env.SESSION_SECRET;
|
||||||
|
if (!sessionSecret || (sessionSecret === 'CHANGE_ME' && process.env.NODE_ENV !== 'production')) {
|
||||||
|
throw new Error('A valid SESSION_SECRET must be set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sm = session({
|
||||||
|
secret: sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const io = new Server(http, { cors: { origin: front, credentials: true } });
|
||||||
|
const allowed = new Set(['get_health', 'get_build_status', 'get_state', 'get_telemetry', 'get_git_status', 'get_git_branch', 'get_git_log', 'get_git_diff', 'list_repo_files', 'read_repo_file']);
|
||||||
|
|
||||||
|
app.use(cors({ origin: front, credentials: true }));
|
||||||
|
app.use(sm);
|
||||||
|
io.use((s, n) => sm(s.request, {}, n));
|
||||||
|
|
||||||
|
app.get('/health', (_, r) => r.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' })));
|
||||||
|
|
||||||
|
app.get('/auth/callback', async (q, r) => {
|
||||||
|
try {
|
||||||
|
let { tokens } = await oauth.getToken(q.query.code);
|
||||||
|
let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload();
|
||||||
|
if (!p?.email || !emails.has(p.email)) return r.status(403).send('Not authorised');
|
||||||
|
q.session.user = { email: p.email, name: p.name || p.email };
|
||||||
|
r.redirect('/');
|
||||||
|
} catch (e) {
|
||||||
|
r.status(500).send('Login failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.status(401).end()));
|
||||||
|
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
|
||||||
|
|
||||||
|
io.on('connection', (s) => {
|
||||||
|
let u = s.request.session?.user;
|
||||||
|
if (!u) return s.disconnect();
|
||||||
|
|
||||||
|
s.on('mcp-call', async ({ id, tool, args }) => {
|
||||||
|
if (typeof id !== 'string' || id.length > 128) {
|
||||||
|
return s.emit('mcp-result', { id, error: 'Invalid request id' });
|
||||||
|
}
|
||||||
|
if (typeof tool !== 'string' || !allowed.has(tool)) {
|
||||||
|
return s.emit('mcp-result', { id, error: 'Tool denied by server allowlist' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
||||||
|
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||||
|
}
|
||||||
|
const argsString = JSON.stringify(args);
|
||||||
|
if (argsString.length > 16384) {
|
||||||
|
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const request_id = randomUUID();
|
||||||
|
try {
|
||||||
|
const result = await callAgent(tool, args);
|
||||||
|
s.emit('mcp-result', { id, result });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AgentNotConfiguredError) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
request_id,
|
||||||
|
event: 'agent_not_configured',
|
||||||
|
category: 'agent_not_configured',
|
||||||
|
}));
|
||||||
|
s.emit('mcp-result', { id, error: 'Agent integration is not configured.' });
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
request_id,
|
||||||
|
event: 'agent_request_failed',
|
||||||
|
category: 'agent_request_failed',
|
||||||
|
}));
|
||||||
|
s.emit('mcp-result', { id, error: 'An unexpected error occurred.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
http.listen(Number(process.env.PORT || 8080), '0.0.0.0');
|
||||||
Loading…
Reference in New Issue
Block a user