one-code-extension 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissions extension — Claude Code-style permission system.
|
|
3
|
+
*
|
|
4
|
+
* - Rules from ~/.claude/settings.json + <project>/.claude/settings.json +
|
|
5
|
+
* settings.local.json (Claude Code format, PascalCase tool names accepted).
|
|
6
|
+
* - Modes: default (manual) | acceptEdits | plan | auto | bypassPermissions |
|
|
7
|
+
* dontAsk, set via --permission-mode / --dangerously-skip-permissions, and
|
|
8
|
+
* cycled with ctrl+q (Claude Code uses shift+tab, which pi reserves for the
|
|
9
|
+
* thinking dial). The cycle is manual → accept edits → plan → [bypass] →
|
|
10
|
+
* [auto], matching Claude Code's order and its rules about which stops appear.
|
|
11
|
+
* - Ask-tier calls prompt Yes / Yes-for-session / No; non-interactive modes
|
|
12
|
+
* deny with an instructive reason instead of prompting.
|
|
13
|
+
* - Auto mode replaces the prompt with an approval classifier (see
|
|
14
|
+
* `extensions/auto-mode`). This file owns the gate; that directory owns the
|
|
15
|
+
* rules, the shell pre-gate, and the classifier call. Protected-path writes
|
|
16
|
+
* are checked here before allow rules, so no allow rule can pre-approve a
|
|
17
|
+
* write to `.git/hooks` or `.claude`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import {
|
|
24
|
+
type ChildAction,
|
|
25
|
+
renderActions,
|
|
26
|
+
SUBAGENT_ACTIONS_CHANNEL,
|
|
27
|
+
type SubagentActionsPayload,
|
|
28
|
+
} from "../auto-mode/actions.ts";
|
|
29
|
+
import { classify, createClassifierState } from "../auto-mode/classifier.ts";
|
|
30
|
+
import {
|
|
31
|
+
type AutoModeConfig,
|
|
32
|
+
autoModeSettingsPaths,
|
|
33
|
+
loadAutoModeConfig,
|
|
34
|
+
loadAutoModeConfigWithDiagnostics,
|
|
35
|
+
persistClassifierModel,
|
|
36
|
+
} from "../auto-mode/config.ts";
|
|
37
|
+
import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../auto-mode/model-picker.ts";
|
|
38
|
+
import { DEFAULT_ALLOW, DEFAULT_ENVIRONMENT, DEFAULT_HARD_DENY, DEFAULT_SOFT_DENY } from "../auto-mode/defaults.ts";
|
|
39
|
+
import { appendDecision, type DecisionEntry, decisionEntry } from "../auto-mode/decision-log.ts";
|
|
40
|
+
import { loadProjectInstructions } from "../auto-mode/instructions.ts";
|
|
41
|
+
import { classifierCandidates, describeCandidate, findConfigured } from "../auto-mode/model-select.ts";
|
|
42
|
+
import { resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
|
|
43
|
+
import { PauseTracker } from "../auto-mode/pause.ts";
|
|
44
|
+
import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
|
|
45
|
+
import { analyzeShellCommand, type ShellEvidence } from "../auto-mode/shell-analysis.ts";
|
|
46
|
+
import { projectMemoryDir } from "../lib/memory.ts";
|
|
47
|
+
import { sessionScratchpadDir } from "../lib/scratchpad.ts";
|
|
48
|
+
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
49
|
+
import {
|
|
50
|
+
decide,
|
|
51
|
+
extractSubject,
|
|
52
|
+
normalizeToolName,
|
|
53
|
+
parseRule,
|
|
54
|
+
parseRules,
|
|
55
|
+
type PermissionMode,
|
|
56
|
+
type PermissionRule,
|
|
57
|
+
} from "./matcher.ts";
|
|
58
|
+
import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "./modes.ts";
|
|
59
|
+
import { ORIGINAL_COMMAND_KEY } from "../worktree/rewrite.ts";
|
|
60
|
+
import { isWritingTool } from "./protected-paths.ts";
|
|
61
|
+
import { loadPermissionSettings, normalizePermissionMode, persistAllowRule, settingsPaths } from "./settings.ts";
|
|
62
|
+
|
|
63
|
+
const DENIED_BY_USER =
|
|
64
|
+
"The user doesn't want to proceed with this tool use. The tool use was rejected. Adjust your approach based on the user's feedback instead of retrying the same call.";
|
|
65
|
+
const DENIED_NON_INTERACTIVE =
|
|
66
|
+
"Permission required but this session is non-interactive, so the user cannot approve the call. It was blocked. Only pre-approved tools can run here; work within those, or ask the user to re-run interactively or with an allow rule / --dangerously-skip-permissions.";
|
|
67
|
+
const DENIED_PLAN_MODE =
|
|
68
|
+
"You are in plan mode: only read-only tools may run. Do not attempt mutations; present your plan to the user instead.";
|
|
69
|
+
const DENIED_DONT_ASK =
|
|
70
|
+
"Permission mode is dontAsk: anything that would normally prompt the user is denied instead. Only pre-approved tools can run; work within those, or tell the user which allow rule would unblock you.";
|
|
71
|
+
const DENIED_PROTECTED_PATH =
|
|
72
|
+
"That path is protected: it configures the user's tooling or this agent itself, so writes to it are never auto-approved and allow rules do not cover them. Achieve the goal another way, or ask the user to make the change.";
|
|
73
|
+
const DENIED_BY_CLASSIFIER = (reason: string) =>
|
|
74
|
+
`Blocked by the auto-mode approval classifier: ${reason}\n\nDo not retry the same call and do not try to work around the block. If you believe the action is what the user asked for, say so and let them decide.`;
|
|
75
|
+
const BLOCKED_BY_TIMEOUT = (reason: string) =>
|
|
76
|
+
`${reason}\n\nThis was not a judgement that the call is unsafe — the classifier could not respond in time. Tell the user what you were about to do so they can re-run interactively or pin a faster classifier; do not try to route around the gate.`;
|
|
77
|
+
const DENIED_SAFETY_FLOOR = (reason: string) =>
|
|
78
|
+
`Auto mode blocked this call without consulting the classifier: ${reason}. Writes to the gate's own configuration are never auto-approved. Do not retry or route around this; ask the user to make the change themselves.`;
|
|
79
|
+
/**
|
|
80
|
+
* Why a delegation call reaches the classifier. Auto mode judges the task before
|
|
81
|
+
* the child starts, so the thing to weigh is the instruction being handed over,
|
|
82
|
+
* not the tool call's mechanics.
|
|
83
|
+
*/
|
|
84
|
+
const DELEGATION_ROUTE_REASONS: Record<string, string | undefined> = {
|
|
85
|
+
subagent:
|
|
86
|
+
"This spawns a subagent: a fresh agent loop that will act on the task text below with the same permissions as this session. Judge the delegated task itself — whether carrying it out would require anything the rules forbid — because the child will not refuse a task its parent should not have handed it.",
|
|
87
|
+
workflow:
|
|
88
|
+
"This launches a multi-agent workflow whose script fans work out to subagents. Judge what the script sets out to do, since its agents inherit this session's permissions.",
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The default: nothing more specific than "no rule pre-approved this". Stated
|
|
92
|
+
* explicitly rather than left blank, because a classifier told nothing about why
|
|
93
|
+
* it is being asked will supply its own answer.
|
|
94
|
+
*/
|
|
95
|
+
const RESIDUAL_ROUTE_REASON =
|
|
96
|
+
"No permission rule covered this call and it is not a read or a working-directory edit, which is why it reaches you. There is nothing unusual about it beyond that — judge it on the rules and the user's request.";
|
|
97
|
+
const DENIED_BY_RULE = (rule: string) =>
|
|
98
|
+
`This tool call is denied by the permission rule "${rule}" in the user's settings. Do not retry it; choose a different approach.`;
|
|
99
|
+
|
|
100
|
+
export default function permissionsExtension(pi: ExtensionAPI) {
|
|
101
|
+
pi.registerFlag("permission-mode", {
|
|
102
|
+
description:
|
|
103
|
+
"Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
|
|
104
|
+
type: "string",
|
|
105
|
+
});
|
|
106
|
+
pi.registerFlag("dangerously-skip-permissions", {
|
|
107
|
+
description: "Skip all permission prompts (Claude Code compatible)",
|
|
108
|
+
type: "boolean",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Subagent children (pi subprocesses spawned by pi-subagents) inherit the
|
|
113
|
+
* parent session's permission mode, Claude Code-style: the parent exports
|
|
114
|
+
* it via env, the child's copy of this extension reads it back.
|
|
115
|
+
*/
|
|
116
|
+
const MODE_ENV = "CC_PERMISSION_MODE";
|
|
117
|
+
const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
118
|
+
|
|
119
|
+
let mode: PermissionMode = "default";
|
|
120
|
+
/** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
|
|
121
|
+
let bypassInCycle = false;
|
|
122
|
+
/** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
|
|
123
|
+
let autoInCycle = false;
|
|
124
|
+
let deny: PermissionRule[] = [];
|
|
125
|
+
let ask: PermissionRule[] = [];
|
|
126
|
+
let allow: PermissionRule[] = [];
|
|
127
|
+
const sessionAllows: PermissionRule[] = [];
|
|
128
|
+
/** Plan mode's one writable file, announced by the plan-mode extension. */
|
|
129
|
+
let planFilePath: string | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* The session's auto-memory and scratchpad dirs, re-derived rather than
|
|
132
|
+
* shared with the extensions that own them (jiti isolates module state);
|
|
133
|
+
* decide() allows writes landing inside them.
|
|
134
|
+
*/
|
|
135
|
+
let memoryDirPath: string | undefined;
|
|
136
|
+
let scratchpadDirPath: string | undefined;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Mode changes arrive over the event bus too (plan-mode tools), where no ctx
|
|
140
|
+
* is passed, so the badge is painted through the last ctx seen. session_start
|
|
141
|
+
* refreshes it, which also covers reloads replacing the session.
|
|
142
|
+
*/
|
|
143
|
+
let badgeCtx: ExtensionContext | undefined;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The classifier the banner and badge should name: the pinned one once a
|
|
147
|
+
* call has settled it, otherwise the chain's first candidate — the thing
|
|
148
|
+
* that *will* screen the next call, worth showing before it happens.
|
|
149
|
+
*/
|
|
150
|
+
const classifierForDisplay = (): { classifier?: string; pinned: boolean } => {
|
|
151
|
+
if (classifierState.pinned) {
|
|
152
|
+
return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
|
|
153
|
+
}
|
|
154
|
+
if (!badgeCtx) return { pinned: false };
|
|
155
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
156
|
+
const chain = classifierCandidates({
|
|
157
|
+
available: badgeCtx.modelRegistry.getAvailable(),
|
|
158
|
+
sessionModel: badgeCtx.model,
|
|
159
|
+
configured: autoConfig.classifierModel,
|
|
160
|
+
}).filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
161
|
+
const first = chain[0];
|
|
162
|
+
return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const applyBadge = () => {
|
|
166
|
+
// setStatus is a no-op outside the TUI, so this is safe unconditionally.
|
|
167
|
+
badgeCtx?.ui.setStatus(
|
|
168
|
+
"permission-mode",
|
|
169
|
+
modeBadge(mode, {
|
|
170
|
+
paused: pauseTracker.isPaused(),
|
|
171
|
+
classifierModel: classifierState.pinned?.id,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
// The banner shows mode and classifier live; it listens on the bus
|
|
175
|
+
// because jiti isolates module state between extensions.
|
|
176
|
+
const display = mode === "auto" ? classifierForDisplay() : { pinned: false };
|
|
177
|
+
pi.events.emit(PERMISSION_STATUS_CHANNEL, {
|
|
178
|
+
mode,
|
|
179
|
+
paused: pauseTracker.isPaused(),
|
|
180
|
+
classifier: display.classifier,
|
|
181
|
+
pinned: display.pinned,
|
|
182
|
+
} satisfies PermissionStatus);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Auto-mode state. Loaded lazily: most sessions never enter auto mode. */
|
|
186
|
+
let autoConfig: AutoModeConfig | undefined;
|
|
187
|
+
|
|
188
|
+
// Drop the cached config and classifier selection state, then refresh the badge.
|
|
189
|
+
// Shared by the "set classifier model" and "clear" paths.
|
|
190
|
+
const resetClassifierChoice = () => {
|
|
191
|
+
autoConfig = undefined; // reloaded lazily, now carrying any new model
|
|
192
|
+
classifierState.pinned = undefined;
|
|
193
|
+
classifierState.rejected.clear();
|
|
194
|
+
classifierState.notified.clear();
|
|
195
|
+
applyBadge();
|
|
196
|
+
};
|
|
197
|
+
let projectInstructions: string | undefined;
|
|
198
|
+
let instructionsLoaded = false;
|
|
199
|
+
const pauseTracker = new PauseTracker();
|
|
200
|
+
/**
|
|
201
|
+
* Which model the classifier settled on. Held here so the choice is pinned for
|
|
202
|
+
* the session rather than re-resolved per call, and so a model that turns out
|
|
203
|
+
* to be unusable is not retried on every tool call.
|
|
204
|
+
*/
|
|
205
|
+
const classifierState = createClassifierState();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One JSONL line per gate decision when `autoMode.logDecisions` is set. The
|
|
209
|
+
* permissive direction is the reason this exists: allows are invisible in
|
|
210
|
+
* the UI by design, so the log is the only complete record of them.
|
|
211
|
+
*/
|
|
212
|
+
const logDecision = (ctx: ExtensionContext, entry: Omit<DecisionEntry, "ts">) => {
|
|
213
|
+
if (!autoConfig?.logDecisions) return;
|
|
214
|
+
try {
|
|
215
|
+
const file = join(ctx.sessionManager.getSessionDir(), "auto-mode-decisions.jsonl");
|
|
216
|
+
appendDecision(file, decisionEntry({ sessionId: ctx.sessionManager.getSessionId?.(), ...entry }));
|
|
217
|
+
} catch {
|
|
218
|
+
// Logging must never break the gate.
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** What would be tried, in order, before anything has been pinned. */
|
|
223
|
+
const describeChain = (ctx: ExtensionContext): string => {
|
|
224
|
+
const chain = classifierCandidates({
|
|
225
|
+
available: ctx.modelRegistry.getAvailable(),
|
|
226
|
+
sessionModel: ctx.model,
|
|
227
|
+
configured: autoConfig?.classifierModel,
|
|
228
|
+
});
|
|
229
|
+
return chain.length > 0 ? chain.map(describeCandidate).join(" → ") : "(no model available)";
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The user's own messages, and only those — the classifier's "explicit intent"
|
|
234
|
+
* tier must not be reachable from file contents or command output, or a
|
|
235
|
+
* prompt injection could manufacture its own authorisation. pi's `input`
|
|
236
|
+
* event fires for real user input, which is exactly that boundary.
|
|
237
|
+
*/
|
|
238
|
+
const userMessages: string[] = [];
|
|
239
|
+
pi.on("input", (event) => {
|
|
240
|
+
const text = event.text?.trim();
|
|
241
|
+
if (!text) return;
|
|
242
|
+
userMessages.push(text);
|
|
243
|
+
if (userMessages.length > 12) userMessages.shift();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run the deterministic pre-gate, then the classifier. The pre-gate may only
|
|
248
|
+
* ever conclude "safe" (see auto-mode/shell-analysis.ts); when it does, the
|
|
249
|
+
* classifier call is skipped entirely, which is what keeps read-heavy work
|
|
250
|
+
* from paying classifier latency on every call.
|
|
251
|
+
*/
|
|
252
|
+
const runClassifier = async (
|
|
253
|
+
toolName: string,
|
|
254
|
+
input: Record<string, unknown>,
|
|
255
|
+
subject: string,
|
|
256
|
+
ctx: ExtensionContext,
|
|
257
|
+
routedBecause: string,
|
|
258
|
+
) => {
|
|
259
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
260
|
+
if (!instructionsLoaded) {
|
|
261
|
+
projectInstructions = loadProjectInstructions(ctx.cwd, os.homedir());
|
|
262
|
+
instructionsLoaded = true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let evidence: ShellEvidence | undefined;
|
|
266
|
+
if (normalizeToolName(toolName) === "bash" && subject) {
|
|
267
|
+
evidence = analyzeShellCommand({ command: subject, cwd: ctx.cwd, home: os.homedir() });
|
|
268
|
+
if (evidence.verdict === "safe") {
|
|
269
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
270
|
+
return { decision: "allow" as const, reason: "", tier: undefined };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const verdict = await classify(
|
|
275
|
+
{ toolName, input, cwd: ctx.cwd, userMessages: [...userMessages], evidence, projectInstructions, routedBecause },
|
|
276
|
+
{
|
|
277
|
+
registry: ctx.modelRegistry,
|
|
278
|
+
sessionModel: ctx.model,
|
|
279
|
+
config: autoConfig,
|
|
280
|
+
signal: ctx.signal,
|
|
281
|
+
state: classifierState,
|
|
282
|
+
onNotice: (message, level) => {
|
|
283
|
+
ctx.ui.notify(message, level);
|
|
284
|
+
// The badge names the classifier, so it has to repaint when the first
|
|
285
|
+
// call settles which model that is.
|
|
286
|
+
badgeCtx ??= ctx;
|
|
287
|
+
applyBadge();
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
logDecision(ctx, {
|
|
292
|
+
tool: toolName,
|
|
293
|
+
subject,
|
|
294
|
+
outcome: verdict.decision,
|
|
295
|
+
source: "classifier",
|
|
296
|
+
tier: verdict.tier,
|
|
297
|
+
ruleId: verdict.ruleId,
|
|
298
|
+
reason: verdict.reason || undefined,
|
|
299
|
+
raw: verdict.raw,
|
|
300
|
+
model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
|
|
301
|
+
});
|
|
302
|
+
return verdict;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const setMode = (next: PermissionMode) => {
|
|
306
|
+
mode = next;
|
|
307
|
+
process.env[MODE_ENV] = next;
|
|
308
|
+
applyBadge();
|
|
309
|
+
// No plan branch: the plan-mode extension owns the "permission-mode"
|
|
310
|
+
// reminder while planning (it knows the plan file) and re-emits it every
|
|
311
|
+
// turn, so plan mode takes the generic path below — the announce is
|
|
312
|
+
// wanted, and the key removal is undone before the next request goes out.
|
|
313
|
+
// applyBadge above notifies that extension synchronously over the status
|
|
314
|
+
// channel.
|
|
315
|
+
if (mode === "auto") {
|
|
316
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
317
|
+
text:
|
|
318
|
+
"Auto mode is active: your tool calls run without per-action prompts, but each one is screened by an approval classifier that blocks anything irreversible, destructive, or aimed outside this environment. " +
|
|
319
|
+
"Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: explain what you were trying to do and let the user decide, rather than retrying it a different way. " +
|
|
320
|
+
"Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
|
|
321
|
+
scope: "every-turn",
|
|
322
|
+
key: "permission-mode",
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
// Keyed so cycling through several modes announces only the one settled on.
|
|
326
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
327
|
+
text: `The user's permission mode is now "${mode}".`,
|
|
328
|
+
key: "permission-mode-change",
|
|
329
|
+
});
|
|
330
|
+
pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const reloadSettings = (ctx: ExtensionContext) => {
|
|
335
|
+
const settings = loadPermissionSettings(ctx.cwd, os.homedir());
|
|
336
|
+
deny = parseRules(settings.deny);
|
|
337
|
+
ask = parseRules(settings.ask);
|
|
338
|
+
allow = parseRules(settings.allow);
|
|
339
|
+
// Dropped so edited autoMode rules and instruction files are picked up on
|
|
340
|
+
// reload rather than staying cached for the life of the process.
|
|
341
|
+
autoConfig = undefined;
|
|
342
|
+
instructionsLoaded = false;
|
|
343
|
+
|
|
344
|
+
const flagMode = normalizePermissionMode(pi.getFlag("permission-mode"));
|
|
345
|
+
const inheritedMode = isSubagentChild ? normalizePermissionMode(process.env[MODE_ENV]) : undefined;
|
|
346
|
+
if (pi.getFlag("dangerously-skip-permissions") === true) {
|
|
347
|
+
mode = "bypassPermissions";
|
|
348
|
+
} else if (flagMode) {
|
|
349
|
+
mode = flagMode;
|
|
350
|
+
} else if (inheritedMode) {
|
|
351
|
+
mode = inheritedMode;
|
|
352
|
+
} else if (settings.defaultMode) {
|
|
353
|
+
mode = settings.defaultMode;
|
|
354
|
+
}
|
|
355
|
+
bypassInCycle = mode === "bypassPermissions";
|
|
356
|
+
// Auto mode needs a model to run its classifier on; with none reachable it
|
|
357
|
+
// would block every call, so it stays out of the cycle instead — the same
|
|
358
|
+
// thing Claude Code does when auto mode's requirements aren't met.
|
|
359
|
+
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
360
|
+
if (mode === "plan") setMode("plan");
|
|
361
|
+
process.env[MODE_ENV] = mode;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
pi.on("session_start", (_event, ctx) => {
|
|
365
|
+
badgeCtx = ctx;
|
|
366
|
+
memoryDirPath = projectMemoryDir(ctx.cwd);
|
|
367
|
+
scratchpadDirPath = sessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
368
|
+
reloadSettings(ctx);
|
|
369
|
+
applyBadge();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Mode-change requests from other extensions (e.g. plan-mode tools).
|
|
373
|
+
pi.events.on("one-code:set-permission-mode", (data) => {
|
|
374
|
+
const requested = normalizePermissionMode((data as { mode?: unknown })?.mode);
|
|
375
|
+
if (requested) setMode(requested);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// The plan-mode extension announces the plan file (see PLAN_FILE_CHANNEL
|
|
379
|
+
// there); decide() then allows writes to that one path in plan mode.
|
|
380
|
+
pi.events.on("one-code:plan-file-path", (data) => {
|
|
381
|
+
const path = (data as { path?: unknown })?.path;
|
|
382
|
+
if (typeof path === "string" && path) planFilePath = path;
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Claude Code cycles permission modes with shift+tab; pi reserves that key
|
|
386
|
+
// for the thinking dial. ctrl+q is the one ctrl+letter pi leaves unbound
|
|
387
|
+
// that terminals don't already own (ctrl+m *is* Enter's byte, alt needs
|
|
388
|
+
// option-as-meta configured on macOS) — see docs/decisions.md.
|
|
389
|
+
pi.registerShortcut("ctrl+q", {
|
|
390
|
+
description: "Cycle permission mode",
|
|
391
|
+
handler: (ctx) => {
|
|
392
|
+
badgeCtx = ctx;
|
|
393
|
+
setMode(nextMode(mode, { bypassInCycle, autoInCycle }));
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// The most recent turn's context, reused by background subagent reviews, which
|
|
398
|
+
// fire off a channel event and so have no live ctx of their own.
|
|
399
|
+
let lastReviewCtx: ExtensionContext | undefined;
|
|
400
|
+
|
|
401
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
402
|
+
lastReviewCtx = ctx;
|
|
403
|
+
const normalizedTool = normalizeToolName(event.toolName);
|
|
404
|
+
const subject = extractSubject(normalizedTool, event.input as Record<string, unknown>);
|
|
405
|
+
// Resolved through symlinks so the protected-path check sees where a
|
|
406
|
+
// write actually lands, not how the path is spelled. Only for writing
|
|
407
|
+
// tools: a bash subject is a command line, not a path.
|
|
408
|
+
const resolvedSubject =
|
|
409
|
+
isWritingTool(normalizedTool) && subject
|
|
410
|
+
? resolveForContainment(toAbsolute(ctx.cwd, subject, os.homedir()))
|
|
411
|
+
: undefined;
|
|
412
|
+
// In a worktree session, worktree's tool_call handler (which runs before this
|
|
413
|
+
// one) cd-wraps bash commands for execution. Rule matching must evaluate the
|
|
414
|
+
// model's original command, not the wrapper — otherwise every configured Bash
|
|
415
|
+
// rule stops matching for the whole session. The classifier and safety floor
|
|
416
|
+
// keep reading event.input (the wrapped command that actually runs).
|
|
417
|
+
const original = (event.input as Record<string, unknown>)[ORIGINAL_COMMAND_KEY];
|
|
418
|
+
const matchSubject = normalizedTool === "bash" && typeof original === "string" ? original : subject;
|
|
419
|
+
const result = decide({
|
|
420
|
+
toolName: event.toolName,
|
|
421
|
+
subject: matchSubject,
|
|
422
|
+
cwd: ctx.cwd,
|
|
423
|
+
mode,
|
|
424
|
+
deny,
|
|
425
|
+
ask,
|
|
426
|
+
allow: [...allow, ...sessionAllows],
|
|
427
|
+
classifyAllShell: autoConfig?.classifyAllShell,
|
|
428
|
+
resolvedSubject,
|
|
429
|
+
planFilePath,
|
|
430
|
+
memoryDirPath,
|
|
431
|
+
scratchpadDirPath,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Auto mode's deterministic floor: a write to the files the gate is made
|
|
436
|
+
* of (permission settings, autoMode config) is never auto-approved — not
|
|
437
|
+
* by an allow rule, not by the classifier, whose hard-deny rules are only
|
|
438
|
+
* as strong as the model enforcing them. Interactive sessions always
|
|
439
|
+
* prompt; non-interactive runs block. Runs even when a rule would allow,
|
|
440
|
+
* because a session allow rule ("write", approved once) must not cover
|
|
441
|
+
* the write that disables every check after it.
|
|
442
|
+
*/
|
|
443
|
+
const floorReason =
|
|
444
|
+
mode === "auto"
|
|
445
|
+
? safetyControlWrite({
|
|
446
|
+
toolName: normalizeToolName(event.toolName),
|
|
447
|
+
input: event.input as Record<string, unknown>,
|
|
448
|
+
cwd: ctx.cwd,
|
|
449
|
+
home: os.homedir(),
|
|
450
|
+
})
|
|
451
|
+
: undefined;
|
|
452
|
+
|
|
453
|
+
if (result.decision === "allow" && !floorReason) return undefined;
|
|
454
|
+
|
|
455
|
+
if (result.decision === "deny") {
|
|
456
|
+
if (result.cause === "plan-mode") return { block: true, reason: DENIED_PLAN_MODE };
|
|
457
|
+
if (result.cause === "protected-path") return { block: true, reason: DENIED_PROTECTED_PATH };
|
|
458
|
+
// The only mode that denies (rather than allows) unmatched calls is dontAsk.
|
|
459
|
+
if (result.cause === "mode") return { block: true, reason: DENIED_DONT_ASK };
|
|
460
|
+
return { block: true, reason: DENIED_BY_RULE(result.rule?.raw ?? "deny") };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let classifierBlock: string | undefined;
|
|
464
|
+
if (floorReason) {
|
|
465
|
+
// Never reaches the classifier: fall through to the prompt below, or
|
|
466
|
+
// block outright where there is no one to ask.
|
|
467
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
468
|
+
if (!ctx.hasUI) {
|
|
469
|
+
logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
470
|
+
return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
|
|
471
|
+
}
|
|
472
|
+
logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
|
|
473
|
+
} else if (result.decision === "classify") {
|
|
474
|
+
// Auto mode pauses after repeated blocks and prompts instead, so a model
|
|
475
|
+
// looping against the classifier reaches the user rather than grinding.
|
|
476
|
+
if (!pauseTracker.isPaused()) {
|
|
477
|
+
const routedBecause =
|
|
478
|
+
result.cause === "protected-path"
|
|
479
|
+
? `This call writes ${subject}, which is a protected path: it configures the user's tooling or this agent itself (git hooks and config, editor and container settings, shell rc files, package-manager config, this agent's own permission settings). It is inside the working directory — that is not what makes it risky. Writes here take effect later without any further approval, and permission allow rules deliberately do not cover them, which is why you are being asked rather than the rules deciding. Judge whether the user asked for this specific change.`
|
|
480
|
+
: (DELEGATION_ROUTE_REASONS[normalizeToolName(event.toolName)] ?? RESIDUAL_ROUTE_REASON);
|
|
481
|
+
const outcome = await runClassifier(
|
|
482
|
+
event.toolName,
|
|
483
|
+
event.input as Record<string, unknown>,
|
|
484
|
+
subject,
|
|
485
|
+
ctx,
|
|
486
|
+
routedBecause,
|
|
487
|
+
);
|
|
488
|
+
if (outcome.decision === "allow") {
|
|
489
|
+
pauseTracker.recordAllow();
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A timeout is not a block the model earned by looping against the
|
|
494
|
+
// gate, so it does not count toward the auto-pause — and it is always
|
|
495
|
+
// the user's to approve, so it never takes the hard-deny path.
|
|
496
|
+
if (outcome.tier === "timeout") {
|
|
497
|
+
if (!ctx.hasUI) return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
|
|
498
|
+
classifierBlock = outcome.reason;
|
|
499
|
+
} else {
|
|
500
|
+
const tripped = pauseTracker.recordBlock({
|
|
501
|
+
toolName: event.toolName,
|
|
502
|
+
subject,
|
|
503
|
+
reason: outcome.reason,
|
|
504
|
+
tier: outcome.tier,
|
|
505
|
+
ruleId: outcome.ruleId,
|
|
506
|
+
raw: outcome.raw,
|
|
507
|
+
});
|
|
508
|
+
if (tripped) {
|
|
509
|
+
const { lifetime } = pauseTracker.stats();
|
|
510
|
+
ctx.ui.notify(
|
|
511
|
+
`Auto mode paused after ${lifetime} blocked call${lifetime === 1 ? "" : "s"} — approving a prompt resumes it.`,
|
|
512
|
+
"warning",
|
|
513
|
+
);
|
|
514
|
+
applyBadge();
|
|
515
|
+
}
|
|
516
|
+
// A hard-deny verdict is not the user's to override: prompting would
|
|
517
|
+
// hand back exactly what the tier exists to refuse.
|
|
518
|
+
if (outcome.tier === "hard_deny") {
|
|
519
|
+
return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
|
|
520
|
+
}
|
|
521
|
+
// Non-interactively there is no one to ask, so the classifier's own
|
|
522
|
+
// reason is more useful to the model than a generic refusal.
|
|
523
|
+
if (!ctx.hasUI) return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
|
|
524
|
+
classifierBlock = outcome.reason;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Paused, or a soft block worth asking about: fall through and prompt.
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ask
|
|
531
|
+
if (!ctx.hasUI) {
|
|
532
|
+
return { block: true, reason: DENIED_NON_INTERACTIVE };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const preview = subject.length > 200 ? `${subject.slice(0, 200)}…` : subject;
|
|
536
|
+
const title = floorReason
|
|
537
|
+
? `Auto mode never auto-approves this — ${event.toolName} ${floorReason}.\n\n ${preview}\n\n Allow it this once?`
|
|
538
|
+
: classifierBlock
|
|
539
|
+
? `Auto mode blocked this — allow it anyway?\n\n ${preview || "(no arguments)"}\n\n Classifier: ${classifierBlock}`
|
|
540
|
+
: result.cause === "protected-path"
|
|
541
|
+
? `Allow ${event.toolName} to write a protected path?\n\n ${preview}\n\n This path configures your tooling or this agent, so allow rules do not pre-approve it.`
|
|
542
|
+
: `Allow ${event.toolName}?\n\n ${preview || "(no arguments)"}`;
|
|
543
|
+
const YES = "Yes";
|
|
544
|
+
const YES_SESSION = "Yes, don't ask again this session";
|
|
545
|
+
const NO = "No, tell the agent what to do differently";
|
|
546
|
+
const choice = await ctx.ui.select(title, [YES, YES_SESSION, NO]);
|
|
547
|
+
|
|
548
|
+
// The user's answer is itself a gate decision worth recording — it is the
|
|
549
|
+
// ground truth a drifting classifier gets calibrated against.
|
|
550
|
+
if (mode === "auto" && (floorReason || classifierBlock)) {
|
|
551
|
+
logDecision(ctx, {
|
|
552
|
+
tool: event.toolName,
|
|
553
|
+
subject,
|
|
554
|
+
outcome: choice === YES || choice === YES_SESSION ? "allow" : "block",
|
|
555
|
+
source: "user",
|
|
556
|
+
reason: floorReason ?? classifierBlock,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Approving a prompted call is what resumes a paused auto mode.
|
|
561
|
+
if (choice === YES || choice === YES_SESSION) {
|
|
562
|
+
if (mode === "auto") {
|
|
563
|
+
const wasPaused = pauseTracker.isPaused();
|
|
564
|
+
pauseTracker.resume();
|
|
565
|
+
if (wasPaused) applyBadge();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (choice === YES) return undefined;
|
|
570
|
+
if (choice === YES_SESSION) {
|
|
571
|
+
const tool = normalizeToolName(event.toolName);
|
|
572
|
+
const rule = tool === "bash" && subject ? parseRule(`bash(${subject})`) : parseRule(tool);
|
|
573
|
+
if (rule) sessionAllows.push(rule);
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The "tell the agent what to do differently" option has to actually carry
|
|
578
|
+
// the user's words, or the model is left guessing why it was stopped.
|
|
579
|
+
const feedback = await ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip");
|
|
580
|
+
return {
|
|
581
|
+
block: true,
|
|
582
|
+
reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
pi.registerCommand("permissions", {
|
|
587
|
+
description: "Show permission mode and loaded rules",
|
|
588
|
+
handler: async (_args, ctx) => {
|
|
589
|
+
const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
|
|
590
|
+
const denials = pauseTracker.recentDenials();
|
|
591
|
+
const autoLines =
|
|
592
|
+
mode === "auto" || denials.length > 0
|
|
593
|
+
? [
|
|
594
|
+
`auto mode: ${pauseTracker.isPaused() ? "paused (approve a prompt to resume)" : "active"}, ${
|
|
595
|
+
pauseTracker.stats().lifetime
|
|
596
|
+
} blocked this session`,
|
|
597
|
+
...(denials.length > 0
|
|
598
|
+
? [
|
|
599
|
+
"recently denied:",
|
|
600
|
+
...denials.map((d) => ` ${d.toolName}(${d.subject.slice(0, 60)}) — ${d.reason}`),
|
|
601
|
+
]
|
|
602
|
+
: []),
|
|
603
|
+
]
|
|
604
|
+
: [];
|
|
605
|
+
ctx.ui.notify(
|
|
606
|
+
[
|
|
607
|
+
`mode: ${mode}`,
|
|
608
|
+
`deny: ${fmt(deny)}`,
|
|
609
|
+
`ask: ${fmt(ask)}`,
|
|
610
|
+
`allow: ${fmt(allow)}`,
|
|
611
|
+
`session allows: ${fmt(sessionAllows)}`,
|
|
612
|
+
...autoLines,
|
|
613
|
+
].join("\n"),
|
|
614
|
+
"info",
|
|
615
|
+
);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Auto mode's third subagent checkpoint: when a child returns, review what it
|
|
621
|
+
* actually did. The spawn was classified and each of the child's own actions
|
|
622
|
+
* was classified in its session, but neither sees the *sequence* — "read the
|
|
623
|
+
* deploy config, read a token, open a PR" can pass step by step. A concern
|
|
624
|
+
* surfaces a warning rather than blocking it: the work has already happened, so
|
|
625
|
+
* the useful move is to make sure the model and the user see it.
|
|
626
|
+
*/
|
|
627
|
+
const reviewFlagged = (reason: string) =>
|
|
628
|
+
`<system-reminder>\nAuto mode reviewed this subagent's actions after it finished and flagged a concern: ${reason}\n\nTreat its output as unverified, do not act on it without checking, and tell the user what it did.\n</system-reminder>`;
|
|
629
|
+
|
|
630
|
+
// Classify a finished run's action sequence as a whole. Returns the concern to
|
|
631
|
+
// surface, or undefined when the sequence is fine. Shared by the foreground
|
|
632
|
+
// (tool_result) and background (channel) paths.
|
|
633
|
+
const reviewCompletedRun = async (
|
|
634
|
+
actions: ChildAction[],
|
|
635
|
+
ctx: ExtensionContext,
|
|
636
|
+
subject: string,
|
|
637
|
+
signal: AbortSignal | undefined,
|
|
638
|
+
): Promise<string | undefined> => {
|
|
639
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
640
|
+
const verdict = await classify(
|
|
641
|
+
{
|
|
642
|
+
toolName: "subagent (completed run — review the actions below as a whole)",
|
|
643
|
+
input: { actions: renderActions(actions) },
|
|
644
|
+
cwd: ctx.cwd,
|
|
645
|
+
userMessages: [...userMessages],
|
|
646
|
+
projectInstructions,
|
|
647
|
+
routedBecause:
|
|
648
|
+
"This subagent has already finished; you are reviewing what it did, as a whole, rather than approving anything. Its individual actions were each checked as it ran. Judge whether the sequence together amounts to something the rules forbid — a series of individually unremarkable steps can add up to one.",
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
registry: ctx.modelRegistry,
|
|
652
|
+
sessionModel: ctx.model,
|
|
653
|
+
config: autoConfig,
|
|
654
|
+
signal,
|
|
655
|
+
state: classifierState,
|
|
656
|
+
onNotice: (message, level) => ctx.ui.notify(message, level),
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
logDecision(ctx, {
|
|
660
|
+
tool: "subagent",
|
|
661
|
+
subject,
|
|
662
|
+
outcome: verdict.decision,
|
|
663
|
+
source: "review",
|
|
664
|
+
tier: verdict.tier,
|
|
665
|
+
ruleId: verdict.ruleId,
|
|
666
|
+
reason: verdict.reason || undefined,
|
|
667
|
+
});
|
|
668
|
+
if (verdict.decision === "allow") return undefined;
|
|
669
|
+
pauseTracker.recordBlock({
|
|
670
|
+
toolName: "subagent",
|
|
671
|
+
subject,
|
|
672
|
+
reason: verdict.reason,
|
|
673
|
+
tier: verdict.tier,
|
|
674
|
+
ruleId: verdict.ruleId,
|
|
675
|
+
raw: verdict.raw,
|
|
676
|
+
});
|
|
677
|
+
return verdict.reason;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const childActions = new Map<string, ChildAction[]>();
|
|
681
|
+
pi.events.on(SUBAGENT_ACTIONS_CHANNEL, (data) => {
|
|
682
|
+
const payload = data as SubagentActionsPayload | undefined;
|
|
683
|
+
if (!payload || !Array.isArray(payload.actions)) return;
|
|
684
|
+
if (!payload.background) {
|
|
685
|
+
// Foreground: hold the actions until the spawning call's tool_result.
|
|
686
|
+
if (payload.toolCallId) childActions.set(payload.toolCallId, payload.actions);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// Background/resident: the spawning call already returned, so there is no
|
|
690
|
+
// tool_result to attach to. Review now and surface any concern as its own
|
|
691
|
+
// follow-up, alongside the completion report the model receives.
|
|
692
|
+
const ctx = lastReviewCtx;
|
|
693
|
+
if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) return;
|
|
694
|
+
const label = payload.agentName ? `${payload.agentName} (background run)` : "background run";
|
|
695
|
+
void reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal).then((reason) => {
|
|
696
|
+
if (!reason) return;
|
|
697
|
+
pi.sendMessage(
|
|
698
|
+
{ customType: "subagent-review", content: [{ type: "text", text: reviewFlagged(reason) }], display: true, details: {} },
|
|
699
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
705
|
+
lastReviewCtx = ctx;
|
|
706
|
+
const actions = childActions.get(event.toolCallId);
|
|
707
|
+
if (actions) childActions.delete(event.toolCallId);
|
|
708
|
+
if (mode !== "auto" || !actions?.length || pauseTracker.isPaused()) return undefined;
|
|
709
|
+
const reason = await reviewCompletedRun(actions, ctx, "completed run", ctx.signal);
|
|
710
|
+
if (!reason) return undefined;
|
|
711
|
+
return { content: [{ type: "text" as const, text: reviewFlagged(reason) }, ...event.content] };
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Apply a chosen classifier model: validate auth first (a persisted model
|
|
716
|
+
* with no credentials would fail every call), persist to user scope, and
|
|
717
|
+
* release the session pin so the choice takes effect on the next call
|
|
718
|
+
* rather than after a restart.
|
|
719
|
+
*/
|
|
720
|
+
const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
|
|
721
|
+
const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
|
|
722
|
+
if (!model) {
|
|
723
|
+
ctx.ui.notify(`No available model matches "${spec}" — check /auto-mode config for the catalog name.`, "error");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const resolved = `${model.provider}/${model.id}`;
|
|
727
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
728
|
+
if (!auth.ok) {
|
|
729
|
+
ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
persistClassifierModel(resolved, os.homedir());
|
|
734
|
+
} catch (error) {
|
|
735
|
+
ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
resetClassifierChoice();
|
|
739
|
+
ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.claude/settings.json).`, "info");
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
/** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
|
|
743
|
+
const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
|
|
744
|
+
if (remainder === "clear") {
|
|
745
|
+
try {
|
|
746
|
+
persistClassifierModel(undefined, os.homedir());
|
|
747
|
+
} catch (error) {
|
|
748
|
+
ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
resetClassifierChoice();
|
|
752
|
+
ctx.ui.notify(`autoMode.classifierModel cleared. Auto mode will choose: ${describeChain(ctx)}`, "info");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (remainder) {
|
|
756
|
+
await applyClassifierChoice(remainder, ctx);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
761
|
+
if (available.length === 0) {
|
|
762
|
+
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// The picker needs focus and a terminal; elsewhere say what to type.
|
|
766
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
767
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
768
|
+
ctx.ui.notify(
|
|
769
|
+
`classifierModel: ${autoConfig.classifierModel ?? "(not set)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
|
|
770
|
+
"info",
|
|
771
|
+
);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
776
|
+
const current = autoConfig.classifierModel;
|
|
777
|
+
const entries = toPickerEntries(available);
|
|
778
|
+
|
|
779
|
+
const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
780
|
+
modelPickerComponent({ entries, current }, tui, theme, done),
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
pi.registerCommand("auto-mode", {
|
|
787
|
+
description: "Auto-mode classifier: /auto-mode [defaults|config|model [provider/model-id|clear]]",
|
|
788
|
+
getArgumentCompletions: () =>
|
|
789
|
+
[
|
|
790
|
+
{ value: "config", label: "effective rules" },
|
|
791
|
+
{ value: "defaults", label: "built-in rules" },
|
|
792
|
+
{ value: "model", label: "choose the classifier model" },
|
|
793
|
+
],
|
|
794
|
+
handler: async (args, ctx) => {
|
|
795
|
+
const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
796
|
+
if (sub === "model") {
|
|
797
|
+
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const which = sub ?? "config";
|
|
801
|
+
if (which !== "defaults" && which !== "config") {
|
|
802
|
+
ctx.ui.notify(
|
|
803
|
+
`Unknown subcommand "${which}". Use: /auto-mode defaults | config | model [provider/model-id|clear]`,
|
|
804
|
+
"warning",
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
// "defaults" prints the built-in lists; "config" prints what the
|
|
809
|
+
// classifier actually uses, with $defaults already spliced in. The
|
|
810
|
+
// config view re-reads disk so it shows the file as it is now, and
|
|
811
|
+
// refreshes the gate's cached copy while it is at it.
|
|
812
|
+
const loaded = which === "config" ? loadAutoModeConfigWithDiagnostics(os.homedir()) : undefined;
|
|
813
|
+
if (loaded) autoConfig = loaded.config;
|
|
814
|
+
const shown = loaded
|
|
815
|
+
? loaded.config
|
|
816
|
+
: {
|
|
817
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
818
|
+
allow: DEFAULT_ALLOW,
|
|
819
|
+
soft_deny: DEFAULT_SOFT_DENY,
|
|
820
|
+
hard_deny: DEFAULT_HARD_DENY,
|
|
821
|
+
};
|
|
822
|
+
const section = (title: string, entries: string[]) =>
|
|
823
|
+
`${title} (${entries.length}):\n${entries.map((entry) => ` - ${entry}`).join("\n")}`;
|
|
824
|
+
ctx.ui.notify(
|
|
825
|
+
[
|
|
826
|
+
which === "config"
|
|
827
|
+
? `read from: ${autoModeSettingsPaths(os.homedir()).join(", ")}\n(project settings are deliberately not read — a repo could otherwise grant itself allow rules)`
|
|
828
|
+
: "built-in rules; add \"$defaults\" to a list in settings to keep them",
|
|
829
|
+
section("hard_deny", shown.hard_deny),
|
|
830
|
+
section("soft_deny", shown.soft_deny),
|
|
831
|
+
section("allow", shown.allow),
|
|
832
|
+
section("environment", shown.environment),
|
|
833
|
+
...(which === "config" && "classifyAllShell" in shown
|
|
834
|
+
? [
|
|
835
|
+
`classifyAllShell: ${shown.classifyAllShell}`,
|
|
836
|
+
`logDecisions: ${shown.logDecisions} (auto-mode-decisions.jsonl next to the session files)`,
|
|
837
|
+
`classifierModel: ${shown.classifierModel ?? "(not set — /auto-mode model chooses one)"}`,
|
|
838
|
+
// Which model actually screens calls, and why — this reads the
|
|
839
|
+
// user's prompts, so it should not take knowing the code to find out.
|
|
840
|
+
`classifier in use: ${
|
|
841
|
+
classifierState.pinned
|
|
842
|
+
? `${classifierState.pinned.provider}/${classifierState.pinned.id} (pinned for this session)`
|
|
843
|
+
: describeChain(ctx)
|
|
844
|
+
}`,
|
|
845
|
+
...(classifierState.rejected.size > 0
|
|
846
|
+
? [`unusable this session: ${[...classifierState.rejected].join(", ")}`]
|
|
847
|
+
: []),
|
|
848
|
+
]
|
|
849
|
+
: []),
|
|
850
|
+
...(loaded && loaded.diagnostics.length > 0
|
|
851
|
+
? [`settings problems:\n${loaded.diagnostics.map((line) => ` ! ${line}`).join("\n")}`]
|
|
852
|
+
: []),
|
|
853
|
+
].join("\n\n"),
|
|
854
|
+
loaded && loaded.diagnostics.length > 0 ? "warning" : "info",
|
|
855
|
+
);
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
pi.registerCommand("allow", {
|
|
860
|
+
description: 'Persist an allow rule: /allow Bash(npm test:*) [global]',
|
|
861
|
+
handler: async (args, ctx) => {
|
|
862
|
+
const global = /\s+global$/.test(args.trim());
|
|
863
|
+
const raw = args.trim().replace(/\s+global$/, "");
|
|
864
|
+
const rule = parseRule(raw);
|
|
865
|
+
if (!rule) {
|
|
866
|
+
ctx.ui.notify(`Could not parse rule: "${raw}". Format: Tool or Tool(pattern)`, "warning");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const paths = settingsPaths(ctx.cwd, os.homedir());
|
|
870
|
+
const target = global ? paths.user : paths.local;
|
|
871
|
+
persistAllowRule(raw, target);
|
|
872
|
+
allow.push(rule);
|
|
873
|
+
ctx.ui.notify(`Added allow rule ${raw} to ${target}`, "info");
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
}
|