Files
my-pi/pi-permission-system/src/authority/permission-prompt-decision.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2aa1be0b54 feat: 组合包配置以包内 config/ 为唯一来源,并新增受保护写入人工审批
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的
RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展
直接读取已安装组合包内的 config/,pi install / pi update 即完成部署;
删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留
日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本
reset 回发布版本。AGENTS.md 新增「组合包配置原则」。

脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh
search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程
安装器改为先下载到临时文件再执行,下载失败可被检测。

权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装
组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、
yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、
web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 01:05:31 -07:00

290 lines
7.9 KiB
TypeScript

import {
createDeniedPermissionDecision,
normalizePermissionDenialReason,
type RequestPermissionOptions,
type UnattributedDecision,
} from "#src/authority/permission-dialog";
/**
* Pure decision model for the inline keybind permission dialog.
*
* The interaction logic — which hotkey produces which decision, double-press
* arming, step transitions, and reason validation — lives here with no SDK or
* TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
* ({@link file://./permission-prompt-component.ts}) is a thin adapter that
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
*/
/** The four decision hotkeys, in display order. */
export type PromptKey = "y" | "s" | "n" | "r";
/** Which sub-view the dialog is showing. */
export type PromptStep = "decision" | "reason" | "scope";
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
const OPTION_VERBS: Record<PromptKey, string> = {
y: "approve",
s: "approve for this session",
n: "deny",
r: "deny with a reason",
};
/**
* The keys this prompt offers: `OPTION_ORDER` minus `s` when
* {@link PromptModelConfig.hideSessionOption} suppresses the session grant
* (a protected-write ask must be confirmed every time). Shared by the
* reducer and the component renderer so the two never disagree about which
* options exist.
*/
export function enabledPromptKeys(
config: PromptModelConfig,
): readonly PromptKey[] {
return config.hideSessionOption
? OPTION_ORDER.filter((key) => key !== "s")
: OPTION_ORDER;
}
/** Static configuration for a single prompt presentation. */
export interface PromptModelConfig {
/** When true, a letter hotkey arms first and commits only on a second press. */
doublePressToConfirm: boolean;
/** Label shown beside the approve-for-session option. */
sessionLabel: string;
/**
* Forwarded asks only: when set, confirming `s` opens a second step choosing
* whether the grant applies to the requesting subagent only (least-privilege
* default) or the whole serving session.
*/
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
/**
* Protected-write asks only: suppress the `s` option entirely — a
* human-only write must be confirmed every time.
*/
hideSessionOption?: boolean;
}
/** The re-render view state the component draws from. */
export interface PromptViewState {
step: PromptStep;
highlightedKey: PromptKey;
/** Set only while awaiting the confirming second press of a hotkey. */
armedKey?: PromptKey;
/** "Press y again to approve." while armed; empty otherwise. */
hint: string;
/** Set when an empty reason submit is rejected. */
reasonError?: string;
/** Scope step: false = subagent-only (default), true = whole serving session. */
scopeServing: boolean;
}
/** An input event the reducer understands. */
export type PromptEvent =
| { type: "nav"; direction: "up" | "down" }
| { type: "hotkey"; key: PromptKey }
| { type: "confirm" }
| { type: "cancel" }
| { type: "submitReason"; draft: string };
/** Either a re-render or a terminal decision. */
export type PromptOutcome =
| { kind: "render"; state: PromptViewState }
| { kind: "decision"; decision: UnattributedDecision };
export function initialPromptState(
_config: PromptModelConfig,
): PromptViewState {
return {
step: "decision",
highlightedKey: "y",
armedKey: undefined,
hint: "",
reasonError: undefined,
scopeServing: false,
};
}
/**
* Advance the dialog by one input event, returning either the next view state
* to render or the committed {@link UnattributedDecision}.
*
* The model states the outcome and not the decider: which human surface this
* is gets attributed by the dispatcher that chose to render this dialog, so
* the two cannot disagree about the surface.
*/
export function reducePrompt(
config: PromptModelConfig,
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (state.step) {
case "decision":
return reduceDecisionStep(config, state, event);
case "reason":
return reduceReasonStep(state, event);
case "scope":
return reduceScopeStep(state, event);
}
}
function reduceDecisionStep(
config: PromptModelConfig,
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (event.type) {
case "nav":
return render({
...state,
highlightedKey: shiftKey(
state.highlightedKey,
event.direction,
enabledPromptKeys(config),
),
armedKey: undefined,
hint: "",
});
case "hotkey":
if (!enabledPromptKeys(config).includes(event.key)) {
return render(state);
}
return pressHotkey(config, state, event.key);
case "confirm":
return commit(config, state, state.highlightedKey);
case "cancel":
return { kind: "decision", decision: createDeniedPermissionDecision() };
case "submitReason":
return render(state);
}
}
function pressHotkey(
config: PromptModelConfig,
state: PromptViewState,
key: PromptKey,
): PromptOutcome {
if (!config.doublePressToConfirm || state.armedKey === key) {
return commit(config, state, key);
}
return render({
...state,
highlightedKey: key,
armedKey: key,
hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
});
}
function commit(
config: PromptModelConfig,
state: PromptViewState,
key: PromptKey,
): PromptOutcome {
switch (key) {
case "y":
return {
kind: "decision",
decision: { approved: true, state: "approved" },
};
case "n":
return { kind: "decision", decision: createDeniedPermissionDecision() };
case "r":
return render({
...state,
step: "reason",
highlightedKey: "r",
armedKey: undefined,
hint: "",
reasonError: undefined,
});
case "s":
if (config.sessionScope) {
return render({
...state,
step: "scope",
highlightedKey: "s",
armedKey: undefined,
hint: "",
scopeServing: false,
});
}
return {
kind: "decision",
decision: { approved: true, state: "approved_for_session" },
};
}
}
function reduceReasonStep(
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
if (event.type === "cancel") {
return render({
...state,
step: "decision",
armedKey: undefined,
hint: "",
reasonError: undefined,
});
}
if (event.type === "submitReason") {
const reason = normalizePermissionDenialReason(event.draft);
if (reason === undefined) {
return render({
...state,
reasonError: "A reason is required.",
});
}
return {
kind: "decision",
decision: createDeniedPermissionDecision(reason),
};
}
return render(state);
}
function reduceScopeStep(
state: PromptViewState,
event: PromptEvent,
): PromptOutcome {
switch (event.type) {
case "nav":
return render({ ...state, scopeServing: event.direction === "down" });
case "confirm":
return {
kind: "decision",
decision: {
approved: true,
state: state.scopeServing
? "approved_for_serving_session"
: "approved_for_session",
},
};
case "cancel":
return render({
...state,
step: "decision",
armedKey: undefined,
hint: "",
});
default:
return render(state);
}
}
function shiftKey(
current: PromptKey,
direction: "up" | "down",
keys: readonly PromptKey[],
): PromptKey {
const index = keys.indexOf(current);
if (index === -1) return keys[0] ?? current;
const delta = direction === "down" ? 1 : -1;
const next = (index + delta + keys.length) % keys.length;
return keys[next] ?? current;
}
function render(state: PromptViewState): PromptOutcome {
return { kind: "render", state };
}