paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
388 lines (385 loc) • 13.4 kB
JavaScript
import { createRequire as __paqadCreateRequire } from 'module'; const require = __paqadCreateRequire(import.meta.url);
import {
unresolvedStageAgent
} from "./chunk-GCMANDHS.js";
import {
isHighTableAgent,
stageForAgentName,
stripPluginAgentNamespace
} from "./chunk-NCYDV2N5.js";
import {
init_esm_shims
} from "./chunk-IDFRWBMN.js";
// src/stage-isolation/bash-write-guard.ts
init_esm_shims();
import { tmpdir } from "os";
import * as nodePath from "path";
import { resolve } from "path";
var READ_ONLY_STAGES = [
"planning",
"specification",
"review",
"checks"
];
var HIGH_TABLE_WRITE_ROOT = ".paqad/tmp/high-table/";
var STAGE_SCRATCH_ROOT = ".paqad/tmp/";
function readOnlyAgentScope(agentType) {
if (typeof agentType !== "string") return null;
const agent = stripPluginAgentNamespace(agentType.trim());
if (isHighTableAgent(agent)) {
return { agent, allowedRoots: [HIGH_TABLE_WRITE_ROOT], allowTempDir: false };
}
const stage = stageForAgentName(agent);
if (stage === null || !READ_ONLY_STAGES.includes(stage)) return null;
return { agent, allowedRoots: [STAGE_SCRATCH_ROOT], allowTempDir: true };
}
var REDIRECT_OUT = /^(?:\d*>>?|\d*>\||&>>?)$/u;
var SEPARATORS = /* @__PURE__ */ new Set([";", "&&", "||", "|", "&", "\n", "(", ")"]);
function readHeredocDelimiter(source, start) {
let index = start;
while (source[index] === " " || source[index] === " ") index += 1;
let word = "";
while (index < source.length && !/[\s;&|<>()]/u.test(source[index])) {
const char = source[index];
if (char !== "'" && char !== '"' && char !== "\\") word += char;
index += 1;
}
return { word, end: index };
}
function skipHeredocBody(source, start, delimiter, dash) {
let index = source.indexOf("\n", start);
if (index < 0) return source.length;
index += 1;
while (index < source.length) {
const lineEnd = source.indexOf("\n", index);
const end = lineEnd < 0 ? source.length : lineEnd;
const line = source.slice(index, end);
if ((dash ? line.replace(/^\t+/u, "") : line) === delimiter) {
return lineEnd < 0 ? source.length : lineEnd;
}
index = end + 1;
}
return source.length;
}
function lexShell(source) {
const tokens = [];
const heredocs = [];
let word = "";
let dynamic = false;
let inWord = false;
const flush = () => {
if (inWord) tokens.push({ kind: "word", text: word, dynamic });
word = "";
dynamic = false;
inWord = false;
};
let index = 0;
while (index < source.length) {
const char = source[index];
if (char === "\n") {
flush();
tokens.push({ kind: "op", text: "\n" });
let next = index + 1;
for (const heredoc of heredocs.splice(0)) {
next = skipHeredocBody(source, next - 1, heredoc.delimiter, heredoc.dash) + 1;
}
index = next;
continue;
}
if (char === " " || char === " ") {
flush();
index += 1;
continue;
}
if (char === "#" && !inWord) {
while (index < source.length && source[index] !== "\n") index += 1;
continue;
}
if (char === "'") {
const close = source.indexOf("'", index + 1);
const end = close < 0 ? source.length : close;
word += source.slice(index + 1, end);
inWord = true;
index = end + 1;
continue;
}
if (char === '"') {
index += 1;
while (index < source.length && source[index] !== '"') {
if (source[index] === "\\" && index + 1 < source.length) {
word += source[index + 1];
index += 2;
continue;
}
if (source[index] === "$" || source[index] === "`") dynamic = true;
word += source[index];
index += 1;
}
inWord = true;
index += 1;
continue;
}
if (char === "\\" && index + 1 < source.length) {
if (source[index + 1] !== "\n") word += source[index + 1];
inWord = inWord || source[index + 1] !== "\n";
index += 2;
continue;
}
if (char === "<" && source.startsWith("<<", index) && !source.startsWith("<<<", index)) {
flush();
const dash = source[index + 2] === "-";
const { word: delimiter, end } = readHeredocDelimiter(source, index + (dash ? 3 : 2));
heredocs.push({ delimiter, dash });
tokens.push({ kind: "op", text: "<<" });
index = end;
continue;
}
if (char === ">" || char === "<" || char === "&" && source[index + 1] === ">") {
const fd = /^\d+$/u.test(word) && inWord ? word : "";
if (fd !== "") {
word = "";
inWord = false;
} else {
flush();
}
const match = /^(?:&>>?|>>|>\||>&|<&|<<<|<>|>|<)/u.exec(source.slice(index))[0];
index += match.length;
if (match === ">&" || match === "<&") {
const target = /^\s*([^\s;&|<>()]+)/u.exec(source.slice(index));
if (target && /^(?:\d+|-)$/u.test(target[1])) {
index += target[0].length;
continue;
}
tokens.push({ kind: "op", text: `${fd}>` });
continue;
}
tokens.push({ kind: "op", text: `${fd}${match}` });
continue;
}
if (char === ";" || char === "|" || char === "&" || char === "(" || char === ")") {
flush();
const two = source.slice(index, index + 2);
if (two === "&&" || two === "||" || two === ";;") {
tokens.push({ kind: "op", text: two === ";;" ? ";" : two });
index += 2;
continue;
}
tokens.push({ kind: "op", text: char });
index += 1;
continue;
}
if (char === "$" || char === "`" || char === "*" || char === "?" || char === "[") {
dynamic = true;
}
word += char;
inWord = true;
index += 1;
}
flush();
return tokens;
}
var WRAPPERS = /* @__PURE__ */ new Set([
"sudo",
"env",
"command",
"nohup",
"time",
"nice",
"exec",
"xargs"
]);
var ALL_ARGS = /* @__PURE__ */ new Set(["rm", "rmdir", "unlink", "touch", "mkdir", "truncate", "shred", "mv"]);
var LAST_ARG = /* @__PURE__ */ new Set(["cp", "install", "ln", "rsync", "scp"]);
var AFTER_FIRST = /* @__PURE__ */ new Set(["chmod", "chown", "chgrp"]);
var GIT_WRITES = /* @__PURE__ */ new Set([
"add",
"am",
"apply",
"checkout",
"cherry-pick",
"clean",
"commit",
"merge",
"mv",
"pull",
"rebase",
"reset",
"restore",
"revert",
"rm",
"stash",
"switch"
]);
function operands(words) {
return words.filter((word) => !word.text.startsWith("-") || word.dynamic);
}
function isWriteCommand(name) {
return name === "tee" || name === "dd" || ALL_ARGS.has(name) || LAST_ARG.has(name) || AFTER_FIRST.has(name);
}
function commandWrites(words) {
let start = 0;
let viaXargs = false;
while (start < words.length) {
const text = words[start].text;
if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(text) || WRAPPERS.has(text)) {
if (text === "xargs") viaXargs = true;
start += 1;
continue;
}
if (viaXargs && text.startsWith("-")) {
start += 1;
continue;
}
break;
}
const head = words[start];
if (!head) return [];
if (/[$`]/u.test(head.text)) return [null];
const name = head.text.split("/").pop() ?? head.text;
const rest = words.slice(start + 1);
if (viaXargs && isWriteCommand(name)) return [null];
if (name === "tee") return operands(rest);
if (ALL_ARGS.has(name)) return operands(rest);
if (LAST_ARG.has(name)) {
const args = operands(rest);
return args.length > 0 ? [args[args.length - 1]] : [];
}
if (AFTER_FIRST.has(name)) return operands(rest).slice(1);
if (name === "dd") {
return rest.filter((word) => word.text.startsWith("of=")).map((word) => ({ ...word, text: word.text.slice(3) }));
}
if (name === "sed" || name === "perl") {
const inPlace = rest.some(
(word) => word.text === "--in-place" || word.text.startsWith("--in-place=") || /^-[a-zA-Z]*i/u.test(word.text)
);
if (!inPlace) return [];
const scripted = rest.some((word) => word.text === "-e" || word.text === "-f");
const args = operands(rest);
return scripted ? args : args.slice(1);
}
if (name === "patch") return [null];
if (name === "git") {
const sub = operands(rest).find((word) => !word.text.startsWith("-"));
return sub && GIT_WRITES.has(sub.text) ? [null] : [];
}
return [];
}
function bashWrites(command, cwd) {
const tokens = lexShell(command);
const writes = [];
let current = cwd;
let words = [];
const endCommand = () => {
const head = words.find((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/u.test(word.text));
if (head?.text === "cd") {
const target = words[words.indexOf(head) + 1];
current = target === void 0 ? null : target.dynamic || target.text === "-" || current === null ? null : resolve(current, target.text.replace(/^~(?=\/|$)/u, process.env.HOME ?? "~"));
} else {
for (const word of commandWrites(words)) {
writes.push({ target: word === null || word.dynamic ? null : word.text, cwd: current });
}
}
words = [];
};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind === "word") {
words.push(token);
continue;
}
if (REDIRECT_OUT.test(token.text)) {
const next = tokens[index + 1];
if (next?.kind === "word") {
writes.push({ target: next.dynamic ? null : next.text, cwd: current });
index += 1;
} else {
writes.push({ target: null, cwd: current });
}
continue;
}
if (token.text === "<" || token.text === "<<<" || token.text === "<>") {
if (token.text === "<>") writes.push({ target: null, cwd: current });
if (tokens[index + 1]?.kind === "word") index += 1;
continue;
}
if (token.text === "<<") continue;
if (SEPARATORS.has(token.text)) endCommand();
}
endCommand();
return writes;
}
var DEVICE_TARGETS = /^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/u;
var WINDOWS_NULL_DEVICE = /^nul(?::)?$/iu;
var BASH_TEMP_DIR = /^\/tmp(?:\/|$)/u;
function defaultHost() {
return { path: nodePath, tmpdir: tmpdir(), home: process.env.HOME };
}
function under(path, child, root) {
const rel = path.relative(root, child);
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
}
function isDeviceTarget(target) {
return DEVICE_TARGETS.test(target) || WINDOWS_NULL_DEVICE.test(target);
}
function writeAllowed(scope, projectRoot, write, host = defaultHost()) {
if (write.target === null || write.cwd === null || write.target === "") return false;
const target = write.target.replace(/^~(?=\/|$)/u, host.home ?? "~");
if (isDeviceTarget(target)) return true;
const { path } = host;
const abs = path.resolve(write.cwd, target);
if (isDeviceTarget(abs)) return true;
const root = path.resolve(projectRoot);
if (under(path, abs, root)) {
const rel = path.relative(root, abs).split(path.sep).join("/");
return scope.allowedRoots.some((allowed) => `${rel}/`.startsWith(allowed));
}
if (!scope.allowTempDir) return false;
if (BASH_TEMP_DIR.test(target) && !target.split("/").includes("..")) return true;
return [host.tmpdir, "/tmp"].some((dir) => under(path, abs, path.resolve(dir)));
}
var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
var ALLOW = { blocked: false, agent: null, target: null };
function scopeFor(input) {
const scope = readOnlyAgentScope(input.agentType);
if (scope !== null || input.toolName !== "Bash") return scope;
const unresolved = unresolvedStageAgent(input.agentType);
return unresolved === null ? null : { agent: unresolved, allowedRoots: [], allowTempDir: false };
}
function stageAgentWriteDecision(input) {
const scope = scopeFor(input);
if (scope === null) return ALLOW;
const cwd = input.cwd ?? input.projectRoot;
let writes;
if (input.toolName === "Bash") {
if (typeof input.command !== "string") {
return { blocked: true, agent: scope.agent, target: null };
}
writes = bashWrites(input.command, cwd);
} else if (input.toolName !== void 0 && EDIT_TOOLS.has(input.toolName)) {
const targets = input.targets ?? [];
writes = targets.length === 0 ? [{ target: null, cwd }] : targets.map((target) => ({ target, cwd }));
} else {
return ALLOW;
}
const denied = writes.find((write) => !writeAllowed(scope, input.projectRoot, write));
if (denied === void 0) return { blocked: false, agent: scope.agent, target: null };
return { blocked: true, agent: scope.agent, target: denied.target };
}
function formatStageAgentWriteSummary(decision) {
const scope = readOnlyAgentScope(decision.agent);
const where = scope === null ? "nowhere, because no paqad stage matches it" : isHighTableAgent(scope.agent) ? `\`${HIGH_TABLE_WRITE_ROOT}\`` : `\`${STAGE_SCRATCH_ROOT}\` or the system temp folder`;
const target = decision.target === null ? "a file paqad cannot resolve" : `\`${decision.target}\``;
return `**\u25B8 paqad** \xB7 write blocked: \`${decision.agent ?? "this agent"}\` is read-only
> \u{1F534} Needs your attention \u2014 \`${decision.agent ?? "this agent"}\` tried to write ${target}. It may write only under ${where}; record the stage result with its paqad-ai verb instead.`;
}
export {
READ_ONLY_STAGES,
HIGH_TABLE_WRITE_ROOT,
STAGE_SCRATCH_ROOT,
readOnlyAgentScope,
lexShell,
bashWrites,
writeAllowed,
stageAgentWriteDecision,
formatStageAgentWriteSummary
};