one-code-extension 0.4.0 → 0.5.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/README.md +219 -219
- package/agents/general-purpose.md +2 -2
- package/docs/guide/README.md +2 -1
- package/docs/guide/bring-your-claude-code-setup.md +60 -9
- package/docs/guide/configuration.md +48 -14
- package/docs/guide/differences-from-claude-code.md +32 -23
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +27 -4
- package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
- package/docs/guide/providers-and-models.md +137 -54
- package/docs/guide/reference.md +27 -15
- package/docs/guide/sessions-and-context.md +69 -16
- package/docs/guide/skills-plugins-and-mcp.md +57 -20
- package/docs/guide/subagents-and-workflows.md +95 -29
- package/docs/guide/tasks-and-background-work.md +101 -26
- package/docs/guide/terminal-interface.md +24 -12
- package/docs/guide/tools.md +46 -5
- package/docs/guide/troubleshooting.md +13 -9
- package/docs/guide/windows.md +66 -34
- package/extensions/artifacts/gallery.ts +227 -0
- package/extensions/artifacts/index.ts +288 -0
- package/extensions/artifacts/store.ts +570 -0
- package/extensions/artifacts/viewer.ts +199 -0
- package/extensions/ask-user/description.ts +67 -0
- package/extensions/ask-user/dialogs.ts +80 -0
- package/extensions/ask-user/index.ts +37 -33
- package/extensions/ask-user/questions.ts +32 -7
- package/extensions/ask-user/widget.ts +45 -25
- package/extensions/auto-mode/actions.ts +6 -0
- package/extensions/auto-mode/cache.ts +88 -0
- package/extensions/auto-mode/classifier-prompt.ts +5 -4
- package/extensions/auto-mode/classifier.ts +290 -65
- package/extensions/auto-mode/config.ts +72 -20
- package/extensions/auto-mode/decision-log.ts +2 -0
- package/extensions/auto-mode/defaults.ts +1 -1
- package/extensions/auto-mode/denials.ts +123 -0
- package/extensions/auto-mode/git-checkout-programs.ts +187 -0
- package/extensions/auto-mode/git-status-meta.ts +159 -0
- package/extensions/auto-mode/history.ts +189 -0
- package/extensions/auto-mode/instructions.ts +58 -52
- package/extensions/auto-mode/model-select.ts +159 -134
- package/extensions/auto-mode/paths.ts +57 -5
- package/extensions/auto-mode/pause.ts +57 -2
- package/extensions/auto-mode/prompt.ts +37 -21
- package/extensions/auto-mode/read-only-options.ts +724 -0
- package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
- package/extensions/auto-mode/rules.ts +18 -1
- package/extensions/auto-mode/safety-floor.ts +361 -18
- package/extensions/auto-mode/setup-run.ts +20 -14
- package/extensions/auto-mode/shell-analysis.ts +764 -490
- package/extensions/auto-mode/shell-parse.ts +885 -0
- package/extensions/auto-mode/system-note.ts +33 -0
- package/extensions/auto-mode/transcript.ts +80 -51
- package/extensions/background/cron.ts +515 -0
- package/extensions/background/index.ts +514 -236
- package/extensions/background/loop-fire.ts +136 -0
- package/extensions/background/loop-preamble.ts +59 -0
- package/extensions/background/loop-skill.ts +163 -0
- package/extensions/background/monitor-batch.ts +53 -0
- package/extensions/background/noop-fold.ts +128 -0
- package/extensions/background/switch-guard.ts +52 -0
- package/extensions/background/wakeup.ts +159 -55
- package/extensions/bash/background.ts +53 -17
- package/extensions/bash/child.ts +7 -0
- package/extensions/bash/description.ts +188 -0
- package/extensions/bash/guards.ts +15 -67
- package/extensions/bash/index.ts +88 -35
- package/extensions/branding/compaction-keep.ts +63 -0
- package/extensions/branding/declined.ts +22 -0
- package/extensions/branding/index.ts +133 -9
- package/extensions/branding/prompt-editor.ts +53 -6
- package/extensions/branding/queued-edit.ts +31 -0
- package/extensions/branding/replaced-builtins.ts +68 -0
- package/extensions/branding/startup.ts +33 -71
- package/extensions/btw/index.ts +229 -71
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +446 -58
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +216 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/index.ts +24 -5
- package/extensions/context-management/index.ts +25 -0
- package/extensions/doctor/build.ts +59 -8
- package/extensions/doctor/builtins.ts +84 -0
- package/extensions/doctor/cli.ts +7 -1
- package/extensions/doctor/compat.ts +88 -23
- package/extensions/doctor/dependencies.ts +41 -5
- package/extensions/doctor/fix-prompt.ts +9 -2
- package/extensions/doctor/index.ts +102 -58
- package/extensions/doctor/models.ts +102 -63
- package/extensions/doctor/presets.ts +42 -27
- package/extensions/doctor/report.ts +19 -2
- package/extensions/doctor/viewer.ts +49 -27
- package/extensions/effort/index.ts +72 -19
- package/extensions/effort/settings.ts +21 -0
- package/extensions/effort/slider.ts +24 -20
- package/extensions/exit/index.ts +32 -1
- package/extensions/file-tracker/index.ts +337 -26
- package/extensions/file-tracker/replay.ts +96 -10
- package/extensions/file-tracker/restore.ts +63 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +32 -15
- package/extensions/footer/footer-line.ts +45 -24
- package/extensions/footer/index.ts +47 -2
- package/extensions/footer/pr.ts +34 -5
- package/extensions/hooks/compaction.ts +7 -0
- package/extensions/hooks/executor.ts +32 -30
- package/extensions/hooks/index.ts +198 -31
- package/extensions/hooks/matcher.ts +3 -0
- package/extensions/hooks/protocol.ts +41 -3
- package/extensions/hooks/settings.ts +50 -19
- package/extensions/hooks/subagent-bridge.ts +2 -0
- package/extensions/hooks/trust.ts +13 -5
- package/extensions/idle-compact/index.ts +119 -0
- package/extensions/idle-compact/policy.ts +143 -0
- package/extensions/init/index.ts +11 -10
- package/extensions/init/prompt.ts +25 -32
- package/extensions/interrupted/index.ts +13 -1
- package/extensions/kept-thinking/index.ts +27 -0
- package/extensions/lib/agent-cron.ts +130 -0
- package/extensions/lib/agent-loader.ts +15 -1
- package/extensions/lib/agent-view.ts +85 -0
- package/extensions/lib/anthropic-payload.ts +82 -1
- package/extensions/lib/anthropic-server-call.ts +230 -0
- package/extensions/lib/anthropic-server-tools.ts +485 -0
- package/extensions/lib/app-launch.mjs +94 -0
- package/extensions/lib/argument-hints.ts +190 -0
- package/extensions/lib/atomic-write.ts +6 -2
- package/extensions/lib/auto-memory.ts +75 -0
- package/extensions/lib/bash-parser.ts +84 -0
- package/extensions/lib/btw-fork.ts +93 -0
- package/extensions/lib/child-extensions.ts +42 -0
- package/extensions/lib/child-writes.ts +45 -0
- package/extensions/lib/claude-context.ts +515 -100
- package/extensions/lib/claude-external-includes.ts +68 -0
- package/extensions/lib/claude-id.ts +22 -0
- package/extensions/lib/claude-rules.ts +323 -0
- package/extensions/lib/claude-settings.ts +76 -4
- package/extensions/lib/compaction-boundary.ts +36 -0
- package/extensions/lib/compaction-keep.mjs +83 -0
- package/extensions/lib/config-mode.ts +112 -0
- package/extensions/lib/consent-dialogs.ts +63 -0
- package/extensions/lib/consent-preview.ts +5 -1
- package/extensions/lib/consent-stores.ts +48 -0
- package/extensions/lib/context-facts.ts +78 -0
- package/extensions/lib/context-stack.ts +81 -0
- package/extensions/lib/deferred.ts +219 -25
- package/extensions/lib/dock-rows.ts +60 -0
- package/extensions/lib/empty-tool-result.ts +27 -0
- package/extensions/lib/environment-block.ts +76 -0
- package/extensions/lib/fetch-timeout.ts +24 -6
- package/extensions/{system-prompt → lib}/git-status.ts +51 -13
- package/extensions/lib/git.ts +75 -13
- package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
- package/extensions/lib/grammars/README.md +2 -0
- package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
- package/extensions/lib/guide-docs.ts +45 -0
- package/extensions/lib/headless-output.ts +46 -0
- package/extensions/lib/hook-gate.ts +7 -7
- package/extensions/lib/interrupt.ts +20 -4
- package/extensions/lib/key-input.ts +455 -0
- package/extensions/lib/keys.ts +14 -1
- package/extensions/lib/local-command.ts +47 -13
- package/extensions/lib/mcp-overrides.ts +1 -1
- package/extensions/lib/mcp-share.ts +46 -0
- package/extensions/lib/memory-content.ts +146 -0
- package/extensions/lib/memory-index.ts +18 -0
- package/extensions/lib/memory.ts +134 -123
- package/extensions/lib/model-catalog/huggingface.json +174 -0
- package/extensions/lib/model-catalog/models-dev.json +18297 -0
- package/extensions/lib/model-catalog/openrouter.json +5252 -0
- package/extensions/lib/model-catalog-data.ts +513 -0
- package/extensions/lib/model-catalog.ts +462 -0
- package/extensions/lib/model-choice-warnings.ts +137 -0
- package/extensions/lib/model-pairings.ts +152 -0
- package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
- package/extensions/lib/model-policy.ts +112 -15
- package/extensions/lib/model-tier.ts +283 -355
- package/extensions/lib/model-unusable.ts +1 -1
- package/extensions/lib/newer-model.ts +100 -0
- package/extensions/lib/notices.ts +87 -0
- package/extensions/lib/notifications.ts +308 -64
- package/extensions/lib/one-code-settings.ts +74 -8
- package/extensions/lib/open-browser.ts +63 -0
- package/extensions/lib/openrouter-generation.ts +94 -0
- package/extensions/lib/operation-deadline.ts +30 -0
- package/extensions/lib/original-command.ts +4 -4
- package/extensions/lib/package-version.ts +1 -1
- package/extensions/lib/panel-focus.ts +134 -0
- package/extensions/lib/paths.ts +17 -2
- package/extensions/lib/permission-gate.ts +72 -6
- package/extensions/lib/persisted-output.ts +60 -8
- package/extensions/lib/pi-ai-estimate.ts +19 -3
- package/extensions/lib/pi-version.ts +21 -2
- package/extensions/lib/plan-mode-channels.ts +20 -5
- package/extensions/lib/plugins.ts +26 -17
- package/extensions/lib/powershell-parse-script.ts +208 -0
- package/extensions/lib/powershell-parser.ts +344 -0
- package/extensions/lib/process-tree.ts +131 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +364 -58
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +439 -0
- package/extensions/lib/scratchpad.ts +81 -14
- package/extensions/lib/send-now-channel.ts +7 -0
- package/extensions/lib/session-model-tier.ts +51 -0
- package/extensions/lib/session-work.ts +64 -0
- package/extensions/lib/settings-channels.ts +4 -4
- package/extensions/lib/shell-exit-semantics.ts +170 -0
- package/extensions/lib/shell-quote.ts +11 -0
- package/extensions/lib/shell-result.ts +70 -0
- package/extensions/lib/shell-spawn.ts +70 -6
- package/extensions/lib/shell-tool.ts +75 -25
- package/extensions/lib/side-call-cache.ts +124 -0
- package/extensions/lib/side-call-run.ts +96 -0
- package/extensions/lib/side-call-usage.ts +38 -0
- package/extensions/lib/side-call.ts +13 -1
- package/extensions/lib/single-flight.ts +18 -0
- package/extensions/lib/skill-body.ts +42 -0
- package/extensions/lib/skill-scan.ts +63 -24
- package/extensions/lib/spill-file.ts +140 -0
- package/extensions/lib/subagent-handback.ts +113 -0
- package/extensions/lib/system-role.ts +156 -0
- package/extensions/lib/terminal-text.ts +114 -0
- package/extensions/lib/text-width.ts +47 -16
- package/extensions/lib/timer-ops.ts +20 -0
- package/extensions/lib/tool-additions.ts +149 -0
- package/extensions/lib/tool-call-corruption.ts +134 -0
- package/extensions/lib/tool-path.ts +66 -0
- package/extensions/lib/tool-variants.ts +69 -0
- package/extensions/lib/tui-render.ts +255 -27
- package/extensions/lib/turn-budget-layout.ts +143 -0
- package/extensions/lib/update-check.mjs +15 -2
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/user-denial.ts +38 -0
- package/extensions/lib/workspace-channel.ts +16 -0
- package/extensions/lib/worktree-channel.ts +31 -3
- package/extensions/lib/worktree-isolation.ts +40 -10
- package/extensions/lsp/client.ts +8 -9
- package/extensions/lsp/index.ts +70 -4
- package/extensions/lsp/keep-alive.ts +1 -1
- package/extensions/lsp/trust.ts +119 -0
- package/extensions/lsp/watcher.ts +1 -1
- package/extensions/mcp/announce.ts +87 -5
- package/extensions/mcp/client.ts +161 -48
- package/extensions/mcp/config.ts +132 -8
- package/extensions/mcp/discovery.ts +19 -0
- package/extensions/mcp/index.ts +209 -71
- package/extensions/mcp/oauth/browser.ts +38 -17
- package/extensions/mcp/oauth/flow.ts +25 -17
- package/extensions/mcp/oauth/provider.ts +14 -8
- package/extensions/mcp/oauth/store.ts +34 -16
- package/extensions/mcp/panel/keys.ts +12 -11
- package/extensions/mcp/panel/render.ts +9 -6
- package/extensions/mcp/schema.ts +104 -5
- package/extensions/mcp/trust.ts +151 -67
- package/extensions/memory/entries.ts +23 -14
- package/extensions/memory/index.ts +134 -28
- package/extensions/memory/open-external.ts +33 -18
- package/extensions/memory/panel.ts +135 -26
- package/extensions/newer-model/index.ts +31 -0
- package/extensions/notebook/description.ts +18 -0
- package/extensions/notebook/index.ts +6 -5
- package/extensions/notebook/notebook.ts +1 -1
- package/extensions/permissions/index.ts +1300 -381
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +6 -4
- package/extensions/permissions/panel/host.ts +130 -0
- package/extensions/permissions/panel/keys.ts +48 -0
- package/extensions/permissions/panel/render.ts +450 -0
- package/extensions/permissions/panel/state.ts +550 -0
- package/extensions/permissions/powershell-cmdlets.ts +198 -0
- package/extensions/permissions/powershell-paths.ts +197 -0
- package/extensions/permissions/powershell-rules.ts +207 -280
- package/extensions/permissions/powershell-tree.ts +557 -0
- package/extensions/permissions/project-trust.ts +27 -11
- package/extensions/permissions/protected-paths.ts +14 -2
- package/extensions/permissions/session-grant.ts +6 -0
- package/extensions/permissions/settings.ts +166 -31
- package/extensions/permissions/subagent-gate.ts +25 -0
- package/extensions/permissions/workspace.ts +78 -0
- package/extensions/plan-mode/index.ts +156 -69
- package/extensions/plan-mode/reminder.ts +89 -21
- package/extensions/plan-mode/texts.ts +177 -0
- package/extensions/plan-mode/viewer.ts +92 -27
- package/extensions/plugins/counts.ts +3 -3
- package/extensions/plugins/index.ts +61 -46
- package/extensions/plugins/install/install.ts +16 -0
- package/extensions/plugins/marketplace/sync.ts +9 -3
- package/extensions/plugins/marketplace/types.ts +3 -1
- package/extensions/plugins/panel/keys.ts +21 -24
- package/extensions/plugins/panel/render.ts +12 -6
- package/extensions/plugins/shell-expand.ts +107 -0
- package/extensions/plugins/template.ts +32 -15
- package/extensions/powershell/child.ts +7 -0
- package/extensions/powershell/description.ts +38 -6
- package/extensions/powershell/guards.ts +1 -1
- package/extensions/powershell/index.ts +12 -7
- package/extensions/recap/index.ts +65 -60
- package/extensions/recap/prompt.ts +2 -2
- package/extensions/recap/scheduler.ts +3 -5
- package/extensions/search-tools/grep-merge.ts +39 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/send-now/chord.ts +62 -0
- package/extensions/send-now/index.ts +133 -0
- package/extensions/session-title/index.ts +3 -0
- package/extensions/session-title/title.ts +4 -1
- package/extensions/skill/code-review.ts +97 -0
- package/extensions/skill/index.ts +421 -101
- package/extensions/skill/invoke.ts +128 -18
- package/extensions/skill/listing.ts +133 -9
- package/extensions/skill/panel/keys.ts +15 -16
- package/extensions/skill/panel/render.ts +5 -2
- package/extensions/skill/panel/state.ts +12 -1
- package/extensions/subagents/agent-args.ts +39 -0
- package/extensions/subagents/agent-description.ts +132 -0
- package/extensions/subagents/agents.ts +116 -23
- package/extensions/subagents/announce.ts +69 -0
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +76 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1493 -824
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +2 -2
- package/extensions/subagents/panel-keys.ts +73 -31
- package/extensions/subagents/panel-render.ts +120 -96
- package/extensions/subagents/panel-widget.ts +60 -89
- package/extensions/subagents/runner.ts +108 -80
- package/extensions/subagents/runs.ts +39 -5
- package/extensions/subagents/send-to-main-tool.ts +1 -1
- package/extensions/subagents/session-turns.ts +32 -8
- package/extensions/subagents/shell-panel.ts +36 -178
- package/extensions/subagents/tasks-dialog.ts +170 -0
- package/extensions/subagents/viewport-mirror.ts +95 -0
- package/extensions/subagents/worktree.ts +57 -13
- package/extensions/system-prompt/environment.ts +19 -19
- package/extensions/system-prompt/idle-turn.ts +65 -0
- package/extensions/system-prompt/index.ts +131 -47
- package/extensions/system-prompt/template.ts +24 -50
- package/extensions/system-prompt/tiers/common.ts +23 -64
- package/extensions/system-prompt/tiers/frontier.ts +32 -11
- package/extensions/system-prompt/tiers/low.ts +67 -45
- package/extensions/system-prompt/tiers/mid.ts +96 -38
- package/extensions/system-reminder/index.ts +175 -13
- package/extensions/tasks/descriptions.ts +190 -0
- package/extensions/tasks/index.ts +120 -41
- package/extensions/tasks/store.ts +83 -16
- package/extensions/tool-call-corruption/child.ts +7 -0
- package/extensions/tool-call-corruption/index.ts +237 -0
- package/extensions/tool-search/announce.ts +16 -0
- package/extensions/tool-search/index.ts +189 -41
- package/extensions/tool-style/index.ts +68 -10
- package/extensions/tool-style/write-description.ts +40 -0
- package/extensions/tool-style/write-render.ts +237 -0
- package/extensions/turn-duration/index.ts +7 -3
- package/extensions/turn-duration/line.ts +47 -4
- package/extensions/web/backends.ts +6 -9
- package/extensions/web/description.ts +50 -0
- package/extensions/web/index.ts +118 -53
- package/extensions/web/native-call.ts +15 -0
- package/extensions/web/native-results.ts +41 -0
- package/extensions/web-fetch/description.ts +42 -0
- package/extensions/web-fetch/extract.ts +53 -6
- package/extensions/web-fetch/index.ts +125 -76
- package/extensions/web-fetch/page-content.ts +19 -0
- package/extensions/web-fetch/response.ts +49 -0
- package/extensions/web-fetch/summarize.ts +9 -10
- package/extensions/workflow/agent-session.ts +105 -18
- package/extensions/workflow/args.ts +24 -0
- package/extensions/workflow/description.ts +67 -0
- package/extensions/workflow/globals.ts +17 -3
- package/extensions/workflow/index.ts +145 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +29 -6
- package/extensions/workflow/saved-workflows.ts +3 -2
- package/extensions/workflow/types.ts +22 -1
- package/extensions/workflow/viewer.ts +54 -24
- package/extensions/workflow/widget.ts +20 -0
- package/extensions/worktree/descriptions.ts +91 -0
- package/extensions/worktree/guards.ts +316 -54
- package/extensions/worktree/index.ts +121 -39
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +19 -7
- package/skills/artifact-design/SKILL.md +85 -0
- package/skills/code-review/SKILL.md +20 -20
- package/skills/code-review/cells/arguments-and-flags.md +19 -0
- package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
- package/skills/code-review/cells/high-8-inline.md +122 -0
- package/skills/code-review/cells/low-1-pass-max4.md +27 -0
- package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
- package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
- package/skills/code-review/cells/medium-3x5-verify.md +132 -0
- package/skills/code-review/cells/minimal-prompt.md +32 -0
- package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
- package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
- package/skills/fewer-permission-prompts/SKILL.md +1 -1
- package/skills/loop/SKILL.md +11 -0
- package/skills/security-review/SKILL.md +2 -5
- package/skills/simplify/SKILL.md +7 -5
- package/skills/workflow-authoring/SKILL.md +143 -0
- package/themes/onecode-light.json +1 -1
- package/themes/onecode.json +1 -1
- package/types/app.d.ts +58 -0
- package/types/community-packages.d.ts +26 -1
- package/extensions/auto-mode/recoverability.ts +0 -229
- package/extensions/lib/capability-index.ts +0 -466
- package/extensions/lib/model-facts.generated.json +0 -2675
- package/extensions/lib/model-facts.ts +0 -162
|
@@ -22,14 +22,20 @@ import { join } from "node:path";
|
|
|
22
22
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
23
23
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { type ChildAction, SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
|
|
25
|
-
import
|
|
25
|
+
import { guideDocsDirs } from "../lib/guide-docs.ts";
|
|
26
|
+
import { TURN_FAILED_CHANNEL, type TurnFailedEvent } from "../lib/interrupt.ts";
|
|
27
|
+
import { handBackWarning, type HandBackVerdict } from "../lib/notifications.ts";
|
|
26
28
|
import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
|
|
27
29
|
|
|
28
|
-
import { classify, createClassifierState } from "../auto-mode/classifier.ts";
|
|
30
|
+
import { classifierCacheKey, classifierChain, classify, createClassifierState, releaseClassifierResources } from "../auto-mode/classifier.ts";
|
|
29
31
|
import {
|
|
30
32
|
type AutoModeConfig,
|
|
31
33
|
autoModeSettingsPaths,
|
|
34
|
+
listAutoModeEntries,
|
|
32
35
|
loadAutoModeConfig,
|
|
36
|
+
oneCodeEnvironmentExtendsDefault,
|
|
37
|
+
type SourcedAutoModeEntry,
|
|
38
|
+
updateOneCodeAutoModeList,
|
|
33
39
|
claudeUserPermissionAllow,
|
|
34
40
|
loadAutoModeConfigWithDiagnostics,
|
|
35
41
|
oneCodePermissionAllow,
|
|
@@ -37,28 +43,41 @@ import {
|
|
|
37
43
|
persistClassifierModel,
|
|
38
44
|
removeOneCodePermissionAllow,
|
|
39
45
|
} from "../auto-mode/config.ts";
|
|
46
|
+
import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../lib/model-picker.ts";
|
|
47
|
+
import { findConfigured, modelIdentity } from "../lib/model-policy.ts";
|
|
48
|
+
import { CLASSIFIER_SETTING_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
|
|
49
|
+
import { readSuggestNewerModels } from "../lib/one-code-settings.ts";
|
|
40
50
|
import { auditPermissionAllow, renderProposal, settingsPatch } from "../auto-mode/setup.ts";
|
|
41
51
|
import { draftSetup, gatherFacts } from "../auto-mode/setup-run.ts";
|
|
42
|
-
import { modelPickerComponent, type PickerEntry, pickerSpec, toPickerEntries } from "../auto-mode/model-picker.ts";
|
|
43
52
|
import { DEFAULT_ENVIRONMENT } from "../auto-mode/defaults.ts";
|
|
53
|
+
import { buildRuleset } from "../auto-mode/classifier-prompt.ts";
|
|
44
54
|
import type { TranscriptEntry } from "../auto-mode/transcript.ts";
|
|
55
|
+
import { childHistoryCursor, classifierHistory, CLASSIFIER_TOOL_META, CLASSIFIER_USER_INPUT, type HistoryCursor, messageText, userMessageDigest } from "../auto-mode/history.ts";
|
|
45
56
|
import { appendDecision, type DecisionEntry, decisionEntry } from "../auto-mode/decision-log.ts";
|
|
46
57
|
import { loadProjectInstructions } from "../auto-mode/instructions.ts";
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import { PauseTracker } from "../auto-mode/pause.ts";
|
|
51
|
-
import {
|
|
52
|
-
import { safetyControlWrite } from "../auto-mode/safety-floor.ts";
|
|
58
|
+
import { describeCandidate } from "../auto-mode/model-select.ts";
|
|
59
|
+
import { conflictingPathArguments, isWithin, resolveForContainment, toAbsolute } from "../auto-mode/paths.ts";
|
|
60
|
+
import { DenialStore, denialInputKey, permissionGrantedMessage } from "../auto-mode/denials.ts";
|
|
61
|
+
import { PauseTracker, unattendedPromptNotice, unattendedPromptTimeoutMs } from "../auto-mode/pause.ts";
|
|
62
|
+
import { safetyControlWrite, safetyFloorBlockText } from "../auto-mode/safety-floor.ts";
|
|
53
63
|
import { isExecutionPrimitivePath, isSensitivePath } from "../auto-mode/sensitive.ts";
|
|
54
|
-
import { analyzeShellCommand
|
|
55
|
-
import {
|
|
64
|
+
import { analyzeShellCommand } from "../auto-mode/shell-analysis.ts";
|
|
65
|
+
import { bashParserReady, bashParserUnavailable } from "../lib/bash-parser.ts";
|
|
66
|
+
import type { PowerShellParse } from "../lib/powershell-parser.ts";
|
|
67
|
+
import { powerShellParser } from "../lib/shell-spawn.ts";
|
|
68
|
+
import { powershellReadOnly } from "./powershell-rules.ts";
|
|
56
69
|
import { isShellTool } from "./matcher.ts";
|
|
57
|
-
import {
|
|
70
|
+
import { gitStatusOutput } from "../lib/git.ts";
|
|
71
|
+
import { gitStatusMeta, gitStatusMetaArgs, reachesIgnoredFiles, wantsGitStatusMeta } from "../auto-mode/git-status-meta.ts";
|
|
72
|
+
import { actionResolvedPaths } from "../auto-mode/resolved-paths-meta.ts";
|
|
73
|
+
import { autoMemoryEnabled } from "../lib/auto-memory.ts";
|
|
58
74
|
import { projectMemoryDir } from "../lib/memory.ts";
|
|
75
|
+
import { queueNotice } from "../lib/notices.ts";
|
|
76
|
+
import { canShowCustomUi, rpcReadOnlyText } from "../lib/headless-output.ts";
|
|
59
77
|
import { sessionResultsDir } from "../lib/persisted-output.ts";
|
|
60
|
-
import {
|
|
61
|
-
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
78
|
+
import { privateSessionScratchpadDir } from "../lib/scratchpad.ts";
|
|
79
|
+
import { CONTEXT_ORDER, REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
|
|
80
|
+
import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
|
|
62
81
|
import {
|
|
63
82
|
decide,
|
|
64
83
|
extractSubject,
|
|
@@ -70,23 +89,65 @@ import {
|
|
|
70
89
|
isPathSubjectTool,
|
|
71
90
|
} from "./matcher.ts";
|
|
72
91
|
import { type SessionGrant, sessionGrant } from "./session-grant.ts";
|
|
73
|
-
import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
|
|
74
|
-
import {
|
|
92
|
+
import { formatModel, modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
|
|
93
|
+
import { intrinsicTier, usesClaudeCodeFastPaths } from "../lib/model-tier.ts";
|
|
94
|
+
import { type ChildToolCall, type ChildGateDecision, sessionPermissionBridge, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
|
|
75
95
|
import { trackOriginalCommands } from "../lib/original-command.ts";
|
|
96
|
+
import { followWorkCwd } from "../lib/worktree-channel.ts";
|
|
76
97
|
import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
|
|
77
98
|
import { isWritingTool } from "./protected-paths.ts";
|
|
78
99
|
import { denyRuleLines } from "./rule-prose.ts";
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
100
|
+
import {
|
|
101
|
+
listPermissionRules,
|
|
102
|
+
listWorkspaceDirectories,
|
|
103
|
+
loadPermissionSettings,
|
|
104
|
+
markOutsideReadPromptSeen,
|
|
105
|
+
normalizePermissionMode,
|
|
106
|
+
outsideReadPromptSeen,
|
|
107
|
+
persistBlockOutsideReads,
|
|
108
|
+
persistAllowRule,
|
|
109
|
+
persistPermissionRule,
|
|
110
|
+
removePermissionRule,
|
|
111
|
+
resolveStartupMode,
|
|
112
|
+
type RuleSource,
|
|
113
|
+
persistWorkspaceDirectory,
|
|
114
|
+
removeWorkspaceDirectory,
|
|
115
|
+
type SourcedDirectory,
|
|
116
|
+
type SourcedRule,
|
|
117
|
+
} from "./settings.ts";
|
|
118
|
+
import { MODE_ENV, parsePowerShell, resolvedOrSelf, runtimeProtectedDirs, runtimeSecretPaths } from "../lib/permission-gate.ts";
|
|
119
|
+
import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved, projectDirectoryConsentEntry, type TrustFiring } from "./project-trust.ts";
|
|
120
|
+
import { parseAddDirFlag, tooBroadForWorkspace, validateWorkspaceDirectory } from "./workspace.ts";
|
|
121
|
+
import { WORKSPACE_CHANNEL, type WorkspaceAnnouncement } from "../lib/workspace-channel.ts";
|
|
83
122
|
import { findProjectRoot } from "../lib/git.ts";
|
|
84
123
|
import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
|
|
85
124
|
import { recordUsage } from "../lib/usage-bus.ts";
|
|
86
|
-
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
125
|
+
import { announceLocalCommand, registerLocalCommand } from "../lib/local-command.ts";
|
|
126
|
+
import { claudeSourcesOn } from "../lib/config-mode.ts";
|
|
127
|
+
import { claudeJsonPath, tildify, tryRealpath } from "../lib/paths.ts";
|
|
128
|
+
import { openPermissionsPanel, type PermissionsPanelHost } from "./panel/host.ts";
|
|
129
|
+
import { PANEL_FOCUS_CHANNEL, PanelFocus, panelUi } from "../lib/panel-focus.ts";
|
|
130
|
+
import {
|
|
131
|
+
AUTO_SECTION_LABELS,
|
|
132
|
+
AUTO_SECTIONS,
|
|
133
|
+
type AutoEntryRow,
|
|
134
|
+
type AutoModeView,
|
|
135
|
+
type Destination,
|
|
136
|
+
type PanelState,
|
|
137
|
+
RULE_TABS,
|
|
138
|
+
type RuleRow,
|
|
139
|
+
type RuleTab,
|
|
140
|
+
type WorkspaceDirRow,
|
|
141
|
+
} from "./panel/state.ts";
|
|
142
|
+
import { builtinRuleCounts } from "../auto-mode/rules.ts";
|
|
143
|
+
import { autoModeSystemNote } from "../auto-mode/system-note.ts";
|
|
144
|
+
import { announceArgumentHint } from "../lib/argument-hints.ts";
|
|
145
|
+
import { userDenialText } from "../lib/user-denial.ts";
|
|
146
|
+
import { liveUiCtx } from "../lib/tui-render.ts";
|
|
147
|
+
|
|
148
|
+
/** Shown when a permission prompt is held behind an open panel (serializePrompt). */
|
|
149
|
+
const PROMPT_WAITS_FOR_PANEL = "A permission request is waiting. Close this panel to answer it.";
|
|
87
150
|
|
|
88
|
-
const DENIED_BY_USER =
|
|
89
|
-
"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.";
|
|
90
151
|
const DENIED_NON_INTERACTIVE =
|
|
91
152
|
"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.";
|
|
92
153
|
// Must agree with the plan-mode reminder (plan-mode/reminder.ts): the plan is
|
|
@@ -99,6 +160,26 @@ const DENIED_DONT_ASK =
|
|
|
99
160
|
"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.";
|
|
100
161
|
const DENIED_PROTECTED_PATH =
|
|
101
162
|
"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.";
|
|
163
|
+
/** A timed resume prompt nobody answered (Claude Code's denial-limit fallback timing). */
|
|
164
|
+
const DENIED_UNANSWERED_RESUME =
|
|
165
|
+
"Auto mode is paused after repeated blocks, and nobody answered the approval prompt for this call in time, so it was denied to avoid blocking an unattended session. Do not retry it; continue with work that does not need it, or stop and wait for the user.";
|
|
166
|
+
/** Claude Code's refusal when `blockReadsOutsideWorkingDirectories` is set. */
|
|
167
|
+
const DENIED_BLOCKED_OUTSIDE_READ =
|
|
168
|
+
"That path is outside the working directories; the permissions.blockReadsOutsideWorkingDirectories setting blocks reads outside the working directories. Ask the user to add the directory with /add-dir, or to remove that setting.";
|
|
169
|
+
/** Claude Code's refusal right after the user picks "block" in the first-read prompt. */
|
|
170
|
+
const DENIED_CHOSE_BLOCK_OUTSIDE_READS =
|
|
171
|
+
"The user chose to block reads outside the working directories (permissions.blockReadsOutsideWorkingDirectories). Ask the user to add the directory with /add-dir, or to remove that setting.";
|
|
172
|
+
/** Claude Code's first outside-read prompt in auto mode (2.1.282): title, question, answers. */
|
|
173
|
+
const OUTSIDE_READ_TITLE = "Read outside the working directories";
|
|
174
|
+
const OUTSIDE_READ_QUESTION = "Allow reads outside the working directories?";
|
|
175
|
+
const OUTSIDE_READ_ANSWERS = {
|
|
176
|
+
allow: "Yes, keep allowing reads outside the working directories",
|
|
177
|
+
block: "No, block reads outside the working directories from now on",
|
|
178
|
+
ask_again: "No, ask again next time",
|
|
179
|
+
} as const;
|
|
180
|
+
/** Claude Code's explainer, without its sandbox sentences (One Code has no sandbox) and with One Code's settings file. */
|
|
181
|
+
const OUTSIDE_READ_EXPLAINER = (settingsFile: string) =>
|
|
182
|
+
`Auto mode reads outside the working directories without asking. Yes or Block settles this question; Ask again asks on the next outside read. Block: the file tools refuse reads outside the working directories in every project. To undo, remove permissions.blockReadsOutsideWorkingDirectories from ${settingsFile}.`;
|
|
102
183
|
const DENIED_OUTSIDE_WORKING_DIR =
|
|
103
184
|
"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.";
|
|
104
185
|
// Returned to the MODEL, not the user: auto mode exists to run unattended, so a
|
|
@@ -111,9 +192,21 @@ const BLOCKED_BY_TIMEOUT = (reason: string) =>
|
|
|
111
192
|
`${reason}\n\nThis was not a judgement that the call is unsafe. Wait a moment and try this action again. ` +
|
|
112
193
|
"If it keeps failing, continue with other tasks that do not require this action and come back to it later — " +
|
|
113
194
|
"reading files, searching code, and other read-only operations do not require the classifier and can still be used. " +
|
|
114
|
-
"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
|
|
115
|
-
|
|
116
|
-
|
|
195
|
+
"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 switch the session model.";
|
|
196
|
+
// The classifier ran but gave no verdict (a provider refusal or error, a
|
|
197
|
+
// truncated reply, no usable model): not a denial, and nothing to route around.
|
|
198
|
+
const BLOCKED_NOT_JUDGED = (reason: string) =>
|
|
199
|
+
`${reason}\n\nThis was not a judgement that the call is unsafe: the approval classifier gave no verdict, so the call did not run. ` +
|
|
200
|
+
"Do not rephrase it or route around the gate. Continue with other tasks that do not require this action — " +
|
|
201
|
+
"reading files, searching code, and other read-only operations do not require the classifier. " +
|
|
202
|
+
"If this action is essential, tell the user what you were about to do so they can re-run interactively or set another classifier model.";
|
|
203
|
+
/** The text a classifier block hands the model: a timeout, a call never judged, or a denial. */
|
|
204
|
+
const classifierBlockReason = (outcome: { tier?: string; noVerdict?: boolean; reason: string }): string =>
|
|
205
|
+
outcome.tier === "timeout"
|
|
206
|
+
? BLOCKED_BY_TIMEOUT(outcome.reason)
|
|
207
|
+
: outcome.noVerdict
|
|
208
|
+
? BLOCKED_NOT_JUDGED(outcome.reason)
|
|
209
|
+
: DENIED_BY_CLASSIFIER(outcome.reason);
|
|
117
210
|
// "Choose a different approach" is exactly what a literal reader does with a
|
|
118
211
|
// second command of the same effect: a tiny-tier model met `Bash(rm:*)` and
|
|
119
212
|
// deleted the file with `python3 -c "import os; os.remove(…)"` 3/3 times
|
|
@@ -123,6 +216,11 @@ const DENIED_SAFETY_FLOOR = (reason: string) =>
|
|
|
123
216
|
const DENIED_BY_RULE = (rule: string) =>
|
|
124
217
|
`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.`;
|
|
125
218
|
|
|
219
|
+
/** The hidden message a /permissions retry starts its turn with. */
|
|
220
|
+
const PERMISSION_RETRY_TYPE = "one-code:permission-retry";
|
|
221
|
+
/** Claude Code's denial notification: the tool, the reason cut to 80 columns, and where to act on it. */
|
|
222
|
+
const deniedNotice = (toolName: string, reason: string): string =>
|
|
223
|
+
`${toolName} denied by auto mode · ${reason.length > 80 ? `${reason.slice(0, 79)}…` : reason} · /permissions`;
|
|
126
224
|
/** Truncate a subject for a permission prompt — shared by the main gate and the subagent bridge. */
|
|
127
225
|
const previewSubject = (subject: string) => (subject.length > 200 ? `${subject.slice(0, 200)}…` : subject);
|
|
128
226
|
|
|
@@ -140,16 +238,22 @@ const askTitle = (actor: string, preview: string, cause: string, pausedResume: b
|
|
|
140
238
|
};
|
|
141
239
|
|
|
142
240
|
/** Map a `decide()` deny result to its model-facing reason — shared by both gate paths. */
|
|
143
|
-
const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string =>
|
|
144
|
-
result.cause
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
241
|
+
const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string => {
|
|
242
|
+
switch (result.cause) {
|
|
243
|
+
case "plan-mode":
|
|
244
|
+
return DENIED_PLAN_MODE;
|
|
245
|
+
case "protected-path":
|
|
246
|
+
return DENIED_PROTECTED_PATH;
|
|
247
|
+
case "blocked-outside-read":
|
|
248
|
+
return DENIED_BLOCKED_OUTSIDE_READ;
|
|
249
|
+
case "working-dir":
|
|
250
|
+
return DENIED_OUTSIDE_WORKING_DIR;
|
|
251
|
+
case "mode":
|
|
252
|
+
return DENIED_DONT_ASK;
|
|
253
|
+
default:
|
|
254
|
+
return DENIED_BY_RULE(result.rule?.raw ?? "deny");
|
|
255
|
+
}
|
|
256
|
+
};
|
|
153
257
|
|
|
154
258
|
/** Ask-prompt option labels — shared by both gate paths. */
|
|
155
259
|
/**
|
|
@@ -180,6 +284,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
180
284
|
"Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
|
|
181
285
|
type: "string",
|
|
182
286
|
});
|
|
287
|
+
pi.registerFlag("add-dir", {
|
|
288
|
+
description: `Additional workspace directories: readable without a prompt, writable in acceptEdits (separate several with "${process.platform === "win32" ? ";" : ":"}")`,
|
|
289
|
+
type: "string",
|
|
290
|
+
});
|
|
183
291
|
pi.registerFlag("dangerously-skip-permissions", {
|
|
184
292
|
description: "Skip all permission prompts (Claude Code compatible)",
|
|
185
293
|
type: "boolean",
|
|
@@ -197,19 +305,30 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
197
305
|
// an OS sandbox for outside-cwd reads; One Code flips the default directly and
|
|
198
306
|
// has no sandbox yet (reads outside the working directory are still
|
|
199
307
|
// classified/asked, never auto-read). The auto-mode safety architecture still
|
|
200
|
-
// holds — the deterministic safety floor, classifier-verdicts-verified
|
|
201
|
-
//
|
|
308
|
+
// holds — the deterministic safety floor, classifier-verdicts-verified and
|
|
309
|
+
// the classifier tier floor — and with no
|
|
202
310
|
// classifier model reachable the classifier fails closed. A user or project
|
|
203
311
|
// `defaultMode` (or `--permission-mode`) still overrides this; `auto` from a
|
|
204
|
-
// project file is still refused. See docs/decisions/auto-mode.md.
|
|
312
|
+
// project file is still refused. See working-docs/decisions/auto-mode.md.
|
|
205
313
|
let mode: PermissionMode = "auto";
|
|
314
|
+
/** Whether the session model gets Claude Code's fast paths, for the model-switch notice. */
|
|
315
|
+
let gateFastPaths: boolean | undefined;
|
|
316
|
+
/** `permissions.blockReadsOutsideWorkingDirectories` from any settings source. */
|
|
317
|
+
let blockOutsideReads = false;
|
|
318
|
+
/** The first outside-read prompt was answered on this machine (read once, then kept). */
|
|
319
|
+
let outsideReadSeen: boolean | undefined;
|
|
206
320
|
// Worktree-wrapped bash calls publish the model's original command here,
|
|
207
321
|
// keyed by pi's toolCallId (never read from `event.input` — model-writable).
|
|
208
322
|
const originalCommands = trackOriginalCommands(pi);
|
|
323
|
+
// Worktree restores publish before this extension's session_start. Keep
|
|
324
|
+
// that state until worktree publishes its next enter, exit or branch reset.
|
|
325
|
+
const workCwd = followWorkCwd(pi.events);
|
|
209
326
|
/** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
|
|
210
327
|
let bypassInCycle = false;
|
|
211
328
|
/** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
|
|
212
329
|
let autoInCycle = false;
|
|
330
|
+
/** Whether this conversation's first turn has decided on Claude Code's auto-mode note. */
|
|
331
|
+
let autoNoteDecided = false;
|
|
213
332
|
let deny: PermissionRule[] = [];
|
|
214
333
|
let ask: PermissionRule[] = [];
|
|
215
334
|
let allow: PermissionRule[] = [];
|
|
@@ -233,6 +352,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
233
352
|
const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
|
|
234
353
|
let unparsableRules: string[] = [];
|
|
235
354
|
let warnedUnparsable = "";
|
|
355
|
+
let warnedTooBroad = "";
|
|
236
356
|
/** Plan mode's one writable file, announced by the plan-mode extension. */
|
|
237
357
|
let planFilePath: string | undefined;
|
|
238
358
|
/**
|
|
@@ -258,6 +378,34 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
258
378
|
let oneCodeProjectSettingsFile: string | undefined;
|
|
259
379
|
/** pi's own agent directory, protected like the static list (lib/permission-gate.ts runtimeProtectedDirs). */
|
|
260
380
|
let protectedDirs: string[] = [];
|
|
381
|
+
/** The harness's own secret stores, never read unclassified outside the working space (runtimeSecretPaths). */
|
|
382
|
+
let secretPaths: string[] = [];
|
|
383
|
+
/** The harness's readable session dirs, resolved at session start; `readableRoots` adds the workspace to them. */
|
|
384
|
+
let harnessReadableRoots: string[] = [];
|
|
385
|
+
/** The shipped docs the `one-code-guide` agent reads (lib/guide-docs.ts): readable, never writable. */
|
|
386
|
+
let docsDirPaths: string[] = [];
|
|
387
|
+
/**
|
|
388
|
+
* Workspace directories (workspace.ts): from settings, where the
|
|
389
|
+
* repository's own files apply only once the user trusts them, from
|
|
390
|
+
* `--add-dir`, and added for this session in /permissions or with /add-dir.
|
|
391
|
+
*/
|
|
392
|
+
let settingsWorkspaceDirs: SourcedDirectory[] = [];
|
|
393
|
+
let flagWorkspaceDirs: string[] = [];
|
|
394
|
+
const sessionWorkspaceDirs: string[] = [];
|
|
395
|
+
/** The workspace directories in force, as real paths: what the user and the system prompt see. */
|
|
396
|
+
let workspacePaths: string[] = [];
|
|
397
|
+
/** The same directories in containment's comparison form: what decide() and both shell pre-gates see. */
|
|
398
|
+
let workspaceDirs: string[] = [];
|
|
399
|
+
const isRepoSource = (source: RuleSource) => source === "project" || source === "project-local";
|
|
400
|
+
const repoWorkspaceDirs = () => settingsWorkspaceDirs.filter((dir) => isRepoSource(dir.source));
|
|
401
|
+
/** What the repository-trust consent covers: its allow rules and its workspace directories. */
|
|
402
|
+
const projectTrustList = () => [...projectAllowRaw, ...repoWorkspaceDirs().map((dir) => projectDirectoryConsentEntry(dir.raw))];
|
|
403
|
+
const refreshWorkspace = () => {
|
|
404
|
+
const trusted = settingsWorkspaceDirs.filter((dir) => projectAllowTrusted || !isRepoSource(dir.source)).map((dir) => dir.path);
|
|
405
|
+
workspacePaths = [...new Set([...trusted, ...flagWorkspaceDirs, ...sessionWorkspaceDirs].map((dir) => tryRealpath(dir) ?? dir))];
|
|
406
|
+
workspaceDirs = [...new Set(workspacePaths.map(resolvedOrSelf))];
|
|
407
|
+
readableRoots = [...harnessReadableRoots, ...docsDirPaths, ...workspaceDirs];
|
|
408
|
+
};
|
|
261
409
|
|
|
262
410
|
/**
|
|
263
411
|
* Mode changes arrive over the event bus too (plan-mode tools), where no ctx
|
|
@@ -279,18 +427,22 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
279
427
|
return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
|
|
280
428
|
}
|
|
281
429
|
if (!badgeCtx) return { pinned: false };
|
|
282
|
-
|
|
283
|
-
const chain = classifierCandidates({
|
|
284
|
-
available: badgeCtx.modelRegistry.getAvailable(),
|
|
285
|
-
sessionModel: sessionModel ?? badgeCtx.model,
|
|
286
|
-
configured: autoConfig.classifierModel,
|
|
287
|
-
configuredSetForContainment: autoConfig.classifierModelSetFor,
|
|
288
|
-
}).candidates.filter((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
289
|
-
const first = chain[0];
|
|
430
|
+
const first = selectionChain(badgeCtx, sessionModel).candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
290
431
|
return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
|
|
291
432
|
};
|
|
292
433
|
|
|
293
|
-
|
|
434
|
+
/**
|
|
435
|
+
* The classifier chain for `sessionModel` (the ctx's by default), cached on
|
|
436
|
+
* the classifier state by the same signature the gate uses, so a repaint
|
|
437
|
+
* does not rank the catalog again (auto-mode/classifier.ts classifierChain).
|
|
438
|
+
*/
|
|
439
|
+
const selectionChain = (ctx: ExtensionContext, sessionModel?: Model<Api>) => {
|
|
440
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
441
|
+
const registry = ctx.modelRegistry;
|
|
442
|
+
return classifierChain(classifierState, { available: () => registry.getAvailable(), sessionModel: sessionModel ?? ctx.model, config: autoConfig });
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const applyBadge = (sessionModel?: Model<Api>, toolCallId?: string) => {
|
|
294
446
|
// A below-editor widget, not a footer status: Claude Code renders the
|
|
295
447
|
// mode line directly under the input box, and the workflow status strip
|
|
296
448
|
// sorts itself below this line by re-setting on the status channel
|
|
@@ -301,7 +453,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
301
453
|
[
|
|
302
454
|
modeBadge(mode, {
|
|
303
455
|
paused: pauseTracker.isPaused(),
|
|
304
|
-
|
|
456
|
+
classifier: classifierState.pinned?.id,
|
|
305
457
|
streaming,
|
|
306
458
|
}),
|
|
307
459
|
],
|
|
@@ -315,6 +467,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
315
467
|
paused: pauseTracker.isPaused(),
|
|
316
468
|
classifier: display.classifier,
|
|
317
469
|
pinned: display.pinned,
|
|
470
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
318
471
|
} satisfies PermissionStatus);
|
|
319
472
|
};
|
|
320
473
|
|
|
@@ -330,15 +483,21 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
330
483
|
mode === "auto" ? (autoConfig ??= loadAutoModeConfig(os.homedir())).classifyAllShell : undefined;
|
|
331
484
|
|
|
332
485
|
// Drop the cached config and classifier selection state, then refresh the badge.
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
486
|
+
// A model switch must re-evaluate the capability and context-window requirements.
|
|
487
|
+
// `"batched"` (session_start, model_select) joins the info line with other
|
|
488
|
+
// extensions' lifecycle notices (lib/notices.ts).
|
|
489
|
+
const resetClassifierChoice = (sessionModel?: Model<Api>, announce: boolean | "batched" = true) => {
|
|
490
|
+
autoConfig = undefined; // reloaded lazily
|
|
336
491
|
classifierState.pinned = undefined;
|
|
337
492
|
classifierState.rejected.clear();
|
|
493
|
+
// A subset of `rejected`: kept alone it would block the retry and blame a
|
|
494
|
+
// refusal from the previous choice.
|
|
495
|
+
classifierState.refused.clear();
|
|
338
496
|
classifierState.notified.clear();
|
|
339
497
|
classifierState.timeoutStreak = 0;
|
|
340
498
|
classifierState.chainCache = undefined;
|
|
341
499
|
applyBadge(sessionModel);
|
|
500
|
+
if (announce) announceClassifierChoice(sessionModel, { batched: announce === "batched" });
|
|
342
501
|
};
|
|
343
502
|
// Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
|
|
344
503
|
// CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
|
|
@@ -351,6 +510,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
351
510
|
return projectInstructionsByCwd.get(cwd);
|
|
352
511
|
};
|
|
353
512
|
const pauseTracker = new PauseTracker();
|
|
513
|
+
/** Classifier denials this session, and the one-shot grants `/permissions` mints from them. */
|
|
514
|
+
const denials = new DenialStore();
|
|
354
515
|
/**
|
|
355
516
|
* Which model the classifier settled on. Held here so the choice is pinned for
|
|
356
517
|
* the session rather than re-resolved per call, and so a model that turns out
|
|
@@ -384,14 +545,41 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
384
545
|
}
|
|
385
546
|
};
|
|
386
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Announce the choice, and any warning about a chosen classifier, before any
|
|
550
|
+
* tool needs screening. Warnings first, then ONE info line: pi folds
|
|
551
|
+
* back-to-back info notices into a single status line (findings §63), so a
|
|
552
|
+
* second one would overwrite the first. `lead` opens that line (the
|
|
553
|
+
* confirmation `/auto-mode model` gives); `force` announces outside auto mode;
|
|
554
|
+
* `batched` hands the info line to the lifecycle notice owner (lib/notices.ts).
|
|
555
|
+
*/
|
|
556
|
+
const announceClassifierChoice = (sessionModel?: Model<Api>, opts: { lead?: string; force?: boolean; batched?: boolean } = {}) => {
|
|
557
|
+
if ((mode !== "auto" && !opts.force) || !badgeCtx) return;
|
|
558
|
+
const choice = selectionChain(badgeCtx, sessionModel);
|
|
559
|
+
const suggestNewer = readSuggestNewerModels(os.homedir());
|
|
560
|
+
// Keyed by text, the same keys classify() uses, so each shows once.
|
|
561
|
+
const info: string[] = opts.lead ? [opts.lead] : [];
|
|
562
|
+
for (const notice of choice.notices) {
|
|
563
|
+
if (notice.fallbackReason || classifierState.notified.has(notice.text) || (!suggestNewer && notice.choiceWarning === "newer")) continue;
|
|
564
|
+
classifierState.notified.add(notice.text);
|
|
565
|
+
if (notice.level === "warning") badgeCtx.ui.notify(notice.text, "warning");
|
|
566
|
+
else info.push(notice.text);
|
|
567
|
+
}
|
|
568
|
+
const first = choice.candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
569
|
+
const key = first ? `using:${first.model.provider}/${first.model.id}` : undefined;
|
|
570
|
+
if (first && key && !classifierState.notified.has(key)) {
|
|
571
|
+
classifierState.notified.add(key);
|
|
572
|
+
if (choice.fallback) classifierState.notified.add(choice.fallback.text);
|
|
573
|
+
info.push(choice.fallback?.text ?? `Auto mode will screen calls with ${describeCandidate(first)}.`);
|
|
574
|
+
}
|
|
575
|
+
if (info.length === 0) return;
|
|
576
|
+
if (opts.batched) queueNotice(pi.events, badgeCtx, "info", info.join("\n"));
|
|
577
|
+
else badgeCtx.ui.notify(info.join("\n"), "info");
|
|
578
|
+
};
|
|
579
|
+
|
|
387
580
|
/** What would be tried, in order, before anything has been pinned. */
|
|
388
581
|
const describeChain = (ctx: ExtensionContext): string => {
|
|
389
|
-
const { candidates } =
|
|
390
|
-
available: ctx.modelRegistry.getAvailable(),
|
|
391
|
-
sessionModel: ctx.model,
|
|
392
|
-
configured: autoConfig?.classifierModel,
|
|
393
|
-
configuredSetForContainment: autoConfig?.classifierModelSetFor,
|
|
394
|
-
});
|
|
582
|
+
const { candidates } = selectionChain(ctx);
|
|
395
583
|
return candidates.length > 0 ? candidates.map(describeCandidate).join(" → ") : "(no model available)";
|
|
396
584
|
};
|
|
397
585
|
|
|
@@ -404,19 +592,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
404
592
|
}
|
|
405
593
|
})();
|
|
406
594
|
|
|
595
|
+
// Only a cursor into pi's live branch, not a separate classifier history.
|
|
596
|
+
// A parent assistant message can contain calls not yet executed when a child
|
|
597
|
+
// asks for approval; those later calls must not become creation evidence.
|
|
598
|
+
let currentMainToolCallId: string | undefined;
|
|
407
599
|
/**
|
|
408
|
-
* The
|
|
409
|
-
*
|
|
410
|
-
*
|
|
600
|
+
* The main calls of the turn in flight, in order: pi persists them with their
|
|
601
|
+
* assistant message before any runs. Cleared when the turn ends, when every
|
|
602
|
+
* one has a result (auto-mode/history.ts childHistoryCursor).
|
|
411
603
|
*/
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
// Bound memory on a long unattended run; the renderer also caps by chars,
|
|
415
|
-
// and intent verification reads userMessages, not this, so trimming old
|
|
416
|
-
// lines here never weakens that check. Trim down to a lower watermark so the
|
|
417
|
-
// O(n) splice is amortized over the next ~100 pushes on the tool_call path.
|
|
418
|
-
if (transcript.length > 500) transcript.splice(0, transcript.length - 400);
|
|
419
|
-
};
|
|
604
|
+
let inFlightMainCalls: string[] = [];
|
|
605
|
+
const childCursor = (startedBy: string | undefined) => childHistoryCursor(startedBy, inFlightMainCalls, currentMainToolCallId);
|
|
420
606
|
|
|
421
607
|
/**
|
|
422
608
|
* Record a rule denial as its own `denied` transcript line, so the classifier
|
|
@@ -426,11 +612,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
426
612
|
* user decision about a class of action, and each already has its own
|
|
427
613
|
* model-facing text.
|
|
428
614
|
*/
|
|
429
|
-
const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } },
|
|
615
|
+
const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolCallId: string, subject: string) => {
|
|
430
616
|
if (mode !== "auto") return;
|
|
431
617
|
if (result.cause === "plan-mode" || result.cause === "protected-path" || result.cause === "working-dir" || result.cause === "mode") return;
|
|
432
|
-
|
|
433
|
-
capTranscript();
|
|
618
|
+
pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId, deniedSubject: subject, rule: result.rule?.raw ?? "deny" });
|
|
434
619
|
};
|
|
435
620
|
|
|
436
621
|
/**
|
|
@@ -453,37 +638,37 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
453
638
|
return { ...autoConfig, hardDeny: [...(autoConfig?.hardDeny ?? []), ...renderedDeny.lines] };
|
|
454
639
|
};
|
|
455
640
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
* for real user input, which is exactly that boundary. Carried in FULL (not a
|
|
461
|
-
* rolling window): in a long unattended run the authorizing setup message must
|
|
462
|
-
* still clear a later action (decision 2 in docs/decisions/auto-mode.md).
|
|
463
|
-
*/
|
|
464
|
-
const userMessages: string[] = [];
|
|
641
|
+
// Persist input provenance beside the user message, not another transcript.
|
|
642
|
+
// pi stores extension-generated user turns in the same role; without this
|
|
643
|
+
// marker a resume could promote a skill body into the user's own intent.
|
|
644
|
+
const pendingInputs: { text: string; user: boolean }[] = [];
|
|
465
645
|
pi.on("input", (event) => {
|
|
466
|
-
// Only what the user typed. A plugin command or skill body arrives as
|
|
467
|
-
// source "extension" and must not become "the user's own words" that an
|
|
468
|
-
// intent quote can cite (review P12; hooks make the same distinction).
|
|
469
|
-
if (event.source === "extension") return;
|
|
470
646
|
const text = event.text?.trim();
|
|
471
647
|
if (!text) return;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
648
|
+
pendingInputs.push({ text, user: event.source !== "extension" });
|
|
649
|
+
// Handled commands never produce a user message. Bound only this pending
|
|
650
|
+
// association queue; the active conversation itself is never truncated.
|
|
651
|
+
if (pendingInputs.length > 256) pendingInputs.shift();
|
|
652
|
+
});
|
|
653
|
+
pi.on("message_end", (event) => {
|
|
654
|
+
if (event.message.role !== "user") return;
|
|
655
|
+
const text = messageText(event.message.content);
|
|
656
|
+
const at = pendingInputs.findIndex((input) => text.trim() === input.text || text.trim().startsWith(`${input.text}\n`));
|
|
657
|
+
const input = at >= 0 ? pendingInputs.splice(at, 1)[0] : undefined;
|
|
658
|
+
// A digest identifies the message; the text is already in the session.
|
|
659
|
+
pi.appendEntry(CLASSIFIER_USER_INPUT, { timestamp: event.message.timestamp, messageDigest: userMessageDigest(text), userText: input?.user ? input.text : null });
|
|
476
660
|
});
|
|
477
661
|
|
|
478
662
|
/**
|
|
479
663
|
* Run the deterministic pre-gate, then the classifier. The pre-gate may only
|
|
480
664
|
* ever conclude "safe" (see auto-mode/shell-analysis.ts); when it does, the
|
|
481
|
-
* classifier call is skipped entirely
|
|
482
|
-
*
|
|
665
|
+
* classifier call is skipped entirely. A read-only command never gets here:
|
|
666
|
+
* `decide()` allows it as the shell tools' own check, in every mode. What the
|
|
667
|
+
* pre-gate still clears here is a safe line that writes inside the project
|
|
668
|
+
* (a redirect).
|
|
483
669
|
*/
|
|
484
670
|
const runClassifier = async (
|
|
485
671
|
toolName: string,
|
|
486
|
-
input: Record<string, unknown>,
|
|
487
672
|
subject: string,
|
|
488
673
|
ctx: ExtensionContext,
|
|
489
674
|
/**
|
|
@@ -500,7 +685,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
500
685
|
* aborted). All default to the parent's own values, so the main path is
|
|
501
686
|
* unchanged.
|
|
502
687
|
*/
|
|
503
|
-
opts?: { cwd?: string; appendEntry?: TranscriptEntry; signal?: AbortSignal },
|
|
688
|
+
opts?: { cwd?: string; appendEntry?: TranscriptEntry; toolCallId?: string; history?: HistoryCursor; signal?: AbortSignal; powershellParse?: PowerShellParse },
|
|
504
689
|
) => {
|
|
505
690
|
const cwd = opts?.cwd ?? ctx.cwd;
|
|
506
691
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
@@ -508,37 +693,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
508
693
|
const home = os.homedir();
|
|
509
694
|
const allow = () => ({ decision: "allow" as const, reason: "", tier: undefined });
|
|
510
695
|
|
|
511
|
-
let evidence: ShellEvidence | undefined;
|
|
512
696
|
if (normalizeToolName(toolName) === "bash" && subject) {
|
|
513
|
-
|
|
514
|
-
if (evidence.verdict === "safe") {
|
|
697
|
+
if (analyzeShellCommand({ command: subject, cwd, home, protectedDirs, readableRoots }).verdict === "safe") {
|
|
515
698
|
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
516
699
|
return allow();
|
|
517
700
|
}
|
|
518
|
-
// The command's only risk is an in-project delete or whole-tree reset.
|
|
519
|
-
// Auto mode trusts the project as the agent's sandbox — but, unlike Claude
|
|
520
|
-
// Code, only when git can put the bytes back. A recoverable destruction
|
|
521
|
-
// runs unattended with no classifier call; an unrecoverable one (untracked,
|
|
522
|
-
// dirty, not a repo) still reaches the classifier.
|
|
523
|
-
if (containmentEligible && evidence.containedNonNetwork) {
|
|
524
|
-
const targets = evidence.writes.filter((w) => !w.outsideCwd && w.resolved).map((w) => w.resolved as string);
|
|
525
|
-
// The same cwd the evidence resolved against — for a worktree-isolated
|
|
526
|
-
// child that is the worktree, not the parent checkout (ctx.cwd).
|
|
527
|
-
const rec = checkRecoverability(cwd, { targets, wholeTree: evidence.wholeTree });
|
|
528
|
-
if (rec.verdict === "recoverable") {
|
|
529
|
-
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate", reason: rec.reason });
|
|
530
|
-
return allow();
|
|
531
|
-
}
|
|
532
|
-
evidence.notes.push(`git recoverability: ${rec.reason}`);
|
|
533
|
-
}
|
|
534
701
|
} else if (normalizeToolName(toolName) === "powershell" && subject) {
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
// destruction is always classified (docs/decisions/windows.md).
|
|
541
|
-
if (powershellReadOnly(subject, { cwd, home, readableRoots }).readOnly) {
|
|
702
|
+
// A line PowerShell's own parse proves read-only runs unclassified;
|
|
703
|
+
// contained edits were cleared in decide() where the model's tier allows
|
|
704
|
+
// them, and everything else goes to the classifier
|
|
705
|
+
// (decisions/windows.md "The PowerShell pre-gate reads PowerShell's own parse").
|
|
706
|
+
if (powershellReadOnly({ parse: opts?.powershellParse, cwd, home, readableRoots }).readOnly) {
|
|
542
707
|
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
543
708
|
return allow();
|
|
544
709
|
}
|
|
@@ -546,16 +711,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
546
711
|
// A write/edit whose target is inside the project and not a credential
|
|
547
712
|
// path is ordinary sandbox work — Claude Code auto-approves it, and so do
|
|
548
713
|
// we (protected paths never reach here: decide() routes them with
|
|
549
|
-
// containmentEligible=false).
|
|
550
|
-
//
|
|
551
|
-
// core of unattended coding autonomy. Execution-primitive paths (build
|
|
714
|
+
// containmentEligible=false). Edit-then-iterate is the core of
|
|
715
|
+
// unattended coding autonomy. Execution-primitive paths (build
|
|
552
716
|
// wrappers, CI workflows, editor auto-run config) are excluded exactly as
|
|
553
717
|
// the bash pre-gate excludes them — being in-project does not make a file
|
|
554
718
|
// that runs later without further approval safe to write unclassified.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
719
|
+
// `subject` is pathArgument's pick, the field every other gate judged;
|
|
720
|
+
// picking a field again here once approved a different file from the
|
|
721
|
+
// one written (AUTO-MODE-SECURITY-REVIEW-2026-09-24 H1).
|
|
722
|
+
if (subject) {
|
|
723
|
+
const absolute = toAbsolute(cwd, subject, home);
|
|
559
724
|
const resolved = resolveForContainment(absolute);
|
|
560
725
|
const root = resolveForContainment(cwd) ?? cwd;
|
|
561
726
|
if (resolved && isWithin(root, resolved) && !isSensitivePath(absolute) && !isExecutionPrimitivePath(absolute)) {
|
|
@@ -565,14 +730,26 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
565
730
|
}
|
|
566
731
|
}
|
|
567
732
|
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
733
|
+
// Project pi's active context on every call. Compaction, resume and /tree
|
|
734
|
+
// therefore change classifier context at the same boundary as the agent's.
|
|
735
|
+
const { transcript: history, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), opts?.toolCallId
|
|
736
|
+
? { beforeToolCallId: opts.toolCallId }
|
|
737
|
+
: (opts?.history ?? { throughToolCallId: currentMainToolCallId }));
|
|
738
|
+
const action = opts?.appendEntry;
|
|
739
|
+
const shell = normalizeToolName(toolName) === "powershell" ? "powershell" : isShellTool(normalizeToolName(toolName)) || normalizeToolName(toolName) === "monitor" ? "bash" : undefined;
|
|
740
|
+
if (shell && subject && wantsGitStatusMeta(shell, subject)) {
|
|
741
|
+
const porcelain = await gitStatusOutput(cwd, gitStatusMetaArgs(reachesIgnoredFiles(shell, subject)));
|
|
742
|
+
const gitStatus = porcelain === undefined ? undefined : gitStatusMeta(porcelain);
|
|
743
|
+
if (gitStatus) {
|
|
744
|
+
history.push({ kind: "meta", gitStatus });
|
|
745
|
+
if (opts?.toolCallId) pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId: opts.toolCallId, gitStatus });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const resolvedPaths = actionResolvedPaths(normalizeToolName(toolName), subject, opts?.powershellParse, { cwd, home, roots: [cwd, ...workspaceDirs] });
|
|
572
749
|
const verdict = await classify(
|
|
573
750
|
{
|
|
574
751
|
toolName,
|
|
575
|
-
transcript:
|
|
752
|
+
transcript: [...history, ...(resolvedPaths ? [{ kind: "resolved-paths" as const, resolvedPaths }] : []), ...(action ? [action] : [])],
|
|
576
753
|
userMessages: [...userMessages],
|
|
577
754
|
claudeMd: instructionsFor(cwd),
|
|
578
755
|
username: classifierUsername,
|
|
@@ -587,6 +764,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
587
764
|
config: autoConfig,
|
|
588
765
|
signal: opts?.signal ?? ctx.signal,
|
|
589
766
|
state: classifierState,
|
|
767
|
+
cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
|
|
590
768
|
onUsage: onClassifierUsage,
|
|
591
769
|
onModelUnusable: onClassifierModelUnusable,
|
|
592
770
|
onNotice: (message, level) => {
|
|
@@ -607,56 +785,73 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
607
785
|
ruleId: verdict.ruleId,
|
|
608
786
|
reason: verdict.reason || undefined,
|
|
609
787
|
raw: verdict.raw,
|
|
788
|
+
transcriptTooLong: verdict.transcriptTooLong,
|
|
610
789
|
model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
|
|
611
790
|
});
|
|
612
791
|
return verdict;
|
|
613
792
|
};
|
|
614
793
|
|
|
615
|
-
|
|
794
|
+
/** `startup`: the session opens in this mode, so there is no switch to announce, only the mode's standing block. */
|
|
795
|
+
const setMode = (next: PermissionMode, startup = false, toolCallId?: string) => {
|
|
796
|
+
const changed = mode !== next;
|
|
616
797
|
mode = next;
|
|
617
798
|
process.env[MODE_ENV] = next;
|
|
618
|
-
//
|
|
799
|
+
// Close the standing block of the mode being left BEFORE the status
|
|
619
800
|
// broadcast in applyBadge: the plan-mode extension owns the shared
|
|
620
801
|
// "permission-mode" key while planning (it knows the plan file) and
|
|
621
802
|
// re-installs its block synchronously from that broadcast when the mode
|
|
622
803
|
// becomes plan — removing the key afterwards would undo that re-add and
|
|
623
804
|
// leave a turn entered mid-stream with no plan reminder until the next
|
|
624
805
|
// prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
|
|
625
|
-
|
|
626
|
-
|
|
806
|
+
const previousMode = restoredContext(pi.events)?.baselines["permission-mode"];
|
|
807
|
+
if (mode !== "auto" && !(mode === "plan" && (startup ? previousMode === "plan" : !changed))) {
|
|
808
|
+
pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
|
|
809
|
+
}
|
|
810
|
+
pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "permission-mode", value: mode });
|
|
811
|
+
applyBadge(undefined, toolCallId);
|
|
627
812
|
// Every switch is announced once on the tail of the next request (the next
|
|
628
813
|
// tool result mid-turn, the prompt between turns), so the model learns of
|
|
629
814
|
// the change where it reads next. The standing block carries the rules
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
// mid-turn, also names the plan file (published by plan-mode's refresh
|
|
633
|
-
// inside the applyBadge broadcast above).
|
|
815
|
+
// from that point forward; the announcement also names the plan file
|
|
816
|
+
// (published by plan-mode's refresh inside the applyBadge broadcast above).
|
|
634
817
|
const planNote =
|
|
635
818
|
mode === "plan"
|
|
636
819
|
? planFilePath
|
|
637
820
|
? ` 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.`
|
|
638
821
|
: " Only read-only tools are available now, plus the plan file named in the plan-mode reminder."
|
|
639
822
|
: "";
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
823
|
+
// Not at startup: "is now" beside the mode's own standing block read as a
|
|
824
|
+
// duplicate to both GPT-6 models in the 2026-10-04 self-test, and Claude
|
|
825
|
+
// Code announces only a switch.
|
|
826
|
+
if (!startup) {
|
|
827
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
828
|
+
text: `The user's permission mode is now "${mode}".${planNote}`,
|
|
829
|
+
key: "permission-mode-change",
|
|
830
|
+
toolCallId,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
644
833
|
if (mode === "auto") {
|
|
645
834
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
646
835
|
text:
|
|
647
836
|
"Auto mode is active: your tool calls run without per-action prompts, but each one is screened by an approval classifier that blocks anything irreversible, destructive, or aimed outside this environment. " +
|
|
648
|
-
"Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: explain what you were trying to do and let the user decide
|
|
837
|
+
"Work normally — do not narrate the classifier or try to phrase calls to get past it. If a call is blocked, take the block at face value: do not retry it, and do not reach the same result another way. Continue with work that does not depend on it, or explain what you were trying to do and let the user decide. " +
|
|
649
838
|
"Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
|
|
650
839
|
scope: "every-turn",
|
|
651
840
|
key: "permission-mode",
|
|
652
841
|
// Session state: rides every user message since auto mode came on, so
|
|
653
842
|
// the cached prefix holds turn to turn (lib/reminders.ts).
|
|
654
843
|
placement: "sticky-append",
|
|
844
|
+
toolCallId,
|
|
655
845
|
});
|
|
656
846
|
}
|
|
657
847
|
};
|
|
658
848
|
|
|
659
|
-
|
|
849
|
+
/**
|
|
850
|
+
* Re-read the permission rules only: what a rule edit in /permissions needs.
|
|
851
|
+
* The mode, a declined project-rule prompt and auto mode's cached config are
|
|
852
|
+
* left alone (reloadSettings resets those for a new session).
|
|
853
|
+
*/
|
|
854
|
+
const reloadRules = (ctx: ExtensionContext) => {
|
|
660
855
|
const settings = loadPermissionSettings(ctx.cwd, os.homedir());
|
|
661
856
|
const parsed = {
|
|
662
857
|
deny: parseRulesReport(settings.deny),
|
|
@@ -669,10 +864,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
669
864
|
allow = parsed.allow.rules;
|
|
670
865
|
projectAllow = parsed.projectAllow.rules;
|
|
671
866
|
projectAllowRaw = settings.projectAllow;
|
|
867
|
+
blockOutsideReads = settings.blockReadsOutsideWorkingDirectories === true;
|
|
672
868
|
// A linked worktree shares its main checkout's consent (findProjectRoot).
|
|
673
869
|
projectRoot = findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
674
|
-
|
|
675
|
-
|
|
870
|
+
// A settings file is held to what /add-dir and --add-dir refuse: `/` or `~`
|
|
871
|
+
// there would make almost the whole disk working space.
|
|
872
|
+
const tooBroad: string[] = [];
|
|
873
|
+
settingsWorkspaceDirs = listWorkspaceDirectories(ctx.cwd, os.homedir()).filter((dir) => {
|
|
874
|
+
const refusal = tooBroadForWorkspace(tryRealpath(dir.path) ?? dir.path, os.homedir());
|
|
875
|
+
if (refusal) tooBroad.push(`${dir.raw} in ${tildify(dir.settingsPath, os.homedir())}: ${refusal}`);
|
|
876
|
+
return !refusal;
|
|
877
|
+
});
|
|
878
|
+
const tooBroadSignature = tooBroad.join("\n");
|
|
879
|
+
if (tooBroad.length > 0 && ctx.hasUI && tooBroadSignature !== warnedTooBroad) {
|
|
880
|
+
warnedTooBroad = tooBroadSignature;
|
|
881
|
+
ctx.ui.notify(`Ignored permissions.additionalDirectories entries: ${tooBroad.join("; ")}`, "warning");
|
|
882
|
+
}
|
|
883
|
+
projectAllowTrusted = projectAllowApproved(projectRoot, projectTrustList());
|
|
884
|
+
refreshWorkspace();
|
|
676
885
|
// A rule that fails to parse is a rule the user believes is in force and is
|
|
677
886
|
// not. Say so (once per distinct set) and list them in /permissions.
|
|
678
887
|
unparsableRules = [...parsed.deny.dropped, ...parsed.ask.dropped, ...parsed.allow.dropped, ...parsed.projectAllow.dropped];
|
|
@@ -684,6 +893,12 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
684
893
|
"warning",
|
|
685
894
|
);
|
|
686
895
|
}
|
|
896
|
+
return settings;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
const reloadSettings = (ctx: ExtensionContext) => {
|
|
900
|
+
const settings = reloadRules(ctx);
|
|
901
|
+
projectAllowDeclined = false;
|
|
687
902
|
// Dropped so edited autoMode rules and instruction files are picked up on
|
|
688
903
|
// reload rather than staying cached for the life of the process.
|
|
689
904
|
autoConfig = undefined;
|
|
@@ -706,54 +921,143 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
706
921
|
// Auto mode needs a model to run its classifier on; with none reachable it
|
|
707
922
|
// would block every call, so it stays out of the cycle instead — the same
|
|
708
923
|
// thing Claude Code does when auto mode's requirements aren't met. The
|
|
709
|
-
//
|
|
710
|
-
//
|
|
924
|
+
// classifier stays on this session's provider, falling back to the session
|
|
925
|
+
// model when no capable window-compatible alternative is available.
|
|
711
926
|
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
712
927
|
// A mode that owns a standing reminder must be announced when the session
|
|
713
928
|
// STARTS in it, not only when the user switches into it. setMode is the
|
|
714
929
|
// only emitter of the block; until 2026-09-05 startup called it for plan
|
|
715
930
|
// alone, so `--permission-mode auto` / `defaultMode: "auto"` sessions ran
|
|
716
931
|
// with no auto-mode reminder at all (STEERING-REVIEW-2026-09-05 H2).
|
|
717
|
-
if (STANDING_REMINDER_MODES.has(mode)) setMode(mode);
|
|
932
|
+
if (STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
|
|
718
933
|
process.env[MODE_ENV] = mode;
|
|
719
934
|
};
|
|
720
935
|
|
|
936
|
+
// Claude Code's auto-mode note joins the mid-conversation system message
|
|
937
|
+
// when the conversation opens in auto mode, decided once on its first turn
|
|
938
|
+
// so message 1 never changes later (auto-mode/system-note.ts).
|
|
939
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
940
|
+
if (autoNoteDecided) return;
|
|
941
|
+
autoNoteDecided = true;
|
|
942
|
+
const note = mode === "auto" && ctx.model ? autoModeSystemNote(ctx.model) : undefined;
|
|
943
|
+
if (!note) return;
|
|
944
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
945
|
+
text: note,
|
|
946
|
+
scope: "every-turn",
|
|
947
|
+
key: "auto-mode-note",
|
|
948
|
+
placement: "first-prepend",
|
|
949
|
+
order: CONTEXT_ORDER.autoModeNote,
|
|
950
|
+
systemRoleOnly: true,
|
|
951
|
+
} satisfies ReminderPayload);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// A turn's calls are all persisted before the first runs, and all have a
|
|
955
|
+
// result by turn_end (an aborted run ends the agent instead).
|
|
956
|
+
pi.on("turn_start", () => {
|
|
957
|
+
inFlightMainCalls = [];
|
|
958
|
+
});
|
|
959
|
+
pi.on("turn_end", () => {
|
|
960
|
+
inFlightMainCalls = [];
|
|
961
|
+
});
|
|
962
|
+
pi.on("agent_end", () => {
|
|
963
|
+
inFlightMainCalls = [];
|
|
964
|
+
});
|
|
965
|
+
|
|
721
966
|
pi.on("session_start", (event, ctx) => {
|
|
967
|
+
childPermissionSession?.abort();
|
|
968
|
+
childPermissionSession = new AbortController();
|
|
722
969
|
badgeCtx = ctx;
|
|
723
970
|
lastReviewCtx = ctx;
|
|
724
971
|
sessionEpoch++;
|
|
972
|
+
panels.reset();
|
|
973
|
+
gateFastPaths = usesClaudeCodeFastPaths(ctx.model);
|
|
974
|
+
// Not awaited: the grammar loads in a few milliseconds, and the gate
|
|
975
|
+
// awaits it per call. Only a failed load is worth telling the user about.
|
|
976
|
+
void bashParserReady().then(() => {
|
|
977
|
+
const why = bashParserUnavailable();
|
|
978
|
+
if (why && ctx.hasUI) ctx.ui.notify(`One Code: ${why}. Every bash command will be escalated or prompted.`, "error");
|
|
979
|
+
});
|
|
725
980
|
// A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
|
|
726
981
|
// previous conversation's user messages are not intent evidence for this
|
|
727
982
|
// one, its "don't ask again" grants and its pause state do not carry over
|
|
728
983
|
// (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps them — same conversation.
|
|
729
984
|
if (event.reason !== "reload") {
|
|
985
|
+
autoNoteDecided = false;
|
|
730
986
|
sessionAllows.length = 0;
|
|
731
|
-
|
|
732
|
-
|
|
987
|
+
currentMainToolCallId = undefined;
|
|
988
|
+
inFlightMainCalls = [];
|
|
989
|
+
pendingInputs.length = 0;
|
|
733
990
|
pauseTracker.reset();
|
|
991
|
+
denials.reset();
|
|
992
|
+
sessionWorkspaceDirs.length = 0;
|
|
734
993
|
}
|
|
735
|
-
|
|
736
|
-
|
|
994
|
+
// With auto-memory off the folder is nobody's working space: no grant.
|
|
995
|
+
memoryDirPath = autoMemoryEnabled(ctx.cwd) ? projectMemoryDir(ctx.cwd) : undefined;
|
|
996
|
+
// Working space only when it is private to this user: another local user
|
|
997
|
+
// who owns the directory on a shared /tmp could swap what is written there.
|
|
998
|
+
scratchpadDirPath = privateSessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
737
999
|
// Resolved like the subjects compared against it (a symlinked parent, macOS /var).
|
|
738
1000
|
resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
|
|
739
1001
|
sessionDirPath = resolvedOrSelf(ctx.sessionManager.getSessionDir());
|
|
740
|
-
|
|
1002
|
+
harnessReadableRoots = [memoryDirPath, scratchpadDirPath, resultsDirPath, sessionDirPath].filter((d): d is string => !!d).map(resolvedOrSelf);
|
|
1003
|
+
docsDirPaths = guideDocsDirs().map(resolvedOrSelf);
|
|
1004
|
+
// `--add-dir`: each directory is validated like one added in the panel.
|
|
1005
|
+
flagWorkspaceDirs = [];
|
|
1006
|
+
for (const entry of parseAddDirFlag(pi.getFlag("add-dir") as string | undefined)) {
|
|
1007
|
+
const checked = validateWorkspaceDirectory(entry, ctx.cwd, os.homedir(), flagWorkspaceDirs);
|
|
1008
|
+
if ("path" in checked) flagWorkspaceDirs.push(checked.path);
|
|
1009
|
+
else if (ctx.hasUI) ctx.ui.notify(`--add-dir ${entry}: ${checked.error}`, "warning");
|
|
1010
|
+
}
|
|
741
1011
|
resolvedCwd = resolveForContainment(ctx.cwd);
|
|
742
1012
|
oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
|
|
743
1013
|
protectedDirs = runtimeProtectedDirs();
|
|
1014
|
+
secretPaths = runtimeSecretPaths(os.homedir());
|
|
744
1015
|
reloadSettings(ctx);
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1016
|
+
// Stored mode is announcement history only: live settings/flags still
|
|
1017
|
+
// decide the gate. A resume into another mode is an ordinary switch.
|
|
1018
|
+
const restored = restoredContext(pi.events);
|
|
1019
|
+
const previousMode = normalizePermissionMode(restored?.baselines["permission-mode"]);
|
|
1020
|
+
if (restored && previousMode && previousMode !== mode) setMode(mode);
|
|
1021
|
+
else if (restored && !STANDING_REMINDER_MODES.has(mode)) pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
|
|
1022
|
+
pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "permission-mode", value: mode });
|
|
1023
|
+
// The system prompt lists the workspace as the session starts (lib/workspace-channel.ts).
|
|
1024
|
+
pi.events.emit(WORKSPACE_CHANNEL, { dirs: workspacePaths } satisfies WorkspaceAnnouncement);
|
|
1025
|
+
resetClassifierChoice(ctx.model, "batched");
|
|
1026
|
+
// The bridge reads live mode/rules, but belongs to this parent session only.
|
|
1027
|
+
pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: sessionPermissionBridge(evaluateChildToolCall, childPermissionSession.signal) });
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// The published bridge stays: aborted, it denies every call a retained child
|
|
1031
|
+
// makes, while a parent without this extension keeps the local gate.
|
|
1032
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
1033
|
+
childPermissionSession?.abort();
|
|
1034
|
+
lastReviewCtx = undefined;
|
|
1035
|
+
sessionEpoch++;
|
|
1036
|
+
// Children screen through this session's key too (evaluateChildToolCall
|
|
1037
|
+
// uses lastReviewCtx), so one release covers their calls.
|
|
1038
|
+
releaseClassifierResources(ctx.sessionManager.getSessionId());
|
|
750
1039
|
});
|
|
751
1040
|
|
|
752
1041
|
pi.on("model_select", (event, ctx) => {
|
|
753
1042
|
badgeCtx = ctx;
|
|
754
1043
|
lastReviewCtx = ctx;
|
|
755
1044
|
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
756
|
-
resetClassifierChoice(event.model);
|
|
1045
|
+
resetClassifierChoice(event.model, "batched");
|
|
1046
|
+
// The gate reads the model's tier on every call, so a switch takes effect
|
|
1047
|
+
// on the next one; a switch across the workhorse/cheap line says so.
|
|
1048
|
+
const fastPaths = usesClaudeCodeFastPaths(event.model);
|
|
1049
|
+
if (gateFastPaths !== undefined && fastPaths !== gateFastPaths && event.model) {
|
|
1050
|
+
const name = `${formatModel(event.model.provider, event.model.id)} (${intrinsicTier(event.model)} tier)`;
|
|
1051
|
+
queueNotice(
|
|
1052
|
+
pi.events,
|
|
1053
|
+
ctx,
|
|
1054
|
+
"info",
|
|
1055
|
+
fastPaths
|
|
1056
|
+
? `${name}: permissions now follow Claude Code's fast paths.`
|
|
1057
|
+
: `${name}: permissions now use One Code's stricter checks, so more calls prompt or go to the auto-mode classifier.`,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
gateFastPaths = fastPaths;
|
|
757
1061
|
});
|
|
758
1062
|
|
|
759
1063
|
// The badge carries "· esc to interrupt" only while the model works (CC's
|
|
@@ -763,6 +1067,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
763
1067
|
lastReviewCtx = ctx;
|
|
764
1068
|
streaming = true;
|
|
765
1069
|
applyBadge();
|
|
1070
|
+
// Start the PowerShell parser before the first PowerShell call needs it,
|
|
1071
|
+
// never awaited: its first start can take seconds on Windows.
|
|
1072
|
+
if (mode !== "bypassPermissions" && pi.getActiveTools().includes("powershell")) void powerShellParser()?.parse("");
|
|
766
1073
|
});
|
|
767
1074
|
// Settle, not agent_end: `agent_end` fires per run, so flipping there made
|
|
768
1075
|
// the badge's "· esc to interrupt" suffix flicker off and back on across a
|
|
@@ -778,8 +1085,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
778
1085
|
// `/auto-mode model` does after its own write, so the next call re-plans.
|
|
779
1086
|
pi.events.on(CLASSIFIER_SETTING_CHANGED_CHANNEL, () => resetClassifierChoice(badgeCtx?.model));
|
|
780
1087
|
pi.events.on(MODE_CHANNEL, (data) => {
|
|
781
|
-
const
|
|
782
|
-
|
|
1088
|
+
const request = data as { mode?: unknown; toolCallId?: unknown } | undefined;
|
|
1089
|
+
const requested = normalizePermissionMode(request?.mode);
|
|
1090
|
+
if (requested) setMode(requested, false, typeof request?.toolCallId === "string" ? request.toolCallId : undefined);
|
|
783
1091
|
});
|
|
784
1092
|
|
|
785
1093
|
// The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
|
|
@@ -794,7 +1102,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
794
1102
|
// that terminals don't already own (ctrl+m *is* Enter's byte, alt needs
|
|
795
1103
|
// option-as-meta configured on macOS) — except on Windows and WSL, where
|
|
796
1104
|
// pi binds ctrl+q itself and the key is alt+m (lib/keys.ts) — see
|
|
797
|
-
// docs/decisions.md.
|
|
1105
|
+
// working-docs/decisions.md.
|
|
798
1106
|
pi.registerShortcut(CYCLE_KEY, {
|
|
799
1107
|
description: "Cycle permission mode",
|
|
800
1108
|
handler: (ctx) => {
|
|
@@ -809,13 +1117,29 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
809
1117
|
let lastReviewCtx: ExtensionContext | undefined;
|
|
810
1118
|
/** Bumped per session_start: an async review that finishes after a /clear must not post into the new session. */
|
|
811
1119
|
let sessionEpoch = 0;
|
|
1120
|
+
let childPermissionSession: AbortController | undefined;
|
|
812
1121
|
|
|
813
1122
|
// Serialize interactive prompts: a background/resident subagent can hit an "ask"
|
|
814
1123
|
// while the main turn (or another child) is already awaiting one, and driving
|
|
815
1124
|
// ctx.ui.select concurrently on one terminal is unverified. This chains them.
|
|
1125
|
+
// A prompt also waits while a panel holds the keyboard (/permissions, the
|
|
1126
|
+
// agent view, a picker): pi would put the prompt in the panel's place, the
|
|
1127
|
+
// panel would not come back, and an Enter meant for the panel would answer
|
|
1128
|
+
// the prompt. Claude Code holds its permission dialog the same way.
|
|
816
1129
|
let promptChain: Promise<unknown> = Promise.resolve();
|
|
817
|
-
|
|
818
|
-
|
|
1130
|
+
/** Calls the user rejected without a reason: the turn stops once pi records their result. */
|
|
1131
|
+
const abortAfterResult = new Set<string>();
|
|
1132
|
+
const panels = new PanelFocus();
|
|
1133
|
+
pi.events.on(PANEL_FOCUS_CHANNEL, (data) => panels.note(data));
|
|
1134
|
+
const serializePrompt = <T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
1135
|
+
const show = async () => {
|
|
1136
|
+
if (panels.busy && !signal?.aborted) {
|
|
1137
|
+
liveUiCtx(lastReviewCtx)?.ui.notify(PROMPT_WAITS_FOR_PANEL, "info");
|
|
1138
|
+
await panels.whenClear(signal);
|
|
1139
|
+
}
|
|
1140
|
+
return fn();
|
|
1141
|
+
};
|
|
1142
|
+
const run = promptChain.then(show, show);
|
|
819
1143
|
promptChain = run.then(
|
|
820
1144
|
() => {},
|
|
821
1145
|
() => {},
|
|
@@ -823,53 +1147,143 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
823
1147
|
return run;
|
|
824
1148
|
};
|
|
825
1149
|
|
|
1150
|
+
/** A context overflow judged nothing: ask once, or stop a headless turn. */
|
|
1151
|
+
const transcriptOverflow = async (ctx: ExtensionContext, toolName: string, subject: string, signal = ctx.signal): Promise<ChildGateDecision> => {
|
|
1152
|
+
if (!ctx.hasUI || ctx.mode === "print") {
|
|
1153
|
+
const reason = "auto mode classifier transcript exceeded context window in headless mode";
|
|
1154
|
+
ctx.ui.notify(`${reason}. Compact the session (/compact) and continue.`, "error");
|
|
1155
|
+
// A failed one-shot run, not a user cancel (exit/index.ts).
|
|
1156
|
+
pi.events.emit(TURN_FAILED_CHANNEL, { reason } satisfies TurnFailedEvent);
|
|
1157
|
+
ctx.abort();
|
|
1158
|
+
return { block: true, reason };
|
|
1159
|
+
}
|
|
1160
|
+
const reason = "Auto mode classifier transcript exceeded context window — falling back to manual approval";
|
|
1161
|
+
ctx.ui.notify(reason, "warning");
|
|
1162
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "prompt", source: "user", transcriptTooLong: true, reason });
|
|
1163
|
+
const choice = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.select(
|
|
1164
|
+
`${reason}\n\n${askTitle(toolName, previewSubject(subject), "mode", false)}`,
|
|
1165
|
+
askOptions(undefined), { signal },
|
|
1166
|
+
), signal);
|
|
1167
|
+
const allowed = !signal?.aborted && choice === YES;
|
|
1168
|
+
logDecision(ctx, { tool: toolName, subject, outcome: allowed ? "allow" : "block", source: "user", transcriptTooLong: true, reason });
|
|
1169
|
+
if (allowed) return undefined;
|
|
1170
|
+
if (!signal?.aborted && choice === NO) {
|
|
1171
|
+
const feedback = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.input(
|
|
1172
|
+
"What should the agent do instead?", "Optional — press Esc to skip", { signal },
|
|
1173
|
+
), signal);
|
|
1174
|
+
return { block: true, reason: userDenialText(feedback) };
|
|
1175
|
+
}
|
|
1176
|
+
return { block: true, reason: "Manual approval was not granted after the auto-mode classifier transcript exceeded its context window." };
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Show an ask prompt through `queue`, timed when it is a paused-auto-mode
|
|
1181
|
+
* resume prompt that Claude Code's denial-limit fallback times (the first
|
|
1182
|
+
* after a consecutive-limit pause). pi shows a live countdown and resolves
|
|
1183
|
+
* an expired dialog as dismissed, so `unanswered` tells the two apart,
|
|
1184
|
+
* measured from when the dialog showed (not when it queued); the caller
|
|
1185
|
+
* then denies the call without asking for feedback. Shared by both gate paths.
|
|
1186
|
+
*/
|
|
1187
|
+
const askMaybeTimed = async (
|
|
1188
|
+
pausedResume: boolean,
|
|
1189
|
+
title: string,
|
|
1190
|
+
queue: (show: () => Promise<string | undefined>) => Promise<string | undefined>,
|
|
1191
|
+
select: (title: string, timeout: number | undefined) => Promise<string | undefined>,
|
|
1192
|
+
): Promise<{ choice: string | undefined; unanswered: boolean }> => {
|
|
1193
|
+
if (!pausedResume || !pauseTracker.takeTimedPrompt()) return { choice: await queue(() => select(title, undefined)), unanswered: false };
|
|
1194
|
+
const timeout = unattendedPromptTimeoutMs();
|
|
1195
|
+
let shownAt: number | undefined;
|
|
1196
|
+
const choice = await queue(() => {
|
|
1197
|
+
shownAt = Date.now();
|
|
1198
|
+
return select(`${title}\n\n${unattendedPromptNotice(timeout)}`, timeout);
|
|
1199
|
+
});
|
|
1200
|
+
return { choice, unanswered: choice === undefined && shownAt !== undefined && Date.now() - shownAt >= timeout - Math.min(1500, timeout / 10) };
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Claude Code's one-time prompt before auto mode's first read outside the
|
|
1205
|
+
* working directories (2.1.282, findings §33): raised only in an interactive
|
|
1206
|
+
* session that has not answered it, on this machine or in Claude Code. Yes
|
|
1207
|
+
* and Block settle it for good; Ask again, or dismissing, refuses this read
|
|
1208
|
+
* and asks on the next. Queued behind other prompts, and re-checked once its
|
|
1209
|
+
* turn comes, so parallel outside reads ask once.
|
|
1210
|
+
*/
|
|
1211
|
+
const firstOutsideRead = (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<"allow" | "block" | "ask_again"> => {
|
|
1212
|
+
const home = os.homedir();
|
|
1213
|
+
const settled = () => (outsideReadSeen ??= outsideReadPromptSeen(oneCodeSettingsPath(home), claudeSourcesOn() ? claudeJsonPath(home) : undefined));
|
|
1214
|
+
if (!ctx.hasUI) return Promise.resolve("allow");
|
|
1215
|
+
// Settled while this call waited (a parallel read's Block included).
|
|
1216
|
+
if (settled()) return Promise.resolve(blockOutsideReads ? "block" : "allow");
|
|
1217
|
+
return serializePrompt(async () => {
|
|
1218
|
+
if (signal?.aborted) return "ask_again";
|
|
1219
|
+
if (settled()) return blockOutsideReads ? "block" : "allow";
|
|
1220
|
+
const settingsFile = tildify(oneCodeSettingsPath(home), home);
|
|
1221
|
+
const title = `${OUTSIDE_READ_TITLE}\n\n ${toolName} ${path}\n\n${OUTSIDE_READ_QUESTION}\n\n${OUTSIDE_READ_EXPLAINER(settingsFile)}`;
|
|
1222
|
+
const choice = await ctx.ui.select(title, Object.values(OUTSIDE_READ_ANSWERS), { signal });
|
|
1223
|
+
if (signal?.aborted || (choice !== OUTSIDE_READ_ANSWERS.allow && choice !== OUTSIDE_READ_ANSWERS.block)) return "ask_again";
|
|
1224
|
+
outsideReadSeen = true;
|
|
1225
|
+
markOutsideReadPromptSeen(oneCodeSettingsPath(home));
|
|
1226
|
+
if (choice === OUTSIDE_READ_ANSWERS.allow) return "allow";
|
|
1227
|
+
persistBlockOutsideReads(oneCodeSettingsPath(home));
|
|
1228
|
+
blockOutsideReads = true;
|
|
1229
|
+
return "block";
|
|
1230
|
+
}, signal);
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
/** The block reason for a first-read prompt answered with Block or Ask again; undefined to run the read. */
|
|
1234
|
+
const outsideReadRefusal = async (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<string | undefined> => {
|
|
1235
|
+
const answer = await firstOutsideRead(ctx, toolName, path, signal);
|
|
1236
|
+
return answer === "allow" ? undefined : answer === "block" ? DENIED_CHOSE_BLOCK_OUTSIDE_READS : userDenialText();
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* An allowed call in auto mode breaks the block streak and ends a
|
|
1241
|
+
* consecutive-limit pause (`PauseTracker.recordAllow`), whatever allowed
|
|
1242
|
+
* it: a rule, a read, a fast path, the classifier or a /permissions grant.
|
|
1243
|
+
* A subagent's calls never touch the streak, as its blocks never count.
|
|
1244
|
+
*/
|
|
1245
|
+
const noteAllow = () => {
|
|
1246
|
+
if (pauseTracker.recordAllow()) applyBadge();
|
|
1247
|
+
};
|
|
1248
|
+
|
|
826
1249
|
pi.on("tool_call", async (event, ctx) => {
|
|
827
1250
|
lastReviewCtx = ctx;
|
|
1251
|
+
// Settled long before the first call; a failed load leaves every bash
|
|
1252
|
+
// command unparseable, which escalates or prompts.
|
|
1253
|
+
await bashParserReady();
|
|
828
1254
|
const normalizedTool = normalizeToolName(event.toolName);
|
|
1255
|
+
const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(event.input as Record<string, unknown>) : undefined;
|
|
1256
|
+
if (conflict) return { block: true, reason: conflict };
|
|
829
1257
|
const subject = extractSubject(normalizedTool, event.input as Record<string, unknown>);
|
|
830
|
-
// Resolved through symlinks so the protected-path and working-directory
|
|
831
|
-
// checks see where a write lands or a read comes from, not how the path
|
|
832
|
-
// is spelled. Path tools only: a bash subject is a command line.
|
|
833
|
-
const resolvedSubject =
|
|
834
|
-
isPathSubjectTool(normalizedTool) && subject
|
|
835
|
-
? resolveForContainment(toAbsolute(ctx.cwd, subject, os.homedir()))
|
|
836
|
-
: undefined;
|
|
837
1258
|
// In a worktree session, worktree's tool_call handler (which runs before this
|
|
838
1259
|
// one) cd-wraps bash commands for execution and publishes the model's
|
|
839
1260
|
// original command — and the worktree it runs in — over the bus under this
|
|
840
|
-
// call's id. Rule matching, the shell pre-gate
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
// 2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
|
|
1261
|
+
// call's id. Rule matching, the shell pre-gate and the prompt all evaluate
|
|
1262
|
+
// that original against the worktree cwd: matched against the wrapper
|
|
1263
|
+
// every configured Bash rule stopped matching, and analysed as the wrapper
|
|
1264
|
+
// (a `cd` plus a newline) every call escalated to the classifier and the
|
|
1265
|
+
// prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
|
|
846
1266
|
// command that actually runs). The lookup is by toolCallId on purpose: a
|
|
847
1267
|
// value inside `event.input` would be the model's to write, and rules would
|
|
848
1268
|
// match a string of its choosing.
|
|
849
|
-
|
|
1269
|
+
// `monitor` is cd-wrapped the same way (worktree/rewrite.ts).
|
|
1270
|
+
const original = isShellTool(normalizedTool) || normalizedTool === "monitor" ? originalCommands.get(event.toolCallId) : undefined;
|
|
850
1271
|
const matchSubject = original?.command ?? subject;
|
|
851
|
-
const callCwd = original?.cwd ?? ctx.cwd;
|
|
1272
|
+
const callCwd = original?.cwd ?? workCwd(ctx.cwd);
|
|
1273
|
+
// File tools are rewritten to absolute worktree paths too. Both the
|
|
1274
|
+
// lexical and resolved working roots must follow entry; keeping the
|
|
1275
|
+
// main checkout's resolved root would still fast-path writes there.
|
|
1276
|
+
const resolvedCallCwd = callCwd === ctx.cwd ? resolvedCwd : resolveForContainment(callCwd);
|
|
1277
|
+
const resolvedSubject =
|
|
1278
|
+
isPathSubjectTool(normalizedTool) && subject
|
|
1279
|
+
? resolveForContainment(toAbsolute(callCwd, subject, os.homedir()))
|
|
1280
|
+
: undefined;
|
|
1281
|
+
const powershellParse = await parsePowerShell(normalizedTool, matchSubject, mode);
|
|
852
1282
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// call is now its last entry — the action under review. For bash, record the
|
|
856
|
-
// model's original command, not the worktree cd-wrapper that actually runs.
|
|
857
|
-
if (mode === "auto") {
|
|
858
|
-
const recordedInput =
|
|
859
|
-
original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>);
|
|
860
|
-
transcript.push({ kind: "tool", tool: normalizedTool, input: recordedInput });
|
|
861
|
-
// Claude Code follows a `git status` call with a ground-truth line the
|
|
862
|
-
// classifier can trust over the model's account of the tree
|
|
863
|
-
// (`{"meta":{"gitStatus":{"clean":…}}}`, captured 2.1.276 — findings §22).
|
|
864
|
-
const recordedCommand = typeof recordedInput.command === "string" ? recordedInput.command : undefined;
|
|
865
|
-
if (isShellTool(normalizedTool) && recordedCommand && isGitStatusCommand(recordedCommand)) {
|
|
866
|
-
const clean = await gitStatusClean(callCwd);
|
|
867
|
-
if (clean !== undefined) transcript.push({ kind: "meta", gitStatus: { clean } });
|
|
868
|
-
}
|
|
869
|
-
capTranscript();
|
|
870
|
-
}
|
|
1283
|
+
currentMainToolCallId = event.toolCallId;
|
|
1284
|
+
inFlightMainCalls.push(event.toolCallId);
|
|
871
1285
|
|
|
872
|
-
const decideWith = (allowRules: PermissionRule[]) =>
|
|
1286
|
+
const decideWith = (allowRules: PermissionRule[], dirs: string[] = workspaceDirs) =>
|
|
873
1287
|
decide({
|
|
874
1288
|
toolName: event.toolName,
|
|
875
1289
|
subject: matchSubject,
|
|
@@ -880,32 +1294,53 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
880
1294
|
allow: allowRules,
|
|
881
1295
|
classifyAllShell: classifyAllShell(),
|
|
882
1296
|
resolvedSubject,
|
|
883
|
-
resolvedCwd,
|
|
1297
|
+
resolvedCwd: resolvedCallCwd,
|
|
884
1298
|
planFilePath,
|
|
885
1299
|
memoryDirPath,
|
|
886
1300
|
scratchpadDirPath,
|
|
887
1301
|
resultsDirPath,
|
|
888
1302
|
sessionDirPath,
|
|
889
1303
|
protectedDirs,
|
|
1304
|
+
secretPaths,
|
|
1305
|
+
workspaceDirs: dirs,
|
|
1306
|
+
claudeCodeFastPaths: usesClaudeCodeFastPaths(ctx.model),
|
|
1307
|
+
blockReadsOutsideWorkingDirectories: blockOutsideReads,
|
|
1308
|
+
readOnlyDirs: docsDirPaths,
|
|
1309
|
+
powershellParse,
|
|
890
1310
|
});
|
|
891
1311
|
let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
|
|
892
1312
|
|
|
893
|
-
// A repo-shipped allow rule would decide this call:
|
|
894
|
-
// the repository's
|
|
895
|
-
// →
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1313
|
+
// A repo-shipped allow rule or workspace directory would decide this call:
|
|
1314
|
+
// ask the user to trust the repository's settings first (once per list;
|
|
1315
|
+
// project-trust.ts). No UI → they stay off and the call takes the normal
|
|
1316
|
+
// path (fail closed).
|
|
1317
|
+
const trustProject = async (withProject: typeof result, firing: TrustFiring) => {
|
|
1318
|
+
const repoDirs = repoWorkspaceDirs().map((dir) => dir.raw);
|
|
1319
|
+
const { title, message } = describeProjectAllow(projectAllowRaw, firing, repoDirs);
|
|
1320
|
+
const approved = (await serializePrompt(() => ctx.ui.confirm(title, message, { signal: ctx.signal }), ctx.signal)) === true;
|
|
1321
|
+
if (ctx.signal?.aborted) return;
|
|
1322
|
+
if (approved) {
|
|
1323
|
+
projectAllowTrusted = true;
|
|
1324
|
+
persistProjectAllowApproval(projectRoot, projectTrustList());
|
|
1325
|
+
refreshWorkspace();
|
|
1326
|
+
result = withProject;
|
|
1327
|
+
} else {
|
|
1328
|
+
projectAllowDeclined = true;
|
|
1329
|
+
ctx.ui.notify("This repository's allow rules and workspace directories stay off for this session (its deny/ask rules still apply).", "info");
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && ctx.hasUI) {
|
|
1333
|
+
const withRules = projectAllow.length > 0 ? decideWith([...allow, ...activeSessionAllows(), ...projectAllow]) : undefined;
|
|
1334
|
+
const repoDirs = repoWorkspaceDirs();
|
|
1335
|
+
if (withRules?.decision === "allow" && withRules.rule && projectAllow.includes(withRules.rule)) {
|
|
1336
|
+
await trustProject(withRules, { rule: withRules.rule.raw });
|
|
1337
|
+
} else if (repoDirs.length > 0) {
|
|
1338
|
+
const withDirs = decideWith([...allow, ...activeSessionAllows()], [...workspaceDirs, ...repoDirs.map((dir) => resolvedOrSelf(dir.path))]);
|
|
1339
|
+
// A read tool's allow is "tier" or "mode"; a shell read's is "read-only" ("plan-readonly" in plan mode).
|
|
1340
|
+
if (withDirs.decision === "allow" && (withDirs.cause === "tier" || withDirs.cause === "mode" || withDirs.cause === "read-only" || withDirs.cause === "plan-readonly")) {
|
|
1341
|
+
const target = resolvedSubject ?? toAbsolute(callCwd, matchSubject, os.homedir());
|
|
1342
|
+
const firing = repoDirs.find((dir) => isWithin(resolvedOrSelf(dir.path), target))?.raw ?? repoDirs[0].raw;
|
|
1343
|
+
await trustProject(withDirs, { dir: firing });
|
|
909
1344
|
}
|
|
910
1345
|
}
|
|
911
1346
|
}
|
|
@@ -924,17 +1359,28 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
924
1359
|
? safetyControlWrite({
|
|
925
1360
|
toolName: normalizeToolName(event.toolName),
|
|
926
1361
|
input: event.input as Record<string, unknown>,
|
|
927
|
-
cwd:
|
|
1362
|
+
cwd: callCwd,
|
|
928
1363
|
home: os.homedir(),
|
|
929
1364
|
oneCodeProjectSettings: oneCodeProjectSettingsFile,
|
|
930
1365
|
})
|
|
931
1366
|
: undefined;
|
|
932
1367
|
|
|
933
|
-
if (result.decision === "allow" &&
|
|
1368
|
+
if (result.decision === "allow" && result.cause === "outside-read") {
|
|
1369
|
+
const refusal = await outsideReadRefusal(ctx, normalizedTool, matchSubject);
|
|
1370
|
+
if (refusal) return { block: true, reason: refusal };
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (result.decision === "allow" && !floorReason) {
|
|
1374
|
+
if (mode === "auto") {
|
|
1375
|
+
if (result.cause === "read-only") logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "pre-gate" });
|
|
1376
|
+
noteAllow();
|
|
1377
|
+
}
|
|
1378
|
+
return undefined;
|
|
1379
|
+
}
|
|
934
1380
|
|
|
935
1381
|
// (dontAsk is the only mode that denies rather than allows unmatched calls.)
|
|
936
1382
|
if (result.decision === "deny") {
|
|
937
|
-
recordRuleDenial(result, event.
|
|
1383
|
+
recordRuleDenial(result, event.toolCallId, matchSubject);
|
|
938
1384
|
return { block: true, reason: denyReason(result) };
|
|
939
1385
|
}
|
|
940
1386
|
|
|
@@ -946,10 +1392,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
946
1392
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
947
1393
|
if (mode === "auto" || !ctx.hasUI) {
|
|
948
1394
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
949
|
-
return { block: true, reason:
|
|
1395
|
+
return { block: true, reason: safetyFloorBlockText(floorReason) };
|
|
950
1396
|
}
|
|
951
1397
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
|
|
952
1398
|
} else if (result.decision === "classify") {
|
|
1399
|
+
// The exact call the user approved in /permissions after the classifier
|
|
1400
|
+
// denied it runs once without the classifier (auto-mode/denials.ts). Deny
|
|
1401
|
+
// rules and the safety floor were checked above, so no grant reaches them.
|
|
1402
|
+
const inputKey = denialInputKey(
|
|
1403
|
+
normalizedTool,
|
|
1404
|
+
original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>),
|
|
1405
|
+
callCwd,
|
|
1406
|
+
isShellTool(normalizedTool),
|
|
1407
|
+
);
|
|
1408
|
+
if (denials.takeGrant(inputKey)) {
|
|
1409
|
+
logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "user", reason: "approved in /permissions" });
|
|
1410
|
+
noteAllow();
|
|
1411
|
+
return undefined;
|
|
1412
|
+
}
|
|
953
1413
|
// Auto mode is for unattended runs: a block is returned to the MODEL so it
|
|
954
1414
|
// can try a safe alternative — it is never raised as a per-action user
|
|
955
1415
|
// prompt. The one exception is the loop-breaker: after repeated blocks the
|
|
@@ -959,7 +1419,6 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
959
1419
|
if (!pauseTracker.isPaused()) {
|
|
960
1420
|
const outcome = await runClassifier(
|
|
961
1421
|
event.toolName,
|
|
962
|
-
event.input as Record<string, unknown>,
|
|
963
1422
|
matchSubject,
|
|
964
1423
|
ctx,
|
|
965
1424
|
// Protected paths must always be judged; everything else may be cleared
|
|
@@ -967,20 +1426,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
967
1426
|
result.cause !== "protected-path",
|
|
968
1427
|
// A worktree-wrapped command is analysed as the model wrote it, in the
|
|
969
1428
|
// worktree it runs in (the classifier transcript already holds it).
|
|
970
|
-
|
|
1429
|
+
{
|
|
1430
|
+
cwd: callCwd, powershellParse, toolCallId: event.toolCallId,
|
|
1431
|
+
appendEntry: { kind: "tool", tool: normalizedTool, input: original !== undefined ? { command: original.command } : event.input as Record<string, unknown> },
|
|
1432
|
+
},
|
|
971
1433
|
);
|
|
972
1434
|
if (outcome.decision === "allow") {
|
|
973
|
-
|
|
1435
|
+
noteAllow();
|
|
1436
|
+
denials.settle(inputKey);
|
|
974
1437
|
return undefined;
|
|
975
1438
|
}
|
|
976
1439
|
|
|
1440
|
+
if (outcome.transcriptTooLong) return transcriptOverflow(ctx, event.toolName, matchSubject);
|
|
1441
|
+
|
|
977
1442
|
// A timeout was not earned by looping against the gate, so it does not
|
|
978
1443
|
// count toward the auto-pause. It is returned to the model as retryable
|
|
979
1444
|
// (the call was never judged) rather than prompting — a prompt would
|
|
980
1445
|
// hang an unattended session waiting on a classifier that was slow.
|
|
981
|
-
if (outcome.tier === "timeout") {
|
|
982
|
-
return { block: true, reason: BLOCKED_BY_TIMEOUT(outcome.reason) };
|
|
983
|
-
}
|
|
1446
|
+
if (outcome.tier === "timeout") return { block: true, reason: classifierBlockReason(outcome) };
|
|
984
1447
|
|
|
985
1448
|
const tripped = pauseTracker.recordBlock({
|
|
986
1449
|
toolName: event.toolName,
|
|
@@ -990,6 +1453,19 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
990
1453
|
ruleId: outcome.ruleId,
|
|
991
1454
|
raw: outcome.raw,
|
|
992
1455
|
});
|
|
1456
|
+
// A verdict, not a failure to reach one, can be approved in /permissions.
|
|
1457
|
+
if (!outcome.noVerdict) {
|
|
1458
|
+
denials.record({
|
|
1459
|
+
toolName: normalizedTool,
|
|
1460
|
+
display: `${event.toolName}(${previewSubject(matchSubject)})`,
|
|
1461
|
+
fullDisplay: `${event.toolName}(${matchSubject})`,
|
|
1462
|
+
inputKey,
|
|
1463
|
+
reason: outcome.reason,
|
|
1464
|
+
...(outcome.ruleId ? { rule: outcome.ruleId } : {}),
|
|
1465
|
+
timestamp: Date.now(),
|
|
1466
|
+
});
|
|
1467
|
+
if (ctx.hasUI) ctx.ui.notify(deniedNotice(event.toolName, outcome.reason), "warning");
|
|
1468
|
+
}
|
|
993
1469
|
if (tripped) {
|
|
994
1470
|
const { lifetime } = pauseTracker.stats();
|
|
995
1471
|
ctx.ui.notify(
|
|
@@ -1000,8 +1476,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1000
1476
|
}
|
|
1001
1477
|
// Every classifier block (hard or soft) goes back to the model with the
|
|
1002
1478
|
// classifier's own reason, so it can find a safe alternative. Auto mode
|
|
1003
|
-
// does not hand a soft block to the user as a per-action prompt.
|
|
1004
|
-
|
|
1479
|
+
// does not hand a soft block to the user as a per-action prompt. A call
|
|
1480
|
+
// the classifier never judged is said to be one, not a denial.
|
|
1481
|
+
return { block: true, reason: classifierBlockReason(outcome) };
|
|
1005
1482
|
}
|
|
1006
1483
|
// Paused: fall through to the resume prompt below.
|
|
1007
1484
|
}
|
|
@@ -1027,7 +1504,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1027
1504
|
floor: floorReason !== undefined,
|
|
1028
1505
|
home: os.homedir(),
|
|
1029
1506
|
});
|
|
1030
|
-
const choice = await
|
|
1507
|
+
const { choice, unanswered } = await askMaybeTimed(pausedResume, title, (show) => serializePrompt(show, ctx.signal), (text, timeout) =>
|
|
1508
|
+
ctx.ui.select(text, askOptions(grant), { signal: ctx.signal, timeout }),
|
|
1509
|
+
);
|
|
1510
|
+
if (ctx.signal?.aborted) return { block: true, reason: "The turn was stopped while waiting for the user's approval." };
|
|
1511
|
+
if (unanswered) {
|
|
1512
|
+
logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
|
|
1513
|
+
return { block: true, reason: DENIED_UNANSWERED_RESUME };
|
|
1514
|
+
}
|
|
1031
1515
|
|
|
1032
1516
|
// The user's answer is itself a gate decision worth recording — it is the
|
|
1033
1517
|
// ground truth a drifting classifier gets calibrated against.
|
|
@@ -1055,12 +1539,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1055
1539
|
}
|
|
1056
1540
|
|
|
1057
1541
|
// The "tell the agent what to do differently" option has to actually carry
|
|
1058
|
-
// the user's words, or the model is left guessing why it was stopped.
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1542
|
+
// the user's words, or the model is left guessing why it was stopped. Esc
|
|
1543
|
+
// on the prompt (no choice) rejects without asking.
|
|
1544
|
+
const feedback =
|
|
1545
|
+
choice === undefined
|
|
1546
|
+
? undefined
|
|
1547
|
+
: await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: ctx.signal }), ctx.signal);
|
|
1548
|
+
// Claude Code's cancelAndAbort: a rejection without the user's words stops
|
|
1549
|
+
// the turn and waits for them; with words, the model acts on them. pi reads
|
|
1550
|
+
// an abort before the block (the result would say "Operation aborted"), so
|
|
1551
|
+
// the abort waits for the recorded result; the run then ends as an abort,
|
|
1552
|
+
// which every reader of the run's outcome treats as the user's interrupt.
|
|
1553
|
+
// `terminate` ends pi's loop after the batch: otherwise it sent one more
|
|
1554
|
+
// request on the aborted signal, which came back as an error and printed
|
|
1555
|
+
// "Error: This operation was aborted" above the Interrupted line.
|
|
1556
|
+
const stop = !feedback?.trim();
|
|
1557
|
+
if (stop) abortAfterResult.add(event.toolCallId);
|
|
1558
|
+
return { block: true, reason: userDenialText(feedback), terminate: stop };
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
1562
|
+
if (abortAfterResult.delete(event.toolCallId)) ctx.abort();
|
|
1064
1563
|
});
|
|
1065
1564
|
|
|
1066
1565
|
/**
|
|
@@ -1081,11 +1580,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1081
1580
|
const ctx = lastReviewCtx;
|
|
1082
1581
|
const { toolName, input, cwd } = call;
|
|
1083
1582
|
const normalizedTool = normalizeToolName(toolName);
|
|
1583
|
+
const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(input) : undefined;
|
|
1584
|
+
if (conflict) return { block: true, reason: conflict };
|
|
1084
1585
|
const subject = extractSubject(normalizedTool, input);
|
|
1085
1586
|
const resolvedSubject =
|
|
1086
1587
|
isPathSubjectTool(normalizedTool) && subject
|
|
1087
1588
|
? resolveForContainment(toAbsolute(cwd, subject, os.homedir()))
|
|
1088
1589
|
: undefined;
|
|
1590
|
+
const powershellParse = await parsePowerShell(normalizedTool, subject, mode);
|
|
1089
1591
|
|
|
1090
1592
|
const result = decide({
|
|
1091
1593
|
toolName,
|
|
@@ -1108,6 +1610,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1108
1610
|
resultsDirPath,
|
|
1109
1611
|
sessionDirPath,
|
|
1110
1612
|
protectedDirs,
|
|
1613
|
+
secretPaths,
|
|
1614
|
+
workspaceDirs,
|
|
1615
|
+
// The child's own model: a cheaper subagent gets the stricter gate.
|
|
1616
|
+
claudeCodeFastPaths: usesClaudeCodeFastPaths(call.model),
|
|
1617
|
+
blockReadsOutsideWorkingDirectories: blockOutsideReads,
|
|
1618
|
+
readOnlyDirs: docsDirPaths,
|
|
1619
|
+
powershellParse,
|
|
1111
1620
|
});
|
|
1112
1621
|
|
|
1113
1622
|
// A child's cwd can be a worktree (different project → different per-repo
|
|
@@ -1120,11 +1629,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1120
1629
|
? safetyControlWrite({ toolName: normalizedTool, input, cwd, home: os.homedir() })
|
|
1121
1630
|
: undefined;
|
|
1122
1631
|
|
|
1632
|
+
// A child's first outside read raises the prompt on the parent's terminal;
|
|
1633
|
+
// with no parent context there is no one to ask, as in a one-shot run.
|
|
1634
|
+
if (result.decision === "allow" && result.cause === "outside-read" && ctx) {
|
|
1635
|
+
const refusal = await outsideReadRefusal(ctx, normalizedTool, subject, call.signal);
|
|
1636
|
+
if (refusal) return { block: true, reason: refusal };
|
|
1637
|
+
}
|
|
1123
1638
|
if (result.decision === "allow" && !floorReason) return undefined;
|
|
1124
1639
|
|
|
1125
1640
|
if (result.decision === "deny") {
|
|
1126
1641
|
// A child's rule denial must NOT be recorded here: the child path never
|
|
1127
|
-
// mutates the parent's live transcript (docs/decisions/subagents-workflows.md
|
|
1642
|
+
// mutates the parent's live transcript (working-docs/decisions/subagents-workflows.md
|
|
1128
1643
|
// — child calls stay out of it). Pushing a `denied` entry would leak the
|
|
1129
1644
|
// child's action into the parent and flip `afterRuleDenial` on for every
|
|
1130
1645
|
// later MAIN classification. The child's classifier already learns the
|
|
@@ -1146,17 +1661,20 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1146
1661
|
tool: normalizedTool,
|
|
1147
1662
|
input: isShellTool(normalizedTool) ? { command: subject } : input,
|
|
1148
1663
|
};
|
|
1149
|
-
const outcome = await runClassifier(toolName,
|
|
1664
|
+
const outcome = await runClassifier(toolName, subject, ctx, result.cause !== "protected-path", {
|
|
1150
1665
|
cwd,
|
|
1151
1666
|
appendEntry,
|
|
1667
|
+
// The main session as far as the call that started this child's turn.
|
|
1668
|
+
history: childCursor(call.parentToolCallId),
|
|
1152
1669
|
// The child's own turn signal, so an aborted child turn cancels the
|
|
1153
1670
|
// classifier call; a fresh (never-aborted) signal only if the child
|
|
1154
1671
|
// didn't supply one, so classify() still gets the signal it expects.
|
|
1155
1672
|
signal: call.signal ?? new AbortController().signal,
|
|
1673
|
+
powershellParse,
|
|
1156
1674
|
});
|
|
1157
1675
|
if (outcome.decision === "allow") return undefined;
|
|
1158
|
-
if (outcome.
|
|
1159
|
-
return { block: true, reason:
|
|
1676
|
+
if (outcome.transcriptTooLong) return transcriptOverflow(ctx, toolName, subject, call.signal);
|
|
1677
|
+
return { block: true, reason: classifierBlockReason(outcome) };
|
|
1160
1678
|
}
|
|
1161
1679
|
|
|
1162
1680
|
// Safety floor: a write to the gate's own config is NEVER auto-approved and
|
|
@@ -1166,7 +1684,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1166
1684
|
// control the classifier itself can't be trusted to enforce.
|
|
1167
1685
|
if (floorReason) {
|
|
1168
1686
|
if (ctx) logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
1169
|
-
return { block: true, reason:
|
|
1687
|
+
return { block: true, reason: safetyFloorBlockText(floorReason) };
|
|
1170
1688
|
}
|
|
1171
1689
|
|
|
1172
1690
|
// ask: needs the user. Bubble it to the parent's terminal; no UI → fail closed.
|
|
@@ -1184,10 +1702,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1184
1702
|
// its turn in the chain comes is skipped outright. Otherwise a dead agent's
|
|
1185
1703
|
// dialog stays on screen and every later prompt waits behind it (M5).
|
|
1186
1704
|
const childPrompt = <T>(show: () => Promise<T>): Promise<T | undefined> =>
|
|
1187
|
-
serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()));
|
|
1705
|
+
serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()), call.signal);
|
|
1188
1706
|
const grant = sessionGrant({ toolName: normalizedTool, subject, cwd, mode, cause: result.cause, home: os.homedir() });
|
|
1189
|
-
const choice = await
|
|
1707
|
+
const { choice, unanswered } = await askMaybeTimed(pausedResume, title, childPrompt, (text, timeout) =>
|
|
1708
|
+
ctx.ui.select(text, askOptions(grant), { signal: call.signal, timeout }),
|
|
1709
|
+
);
|
|
1190
1710
|
if (call.signal?.aborted) return { block: true, reason: "The agent was stopped while waiting for the user's approval." };
|
|
1711
|
+
if (unanswered) {
|
|
1712
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
|
|
1713
|
+
return { block: true, reason: DENIED_UNANSWERED_RESUME };
|
|
1714
|
+
}
|
|
1191
1715
|
|
|
1192
1716
|
if (pausedResume) {
|
|
1193
1717
|
logDecision(ctx, {
|
|
@@ -1212,46 +1736,424 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1212
1736
|
const feedback = await childPrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: call.signal }));
|
|
1213
1737
|
return {
|
|
1214
1738
|
block: true,
|
|
1215
|
-
reason:
|
|
1739
|
+
reason: userDenialText(feedback),
|
|
1216
1740
|
};
|
|
1217
1741
|
};
|
|
1218
1742
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1743
|
+
/** Where a rule came from, and why the panel cannot edit it when it cannot. */
|
|
1744
|
+
const describeSource = (rule: SourcedRule, home: string): { label: string; editable: boolean; note?: string } => {
|
|
1745
|
+
const where = tildify(rule.path, home);
|
|
1746
|
+
const readOnly = (label: string, note: string) => ({ label: `From ${label}`, editable: false, note });
|
|
1747
|
+
switch (rule.source) {
|
|
1748
|
+
case "onecode-user":
|
|
1749
|
+
return { label: `From One Code user settings (${where})`, editable: true };
|
|
1750
|
+
case "onecode-project":
|
|
1751
|
+
return { label: `From One Code project settings (${where})`, editable: true };
|
|
1752
|
+
case "claude-user":
|
|
1753
|
+
return readOnly(`Claude Code user settings (${where})`, `One Code does not edit Claude Code's files. Change this rule in ${where}.`);
|
|
1754
|
+
case "project":
|
|
1755
|
+
case "project-local": {
|
|
1756
|
+
const file = rule.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
|
|
1757
|
+
const consent =
|
|
1758
|
+
rule.behavior !== "allow" ? "" : projectAllowTrusted ? " You trusted this repository's allow rules." : projectAllowDeclined ? " Its allow rules are off this session." : " Its allow rules apply only after you trust them.";
|
|
1759
|
+
return readOnly(`the repository's ${file}`, `This rule ships with the repository. Change it in ${file}.${consent}`);
|
|
1760
|
+
}
|
|
1761
|
+
case "managed":
|
|
1762
|
+
return readOnly("managed settings", "This rule is configured by managed settings and cannot be modified. Contact your system administrator for more information.");
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
const ruleRows = (cwd: string, home: string): Record<RuleTab, RuleRow[]> => {
|
|
1767
|
+
const rows: Record<RuleTab, RuleRow[]> = { allow: [], ask: [], deny: [] };
|
|
1768
|
+
for (const rule of listPermissionRules(cwd, home)) {
|
|
1769
|
+
const source = describeSource(rule, home);
|
|
1770
|
+
rows[rule.behavior].push({
|
|
1771
|
+
key: `${rule.source}\0${rule.path}\0${rule.raw}`,
|
|
1772
|
+
behavior: rule.behavior,
|
|
1773
|
+
raw: rule.raw,
|
|
1774
|
+
sourceLabel: source.label,
|
|
1775
|
+
editable: source.editable,
|
|
1776
|
+
...(source.note ? { readOnlyNote: source.note } : {}),
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
// "Don't ask again" grants live in memory for this session; deleting one ends it.
|
|
1780
|
+
for (const grant of sessionAllows) {
|
|
1781
|
+
rows.allow.push({ key: `session\0\0${grant.raw}`, behavior: "allow", raw: grant.raw, sourceLabel: "From this session (don't ask again)", editable: true });
|
|
1782
|
+
}
|
|
1783
|
+
for (const tab of RULE_TABS) rows[tab].sort((a, b) => a.raw.toLowerCase().localeCompare(b.raw.toLowerCase()));
|
|
1784
|
+
return rows;
|
|
1785
|
+
};
|
|
1786
|
+
|
|
1787
|
+
const ruleDestinations = (cwd: string, home: string): Destination[] => [
|
|
1788
|
+
{ id: "onecode-project", label: "One Code project settings", description: `Saved in ${tildify(oneCodeProjectSettingsPath(cwd, home), home)}, for this project only` },
|
|
1789
|
+
{ id: "onecode-user", label: "One Code user settings", description: `Saved in ${tildify(oneCodeSettingsPath(home), home)}, for every project` },
|
|
1790
|
+
];
|
|
1791
|
+
|
|
1792
|
+
/** The status line under the panel's tabs. */
|
|
1793
|
+
const panelStatus = (): string => {
|
|
1794
|
+
const parts = [`Mode: ${mode}`];
|
|
1795
|
+
if (mode === "auto" || pauseTracker.stats().lifetime > 0) {
|
|
1796
|
+
parts.push(`${pauseTracker.stats().lifetime} blocked by auto mode this session`);
|
|
1797
|
+
if (pauseTracker.isPaused()) parts.push("auto mode paused (approve a prompt to resume)");
|
|
1798
|
+
}
|
|
1799
|
+
if (unparsableRules.length > 0) parts.push(`${unparsableRules.length} rule(s) could not be parsed and are ignored`);
|
|
1800
|
+
return parts.join(" · ");
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
/** The text summary /permissions prints where no panel can open. */
|
|
1804
|
+
const permissionsSummary = (): string => {
|
|
1805
|
+
const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
|
|
1806
|
+
return [
|
|
1807
|
+
`mode: ${mode}`,
|
|
1808
|
+
`deny: ${fmt(deny)}`,
|
|
1809
|
+
`ask: ${fmt(ask)}`,
|
|
1810
|
+
`allow: ${fmt(allow)}`,
|
|
1811
|
+
...(projectAllow.length > 0
|
|
1812
|
+
? [`project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`]
|
|
1813
|
+
: []),
|
|
1814
|
+
`session allows: ${fmt(sessionAllows)}`,
|
|
1815
|
+
...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
|
|
1816
|
+
...(denials.list().length > 0 ? ["recently denied:", ...denials.list().map((d) => ` ${d.display} — ${d.reason}`)] : []),
|
|
1817
|
+
].join("\n");
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
/** Built-in rules per auto-mode section, counted once from the embedded ruleset. */
|
|
1821
|
+
let builtinCounts: ReturnType<typeof builtinRuleCounts> | undefined;
|
|
1822
|
+
|
|
1823
|
+
const autoSource = (entry: SourcedAutoModeEntry, home: string): { label: string; editable: boolean; note?: string } => {
|
|
1824
|
+
const where = tildify(entry.path, home);
|
|
1825
|
+
if (entry.source === "onecode-user") return { label: `From One Code user settings (${where})`, editable: true };
|
|
1826
|
+
if (entry.source === "claude-user") {
|
|
1827
|
+
return { label: `From Claude Code user settings (${where})`, editable: false, note: `One Code does not edit Claude Code's files. Change this entry in ${where}; it stays in effect here.` };
|
|
1828
|
+
}
|
|
1829
|
+
return { label: "From managed settings", editable: false, note: "This entry is configured by managed settings and cannot be modified. Contact your system administrator for more information." };
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
const autoModeView = (home: string): AutoModeView => {
|
|
1833
|
+
const listed = listAutoModeEntries(home);
|
|
1834
|
+
const entries: AutoEntryRow[] = [];
|
|
1835
|
+
for (const section of AUTO_SECTIONS) {
|
|
1836
|
+
for (const entry of listed.filter((e) => e.key === section)) {
|
|
1837
|
+
const source = autoSource(entry, home);
|
|
1838
|
+
entries.push({
|
|
1839
|
+
key: `${entry.source}\0${entry.path}\0${section}\0${entry.text}`,
|
|
1840
|
+
section,
|
|
1841
|
+
text: entry.text,
|
|
1842
|
+
sourceLabel: source.label,
|
|
1843
|
+
editable: source.editable,
|
|
1844
|
+
...(source.note ? { readOnlyNote: source.note } : {}),
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
const environment = loadAutoModeConfig(home).environment;
|
|
1849
|
+
const envSources = [...new Set(listed.filter((e) => e.key === "environment").map((e) => autoSource(e, home).label.replace(/^From /, "").replace(/ \(.*\)$/, "")))];
|
|
1850
|
+
const isDefault = envSources.length === 0;
|
|
1851
|
+
const extendsDefault = !isDefault && DEFAULT_ENVIRONMENT.every((line) => environment.includes(line));
|
|
1852
|
+
builtinCounts ??= builtinRuleCounts(buildRuleset(DEFAULT_ENVIRONMENT));
|
|
1853
|
+
return {
|
|
1854
|
+
builtins: builtinCounts,
|
|
1855
|
+
entries,
|
|
1856
|
+
environment: {
|
|
1857
|
+
lines: environment,
|
|
1858
|
+
summary: isDefault ? "Built-in default" : `${extendsDefault ? "Extends" : "Replaces"} the built-in default · from ${envSources.join(" and ")}`,
|
|
1859
|
+
isDefault,
|
|
1860
|
+
},
|
|
1861
|
+
};
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
/** A panel row key for an auto-mode entry: source, file, section, text. */
|
|
1865
|
+
const parseAutoKey = (key: string) => {
|
|
1866
|
+
const [source, path, section, text] = key.split("\0");
|
|
1867
|
+
if (source !== "onecode-user") throw new Error(`One Code can only change auto mode rules in its own settings file; this one is in ${tildify(path, os.homedir())}.`);
|
|
1868
|
+
return { section: section as (typeof AUTO_SECTIONS)[number], text };
|
|
1869
|
+
};
|
|
1870
|
+
const autoLabel = (section: (typeof AUTO_SECTIONS)[number]) => AUTO_SECTION_LABELS[section].toLowerCase();
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Edit One Code's own `autoMode.environment` in pi's editor. It starts from
|
|
1874
|
+
* One Code's entries, or from the built-in default when nothing else sets
|
|
1875
|
+
* one (Claude Code starts from the full default text too). Saving it empty
|
|
1876
|
+
* removes the key, which restores the default. Returns the change line.
|
|
1877
|
+
*/
|
|
1878
|
+
const editEnvironment = async (ctx: ExtensionContext, home: string): Promise<string | undefined> => {
|
|
1879
|
+
const listed = listAutoModeEntries(home).filter((e) => e.key === "environment");
|
|
1880
|
+
const own = listed.filter((e) => e.source === "onecode-user").map((e) => e.text);
|
|
1881
|
+
const others = listed.length - own.length;
|
|
1882
|
+
// `"$defaults"` in the list: the user's lines extend the built-in default,
|
|
1883
|
+
// which stays out of the editor and is kept on save.
|
|
1884
|
+
const extendsDefault = oneCodeEnvironmentExtendsDefault(home);
|
|
1885
|
+
const start = own.length > 0 ? own : others > 0 || extendsDefault ? [] : DEFAULT_ENVIRONMENT;
|
|
1886
|
+
const title =
|
|
1887
|
+
"Auto mode environment: one entry per line, `### ` lines are section headers. Save it empty to restore the built-in default." +
|
|
1888
|
+
(extendsDefault ? " These lines are added to the built-in default." : "") +
|
|
1889
|
+
(others > 0 ? ` These lines are added to the ${others} entries from Claude Code's user or managed settings.` : "");
|
|
1890
|
+
const edited = await ctx.ui.editor(title, start.join("\n"));
|
|
1891
|
+
if (edited === undefined) return undefined;
|
|
1892
|
+
const lines = edited.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
|
|
1893
|
+
if (lines.join("\n") === start.join("\n")) return undefined;
|
|
1894
|
+
updateOneCodeAutoModeList(
|
|
1895
|
+
"environment",
|
|
1896
|
+
(entries) => {
|
|
1897
|
+
const at = entries.findIndex((entry) => entry.trim() === "$defaults");
|
|
1898
|
+
if (at < 0 || lines.length === 0) return lines;
|
|
1899
|
+
return [...lines.slice(0, at), entries[at], ...lines.slice(at)];
|
|
1900
|
+
},
|
|
1901
|
+
home,
|
|
1902
|
+
);
|
|
1903
|
+
autoConfig = undefined;
|
|
1904
|
+
return lines.length > 0 ? `Saved your auto mode environment to ${tildify(oneCodeSettingsPath(home), home)}` : "Restored the built-in auto mode environment";
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
/** Where a workspace directory came from, and why the panel cannot remove it when it cannot. */
|
|
1908
|
+
const workspaceRows = (home: string): WorkspaceDirRow[] => {
|
|
1909
|
+
const rows: WorkspaceDirRow[] = [];
|
|
1910
|
+
for (const dir of settingsWorkspaceDirs) {
|
|
1911
|
+
const where = tildify(dir.settingsPath, home);
|
|
1912
|
+
const key = `${dir.source}\0${dir.settingsPath}\0${dir.raw}`;
|
|
1913
|
+
if (dir.source === "onecode-user" || dir.source === "onecode-project") {
|
|
1914
|
+
rows.push({ key, path: dir.path, sourceLabel: `From One Code ${dir.source === "onecode-user" ? "user" : "project"} settings (${where})`, editable: true });
|
|
1915
|
+
} else if (isRepoSource(dir.source)) {
|
|
1916
|
+
const file = dir.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
|
|
1917
|
+
const state = projectAllowTrusted ? "" : projectAllowDeclined ? ", off this session" : ", awaiting your trust";
|
|
1918
|
+
rows.push({
|
|
1919
|
+
key,
|
|
1920
|
+
path: dir.path,
|
|
1921
|
+
sourceLabel: `From the repository's ${file}${state}`,
|
|
1922
|
+
editable: false,
|
|
1923
|
+
readOnlyNote: `This directory ships with the repository. Change it in ${file}.${projectAllowTrusted ? "" : " It applies only after you trust the repository's settings, which you are asked the first time a call needs it."}`,
|
|
1924
|
+
});
|
|
1925
|
+
} else {
|
|
1926
|
+
const label = dir.source === "claude-user" ? `Claude Code user settings (${where})` : "managed settings";
|
|
1927
|
+
rows.push({ key, path: dir.path, sourceLabel: `From ${label}`, editable: false, readOnlyNote: dir.source === "claude-user" ? `One Code does not edit Claude Code's files. Change it in ${where}.` : "This directory is configured by managed settings and cannot be modified here." });
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
for (const path of flagWorkspaceDirs) rows.push({ key: `flag\0\0${path}`, path, sourceLabel: "From --add-dir", editable: false, readOnlyNote: "Added with --add-dir for this run. Start One Code without it to leave it out." });
|
|
1931
|
+
for (const path of sessionWorkspaceDirs) rows.push({ key: `session\0\0${path}`, path, sourceLabel: "Added for this session", editable: true });
|
|
1932
|
+
return rows;
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
/** Add a validated directory for this session, or remember it in One Code's project settings. Returns the change line. */
|
|
1936
|
+
const addWorkspaceDirectory = (ctx: ExtensionContext, path: string, remember: boolean): string => {
|
|
1937
|
+
const home = os.homedir();
|
|
1938
|
+
if (remember) {
|
|
1939
|
+
const target = oneCodeProjectSettingsPath(ctx.cwd, home);
|
|
1940
|
+
persistWorkspaceDirectory(path, target);
|
|
1941
|
+
reloadRules(ctx);
|
|
1942
|
+
return `Added directory ${path} to workspace and saved to ${tildify(target, home)}`;
|
|
1943
|
+
}
|
|
1944
|
+
if (!sessionWorkspaceDirs.includes(path)) sessionWorkspaceDirs.push(path);
|
|
1945
|
+
refreshWorkspace();
|
|
1946
|
+
return `Added directory ${path} to workspace for this session`;
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
/** The panel's I/O over the gate's state (panel/host.ts). */
|
|
1950
|
+
const panelHost = (ctx: ExtensionContext, home: string): PermissionsPanelHost => {
|
|
1951
|
+
return {
|
|
1952
|
+
view: () => ({
|
|
1953
|
+
denials: denials.list().map((d) => ({ id: d.id, display: d.display, ...(d.rule ? { rule: d.rule } : {}) })),
|
|
1954
|
+
rules: ruleRows(ctx.cwd, home),
|
|
1955
|
+
autoMode: autoModeView(home),
|
|
1956
|
+
destinations: ruleDestinations(ctx.cwd, home),
|
|
1957
|
+
ruleError: (raw) =>
|
|
1958
|
+
parseRule(raw) ? undefined : `Could not parse "${raw}". A rule is a tool name, optionally followed by a pattern in parentheses: Bash(npm test:*).`,
|
|
1959
|
+
workspace: { cwd: ctx.cwd, dirs: workspaceRows(home) },
|
|
1960
|
+
validateDir: (input) => validateWorkspaceDirectory(input, ctx.cwd, home, workspacePaths),
|
|
1961
|
+
}),
|
|
1962
|
+
status: panelStatus,
|
|
1963
|
+
addRule: (behavior, rule, destination) => {
|
|
1964
|
+
const target = destination === "onecode-user" ? oneCodeSettingsPath(home) : oneCodeProjectSettingsPath(ctx.cwd, home);
|
|
1965
|
+
if (!persistPermissionRule(behavior, rule, target)) throw new Error(`That ${behavior} rule is already in ${tildify(target, home)}.`);
|
|
1966
|
+
reloadRules(ctx);
|
|
1967
|
+
return `Added ${behavior} rule ${rule} to ${tildify(target, home)}`;
|
|
1968
|
+
},
|
|
1969
|
+
deleteRule: (behavior, key) => {
|
|
1970
|
+
const [source, path, raw] = key.split("\0");
|
|
1971
|
+
if (source === "session") {
|
|
1972
|
+
const index = sessionAllows.findIndex((grant) => grant.raw === raw);
|
|
1973
|
+
if (index >= 0) sessionAllows.splice(index, 1);
|
|
1974
|
+
} else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
|
|
1975
|
+
if (!removePermissionRule(behavior, raw, path)) throw new Error(`${raw} is no longer in ${tildify(path, home)}.`);
|
|
1976
|
+
reloadRules(ctx);
|
|
1977
|
+
} else {
|
|
1978
|
+
throw new Error(`One Code can only delete rules from its own settings files; ${raw} is in ${tildify(path, home)}.`);
|
|
1979
|
+
}
|
|
1980
|
+
return `Deleted ${behavior} rule ${raw}`;
|
|
1981
|
+
},
|
|
1982
|
+
// Auto mode rules go to One Code's user settings: autoMode is never read
|
|
1983
|
+
// from project settings (decisions/modes.md). The cached config is dropped
|
|
1984
|
+
// so the next classifier call reads the change.
|
|
1985
|
+
addAutoRule: (section, text) => {
|
|
1986
|
+
updateOneCodeAutoModeList(
|
|
1987
|
+
section,
|
|
1988
|
+
(entries) => {
|
|
1989
|
+
if (entries.includes(text)) throw new Error(`That ${autoLabel(section)} rule is already in your settings.`);
|
|
1990
|
+
return [...entries, text];
|
|
1991
|
+
},
|
|
1992
|
+
home,
|
|
1993
|
+
);
|
|
1994
|
+
autoConfig = undefined;
|
|
1995
|
+
return `Added auto mode ${autoLabel(section)} rule: ${text}`;
|
|
1996
|
+
},
|
|
1997
|
+
editAutoRule: (key, text) => {
|
|
1998
|
+
const { section, text: old } = parseAutoKey(key);
|
|
1999
|
+
updateOneCodeAutoModeList(
|
|
2000
|
+
section,
|
|
2001
|
+
(entries) => {
|
|
2002
|
+
const index = entries.indexOf(old);
|
|
2003
|
+
if (index < 0) throw new Error("That rule is no longer in your settings.");
|
|
2004
|
+
return entries.map((entry, i) => (i === index ? text : entry));
|
|
2005
|
+
},
|
|
2006
|
+
home,
|
|
2007
|
+
);
|
|
2008
|
+
autoConfig = undefined;
|
|
2009
|
+
return `Updated auto mode ${autoLabel(section)} rule: ${text}`;
|
|
2010
|
+
},
|
|
2011
|
+
deleteAutoRule: (key) => {
|
|
2012
|
+
const { section, text } = parseAutoKey(key);
|
|
2013
|
+
updateOneCodeAutoModeList(
|
|
2014
|
+
section,
|
|
2015
|
+
(entries) => {
|
|
2016
|
+
if (!entries.includes(text)) throw new Error("That rule is no longer in your settings.");
|
|
2017
|
+
return entries.filter((entry) => entry !== text);
|
|
2018
|
+
},
|
|
2019
|
+
home,
|
|
2020
|
+
);
|
|
2021
|
+
autoConfig = undefined;
|
|
2022
|
+
return `Deleted auto mode ${autoLabel(section)} rule: ${text}`;
|
|
2023
|
+
},
|
|
2024
|
+
addDir: (path, remember) => addWorkspaceDirectory(ctx, path, remember),
|
|
2025
|
+
removeDir: (key) => {
|
|
2026
|
+
const [source, settingsPath, raw] = key.split("\0");
|
|
2027
|
+
if (source === "session") {
|
|
2028
|
+
const index = sessionWorkspaceDirs.indexOf(raw);
|
|
2029
|
+
if (index >= 0) sessionWorkspaceDirs.splice(index, 1);
|
|
2030
|
+
refreshWorkspace();
|
|
2031
|
+
} else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
|
|
2032
|
+
if (!removeWorkspaceDirectory(raw, settingsPath)) throw new Error(`${raw} is no longer in ${tildify(settingsPath, home)}.`);
|
|
2033
|
+
reloadRules(ctx);
|
|
2034
|
+
} else {
|
|
2035
|
+
throw new Error(`One Code can only remove directories it added; ${raw} comes from elsewhere.`);
|
|
2036
|
+
}
|
|
2037
|
+
return `Removed directory ${raw} from workspace`;
|
|
2038
|
+
},
|
|
2039
|
+
};
|
|
2040
|
+
};
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Open the panel, run the environment editor round trips, then tell the
|
|
2044
|
+
* model what happened: approvals mint their grants here, once, as in Claude
|
|
2045
|
+
* Code (a row toggled on and off again grants nothing). `command` names the
|
|
2046
|
+
* breadcrumb (`permissions`, `add-dir`).
|
|
2047
|
+
*/
|
|
2048
|
+
/** `dismissed`: the command's stdout when the panel closes with nothing changed. */
|
|
2049
|
+
const runPermissionsPanel = async (ctx: ExtensionContext, command: string, args: string, prepare?: (state: PanelState) => void, dismissed?: string) => {
|
|
2050
|
+
const home = os.homedir();
|
|
2051
|
+
const host = panelHost(ctx, home);
|
|
2052
|
+
// The environment editor cannot open over the panel: close, edit, reopen.
|
|
2053
|
+
let session = await openPermissionsPanel(ctx, pi.events, host, undefined, prepare);
|
|
2054
|
+
while (session.editEnvironment) {
|
|
2055
|
+
session.editEnvironment = false;
|
|
2056
|
+
try {
|
|
2057
|
+
const change = await editEnvironment(ctx, home);
|
|
2058
|
+
if (change) session.changes.push(change);
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
session.state.notice = `Could not save the environment: ${(error as Error).message}`;
|
|
2061
|
+
}
|
|
2062
|
+
session = await openPermissionsPanel(ctx, pi.events, host, session);
|
|
2063
|
+
}
|
|
2064
|
+
const { state, changes } = session;
|
|
2065
|
+
|
|
2066
|
+
const approved = denials.approve(state.approved);
|
|
2067
|
+
const retried = approved.filter((d) => state.retry.has(d.id));
|
|
2068
|
+
// Model-facing: never the panel's clipped preview.
|
|
2069
|
+
const displays = approved.map((d) => d.fullDisplay);
|
|
2070
|
+
if (retried.length > 0) {
|
|
2071
|
+
// Claude Code's retry: a banner says what was allowed, and a turn starts
|
|
2072
|
+
// with the grant message.
|
|
2073
|
+
announceLocalCommand(pi, { name: command, args, stdout: changes.join("\n") });
|
|
2074
|
+
ctx.ui.notify(`Allowed ${retried.map((d) => d.display).join(", ")}`, "info");
|
|
2075
|
+
pi.sendMessage(
|
|
2076
|
+
{ customType: PERMISSION_RETRY_TYPE, content: permissionGrantedMessage(displays), display: false },
|
|
2077
|
+
ctx.isIdle() ? { triggerTurn: true } : { deliverAs: "followUp", triggerTurn: true },
|
|
1254
2078
|
);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (approved.length === 0 && changes.length === 0) {
|
|
2082
|
+
if (dismissed) announceLocalCommand(pi, { name: command, args, stdout: dismissed });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const stdout = [...(approved.length > 0 ? [`Approved ${displays.join(", ")}`] : []), ...changes].join("\n");
|
|
2086
|
+
announceLocalCommand(pi, { name: command, args, stdout });
|
|
2087
|
+
// The grant message rides with the breadcrumb on the next prompt; no turn starts.
|
|
2088
|
+
if (approved.length > 0) pi.events.emit(REMINDER_CHANNEL, { text: `${permissionGrantedMessage(displays)}\n`, placement: "user-prepend", raw: true });
|
|
2089
|
+
};
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* /permissions — Claude Code's panel (findings §33): approve or retry calls
|
|
2093
|
+
* the classifier denied, list, add or delete permission rules, manage auto
|
|
2094
|
+
* mode's own rules and environment, and the workspace directories.
|
|
2095
|
+
* Registered plainly, not through registerLocalCommand: the breadcrumb
|
|
2096
|
+
* carries what the panel did, so it is announced after the panel closes,
|
|
2097
|
+
* not before it opens.
|
|
2098
|
+
*/
|
|
2099
|
+
pi.registerCommand("permissions", {
|
|
2100
|
+
description: "Review recently denied calls and manage permission rules, auto mode rules and workspace directories",
|
|
2101
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
2102
|
+
if (!canShowCustomUi(ctx)) {
|
|
2103
|
+
// One notice with the summary, not notifyRpcReadOnly's separate one:
|
|
2104
|
+
// pi shows only the last of back-to-back info notices (findings §63).
|
|
2105
|
+
const limitation = ctx.mode === "rpc" ? `${rpcReadOnlyText("/permissions", "manage rules or approve recently denied calls")}\n\n` : "";
|
|
2106
|
+
announceLocalCommand(pi, { name: "permissions", args, stdout: limitation.trim() });
|
|
2107
|
+
ctx.ui.notify(`${limitation}${permissionsSummary()}`, "info");
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
await runPermissionsPanel(ctx, "permissions", args, undefined, "Permissions dialog dismissed");
|
|
2111
|
+
},
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
/**
|
|
2115
|
+
* /add-dir — Claude Code's: add a workspace directory. With a path it asks
|
|
2116
|
+
* whether to keep it for this session or remember it; without one it opens
|
|
2117
|
+
* the panel's Workspace tab on the path input.
|
|
2118
|
+
*/
|
|
2119
|
+
announceArgumentHint(pi, "add-dir", "<path>");
|
|
2120
|
+
pi.registerCommand("add-dir", {
|
|
2121
|
+
description: "Add a workspace directory: /add-dir <path>",
|
|
2122
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
2123
|
+
const input = args.trim();
|
|
2124
|
+
if (!ctx.hasUI) {
|
|
2125
|
+
announceLocalCommand(pi, { name: "add-dir", args });
|
|
2126
|
+
ctx.ui.notify("/add-dir needs the interactive UI; start One Code with --add-dir instead.", "warning");
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
if (!input) {
|
|
2130
|
+
if (!canShowCustomUi(ctx)) {
|
|
2131
|
+
const message = "The workspace panel is unavailable in RPC. Use /add-dir <path> to add a directory.";
|
|
2132
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: message });
|
|
2133
|
+
ctx.ui.notify(message, "info");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
await runPermissionsPanel(ctx, "add-dir", args, (state) => {
|
|
2137
|
+
state.tab = "workspace";
|
|
2138
|
+
state.dialog = { kind: "addDir", draft: "" };
|
|
2139
|
+
});
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
const checked = validateWorkspaceDirectory(input, ctx.cwd, os.homedir(), workspacePaths);
|
|
2143
|
+
if ("error" in checked) {
|
|
2144
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: checked.error });
|
|
2145
|
+
ctx.ui.notify(checked.error, "warning");
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
const choice = await ctx.ui.select(`Add ${checked.path} to the workspace? One Code will be able to read files in it and make edits when auto-accept edits is on.`, [
|
|
2149
|
+
"Yes, for this session",
|
|
2150
|
+
"Yes, and remember this directory",
|
|
2151
|
+
"No",
|
|
2152
|
+
]);
|
|
2153
|
+
if (choice !== "Yes, for this session" && choice !== "Yes, and remember this directory") return;
|
|
2154
|
+
const change = addWorkspaceDirectory(ctx, checked.path, choice === "Yes, and remember this directory");
|
|
2155
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: change });
|
|
2156
|
+
ctx.ui.notify(change, "info");
|
|
1255
2157
|
},
|
|
1256
2158
|
});
|
|
1257
2159
|
|
|
@@ -1263,18 +2165,21 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1263
2165
|
* surfaces a warning rather than blocking it: the work has already happened, so
|
|
1264
2166
|
* the useful move is to make sure the model and the user see it.
|
|
1265
2167
|
*/
|
|
1266
|
-
const reviewFlagged = (
|
|
1267
|
-
`<system-reminder>\nAuto mode reviewed this subagent's actions after it finished and flagged a concern: ${reason}\n\nTreat its output as unverified, do not act on it without checking, and tell the user what it did.\n</system-reminder>`;
|
|
2168
|
+
const reviewFlagged = (verdict: HandBackVerdict) => `<system-reminder>\n${handBackWarning(verdict)}\n</system-reminder>`;
|
|
1268
2169
|
|
|
1269
|
-
// Classify a finished run's action sequence as a whole. Returns the
|
|
2170
|
+
// Classify a finished run's action sequence as a whole. Returns the verdict to
|
|
1270
2171
|
// surface, or undefined when the sequence is fine. Shared by the foreground
|
|
1271
|
-
// (tool_result) and background (channel) paths.
|
|
2172
|
+
// (tool_result) and background (channel) paths. A review that reached no
|
|
2173
|
+
// verdict (an unreadable reply, a transcript past the limit, a timeout) is an
|
|
2174
|
+
// unreviewed run, not a finding.
|
|
1272
2175
|
const reviewCompletedRun = async (
|
|
1273
2176
|
actions: ChildAction[],
|
|
1274
2177
|
ctx: ExtensionContext,
|
|
1275
2178
|
subject: string,
|
|
1276
2179
|
signal: AbortSignal | undefined,
|
|
1277
|
-
|
|
2180
|
+
/** The main-session call that started the reviewed turn, when known. */
|
|
2181
|
+
startedBy: string | undefined,
|
|
2182
|
+
): Promise<HandBackVerdict | undefined> => {
|
|
1278
2183
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
1279
2184
|
// Present the child's actions as tool entries appended to the session
|
|
1280
2185
|
// transcript, so the reviewer judges the sequence with the parent's context
|
|
@@ -1284,6 +2189,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1284
2189
|
const tool = normalizeToolName(action.toolName);
|
|
1285
2190
|
return { kind: "tool", tool, input: isShellTool(tool) ? { command: action.subject } : { subject: action.subject } };
|
|
1286
2191
|
});
|
|
2192
|
+
const { transcript, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), childCursor(startedBy));
|
|
1287
2193
|
const verdict = await classify(
|
|
1288
2194
|
{
|
|
1289
2195
|
toolName: "subagent-review",
|
|
@@ -1302,6 +2208,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1302
2208
|
config: autoConfig,
|
|
1303
2209
|
signal,
|
|
1304
2210
|
state: classifierState,
|
|
2211
|
+
cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
|
|
1305
2212
|
onNotice: (message, level) => ctx.ui.notify(message, level),
|
|
1306
2213
|
onUsage: onClassifierUsage,
|
|
1307
2214
|
onModelUnusable: onClassifierModelUnusable,
|
|
@@ -1313,20 +2220,18 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1313
2220
|
subject,
|
|
1314
2221
|
outcome: verdict.decision,
|
|
1315
2222
|
source: "review",
|
|
2223
|
+
transcriptTooLong: verdict.transcriptTooLong,
|
|
2224
|
+
model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
|
|
1316
2225
|
tier: verdict.tier,
|
|
1317
2226
|
ruleId: verdict.ruleId,
|
|
1318
2227
|
reason: verdict.reason || undefined,
|
|
1319
2228
|
});
|
|
1320
2229
|
if (verdict.decision === "allow") return undefined;
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
ruleId: verdict.ruleId,
|
|
1327
|
-
raw: verdict.raw,
|
|
1328
|
-
});
|
|
1329
|
-
return verdict.reason;
|
|
2230
|
+
// A completed run cannot be approved retroactively, so a review only warns.
|
|
2231
|
+
// It never counts toward the auto-mode pause: as in Claude Code, denials
|
|
2232
|
+
// are counted on the main permission path alone.
|
|
2233
|
+
const unavailable = verdict.transcriptTooLong || verdict.noVerdict || verdict.tier === "timeout";
|
|
2234
|
+
return { kind: unavailable ? "unavailable" : "blocked", reason: verdict.reason };
|
|
1330
2235
|
};
|
|
1331
2236
|
|
|
1332
2237
|
const childActions = new Map<string, ChildAction[]>();
|
|
@@ -1343,11 +2248,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1343
2248
|
// we answer `onReview`, so the verdict travels with the report. Answer
|
|
1344
2249
|
// synchronously when no review will run, so the report is never delayed
|
|
1345
2250
|
// for nothing. A review that throws fails closed: the report goes out
|
|
1346
|
-
// flagged, not clean.
|
|
2251
|
+
// flagged, not clean. A pause does not skip it: the pause is about the
|
|
2252
|
+
// main loop's blocks, and a child already running when it tripped still
|
|
2253
|
+
// hands back a sequence nobody judged whole (PERMISSIONS-AUTOMODE-REVIEW
|
|
2254
|
+
// -2026-09-26 L1).
|
|
1347
2255
|
const respond = payload.onReview;
|
|
1348
2256
|
if (!respond) return; // every background emitter supplies the callback (hand-back-review.ts)
|
|
1349
2257
|
const ctx = lastReviewCtx;
|
|
1350
|
-
if (mode !== "auto" || payload.actions.length === 0 ||
|
|
2258
|
+
if (mode !== "auto" || payload.actions.length === 0 || !ctx) {
|
|
1351
2259
|
respond(undefined);
|
|
1352
2260
|
return;
|
|
1353
2261
|
}
|
|
@@ -1358,8 +2266,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1358
2266
|
const respondIfCurrent = (verdict: HandBackVerdict | undefined) => {
|
|
1359
2267
|
if (epoch === sessionEpoch) respond(verdict);
|
|
1360
2268
|
};
|
|
1361
|
-
reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
|
|
1362
|
-
.then(
|
|
2269
|
+
reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal, payload.startedBy)
|
|
2270
|
+
.then(respondIfCurrent)
|
|
1363
2271
|
.catch((error) => respondIfCurrent({ kind: "unavailable", reason: `the review itself failed (${(error as Error).message})` }));
|
|
1364
2272
|
});
|
|
1365
2273
|
|
|
@@ -1367,86 +2275,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1367
2275
|
lastReviewCtx = ctx;
|
|
1368
2276
|
const actions = childActions.get(event.toolCallId);
|
|
1369
2277
|
if (actions) childActions.delete(event.toolCallId);
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
2278
|
+
// Reviewed while paused too, as the background path above is.
|
|
2279
|
+
if (mode !== "auto" || !actions?.length) return undefined;
|
|
2280
|
+
// The spawning call is this tool result's own: the run happened inside it.
|
|
2281
|
+
const verdict = await reviewCompletedRun(actions, ctx, "completed run", ctx.signal, event.toolCallId);
|
|
2282
|
+
if (!verdict) return undefined;
|
|
2283
|
+
return { content: [{ type: "text" as const, text: reviewFlagged(verdict) }, ...event.content] };
|
|
1374
2284
|
});
|
|
1375
2285
|
|
|
1376
|
-
/**
|
|
1377
|
-
* Apply a chosen classifier model: validate auth first (a persisted model
|
|
1378
|
-
* with no credentials would fail every call), persist to user scope, and
|
|
1379
|
-
* release the session pin so the choice takes effect on the next call
|
|
1380
|
-
* rather than after a restart.
|
|
1381
|
-
*/
|
|
1382
|
-
const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
|
|
1383
|
-
const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
|
|
1384
|
-
if (!model) {
|
|
1385
|
-
ctx.ui.notify(`No available model matches "${spec}" — check /auto-mode config for the catalog name.`, "error");
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
const resolved = `${model.provider}/${model.id}`;
|
|
1389
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
1390
|
-
if (!auth.ok) {
|
|
1391
|
-
ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
try {
|
|
1395
|
-
// Stamp the session's containment so a later session on another provider
|
|
1396
|
-
// treats this cross-provider setting as stale (parity with /subagent).
|
|
1397
|
-
persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
|
|
1398
|
-
} catch (error) {
|
|
1399
|
-
ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
resetClassifierChoice();
|
|
1403
|
-
ctx.ui.notify(`Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, "info");
|
|
1404
|
-
};
|
|
1405
|
-
|
|
1406
|
-
/** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
|
|
1407
|
-
const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
|
|
1408
|
-
if (remainder === "clear") {
|
|
1409
|
-
try {
|
|
1410
|
-
persistClassifierModel(undefined, os.homedir());
|
|
1411
|
-
} catch (error) {
|
|
1412
|
-
ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
resetClassifierChoice();
|
|
1416
|
-
ctx.ui.notify(`autoMode.classifierModel cleared. Auto mode will choose: ${describeChain(ctx)}`, "info");
|
|
1417
|
-
return;
|
|
1418
|
-
}
|
|
1419
|
-
if (remainder) {
|
|
1420
|
-
await applyClassifierChoice(remainder, ctx);
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
const available = ctx.modelRegistry.getAvailable();
|
|
1425
|
-
if (available.length === 0) {
|
|
1426
|
-
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
1427
|
-
return;
|
|
1428
|
-
}
|
|
1429
|
-
// The picker needs focus and a terminal; elsewhere say what to type.
|
|
1430
|
-
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
1431
|
-
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
1432
|
-
ctx.ui.notify(
|
|
1433
|
-
`classifierModel: ${autoConfig.classifierModel ?? "(not set)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
|
|
1434
|
-
"info",
|
|
1435
|
-
);
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
1440
|
-
const current = autoConfig.classifierModel;
|
|
1441
|
-
const entries = toPickerEntries(available);
|
|
1442
|
-
|
|
1443
|
-
const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
1444
|
-
modelPickerComponent({ entries, current }, tui, theme, done),
|
|
1445
|
-
);
|
|
1446
|
-
|
|
1447
|
-
if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
|
|
1448
|
-
};
|
|
1449
|
-
|
|
1450
2286
|
/**
|
|
1451
2287
|
* `/auto-mode setup` — Claude Code's setup-wizard flow: ask how this
|
|
1452
2288
|
* environment is used, gather facts (git/gh/CLAUDE.md, opt-in shell
|
|
@@ -1525,7 +2361,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1525
2361
|
// must appear in both lists (removing the One Code copy alone would leave the
|
|
1526
2362
|
// exposure and a false "fixed" impression).
|
|
1527
2363
|
const oneCodeAllow = new Set(oneCodePermissionAllow(os.homedir()));
|
|
1528
|
-
|
|
2364
|
+
// Independent mode reads no ~/.claude rule, so none of them is in force.
|
|
2365
|
+
const claudeAllow = new Set(claudeSourcesOn() ? claudeUserPermissionAllow(os.homedir()) : []);
|
|
1529
2366
|
const removable = flagged.filter((entry) => oneCodeAllow.has(entry.rule));
|
|
1530
2367
|
const claudeSide = flagged.filter((entry) => claudeAllow.has(entry.rule));
|
|
1531
2368
|
|
|
@@ -1562,8 +2399,89 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1562
2399
|
}
|
|
1563
2400
|
};
|
|
1564
2401
|
|
|
2402
|
+
/**
|
|
2403
|
+
* Apply a chosen classifier model: validate auth first (a persisted model
|
|
2404
|
+
* with no credentials would fail every call), persist to user scope, release
|
|
2405
|
+
* the session pin so the choice takes effect on the next call, and say what
|
|
2406
|
+
* it costs (`lib/model-choice-warnings.ts`).
|
|
2407
|
+
*/
|
|
2408
|
+
const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
|
|
2409
|
+
const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
|
|
2410
|
+
if (!model) {
|
|
2411
|
+
ctx.ui.notify(`No available model matches "${spec}". Run /auto-mode model for the list.`, "error");
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
const resolved = `${model.provider}/${model.id}`;
|
|
2415
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2416
|
+
if (!auth.ok) {
|
|
2417
|
+
ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
try {
|
|
2421
|
+
// Stamp the session's containment so a later session on another provider
|
|
2422
|
+
// treats this cross-provider setting as stale (parity with /subagent).
|
|
2423
|
+
persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
// Re-plan, then announce the choice with its warnings, in any mode: the
|
|
2429
|
+
// setting applies whenever auto mode is on.
|
|
2430
|
+
resetClassifierChoice(ctx.model, false);
|
|
2431
|
+
announceClassifierChoice(ctx.model, { lead: `Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, force: true });
|
|
2432
|
+
};
|
|
2433
|
+
|
|
2434
|
+
/** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
|
|
2435
|
+
const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
|
|
2436
|
+
if (remainder === "clear") {
|
|
2437
|
+
try {
|
|
2438
|
+
persistClassifierModel(undefined, os.homedir());
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
// One info line: pi folds back-to-back info notices (findings §63).
|
|
2444
|
+
resetClassifierChoice(ctx.model, false);
|
|
2445
|
+
announceClassifierChoice(ctx.model, { lead: `autoMode.classifierModel cleared. Auto mode chooses automatically: ${describeChain(ctx)}`, force: true });
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
if (remainder) {
|
|
2449
|
+
await applyClassifierChoice(remainder, ctx);
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
2453
|
+
if (available.length === 0) {
|
|
2454
|
+
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
2458
|
+
// The picker needs focus and a terminal; elsewhere say what to type.
|
|
2459
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
2460
|
+
ctx.ui.notify(
|
|
2461
|
+
`classifierModel: ${autoConfig.classifierModel ?? "(not set: chosen automatically)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
|
|
2462
|
+
"info",
|
|
2463
|
+
);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
2467
|
+
modelPickerComponent(
|
|
2468
|
+
{
|
|
2469
|
+
entries: toPickerEntries(available),
|
|
2470
|
+
current: autoConfig?.classifierModel,
|
|
2471
|
+
title: "Select the auto-mode classifier model",
|
|
2472
|
+
subtitle: "Screens auto-mode tool calls; /auto-mode model clear returns to the automatic choice",
|
|
2473
|
+
},
|
|
2474
|
+
tui,
|
|
2475
|
+
theme,
|
|
2476
|
+
done,
|
|
2477
|
+
),
|
|
2478
|
+
);
|
|
2479
|
+
if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
|
|
2480
|
+
};
|
|
2481
|
+
|
|
1565
2482
|
registerLocalCommand(pi, "auto-mode", {
|
|
1566
2483
|
description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
|
|
2484
|
+
argumentHint: "[setup|defaults|config|model [provider/model-id|clear]]",
|
|
1567
2485
|
getArgumentCompletions: () =>
|
|
1568
2486
|
[
|
|
1569
2487
|
{ value: "setup", label: "analyze this environment and draft the config" },
|
|
@@ -1573,14 +2491,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1573
2491
|
],
|
|
1574
2492
|
handler: async (args, ctx) => {
|
|
1575
2493
|
const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
1576
|
-
if (sub === "model") {
|
|
1577
|
-
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
1578
|
-
return;
|
|
1579
|
-
}
|
|
1580
2494
|
if (sub === "setup") {
|
|
1581
2495
|
await handleSetupSubcommand(ctx);
|
|
1582
2496
|
return;
|
|
1583
2497
|
}
|
|
2498
|
+
if (sub === "model") {
|
|
2499
|
+
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
1584
2502
|
const which = sub ?? "config";
|
|
1585
2503
|
if (which !== "defaults" && which !== "config") {
|
|
1586
2504
|
ctx.ui.notify(
|
|
@@ -1625,8 +2543,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1625
2543
|
...(which === "config" && "classifyAllShell" in shown
|
|
1626
2544
|
? [
|
|
1627
2545
|
`classifyAllShell: ${shown.classifyAllShell}`,
|
|
2546
|
+
`classifierModel: ${"classifierModel" in shown && shown.classifierModel ? shown.classifierModel : "(not set: chosen automatically; /auto-mode model chooses one)"}`,
|
|
1628
2547
|
`logDecisions: ${shown.logDecisions} (auto-mode-decisions.jsonl next to the session files)`,
|
|
1629
|
-
`classifierModel: ${shown.classifierModel ?? "(not set — /auto-mode model chooses one)"}`,
|
|
1630
2548
|
// Which model actually screens calls, and why — this reads the
|
|
1631
2549
|
// user's prompts, so it should not take knowing the code to find out.
|
|
1632
2550
|
`classifier in use: ${
|
|
@@ -1650,6 +2568,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1650
2568
|
|
|
1651
2569
|
registerLocalCommand(pi, "allow", {
|
|
1652
2570
|
description: 'Persist an allow rule: /allow Bash(npm test:*) [global]',
|
|
2571
|
+
argumentHint: "<rule> [global]",
|
|
1653
2572
|
handler: async (args, ctx) => {
|
|
1654
2573
|
const global = /\s+global$/.test(args.trim());
|
|
1655
2574
|
const raw = args.trim().replace(/\s+global$/, "");
|