harden durable prompt relay and sensitive read approvals
Browse files- README.md +4 -3
- approval-policy.mjs +77 -13
- bridge.mjs +61 -2
README.md
CHANGED
|
@@ -19,9 +19,10 @@ No Hugging Face or provider credential is baked into this image.
|
|
| 19 |
extension reads the bounded policy file at `AGENT_POLICY_PATH` (normally
|
| 20 |
`/run/chat-ui-agent/policy`). A missing, invalid, or oversized file falls back to `AGENT_POLICY`:
|
| 21 |
|
| 22 |
-
- `auto` confirms remote publishes, uploads, deployments,
|
| 23 |
-
-
|
| 24 |
-
- `
|
|
|
|
| 25 |
|
| 26 |
Missing or invalid policy values use `ask`. This live policy file makes “always allow” effective
|
| 27 |
for the active session without restarting Pi. If the RPC UI is unavailable, gated calls are blocked.
|
|
|
|
| 19 |
extension reads the bounded policy file at `AGENT_POLICY_PATH` (normally
|
| 20 |
`/run/chat-ui-agent/policy`). A missing, invalid, or oversized file falls back to `AGENT_POLICY`:
|
| 21 |
|
| 22 |
+
- `auto` confirms remote publishes, uploads, deployments, other likely exit points, and reads of
|
| 23 |
+
credential-bearing files or process-environment values.
|
| 24 |
+
- `ask` confirms all writes, edits, shell commands, and sensitive reads.
|
| 25 |
+
- `read-only` confirms any attempted write, edit, or shell command, plus sensitive direct reads.
|
| 26 |
|
| 27 |
Missing or invalid policy values use `ask`. This live policy file makes “always allow” effective
|
| 28 |
for the active session without restarting Pi. If the RPC UI is unavailable, gated calls are blocked.
|
approval-policy.mjs
CHANGED
|
@@ -51,7 +51,8 @@ export function classifySensitivePath(value) {
|
|
| 51 |
/(?:^|\/)\.(?:aws\/credentials|docker\/config\.json|kube\/config)$/.test(path) ||
|
| 52 |
/(?:^|\/)\.config\/(?:gh\/hosts\.yml|glab-cli\/config\.yml)$/.test(path) ||
|
| 53 |
/(?:^|\/)\.config\/huggingface\/(?:token|stored_tokens)$/.test(path) ||
|
| 54 |
-
/(?:^|\/)\.(?:cache\/)?huggingface\/token$/.test(path)
|
|
|
|
| 55 |
) {
|
| 56 |
return sensitivePath("credential-file", "a service credential file");
|
| 57 |
}
|
|
@@ -109,6 +110,17 @@ export function classifyToolApproval(policyValue, toolNameValue, inputValue) {
|
|
| 109 |
const policy = normalizeAgentPolicy(policyValue);
|
| 110 |
const toolName = safeString(toolNameValue).trim().toLowerCase();
|
| 111 |
const input = isRecord(inputValue) ? inputValue : {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
if (policy === "ask") {
|
| 114 |
if (WRITE_TOOLS.has(toolName)) {
|
|
@@ -129,18 +141,6 @@ export function classifyToolApproval(policyValue, toolNameValue, inputValue) {
|
|
| 129 |
preview: commandPreview(input),
|
| 130 |
});
|
| 131 |
}
|
| 132 |
-
if (READ_TOOLS.has(toolName)) {
|
| 133 |
-
const sensitiveRead = classifySensitiveRead(toolName, input);
|
| 134 |
-
if (sensitiveRead) {
|
| 135 |
-
return requirement({
|
| 136 |
-
category: "sensitive-read",
|
| 137 |
-
title: "Approve sensitive read?",
|
| 138 |
-
reason: `Ask policy requires confirmation before reading ${sensitiveRead.description}.`,
|
| 139 |
-
toolName,
|
| 140 |
-
preview: sensitiveRead.preview,
|
| 141 |
-
});
|
| 142 |
-
}
|
| 143 |
-
}
|
| 144 |
return null;
|
| 145 |
}
|
| 146 |
|
|
@@ -168,6 +168,16 @@ export function classifyToolApproval(policyValue, toolNameValue, inputValue) {
|
|
| 168 |
|
| 169 |
if (toolName !== "bash") return null;
|
| 170 |
const command = getString(input, "command");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const exit = classifyExitPoint(command);
|
| 172 |
if (!exit) return null;
|
| 173 |
return requirement({
|
|
@@ -179,6 +189,60 @@ export function classifyToolApproval(policyValue, toolNameValue, inputValue) {
|
|
| 179 |
});
|
| 180 |
}
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
/** Scrub common credential forms before an approval request is persisted by the UI. */
|
| 183 |
export function redactSensitiveText(value, maxChars = MAX_APPROVAL_PREVIEW_CHARS) {
|
| 184 |
let text = stripUnsafeControls(safeString(value));
|
|
|
|
| 51 |
/(?:^|\/)\.(?:aws\/credentials|docker\/config\.json|kube\/config)$/.test(path) ||
|
| 52 |
/(?:^|\/)\.config\/(?:gh\/hosts\.yml|glab-cli\/config\.yml)$/.test(path) ||
|
| 53 |
/(?:^|\/)\.config\/huggingface\/(?:token|stored_tokens)$/.test(path) ||
|
| 54 |
+
/(?:^|\/)\.(?:cache\/)?huggingface\/token$/.test(path) ||
|
| 55 |
+
/(?:^|\/)\.pi\/agent\/models\.json$/.test(path)
|
| 56 |
) {
|
| 57 |
return sensitivePath("credential-file", "a service credential file");
|
| 58 |
}
|
|
|
|
| 110 |
const policy = normalizeAgentPolicy(policyValue);
|
| 111 |
const toolName = safeString(toolNameValue).trim().toLowerCase();
|
| 112 |
const input = isRecord(inputValue) ? inputValue : {};
|
| 113 |
+
const sensitiveRead = READ_TOOLS.has(toolName) ? classifySensitiveRead(toolName, input) : null;
|
| 114 |
+
|
| 115 |
+
if (sensitiveRead) {
|
| 116 |
+
return requirement({
|
| 117 |
+
category: "sensitive-read",
|
| 118 |
+
title: "Approve sensitive read?",
|
| 119 |
+
reason: `Workspace policy requires confirmation before reading ${sensitiveRead.description}.`,
|
| 120 |
+
toolName,
|
| 121 |
+
preview: sensitiveRead.preview,
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
|
| 125 |
if (policy === "ask") {
|
| 126 |
if (WRITE_TOOLS.has(toolName)) {
|
|
|
|
| 141 |
preview: commandPreview(input),
|
| 142 |
});
|
| 143 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
return null;
|
| 145 |
}
|
| 146 |
|
|
|
|
| 168 |
|
| 169 |
if (toolName !== "bash") return null;
|
| 170 |
const command = getString(input, "command");
|
| 171 |
+
const sensitiveShellRead = classifySensitiveShellRead(command);
|
| 172 |
+
if (sensitiveShellRead) {
|
| 173 |
+
return requirement({
|
| 174 |
+
category: "sensitive-read",
|
| 175 |
+
title: "Approve sensitive read?",
|
| 176 |
+
reason: `Auto policy requires confirmation before reading ${sensitiveShellRead.description}.`,
|
| 177 |
+
toolName,
|
| 178 |
+
preview: command,
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
const exit = classifyExitPoint(command);
|
| 182 |
if (!exit) return null;
|
| 183 |
return requirement({
|
|
|
|
| 189 |
});
|
| 190 |
}
|
| 191 |
|
| 192 |
+
/** Classify common shell forms that can disclose credential-bearing files or environment values. */
|
| 193 |
+
export function classifySensitiveShellRead(value) {
|
| 194 |
+
const command = safeString(value);
|
| 195 |
+
if (!command.trim()) return null;
|
| 196 |
+
|
| 197 |
+
for (const rawSegment of tokenizeShellSegments(command)) {
|
| 198 |
+
const tokens = unwrapInvocation(rawSegment);
|
| 199 |
+
if (tokens.length === 0) {
|
| 200 |
+
if (executableName(rawSegment[0]) === "env") {
|
| 201 |
+
return sensitivePath("process-environment", "the sandbox process environment");
|
| 202 |
+
}
|
| 203 |
+
continue;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const executable = executableName(tokens[0]);
|
| 207 |
+
const args = tokens.slice(1);
|
| 208 |
+
if (
|
| 209 |
+
executable === "printenv" ||
|
| 210 |
+
(executable === "set" && args.length === 0) ||
|
| 211 |
+
(["declare", "export", "readonly", "typeset"].includes(executable) && args.includes("-p")) ||
|
| 212 |
+
(executable === "compgen" && args.includes("-v"))
|
| 213 |
+
) {
|
| 214 |
+
return sensitivePath("process-environment", "the sandbox process environment");
|
| 215 |
+
}
|
| 216 |
+
if (["bash", "dash", "fish", "sh", "zsh"].includes(executable)) {
|
| 217 |
+
const commandIndex = args.findIndex(
|
| 218 |
+
(arg) => arg === "--command" || (/^-[^-]+$/.test(arg) && arg.slice(1).includes("c"))
|
| 219 |
+
);
|
| 220 |
+
if (commandIndex >= 0 && args[commandIndex + 1]) {
|
| 221 |
+
const nested = classifySensitiveShellRead(args[commandIndex + 1]);
|
| 222 |
+
if (nested) return nested;
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
for (const candidateValue of args) {
|
| 227 |
+
const candidate = candidateValue.replace(/^[<>]+|[<>]+$/g, "");
|
| 228 |
+
const classification = classifySensitivePath(candidate);
|
| 229 |
+
if (classification) return classification;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
if (
|
| 233 |
+
["awk", "find", "grep", "rg"].includes(executable) &&
|
| 234 |
+
args.some((arg) => SECRET_PATTERN.test(arg))
|
| 235 |
+
) {
|
| 236 |
+
return sensitivePath("secret-pattern", "content or filenames matching a secret pattern");
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if (/\b(?:process\.env|os\.environ|deno\.env|system\.getenv)\b/i.test(command)) {
|
| 241 |
+
return sensitivePath("process-environment", "the sandbox process environment");
|
| 242 |
+
}
|
| 243 |
+
return null;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
/** Scrub common credential forms before an approval request is persisted by the UI. */
|
| 247 |
export function redactSensitiveText(value, maxChars = MAX_APPROVAL_PREVIEW_CHARS) {
|
| 248 |
let text = stripUnsafeControls(safeString(value));
|
bridge.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const PI_BIN = process.env.PI_BIN || "pi";
|
|
| 11 |
const MAX_EVENTS = parsePositiveInt(process.env.BRIDGE_MAX_EVENTS, 5_000);
|
| 12 |
const MAX_BUFFER_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_BUFFER_BYTES, 8 * 1024 * 1024);
|
| 13 |
const MAX_LINE_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_LINE_BYTES, 1024 * 1024);
|
|
|
|
| 14 |
const WS_HIGH_WATER_BYTES = parsePositiveInt(
|
| 15 |
process.env.BRIDGE_WS_HIGH_WATER_BYTES,
|
| 16 |
4 * 1024 * 1024
|
|
@@ -24,6 +25,9 @@ let bufferedBytes = 0;
|
|
| 24 |
let stdoutBuffer = "";
|
| 25 |
const replayBuffer = [];
|
| 26 |
const sockets = new Set();
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
const pi = spawn(PI_BIN, piArgs, {
|
| 29 |
cwd: WORKSPACE,
|
|
@@ -102,11 +106,49 @@ webSockets.on("connection", (socket, request) => {
|
|
| 102 |
});
|
| 103 |
return;
|
| 104 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if (!pi.stdin.writable || pi.exitCode !== null) {
|
| 106 |
send(socket, { type: "bridge_error", message: "pi is not running" });
|
| 107 |
return;
|
| 108 |
}
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
});
|
| 111 |
|
| 112 |
socket.on("close", () => {
|
|
@@ -154,7 +196,12 @@ function handlePiLine(line) {
|
|
| 154 |
return;
|
| 155 |
}
|
| 156 |
try {
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
} catch {
|
| 159 |
record({
|
| 160 |
type: "bridge_error",
|
|
@@ -164,6 +211,18 @@ function handlePiLine(line) {
|
|
| 164 |
}
|
| 165 |
}
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
function record(payload) {
|
| 168 |
const record = { seq: nextSequence++, payload };
|
| 169 |
const bytes = Buffer.byteLength(JSON.stringify(record));
|
|
|
|
| 11 |
const MAX_EVENTS = parsePositiveInt(process.env.BRIDGE_MAX_EVENTS, 5_000);
|
| 12 |
const MAX_BUFFER_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_BUFFER_BYTES, 8 * 1024 * 1024);
|
| 13 |
const MAX_LINE_BYTES = parsePositiveInt(process.env.BRIDGE_MAX_LINE_BYTES, 1024 * 1024);
|
| 14 |
+
const MAX_COMMANDS = parsePositiveInt(process.env.BRIDGE_MAX_COMMANDS, 20_000);
|
| 15 |
const WS_HIGH_WATER_BYTES = parsePositiveInt(
|
| 16 |
process.env.BRIDGE_WS_HIGH_WATER_BYTES,
|
| 17 |
4 * 1024 * 1024
|
|
|
|
| 25 |
let stdoutBuffer = "";
|
| 26 |
const replayBuffer = [];
|
| 27 |
const sockets = new Set();
|
| 28 |
+
// The bridge outlives chat-ui pods. Stable command ids turn a server retry into
|
| 29 |
+
// an idempotent acknowledgement instead of a second pi prompt.
|
| 30 |
+
const commands = new Map();
|
| 31 |
|
| 32 |
const pi = spawn(PI_BIN, piArgs, {
|
| 33 |
cwd: WORKSPACE,
|
|
|
|
| 106 |
});
|
| 107 |
return;
|
| 108 |
}
|
| 109 |
+
if (typeof command.id === "string" && command.id.length > 0) {
|
| 110 |
+
const fingerprint = JSON.stringify(command);
|
| 111 |
+
const previous = commands.get(command.id);
|
| 112 |
+
if (previous) {
|
| 113 |
+
if (previous.fingerprint !== fingerprint) {
|
| 114 |
+
send(socket, {
|
| 115 |
+
type: "bridge_error",
|
| 116 |
+
message: `RPC command id '${command.id}' was reused with a different payload`,
|
| 117 |
+
});
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
// An in-flight retry simply waits for the original broadcast response.
|
| 121 |
+
// A completed retry gets a fresh replay sequence so a client whose
|
| 122 |
+
// cursor already covered the original response can correlate it.
|
| 123 |
+
if (previous.response) record(previous.response);
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
if (!pi.stdin.writable || pi.exitCode !== null) {
|
| 128 |
send(socket, { type: "bridge_error", message: "pi is not running" });
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
+
const commandEntry =
|
| 132 |
+
typeof command.id === "string" && command.id.length > 0
|
| 133 |
+
? { fingerprint: JSON.stringify(command), type: command.type }
|
| 134 |
+
: undefined;
|
| 135 |
+
if (commandEntry) {
|
| 136 |
+
commands.set(command.id, commandEntry);
|
| 137 |
+
pruneCommands();
|
| 138 |
+
}
|
| 139 |
+
try {
|
| 140 |
+
pi.stdin.write(`${JSON.stringify(command)}\n`, (error) => {
|
| 141 |
+
if (!error || !commandEntry || commandEntry.response) return;
|
| 142 |
+
if (commands.get(command.id) === commandEntry) commands.delete(command.id);
|
| 143 |
+
send(socket, { type: "bridge_error", message: `Unable to send RPC command: ${error.message}` });
|
| 144 |
+
});
|
| 145 |
+
} catch (error) {
|
| 146 |
+
if (commandEntry && commands.get(command.id) === commandEntry) commands.delete(command.id);
|
| 147 |
+
send(socket, {
|
| 148 |
+
type: "bridge_error",
|
| 149 |
+
message: `Unable to send RPC command: ${error instanceof Error ? error.message : String(error)}`,
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
});
|
| 153 |
|
| 154 |
socket.on("close", () => {
|
|
|
|
| 196 |
return;
|
| 197 |
}
|
| 198 |
try {
|
| 199 |
+
const payload = JSON.parse(line);
|
| 200 |
+
if (payload?.type === "response" && typeof payload.id === "string") {
|
| 201 |
+
const command = commands.get(payload.id);
|
| 202 |
+
if (command) command.response = payload;
|
| 203 |
+
}
|
| 204 |
+
record(payload);
|
| 205 |
} catch {
|
| 206 |
record({
|
| 207 |
type: "bridge_error",
|
|
|
|
| 211 |
}
|
| 212 |
}
|
| 213 |
|
| 214 |
+
function pruneCommands() {
|
| 215 |
+
if (commands.size <= MAX_COMMANDS) return;
|
| 216 |
+
for (const [id, command] of commands) {
|
| 217 |
+
// Prompt ids are the cross-server exactly-once boundary. Keep them for
|
| 218 |
+
// the lifetime of this pi process even if a very chatty extension causes
|
| 219 |
+
// ordinary diagnostic/control command entries to roll over.
|
| 220 |
+
if (!command.response || command.type === "prompt") continue;
|
| 221 |
+
commands.delete(id);
|
| 222 |
+
if (commands.size <= MAX_COMMANDS) return;
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
function record(payload) {
|
| 227 |
const record = { seq: nextSequence++, payload };
|
| 228 |
const bytes = Buffer.byteLength(JSON.stringify(record));
|