one-code-extension 0.1.3 → 0.3.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 +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -202
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -19,31 +19,41 @@
|
|
|
19
19
|
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
22
23
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
type SubagentActionsPayload,
|
|
28
|
-
} from "../auto-mode/actions.ts";
|
|
24
|
+
import { type ChildAction, SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
|
|
25
|
+
import { createTaskNotifier } from "../lib/notifications.ts";
|
|
26
|
+
import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
|
|
27
|
+
import { customMessageText, notificationComponent } from "../lib/tui-render.ts";
|
|
29
28
|
import { classify, createClassifierState } from "../auto-mode/classifier.ts";
|
|
30
29
|
import {
|
|
31
30
|
type AutoModeConfig,
|
|
32
31
|
autoModeSettingsPaths,
|
|
33
32
|
loadAutoModeConfig,
|
|
33
|
+
claudeUserPermissionAllow,
|
|
34
34
|
loadAutoModeConfigWithDiagnostics,
|
|
35
|
+
oneCodePermissionAllow,
|
|
36
|
+
persistAutoModeSetup,
|
|
35
37
|
persistClassifierModel,
|
|
38
|
+
removeOneCodePermissionAllow,
|
|
36
39
|
} from "../auto-mode/config.ts";
|
|
40
|
+
import { auditPermissionAllow, renderProposal, settingsPatch } from "../auto-mode/setup.ts";
|
|
41
|
+
import { draftSetup, gatherFacts } from "../auto-mode/setup-run.ts";
|
|
37
42
|
import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../auto-mode/model-picker.ts";
|
|
38
|
-
import {
|
|
43
|
+
import { DEFAULT_ENVIRONMENT } from "../auto-mode/defaults.ts";
|
|
44
|
+
import type { TranscriptEntry } from "../auto-mode/transcript.ts";
|
|
39
45
|
import { appendDecision, type DecisionEntry, decisionEntry } from "../auto-mode/decision-log.ts";
|
|
40
46
|
import { loadProjectInstructions } from "../auto-mode/instructions.ts";
|
|
41
47
|
import { classifierCandidates, describeCandidate, findConfigured } from "../auto-mode/model-select.ts";
|
|
42
|
-
import {
|
|
48
|
+
import { modelIdentity } from "../lib/model-policy.ts";
|
|
49
|
+
import { isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
|
|
43
50
|
import { PauseTracker } from "../auto-mode/pause.ts";
|
|
51
|
+
import { checkRecoverability } from "../auto-mode/recoverability.ts";
|
|
44
52
|
import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
|
|
53
|
+
import { isExecutionPrimitivePath, isSensitivePath } from "../auto-mode/sensitive.ts";
|
|
45
54
|
import { analyzeShellCommand, type ShellEvidence } from "../auto-mode/shell-analysis.ts";
|
|
46
55
|
import { projectMemoryDir } from "../lib/memory.ts";
|
|
56
|
+
import { sessionResultsDir } from "../lib/persisted-output.ts";
|
|
47
57
|
import { sessionScratchpadDir } from "../lib/scratchpad.ts";
|
|
48
58
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
49
59
|
import {
|
|
@@ -51,53 +61,120 @@ import {
|
|
|
51
61
|
extractSubject,
|
|
52
62
|
normalizeToolName,
|
|
53
63
|
parseRule,
|
|
54
|
-
|
|
64
|
+
parseRulesReport,
|
|
55
65
|
type PermissionMode,
|
|
56
66
|
type PermissionRule,
|
|
67
|
+
isPathSubjectTool,
|
|
57
68
|
} from "./matcher.ts";
|
|
69
|
+
import { type SessionGrant, sessionGrant } from "./session-grant.ts";
|
|
58
70
|
import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "./modes.ts";
|
|
59
|
-
import {
|
|
71
|
+
import { type ChildToolCall, type ChildGateDecision, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
|
|
72
|
+
import { trackOriginalCommands } from "../lib/original-command.ts";
|
|
73
|
+
import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
|
|
60
74
|
import { isWritingTool } from "./protected-paths.ts";
|
|
61
|
-
import {
|
|
75
|
+
import { denyRuleLines } from "./rule-prose.ts";
|
|
76
|
+
import { loadPermissionSettings, normalizePermissionMode, persistAllowRule, resolveStartupMode } from "./settings.ts";
|
|
77
|
+
import { MODE_ENV, resolvedOrSelf, runtimeProtectedDirs } from "../lib/permission-gate.ts";
|
|
78
|
+
import { CLASSIFIER_SETTING_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
|
|
79
|
+
import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved } from "./project-trust.ts";
|
|
80
|
+
import { findProjectRoot } from "../lib/git.ts";
|
|
81
|
+
import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
|
|
82
|
+
import { recordUsage } from "../lib/usage-bus.ts";
|
|
62
83
|
|
|
63
84
|
const DENIED_BY_USER =
|
|
64
85
|
"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
86
|
const DENIED_NON_INTERACTIVE =
|
|
66
87
|
"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.";
|
|
88
|
+
// Must agree with the plan-mode reminder (plan-mode/reminder.ts): the plan is
|
|
89
|
+
// built in the plan file and approved through exit_plan_mode, never presented
|
|
90
|
+
// as chat text. A denial that said "present your plan to the user instead"
|
|
91
|
+
// contradicted the reminder in the same turn.
|
|
67
92
|
const DENIED_PLAN_MODE =
|
|
68
|
-
"You are in plan mode: only read-only tools may run.
|
|
93
|
+
"You are in plan mode: only read-only tools may run, and edit/write are limited to the plan file. This call was blocked. Keep investigating with read-only tools and build the plan in the plan file named in the plan-mode reminder; finish the turn with ask_user_question or exit_plan_mode, not by presenting the plan as chat text.";
|
|
69
94
|
const DENIED_DONT_ASK =
|
|
70
95
|
"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
96
|
const DENIED_PROTECTED_PATH =
|
|
72
97
|
"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.";
|
|
98
|
+
const DENIED_OUTSIDE_WORKING_DIR =
|
|
99
|
+
"That path is outside the working directory, which needs the user's approval, and permission mode is dontAsk (anything that would prompt is denied instead). Work inside the project, or tell the user which allow rule (e.g. Read(~/dir/**)) would unblock you.";
|
|
100
|
+
// Returned to the MODEL, not the user: auto mode exists to run unattended, so a
|
|
101
|
+
// block is handed back so the model can accomplish the goal a safe way — mirrors
|
|
102
|
+
// Claude Code's own auto-mode denial message rather than halting for a prompt.
|
|
73
103
|
const DENIED_BY_CLASSIFIER = (reason: string) =>
|
|
74
|
-
`
|
|
104
|
+
`Permission for this action was denied by the auto-mode approval classifier. Reason: ${reason}\n\n` +
|
|
105
|
+
"If you have other tasks that don't depend on this action, continue with those. You *may* try to accomplish the goal a different, safe way (e.g. a less destructive command, or committing first so a change is recoverable), but do *not* try to work around or defeat this denial. If you believe this capability is essential, STOP and explain to the user what you were trying to do and why, and let them decide.";
|
|
75
106
|
const BLOCKED_BY_TIMEOUT = (reason: string) =>
|
|
76
|
-
`${reason}\n\nThis was not a judgement that the call is unsafe
|
|
107
|
+
`${reason}\n\nThis was not a judgement that the call is unsafe. Wait a moment and try this action again. ` +
|
|
108
|
+
"If it keeps failing, continue with other tasks that do not require this action and come back to it later — " +
|
|
109
|
+
"reading files, searching code, and other read-only operations do not require the classifier and can still be used. " +
|
|
110
|
+
"Do not try to route around the gate; if this specific action is essential, tell the user what you were about to do so they can re-run interactively or pin a faster classifier with /auto-mode model.";
|
|
77
111
|
const DENIED_SAFETY_FLOOR = (reason: string) =>
|
|
78
112
|
`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.`;
|
|
113
|
+
// "Choose a different approach" is exactly what a literal reader does with a
|
|
114
|
+
// second command of the same effect: a tiny-tier model met `Bash(rm:*)` and
|
|
115
|
+
// deleted the file with `python3 -c "import os; os.remove(…)"` 3/3 times
|
|
116
|
+
// (WEAK-MODEL-REVIEW-2026-09-06 H2). A rule is the user's standing decision
|
|
117
|
+
// about a CLASS of action, so the denial has to say that equivalents are denied
|
|
118
|
+
// too — the wording the classifier's own denial already uses.
|
|
119
|
+
const DENIED_BY_RULE = (rule: string) =>
|
|
120
|
+
`This tool call is denied by the permission rule "${rule}" in the user's settings. The rule is the user's standing decision about this class of action: do not retry it, and do not achieve the same effect another way (a different command, a script, or another tool). Continue with work that does not depend on it, and tell the user what was denied and by which rule.`;
|
|
121
|
+
|
|
122
|
+
/** Truncate a subject for a permission prompt — shared by the main gate and the subagent bridge. */
|
|
123
|
+
const previewSubject = (subject: string) => (subject.length > 200 ? `${subject.slice(0, 200)}…` : subject);
|
|
124
|
+
|
|
79
125
|
/**
|
|
80
|
-
*
|
|
81
|
-
* the
|
|
82
|
-
*
|
|
126
|
+
* The ask-prompt title for a non-floor decision — shared by the main gate and
|
|
127
|
+
* the subagent bridge so a new `cause` is worded once. `actor` is the tool name,
|
|
128
|
+
* prefixed for a child ("subagent x's write").
|
|
83
129
|
*/
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
130
|
+
const askTitle = (actor: string, preview: string, cause: string, pausedResume: boolean): string => {
|
|
131
|
+
if (pausedResume) return `Auto mode is paused after repeated blocks — approve to resume.\n\n ${actor}: ${preview || "(no arguments)"}`;
|
|
132
|
+
if (cause === "protected-path")
|
|
133
|
+
return `Allow ${actor} 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.`;
|
|
134
|
+
if (cause === "working-dir") return `Allow ${actor} outside the working directory?\n\n ${preview || "(no arguments)"}`;
|
|
135
|
+
return `Allow ${actor}?\n\n ${preview || "(no arguments)"}`;
|
|
89
136
|
};
|
|
137
|
+
|
|
138
|
+
/** Map a `decide()` deny result to its model-facing reason — shared by both gate paths. */
|
|
139
|
+
const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string =>
|
|
140
|
+
result.cause === "plan-mode"
|
|
141
|
+
? DENIED_PLAN_MODE
|
|
142
|
+
: result.cause === "protected-path"
|
|
143
|
+
? DENIED_PROTECTED_PATH
|
|
144
|
+
: result.cause === "working-dir"
|
|
145
|
+
? DENIED_OUTSIDE_WORKING_DIR
|
|
146
|
+
: result.cause === "mode"
|
|
147
|
+
? DENIED_DONT_ASK
|
|
148
|
+
: DENIED_BY_RULE(result.rule?.raw ?? "deny");
|
|
149
|
+
|
|
150
|
+
/** Ask-prompt option labels — shared by both gate paths. */
|
|
90
151
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
152
|
+
* Modes that carry a standing `permission-mode` reminder block: auto installs
|
|
153
|
+
* its own in `setMode`, plan's is owned by the plan-mode extension (it knows
|
|
154
|
+
* the plan file) and re-installed from the status broadcast `setMode` sends.
|
|
155
|
+
* Startup announces exactly these; a new mode with a standing block is added
|
|
156
|
+
* here, next to the emitter it belongs to.
|
|
94
157
|
*/
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
158
|
+
const STANDING_REMINDER_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["plan", "auto"]);
|
|
159
|
+
|
|
160
|
+
const YES = "Yes";
|
|
161
|
+
const NO = "No, tell the agent what to do differently";
|
|
162
|
+
/**
|
|
163
|
+
* The ask prompt's options: Yes, the scoped session grant when one exists
|
|
164
|
+
* (session-grant.ts — none for a protected path, the safety floor, or auto
|
|
165
|
+
* mode, where a minted rule could not or must not apply), No.
|
|
166
|
+
*/
|
|
167
|
+
const askOptions = (grant: SessionGrant | undefined) => (grant ? [YES, grant.label, NO] : [YES, NO]);
|
|
168
|
+
const approved = (choice: string | undefined, grant: SessionGrant | undefined) =>
|
|
169
|
+
choice === YES || (grant !== undefined && choice === grant.label);
|
|
99
170
|
|
|
100
171
|
export default function permissionsExtension(pi: ExtensionAPI) {
|
|
172
|
+
const notifyTask = createTaskNotifier(pi);
|
|
173
|
+
// The background hand-back review rides the same steered path as agent
|
|
174
|
+
// completions; render it the same compact way (full body on ctrl+o).
|
|
175
|
+
pi.registerMessageRenderer("subagent-review", (message, { expanded }, theme) =>
|
|
176
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
177
|
+
);
|
|
101
178
|
pi.registerFlag("permission-mode", {
|
|
102
179
|
description:
|
|
103
180
|
"Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
|
|
@@ -108,15 +185,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
108
185
|
type: "boolean",
|
|
109
186
|
});
|
|
110
187
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
188
|
+
// The live mode is published in the process environment (MODE_ENV) for the
|
|
189
|
+
// in-process child gate (lib/permission-gate.ts), which judges under it when
|
|
190
|
+
// no bridge to this extension is reachable. Subagents run in-process; there
|
|
191
|
+
// is no child *process* reading this any more.
|
|
192
|
+
//
|
|
193
|
+
// Auto is the shipped default: Claude Code 2.1.266 made auto its default
|
|
194
|
+
// permission mode ("Auto mode is now Claude Code's default permission mode"),
|
|
195
|
+
// so One Code follows. Deliberate divergences from CC's rollout, both the
|
|
196
|
+
// user's call: CC gates it behind a one-time first-run offer and pairs it with
|
|
197
|
+
// an OS sandbox for outside-cwd reads; One Code flips the default directly and
|
|
198
|
+
// has no sandbox yet (reads outside the working directory are still
|
|
199
|
+
// classified/asked, never auto-read). The auto-mode safety architecture still
|
|
200
|
+
// holds — the deterministic safety floor, classifier-verdicts-verified, the
|
|
201
|
+
// git-recoverability gate, and the classifier tier floor — and with no
|
|
202
|
+
// classifier model reachable the classifier fails closed. A user or project
|
|
203
|
+
// `defaultMode` (or `--permission-mode`) still overrides this; `auto` from a
|
|
204
|
+
// project file is still refused. See docs/decisions/auto-mode.md.
|
|
205
|
+
let mode: PermissionMode = "auto";
|
|
206
|
+
// Worktree-wrapped bash calls publish the model's original command here,
|
|
207
|
+
// keyed by pi's toolCallId (never read from `event.input` — model-writable).
|
|
208
|
+
const originalCommands = trackOriginalCommands(pi);
|
|
120
209
|
/** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
|
|
121
210
|
let bypassInCycle = false;
|
|
122
211
|
/** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
|
|
@@ -124,7 +213,26 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
124
213
|
let deny: PermissionRule[] = [];
|
|
125
214
|
let ask: PermissionRule[] = [];
|
|
126
215
|
let allow: PermissionRule[] = [];
|
|
216
|
+
/**
|
|
217
|
+
* The repository's own allow rules (project-trust.ts). Applied only once the
|
|
218
|
+
* user has consented to exactly this list; until then they are held here and
|
|
219
|
+
* the first call they would decide raises the consent dialog.
|
|
220
|
+
*/
|
|
221
|
+
let projectAllow: PermissionRule[] = [];
|
|
222
|
+
let projectAllowRaw: string[] = [];
|
|
223
|
+
let projectAllowTrusted = false;
|
|
224
|
+
let projectAllowDeclined = false;
|
|
225
|
+
let projectRoot = "";
|
|
226
|
+
/**
|
|
227
|
+
* Rules minted by "Yes, and don't ask again … this session" (session-grant.ts).
|
|
228
|
+
* Never applied in auto mode: there the classifier is the boundary and a
|
|
229
|
+
* standing grant would be a bypass of it (PERMISSIONS-REVIEW-2026-09-05 M2).
|
|
230
|
+
* Cleared with the session (L1).
|
|
231
|
+
*/
|
|
127
232
|
const sessionAllows: PermissionRule[] = [];
|
|
233
|
+
const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
|
|
234
|
+
let unparsableRules: string[] = [];
|
|
235
|
+
let warnedUnparsable = "";
|
|
128
236
|
/** Plan mode's one writable file, announced by the plan-mode extension. */
|
|
129
237
|
let planFilePath: string | undefined;
|
|
130
238
|
/**
|
|
@@ -134,6 +242,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
134
242
|
*/
|
|
135
243
|
let memoryDirPath: string | undefined;
|
|
136
244
|
let scratchpadDirPath: string | undefined;
|
|
245
|
+
/** Where oversized tool outputs are persisted this session — readable like the cwd. */
|
|
246
|
+
let resultsDirPath: string | undefined;
|
|
247
|
+
/** The session cwd's own realpath (macOS /var → /private/var), for decide()'s containment check. */
|
|
248
|
+
let resolvedCwd: string | undefined;
|
|
249
|
+
/** The per-repo One Code settings file, resolved once per session so the auto-mode floor need not re-walk for the project root on every tool call. */
|
|
250
|
+
let oneCodeProjectSettingsFile: string | undefined;
|
|
251
|
+
/** pi's own agent directory, protected like the static list (lib/permission-gate.ts runtimeProtectedDirs). */
|
|
252
|
+
let protectedDirs: string[] = [];
|
|
137
253
|
|
|
138
254
|
/**
|
|
139
255
|
* Mode changes arrive over the event bus too (plan-mode tools), where no ctx
|
|
@@ -142,12 +258,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
142
258
|
*/
|
|
143
259
|
let badgeCtx: ExtensionContext | undefined;
|
|
144
260
|
|
|
261
|
+
/** True while the model is streaming — the badge carries the interrupt hint. */
|
|
262
|
+
let streaming = false;
|
|
263
|
+
|
|
145
264
|
/**
|
|
146
265
|
* The classifier the banner and badge should name: the pinned one once a
|
|
147
266
|
* call has settled it, otherwise the chain's first candidate — the thing
|
|
148
267
|
* that *will* screen the next call, worth showing before it happens.
|
|
149
268
|
*/
|
|
150
|
-
const classifierForDisplay = (): { classifier?: string; pinned: boolean } => {
|
|
269
|
+
const classifierForDisplay = (sessionModel?: Model<Api>): { classifier?: string; pinned: boolean } => {
|
|
151
270
|
if (classifierState.pinned) {
|
|
152
271
|
return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
|
|
153
272
|
}
|
|
@@ -155,25 +274,34 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
155
274
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
156
275
|
const chain = classifierCandidates({
|
|
157
276
|
available: badgeCtx.modelRegistry.getAvailable(),
|
|
158
|
-
sessionModel: badgeCtx.model,
|
|
277
|
+
sessionModel: sessionModel ?? badgeCtx.model,
|
|
159
278
|
configured: autoConfig.classifierModel,
|
|
160
|
-
|
|
279
|
+
configuredSetForContainment: autoConfig.classifierModelSetFor,
|
|
280
|
+
}).candidates.filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
161
281
|
const first = chain[0];
|
|
162
282
|
return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
|
|
163
283
|
};
|
|
164
284
|
|
|
165
|
-
const applyBadge = () => {
|
|
166
|
-
//
|
|
167
|
-
|
|
285
|
+
const applyBadge = (sessionModel?: Model<Api>) => {
|
|
286
|
+
// A below-editor widget, not a footer status: Claude Code renders the
|
|
287
|
+
// mode line directly under the input box, and the workflow status strip
|
|
288
|
+
// sorts itself below this line by re-setting on the status channel
|
|
289
|
+
// (setWidget re-inserts on update, so widget order is last-write order).
|
|
290
|
+
// setWidget is a no-op outside the TUI, so this is safe unconditionally.
|
|
291
|
+
badgeCtx?.ui.setWidget(
|
|
168
292
|
"permission-mode",
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
293
|
+
[
|
|
294
|
+
modeBadge(mode, {
|
|
295
|
+
paused: pauseTracker.isPaused(),
|
|
296
|
+
classifierModel: classifierState.pinned?.id,
|
|
297
|
+
streaming,
|
|
298
|
+
}),
|
|
299
|
+
],
|
|
300
|
+
{ placement: "belowEditor" },
|
|
173
301
|
);
|
|
174
302
|
// The banner shows mode and classifier live; it listens on the bus
|
|
175
303
|
// because jiti isolates module state between extensions.
|
|
176
|
-
const display = mode === "auto" ? classifierForDisplay() : { pinned: false };
|
|
304
|
+
const display = mode === "auto" ? classifierForDisplay(sessionModel) : { pinned: false };
|
|
177
305
|
pi.events.emit(PERMISSION_STATUS_CHANNEL, {
|
|
178
306
|
mode,
|
|
179
307
|
paused: pauseTracker.isPaused(),
|
|
@@ -182,20 +310,38 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
182
310
|
} satisfies PermissionStatus);
|
|
183
311
|
};
|
|
184
312
|
|
|
185
|
-
/** Auto-mode state. Loaded lazily: most sessions never enter auto mode. */
|
|
313
|
+
/** Auto-mode state. Loaded lazily by every reader (`autoConfig ??= …`): most sessions never enter auto mode. */
|
|
186
314
|
let autoConfig: AutoModeConfig | undefined;
|
|
315
|
+
/**
|
|
316
|
+
* `decide()`'s classifyAllShell input, loading the config itself like every
|
|
317
|
+
* other reader in this file — until 2026-09-05 the two gate call sites read
|
|
318
|
+
* `autoConfig?.classifyAllShell` bare and were correct only because painting
|
|
319
|
+
* the badge happened to load it first (PERMISSIONS-REVIEW-2026-09-05 L2).
|
|
320
|
+
*/
|
|
321
|
+
const classifyAllShell = (): boolean | undefined =>
|
|
322
|
+
mode === "auto" ? (autoConfig ??= loadAutoModeConfig(os.homedir())).classifyAllShell : undefined;
|
|
187
323
|
|
|
188
324
|
// Drop the cached config and classifier selection state, then refresh the badge.
|
|
189
325
|
// Shared by the "set classifier model" and "clear" paths.
|
|
190
|
-
const resetClassifierChoice = () => {
|
|
326
|
+
const resetClassifierChoice = (sessionModel?: Model<Api>) => {
|
|
191
327
|
autoConfig = undefined; // reloaded lazily, now carrying any new model
|
|
192
328
|
classifierState.pinned = undefined;
|
|
193
329
|
classifierState.rejected.clear();
|
|
194
330
|
classifierState.notified.clear();
|
|
195
|
-
|
|
331
|
+
classifierState.timeoutStreak = 0;
|
|
332
|
+
classifierState.chainCache = undefined;
|
|
333
|
+
applyBadge(sessionModel);
|
|
334
|
+
};
|
|
335
|
+
// Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
|
|
336
|
+
// CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
|
|
337
|
+
// calls (a different cwd) read for the rest of the session.
|
|
338
|
+
const projectInstructionsByCwd = new Map<string, string | undefined>();
|
|
339
|
+
const instructionsFor = (cwd: string): string | undefined => {
|
|
340
|
+
if (!projectInstructionsByCwd.has(cwd)) {
|
|
341
|
+
projectInstructionsByCwd.set(cwd, loadProjectInstructions(cwd, os.homedir()));
|
|
342
|
+
}
|
|
343
|
+
return projectInstructionsByCwd.get(cwd);
|
|
196
344
|
};
|
|
197
|
-
let projectInstructions: string | undefined;
|
|
198
|
-
let instructionsLoaded = false;
|
|
199
345
|
const pauseTracker = new PauseTracker();
|
|
200
346
|
/**
|
|
201
347
|
* Which model the classifier settled on. Held here so the choice is pinned for
|
|
@@ -203,6 +349,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
203
349
|
* to be unusable is not retried on every tool call.
|
|
204
350
|
*/
|
|
205
351
|
const classifierState = createClassifierState();
|
|
352
|
+
// What another role learned the hard way (a subagent's provider refused its
|
|
353
|
+
// model as not usable on this account) is a rejection here too — the roles
|
|
354
|
+
// share one selection floor, so they share one first pick (lib/model-unusable.ts).
|
|
355
|
+
pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
|
|
356
|
+
classifierState.rejected.add((data as ModelUnusableEvent).model);
|
|
357
|
+
});
|
|
358
|
+
/** Report each classifier reply's usage to the all-in footer cost. */
|
|
359
|
+
const onClassifierUsage = (usage: unknown) => recordUsage(pi, "classifier", usage);
|
|
360
|
+
/** A candidate the provider refused for this account: published so subagent/workflow selection skips it too (lib/model-unusable.ts). */
|
|
361
|
+
const onClassifierModelUnusable = (model: string, reason: string) =>
|
|
362
|
+
pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
|
|
206
363
|
|
|
207
364
|
/**
|
|
208
365
|
* One JSONL line per gate decision when `autoMode.logDecisions` is set. The
|
|
@@ -221,26 +378,93 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
221
378
|
|
|
222
379
|
/** What would be tried, in order, before anything has been pinned. */
|
|
223
380
|
const describeChain = (ctx: ExtensionContext): string => {
|
|
224
|
-
const
|
|
381
|
+
const { candidates } = classifierCandidates({
|
|
225
382
|
available: ctx.modelRegistry.getAvailable(),
|
|
226
383
|
sessionModel: ctx.model,
|
|
227
384
|
configured: autoConfig?.classifierModel,
|
|
385
|
+
configuredSetForContainment: autoConfig?.classifierModelSetFor,
|
|
228
386
|
});
|
|
229
|
-
return
|
|
387
|
+
return candidates.length > 0 ? candidates.map(describeCandidate).join(" → ") : "(no model available)";
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Session identity for the classifier's Session Context block (CC system[2]).
|
|
391
|
+
const classifierUsername = (() => {
|
|
392
|
+
try {
|
|
393
|
+
return os.userInfo().username;
|
|
394
|
+
} catch {
|
|
395
|
+
return "user";
|
|
396
|
+
}
|
|
397
|
+
})();
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The classifier's `<transcript>`: user messages and tool inputs, in order,
|
|
401
|
+
* results stripped (transcript.ts renders it). The last entry is always the
|
|
402
|
+
* action under review — the tool_call handler appends the call being judged.
|
|
403
|
+
*/
|
|
404
|
+
const transcript: TranscriptEntry[] = [];
|
|
405
|
+
const capTranscript = () => {
|
|
406
|
+
// Bound memory on a long unattended run; the renderer also caps by chars,
|
|
407
|
+
// and intent verification reads userMessages, not this, so trimming old
|
|
408
|
+
// lines here never weakens that check. Trim down to a lower watermark so the
|
|
409
|
+
// O(n) splice is amortized over the next ~100 pushes on the tool_call path.
|
|
410
|
+
if (transcript.length > 500) transcript.splice(0, transcript.length - 400);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Record a rule denial as its own `denied` transcript line, so the classifier
|
|
415
|
+
* can see that the user's rules already refused this class of action (it also
|
|
416
|
+
* switches on the stage-2 denial addendum). Rule denials only — a plan-mode,
|
|
417
|
+
* protected-path, working-dir or dontAsk block is a mode fact, not a standing
|
|
418
|
+
* user decision about a class of action, and each already has its own
|
|
419
|
+
* model-facing text.
|
|
420
|
+
*/
|
|
421
|
+
const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolName: string, subject: string) => {
|
|
422
|
+
if (mode !== "auto") return;
|
|
423
|
+
if (result.cause === "plan-mode" || result.cause === "protected-path" || result.cause === "working-dir" || result.cause === "mode") return;
|
|
424
|
+
transcript.push({ kind: "denied", tool: normalizeToolName(toolName), subject, rule: result.rule?.raw ?? "deny" });
|
|
425
|
+
capTranscript();
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The classifier's rule extras: the configured `hard_deny`/`soft_deny`/`allow`
|
|
430
|
+
* lists, plus one HARD BLOCK line per deny rule in the user's settings
|
|
431
|
+
* (rule-prose.ts). A rule binds only the spelling it names, so without this
|
|
432
|
+
* the classifier never learns what the user forbade and clears an
|
|
433
|
+
* equivalent-effect action the pattern happened to miss
|
|
434
|
+
* (WEAK-MODEL-REVIEW-2026-09-06 H2). Additive only: the rule already refused
|
|
435
|
+
* its literal form deterministically before anything reached the classifier.
|
|
436
|
+
*
|
|
437
|
+
* Rendered once per deny list rather than per call: `deny` is replaced
|
|
438
|
+
* wholesale by `reloadSettings`, so its identity is the cache key, and the
|
|
439
|
+
* output has to be byte-stable anyway or every classifier call would bust the
|
|
440
|
+
* cached ruleset prefix.
|
|
441
|
+
*/
|
|
442
|
+
let renderedDeny: { source: PermissionRule[]; lines: string[] } | undefined;
|
|
443
|
+
const classifierRuleExtras = () => {
|
|
444
|
+
if (renderedDeny?.source !== deny) renderedDeny = { source: deny, lines: denyRuleLines(deny.map((rule) => rule.raw)) };
|
|
445
|
+
return { ...autoConfig, hardDeny: [...(autoConfig?.hardDeny ?? []), ...renderedDeny.lines] };
|
|
230
446
|
};
|
|
231
447
|
|
|
232
448
|
/**
|
|
233
449
|
* 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
|
-
*
|
|
236
|
-
*
|
|
450
|
+
* tier must not be reachable from file contents or command output, or a prompt
|
|
451
|
+
* injection could manufacture its own authorisation. pi's `input` event fires
|
|
452
|
+
* for real user input, which is exactly that boundary. Carried in FULL (not a
|
|
453
|
+
* rolling window): in a long unattended run the authorizing setup message must
|
|
454
|
+
* still clear a later action (decision 2 in docs/decisions/auto-mode.md).
|
|
237
455
|
*/
|
|
238
456
|
const userMessages: string[] = [];
|
|
239
457
|
pi.on("input", (event) => {
|
|
458
|
+
// Only what the user typed. A plugin command or skill body arrives as
|
|
459
|
+
// source "extension" and must not become "the user's own words" that an
|
|
460
|
+
// intent quote can cite (review P12; hooks make the same distinction).
|
|
461
|
+
if (event.source === "extension") return;
|
|
240
462
|
const text = event.text?.trim();
|
|
241
463
|
if (!text) return;
|
|
242
464
|
userMessages.push(text);
|
|
243
|
-
if (userMessages.length >
|
|
465
|
+
if (userMessages.length > 1000) userMessages.shift();
|
|
466
|
+
transcript.push({ kind: "user", text });
|
|
467
|
+
capTranscript();
|
|
244
468
|
});
|
|
245
469
|
|
|
246
470
|
/**
|
|
@@ -254,31 +478,98 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
254
478
|
input: Record<string, unknown>,
|
|
255
479
|
subject: string,
|
|
256
480
|
ctx: ExtensionContext,
|
|
257
|
-
|
|
481
|
+
/**
|
|
482
|
+
* Whether the deterministic containment fast-path may clear this call
|
|
483
|
+
* without the classifier. False for protected paths and for completed-run
|
|
484
|
+
* subagent reviews — those must always be judged.
|
|
485
|
+
*/
|
|
486
|
+
containmentEligible = false,
|
|
487
|
+
/**
|
|
488
|
+
* Overrides for a bridged subagent call: its own cwd (a worktree, if
|
|
489
|
+
* isolated), the child action appended to a COPY of the transcript (the
|
|
490
|
+
* parent's live transcript is never mutated for a child), and a fresh signal
|
|
491
|
+
* (the parent's last ctx.signal may be from a settled turn and already
|
|
492
|
+
* aborted). All default to the parent's own values, so the main path is
|
|
493
|
+
* unchanged.
|
|
494
|
+
*/
|
|
495
|
+
opts?: { cwd?: string; appendEntry?: TranscriptEntry; signal?: AbortSignal },
|
|
258
496
|
) => {
|
|
497
|
+
const cwd = opts?.cwd ?? ctx.cwd;
|
|
259
498
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
499
|
+
|
|
500
|
+
const home = os.homedir();
|
|
501
|
+
const allow = () => ({ decision: "allow" as const, reason: "", tier: undefined });
|
|
264
502
|
|
|
265
503
|
let evidence: ShellEvidence | undefined;
|
|
266
504
|
if (normalizeToolName(toolName) === "bash" && subject) {
|
|
267
|
-
evidence = analyzeShellCommand({ command: subject, cwd
|
|
505
|
+
evidence = analyzeShellCommand({ command: subject, cwd, home, protectedDirs });
|
|
268
506
|
if (evidence.verdict === "safe") {
|
|
269
507
|
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
270
|
-
return
|
|
508
|
+
return allow();
|
|
509
|
+
}
|
|
510
|
+
// The command's only risk is an in-project delete or whole-tree reset.
|
|
511
|
+
// Auto mode trusts the project as the agent's sandbox — but, unlike Claude
|
|
512
|
+
// Code, only when git can put the bytes back. A recoverable destruction
|
|
513
|
+
// runs unattended with no classifier call; an unrecoverable one (untracked,
|
|
514
|
+
// dirty, not a repo) still reaches the classifier.
|
|
515
|
+
if (containmentEligible && evidence.containedNonNetwork) {
|
|
516
|
+
const targets = evidence.writes.filter((w) => !w.outsideCwd && w.resolved).map((w) => w.resolved as string);
|
|
517
|
+
// The same cwd the evidence resolved against — for a worktree-isolated
|
|
518
|
+
// child that is the worktree, not the parent checkout (ctx.cwd).
|
|
519
|
+
const rec = checkRecoverability(cwd, { targets, wholeTree: evidence.wholeTree });
|
|
520
|
+
if (rec.verdict === "recoverable") {
|
|
521
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate", reason: rec.reason });
|
|
522
|
+
return allow();
|
|
523
|
+
}
|
|
524
|
+
evidence.notes.push(`git recoverability: ${rec.reason}`);
|
|
525
|
+
}
|
|
526
|
+
} else if (containmentEligible && isWritingTool(normalizeToolName(toolName))) {
|
|
527
|
+
// A write/edit whose target is inside the project and not a credential
|
|
528
|
+
// path is ordinary sandbox work — Claude Code auto-approves it, and so do
|
|
529
|
+
// we (protected paths never reach here: decide() routes them with
|
|
530
|
+
// containmentEligible=false). Overwrites are not recoverability-gated the
|
|
531
|
+
// way deletes are: the file still exists, and edit-then-iterate is the
|
|
532
|
+
// core of unattended coding autonomy. Execution-primitive paths (build
|
|
533
|
+
// wrappers, CI workflows, editor auto-run config) are excluded exactly as
|
|
534
|
+
// the bash pre-gate excludes them — being in-project does not make a file
|
|
535
|
+
// that runs later without further approval safe to write unclassified.
|
|
536
|
+
const raw = input.file_path ?? input.path;
|
|
537
|
+
const target = typeof raw === "string" ? raw : undefined;
|
|
538
|
+
if (target) {
|
|
539
|
+
const absolute = toAbsolute(cwd, target, home);
|
|
540
|
+
const resolved = resolveForContainment(absolute);
|
|
541
|
+
const root = resolveForContainment(cwd) ?? cwd;
|
|
542
|
+
if (resolved && isWithin(root, resolved) && !isSensitivePath(absolute) && !isExecutionPrimitivePath(absolute)) {
|
|
543
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
544
|
+
return allow();
|
|
545
|
+
}
|
|
271
546
|
}
|
|
272
547
|
}
|
|
273
548
|
|
|
549
|
+
// The current call was pushed onto `transcript` by the tool_call handler, so
|
|
550
|
+
// it is already the last entry — the action under review. `evidence` is used
|
|
551
|
+
// only for the containment fast-path above; CC's payload carries no separate
|
|
552
|
+
// static-analysis block, so it is not sent to the classifier.
|
|
274
553
|
const verdict = await classify(
|
|
275
|
-
{
|
|
554
|
+
{
|
|
555
|
+
toolName,
|
|
556
|
+
transcript: opts?.appendEntry ? [...transcript, opts.appendEntry] : [...transcript],
|
|
557
|
+
userMessages: [...userMessages],
|
|
558
|
+
claudeMd: instructionsFor(cwd),
|
|
559
|
+
username: classifierUsername,
|
|
560
|
+
environment: autoConfig.environment,
|
|
561
|
+
// AutoModeConfig is structurally a RuleExtras; the user's own deny rules
|
|
562
|
+
// are appended to its hard list (classifierRuleExtras).
|
|
563
|
+
ruleExtras: classifierRuleExtras(),
|
|
564
|
+
},
|
|
276
565
|
{
|
|
277
566
|
registry: ctx.modelRegistry,
|
|
278
567
|
sessionModel: ctx.model,
|
|
279
568
|
config: autoConfig,
|
|
280
|
-
signal: ctx.signal,
|
|
569
|
+
signal: opts?.signal ?? ctx.signal,
|
|
281
570
|
state: classifierState,
|
|
571
|
+
onUsage: onClassifierUsage,
|
|
572
|
+
onModelUnusable: onClassifierModelUnusable,
|
|
282
573
|
onNotice: (message, level) => {
|
|
283
574
|
ctx.ui.notify(message, level);
|
|
284
575
|
// The badge names the classifier, so it has to repaint when the first
|
|
@@ -305,13 +596,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
305
596
|
const setMode = (next: PermissionMode) => {
|
|
306
597
|
mode = next;
|
|
307
598
|
process.env[MODE_ENV] = next;
|
|
599
|
+
// The standing block of the mode being left goes first, BEFORE the status
|
|
600
|
+
// broadcast in applyBadge: the plan-mode extension owns the shared
|
|
601
|
+
// "permission-mode" key while planning (it knows the plan file) and
|
|
602
|
+
// re-installs its block synchronously from that broadcast when the mode
|
|
603
|
+
// becomes plan — removing the key afterwards would undo that re-add and
|
|
604
|
+
// leave a turn entered mid-stream with no plan reminder until the next
|
|
605
|
+
// prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
|
|
606
|
+
if (mode !== "auto") pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
|
|
308
607
|
applyBadge();
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
608
|
+
// Every switch is announced once on the tail of the next request (the next
|
|
609
|
+
// tool result mid-turn, the prompt between turns), so the model learns of
|
|
610
|
+
// the change where it reads next. The standing block carries the rules
|
|
611
|
+
// but rides the turn's user message, behind the model's own actions — so
|
|
612
|
+
// for plan mode the announcement, the one thing guaranteed to land
|
|
613
|
+
// mid-turn, also names the plan file (published by plan-mode's refresh
|
|
614
|
+
// inside the applyBadge broadcast above).
|
|
615
|
+
const planNote =
|
|
616
|
+
mode === "plan"
|
|
617
|
+
? planFilePath
|
|
618
|
+
? ` Only read-only tools are available now, plus one writable file: your plan file at ${planFilePath}. Build the plan there, then call exit_plan_mode.`
|
|
619
|
+
: " Only read-only tools are available now, plus the plan file named in the plan-mode reminder."
|
|
620
|
+
: "";
|
|
621
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
622
|
+
text: `The user's permission mode is now "${mode}".${planNote}`,
|
|
623
|
+
key: "permission-mode-change",
|
|
624
|
+
});
|
|
315
625
|
if (mode === "auto") {
|
|
316
626
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
317
627
|
text:
|
|
@@ -320,64 +630,140 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
320
630
|
"Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
|
|
321
631
|
scope: "every-turn",
|
|
322
632
|
key: "permission-mode",
|
|
633
|
+
// Session state: rides every user message since auto mode came on, so
|
|
634
|
+
// the cached prefix holds turn to turn (lib/reminders.ts).
|
|
635
|
+
placement: "sticky-append",
|
|
323
636
|
});
|
|
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
637
|
}
|
|
332
638
|
};
|
|
333
639
|
|
|
334
640
|
const reloadSettings = (ctx: ExtensionContext) => {
|
|
335
641
|
const settings = loadPermissionSettings(ctx.cwd, os.homedir());
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
642
|
+
const parsed = {
|
|
643
|
+
deny: parseRulesReport(settings.deny),
|
|
644
|
+
ask: parseRulesReport(settings.ask),
|
|
645
|
+
allow: parseRulesReport(settings.allow),
|
|
646
|
+
projectAllow: parseRulesReport(settings.projectAllow),
|
|
647
|
+
};
|
|
648
|
+
deny = parsed.deny.rules;
|
|
649
|
+
ask = parsed.ask.rules;
|
|
650
|
+
allow = parsed.allow.rules;
|
|
651
|
+
projectAllow = parsed.projectAllow.rules;
|
|
652
|
+
projectAllowRaw = settings.projectAllow;
|
|
653
|
+
// A linked worktree shares its main checkout's consent (findProjectRoot).
|
|
654
|
+
projectRoot = findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
655
|
+
projectAllowTrusted = projectAllowApproved(projectRoot, projectAllowRaw);
|
|
656
|
+
projectAllowDeclined = false;
|
|
657
|
+
// A rule that fails to parse is a rule the user believes is in force and is
|
|
658
|
+
// not. Say so (once per distinct set) and list them in /permissions.
|
|
659
|
+
unparsableRules = [...parsed.deny.dropped, ...parsed.ask.dropped, ...parsed.allow.dropped, ...parsed.projectAllow.dropped];
|
|
660
|
+
const signature = unparsableRules.join("\n");
|
|
661
|
+
if (unparsableRules.length > 0 && ctx.hasUI && signature !== warnedUnparsable) {
|
|
662
|
+
warnedUnparsable = signature;
|
|
663
|
+
ctx.ui.notify(
|
|
664
|
+
`${unparsableRules.length} permission rule(s) could not be parsed and are ignored: ${unparsableRules.join(", ")}`,
|
|
665
|
+
"warning",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
339
668
|
// Dropped so edited autoMode rules and instruction files are picked up on
|
|
340
669
|
// reload rather than staying cached for the life of the process.
|
|
341
670
|
autoConfig = undefined;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
671
|
+
projectInstructionsByCwd.clear();
|
|
672
|
+
|
|
673
|
+
// Claude Code's resolution order (flags, then settings' defaultMode), with
|
|
674
|
+
// bypassPermissions refused wherever a settings source disabled it — before
|
|
675
|
+
// 2026-09-05 (H3) that policy was silently not in force here.
|
|
676
|
+
const startup = resolveStartupMode(
|
|
677
|
+
[
|
|
678
|
+
pi.getFlag("dangerously-skip-permissions") === true ? "bypassPermissions" : undefined,
|
|
679
|
+
normalizePermissionMode(pi.getFlag("permission-mode")),
|
|
680
|
+
settings.defaultMode,
|
|
681
|
+
],
|
|
682
|
+
settings,
|
|
683
|
+
);
|
|
684
|
+
if (startup.mode) mode = startup.mode;
|
|
685
|
+
if (startup.bypassRefused && ctx.hasUI) ctx.ui.notify("Bypass permissions mode was disabled by settings", "warning");
|
|
355
686
|
bypassInCycle = mode === "bypassPermissions";
|
|
356
687
|
// Auto mode needs a model to run its classifier on; with none reachable it
|
|
357
688
|
// 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.
|
|
689
|
+
// thing Claude Code does when auto mode's requirements aren't met. The
|
|
690
|
+
// candidate chain ends in an unconditional fallback to any available model
|
|
691
|
+
// (model-select.ts), so "a model exists" is exactly "a classifier exists".
|
|
359
692
|
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
360
|
-
|
|
693
|
+
// A mode that owns a standing reminder must be announced when the session
|
|
694
|
+
// STARTS in it, not only when the user switches into it. setMode is the
|
|
695
|
+
// only emitter of the block; until 2026-09-05 startup called it for plan
|
|
696
|
+
// alone, so `--permission-mode auto` / `defaultMode: "auto"` sessions ran
|
|
697
|
+
// with no auto-mode reminder at all (STEERING-REVIEW-2026-09-05 H2).
|
|
698
|
+
if (STANDING_REMINDER_MODES.has(mode)) setMode(mode);
|
|
361
699
|
process.env[MODE_ENV] = mode;
|
|
362
700
|
};
|
|
363
701
|
|
|
364
|
-
pi.on("session_start", (
|
|
702
|
+
pi.on("session_start", (event, ctx) => {
|
|
365
703
|
badgeCtx = ctx;
|
|
704
|
+
lastReviewCtx = ctx;
|
|
705
|
+
sessionEpoch++;
|
|
706
|
+
// A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
|
|
707
|
+
// previous conversation's user messages are not intent evidence for this
|
|
708
|
+
// one, its "don't ask again" grants and its pause state do not carry over
|
|
709
|
+
// (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps them — same conversation.
|
|
710
|
+
if (event.reason !== "reload") {
|
|
711
|
+
sessionAllows.length = 0;
|
|
712
|
+
transcript.length = 0;
|
|
713
|
+
userMessages.length = 0;
|
|
714
|
+
pauseTracker.reset();
|
|
715
|
+
}
|
|
366
716
|
memoryDirPath = projectMemoryDir(ctx.cwd);
|
|
367
717
|
scratchpadDirPath = sessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
718
|
+
// Resolved like the subjects compared against it (a symlinked parent, macOS /var).
|
|
719
|
+
resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
|
|
720
|
+
resolvedCwd = resolveForContainment(ctx.cwd);
|
|
721
|
+
oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
|
|
722
|
+
protectedDirs = runtimeProtectedDirs();
|
|
368
723
|
reloadSettings(ctx);
|
|
369
724
|
applyBadge();
|
|
725
|
+
// Publish the subagent permission bridge (see subagent-gate.ts). The closure
|
|
726
|
+
// reads live parent state on each call, so emitting once at session start is
|
|
727
|
+
// enough; subagents captures it and threads it into child sessions.
|
|
728
|
+
pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: evaluateChildToolCall });
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
pi.on("model_select", (event, ctx) => {
|
|
732
|
+
badgeCtx = ctx;
|
|
733
|
+
lastReviewCtx = ctx;
|
|
734
|
+
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
735
|
+
resetClassifierChoice(event.model);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// The badge carries "· esc to interrupt" only while the model works (CC's
|
|
739
|
+
// mode line does the same).
|
|
740
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
741
|
+
badgeCtx = ctx;
|
|
742
|
+
lastReviewCtx = ctx;
|
|
743
|
+
streaming = true;
|
|
744
|
+
applyBadge();
|
|
745
|
+
});
|
|
746
|
+
// Settle, not agent_end: `agent_end` fires per run, so flipping there made
|
|
747
|
+
// the badge's "· esc to interrupt" suffix flicker off and back on across a
|
|
748
|
+
// provider retry — the model is still working until the turn settles.
|
|
749
|
+
pi.on("agent_settled", () => {
|
|
750
|
+
streaming = false;
|
|
751
|
+
applyBadge();
|
|
370
752
|
});
|
|
371
753
|
|
|
372
754
|
// Mode-change requests from other extensions (e.g. plan-mode tools).
|
|
373
|
-
|
|
755
|
+
// `autoMode.classifierModel` was rewritten by another extension (`/doctor
|
|
756
|
+
// preset`): forget the pinned classifier and the cached config, exactly as
|
|
757
|
+
// `/auto-mode model` does after its own write, so the next call re-plans.
|
|
758
|
+
pi.events.on(CLASSIFIER_SETTING_CHANGED_CHANNEL, () => resetClassifierChoice(badgeCtx?.model));
|
|
759
|
+
pi.events.on(MODE_CHANNEL, (data) => {
|
|
374
760
|
const requested = normalizePermissionMode((data as { mode?: unknown })?.mode);
|
|
375
761
|
if (requested) setMode(requested);
|
|
376
762
|
});
|
|
377
763
|
|
|
378
|
-
// The plan-mode extension announces the plan file
|
|
379
|
-
//
|
|
380
|
-
pi.events.on(
|
|
764
|
+
// The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
|
|
765
|
+
// decide() then allows writes to that one path in plan mode.
|
|
766
|
+
pi.events.on(PLAN_FILE_CHANNEL, (data) => {
|
|
381
767
|
const path = (data as { path?: unknown })?.path;
|
|
382
768
|
if (typeof path === "string" && path) planFilePath = path;
|
|
383
769
|
});
|
|
@@ -394,42 +780,103 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
394
780
|
},
|
|
395
781
|
});
|
|
396
782
|
|
|
397
|
-
// The most recent turn's context, reused by background subagent reviews
|
|
398
|
-
// fire off a channel event and so have no live ctx of their own
|
|
783
|
+
// The most recent turn's context, reused by background subagent reviews (which
|
|
784
|
+
// fire off a channel event and so have no live ctx of their own) and by the
|
|
785
|
+
// subagent permission bridge (a child prompt renders on this parent ctx's UI).
|
|
399
786
|
let lastReviewCtx: ExtensionContext | undefined;
|
|
787
|
+
/** Bumped per session_start: an async review that finishes after a /clear must not post into the new session. */
|
|
788
|
+
let sessionEpoch = 0;
|
|
789
|
+
|
|
790
|
+
// Serialize interactive prompts: a background/resident subagent can hit an "ask"
|
|
791
|
+
// while the main turn (or another child) is already awaiting one, and driving
|
|
792
|
+
// ctx.ui.select concurrently on one terminal is unverified. This chains them.
|
|
793
|
+
let promptChain: Promise<unknown> = Promise.resolve();
|
|
794
|
+
const serializePrompt = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
795
|
+
const run = promptChain.then(fn, fn);
|
|
796
|
+
promptChain = run.then(
|
|
797
|
+
() => {},
|
|
798
|
+
() => {},
|
|
799
|
+
);
|
|
800
|
+
return run;
|
|
801
|
+
};
|
|
400
802
|
|
|
401
803
|
pi.on("tool_call", async (event, ctx) => {
|
|
402
804
|
lastReviewCtx = ctx;
|
|
403
805
|
const normalizedTool = normalizeToolName(event.toolName);
|
|
404
806
|
const subject = extractSubject(normalizedTool, event.input as Record<string, unknown>);
|
|
405
|
-
// Resolved through symlinks so the protected-path
|
|
406
|
-
// write
|
|
407
|
-
// tools: a bash subject is a command line
|
|
807
|
+
// Resolved through symlinks so the protected-path and working-directory
|
|
808
|
+
// checks see where a write lands or a read comes from, not how the path
|
|
809
|
+
// is spelled. Path tools only: a bash subject is a command line.
|
|
408
810
|
const resolvedSubject =
|
|
409
|
-
|
|
811
|
+
isPathSubjectTool(normalizedTool) && subject
|
|
410
812
|
? resolveForContainment(toAbsolute(ctx.cwd, subject, os.homedir()))
|
|
411
813
|
: undefined;
|
|
412
814
|
// In a worktree session, worktree's tool_call handler (which runs before this
|
|
413
|
-
// one) cd-wraps bash commands for execution
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
815
|
+
// one) cd-wraps bash commands for execution and publishes the model's
|
|
816
|
+
// original command — and the worktree it runs in — over the bus under this
|
|
817
|
+
// call's id. Rule matching, the shell pre-gate, the recoverability judge and
|
|
818
|
+
// the prompt all evaluate that original against the worktree cwd: matched
|
|
819
|
+
// against the wrapper every configured Bash rule stopped matching, and
|
|
820
|
+
// analysed as the wrapper (a `cd` plus a newline) every call escalated to
|
|
821
|
+
// the classifier and the prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-
|
|
822
|
+
// 2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
|
|
823
|
+
// command that actually runs). The lookup is by toolCallId on purpose: a
|
|
824
|
+
// value inside `event.input` would be the model's to write, and rules would
|
|
825
|
+
// match a string of its choosing.
|
|
826
|
+
const original = normalizedTool === "bash" ? originalCommands.get(event.toolCallId) : undefined;
|
|
827
|
+
const matchSubject = original?.command ?? subject;
|
|
828
|
+
const callCwd = original?.cwd ?? ctx.cwd;
|
|
829
|
+
|
|
830
|
+
// Record every tool call into the classifier transcript (inputs only). In
|
|
831
|
+
// auto mode this is the running <transcript> the classifier reads, and this
|
|
832
|
+
// call is now its last entry — the action under review. For bash, record the
|
|
833
|
+
// model's original command, not the worktree cd-wrapper that actually runs.
|
|
834
|
+
if (mode === "auto") {
|
|
835
|
+
const recordedInput =
|
|
836
|
+
original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>);
|
|
837
|
+
transcript.push({ kind: "tool", tool: normalizedTool, input: recordedInput });
|
|
838
|
+
capTranscript();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const decideWith = (allowRules: PermissionRule[]) =>
|
|
842
|
+
decide({
|
|
843
|
+
toolName: event.toolName,
|
|
844
|
+
subject: matchSubject,
|
|
845
|
+
cwd: callCwd,
|
|
846
|
+
mode,
|
|
847
|
+
deny,
|
|
848
|
+
ask,
|
|
849
|
+
allow: allowRules,
|
|
850
|
+
classifyAllShell: classifyAllShell(),
|
|
851
|
+
resolvedSubject,
|
|
852
|
+
resolvedCwd,
|
|
853
|
+
planFilePath,
|
|
854
|
+
memoryDirPath,
|
|
855
|
+
scratchpadDirPath,
|
|
856
|
+
resultsDirPath,
|
|
857
|
+
protectedDirs,
|
|
858
|
+
});
|
|
859
|
+
let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
|
|
860
|
+
|
|
861
|
+
// A repo-shipped allow rule would decide this call: ask the user to trust
|
|
862
|
+
// the repository's rule list first (once per list; project-trust.ts). No UI
|
|
863
|
+
// → the rules stay off and the call takes the normal path (fail closed).
|
|
864
|
+
if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && projectAllow.length > 0 && ctx.hasUI) {
|
|
865
|
+
const withProject = decideWith([...allow, ...activeSessionAllows(), ...projectAllow]);
|
|
866
|
+
if (withProject.decision === "allow" && withProject.rule && projectAllow.includes(withProject.rule)) {
|
|
867
|
+
const firing = withProject.rule.raw;
|
|
868
|
+
const { title, message } = describeProjectAllow(projectAllowRaw, firing);
|
|
869
|
+
const approved = (await serializePrompt(() => ctx.ui.confirm(title, message))) === true;
|
|
870
|
+
if (approved) {
|
|
871
|
+
projectAllowTrusted = true;
|
|
872
|
+
persistProjectAllowApproval(projectRoot, projectAllowRaw);
|
|
873
|
+
result = withProject;
|
|
874
|
+
} else {
|
|
875
|
+
projectAllowDeclined = true;
|
|
876
|
+
ctx.ui.notify("This repository's allow rules stay off for this session (its deny/ask rules still apply).", "info");
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
433
880
|
|
|
434
881
|
/**
|
|
435
882
|
* Auto mode's deterministic floor: a write to the files the gate is made
|
|
@@ -447,84 +894,84 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
447
894
|
input: event.input as Record<string, unknown>,
|
|
448
895
|
cwd: ctx.cwd,
|
|
449
896
|
home: os.homedir(),
|
|
897
|
+
oneCodeProjectSettings: oneCodeProjectSettingsFile,
|
|
450
898
|
})
|
|
451
899
|
: undefined;
|
|
452
900
|
|
|
453
901
|
if (result.decision === "allow" && !floorReason) return undefined;
|
|
454
902
|
|
|
903
|
+
// (dontAsk is the only mode that denies rather than allows unmatched calls.)
|
|
455
904
|
if (result.decision === "deny") {
|
|
456
|
-
|
|
457
|
-
|
|
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") };
|
|
905
|
+
recordRuleDenial(result, event.toolName, matchSubject);
|
|
906
|
+
return { block: true, reason: denyReason(result) };
|
|
461
907
|
}
|
|
462
908
|
|
|
463
|
-
let classifierBlock: string | undefined;
|
|
464
909
|
if (floorReason) {
|
|
465
|
-
//
|
|
466
|
-
// block
|
|
910
|
+
// The floor never reaches the classifier. Auto mode is unattended, so a
|
|
911
|
+
// block goes back to the model rather than a per-action prompt (a prompt
|
|
912
|
+
// would hang the session); a non-auto interactive session still prompts,
|
|
913
|
+
// since editing your own settings by hand is legitimate.
|
|
467
914
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
468
|
-
if (!ctx.hasUI) {
|
|
915
|
+
if (mode === "auto" || !ctx.hasUI) {
|
|
469
916
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
470
917
|
return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
|
|
471
918
|
}
|
|
472
919
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
|
|
473
920
|
} else if (result.decision === "classify") {
|
|
474
|
-
// Auto mode
|
|
475
|
-
//
|
|
921
|
+
// Auto mode is for unattended runs: a block is returned to the MODEL so it
|
|
922
|
+
// can try a safe alternative — it is never raised as a per-action user
|
|
923
|
+
// prompt. The one exception is the loop-breaker: after repeated blocks the
|
|
924
|
+
// gate pauses and the next call falls through to a resume prompt, so a
|
|
925
|
+
// model grinding against the classifier reaches the user instead of
|
|
926
|
+
// burning the night.
|
|
476
927
|
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
928
|
const outcome = await runClassifier(
|
|
482
929
|
event.toolName,
|
|
483
930
|
event.input as Record<string, unknown>,
|
|
484
|
-
|
|
931
|
+
matchSubject,
|
|
485
932
|
ctx,
|
|
486
|
-
|
|
933
|
+
// Protected paths must always be judged; everything else may be cleared
|
|
934
|
+
// by the deterministic containment fast-path.
|
|
935
|
+
result.cause !== "protected-path",
|
|
936
|
+
// A worktree-wrapped command is analysed as the model wrote it, in the
|
|
937
|
+
// worktree it runs in (the classifier transcript already holds it).
|
|
938
|
+
original?.cwd ? { cwd: original.cwd } : undefined,
|
|
487
939
|
);
|
|
488
940
|
if (outcome.decision === "allow") {
|
|
489
941
|
pauseTracker.recordAllow();
|
|
490
942
|
return undefined;
|
|
491
943
|
}
|
|
492
944
|
|
|
493
|
-
// A timeout
|
|
494
|
-
//
|
|
495
|
-
// the
|
|
945
|
+
// A timeout was not earned by looping against the gate, so it does not
|
|
946
|
+
// count toward the auto-pause. It is returned to the model as retryable
|
|
947
|
+
// (the call was never judged) rather than prompting — a prompt would
|
|
948
|
+
// hang an unattended session waiting on a classifier that was slow.
|
|
496
949
|
if (outcome.tier === "timeout") {
|
|
497
|
-
|
|
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;
|
|
950
|
+
return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
|
|
525
951
|
}
|
|
952
|
+
|
|
953
|
+
const tripped = pauseTracker.recordBlock({
|
|
954
|
+
toolName: event.toolName,
|
|
955
|
+
subject: matchSubject,
|
|
956
|
+
reason: outcome.reason,
|
|
957
|
+
tier: outcome.tier,
|
|
958
|
+
ruleId: outcome.ruleId,
|
|
959
|
+
raw: outcome.raw,
|
|
960
|
+
});
|
|
961
|
+
if (tripped) {
|
|
962
|
+
const { lifetime } = pauseTracker.stats();
|
|
963
|
+
ctx.ui.notify(
|
|
964
|
+
`Auto mode paused after ${lifetime} blocked call${lifetime === 1 ? "" : "s"} — approving a prompt resumes it.`,
|
|
965
|
+
"warning",
|
|
966
|
+
);
|
|
967
|
+
applyBadge();
|
|
968
|
+
}
|
|
969
|
+
// Every classifier block (hard or soft) goes back to the model with the
|
|
970
|
+
// classifier's own reason, so it can find a safe alternative. Auto mode
|
|
971
|
+
// does not hand a soft block to the user as a per-action prompt.
|
|
972
|
+
return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
|
|
526
973
|
}
|
|
527
|
-
// Paused
|
|
974
|
+
// Paused: fall through to the resume prompt below.
|
|
528
975
|
}
|
|
529
976
|
|
|
530
977
|
// ask
|
|
@@ -532,57 +979,210 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
532
979
|
return { block: true, reason: DENIED_NON_INTERACTIVE };
|
|
533
980
|
}
|
|
534
981
|
|
|
535
|
-
const preview =
|
|
982
|
+
const preview = previewSubject(matchSubject);
|
|
983
|
+
// Reaching a prompt from the classify branch only happens when auto mode is
|
|
984
|
+
// paused — this is the resume prompt, not a per-action approval.
|
|
985
|
+
const pausedResume = mode === "auto" && result.decision === "classify";
|
|
536
986
|
const title = floorReason
|
|
537
987
|
? `Auto mode never auto-approves this — ${event.toolName} ${floorReason}.\n\n ${preview}\n\n Allow it this once?`
|
|
538
|
-
:
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
988
|
+
: askTitle(event.toolName, preview, result.cause, pausedResume);
|
|
989
|
+
const grant = sessionGrant({
|
|
990
|
+
toolName: normalizedTool,
|
|
991
|
+
subject: matchSubject,
|
|
992
|
+
cwd: callCwd,
|
|
993
|
+
mode,
|
|
994
|
+
cause: result.cause,
|
|
995
|
+
floor: floorReason !== undefined,
|
|
996
|
+
home: os.homedir(),
|
|
997
|
+
});
|
|
998
|
+
const choice = await serializePrompt(() => ctx.ui.select(title, askOptions(grant)));
|
|
547
999
|
|
|
548
1000
|
// The user's answer is itself a gate decision worth recording — it is the
|
|
549
1001
|
// ground truth a drifting classifier gets calibrated against.
|
|
550
|
-
if (mode === "auto" && (floorReason ||
|
|
1002
|
+
if (mode === "auto" && (floorReason || pausedResume)) {
|
|
551
1003
|
logDecision(ctx, {
|
|
552
1004
|
tool: event.toolName,
|
|
553
|
-
subject,
|
|
554
|
-
outcome: choice
|
|
1005
|
+
subject: matchSubject,
|
|
1006
|
+
outcome: approved(choice, grant) ? "allow" : "block",
|
|
555
1007
|
source: "user",
|
|
556
|
-
reason: floorReason ??
|
|
1008
|
+
reason: floorReason ?? "resume after pause",
|
|
557
1009
|
});
|
|
558
1010
|
}
|
|
559
1011
|
|
|
560
1012
|
// Approving a prompted call is what resumes a paused auto mode.
|
|
561
|
-
if (choice
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
if (wasPaused) applyBadge();
|
|
566
|
-
}
|
|
1013
|
+
if (approved(choice, grant) && mode === "auto") {
|
|
1014
|
+
const wasPaused = pauseTracker.isPaused();
|
|
1015
|
+
pauseTracker.resume();
|
|
1016
|
+
if (wasPaused) applyBadge();
|
|
567
1017
|
}
|
|
568
1018
|
|
|
569
1019
|
if (choice === YES) return undefined;
|
|
570
|
-
if (choice ===
|
|
571
|
-
|
|
572
|
-
const rule = tool === "bash" && subject ? parseRule(`bash(${subject})`) : parseRule(tool);
|
|
573
|
-
if (rule) sessionAllows.push(rule);
|
|
1020
|
+
if (grant && choice === grant.label) {
|
|
1021
|
+
sessionAllows.push(grant.rule);
|
|
574
1022
|
return undefined;
|
|
575
1023
|
}
|
|
576
1024
|
|
|
577
1025
|
// The "tell the agent what to do differently" option has to actually carry
|
|
578
1026
|
// 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");
|
|
1027
|
+
const feedback = await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip"));
|
|
580
1028
|
return {
|
|
581
1029
|
block: true,
|
|
582
1030
|
reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
|
|
583
1031
|
};
|
|
584
1032
|
});
|
|
585
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Evaluate ONE subagent tool call against the parent's live gate — the bridge
|
|
1036
|
+
* published on SUBAGENT_GATE_CHANNEL. Mirrors the main tool_call handler's
|
|
1037
|
+
* decision flow (mode, rules, safety floor, auto-mode classifier, and an
|
|
1038
|
+
* interactive ask), so a child inherits the parent's mode and hits the real gate
|
|
1039
|
+
* (findings §17.1). Differences from the main path, all deliberate: it never
|
|
1040
|
+
* mutates the parent's live transcript (the child action is appended to a copy
|
|
1041
|
+
* for the classifier) and uses the child's own cwd; child blocks do not COUNT
|
|
1042
|
+
* toward the parent's pauseTracker (a child is bounded by its wall-clock cap
|
|
1043
|
+
* and the hand-back return review instead) but an active pause IS honoured —
|
|
1044
|
+
* a paused session's child calls fall through to the resume prompt; and an ask
|
|
1045
|
+
* prompt renders on the parent's terminal via lastReviewCtx, serialized. No
|
|
1046
|
+
* parent UI reachable → fail closed.
|
|
1047
|
+
*/
|
|
1048
|
+
const evaluateChildToolCall = async (call: ChildToolCall): Promise<ChildGateDecision> => {
|
|
1049
|
+
const ctx = lastReviewCtx;
|
|
1050
|
+
const { toolName, input, cwd } = call;
|
|
1051
|
+
const normalizedTool = normalizeToolName(toolName);
|
|
1052
|
+
const subject = extractSubject(normalizedTool, input);
|
|
1053
|
+
const resolvedSubject =
|
|
1054
|
+
isPathSubjectTool(normalizedTool) && subject
|
|
1055
|
+
? resolveForContainment(toAbsolute(cwd, subject, os.homedir()))
|
|
1056
|
+
: undefined;
|
|
1057
|
+
|
|
1058
|
+
const result = decide({
|
|
1059
|
+
toolName,
|
|
1060
|
+
subject,
|
|
1061
|
+
cwd,
|
|
1062
|
+
mode,
|
|
1063
|
+
deny,
|
|
1064
|
+
ask,
|
|
1065
|
+
// Same rule set the main handler applies once the user has consented to
|
|
1066
|
+
// the repo's own allow list — a child must not prompt (or, headless, be
|
|
1067
|
+
// denied) for a call the parent would allow (SUBAGENT-REVIEW L5).
|
|
1068
|
+
allow: [...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])],
|
|
1069
|
+
classifyAllShell: classifyAllShell(),
|
|
1070
|
+
resolvedSubject,
|
|
1071
|
+
// The child's own cwd (a worktree, if isolated) is its working directory.
|
|
1072
|
+
resolvedCwd: resolveForContainment(cwd),
|
|
1073
|
+
planFilePath,
|
|
1074
|
+
memoryDirPath,
|
|
1075
|
+
scratchpadDirPath,
|
|
1076
|
+
resultsDirPath,
|
|
1077
|
+
protectedDirs,
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// A child's cwd can be a worktree (different project → different per-repo
|
|
1081
|
+
// settings path), so the floor must derive it from THIS call's cwd, not the
|
|
1082
|
+
// parent-session-cached path. Omitting it lets safetyControlWrite derive
|
|
1083
|
+
// from `cwd`; the fs walk is acceptable on the (rarer) child path, and
|
|
1084
|
+
// correctness of the gate-control floor is not negotiable.
|
|
1085
|
+
const floorReason =
|
|
1086
|
+
mode === "auto"
|
|
1087
|
+
? safetyControlWrite({ toolName: normalizedTool, input, cwd, home: os.homedir() })
|
|
1088
|
+
: undefined;
|
|
1089
|
+
|
|
1090
|
+
if (result.decision === "allow" && !floorReason) return undefined;
|
|
1091
|
+
|
|
1092
|
+
if (result.decision === "deny") {
|
|
1093
|
+
// A child's rule denial must NOT be recorded here: the child path never
|
|
1094
|
+
// mutates the parent's live transcript (docs/decisions/subagents-workflows.md
|
|
1095
|
+
// — child calls stay out of it). Pushing a `denied` entry would leak the
|
|
1096
|
+
// child's action into the parent and flip `afterRuleDenial` on for every
|
|
1097
|
+
// later MAIN classification. The child's classifier already learns the
|
|
1098
|
+
// user's forbidden classes from the rule extras.
|
|
1099
|
+
return { block: true, reason: denyReason(result) };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Auto mode: the classifier screens the child's call, exactly as it screens
|
|
1103
|
+
// the main agent's. A block is returned to the child (never a per-action
|
|
1104
|
+
// prompt), so it can find a safe alternative — auto mode runs unattended.
|
|
1105
|
+
// While the loop-breaker pause is in effect, the classifier is NOT consulted:
|
|
1106
|
+
// the call falls through to the resume prompt below, exactly as the main
|
|
1107
|
+
// handler's paused branch does — a pause is a full-session stop awaiting the
|
|
1108
|
+
// user, and child work must not continue through it unattended.
|
|
1109
|
+
if (!floorReason && result.decision === "classify" && !pauseTracker.isPaused()) {
|
|
1110
|
+
if (!ctx) return { block: true, reason: DENIED_NON_INTERACTIVE };
|
|
1111
|
+
const appendEntry: TranscriptEntry = {
|
|
1112
|
+
kind: "tool",
|
|
1113
|
+
tool: normalizedTool,
|
|
1114
|
+
input: normalizedTool === "bash" ? { command: subject } : input,
|
|
1115
|
+
};
|
|
1116
|
+
const outcome = await runClassifier(toolName, input, subject, ctx, result.cause !== "protected-path", {
|
|
1117
|
+
cwd,
|
|
1118
|
+
appendEntry,
|
|
1119
|
+
// The child's own turn signal, so an aborted child turn cancels the
|
|
1120
|
+
// classifier call; a fresh (never-aborted) signal only if the child
|
|
1121
|
+
// didn't supply one, so classify() still gets the signal it expects.
|
|
1122
|
+
signal: call.signal ?? new AbortController().signal,
|
|
1123
|
+
});
|
|
1124
|
+
if (outcome.decision === "allow") return undefined;
|
|
1125
|
+
if (outcome.tier === "timeout") return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
|
|
1126
|
+
return { block: true, reason: DENIED_BY_CLASSIFIER(outcome.reason) };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Safety floor: a write to the gate's own config is NEVER auto-approved and
|
|
1130
|
+
// never prompted — in auto mode it is returned to the model, exactly as the
|
|
1131
|
+
// main handler does (floorReason is only ever set in auto mode). Prompting it
|
|
1132
|
+
// would let an inattentive "Yes" — or a prompt-injected child — defeat the one
|
|
1133
|
+
// control the classifier itself can't be trusted to enforce.
|
|
1134
|
+
if (floorReason) {
|
|
1135
|
+
if (ctx) logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
1136
|
+
return { block: true, reason: DENIED_SAFETY_FLOOR(floorReason) };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ask: needs the user. Bubble it to the parent's terminal; no UI → fail closed.
|
|
1140
|
+
if (!ctx || !ctx.hasUI) return { block: true, reason: DENIED_NON_INTERACTIVE };
|
|
1141
|
+
const preview = previewSubject(subject);
|
|
1142
|
+
// Reaching here from the classify branch only happens while auto mode is
|
|
1143
|
+
// paused — this is the resume prompt, not a per-action approval.
|
|
1144
|
+
const pausedResume = mode === "auto" && result.decision === "classify";
|
|
1145
|
+
// Name the asking agent: two children prompting back-to-back are otherwise
|
|
1146
|
+
// indistinguishable on screen.
|
|
1147
|
+
const who = call.agent ? `subagent ${call.agent}'s` : "a subagent's";
|
|
1148
|
+
const title = askTitle(`${who} ${toolName}`, preview, result.cause, pausedResume);
|
|
1149
|
+
// The child's turn signal dismisses the dialog (as a denial) when the child
|
|
1150
|
+
// is stopped mid-prompt; a prompt whose child is already gone by the time
|
|
1151
|
+
// its turn in the chain comes is skipped outright. Otherwise a dead agent's
|
|
1152
|
+
// dialog stays on screen and every later prompt waits behind it (M5).
|
|
1153
|
+
const childPrompt = <T>(show: () => Promise<T>): Promise<T | undefined> =>
|
|
1154
|
+
serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()));
|
|
1155
|
+
const grant = sessionGrant({ toolName: normalizedTool, subject, cwd, mode, cause: result.cause, home: os.homedir() });
|
|
1156
|
+
const choice = await childPrompt(() => ctx.ui.select(title, askOptions(grant), { signal: call.signal }));
|
|
1157
|
+
if (call.signal?.aborted) return { block: true, reason: "The agent was stopped while waiting for the user's approval." };
|
|
1158
|
+
|
|
1159
|
+
if (pausedResume) {
|
|
1160
|
+
logDecision(ctx, {
|
|
1161
|
+
tool: toolName,
|
|
1162
|
+
subject,
|
|
1163
|
+
outcome: approved(choice, grant) ? "allow" : "block",
|
|
1164
|
+
source: "user",
|
|
1165
|
+
reason: "resume after pause",
|
|
1166
|
+
});
|
|
1167
|
+
// Approving a prompted call is what resumes a paused auto mode.
|
|
1168
|
+
if (approved(choice, grant)) {
|
|
1169
|
+
pauseTracker.resume();
|
|
1170
|
+
applyBadge();
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (choice === YES) return undefined;
|
|
1175
|
+
if (grant && choice === grant.label) {
|
|
1176
|
+
sessionAllows.push(grant.rule);
|
|
1177
|
+
return undefined;
|
|
1178
|
+
}
|
|
1179
|
+
const feedback = await childPrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: call.signal }));
|
|
1180
|
+
return {
|
|
1181
|
+
block: true,
|
|
1182
|
+
reason: feedback?.trim() ? `${DENIED_BY_USER}\n\nThe user said: ${feedback.trim()}` : DENIED_BY_USER,
|
|
1183
|
+
};
|
|
1184
|
+
};
|
|
1185
|
+
|
|
586
1186
|
pi.registerCommand("permissions", {
|
|
587
1187
|
description: "Show permission mode and loaded rules",
|
|
588
1188
|
handler: async (_args, ctx) => {
|
|
@@ -608,7 +1208,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
608
1208
|
`deny: ${fmt(deny)}`,
|
|
609
1209
|
`ask: ${fmt(ask)}`,
|
|
610
1210
|
`allow: ${fmt(allow)}`,
|
|
1211
|
+
...(projectAllow.length > 0
|
|
1212
|
+
? [
|
|
1213
|
+
`project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`,
|
|
1214
|
+
]
|
|
1215
|
+
: []),
|
|
611
1216
|
`session allows: ${fmt(sessionAllows)}`,
|
|
1217
|
+
...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
|
|
612
1218
|
...autoLines,
|
|
613
1219
|
].join("\n"),
|
|
614
1220
|
"info",
|
|
@@ -637,15 +1243,25 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
637
1243
|
signal: AbortSignal | undefined,
|
|
638
1244
|
): Promise<string | undefined> => {
|
|
639
1245
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
1246
|
+
// Present the child's actions as tool entries appended to the session
|
|
1247
|
+
// transcript, so the reviewer judges the sequence with the parent's context
|
|
1248
|
+
// in view (SESSION LINKING). reviewOnly runs a single stage-2 full-eval —
|
|
1249
|
+
// there is no harm floor to short-circuit for a run that already happened.
|
|
1250
|
+
const childEntries: TranscriptEntry[] = actions.map((action) => {
|
|
1251
|
+
const tool = normalizeToolName(action.toolName);
|
|
1252
|
+
return { kind: "tool", tool, input: tool === "bash" ? { command: action.subject } : { subject: action.subject } };
|
|
1253
|
+
});
|
|
640
1254
|
const verdict = await classify(
|
|
641
1255
|
{
|
|
642
|
-
toolName: "subagent
|
|
643
|
-
|
|
644
|
-
cwd: ctx.cwd,
|
|
1256
|
+
toolName: "subagent-review",
|
|
1257
|
+
transcript: [...transcript, ...childEntries],
|
|
645
1258
|
userMessages: [...userMessages],
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
1259
|
+
claudeMd: instructionsFor(ctx.cwd),
|
|
1260
|
+
username: classifierUsername,
|
|
1261
|
+
environment: autoConfig.environment,
|
|
1262
|
+
// AutoModeConfig is structurally a RuleExtras; the user's own deny rules
|
|
1263
|
+
// are appended to its hard list (classifierRuleExtras).
|
|
1264
|
+
ruleExtras: classifierRuleExtras(),
|
|
649
1265
|
},
|
|
650
1266
|
{
|
|
651
1267
|
registry: ctx.modelRegistry,
|
|
@@ -654,10 +1270,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
654
1270
|
signal,
|
|
655
1271
|
state: classifierState,
|
|
656
1272
|
onNotice: (message, level) => ctx.ui.notify(message, level),
|
|
1273
|
+
onUsage: onClassifierUsage,
|
|
1274
|
+
onModelUnusable: onClassifierModelUnusable,
|
|
1275
|
+
reviewOnly: true,
|
|
657
1276
|
},
|
|
658
1277
|
);
|
|
659
1278
|
logDecision(ctx, {
|
|
660
|
-
tool: "
|
|
1279
|
+
tool: "Agent",
|
|
661
1280
|
subject,
|
|
662
1281
|
outcome: verdict.decision,
|
|
663
1282
|
source: "review",
|
|
@@ -667,7 +1286,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
667
1286
|
});
|
|
668
1287
|
if (verdict.decision === "allow") return undefined;
|
|
669
1288
|
pauseTracker.recordBlock({
|
|
670
|
-
toolName: "
|
|
1289
|
+
toolName: "Agent",
|
|
671
1290
|
subject,
|
|
672
1291
|
reason: verdict.reason,
|
|
673
1292
|
tier: verdict.tier,
|
|
@@ -687,18 +1306,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
687
1306
|
return;
|
|
688
1307
|
}
|
|
689
1308
|
// Background/resident: the spawning call already returned, so there is no
|
|
690
|
-
// tool_result to attach to.
|
|
691
|
-
//
|
|
1309
|
+
// tool_result to attach to. The emitter holds its completion report until
|
|
1310
|
+
// we answer `onReview`, so the verdict travels with the report (a payload
|
|
1311
|
+
// without the callback gets the older standalone notice instead). Answer
|
|
1312
|
+
// synchronously when no review will run, so the report is never delayed
|
|
1313
|
+
// for nothing. A review that throws fails closed: the report goes out
|
|
1314
|
+
// flagged, not clean.
|
|
1315
|
+
const respond =
|
|
1316
|
+
payload.onReview ??
|
|
1317
|
+
((flag: string | undefined) => {
|
|
1318
|
+
if (flag) notifyTask("subagent-review", flag);
|
|
1319
|
+
});
|
|
692
1320
|
const ctx = lastReviewCtx;
|
|
693
|
-
if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx)
|
|
1321
|
+
if (mode !== "auto" || payload.actions.length === 0 || pauseTracker.isPaused() || !ctx) {
|
|
1322
|
+
respond(undefined);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
694
1325
|
const label = payload.agentName ? `${payload.agentName} (background run)` : "background run";
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1326
|
+
const epoch = sessionEpoch;
|
|
1327
|
+
// A review that outlives its session (a /clear mid-review) is dropped: the
|
|
1328
|
+
// agents were stopped with the old session and its ctx no longer renders.
|
|
1329
|
+
const respondIfCurrent = (flag: string | undefined) => {
|
|
1330
|
+
if (epoch === sessionEpoch) respond(flag);
|
|
1331
|
+
};
|
|
1332
|
+
reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
|
|
1333
|
+
.then((reason) => respondIfCurrent(reason ? reviewFlagged(reason) : undefined))
|
|
1334
|
+
.catch((error) => respondIfCurrent(reviewFlagged(`the review itself failed (${(error as Error).message})`)));
|
|
702
1335
|
});
|
|
703
1336
|
|
|
704
1337
|
pi.on("tool_result", async (event, ctx) => {
|
|
@@ -730,13 +1363,15 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
730
1363
|
return;
|
|
731
1364
|
}
|
|
732
1365
|
try {
|
|
733
|
-
|
|
1366
|
+
// Stamp the session's containment so a later session on another provider
|
|
1367
|
+
// treats this cross-provider setting as stale (parity with /subagent).
|
|
1368
|
+
persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
|
|
734
1369
|
} catch (error) {
|
|
735
1370
|
ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
736
1371
|
return;
|
|
737
1372
|
}
|
|
738
1373
|
resetClassifierChoice();
|
|
739
|
-
ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.
|
|
1374
|
+
ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, "info");
|
|
740
1375
|
};
|
|
741
1376
|
|
|
742
1377
|
/** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
|
|
@@ -783,12 +1418,128 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
783
1418
|
if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
|
|
784
1419
|
};
|
|
785
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* `/auto-mode setup` — Claude Code's setup-wizard flow: ask how this
|
|
1423
|
+
* environment is used, gather facts (git/gh/CLAUDE.md, opt-in shell
|
|
1424
|
+
* history), have a capable model draft the Environment slots and rule
|
|
1425
|
+
* extras, review, persist to user settings, then audit permissions.allow
|
|
1426
|
+
* for rules that skip the classifier entirely.
|
|
1427
|
+
*/
|
|
1428
|
+
const handleSetupSubcommand = async (ctx: ExtensionContext): Promise<void> => {
|
|
1429
|
+
if (!ctx.hasUI) {
|
|
1430
|
+
ctx.ui.notify("/auto-mode setup is interactive — run it in a live session.", "warning");
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const usage = await ctx.ui.select("How do you use One Code in this environment?", [
|
|
1434
|
+
"Software development in this repo",
|
|
1435
|
+
"Mixed — coding and general tasks",
|
|
1436
|
+
"Mostly questions and analysis",
|
|
1437
|
+
]);
|
|
1438
|
+
if (!usage) return;
|
|
1439
|
+
const history = await ctx.ui.select("Also scan recent shell history? (stays local except the drafting call; secrets are redacted)", [
|
|
1440
|
+
"Yes",
|
|
1441
|
+
"No",
|
|
1442
|
+
]);
|
|
1443
|
+
if (!history) return;
|
|
1444
|
+
|
|
1445
|
+
ctx.ui.notify("Gathering environment facts (git, gh, CLAUDE.md)…", "info");
|
|
1446
|
+
const facts = await gatherFacts({
|
|
1447
|
+
cwd: ctx.cwd,
|
|
1448
|
+
home: os.homedir(),
|
|
1449
|
+
username: classifierUsername,
|
|
1450
|
+
usage,
|
|
1451
|
+
includeShellHistory: history === "Yes",
|
|
1452
|
+
});
|
|
1453
|
+
ctx.ui.notify("Drafting the auto-mode setup — this can take a minute…", "info");
|
|
1454
|
+
let draft: Awaited<ReturnType<typeof draftSetup>>;
|
|
1455
|
+
try {
|
|
1456
|
+
draft = await draftSetup(facts, {
|
|
1457
|
+
registry: ctx.modelRegistry,
|
|
1458
|
+
sessionModel: ctx.model,
|
|
1459
|
+
config: autoConfig ?? loadAutoModeConfig(os.homedir()),
|
|
1460
|
+
defaultEnvironment: DEFAULT_ENVIRONMENT,
|
|
1461
|
+
signal: ctx.signal,
|
|
1462
|
+
onNotice: (message, level) => ctx.ui.notify(message, level),
|
|
1463
|
+
onUsage: (usage) => recordUsage(pi, "setup", usage),
|
|
1464
|
+
});
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
ctx.ui.notify(`Auto-mode setup failed: ${(error as Error).message}. Nothing was written.`, "warning");
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
ctx.ui.notify(`Proposed auto-mode setup\n\n${renderProposal(draft)}`, "info");
|
|
1471
|
+
const decision = await ctx.ui.select("Save this auto-mode setup to ~/.onecode/settings.json?", [
|
|
1472
|
+
"Looks good — save it",
|
|
1473
|
+
"Discard",
|
|
1474
|
+
]);
|
|
1475
|
+
if (decision === "Looks good — save it") {
|
|
1476
|
+
try {
|
|
1477
|
+
persistAutoModeSetup(settingsPatch(draft), os.homedir());
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
ctx.ui.notify(`Could not write One Code settings: ${(error as Error).message}`, "warning");
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
autoConfig = loadAutoModeConfig(os.homedir());
|
|
1483
|
+
ctx.ui.notify("Saved. /auto-mode config shows the effective setup.", "info");
|
|
1484
|
+
} else {
|
|
1485
|
+
ctx.ui.notify("Discarded — nothing was written.", "info");
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// CC's "rules that skip checks": broad permissions.allow entries bypass the
|
|
1489
|
+
// classifier whether or not the setup above was saved, so the audit runs
|
|
1490
|
+
// either way. Entries that live in Claude Code's own settings are One Code's
|
|
1491
|
+
// to warn about, never to delete — only One Code's own file is editable here.
|
|
1492
|
+
const flagged = auditPermissionAllow(facts.permissionsAllow);
|
|
1493
|
+
if (flagged.length === 0) return;
|
|
1494
|
+
// Split by the file each rule lives in, not by exclusion — a rule in BOTH
|
|
1495
|
+
// files is removable from One Code's AND still live in Claude Code's, so it
|
|
1496
|
+
// must appear in both lists (removing the One Code copy alone would leave the
|
|
1497
|
+
// exposure and a false "fixed" impression).
|
|
1498
|
+
const oneCodeAllow = new Set(oneCodePermissionAllow(os.homedir()));
|
|
1499
|
+
const claudeAllow = new Set(claudeUserPermissionAllow(os.homedir()));
|
|
1500
|
+
const removable = flagged.filter((entry) => oneCodeAllow.has(entry.rule));
|
|
1501
|
+
const claudeSide = flagged.filter((entry) => claudeAllow.has(entry.rule));
|
|
1502
|
+
|
|
1503
|
+
ctx.ui.notify(
|
|
1504
|
+
"These permissions.allow entries are broad enough that matching commands never reach auto mode's checks:\n" +
|
|
1505
|
+
flagged.map((entry) => ` · ${entry.rule} — ${entry.why}`).join("\n"),
|
|
1506
|
+
"warning",
|
|
1507
|
+
);
|
|
1508
|
+
if (claudeSide.length > 0) {
|
|
1509
|
+
ctx.ui.notify(
|
|
1510
|
+
"These live in your Claude Code settings (~/.claude) and stay in force even after any One Code copy is removed. One Code never edits Claude Code's files — remove them there yourself if you want them gated:\n" +
|
|
1511
|
+
claudeSide.map((entry) => ` · ${entry.rule}`).join("\n"),
|
|
1512
|
+
"warning",
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
if (removable.length === 0) return;
|
|
1516
|
+
const act = await ctx.ui.select(
|
|
1517
|
+
`Remove ${removable.length} broad ${removable.length === 1 ? "entry" : "entries"} from One Code settings (~/.onecode/settings.json)?`,
|
|
1518
|
+
["Remove them", "Leave them"],
|
|
1519
|
+
);
|
|
1520
|
+
if (act === "Remove them") {
|
|
1521
|
+
try {
|
|
1522
|
+
const removed = removeOneCodePermissionAllow(
|
|
1523
|
+
removable.map((entry) => entry.rule),
|
|
1524
|
+
os.homedir(),
|
|
1525
|
+
);
|
|
1526
|
+
ctx.ui.notify(
|
|
1527
|
+
`Removed ${removed} allow ${removed === 1 ? "entry" : "entries"} from One Code settings. Removed entries can be restored by re-adding them verbatim.`,
|
|
1528
|
+
"info",
|
|
1529
|
+
);
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
ctx.ui.notify(`Could not update One Code settings: ${(error as Error).message}`, "warning");
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
|
|
786
1536
|
pi.registerCommand("auto-mode", {
|
|
787
|
-
description: "Auto-mode classifier: /auto-mode [defaults|config|model [provider/model-id|clear]]",
|
|
1537
|
+
description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
|
|
788
1538
|
getArgumentCompletions: () =>
|
|
789
1539
|
[
|
|
790
|
-
{ value: "
|
|
791
|
-
{ value: "
|
|
1540
|
+
{ value: "setup", label: "analyze this environment and draft the config" },
|
|
1541
|
+
{ value: "config", label: "effective environment" },
|
|
1542
|
+
{ value: "defaults", label: "built-in environment" },
|
|
792
1543
|
{ value: "model", label: "choose the classifier model" },
|
|
793
1544
|
],
|
|
794
1545
|
handler: async (args, ctx) => {
|
|
@@ -797,39 +1548,51 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
797
1548
|
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
798
1549
|
return;
|
|
799
1550
|
}
|
|
1551
|
+
if (sub === "setup") {
|
|
1552
|
+
await handleSetupSubcommand(ctx);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
800
1555
|
const which = sub ?? "config";
|
|
801
1556
|
if (which !== "defaults" && which !== "config") {
|
|
802
1557
|
ctx.ui.notify(
|
|
803
|
-
`Unknown subcommand "${which}". Use: /auto-mode defaults | config | model [provider/model-id|clear]`,
|
|
1558
|
+
`Unknown subcommand "${which}". Use: /auto-mode setup | defaults | config | model [provider/model-id|clear]`,
|
|
804
1559
|
"warning",
|
|
805
1560
|
);
|
|
806
1561
|
return;
|
|
807
1562
|
}
|
|
808
|
-
// "defaults" prints the built-in
|
|
809
|
-
// classifier actually uses, with $defaults already spliced in. The
|
|
1563
|
+
// "defaults" prints the built-in Environment; "config" prints what the
|
|
1564
|
+
// classifier actually uses, with $defaults already spliced in. The rule
|
|
1565
|
+
// set's prose is Claude Code's fixed monolith and is not shown here — the
|
|
1566
|
+
// customization surface is the Environment plus the append-only
|
|
1567
|
+
// hard_deny/soft_deny/allow rule extras (the same schema Claude Code's
|
|
1568
|
+
// /auto-mode-setup writes; those settings work here unchanged). The
|
|
810
1569
|
// config view re-reads disk so it shows the file as it is now, and
|
|
811
|
-
// refreshes the
|
|
1570
|
+
// refreshes the cached copy.
|
|
812
1571
|
const loaded = which === "config" ? loadAutoModeConfigWithDiagnostics(os.homedir()) : undefined;
|
|
813
1572
|
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
|
-
};
|
|
1573
|
+
const shown = loaded ? loaded.config : { environment: DEFAULT_ENVIRONMENT };
|
|
822
1574
|
const section = (title: string, entries: string[]) =>
|
|
823
1575
|
`${title} (${entries.length}):\n${entries.map((entry) => ` - ${entry}`).join("\n")}`;
|
|
1576
|
+
const ruleSections =
|
|
1577
|
+
loaded && "hardDeny" in shown
|
|
1578
|
+
? (
|
|
1579
|
+
[
|
|
1580
|
+
["extra hard_deny rules", shown.hardDeny],
|
|
1581
|
+
["extra soft_deny rules", shown.softDeny],
|
|
1582
|
+
["extra allow rules", shown.allow],
|
|
1583
|
+
] as const
|
|
1584
|
+
)
|
|
1585
|
+
.filter(([, entries]) => entries.length > 0)
|
|
1586
|
+
.map(([title, entries]) => section(title, entries))
|
|
1587
|
+
: [];
|
|
824
1588
|
ctx.ui.notify(
|
|
825
1589
|
[
|
|
826
1590
|
which === "config"
|
|
827
|
-
? `read from: ${autoModeSettingsPaths(os.homedir()).join(", ")}\n(project settings are deliberately not read — a repo could otherwise grant itself
|
|
828
|
-
:
|
|
829
|
-
|
|
830
|
-
section("soft_deny", shown.soft_deny),
|
|
831
|
-
section("allow", shown.allow),
|
|
1591
|
+
? `read from: ${autoModeSettingsPaths(os.homedir()).join(", ")}\n(project settings are deliberately not read — a repo could otherwise grant itself permissions)`
|
|
1592
|
+
: 'built-in environment; add "$defaults" to autoMode.environment in settings to keep it while adding your own',
|
|
1593
|
+
"The built-in ruleset is Claude Code's fixed classifier ruleset (not shown); customize via the environment and the append-only hard_deny/soft_deny/allow extras.",
|
|
832
1594
|
section("environment", shown.environment),
|
|
1595
|
+
...ruleSections,
|
|
833
1596
|
...(which === "config" && "classifyAllShell" in shown
|
|
834
1597
|
? [
|
|
835
1598
|
`classifyAllShell: ${shown.classifyAllShell}`,
|
|
@@ -866,8 +1629,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
866
1629
|
ctx.ui.notify(`Could not parse rule: "${raw}". Format: Tool or Tool(pattern)`, "warning");
|
|
867
1630
|
return;
|
|
868
1631
|
}
|
|
869
|
-
|
|
870
|
-
|
|
1632
|
+
// Allow rules are One Code's own state — never written into Claude Code's
|
|
1633
|
+
// files. `global` lands in ~/.onecode/settings.json; a project rule in a
|
|
1634
|
+
// per-repo file under ~/.onecode, keyed by the repo root.
|
|
1635
|
+
const target = global ? oneCodeSettingsPath(os.homedir()) : oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
|
|
871
1636
|
persistAllowRule(raw, target);
|
|
872
1637
|
allow.push(rule);
|
|
873
1638
|
ctx.ui.notify(`Added allow rule ${raw} to ${target}`, "info");
|