Compare commits

..

3 Commits

Author SHA1 Message Date
Colin McDonnell d5ab3706db 173 2026-02-25 01:38:04 +00:00
Colin McDonnell f8a871f723 fix sudo-unshare sandbox: drop privileges after PROC_CLEANUP (#383)
the sudo-unshare sandbox path runs the entire command as root, causing
files modified by shell commands (e.g. git merge) to become root-owned.
this breaks file_write/file_edit which run in the Node.js parent process
as the normal user (EACCES errors).

after PROC_CLEANUP (which needs root for umount/mount), drop back to
the original user via `su -p` so file operations match the uid of the
parent process. security-neutral: PID namespace isolation is the barrier,
not privilege level inside it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 01:37:40 +00:00
Colin McDonnell 52ec35790a fix review reply trigger: treat replies to Pullfrog threads as implicit triggers, only add eyes reaction when dispatching
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 19:28:08 +00:00
6 changed files with 21 additions and 33 deletions
+9 -8
View File
@@ -141651,10 +141651,8 @@ function DelegateTool(ctx) {
const outcome = settled[i]; const outcome = settled[i];
const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error; const error49 = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49); const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error49);
log.info( const status = result.success ? "succeeded" : "failed";
`\xBB task "${entry.task.label}" ${result.success ? "succeeded" : "failed"}: log.box(result.summary, { title: `task "${entry.task.label}" ${status}` });
${result.summary}`
);
return result; return result;
}); });
const succeeded = results.filter((r) => r.success).length; const succeeded = results.filter((r) => r.success).length;
@@ -143924,6 +143922,7 @@ function SelectModeTool(ctx) {
import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process"; import { spawn as spawn2, spawnSync as spawnSync3 } from "node:child_process";
import { randomUUID as randomUUID3 } from "node:crypto"; import { randomUUID as randomUUID3 } from "node:crypto";
import { closeSync, openSync, writeFileSync as writeFileSync7 } from "node:fs"; import { closeSync, openSync, writeFileSync as writeFileSync7 } from "node:fs";
import { userInfo } from "node:os";
import { join as join9 } from "node:path"; import { join as join9 } from "node:path";
var ShellParams = type({ var ShellParams = type({
command: "string", command: "string",
@@ -143988,6 +143987,8 @@ function spawnShell(params) {
envArgs.push(`${k}=${v}`); envArgs.push(`${k}=${v}`);
} }
} }
const username = userInfo().username;
const escaped = params.command.replace(/'/g, "'\\''");
return spawn2( return spawn2(
"sudo", "sudo",
[ [
@@ -143999,7 +144000,7 @@ function spawnShell(params) {
"--mount-proc", "--mount-proc",
"bash", "bash",
"-c", "-c",
`${PROC_CLEANUP} ${params.command}` `${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`
], ],
{ ...spawnOpts, env: {} } { ...spawnOpts, env: {} }
); );
@@ -144281,8 +144282,7 @@ function buildCommonTools(ctx) {
FileWriteTool(ctx), FileWriteTool(ctx),
FileEditTool(ctx), FileEditTool(ctx),
FileDeleteTool(ctx), FileDeleteTool(ctx),
ListDirectoryTool(ctx), ListDirectoryTool(ctx)
ReportProgressTool(ctx)
]; ];
if (ctx.payload.shell === "restricted") { if (ctx.payload.shell === "restricted") {
tools.push(ShellTool(ctx)); tools.push(ShellTool(ctx));
@@ -144293,6 +144293,7 @@ function buildCommonTools(ctx) {
function buildOrchestratorTools(ctx) { function buildOrchestratorTools(ctx) {
return [ return [
...buildCommonTools(ctx), ...buildCommonTools(ctx),
ReportProgressTool(ctx),
SelectModeTool(ctx), SelectModeTool(ctx),
DelegateTool(ctx), DelegateTool(ctx),
AskQuestionTool(ctx), AskQuestionTool(ctx),
@@ -144638,7 +144639,7 @@ import { join as join11 } from "node:path";
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.172", version: "0.0.173",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
+2 -3
View File
@@ -104,9 +104,8 @@ export function DelegateTool(ctx: ToolContext) {
const outcome = settled[i]; const outcome = settled[i];
const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error; const error = outcome.status === "rejected" ? String(outcome.reason) : outcome.value.error;
const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error); const result = buildTaskResult(entry.task.label, entry.effort, entry.subagent, error);
log.info( const status = result.success ? "succeeded" : "failed";
`» task "${entry.task.label}" ${result.success ? "succeeded" : "failed"}:\n${result.summary}` log.box(result.summary, { title: `task "${entry.task.label}" ${status}` });
);
return result; return result;
}); });
+1 -19
View File
@@ -107,24 +107,6 @@ export interface ToolContext {
tmpdir: string; tmpdir: string;
} }
/**
* tool names that are only available to the orchestrator.
* subagent MCP servers are started with these tools excluded.
*
* - delegation tools: only the orchestrator can spawn/manage subagents
* - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs
*/
export const ORCHESTRATOR_ONLY_TOOLS = [
"select_mode",
"delegate",
"ask_question",
"push_branch",
"push_tags",
"delete_branch",
"create_pull_request",
"update_pull_request_body",
] as const;
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import type { RunContextData } from "../utils/runContextData.ts"; import type { RunContextData } from "../utils/runContextData.ts";
import { AskQuestionTool } from "./askQuestion.ts"; import { AskQuestionTool } from "./askQuestion.ts";
@@ -236,7 +218,6 @@ function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
FileEditTool(ctx), FileEditTool(ctx),
FileDeleteTool(ctx), FileDeleteTool(ctx),
ListDirectoryTool(ctx), ListDirectoryTool(ctx),
ReportProgressTool(ctx),
]; ];
// only add ShellTool when shell is "restricted" // only add ShellTool when shell is "restricted"
@@ -255,6 +236,7 @@ function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] { function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
return [ return [
...buildCommonTools(ctx), ...buildCommonTools(ctx),
ReportProgressTool(ctx),
SelectModeTool(ctx), SelectModeTool(ctx),
DelegateTool(ctx), DelegateTool(ctx),
AskQuestionTool(ctx), AskQuestionTool(ctx),
+7 -1
View File
@@ -2,6 +2,7 @@
import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process"; import { type ChildProcess, type StdioOptions, spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { closeSync, openSync, writeFileSync } from "node:fs"; import { closeSync, openSync, writeFileSync } from "node:fs";
import { userInfo } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import { log } from "../utils/log.ts"; import { log } from "../utils/log.ts";
@@ -110,6 +111,11 @@ function spawnShell(params: SpawnParams): ChildProcess {
envArgs.push(`${k}=${v}`); envArgs.push(`${k}=${v}`);
} }
} }
// drop back to original user after PROC_CLEANUP so files aren't owned by root.
// sudo is only needed for unshare; the actual command should run as the normal user
// to avoid ownership mismatches with file_write/file_edit (which run in the Node.js parent).
const username = userInfo().username;
const escaped = params.command.replace(/'/g, "'\\''");
return spawn( return spawn(
"sudo", "sudo",
[ [
@@ -121,7 +127,7 @@ function spawnShell(params: SpawnParams): ChildProcess {
"--mount-proc", "--mount-proc",
"bash", "bash",
"-c", "-c",
`${PROC_CLEANUP} ${params.command}`, `${PROC_CLEANUP} exec su -p -s /bin/bash ${username} -c '${escaped}'`,
], ],
{ ...spawnOpts, env: {} } { ...spawnOpts, env: {} }
); );
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.172", "version": "0.0.173",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+1 -1
View File
@@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.172", version: "0.0.173",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",