Mike0021 commited on
Commit
45ea3e5
·
verified ·
1 Parent(s): f27ecd9

harden durable prompt relay and sensitive read approvals

Browse files
Files changed (3) hide show
  1. README.md +4 -3
  2. approval-policy.mjs +77 -13
  3. 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, and other likely exit points.
23
- - `ask` confirms all writes, edits, shell commands, and likely secret-file reads.
24
- - `read-only` confirms any attempted write, edit, or shell command.
 
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
- pi.stdin.write(`${JSON.stringify(command)}\n`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  });
111
 
112
  socket.on("close", () => {
@@ -154,7 +196,12 @@ function handlePiLine(line) {
154
  return;
155
  }
156
  try {
157
- record(JSON.parse(line));
 
 
 
 
 
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));