Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions lib/actions.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { createJob } from './tools/create-job.js';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

/**
* Execute a single action
Expand All @@ -14,7 +14,9 @@ async function executeAction(action, opts = {}) {
const type = action.type || 'agent';

if (type === 'command') {
const { stdout, stderr } = await execAsync(action.command, { cwd: opts.cwd });
const { stdout, stderr } = await execFileAsync(
'/bin/sh', ['-c', action.command], { cwd: opts.cwd }
);
return (stdout || stderr || '').trim();
}

Expand Down
30 changes: 25 additions & 5 deletions lib/triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,37 @@ import { triggersFile, triggersDir } from './paths.js';
import { executeAction } from './actions.js';

/**
* Replace {{body.field}} templates with values from request context
* Escape a string for safe inclusion in a single-quoted shell argument.
* Wraps in single quotes and escapes any embedded single quotes.
* @param {string} value
* @returns {string}
*/
function shellEscape(value) {
// Replace each ' with '\'' (end quote, escaped quote, start quote)
return "'" + value.replace(/'/g, "'\\''") + "'";
}

/**
* Replace {{body.field}} templates with values from request context.
* When the template is used in a command action, values are shell-escaped
* to prevent injection. Job templates pass values through unescaped since
* they are used as LLM prompt text, not shell commands.
* @param {string} template - String with {{body.field}} placeholders
* @param {Object} context - { body, query, headers }
* @param {Object} [options]
* @param {boolean} [options.shellEscape=false] - Shell-escape interpolated values
* @returns {string}
*/
function resolveTemplate(template, context) {
function resolveTemplate(template, context, options = {}) {
const escape = options.shellEscape ? shellEscape : (v) => v;
return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => {
const data = context[source];
if (data === undefined) return match;
if (!field) return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
if (data[field] !== undefined) return String(data[field]);
if (!field) {
const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
return escape(raw);
}
if (data[field] !== undefined) return escape(String(data[field]));
return match;
});
}
Expand All @@ -27,7 +47,7 @@ async function executeActions(trigger, context) {
for (const action of trigger.actions) {
try {
const resolved = { ...action };
if (resolved.command) resolved.command = resolveTemplate(resolved.command, context);
if (resolved.command) resolved.command = resolveTemplate(resolved.command, context, { shellEscape: true });
if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
const result = await executeAction(resolved, { cwd: triggersDir, data: context.body });
console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
Expand Down