大模型
dev.to ·
@the bomber
把语音指令转为安全操作:先正则,后语言模型
作者为自建家庭自动化服务器设计两段式过滤:先用严格正则白名单匹配有限动作(开灯、备份、状态查询),不匹配即拒绝,再交给 LLM 补全细节,以降低提示注入转为任意 shell 命令的风险;文中未提供攻击测试或量化评估。
AI推荐理由 提出先用严格正则白名单做确定性闸门、再由 LLM 补全参数的两段式语音指令过滤,以降低提示注入导致任意命令执行的风险,思路具体且可复用;但为个人小项目,未给出攻击测试或量化评估。
63
正文
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 on|off
backup now
status
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:
Environment isolation – use a systemd service that runs the Node process as a non‑root user.
Rate limiting – the Web Speech API can be noisy; a simple token bucket middleware prevents abuse.
Logging – store the raw transcript, the resolved command, and the safety check result. This makes debugging prompt‑injection attempts easier.
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.