Skip to content

Commit 2223590

Browse files
committed
feat: three-tier permission modes with bash confirmation in Edit Mode
- Edit Mode (default): auto-allows edits, prompts for bash commands - Full Auto (bypassPermissions): allows everything without prompts - Plan Mode: plans first as before - Bash PreToolUse hook sends confirmation to browser in Edit Mode - Confirmation card collapses to status line after Allow/Deny
1 parent f3cae0d commit 2223590

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ let _questionResolve = null;
6060
// Pending plan resolver — used by ExitPlanMode stream interception
6161
let _planResolve = null;
6262

63+
// Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode)
64+
let _bashConfirmResolve = null;
65+
6366
// Stores rejection feedback when user rejects a plan
6467
let _planRejectionFeedback = null;
6568

@@ -272,6 +275,7 @@ exports.cancelQuery = async function () {
272275
// Clear any pending question or plan
273276
_questionResolve = null;
274277
_planResolve = null;
278+
_bashConfirmResolve = null;
275279
_queuedClarification = null;
276280
return { success: true };
277281
}
@@ -302,6 +306,18 @@ exports.answerPlan = async function (params) {
302306
return { success: true };
303307
};
304308

309+
/**
310+
* Receive the user's response to a bash confirmation prompt (Edit Mode).
311+
* Called from browser via execPeer("answerBashConfirm", {allowed}).
312+
*/
313+
exports.answerBashConfirm = async function (params) {
314+
if (_bashConfirmResolve) {
315+
_bashConfirmResolve(params);
316+
_bashConfirmResolve = null;
317+
}
318+
return { success: true };
319+
};
320+
305321
/**
306322
* Resume a previous session by setting the session ID.
307323
* The next sendPrompt call will use queryOptions.resume with this session ID.
@@ -313,6 +329,7 @@ exports.resumeSession = async function (params) {
313329
}
314330
_questionResolve = null;
315331
_planResolve = null;
332+
_bashConfirmResolve = null;
316333
_queuedClarification = null;
317334
currentSessionId = params.sessionId;
318335
return { success: true };
@@ -696,6 +713,48 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
696713
}
697714
]
698715
},
716+
{
717+
matcher: "Bash",
718+
hooks: [
719+
async (input) => {
720+
if (permissionMode !== "acceptEdits") {
721+
// Plan mode: SDK handles. Full Auto: allow freely.
722+
return {};
723+
}
724+
// Edit Mode: ask user confirmation before running bash
725+
const command = input.tool_input.command || "";
726+
console.log("[Phoenix AI] Bash confirmation requested:", command.slice(0, 80));
727+
nodeConnector.triggerPeer("aiBashConfirm", {
728+
requestId: requestId,
729+
command: command,
730+
toolId: toolCounter
731+
});
732+
const response = await new Promise((resolve, reject) => {
733+
_bashConfirmResolve = resolve;
734+
if (signal.aborted) {
735+
_bashConfirmResolve = null;
736+
reject(new Error("Aborted"));
737+
return;
738+
}
739+
const onAbort = () => {
740+
_bashConfirmResolve = null;
741+
reject(new Error("Aborted"));
742+
};
743+
signal.addEventListener("abort", onAbort, { once: true });
744+
});
745+
if (response.allowed) {
746+
return {};
747+
}
748+
return {
749+
hookSpecificOutput: {
750+
hookEventName: "PreToolUse",
751+
permissionDecision: "deny",
752+
permissionDecisionReason: "User denied this command."
753+
}
754+
};
755+
}
756+
]
757+
},
699758
{
700759
matcher: "AskUserQuestion",
701760
hooks: [

src/nls/root/strings.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,7 +1927,13 @@ define({
19271927
"AI_CHAT_PLAN_FEEDBACK_PLACEHOLDER": "What would you like changed?",
19281928
"AI_CHAT_PLAN_REVISE_DEFAULT": "Please revise the plan.",
19291929
"AI_CHAT_MODE_PLAN": "Plan Mode",
1930+
"AI_CHAT_MODE_EDIT": "Edit Mode",
19301931
"AI_CHAT_MODE_FULL_AUTO": "Full Auto",
1932+
"AI_CHAT_BASH_CONFIRM_TITLE": "Allow command?",
1933+
"AI_CHAT_BASH_ALLOW": "Allow",
1934+
"AI_CHAT_BASH_DENY": "Deny",
1935+
"AI_CHAT_BASH_ALLOWED": "Command allowed",
1936+
"AI_CHAT_BASH_DENIED": "Command denied",
19311937
"AI_CHAT_CODE_DEFAULT_LANG": "text",
19321938
"AI_CHAT_CODE_COLLAPSE": "Collapse",
19331939
"AI_CHAT_CODE_EXPAND": "Expand",

src/styles/Extn-AIChatPanel.less

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,6 +1412,92 @@
14121412
}
14131413
}
14141414

1415+
/* ── Bash confirmation card ─────────────────────────────────────────── */
1416+
.ai-bash-confirm {
1417+
border: 1px solid rgba(243, 156, 18, 0.3);
1418+
border-radius: 8px;
1419+
background: rgba(243, 156, 18, 0.06);
1420+
margin: 8px 0;
1421+
overflow: hidden;
1422+
1423+
.ai-bash-confirm-header {
1424+
display: flex;
1425+
align-items: center;
1426+
gap: 8px;
1427+
padding: 8px 12px;
1428+
font-weight: 600;
1429+
font-size: @sidebar-content-font-size;
1430+
color: #f39c12;
1431+
border-bottom: 1px solid rgba(243, 156, 18, 0.15);
1432+
}
1433+
1434+
.ai-bash-confirm-body {
1435+
padding: 8px 12px;
1436+
1437+
pre {
1438+
margin: 0;
1439+
padding: 8px;
1440+
border-radius: 4px;
1441+
background: rgba(0, 0, 0, 0.2);
1442+
color: #e0e0e0;
1443+
font-size: @sidebar-content-font-size;
1444+
white-space: pre-wrap;
1445+
word-break: break-all;
1446+
}
1447+
}
1448+
1449+
.ai-bash-confirm-actions {
1450+
display: flex;
1451+
gap: 8px;
1452+
padding: 8px 12px;
1453+
border-top: 1px solid rgba(243, 156, 18, 0.15);
1454+
}
1455+
1456+
.ai-bash-allow-btn {
1457+
background: rgba(76, 175, 80, 0.15);
1458+
border: 1px solid rgba(76, 175, 80, 0.35);
1459+
color: #81c784;
1460+
padding: 4px 14px;
1461+
border-radius: 4px;
1462+
cursor: pointer;
1463+
font-size: @sidebar-xs-font-size;
1464+
1465+
&:hover {
1466+
background: rgba(76, 175, 80, 0.25);
1467+
}
1468+
}
1469+
1470+
.ai-bash-deny-btn {
1471+
background: rgba(231, 76, 60, 0.15);
1472+
border: 1px solid rgba(231, 76, 60, 0.35);
1473+
color: #e57373;
1474+
padding: 4px 14px;
1475+
border-radius: 4px;
1476+
cursor: pointer;
1477+
font-size: @sidebar-xs-font-size;
1478+
1479+
&:hover {
1480+
background: rgba(231, 76, 60, 0.25);
1481+
}
1482+
}
1483+
1484+
}
1485+
1486+
.ai-bash-result {
1487+
padding: 4px 10px;
1488+
border-radius: 4px;
1489+
font-size: @sidebar-xs-font-size;
1490+
margin: 4px 0;
1491+
1492+
&.ai-bash-result-allowed {
1493+
color: #81c784;
1494+
}
1495+
1496+
&.ai-bash-result-denied {
1497+
color: #e57373;
1498+
}
1499+
}
1500+
14151501
/* ── Queued clarification bubble (static, above input) ─────────────── */
14161502
.ai-queued-msg {
14171503
border: 1px dashed rgba(255, 255, 255, 0.15);
@@ -1647,6 +1733,10 @@
16471733
background-color: #e74c3c;
16481734
}
16491735

1736+
&.mode-edit {
1737+
background-color: #f39c12;
1738+
}
1739+
16501740
&.mode-plan {
16511741
background-color: #3498db;
16521742
}

0 commit comments

Comments
 (0)