Scaffold dd0c/run: Rust agent (classifier, executor, audit) + TypeScript SaaS

- Rust agent: clap CLI, command classifier (read-only/modifying/destructive), executor with approval gates, audit log entries
- Classifier: pattern-based safety classification for shell, AWS, kubectl, terraform/tofu commands
- 6 Rust tests: read-only, destructive, modifying, empty, terraform apply, tofu destroy
- SaaS backend: Fastify server, runbook CRUD API, approval API, Slack interactive handler
- Slack integration: signature verification, block_actions for approve/reject buttons
- PostgreSQL schema with RLS: runbooks, executions, audit_entries (append-only), agents
- Dual Dockerfiles: Rust multi-stage (agent), Node multi-stage (SaaS)
- Gitea Actions CI: Rust test+clippy, Node typecheck+test
- Fly.io config for SaaS
This commit is contained in:
2026-03-01 03:03:29 +00:00
parent 6f692fc5ef
commit 57e7083986
18 changed files with 1046 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import pino from 'pino';
const logger = pino({ name: 'api-approvals' });
const approvalDecisionSchema = z.object({
decision: z.enum(['approve', 'reject']),
reason: z.string().max(500).optional(),
});
export function registerApprovalRoutes(app: FastifyInstance) {
// List pending approvals for tenant
app.get('/api/v1/approvals', async (req, reply) => {
// TODO: SELECT from audit_entries WHERE status = 'awaiting_approval'
return { approvals: [] };
});
// Approve or reject a step
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
const { stepId } = req.params as { stepId: string };
const body = approvalDecisionSchema.parse(req.body);
// TODO: Update audit entry, notify agent via WebSocket/Redis pub-sub
logger.info({ stepId, decision: body.decision }, 'Approval decision recorded');
return {
step_id: stepId,
decision: body.decision,
reason: body.reason,
};
});
}

View File

@@ -0,0 +1,73 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import pino from 'pino';
const logger = pino({ name: 'api-runbooks' });
const createRunbookSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
yaml_content: z.string().min(1),
});
const listQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
status: z.enum(['active', 'archived']).optional(),
});
export function registerRunbookRoutes(app: FastifyInstance) {
// List runbooks
app.get('/api/v1/runbooks', async (req, reply) => {
const query = listQuerySchema.parse(req.query);
// TODO: SELECT from runbooks with RLS tenant context
return { runbooks: [], page: query.page, limit: query.limit, total: 0 };
});
// Get single runbook
app.get('/api/v1/runbooks/:id', async (req, reply) => {
const { id } = req.params as { id: string };
// TODO: SELECT by id
return { runbook: null };
});
// Create runbook
app.post('/api/v1/runbooks', async (req, reply) => {
const body = createRunbookSchema.parse(req.body);
// TODO: INSERT into runbooks, parse YAML, validate steps
logger.info({ name: body.name }, 'Runbook created');
return reply.status(201).send({ id: 'placeholder', ...body });
});
// Trigger runbook execution
app.post('/api/v1/runbooks/:id/execute', async (req, reply) => {
const { id } = req.params as { id: string };
const body = z.object({
dry_run: z.boolean().default(false),
variables: z.record(z.string()).optional(),
}).parse(req.body ?? {});
// TODO: Create execution record, dispatch to agent via WebSocket/queue
logger.info({ runbookId: id, dryRun: body.dry_run }, 'Execution triggered');
return reply.status(202).send({
execution_id: 'placeholder',
runbook_id: id,
status: 'pending',
dry_run: body.dry_run,
});
});
// Get execution history
app.get('/api/v1/runbooks/:id/executions', async (req, reply) => {
const { id } = req.params as { id: string };
// TODO: SELECT from executions
return { executions: [] };
});
// Get execution detail (with step-by-step audit trail)
app.get('/api/v1/executions/:executionId', async (req, reply) => {
const { executionId } = req.params as { executionId: string };
// TODO: SELECT execution + JOIN audit_entries
return { execution: null, steps: [] };
});
}

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_run'),
REDIS_URL: z.string().default('redis://localhost:6379'),
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
SLACK_BOT_TOKEN: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
CORS_ORIGIN: z.string().default('*'),
LOG_LEVEL: z.string().default('info'),
});
export const config = envSchema.parse(process.env);
export type Config = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,31 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import pino from 'pino';
import { config } from './config/index.js';
import { registerRunbookRoutes } from './api/runbooks.js';
import { registerApprovalRoutes } from './api/approvals.js';
import { registerSlackRoutes } from './slackbot/handler.js';
const logger = pino({ name: 'dd0c-run', level: config.LOG_LEVEL });
const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(helmet);
// Health check
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' }));
// API routes
registerRunbookRoutes(app);
registerApprovalRoutes(app);
registerSlackRoutes(app);
try {
await app.listen({ port: config.PORT, host: '0.0.0.0' });
logger.info({ port: config.PORT }, 'dd0c/run SaaS started');
} catch (err) {
logger.fatal(err, 'Failed to start');
process.exit(1);
}

View File

@@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify';
import pino from 'pino';
import crypto from 'node:crypto';
import { config } from '../config/index.js';
const logger = pino({ name: 'slackbot' });
/**
* Slack interactive message handler.
* Receives button clicks for approve/reject from Slack Block Kit messages.
*/
export function registerSlackRoutes(app: FastifyInstance) {
// Slack Events API verification + interactive payloads
app.post('/slack/events', async (req, reply) => {
// Verify Slack signature
if (config.SLACK_SIGNING_SECRET) {
const timestamp = req.headers['x-slack-request-timestamp'] as string;
const signature = req.headers['x-slack-signature'] as string;
const body = JSON.stringify(req.body);
if (!verifySlackSignature(body, timestamp, signature, config.SLACK_SIGNING_SECRET)) {
return reply.status(401).send({ error: 'Invalid signature' });
}
}
const payload = req.body as any;
// URL verification challenge
if (payload.type === 'url_verification') {
return { challenge: payload.challenge };
}
return { ok: true };
});
// Slack interactive components (button clicks)
app.post('/slack/interactions', async (req, reply) => {
// Slack sends form-encoded payload
const rawPayload = (req.body as any)?.payload;
if (!rawPayload) return reply.status(400).send({ error: 'Missing payload' });
let payload: any;
try {
payload = JSON.parse(rawPayload);
} catch {
return reply.status(400).send({ error: 'Invalid payload' });
}
if (payload.type === 'block_actions') {
for (const action of payload.actions ?? []) {
const [actionType, stepId] = (action.action_id ?? '').split(':');
if (actionType === 'approve_step') {
logger.info({ stepId, user: payload.user?.id }, 'Step approved via Slack');
// TODO: Update audit entry, resume execution
} else if (actionType === 'reject_step') {
logger.info({ stepId, user: payload.user?.id }, 'Step rejected via Slack');
// TODO: Update audit entry, abort execution
}
}
}
return { ok: true };
});
}
function verifySlackSignature(body: string, timestamp: string, signature: string, secret: string): boolean {
if (!timestamp || !signature) return false;
// Reject stale requests (>5 min)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
const sigBasestring = `v0:${timestamp}:${body}`;
const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}