DEV Community

the bomber
the bomber

Posted on

Turning spoken commands into safe actions: regex first, the language model second

Introduction

I built a tiny voice‑controlled home automation server for my own website. The goal was simple: speak a command, have the server execute a safe action (turn a light on, start a backup, etc.). The tricky part is making sure the spoken text can’t be turned into an arbitrary shell command. My solution is a two‑step filter – a strict regular‑expression whitelist first, then a language‑model (LLM) to fill in the details.

Why regex first?

Regular expressions give me a deterministic gate. Anything that doesn’t match a known pattern is rejected before it ever reaches the LLM. This prevents prompt injection attacks where a user tries to sneak a malicious instruction into the natural‑language prompt. In my experience the overhead of a few regex checks is negligible compared to the round‑trip to the OpenAI API.

Designing the command grammar

I started by listing the actions I actually want to expose:

  • light <room> on|off
  • backup now
  • status <service>

From that list I wrote a single regex that captures the intent and the arguments:

// commandRegex.js
export const commandRegex = /^(light)\s+(living|kitchen|bedroom)\s+(on|off)$|^(backup)\s+now$|^(status)\s+(nginx|postgres)$/i;
Enter fullscreen mode Exit fullscreen mode

The pattern is deliberately simple – each branch matches one of the allowed commands. If a spoken phrase doesn’t match, the request is dropped with a 400 response.

Hooking up the speech recognizer

On the client side I use the Web Speech API because it works in Chrome without any extra libraries. The recognizer sends the raw transcript to my Node server via a POST request.

// public/voice.js
const btn = document.getElementById('talk');
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'en-US';
recognition.interimResults = false;

btn.onclick = () => recognition.start();

recognition.onresult = e => {
  const transcript = e.results[0][0].transcript.trim();
  fetch('/api/command', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({text: transcript})
  })
  .then(r => r.json())
  .then(console.log);
};
Enter fullscreen mode Exit fullscreen mode

The server receives {"text":"light kitchen on"} and runs it through the regex.

Using a language model for intent

Once the regex validates the shape, I still need to translate the captured groups into concrete parameters (e.g., which GPIO pin controls the kitchen light). I keep that mapping in a JSON file and ask the LLM to produce the final command string. The prompt is static and never includes user‑provided text directly.

// intentResolver.js
import {Configuration, OpenAIApi} from 'openai';
import {commandRegex} from './commandRegex.js';
import pinMap from './pinMap.json';

const openai = new OpenAIApi(new Configuration({apiKey: process.env.OPENAI_API_KEY}));

export async function resolveIntent(rawText) {
  const match = rawText.match(commandRegex);
  if (!match) throw new Error('Invalid command');

  // Extract intent and args from the regex groups
  const [_, intent, room, state, backup, statusService] = match;

  // Build a deterministic prompt – no user text interpolation
  const prompt = `You are a helper that converts structured intent data into a shell command.
Intent: ${intent || backup || statusService}
Parameters: ${JSON.stringify({room, state, service: statusService})}
Return only the command, nothing else.`;

  const response = await openai.createChatCompletion({
    model: 'gpt-4o-mini',
    messages: [{role: 'user', content: prompt}],
    temperature: 0,
  });

  const command = response.data.choices[0].message.content.trim();
  return command;
}
Enter fullscreen mode Exit fullscreen mode

Because the prompt never contains the raw transcript, the LLM cannot be tricked into echoing back malicious text. The only variable data it sees are the intent name and a sanitized JSON object.

Safety checks

Even after the LLM returns a command, I run a final whitelist of allowed shell snippets. This is a second line of defense in case the model hallucinates.

// safeCommands.js
export const safeCommands = [
  /^gpio write \d+ (1|0)$/,   // turn a pin on/off
  /^rsync -avz .* backup$/,    // run a backup
  /^systemctl status (nginx|postgres)$/
];

export function isSafe(cmd) {
  return safeCommands.some(rx => rx.test(cmd));
}
Enter fullscreen mode Exit fullscreen mode

If isSafe returns false, the request is logged and rejected.

Putting it together

The Express route ties all pieces together:

// server.js
import express from 'express';
import {resolveIntent} from './intentResolver.js';
import {isSafe} from './safeCommands.js';
import {exec} from 'child_process';

const app = express();
app.use(express.json());

app.post('/api/command', async (req, res) => {
  const {text} = req.body;
  try {
    const cmd = await resolveIntent(text);
    if (!isSafe(cmd)) throw new Error('Command failed safety check');
    exec(cmd, (err, stdout, stderr) => {
      if (err) return res.status(500).json({error: err.message});
      res.json({output: stdout || stderr});
    });
  } catch (e) {
    console.error(e);
    res.status(400).json({error: e.message});
  }
});

app.listen(3000, () => console.log('Voice command server listening on :3000'));
Enter fullscreen mode Exit fullscreen mode

All the heavy lifting is deterministic: regex → intent → LLM prompt → safety whitelist → exec.

Deployment considerations

I run this server on a Raspberry Pi at home, but the same code works on any Linux box. A few practical tips:

  1. Environment isolation – use a systemd service that runs the Node process as a non‑root user.
  2. Rate limiting – the Web Speech API can be noisy; a simple token bucket middleware prevents abuse.
  3. Logging – store the raw transcript, the resolved command, and the safety check result. This makes debugging prompt‑injection attempts easier.
  4. Model cost – because the prompt is tiny and temperature is 0, the cost per request is a few fractions of a cent.

Conclusion

By front‑loading a strict regex filter, I keep the attack surface small enough that the LLM can be used safely for natural‑language expansion. The pattern works for any limited command set: define a grammar, whitelist the resulting shell, and let the model fill in the boring details. I’ve been running this for months with no false positives, and the approach scales nicely when I add new devices – just extend the regex and the safe‑command list.

I build sites and automations for small businesses at dreamfuturestech.com.

Top comments (0)