one-code-extension 0.4.1 → 0.6.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 +16 -5
- 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 +40 -28
- package/docs/guide/doctor.md +20 -13
- package/docs/guide/hooks.md +18 -3
- package/docs/guide/installation.md +26 -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 +58 -31
- 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 +124 -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 +642 -0
- package/extensions/background/index.ts +614 -248
- package/extensions/background/loop-fire.ts +173 -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 +56 -0
- package/extensions/background/wakeup.ts +178 -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 +66 -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 +250 -78
- package/extensions/btw/panel.ts +228 -100
- package/extensions/btw/prompt.ts +41 -4
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-compat/index.ts +7 -11
- package/extensions/claude-context/index.ts +436 -63
- package/extensions/compaction/cut.ts +157 -0
- package/extensions/compaction/index.ts +219 -75
- package/extensions/compaction/kept-thinking.ts +62 -0
- package/extensions/compaction/prompt.ts +15 -12
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +35 -15
- package/extensions/context-management/index.ts +36 -28
- 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 +141 -25
- 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 +420 -42
- package/extensions/file-tracker/replay.ts +137 -24
- package/extensions/file-tracker/restore.ts +66 -0
- package/extensions/file-tracker/results.ts +66 -0
- package/extensions/file-tracker/shell-reads.ts +211 -0
- package/extensions/file-tracker/tracker.ts +45 -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 +209 -58
- package/extensions/hooks/matcher.ts +3 -0
- package/extensions/hooks/prompt-context.ts +59 -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 +26 -12
- 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/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +146 -0
- package/extensions/lib/agent-loader.ts +18 -3
- package/extensions/lib/agent-view.ts +85 -0
- package/extensions/lib/anthropic-auth.ts +98 -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 +52 -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 +97 -0
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +359 -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/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +71 -19
- 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 +178 -0
- package/extensions/lib/model-catalog/models-dev.json +18533 -0
- package/extensions/lib/model-catalog/openrouter.json +5050 -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 +357 -73
- 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 +38 -2
- package/extensions/lib/plan-mode-channels.ts +42 -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/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +132 -8
- package/extensions/lib/prompt-options.ts +82 -0
- package/extensions/lib/queued-delivery.ts +44 -0
- package/extensions/lib/reminders.ts +370 -358
- package/extensions/lib/replaced-builtins.mjs +199 -0
- package/extensions/lib/replay-call.ts +70 -0
- package/extensions/lib/request-replay.ts +547 -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 +24 -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 +112 -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 +129 -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 +159 -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 +76 -12
- 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 +90 -43
- 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 +220 -121
- 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 +164 -73
- 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/model-default/index.ts +86 -19
- 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 +1426 -398
- package/extensions/permissions/matcher.ts +425 -74
- package/extensions/permissions/modes.ts +11 -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 +276 -92
- package/extensions/plan-mode/reminder.ts +89 -21
- package/extensions/plan-mode/run-state.ts +26 -0
- 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 +64 -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 +14 -7
- package/extensions/recap/index.ts +73 -71
- 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/announce.ts +30 -0
- package/extensions/skill/code-review.ts +97 -0
- package/extensions/skill/index.ts +469 -132
- package/extensions/skill/invoke.ts +118 -75
- 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 +146 -31
- package/extensions/subagents/default-model.ts +2 -3
- package/extensions/subagents/delegation-steer.ts +4 -4
- package/extensions/subagents/fork-cache.ts +82 -0
- package/extensions/subagents/guide-agent.ts +189 -0
- package/extensions/subagents/hand-back-review.ts +3 -0
- package/extensions/subagents/index.ts +1594 -852
- package/extensions/subagents/live-runs.ts +26 -6
- package/extensions/subagents/model-select.ts +38 -25
- package/extensions/subagents/outcome.ts +5 -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/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +135 -80
- package/extensions/subagents/runs.ts +44 -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 +165 -47
- package/extensions/system-prompt/template.ts +34 -51
- 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 +204 -70
- 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/index.ts +366 -144
- 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 +108 -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 +157 -162
- package/extensions/workflow/records.ts +1 -0
- package/extensions/workflow/run-manager.ts +33 -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 +162 -44
- package/extensions/worktree/powershell-guards.ts +167 -0
- package/extensions/worktree/rewrite.ts +14 -16
- package/package.json +21 -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
- package/extensions/tool-search/announce.ts +0 -42
|
@@ -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,43 @@ 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 { readState, readStringsState, recordState } from "../lib/ledger/state.ts";
|
|
81
|
+
import { processState } from "../lib/process-state.ts";
|
|
82
|
+
import { type EntryLike, readRecord } from "../lib/ledger/records.ts";
|
|
62
83
|
import {
|
|
63
84
|
decide,
|
|
64
85
|
extractSubject,
|
|
@@ -70,23 +91,65 @@ import {
|
|
|
70
91
|
isPathSubjectTool,
|
|
71
92
|
} from "./matcher.ts";
|
|
72
93
|
import { type SessionGrant, sessionGrant } from "./session-grant.ts";
|
|
73
|
-
import { modeBadge, nextMode, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
|
|
74
|
-
import {
|
|
94
|
+
import { formatModel, modeBadge, nextMode, PERMISSION_MODE_STATE, PERMISSION_STATUS_CHANNEL, type PermissionStatus, CYCLE_KEY } from "./modes.ts";
|
|
95
|
+
import { intrinsicTier, usesClaudeCodeFastPaths } from "../lib/model-tier.ts";
|
|
96
|
+
import { type ChildToolCall, type ChildGateDecision, sessionPermissionBridge, SUBAGENT_GATE_CHANNEL } from "./subagent-gate.ts";
|
|
75
97
|
import { trackOriginalCommands } from "../lib/original-command.ts";
|
|
98
|
+
import { followWorkCwd } from "../lib/worktree-channel.ts";
|
|
76
99
|
import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
|
|
77
100
|
import { isWritingTool } from "./protected-paths.ts";
|
|
78
101
|
import { denyRuleLines } from "./rule-prose.ts";
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
import {
|
|
103
|
+
listPermissionRules,
|
|
104
|
+
listWorkspaceDirectories,
|
|
105
|
+
loadPermissionSettings,
|
|
106
|
+
markOutsideReadPromptSeen,
|
|
107
|
+
normalizePermissionMode,
|
|
108
|
+
outsideReadPromptSeen,
|
|
109
|
+
persistBlockOutsideReads,
|
|
110
|
+
persistAllowRule,
|
|
111
|
+
persistPermissionRule,
|
|
112
|
+
removePermissionRule,
|
|
113
|
+
resolveStartupMode,
|
|
114
|
+
type RuleSource,
|
|
115
|
+
persistWorkspaceDirectory,
|
|
116
|
+
removeWorkspaceDirectory,
|
|
117
|
+
type SourcedDirectory,
|
|
118
|
+
type SourcedRule,
|
|
119
|
+
} from "./settings.ts";
|
|
120
|
+
import { MODE_ENV, parsePowerShell, resolvedOrSelf, runtimeProtectedDirs, runtimeSecretPaths } from "../lib/permission-gate.ts";
|
|
121
|
+
import { describeProjectAllow, persistProjectAllowApproval, projectAllowApproved, projectDirectoryConsentEntry, type TrustFiring } from "./project-trust.ts";
|
|
122
|
+
import { parseAddDirFlag, tooBroadForWorkspace, validateWorkspaceDirectory } from "./workspace.ts";
|
|
123
|
+
import { WORKSPACE_CHANNEL, type WorkspaceAnnouncement } from "../lib/workspace-channel.ts";
|
|
83
124
|
import { findProjectRoot } from "../lib/git.ts";
|
|
84
125
|
import { oneCodeProjectSettingsPath, oneCodeSettingsPath } from "../lib/one-code-settings.ts";
|
|
85
126
|
import { recordUsage } from "../lib/usage-bus.ts";
|
|
86
|
-
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
127
|
+
import { announceLocalCommand, registerLocalCommand } from "../lib/local-command.ts";
|
|
128
|
+
import { claudeSourcesOn } from "../lib/config-mode.ts";
|
|
129
|
+
import { claudeJsonPath, tildify, tryRealpath } from "../lib/paths.ts";
|
|
130
|
+
import { openPermissionsPanel, type PermissionsPanelHost } from "./panel/host.ts";
|
|
131
|
+
import { PANEL_FOCUS_CHANNEL, PanelFocus, panelUi } from "../lib/panel-focus.ts";
|
|
132
|
+
import {
|
|
133
|
+
AUTO_SECTION_LABELS,
|
|
134
|
+
AUTO_SECTIONS,
|
|
135
|
+
type AutoEntryRow,
|
|
136
|
+
type AutoModeView,
|
|
137
|
+
type Destination,
|
|
138
|
+
type PanelState,
|
|
139
|
+
RULE_TABS,
|
|
140
|
+
type RuleRow,
|
|
141
|
+
type RuleTab,
|
|
142
|
+
type WorkspaceDirRow,
|
|
143
|
+
} from "./panel/state.ts";
|
|
144
|
+
import { builtinRuleCounts } from "../auto-mode/rules.ts";
|
|
145
|
+
import { autoModeSystemNote } from "../auto-mode/system-note.ts";
|
|
146
|
+
import { announceArgumentHint } from "../lib/argument-hints.ts";
|
|
147
|
+
import { userDenialText } from "../lib/user-denial.ts";
|
|
148
|
+
import { liveUiCtx } from "../lib/tui-render.ts";
|
|
149
|
+
|
|
150
|
+
/** Shown when a permission prompt is held behind an open panel (serializePrompt). */
|
|
151
|
+
const PROMPT_WAITS_FOR_PANEL = "A permission request is waiting. Close this panel to answer it.";
|
|
87
152
|
|
|
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
153
|
const DENIED_NON_INTERACTIVE =
|
|
91
154
|
"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
155
|
// Must agree with the plan-mode reminder (plan-mode/reminder.ts): the plan is
|
|
@@ -99,6 +162,26 @@ const DENIED_DONT_ASK =
|
|
|
99
162
|
"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
163
|
const DENIED_PROTECTED_PATH =
|
|
101
164
|
"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.";
|
|
165
|
+
/** A timed resume prompt nobody answered (Claude Code's denial-limit fallback timing). */
|
|
166
|
+
const DENIED_UNANSWERED_RESUME =
|
|
167
|
+
"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.";
|
|
168
|
+
/** Claude Code's refusal when `blockReadsOutsideWorkingDirectories` is set. */
|
|
169
|
+
const DENIED_BLOCKED_OUTSIDE_READ =
|
|
170
|
+
"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.";
|
|
171
|
+
/** Claude Code's refusal right after the user picks "block" in the first-read prompt. */
|
|
172
|
+
const DENIED_CHOSE_BLOCK_OUTSIDE_READS =
|
|
173
|
+
"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.";
|
|
174
|
+
/** Claude Code's first outside-read prompt in auto mode (2.1.282): title, question, answers. */
|
|
175
|
+
const OUTSIDE_READ_TITLE = "Read outside the working directories";
|
|
176
|
+
const OUTSIDE_READ_QUESTION = "Allow reads outside the working directories?";
|
|
177
|
+
const OUTSIDE_READ_ANSWERS = {
|
|
178
|
+
allow: "Yes, keep allowing reads outside the working directories",
|
|
179
|
+
block: "No, block reads outside the working directories from now on",
|
|
180
|
+
ask_again: "No, ask again next time",
|
|
181
|
+
} as const;
|
|
182
|
+
/** Claude Code's explainer, without its sandbox sentences (One Code has no sandbox) and with One Code's settings file. */
|
|
183
|
+
const OUTSIDE_READ_EXPLAINER = (settingsFile: string) =>
|
|
184
|
+
`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
185
|
const DENIED_OUTSIDE_WORKING_DIR =
|
|
103
186
|
"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
187
|
// Returned to the MODEL, not the user: auto mode exists to run unattended, so a
|
|
@@ -111,9 +194,21 @@ const BLOCKED_BY_TIMEOUT = (reason: string) =>
|
|
|
111
194
|
`${reason}\n\nThis was not a judgement that the call is unsafe. Wait a moment and try this action again. ` +
|
|
112
195
|
"If it keeps failing, continue with other tasks that do not require this action and come back to it later — " +
|
|
113
196
|
"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
|
-
|
|
197
|
+
"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.";
|
|
198
|
+
// The classifier ran but gave no verdict (a provider refusal or error, a
|
|
199
|
+
// truncated reply, no usable model): not a denial, and nothing to route around.
|
|
200
|
+
const BLOCKED_NOT_JUDGED = (reason: string) =>
|
|
201
|
+
`${reason}\n\nThis was not a judgement that the call is unsafe: the approval classifier gave no verdict, so the call did not run. ` +
|
|
202
|
+
"Do not rephrase it or route around the gate. Continue with other tasks that do not require this action — " +
|
|
203
|
+
"reading files, searching code, and other read-only operations do not require the classifier. " +
|
|
204
|
+
"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.";
|
|
205
|
+
/** The text a classifier block hands the model: a timeout, a call never judged, or a denial. */
|
|
206
|
+
const classifierBlockReason = (outcome: { tier?: string; noVerdict?: boolean; reason: string }): string =>
|
|
207
|
+
outcome.tier === "timeout"
|
|
208
|
+
? BLOCKED_BY_TIMEOUT(outcome.reason)
|
|
209
|
+
: outcome.noVerdict
|
|
210
|
+
? BLOCKED_NOT_JUDGED(outcome.reason)
|
|
211
|
+
: DENIED_BY_CLASSIFIER(outcome.reason);
|
|
117
212
|
// "Choose a different approach" is exactly what a literal reader does with a
|
|
118
213
|
// second command of the same effect: a tiny-tier model met `Bash(rm:*)` and
|
|
119
214
|
// deleted the file with `python3 -c "import os; os.remove(…)"` 3/3 times
|
|
@@ -123,6 +218,11 @@ const DENIED_SAFETY_FLOOR = (reason: string) =>
|
|
|
123
218
|
const DENIED_BY_RULE = (rule: string) =>
|
|
124
219
|
`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
220
|
|
|
221
|
+
/** The hidden message a /permissions retry starts its turn with. */
|
|
222
|
+
const PERMISSION_RETRY_TYPE = "one-code:permission-retry";
|
|
223
|
+
/** Claude Code's denial notification: the tool, the reason cut to 80 columns, and where to act on it. */
|
|
224
|
+
const deniedNotice = (toolName: string, reason: string): string =>
|
|
225
|
+
`${toolName} denied by auto mode · ${reason.length > 80 ? `${reason.slice(0, 79)}…` : reason} · /permissions`;
|
|
126
226
|
/** Truncate a subject for a permission prompt — shared by the main gate and the subagent bridge. */
|
|
127
227
|
const previewSubject = (subject: string) => (subject.length > 200 ? `${subject.slice(0, 200)}…` : subject);
|
|
128
228
|
|
|
@@ -140,16 +240,22 @@ const askTitle = (actor: string, preview: string, cause: string, pausedResume: b
|
|
|
140
240
|
};
|
|
141
241
|
|
|
142
242
|
/** 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
|
-
|
|
243
|
+
const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string => {
|
|
244
|
+
switch (result.cause) {
|
|
245
|
+
case "plan-mode":
|
|
246
|
+
return DENIED_PLAN_MODE;
|
|
247
|
+
case "protected-path":
|
|
248
|
+
return DENIED_PROTECTED_PATH;
|
|
249
|
+
case "blocked-outside-read":
|
|
250
|
+
return DENIED_BLOCKED_OUTSIDE_READ;
|
|
251
|
+
case "working-dir":
|
|
252
|
+
return DENIED_OUTSIDE_WORKING_DIR;
|
|
253
|
+
case "mode":
|
|
254
|
+
return DENIED_DONT_ASK;
|
|
255
|
+
default:
|
|
256
|
+
return DENIED_BY_RULE(result.rule?.raw ?? "deny");
|
|
257
|
+
}
|
|
258
|
+
};
|
|
153
259
|
|
|
154
260
|
/** Ask-prompt option labels — shared by both gate paths. */
|
|
155
261
|
/**
|
|
@@ -160,6 +266,24 @@ const denyReason = (result: { cause?: string; rule?: { raw?: string } }): string
|
|
|
160
266
|
* here, next to the emitter it belongs to.
|
|
161
267
|
*/
|
|
162
268
|
const STANDING_REMINDER_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>(["plan", "auto"]);
|
|
269
|
+
/** The `one-code:state` key for the directories added to the workspace for this session. */
|
|
270
|
+
const WORKSPACE_DIRS_STATE = "workspace-dirs";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* What the user granted "for this session" and what auto mode's loop-breaker
|
|
274
|
+
* counted. This is process state (lib/process-state.ts): pi re-runs this
|
|
275
|
+
* factory at a /reload, which is the same conversation and keeps them
|
|
276
|
+
* (decisions/modes.md), and every other session start clears them. Only the
|
|
277
|
+
* main session loads this extension (children reach it through the bridge),
|
|
278
|
+
* so one slot per process is one per conversation.
|
|
279
|
+
*/
|
|
280
|
+
interface SessionGate {
|
|
281
|
+
/** Rules minted by "Yes, and don't ask again … this session" (session-grant.ts). */
|
|
282
|
+
allows: PermissionRule[];
|
|
283
|
+
pause: PauseTracker;
|
|
284
|
+
/** Classifier denials, and the one-shot grants `/permissions` mints from them. */
|
|
285
|
+
denials: DenialStore;
|
|
286
|
+
}
|
|
163
287
|
|
|
164
288
|
const YES = "Yes";
|
|
165
289
|
const NO = "No, tell the agent what to do differently";
|
|
@@ -180,6 +304,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
180
304
|
"Permission mode: default (alias: manual) | acceptEdits | plan | auto | bypassPermissions | dontAsk",
|
|
181
305
|
type: "string",
|
|
182
306
|
});
|
|
307
|
+
pi.registerFlag("add-dir", {
|
|
308
|
+
description: `Additional workspace directories: readable without a prompt, writable in acceptEdits (separate several with "${process.platform === "win32" ? ";" : ":"}")`,
|
|
309
|
+
type: "string",
|
|
310
|
+
});
|
|
183
311
|
pi.registerFlag("dangerously-skip-permissions", {
|
|
184
312
|
description: "Skip all permission prompts (Claude Code compatible)",
|
|
185
313
|
type: "boolean",
|
|
@@ -197,19 +325,32 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
197
325
|
// an OS sandbox for outside-cwd reads; One Code flips the default directly and
|
|
198
326
|
// has no sandbox yet (reads outside the working directory are still
|
|
199
327
|
// classified/asked, never auto-read). The auto-mode safety architecture still
|
|
200
|
-
// holds — the deterministic safety floor, classifier-verdicts-verified
|
|
201
|
-
//
|
|
328
|
+
// holds — the deterministic safety floor, classifier-verdicts-verified and
|
|
329
|
+
// the classifier tier floor — and with no
|
|
202
330
|
// classifier model reachable the classifier fails closed. A user or project
|
|
203
331
|
// `defaultMode` (or `--permission-mode`) still overrides this; `auto` from a
|
|
204
|
-
// project file is still refused. See docs/decisions/auto-mode.md.
|
|
332
|
+
// project file is still refused. See working-docs/decisions/auto-mode.md.
|
|
205
333
|
let mode: PermissionMode = "auto";
|
|
334
|
+
/** Whether the session model gets Claude Code's fast paths, for the model-switch notice. */
|
|
335
|
+
let gateFastPaths: boolean | undefined;
|
|
336
|
+
/** `permissions.blockReadsOutsideWorkingDirectories` from any settings source. */
|
|
337
|
+
let blockOutsideReads = false;
|
|
338
|
+
/** The first outside-read prompt was answered on this machine (read once, then kept). */
|
|
339
|
+
let outsideReadSeen: boolean | undefined;
|
|
206
340
|
// Worktree-wrapped bash calls publish the model's original command here,
|
|
207
341
|
// keyed by pi's toolCallId (never read from `event.input` — model-writable).
|
|
208
342
|
const originalCommands = trackOriginalCommands(pi);
|
|
343
|
+
// Worktree restores publish before this extension's session_start. Keep
|
|
344
|
+
// that state until worktree publishes its next enter, exit or branch reset.
|
|
345
|
+
const workCwd = followWorkCwd(pi.events);
|
|
209
346
|
/** Whether bypassPermissions is a stop on the cycle — only when the session started with it (Claude Code semantics). */
|
|
210
347
|
let bypassInCycle = false;
|
|
211
348
|
/** Whether auto mode is a stop on the cycle — only when a classifier model is reachable. */
|
|
212
349
|
let autoInCycle = false;
|
|
350
|
+
/** The mode the branch's latest `one-code:state` entry holds: the mode follows the branch. */
|
|
351
|
+
let branchMode: PermissionMode | undefined;
|
|
352
|
+
/** Whether a request went out on this branch: only then was its model told the branch's mode. */
|
|
353
|
+
let requestOnBranch = false;
|
|
213
354
|
let deny: PermissionRule[] = [];
|
|
214
355
|
let ask: PermissionRule[] = [];
|
|
215
356
|
let allow: PermissionRule[] = [];
|
|
@@ -227,12 +368,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
227
368
|
* Rules minted by "Yes, and don't ask again … this session" (session-grant.ts).
|
|
228
369
|
* Never applied in auto mode: there the classifier is the boundary and a
|
|
229
370
|
* standing grant would be a bypass of it (PERMISSIONS-REVIEW-2026-09-05 M2).
|
|
230
|
-
* Cleared with the session (L1).
|
|
371
|
+
* Cleared with the session (L1), kept by a reload (SessionGate).
|
|
231
372
|
*/
|
|
232
|
-
const
|
|
373
|
+
const sessionGate = processState<SessionGate>("permissions:session-gate", () => ({ allows: [], pause: new PauseTracker(), denials: new DenialStore() }));
|
|
374
|
+
const sessionAllows = sessionGate.allows;
|
|
233
375
|
const activeSessionAllows = () => (mode === "auto" ? [] : sessionAllows);
|
|
234
376
|
let unparsableRules: string[] = [];
|
|
235
377
|
let warnedUnparsable = "";
|
|
378
|
+
let warnedTooBroad = "";
|
|
236
379
|
/** Plan mode's one writable file, announced by the plan-mode extension. */
|
|
237
380
|
let planFilePath: string | undefined;
|
|
238
381
|
/**
|
|
@@ -258,6 +401,69 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
258
401
|
let oneCodeProjectSettingsFile: string | undefined;
|
|
259
402
|
/** pi's own agent directory, protected like the static list (lib/permission-gate.ts runtimeProtectedDirs). */
|
|
260
403
|
let protectedDirs: string[] = [];
|
|
404
|
+
/** The harness's own secret stores, never read unclassified outside the working space (runtimeSecretPaths). */
|
|
405
|
+
let secretPaths: string[] = [];
|
|
406
|
+
/** The harness's readable session dirs, resolved at session start; `readableRoots` adds the workspace to them. */
|
|
407
|
+
let harnessReadableRoots: string[] = [];
|
|
408
|
+
/** The shipped docs the `one-code-guide` agent reads (lib/guide-docs.ts): readable, never writable. */
|
|
409
|
+
let docsDirPaths: string[] = [];
|
|
410
|
+
/**
|
|
411
|
+
* Workspace directories (workspace.ts): from settings, where the
|
|
412
|
+
* repository's own files apply only once the user trusts them, from
|
|
413
|
+
* `--add-dir`, and added for this session in /permissions or with /add-dir.
|
|
414
|
+
*/
|
|
415
|
+
let settingsWorkspaceDirs: SourcedDirectory[] = [];
|
|
416
|
+
let flagWorkspaceDirs: string[] = [];
|
|
417
|
+
/**
|
|
418
|
+
* Added for this session. This is branch state: the /add-dir breadcrumb told
|
|
419
|
+
* the branch's model they are in its workspace. Recorded on every change and
|
|
420
|
+
* read back, validated again, at every session start and /tree switch.
|
|
421
|
+
*/
|
|
422
|
+
const sessionWorkspaceDirs: string[] = [];
|
|
423
|
+
/** The workspace directories in force, as real paths: what the user and the system prompt see. */
|
|
424
|
+
let workspacePaths: string[] = [];
|
|
425
|
+
/** The same directories in containment's comparison form: what decide() and both shell pre-gates see. */
|
|
426
|
+
let workspaceDirs: string[] = [];
|
|
427
|
+
const isRepoSource = (source: RuleSource) => source === "project" || source === "project-local";
|
|
428
|
+
const repoWorkspaceDirs = () => settingsWorkspaceDirs.filter((dir) => isRepoSource(dir.source));
|
|
429
|
+
/** What the repository-trust consent covers: its allow rules and its workspace directories. */
|
|
430
|
+
const projectTrustList = () => [...projectAllowRaw, ...repoWorkspaceDirs().map((dir) => projectDirectoryConsentEntry(dir.raw))];
|
|
431
|
+
const refreshWorkspace = () => {
|
|
432
|
+
const trusted = settingsWorkspaceDirs.filter((dir) => projectAllowTrusted || !isRepoSource(dir.source)).map((dir) => dir.path);
|
|
433
|
+
workspacePaths = [...new Set([...trusted, ...flagWorkspaceDirs, ...sessionWorkspaceDirs].map((dir) => tryRealpath(dir) ?? dir))];
|
|
434
|
+
workspaceDirs = [...new Set(workspacePaths.map(resolvedOrSelf))];
|
|
435
|
+
readableRoots = [...harnessReadableRoots, ...docsDirPaths, ...workspaceDirs];
|
|
436
|
+
if (sessionStarted) announceWorkspace();
|
|
437
|
+
};
|
|
438
|
+
/** What the system prompt was last told the workspace is (lib/workspace-channel.ts). */
|
|
439
|
+
let announcedWorkspace: string | undefined;
|
|
440
|
+
let sessionStarted = false;
|
|
441
|
+
/**
|
|
442
|
+
* The environment block lists the workspace: a change mid-session reaches
|
|
443
|
+
* the next epoch's message 1 (a compaction), while inside the epoch the
|
|
444
|
+
* /add-dir output already told the model (R3-L4).
|
|
445
|
+
*/
|
|
446
|
+
const announceWorkspace = () => {
|
|
447
|
+
const key = workspacePaths.join("\0");
|
|
448
|
+
if (key === announcedWorkspace) return;
|
|
449
|
+
announcedWorkspace = key;
|
|
450
|
+
pi.events.emit(WORKSPACE_CHANNEL, { dirs: workspacePaths } satisfies WorkspaceAnnouncement);
|
|
451
|
+
};
|
|
452
|
+
const recordSessionWorkspace = () => recordState(pi, WORKSPACE_DIRS_STATE, [...sessionWorkspaceDirs]);
|
|
453
|
+
/**
|
|
454
|
+
* Take the branch's session directories. Each is validated again, as a live
|
|
455
|
+
* /add-dir is: the stored list says only that the user once added it, so a
|
|
456
|
+
* directory that is gone, or one too broad for a workspace, is not applied.
|
|
457
|
+
*/
|
|
458
|
+
const restoreSessionWorkspace = (ctx: ExtensionContext) => {
|
|
459
|
+
const restored: string[] = [];
|
|
460
|
+
for (const dir of readStringsState(ctx, WORKSPACE_DIRS_STATE)) {
|
|
461
|
+
const checked = validateWorkspaceDirectory(dir, ctx.cwd, os.homedir(), restored);
|
|
462
|
+
if ("path" in checked) restored.push(checked.path);
|
|
463
|
+
else if (ctx.hasUI) ctx.ui.notify(`${dir}, added earlier in this conversation, is no longer in the workspace: ${checked.error}`, "warning");
|
|
464
|
+
}
|
|
465
|
+
sessionWorkspaceDirs.splice(0, sessionWorkspaceDirs.length, ...restored);
|
|
466
|
+
};
|
|
261
467
|
|
|
262
468
|
/**
|
|
263
469
|
* Mode changes arrive over the event bus too (plan-mode tools), where no ctx
|
|
@@ -279,18 +485,22 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
279
485
|
return { classifier: `${classifierState.pinned.provider}/${classifierState.pinned.id}`, pinned: true };
|
|
280
486
|
}
|
|
281
487
|
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];
|
|
488
|
+
const first = selectionChain(badgeCtx, sessionModel).candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
290
489
|
return { classifier: first ? `${first.model.provider}/${first.model.id}` : undefined, pinned: false };
|
|
291
490
|
};
|
|
292
491
|
|
|
293
|
-
|
|
492
|
+
/**
|
|
493
|
+
* The classifier chain for `sessionModel` (the ctx's by default), cached on
|
|
494
|
+
* the classifier state by the same signature the gate uses, so a repaint
|
|
495
|
+
* does not rank the catalog again (auto-mode/classifier.ts classifierChain).
|
|
496
|
+
*/
|
|
497
|
+
const selectionChain = (ctx: ExtensionContext, sessionModel?: Model<Api>) => {
|
|
498
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
499
|
+
const registry = ctx.modelRegistry;
|
|
500
|
+
return classifierChain(classifierState, { available: () => registry.getAvailable(), sessionModel: sessionModel ?? ctx.model, config: autoConfig });
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const applyBadge = (sessionModel?: Model<Api>, toolCallId?: string) => {
|
|
294
504
|
// A below-editor widget, not a footer status: Claude Code renders the
|
|
295
505
|
// mode line directly under the input box, and the workflow status strip
|
|
296
506
|
// sorts itself below this line by re-setting on the status channel
|
|
@@ -301,7 +511,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
301
511
|
[
|
|
302
512
|
modeBadge(mode, {
|
|
303
513
|
paused: pauseTracker.isPaused(),
|
|
304
|
-
|
|
514
|
+
classifier: classifierState.pinned?.id,
|
|
305
515
|
streaming,
|
|
306
516
|
}),
|
|
307
517
|
],
|
|
@@ -315,6 +525,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
315
525
|
paused: pauseTracker.isPaused(),
|
|
316
526
|
classifier: display.classifier,
|
|
317
527
|
pinned: display.pinned,
|
|
528
|
+
bypassAvailable: bypassInCycle,
|
|
529
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
318
530
|
} satisfies PermissionStatus);
|
|
319
531
|
};
|
|
320
532
|
|
|
@@ -330,19 +542,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
330
542
|
mode === "auto" ? (autoConfig ??= loadAutoModeConfig(os.homedir())).classifyAllShell : undefined;
|
|
331
543
|
|
|
332
544
|
// Drop the cached config and classifier selection state, then refresh the badge.
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
545
|
+
// A model switch must re-evaluate the capability and context-window requirements.
|
|
546
|
+
// `"batched"` (session_start, model_select) joins the info line with other
|
|
547
|
+
// extensions' lifecycle notices (lib/notices.ts).
|
|
548
|
+
const resetClassifierChoice = (sessionModel?: Model<Api>, announce: boolean | "batched" = true) => {
|
|
549
|
+
autoConfig = undefined; // reloaded lazily
|
|
336
550
|
classifierState.pinned = undefined;
|
|
337
551
|
classifierState.rejected.clear();
|
|
552
|
+
// A subset of `rejected`: kept alone it would block the retry and blame a
|
|
553
|
+
// refusal from the previous choice.
|
|
554
|
+
classifierState.refused.clear();
|
|
338
555
|
classifierState.notified.clear();
|
|
339
556
|
classifierState.timeoutStreak = 0;
|
|
340
557
|
classifierState.chainCache = undefined;
|
|
341
558
|
applyBadge(sessionModel);
|
|
559
|
+
if (announce) announceClassifierChoice(sessionModel, { batched: announce === "batched" });
|
|
342
560
|
};
|
|
343
561
|
// Keyed by cwd: a worktree-isolated child classifies against ITS checkout's
|
|
344
562
|
// CLAUDE.md/AGENTS.md, and must not poison the cache the main agent's own
|
|
345
|
-
// calls (a different cwd) read for the rest of the session.
|
|
563
|
+
// calls (a different cwd) read for the rest of the session. The main
|
|
564
|
+
// session's calls read the entered worktree's. Dropped where the agent's own
|
|
565
|
+
// instructions are read again: a session start and a compaction.
|
|
346
566
|
const projectInstructionsByCwd = new Map<string, string | undefined>();
|
|
347
567
|
const instructionsFor = (cwd: string): string | undefined => {
|
|
348
568
|
if (!projectInstructionsByCwd.has(cwd)) {
|
|
@@ -350,7 +570,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
350
570
|
}
|
|
351
571
|
return projectInstructionsByCwd.get(cwd);
|
|
352
572
|
};
|
|
353
|
-
const pauseTracker =
|
|
573
|
+
const pauseTracker = sessionGate.pause;
|
|
574
|
+
const denials = sessionGate.denials;
|
|
354
575
|
/**
|
|
355
576
|
* Which model the classifier settled on. Held here so the choice is pinned for
|
|
356
577
|
* the session rather than re-resolved per call, and so a model that turns out
|
|
@@ -384,14 +605,41 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
384
605
|
}
|
|
385
606
|
};
|
|
386
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Announce the choice, and any warning about a chosen classifier, before any
|
|
610
|
+
* tool needs screening. Warnings first, then ONE info line: pi folds
|
|
611
|
+
* back-to-back info notices into a single status line (findings §63), so a
|
|
612
|
+
* second one would overwrite the first. `lead` opens that line (the
|
|
613
|
+
* confirmation `/auto-mode model` gives); `force` announces outside auto mode;
|
|
614
|
+
* `batched` hands the info line to the lifecycle notice owner (lib/notices.ts).
|
|
615
|
+
*/
|
|
616
|
+
const announceClassifierChoice = (sessionModel?: Model<Api>, opts: { lead?: string; force?: boolean; batched?: boolean } = {}) => {
|
|
617
|
+
if ((mode !== "auto" && !opts.force) || !badgeCtx) return;
|
|
618
|
+
const choice = selectionChain(badgeCtx, sessionModel);
|
|
619
|
+
const suggestNewer = readSuggestNewerModels(os.homedir());
|
|
620
|
+
// Keyed by text, the same keys classify() uses, so each shows once.
|
|
621
|
+
const info: string[] = opts.lead ? [opts.lead] : [];
|
|
622
|
+
for (const notice of choice.notices) {
|
|
623
|
+
if (notice.fallbackReason || classifierState.notified.has(notice.text) || (!suggestNewer && notice.choiceWarning === "newer")) continue;
|
|
624
|
+
classifierState.notified.add(notice.text);
|
|
625
|
+
if (notice.level === "warning") badgeCtx.ui.notify(notice.text, "warning");
|
|
626
|
+
else info.push(notice.text);
|
|
627
|
+
}
|
|
628
|
+
const first = choice.candidates.find((entry) => !classifierState.rejected.has(`${entry.model.provider}/${entry.model.id}`));
|
|
629
|
+
const key = first ? `using:${first.model.provider}/${first.model.id}` : undefined;
|
|
630
|
+
if (first && key && !classifierState.notified.has(key)) {
|
|
631
|
+
classifierState.notified.add(key);
|
|
632
|
+
if (choice.fallback) classifierState.notified.add(choice.fallback.text);
|
|
633
|
+
info.push(choice.fallback?.text ?? `Auto mode will screen calls with ${describeCandidate(first)}.`);
|
|
634
|
+
}
|
|
635
|
+
if (info.length === 0) return;
|
|
636
|
+
if (opts.batched) queueNotice(pi.events, badgeCtx, "info", info.join("\n"));
|
|
637
|
+
else badgeCtx.ui.notify(info.join("\n"), "info");
|
|
638
|
+
};
|
|
639
|
+
|
|
387
640
|
/** What would be tried, in order, before anything has been pinned. */
|
|
388
641
|
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
|
-
});
|
|
642
|
+
const { candidates } = selectionChain(ctx);
|
|
395
643
|
return candidates.length > 0 ? candidates.map(describeCandidate).join(" → ") : "(no model available)";
|
|
396
644
|
};
|
|
397
645
|
|
|
@@ -404,19 +652,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
404
652
|
}
|
|
405
653
|
})();
|
|
406
654
|
|
|
655
|
+
// Only a cursor into pi's live branch, not a separate classifier history.
|
|
656
|
+
// A parent assistant message can contain calls not yet executed when a child
|
|
657
|
+
// asks for approval; those later calls must not become creation evidence.
|
|
658
|
+
let currentMainToolCallId: string | undefined;
|
|
407
659
|
/**
|
|
408
|
-
* The
|
|
409
|
-
*
|
|
410
|
-
*
|
|
660
|
+
* The main calls of the turn in flight, in order: pi persists them with their
|
|
661
|
+
* assistant message before any runs. Cleared when the turn ends, when every
|
|
662
|
+
* one has a result (auto-mode/history.ts childHistoryCursor).
|
|
411
663
|
*/
|
|
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
|
-
};
|
|
664
|
+
let inFlightMainCalls: string[] = [];
|
|
665
|
+
const childCursor = (startedBy: string | undefined) => childHistoryCursor(startedBy, inFlightMainCalls, currentMainToolCallId);
|
|
420
666
|
|
|
421
667
|
/**
|
|
422
668
|
* Record a rule denial as its own `denied` transcript line, so the classifier
|
|
@@ -426,11 +672,10 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
426
672
|
* user decision about a class of action, and each already has its own
|
|
427
673
|
* model-facing text.
|
|
428
674
|
*/
|
|
429
|
-
const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } },
|
|
675
|
+
const recordRuleDenial = (result: { cause?: string; rule?: { raw?: string } }, toolCallId: string, subject: string) => {
|
|
430
676
|
if (mode !== "auto") return;
|
|
431
677
|
if (result.cause === "plan-mode" || result.cause === "protected-path" || result.cause === "working-dir" || result.cause === "mode") return;
|
|
432
|
-
|
|
433
|
-
capTranscript();
|
|
678
|
+
pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId, deniedSubject: subject, rule: result.rule?.raw ?? "deny" });
|
|
434
679
|
};
|
|
435
680
|
|
|
436
681
|
/**
|
|
@@ -453,37 +698,37 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
453
698
|
return { ...autoConfig, hardDeny: [...(autoConfig?.hardDeny ?? []), ...renderedDeny.lines] };
|
|
454
699
|
};
|
|
455
700
|
|
|
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[] = [];
|
|
701
|
+
// Persist input provenance beside the user message, not another transcript.
|
|
702
|
+
// pi stores extension-generated user turns in the same role; without this
|
|
703
|
+
// marker a resume could promote a skill body into the user's own intent.
|
|
704
|
+
const pendingInputs: { text: string; user: boolean }[] = [];
|
|
465
705
|
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
706
|
const text = event.text?.trim();
|
|
471
707
|
if (!text) return;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
708
|
+
pendingInputs.push({ text, user: event.source !== "extension" });
|
|
709
|
+
// Handled commands never produce a user message. Bound only this pending
|
|
710
|
+
// association queue; the active conversation itself is never truncated.
|
|
711
|
+
if (pendingInputs.length > 256) pendingInputs.shift();
|
|
712
|
+
});
|
|
713
|
+
pi.on("message_end", (event) => {
|
|
714
|
+
if (event.message.role !== "user") return;
|
|
715
|
+
const text = messageText(event.message.content);
|
|
716
|
+
const at = pendingInputs.findIndex((input) => text.trim() === input.text || text.trim().startsWith(`${input.text}\n`));
|
|
717
|
+
const input = at >= 0 ? pendingInputs.splice(at, 1)[0] : undefined;
|
|
718
|
+
// A digest identifies the message; the text is already in the session.
|
|
719
|
+
pi.appendEntry(CLASSIFIER_USER_INPUT, { timestamp: event.message.timestamp, messageDigest: userMessageDigest(text), userText: input?.user ? input.text : null });
|
|
476
720
|
});
|
|
477
721
|
|
|
478
722
|
/**
|
|
479
723
|
* Run the deterministic pre-gate, then the classifier. The pre-gate may only
|
|
480
724
|
* ever conclude "safe" (see auto-mode/shell-analysis.ts); when it does, the
|
|
481
|
-
* classifier call is skipped entirely
|
|
482
|
-
*
|
|
725
|
+
* classifier call is skipped entirely. A read-only command never gets here:
|
|
726
|
+
* `decide()` allows it as the shell tools' own check, in every mode. What the
|
|
727
|
+
* pre-gate still clears here is a safe line that writes inside the project
|
|
728
|
+
* (a redirect).
|
|
483
729
|
*/
|
|
484
730
|
const runClassifier = async (
|
|
485
731
|
toolName: string,
|
|
486
|
-
input: Record<string, unknown>,
|
|
487
732
|
subject: string,
|
|
488
733
|
ctx: ExtensionContext,
|
|
489
734
|
/**
|
|
@@ -500,7 +745,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
500
745
|
* aborted). All default to the parent's own values, so the main path is
|
|
501
746
|
* unchanged.
|
|
502
747
|
*/
|
|
503
|
-
opts?: { cwd?: string; appendEntry?: TranscriptEntry; signal?: AbortSignal },
|
|
748
|
+
opts?: { cwd?: string; appendEntry?: TranscriptEntry; toolCallId?: string; history?: HistoryCursor; signal?: AbortSignal; powershellParse?: PowerShellParse },
|
|
504
749
|
) => {
|
|
505
750
|
const cwd = opts?.cwd ?? ctx.cwd;
|
|
506
751
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
@@ -508,37 +753,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
508
753
|
const home = os.homedir();
|
|
509
754
|
const allow = () => ({ decision: "allow" as const, reason: "", tier: undefined });
|
|
510
755
|
|
|
511
|
-
let evidence: ShellEvidence | undefined;
|
|
512
756
|
if (normalizeToolName(toolName) === "bash" && subject) {
|
|
513
|
-
|
|
514
|
-
if (evidence.verdict === "safe") {
|
|
757
|
+
if (analyzeShellCommand({ command: subject, cwd, home, protectedDirs, readableRoots }).verdict === "safe") {
|
|
515
758
|
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
516
759
|
return allow();
|
|
517
760
|
}
|
|
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
761
|
} 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) {
|
|
762
|
+
// A line PowerShell's own parse proves read-only runs unclassified;
|
|
763
|
+
// contained edits were cleared in decide() where the model's tier allows
|
|
764
|
+
// them, and everything else goes to the classifier
|
|
765
|
+
// (decisions/windows.md "The PowerShell pre-gate reads PowerShell's own parse").
|
|
766
|
+
if (powershellReadOnly({ parse: opts?.powershellParse, cwd, home, readableRoots }).readOnly) {
|
|
542
767
|
logDecision(ctx, { tool: toolName, subject, outcome: "allow", source: "pre-gate" });
|
|
543
768
|
return allow();
|
|
544
769
|
}
|
|
@@ -546,16 +771,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
546
771
|
// A write/edit whose target is inside the project and not a credential
|
|
547
772
|
// path is ordinary sandbox work — Claude Code auto-approves it, and so do
|
|
548
773
|
// we (protected paths never reach here: decide() routes them with
|
|
549
|
-
// containmentEligible=false).
|
|
550
|
-
//
|
|
551
|
-
// core of unattended coding autonomy. Execution-primitive paths (build
|
|
774
|
+
// containmentEligible=false). Edit-then-iterate is the core of
|
|
775
|
+
// unattended coding autonomy. Execution-primitive paths (build
|
|
552
776
|
// wrappers, CI workflows, editor auto-run config) are excluded exactly as
|
|
553
777
|
// the bash pre-gate excludes them — being in-project does not make a file
|
|
554
778
|
// that runs later without further approval safe to write unclassified.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
779
|
+
// `subject` is pathArgument's pick, the field every other gate judged;
|
|
780
|
+
// picking a field again here once approved a different file from the
|
|
781
|
+
// one written (AUTO-MODE-SECURITY-REVIEW-2026-09-24 H1).
|
|
782
|
+
if (subject) {
|
|
783
|
+
const absolute = toAbsolute(cwd, subject, home);
|
|
559
784
|
const resolved = resolveForContainment(absolute);
|
|
560
785
|
const root = resolveForContainment(cwd) ?? cwd;
|
|
561
786
|
if (resolved && isWithin(root, resolved) && !isSensitivePath(absolute) && !isExecutionPrimitivePath(absolute)) {
|
|
@@ -565,14 +790,26 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
565
790
|
}
|
|
566
791
|
}
|
|
567
792
|
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
793
|
+
// Project pi's active context on every call. Compaction, resume and /tree
|
|
794
|
+
// therefore change classifier context at the same boundary as the agent's.
|
|
795
|
+
const { transcript: history, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), opts?.toolCallId
|
|
796
|
+
? { beforeToolCallId: opts.toolCallId }
|
|
797
|
+
: (opts?.history ?? { throughToolCallId: currentMainToolCallId }));
|
|
798
|
+
const action = opts?.appendEntry;
|
|
799
|
+
const shell = normalizeToolName(toolName) === "powershell" ? "powershell" : isShellTool(normalizeToolName(toolName)) || normalizeToolName(toolName) === "monitor" ? "bash" : undefined;
|
|
800
|
+
if (shell && subject && wantsGitStatusMeta(shell, subject)) {
|
|
801
|
+
const porcelain = await gitStatusOutput(cwd, gitStatusMetaArgs(reachesIgnoredFiles(shell, subject)));
|
|
802
|
+
const gitStatus = porcelain === undefined ? undefined : gitStatusMeta(porcelain);
|
|
803
|
+
if (gitStatus) {
|
|
804
|
+
history.push({ kind: "meta", gitStatus });
|
|
805
|
+
if (opts?.toolCallId) pi.appendEntry(CLASSIFIER_TOOL_META, { toolCallId: opts.toolCallId, gitStatus });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const resolvedPaths = actionResolvedPaths(normalizeToolName(toolName), subject, opts?.powershellParse, { cwd, home, roots: [cwd, ...workspaceDirs] });
|
|
572
809
|
const verdict = await classify(
|
|
573
810
|
{
|
|
574
811
|
toolName,
|
|
575
|
-
transcript:
|
|
812
|
+
transcript: [...history, ...(resolvedPaths ? [{ kind: "resolved-paths" as const, resolvedPaths }] : []), ...(action ? [action] : [])],
|
|
576
813
|
userMessages: [...userMessages],
|
|
577
814
|
claudeMd: instructionsFor(cwd),
|
|
578
815
|
username: classifierUsername,
|
|
@@ -587,6 +824,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
587
824
|
config: autoConfig,
|
|
588
825
|
signal: opts?.signal ?? ctx.signal,
|
|
589
826
|
state: classifierState,
|
|
827
|
+
cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
|
|
590
828
|
onUsage: onClassifierUsage,
|
|
591
829
|
onModelUnusable: onClassifierModelUnusable,
|
|
592
830
|
onNotice: (message, level) => {
|
|
@@ -607,56 +845,79 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
607
845
|
ruleId: verdict.ruleId,
|
|
608
846
|
reason: verdict.reason || undefined,
|
|
609
847
|
raw: verdict.raw,
|
|
848
|
+
transcriptTooLong: verdict.transcriptTooLong,
|
|
610
849
|
model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
|
|
611
850
|
});
|
|
612
851
|
return verdict;
|
|
613
852
|
};
|
|
614
853
|
|
|
615
|
-
|
|
854
|
+
/** `startup`: the session opens in this mode, so there is no switch to announce, only the mode's standing block. */
|
|
855
|
+
const setMode = (next: PermissionMode, startup = false, toolCallId?: string) => {
|
|
856
|
+
const changed = mode !== next;
|
|
857
|
+
/** The mode the branch recorded before this switch; its model was told it once a request went out. */
|
|
858
|
+
const told = branchMode;
|
|
616
859
|
mode = next;
|
|
617
860
|
process.env[MODE_ENV] = next;
|
|
618
|
-
//
|
|
861
|
+
// Close the standing block of the mode being left BEFORE the status
|
|
619
862
|
// broadcast in applyBadge: the plan-mode extension owns the shared
|
|
620
863
|
// "permission-mode" key while planning (it knows the plan file) and
|
|
621
864
|
// re-installs its block synchronously from that broadcast when the mode
|
|
622
865
|
// becomes plan — removing the key afterwards would undo that re-add and
|
|
623
866
|
// leave a turn entered mid-stream with no plan reminder until the next
|
|
624
867
|
// prompt (STEERING-REVIEW-2026-09-05 M3). Auto installs its own block below.
|
|
625
|
-
if (mode !== "auto"
|
|
626
|
-
|
|
868
|
+
if (mode !== "auto" && !(mode === "plan" && (startup ? branchMode === "plan" : !changed))) {
|
|
869
|
+
pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "permission-mode" });
|
|
870
|
+
}
|
|
871
|
+
// The mode follows the branch: recorded on every change, so a resume, a
|
|
872
|
+
// fork, /reload or a /tree switch starts from it (decisions/modes.md).
|
|
873
|
+
if (mode !== branchMode) {
|
|
874
|
+
recordState(pi, PERMISSION_MODE_STATE, mode);
|
|
875
|
+
branchMode = mode;
|
|
876
|
+
}
|
|
877
|
+
applyBadge(undefined, toolCallId);
|
|
627
878
|
// Every switch is announced once on the tail of the next request (the next
|
|
628
879
|
// tool result mid-turn, the prompt between turns), so the model learns of
|
|
629
880
|
// 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).
|
|
881
|
+
// from that point forward; the announcement also names the plan file
|
|
882
|
+
// (published by plan-mode's refresh inside the applyBadge broadcast above).
|
|
634
883
|
const planNote =
|
|
635
884
|
mode === "plan"
|
|
636
885
|
? planFilePath
|
|
637
886
|
? ` 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
887
|
: " Only read-only tools are available now, plus the plan file named in the plan-mode reminder."
|
|
639
888
|
: "";
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
889
|
+
// Not at startup: "is now" beside the mode's own standing block read as a
|
|
890
|
+
// duplicate to both GPT-6 models in the 2026-10-04 self-test, and Claude
|
|
891
|
+
// Code announces only a switch. A startup that leaves the mode the branch
|
|
892
|
+
// recorded (a flag, a stored bypass that needs its flag again) is one.
|
|
893
|
+
if (!startup || (told !== undefined && told !== mode && requestOnBranch)) {
|
|
894
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
895
|
+
text: `The user's permission mode is now "${mode}".${planNote}`,
|
|
896
|
+
key: "permission-mode-change",
|
|
897
|
+
toolCallId,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
644
900
|
if (mode === "auto") {
|
|
645
901
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
646
902
|
text:
|
|
647
903
|
"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
|
|
904
|
+
"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
905
|
"Attempting to weaken this gate (editing permission settings, changing the mode, or routing work around it) is itself blocked.",
|
|
650
|
-
scope: "every-turn",
|
|
651
906
|
key: "permission-mode",
|
|
652
907
|
// Session state: rides every user message since auto mode came on, so
|
|
653
908
|
// the cached prefix holds turn to turn (lib/reminders.ts).
|
|
654
909
|
placement: "sticky-append",
|
|
910
|
+
toolCallId,
|
|
655
911
|
});
|
|
656
912
|
}
|
|
657
913
|
};
|
|
658
914
|
|
|
659
|
-
|
|
915
|
+
/**
|
|
916
|
+
* Re-read the permission rules only: what a rule edit in /permissions needs.
|
|
917
|
+
* The mode, a declined project-rule prompt and auto mode's cached config are
|
|
918
|
+
* left alone (reloadSettings resets those for a new session).
|
|
919
|
+
*/
|
|
920
|
+
const reloadRules = (ctx: ExtensionContext) => {
|
|
660
921
|
const settings = loadPermissionSettings(ctx.cwd, os.homedir());
|
|
661
922
|
const parsed = {
|
|
662
923
|
deny: parseRulesReport(settings.deny),
|
|
@@ -669,10 +930,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
669
930
|
allow = parsed.allow.rules;
|
|
670
931
|
projectAllow = parsed.projectAllow.rules;
|
|
671
932
|
projectAllowRaw = settings.projectAllow;
|
|
933
|
+
blockOutsideReads = settings.blockReadsOutsideWorkingDirectories === true;
|
|
672
934
|
// A linked worktree shares its main checkout's consent (findProjectRoot).
|
|
673
935
|
projectRoot = findProjectRoot(ctx.cwd) ?? ctx.cwd;
|
|
674
|
-
|
|
675
|
-
|
|
936
|
+
// A settings file is held to what /add-dir and --add-dir refuse: `/` or `~`
|
|
937
|
+
// there would make almost the whole disk working space.
|
|
938
|
+
const tooBroad: string[] = [];
|
|
939
|
+
settingsWorkspaceDirs = listWorkspaceDirectories(ctx.cwd, os.homedir()).filter((dir) => {
|
|
940
|
+
const refusal = tooBroadForWorkspace(tryRealpath(dir.path) ?? dir.path, os.homedir());
|
|
941
|
+
if (refusal) tooBroad.push(`${dir.raw} in ${tildify(dir.settingsPath, os.homedir())}: ${refusal}`);
|
|
942
|
+
return !refusal;
|
|
943
|
+
});
|
|
944
|
+
const tooBroadSignature = tooBroad.join("\n");
|
|
945
|
+
if (tooBroad.length > 0 && ctx.hasUI && tooBroadSignature !== warnedTooBroad) {
|
|
946
|
+
warnedTooBroad = tooBroadSignature;
|
|
947
|
+
ctx.ui.notify(`Ignored permissions.additionalDirectories entries: ${tooBroad.join("; ")}`, "warning");
|
|
948
|
+
}
|
|
949
|
+
projectAllowTrusted = projectAllowApproved(projectRoot, projectTrustList());
|
|
950
|
+
refreshWorkspace();
|
|
676
951
|
// A rule that fails to parse is a rule the user believes is in force and is
|
|
677
952
|
// not. Say so (once per distinct set) and list them in /permissions.
|
|
678
953
|
unparsableRules = [...parsed.deny.dropped, ...parsed.ask.dropped, ...parsed.allow.dropped, ...parsed.projectAllow.dropped];
|
|
@@ -684,6 +959,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
684
959
|
"warning",
|
|
685
960
|
);
|
|
686
961
|
}
|
|
962
|
+
return settings;
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* `launching`: the process is starting (reason "startup"), when its flags
|
|
967
|
+
* pick the mode. `carried`: the mode a /clear or /new carries onto its empty branch.
|
|
968
|
+
*/
|
|
969
|
+
const reloadSettings = (ctx: ExtensionContext, launching: boolean, carried: PermissionMode | undefined) => {
|
|
970
|
+
const settings = reloadRules(ctx);
|
|
971
|
+
projectAllowDeclined = false;
|
|
687
972
|
// Dropped so edited autoMode rules and instruction files are picked up on
|
|
688
973
|
// reload rather than staying cached for the life of the process.
|
|
689
974
|
autoConfig = undefined;
|
|
@@ -692,68 +977,184 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
692
977
|
// Claude Code's resolution order (flags, then settings' defaultMode), with
|
|
693
978
|
// bypassPermissions refused wherever a settings source disabled it — before
|
|
694
979
|
// 2026-09-05 (H3) that policy was silently not in force here.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
980
|
+
// The mode follows the branch: at launch the flags win over it, and after
|
|
981
|
+
// that (/reload, /resume, /fork, /clear in the same process) the branch's
|
|
982
|
+
// own mode wins over the launch flags, so a switch the user made is kept.
|
|
983
|
+
// A stored bypassPermissions is never restored by itself: it needs its flag.
|
|
984
|
+
const bypassFlag = pi.getFlag("dangerously-skip-permissions") === true ? ("bypassPermissions" as const) : undefined;
|
|
985
|
+
const modeFlag = normalizePermissionMode(pi.getFlag("permission-mode"));
|
|
986
|
+
const fromBranch = branchMode ?? carried;
|
|
987
|
+
const restored = fromBranch === "bypassPermissions" ? undefined : fromBranch;
|
|
988
|
+
const startup = resolveStartupMode(launching ? [bypassFlag, modeFlag, restored, settings.defaultMode] : [restored, bypassFlag, modeFlag, settings.defaultMode], settings);
|
|
703
989
|
if (startup.mode) mode = startup.mode;
|
|
704
990
|
if (startup.bypassRefused && ctx.hasUI) ctx.ui.notify("Bypass permissions mode was disabled by settings", "warning");
|
|
705
991
|
bypassInCycle = mode === "bypassPermissions";
|
|
706
992
|
// Auto mode needs a model to run its classifier on; with none reachable it
|
|
707
993
|
// would block every call, so it stays out of the cycle instead — the same
|
|
708
994
|
// thing Claude Code does when auto mode's requirements aren't met. The
|
|
709
|
-
//
|
|
710
|
-
//
|
|
995
|
+
// classifier stays on this session's provider, falling back to the session
|
|
996
|
+
// model when no capable window-compatible alternative is available.
|
|
711
997
|
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
712
998
|
// A mode that owns a standing reminder must be announced when the session
|
|
713
999
|
// STARTS in it, not only when the user switches into it. setMode is the
|
|
714
1000
|
// only emitter of the block; until 2026-09-05 startup called it for plan
|
|
715
1001
|
// alone, so `--permission-mode auto` / `defaultMode: "auto"` sessions ran
|
|
716
1002
|
// with no auto-mode reminder at all (STEERING-REVIEW-2026-09-05 H2).
|
|
717
|
-
if (STANDING_REMINDER_MODES.has(mode)) setMode(mode);
|
|
1003
|
+
if (STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
|
|
718
1004
|
process.env[MODE_ENV] = mode;
|
|
719
1005
|
};
|
|
720
1006
|
|
|
1007
|
+
// Claude Code's auto-mode note joins the mid-conversation system message
|
|
1008
|
+
// when the conversation opens in auto mode (auto-mode/system-note.ts). It is
|
|
1009
|
+
// live state, rendered only by a request that opens an epoch, so message 1
|
|
1010
|
+
// never changes inside one; a compaction or a model switch decides it again.
|
|
1011
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
1012
|
+
const note = mode === "auto" && ctx.model ? autoModeSystemNote(ctx.model) : undefined;
|
|
1013
|
+
if (!note) {
|
|
1014
|
+
pi.events.emit(REMINDER_CHANNEL, { remove: true, key: "auto-mode-note" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
1018
|
+
text: note,
|
|
1019
|
+
key: "auto-mode-note",
|
|
1020
|
+
placement: "first-prepend",
|
|
1021
|
+
order: CONTEXT_ORDER.autoModeNote,
|
|
1022
|
+
systemRoleOnly: true,
|
|
1023
|
+
} satisfies ReminderPayload);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// A turn's calls are all persisted before the first runs, and all have a
|
|
1027
|
+
// result by turn_end (an aborted run ends the agent instead).
|
|
1028
|
+
pi.on("turn_start", () => {
|
|
1029
|
+
inFlightMainCalls = [];
|
|
1030
|
+
});
|
|
1031
|
+
pi.on("turn_end", () => {
|
|
1032
|
+
inFlightMainCalls = [];
|
|
1033
|
+
});
|
|
1034
|
+
pi.on("agent_end", () => {
|
|
1035
|
+
inFlightMainCalls = [];
|
|
1036
|
+
});
|
|
1037
|
+
|
|
721
1038
|
pi.on("session_start", (event, ctx) => {
|
|
1039
|
+
childPermissionSession?.abort();
|
|
1040
|
+
childPermissionSession = new AbortController();
|
|
722
1041
|
badgeCtx = ctx;
|
|
723
1042
|
lastReviewCtx = ctx;
|
|
724
1043
|
sessionEpoch++;
|
|
1044
|
+
panels.reset();
|
|
1045
|
+
gateFastPaths = usesClaudeCodeFastPaths(ctx.model);
|
|
1046
|
+
// Not awaited: the grammar loads in a few milliseconds, and the gate
|
|
1047
|
+
// awaits it per call. Only a failed load is worth telling the user about.
|
|
1048
|
+
void bashParserReady().then(() => {
|
|
1049
|
+
const why = bashParserUnavailable();
|
|
1050
|
+
if (why && ctx.hasUI) ctx.ui.notify(`One Code: ${why}. Every bash command will be escalated or prompted.`, "error");
|
|
1051
|
+
});
|
|
725
1052
|
// A new session (`/clear`, `/resume`, a fork) starts with a clean gate: the
|
|
726
1053
|
// previous conversation's user messages are not intent evidence for this
|
|
727
|
-
// one, its "don't ask again" grants and
|
|
728
|
-
// (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps
|
|
1054
|
+
// one, and its "don't ask again" grants, approved denials and pause state
|
|
1055
|
+
// do not carry over (PERMISSIONS-REVIEW-2026-09-05 L1). A reload keeps
|
|
1056
|
+
// them, the same conversation: they are process state (SessionGate),
|
|
1057
|
+
// which the factory pi re-runs there does not reset.
|
|
729
1058
|
if (event.reason !== "reload") {
|
|
730
1059
|
sessionAllows.length = 0;
|
|
731
|
-
|
|
732
|
-
|
|
1060
|
+
currentMainToolCallId = undefined;
|
|
1061
|
+
inFlightMainCalls = [];
|
|
1062
|
+
pendingInputs.length = 0;
|
|
733
1063
|
pauseTracker.reset();
|
|
1064
|
+
denials.reset();
|
|
734
1065
|
}
|
|
735
|
-
|
|
736
|
-
|
|
1066
|
+
// The directories this branch was told it added follow it, so a reload, a
|
|
1067
|
+
// resume and a fork keep them and a /clear starts without.
|
|
1068
|
+
restoreSessionWorkspace(ctx);
|
|
1069
|
+
// With auto-memory off the folder is nobody's working space: no grant.
|
|
1070
|
+
memoryDirPath = autoMemoryEnabled(ctx.cwd) ? projectMemoryDir(ctx.cwd) : undefined;
|
|
1071
|
+
// Working space only when it is private to this user: another local user
|
|
1072
|
+
// who owns the directory on a shared /tmp could swap what is written there.
|
|
1073
|
+
scratchpadDirPath = privateSessionScratchpadDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
737
1074
|
// Resolved like the subjects compared against it (a symlinked parent, macOS /var).
|
|
738
1075
|
resultsDirPath = resolvedOrSelf(sessionResultsDir(ctx));
|
|
739
1076
|
sessionDirPath = resolvedOrSelf(ctx.sessionManager.getSessionDir());
|
|
740
|
-
|
|
1077
|
+
harnessReadableRoots = [memoryDirPath, scratchpadDirPath, resultsDirPath, sessionDirPath].filter((d): d is string => !!d).map(resolvedOrSelf);
|
|
1078
|
+
docsDirPaths = guideDocsDirs().map(resolvedOrSelf);
|
|
1079
|
+
// `--add-dir`: each directory is validated like one added in the panel.
|
|
1080
|
+
flagWorkspaceDirs = [];
|
|
1081
|
+
for (const entry of parseAddDirFlag(pi.getFlag("add-dir") as string | undefined)) {
|
|
1082
|
+
const checked = validateWorkspaceDirectory(entry, ctx.cwd, os.homedir(), flagWorkspaceDirs);
|
|
1083
|
+
if ("path" in checked) flagWorkspaceDirs.push(checked.path);
|
|
1084
|
+
else if (ctx.hasUI) ctx.ui.notify(`--add-dir ${entry}: ${checked.error}`, "warning");
|
|
1085
|
+
}
|
|
741
1086
|
resolvedCwd = resolveForContainment(ctx.cwd);
|
|
742
1087
|
oneCodeProjectSettingsFile = oneCodeProjectSettingsPath(ctx.cwd, os.homedir());
|
|
743
1088
|
protectedDirs = runtimeProtectedDirs();
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
1089
|
+
secretPaths = runtimeSecretPaths(os.homedir());
|
|
1090
|
+
branchMode = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
|
|
1091
|
+
requestOnBranch = (ctx.sessionManager.getBranch() as EntryLike[]).some((entry) => readRecord(entry)?.kind === "request");
|
|
1092
|
+
// Claude Code keeps the mode across /clear: the new branch starts from the
|
|
1093
|
+
// mode this process last published (MODE_ENV), and records it.
|
|
1094
|
+
const carried = event.reason === "new" && !branchMode ? normalizePermissionMode(process.env[MODE_ENV]) : undefined;
|
|
1095
|
+
reloadSettings(ctx, event.reason === "startup", carried);
|
|
1096
|
+
if (!STANDING_REMINDER_MODES.has(mode)) setMode(mode, true);
|
|
1097
|
+
// The system prompt lists the workspace as the session starts (lib/workspace-channel.ts).
|
|
1098
|
+
sessionStarted = true;
|
|
1099
|
+
announcedWorkspace = undefined;
|
|
1100
|
+
announceWorkspace();
|
|
1101
|
+
resetClassifierChoice(ctx.model, "batched");
|
|
1102
|
+
// The bridge reads live mode/rules, but belongs to this parent session only.
|
|
1103
|
+
pi.events.emit(SUBAGENT_GATE_CHANNEL, { decide: sessionPermissionBridge(evaluateChildToolCall, childPermissionSession.signal) });
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// A /tree switch selects a branch whose model was told that branch's mode:
|
|
1107
|
+
// take it, as a resume would, without announcing a switch. A stored
|
|
1108
|
+
// bypassPermissions is never taken.
|
|
1109
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
1110
|
+
// The selected branch's model was told its own session directories.
|
|
1111
|
+
restoreSessionWorkspace(ctx);
|
|
1112
|
+
refreshWorkspace();
|
|
1113
|
+
const onBranch = normalizePermissionMode(readState(ctx, PERMISSION_MODE_STATE));
|
|
1114
|
+
if (!onBranch || onBranch === "bypassPermissions" || onBranch === mode) return;
|
|
1115
|
+
branchMode = onBranch;
|
|
1116
|
+
setMode(onBranch, true);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// claude-context re-reads the instruction files at a compaction, and the
|
|
1120
|
+
// summary's epoch renders them, so the classifier judges against the same
|
|
1121
|
+
// set from then on. Unchanged files give the same bytes, so its cache holds;
|
|
1122
|
+
// an edit costs one classifier cache write at this boundary.
|
|
1123
|
+
pi.on("session_compact", () => {
|
|
1124
|
+
projectInstructionsByCwd.clear();
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// The published bridge stays: aborted, it denies every call a retained child
|
|
1128
|
+
// makes, while a parent without this extension keeps the local gate.
|
|
1129
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
1130
|
+
childPermissionSession?.abort();
|
|
1131
|
+
lastReviewCtx = undefined;
|
|
1132
|
+
sessionEpoch++;
|
|
1133
|
+
// Children screen through this session's key too (evaluateChildToolCall
|
|
1134
|
+
// uses lastReviewCtx), so one release covers their calls.
|
|
1135
|
+
releaseClassifierResources(ctx.sessionManager.getSessionId());
|
|
750
1136
|
});
|
|
751
1137
|
|
|
752
1138
|
pi.on("model_select", (event, ctx) => {
|
|
753
1139
|
badgeCtx = ctx;
|
|
754
1140
|
lastReviewCtx = ctx;
|
|
755
1141
|
autoInCycle = ctx.modelRegistry.getAvailable().length > 0;
|
|
756
|
-
resetClassifierChoice(event.model);
|
|
1142
|
+
resetClassifierChoice(event.model, "batched");
|
|
1143
|
+
// The gate reads the model's tier on every call, so a switch takes effect
|
|
1144
|
+
// on the next one; a switch across the workhorse/cheap line says so.
|
|
1145
|
+
const fastPaths = usesClaudeCodeFastPaths(event.model);
|
|
1146
|
+
if (gateFastPaths !== undefined && fastPaths !== gateFastPaths && event.model) {
|
|
1147
|
+
const name = `${formatModel(event.model.provider, event.model.id)} (${intrinsicTier(event.model)} tier)`;
|
|
1148
|
+
queueNotice(
|
|
1149
|
+
pi.events,
|
|
1150
|
+
ctx,
|
|
1151
|
+
"info",
|
|
1152
|
+
fastPaths
|
|
1153
|
+
? `${name}: permissions now follow Claude Code's fast paths.`
|
|
1154
|
+
: `${name}: permissions now use One Code's stricter checks, so more calls prompt or go to the auto-mode classifier.`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
gateFastPaths = fastPaths;
|
|
757
1158
|
});
|
|
758
1159
|
|
|
759
1160
|
// The badge carries "· esc to interrupt" only while the model works (CC's
|
|
@@ -763,6 +1164,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
763
1164
|
lastReviewCtx = ctx;
|
|
764
1165
|
streaming = true;
|
|
765
1166
|
applyBadge();
|
|
1167
|
+
// Start the PowerShell parser before the first PowerShell call needs it,
|
|
1168
|
+
// never awaited: its first start can take seconds on Windows.
|
|
1169
|
+
if (mode !== "bypassPermissions" && pi.getActiveTools().includes("powershell")) void powerShellParser()?.parse("");
|
|
766
1170
|
});
|
|
767
1171
|
// Settle, not agent_end: `agent_end` fires per run, so flipping there made
|
|
768
1172
|
// the badge's "· esc to interrupt" suffix flicker off and back on across a
|
|
@@ -778,8 +1182,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
778
1182
|
// `/auto-mode model` does after its own write, so the next call re-plans.
|
|
779
1183
|
pi.events.on(CLASSIFIER_SETTING_CHANGED_CHANNEL, () => resetClassifierChoice(badgeCtx?.model));
|
|
780
1184
|
pi.events.on(MODE_CHANNEL, (data) => {
|
|
781
|
-
const
|
|
782
|
-
|
|
1185
|
+
const request = data as { mode?: unknown; toolCallId?: unknown } | undefined;
|
|
1186
|
+
const requested = normalizePermissionMode(request?.mode);
|
|
1187
|
+
if (!requested) return;
|
|
1188
|
+
// Another extension asks (plan mode's exit restores the mode it came from,
|
|
1189
|
+
// read from the branch). A stored value never grants a permission by
|
|
1190
|
+
// itself: bypass only where this session has it, else the default mode.
|
|
1191
|
+
const allowed = requested === "bypassPermissions" && !bypassInCycle ? "default" : requested;
|
|
1192
|
+
setMode(allowed, false, typeof request?.toolCallId === "string" ? request.toolCallId : undefined);
|
|
783
1193
|
});
|
|
784
1194
|
|
|
785
1195
|
// The plan-mode extension announces the plan file on PLAN_FILE_CHANNEL;
|
|
@@ -794,7 +1204,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
794
1204
|
// that terminals don't already own (ctrl+m *is* Enter's byte, alt needs
|
|
795
1205
|
// option-as-meta configured on macOS) — except on Windows and WSL, where
|
|
796
1206
|
// pi binds ctrl+q itself and the key is alt+m (lib/keys.ts) — see
|
|
797
|
-
// docs/decisions.md.
|
|
1207
|
+
// working-docs/decisions.md.
|
|
798
1208
|
pi.registerShortcut(CYCLE_KEY, {
|
|
799
1209
|
description: "Cycle permission mode",
|
|
800
1210
|
handler: (ctx) => {
|
|
@@ -809,13 +1219,29 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
809
1219
|
let lastReviewCtx: ExtensionContext | undefined;
|
|
810
1220
|
/** Bumped per session_start: an async review that finishes after a /clear must not post into the new session. */
|
|
811
1221
|
let sessionEpoch = 0;
|
|
1222
|
+
let childPermissionSession: AbortController | undefined;
|
|
812
1223
|
|
|
813
1224
|
// Serialize interactive prompts: a background/resident subagent can hit an "ask"
|
|
814
1225
|
// while the main turn (or another child) is already awaiting one, and driving
|
|
815
1226
|
// ctx.ui.select concurrently on one terminal is unverified. This chains them.
|
|
1227
|
+
// A prompt also waits while a panel holds the keyboard (/permissions, the
|
|
1228
|
+
// agent view, a picker): pi would put the prompt in the panel's place, the
|
|
1229
|
+
// panel would not come back, and an Enter meant for the panel would answer
|
|
1230
|
+
// the prompt. Claude Code holds its permission dialog the same way.
|
|
816
1231
|
let promptChain: Promise<unknown> = Promise.resolve();
|
|
817
|
-
|
|
818
|
-
|
|
1232
|
+
/** Calls the user rejected without a reason: the turn stops once pi records their result. */
|
|
1233
|
+
const abortAfterResult = new Set<string>();
|
|
1234
|
+
const panels = new PanelFocus();
|
|
1235
|
+
pi.events.on(PANEL_FOCUS_CHANNEL, (data) => panels.note(data));
|
|
1236
|
+
const serializePrompt = <T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> => {
|
|
1237
|
+
const show = async () => {
|
|
1238
|
+
if (panels.busy && !signal?.aborted) {
|
|
1239
|
+
liveUiCtx(lastReviewCtx)?.ui.notify(PROMPT_WAITS_FOR_PANEL, "info");
|
|
1240
|
+
await panels.whenClear(signal);
|
|
1241
|
+
}
|
|
1242
|
+
return fn();
|
|
1243
|
+
};
|
|
1244
|
+
const run = promptChain.then(show, show);
|
|
819
1245
|
promptChain = run.then(
|
|
820
1246
|
() => {},
|
|
821
1247
|
() => {},
|
|
@@ -823,53 +1249,143 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
823
1249
|
return run;
|
|
824
1250
|
};
|
|
825
1251
|
|
|
1252
|
+
/** A context overflow judged nothing: ask once, or stop a headless turn. */
|
|
1253
|
+
const transcriptOverflow = async (ctx: ExtensionContext, toolName: string, subject: string, signal = ctx.signal): Promise<ChildGateDecision> => {
|
|
1254
|
+
if (!ctx.hasUI || ctx.mode === "print") {
|
|
1255
|
+
const reason = "auto mode classifier transcript exceeded context window in headless mode";
|
|
1256
|
+
ctx.ui.notify(`${reason}. Compact the session (/compact) and continue.`, "error");
|
|
1257
|
+
// A failed one-shot run, not a user cancel (exit/index.ts).
|
|
1258
|
+
pi.events.emit(TURN_FAILED_CHANNEL, { reason } satisfies TurnFailedEvent);
|
|
1259
|
+
ctx.abort();
|
|
1260
|
+
return { block: true, reason };
|
|
1261
|
+
}
|
|
1262
|
+
const reason = "Auto mode classifier transcript exceeded context window — falling back to manual approval";
|
|
1263
|
+
ctx.ui.notify(reason, "warning");
|
|
1264
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "prompt", source: "user", transcriptTooLong: true, reason });
|
|
1265
|
+
const choice = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.select(
|
|
1266
|
+
`${reason}\n\n${askTitle(toolName, previewSubject(subject), "mode", false)}`,
|
|
1267
|
+
askOptions(undefined), { signal },
|
|
1268
|
+
), signal);
|
|
1269
|
+
const allowed = !signal?.aborted && choice === YES;
|
|
1270
|
+
logDecision(ctx, { tool: toolName, subject, outcome: allowed ? "allow" : "block", source: "user", transcriptTooLong: true, reason });
|
|
1271
|
+
if (allowed) return undefined;
|
|
1272
|
+
if (!signal?.aborted && choice === NO) {
|
|
1273
|
+
const feedback = await serializePrompt(() => signal?.aborted ? Promise.resolve(undefined) : ctx.ui.input(
|
|
1274
|
+
"What should the agent do instead?", "Optional — press Esc to skip", { signal },
|
|
1275
|
+
), signal);
|
|
1276
|
+
return { block: true, reason: userDenialText(feedback) };
|
|
1277
|
+
}
|
|
1278
|
+
return { block: true, reason: "Manual approval was not granted after the auto-mode classifier transcript exceeded its context window." };
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Show an ask prompt through `queue`, timed when it is a paused-auto-mode
|
|
1283
|
+
* resume prompt that Claude Code's denial-limit fallback times (the first
|
|
1284
|
+
* after a consecutive-limit pause). pi shows a live countdown and resolves
|
|
1285
|
+
* an expired dialog as dismissed, so `unanswered` tells the two apart,
|
|
1286
|
+
* measured from when the dialog showed (not when it queued); the caller
|
|
1287
|
+
* then denies the call without asking for feedback. Shared by both gate paths.
|
|
1288
|
+
*/
|
|
1289
|
+
const askMaybeTimed = async (
|
|
1290
|
+
pausedResume: boolean,
|
|
1291
|
+
title: string,
|
|
1292
|
+
queue: (show: () => Promise<string | undefined>) => Promise<string | undefined>,
|
|
1293
|
+
select: (title: string, timeout: number | undefined) => Promise<string | undefined>,
|
|
1294
|
+
): Promise<{ choice: string | undefined; unanswered: boolean }> => {
|
|
1295
|
+
if (!pausedResume || !pauseTracker.takeTimedPrompt()) return { choice: await queue(() => select(title, undefined)), unanswered: false };
|
|
1296
|
+
const timeout = unattendedPromptTimeoutMs();
|
|
1297
|
+
let shownAt: number | undefined;
|
|
1298
|
+
const choice = await queue(() => {
|
|
1299
|
+
shownAt = Date.now();
|
|
1300
|
+
return select(`${title}\n\n${unattendedPromptNotice(timeout)}`, timeout);
|
|
1301
|
+
});
|
|
1302
|
+
return { choice, unanswered: choice === undefined && shownAt !== undefined && Date.now() - shownAt >= timeout - Math.min(1500, timeout / 10) };
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Claude Code's one-time prompt before auto mode's first read outside the
|
|
1307
|
+
* working directories (2.1.282, findings §33): raised only in an interactive
|
|
1308
|
+
* session that has not answered it, on this machine or in Claude Code. Yes
|
|
1309
|
+
* and Block settle it for good; Ask again, or dismissing, refuses this read
|
|
1310
|
+
* and asks on the next. Queued behind other prompts, and re-checked once its
|
|
1311
|
+
* turn comes, so parallel outside reads ask once.
|
|
1312
|
+
*/
|
|
1313
|
+
const firstOutsideRead = (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<"allow" | "block" | "ask_again"> => {
|
|
1314
|
+
const home = os.homedir();
|
|
1315
|
+
const settled = () => (outsideReadSeen ??= outsideReadPromptSeen(oneCodeSettingsPath(home), claudeSourcesOn() ? claudeJsonPath(home) : undefined));
|
|
1316
|
+
if (!ctx.hasUI) return Promise.resolve("allow");
|
|
1317
|
+
// Settled while this call waited (a parallel read's Block included).
|
|
1318
|
+
if (settled()) return Promise.resolve(blockOutsideReads ? "block" : "allow");
|
|
1319
|
+
return serializePrompt(async () => {
|
|
1320
|
+
if (signal?.aborted) return "ask_again";
|
|
1321
|
+
if (settled()) return blockOutsideReads ? "block" : "allow";
|
|
1322
|
+
const settingsFile = tildify(oneCodeSettingsPath(home), home);
|
|
1323
|
+
const title = `${OUTSIDE_READ_TITLE}\n\n ${toolName} ${path}\n\n${OUTSIDE_READ_QUESTION}\n\n${OUTSIDE_READ_EXPLAINER(settingsFile)}`;
|
|
1324
|
+
const choice = await ctx.ui.select(title, Object.values(OUTSIDE_READ_ANSWERS), { signal });
|
|
1325
|
+
if (signal?.aborted || (choice !== OUTSIDE_READ_ANSWERS.allow && choice !== OUTSIDE_READ_ANSWERS.block)) return "ask_again";
|
|
1326
|
+
outsideReadSeen = true;
|
|
1327
|
+
markOutsideReadPromptSeen(oneCodeSettingsPath(home));
|
|
1328
|
+
if (choice === OUTSIDE_READ_ANSWERS.allow) return "allow";
|
|
1329
|
+
persistBlockOutsideReads(oneCodeSettingsPath(home));
|
|
1330
|
+
blockOutsideReads = true;
|
|
1331
|
+
return "block";
|
|
1332
|
+
}, signal);
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
/** The block reason for a first-read prompt answered with Block or Ask again; undefined to run the read. */
|
|
1336
|
+
const outsideReadRefusal = async (ctx: ExtensionContext, toolName: string, path: string, signal = ctx.signal): Promise<string | undefined> => {
|
|
1337
|
+
const answer = await firstOutsideRead(ctx, toolName, path, signal);
|
|
1338
|
+
return answer === "allow" ? undefined : answer === "block" ? DENIED_CHOSE_BLOCK_OUTSIDE_READS : userDenialText();
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* An allowed call in auto mode breaks the block streak and ends a
|
|
1343
|
+
* consecutive-limit pause (`PauseTracker.recordAllow`), whatever allowed
|
|
1344
|
+
* it: a rule, a read, a fast path, the classifier or a /permissions grant.
|
|
1345
|
+
* A subagent's calls never touch the streak, as its blocks never count.
|
|
1346
|
+
*/
|
|
1347
|
+
const noteAllow = () => {
|
|
1348
|
+
if (pauseTracker.recordAllow()) applyBadge();
|
|
1349
|
+
};
|
|
1350
|
+
|
|
826
1351
|
pi.on("tool_call", async (event, ctx) => {
|
|
827
1352
|
lastReviewCtx = ctx;
|
|
1353
|
+
// Settled long before the first call; a failed load leaves every bash
|
|
1354
|
+
// command unparseable, which escalates or prompts.
|
|
1355
|
+
await bashParserReady();
|
|
828
1356
|
const normalizedTool = normalizeToolName(event.toolName);
|
|
1357
|
+
const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(event.input as Record<string, unknown>) : undefined;
|
|
1358
|
+
if (conflict) return { block: true, reason: conflict };
|
|
829
1359
|
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
1360
|
// In a worktree session, worktree's tool_call handler (which runs before this
|
|
838
1361
|
// one) cd-wraps bash commands for execution and publishes the model's
|
|
839
1362
|
// 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
|
|
1363
|
+
// call's id. Rule matching, the shell pre-gate and the prompt all evaluate
|
|
1364
|
+
// that original against the worktree cwd: matched against the wrapper
|
|
1365
|
+
// every configured Bash rule stopped matching, and analysed as the wrapper
|
|
1366
|
+
// (a `cd` plus a newline) every call escalated to the classifier and the
|
|
1367
|
+
// prompt showed `cd '…' && (…)` (PERMISSIONS-REVIEW-2026-09-05 L3). The safety floor keeps reading event.input (the wrapped
|
|
846
1368
|
// command that actually runs). The lookup is by toolCallId on purpose: a
|
|
847
1369
|
// value inside `event.input` would be the model's to write, and rules would
|
|
848
1370
|
// match a string of its choosing.
|
|
849
|
-
|
|
1371
|
+
// `monitor` is cd-wrapped the same way (worktree/rewrite.ts).
|
|
1372
|
+
const original = isShellTool(normalizedTool) || normalizedTool === "monitor" ? originalCommands.get(event.toolCallId) : undefined;
|
|
850
1373
|
const matchSubject = original?.command ?? subject;
|
|
851
|
-
const callCwd = original?.cwd ?? ctx.cwd;
|
|
1374
|
+
const callCwd = original?.cwd ?? workCwd(ctx.cwd);
|
|
1375
|
+
// File tools are rewritten to absolute worktree paths too. Both the
|
|
1376
|
+
// lexical and resolved working roots must follow entry; keeping the
|
|
1377
|
+
// main checkout's resolved root would still fast-path writes there.
|
|
1378
|
+
const resolvedCallCwd = callCwd === ctx.cwd ? resolvedCwd : resolveForContainment(callCwd);
|
|
1379
|
+
const resolvedSubject =
|
|
1380
|
+
isPathSubjectTool(normalizedTool) && subject
|
|
1381
|
+
? resolveForContainment(toAbsolute(callCwd, subject, os.homedir()))
|
|
1382
|
+
: undefined;
|
|
1383
|
+
const powershellParse = await parsePowerShell(normalizedTool, matchSubject, mode);
|
|
852
1384
|
|
|
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
|
-
}
|
|
1385
|
+
currentMainToolCallId = event.toolCallId;
|
|
1386
|
+
inFlightMainCalls.push(event.toolCallId);
|
|
871
1387
|
|
|
872
|
-
const decideWith = (allowRules: PermissionRule[]) =>
|
|
1388
|
+
const decideWith = (allowRules: PermissionRule[], dirs: string[] = workspaceDirs) =>
|
|
873
1389
|
decide({
|
|
874
1390
|
toolName: event.toolName,
|
|
875
1391
|
subject: matchSubject,
|
|
@@ -880,32 +1396,53 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
880
1396
|
allow: allowRules,
|
|
881
1397
|
classifyAllShell: classifyAllShell(),
|
|
882
1398
|
resolvedSubject,
|
|
883
|
-
resolvedCwd,
|
|
1399
|
+
resolvedCwd: resolvedCallCwd,
|
|
884
1400
|
planFilePath,
|
|
885
1401
|
memoryDirPath,
|
|
886
1402
|
scratchpadDirPath,
|
|
887
1403
|
resultsDirPath,
|
|
888
1404
|
sessionDirPath,
|
|
889
1405
|
protectedDirs,
|
|
1406
|
+
secretPaths,
|
|
1407
|
+
workspaceDirs: dirs,
|
|
1408
|
+
claudeCodeFastPaths: usesClaudeCodeFastPaths(ctx.model),
|
|
1409
|
+
blockReadsOutsideWorkingDirectories: blockOutsideReads,
|
|
1410
|
+
readOnlyDirs: docsDirPaths,
|
|
1411
|
+
powershellParse,
|
|
890
1412
|
});
|
|
891
1413
|
let result = decideWith([...allow, ...activeSessionAllows(), ...(projectAllowTrusted ? projectAllow : [])]);
|
|
892
1414
|
|
|
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
|
-
|
|
1415
|
+
// A repo-shipped allow rule or workspace directory would decide this call:
|
|
1416
|
+
// ask the user to trust the repository's settings first (once per list;
|
|
1417
|
+
// project-trust.ts). No UI → they stay off and the call takes the normal
|
|
1418
|
+
// path (fail closed).
|
|
1419
|
+
const trustProject = async (withProject: typeof result, firing: TrustFiring) => {
|
|
1420
|
+
const repoDirs = repoWorkspaceDirs().map((dir) => dir.raw);
|
|
1421
|
+
const { title, message } = describeProjectAllow(projectAllowRaw, firing, repoDirs);
|
|
1422
|
+
const approved = (await serializePrompt(() => ctx.ui.confirm(title, message, { signal: ctx.signal }), ctx.signal)) === true;
|
|
1423
|
+
if (ctx.signal?.aborted) return;
|
|
1424
|
+
if (approved) {
|
|
1425
|
+
projectAllowTrusted = true;
|
|
1426
|
+
persistProjectAllowApproval(projectRoot, projectTrustList());
|
|
1427
|
+
refreshWorkspace();
|
|
1428
|
+
result = withProject;
|
|
1429
|
+
} else {
|
|
1430
|
+
projectAllowDeclined = true;
|
|
1431
|
+
ctx.ui.notify("This repository's allow rules and workspace directories stay off for this session (its deny/ask rules still apply).", "info");
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
if (result.decision !== "allow" && !projectAllowTrusted && !projectAllowDeclined && ctx.hasUI) {
|
|
1435
|
+
const withRules = projectAllow.length > 0 ? decideWith([...allow, ...activeSessionAllows(), ...projectAllow]) : undefined;
|
|
1436
|
+
const repoDirs = repoWorkspaceDirs();
|
|
1437
|
+
if (withRules?.decision === "allow" && withRules.rule && projectAllow.includes(withRules.rule)) {
|
|
1438
|
+
await trustProject(withRules, { rule: withRules.rule.raw });
|
|
1439
|
+
} else if (repoDirs.length > 0) {
|
|
1440
|
+
const withDirs = decideWith([...allow, ...activeSessionAllows()], [...workspaceDirs, ...repoDirs.map((dir) => resolvedOrSelf(dir.path))]);
|
|
1441
|
+
// A read tool's allow is "tier" or "mode"; a shell read's is "read-only" ("plan-readonly" in plan mode).
|
|
1442
|
+
if (withDirs.decision === "allow" && (withDirs.cause === "tier" || withDirs.cause === "mode" || withDirs.cause === "read-only" || withDirs.cause === "plan-readonly")) {
|
|
1443
|
+
const target = resolvedSubject ?? toAbsolute(callCwd, matchSubject, os.homedir());
|
|
1444
|
+
const firing = repoDirs.find((dir) => isWithin(resolvedOrSelf(dir.path), target))?.raw ?? repoDirs[0].raw;
|
|
1445
|
+
await trustProject(withDirs, { dir: firing });
|
|
909
1446
|
}
|
|
910
1447
|
}
|
|
911
1448
|
}
|
|
@@ -924,17 +1461,28 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
924
1461
|
? safetyControlWrite({
|
|
925
1462
|
toolName: normalizeToolName(event.toolName),
|
|
926
1463
|
input: event.input as Record<string, unknown>,
|
|
927
|
-
cwd:
|
|
1464
|
+
cwd: callCwd,
|
|
928
1465
|
home: os.homedir(),
|
|
929
1466
|
oneCodeProjectSettings: oneCodeProjectSettingsFile,
|
|
930
1467
|
})
|
|
931
1468
|
: undefined;
|
|
932
1469
|
|
|
933
|
-
if (result.decision === "allow" &&
|
|
1470
|
+
if (result.decision === "allow" && result.cause === "outside-read") {
|
|
1471
|
+
const refusal = await outsideReadRefusal(ctx, normalizedTool, matchSubject);
|
|
1472
|
+
if (refusal) return { block: true, reason: refusal };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (result.decision === "allow" && !floorReason) {
|
|
1476
|
+
if (mode === "auto") {
|
|
1477
|
+
if (result.cause === "read-only") logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "pre-gate" });
|
|
1478
|
+
noteAllow();
|
|
1479
|
+
}
|
|
1480
|
+
return undefined;
|
|
1481
|
+
}
|
|
934
1482
|
|
|
935
1483
|
// (dontAsk is the only mode that denies rather than allows unmatched calls.)
|
|
936
1484
|
if (result.decision === "deny") {
|
|
937
|
-
recordRuleDenial(result, event.
|
|
1485
|
+
recordRuleDenial(result, event.toolCallId, matchSubject);
|
|
938
1486
|
return { block: true, reason: denyReason(result) };
|
|
939
1487
|
}
|
|
940
1488
|
|
|
@@ -946,10 +1494,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
946
1494
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
947
1495
|
if (mode === "auto" || !ctx.hasUI) {
|
|
948
1496
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
949
|
-
return { block: true, reason:
|
|
1497
|
+
return { block: true, reason: safetyFloorBlockText(floorReason) };
|
|
950
1498
|
}
|
|
951
1499
|
logDecision(ctx, { tool: event.toolName, subject, outcome: "prompt", source: "floor", reason: floorReason });
|
|
952
1500
|
} else if (result.decision === "classify") {
|
|
1501
|
+
// The exact call the user approved in /permissions after the classifier
|
|
1502
|
+
// denied it runs once without the classifier (auto-mode/denials.ts). Deny
|
|
1503
|
+
// rules and the safety floor were checked above, so no grant reaches them.
|
|
1504
|
+
const inputKey = denialInputKey(
|
|
1505
|
+
normalizedTool,
|
|
1506
|
+
original !== undefined ? { command: original.command } : (event.input as Record<string, unknown>),
|
|
1507
|
+
callCwd,
|
|
1508
|
+
isShellTool(normalizedTool),
|
|
1509
|
+
);
|
|
1510
|
+
if (denials.takeGrant(inputKey)) {
|
|
1511
|
+
logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "allow", source: "user", reason: "approved in /permissions" });
|
|
1512
|
+
noteAllow();
|
|
1513
|
+
return undefined;
|
|
1514
|
+
}
|
|
953
1515
|
// Auto mode is for unattended runs: a block is returned to the MODEL so it
|
|
954
1516
|
// can try a safe alternative — it is never raised as a per-action user
|
|
955
1517
|
// prompt. The one exception is the loop-breaker: after repeated blocks the
|
|
@@ -959,7 +1521,6 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
959
1521
|
if (!pauseTracker.isPaused()) {
|
|
960
1522
|
const outcome = await runClassifier(
|
|
961
1523
|
event.toolName,
|
|
962
|
-
event.input as Record<string, unknown>,
|
|
963
1524
|
matchSubject,
|
|
964
1525
|
ctx,
|
|
965
1526
|
// Protected paths must always be judged; everything else may be cleared
|
|
@@ -967,20 +1528,24 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
967
1528
|
result.cause !== "protected-path",
|
|
968
1529
|
// A worktree-wrapped command is analysed as the model wrote it, in the
|
|
969
1530
|
// worktree it runs in (the classifier transcript already holds it).
|
|
970
|
-
|
|
1531
|
+
{
|
|
1532
|
+
cwd: callCwd, powershellParse, toolCallId: event.toolCallId,
|
|
1533
|
+
appendEntry: { kind: "tool", tool: normalizedTool, input: original !== undefined ? { command: original.command } : event.input as Record<string, unknown> },
|
|
1534
|
+
},
|
|
971
1535
|
);
|
|
972
1536
|
if (outcome.decision === "allow") {
|
|
973
|
-
|
|
1537
|
+
noteAllow();
|
|
1538
|
+
denials.settle(inputKey);
|
|
974
1539
|
return undefined;
|
|
975
1540
|
}
|
|
976
1541
|
|
|
1542
|
+
if (outcome.transcriptTooLong) return transcriptOverflow(ctx, event.toolName, matchSubject);
|
|
1543
|
+
|
|
977
1544
|
// A timeout was not earned by looping against the gate, so it does not
|
|
978
1545
|
// count toward the auto-pause. It is returned to the model as retryable
|
|
979
1546
|
// (the call was never judged) rather than prompting — a prompt would
|
|
980
1547
|
// 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
|
-
}
|
|
1548
|
+
if (outcome.tier === "timeout") return { block: true, reason: classifierBlockReason(outcome) };
|
|
984
1549
|
|
|
985
1550
|
const tripped = pauseTracker.recordBlock({
|
|
986
1551
|
toolName: event.toolName,
|
|
@@ -990,6 +1555,19 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
990
1555
|
ruleId: outcome.ruleId,
|
|
991
1556
|
raw: outcome.raw,
|
|
992
1557
|
});
|
|
1558
|
+
// A verdict, not a failure to reach one, can be approved in /permissions.
|
|
1559
|
+
if (!outcome.noVerdict) {
|
|
1560
|
+
denials.record({
|
|
1561
|
+
toolName: normalizedTool,
|
|
1562
|
+
display: `${event.toolName}(${previewSubject(matchSubject)})`,
|
|
1563
|
+
fullDisplay: `${event.toolName}(${matchSubject})`,
|
|
1564
|
+
inputKey,
|
|
1565
|
+
reason: outcome.reason,
|
|
1566
|
+
...(outcome.ruleId ? { rule: outcome.ruleId } : {}),
|
|
1567
|
+
timestamp: Date.now(),
|
|
1568
|
+
});
|
|
1569
|
+
if (ctx.hasUI) ctx.ui.notify(deniedNotice(event.toolName, outcome.reason), "warning");
|
|
1570
|
+
}
|
|
993
1571
|
if (tripped) {
|
|
994
1572
|
const { lifetime } = pauseTracker.stats();
|
|
995
1573
|
ctx.ui.notify(
|
|
@@ -1000,8 +1578,9 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1000
1578
|
}
|
|
1001
1579
|
// Every classifier block (hard or soft) goes back to the model with the
|
|
1002
1580
|
// 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
|
-
|
|
1581
|
+
// does not hand a soft block to the user as a per-action prompt. A call
|
|
1582
|
+
// the classifier never judged is said to be one, not a denial.
|
|
1583
|
+
return { block: true, reason: classifierBlockReason(outcome) };
|
|
1005
1584
|
}
|
|
1006
1585
|
// Paused: fall through to the resume prompt below.
|
|
1007
1586
|
}
|
|
@@ -1027,7 +1606,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1027
1606
|
floor: floorReason !== undefined,
|
|
1028
1607
|
home: os.homedir(),
|
|
1029
1608
|
});
|
|
1030
|
-
const choice = await
|
|
1609
|
+
const { choice, unanswered } = await askMaybeTimed(pausedResume, title, (show) => serializePrompt(show, ctx.signal), (text, timeout) =>
|
|
1610
|
+
ctx.ui.select(text, askOptions(grant), { signal: ctx.signal, timeout }),
|
|
1611
|
+
);
|
|
1612
|
+
if (ctx.signal?.aborted) return { block: true, reason: "The turn was stopped while waiting for the user's approval." };
|
|
1613
|
+
if (unanswered) {
|
|
1614
|
+
logDecision(ctx, { tool: event.toolName, subject: matchSubject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
|
|
1615
|
+
return { block: true, reason: DENIED_UNANSWERED_RESUME };
|
|
1616
|
+
}
|
|
1031
1617
|
|
|
1032
1618
|
// The user's answer is itself a gate decision worth recording — it is the
|
|
1033
1619
|
// ground truth a drifting classifier gets calibrated against.
|
|
@@ -1055,12 +1641,27 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1055
1641
|
}
|
|
1056
1642
|
|
|
1057
1643
|
// 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
|
-
|
|
1644
|
+
// the user's words, or the model is left guessing why it was stopped. Esc
|
|
1645
|
+
// on the prompt (no choice) rejects without asking.
|
|
1646
|
+
const feedback =
|
|
1647
|
+
choice === undefined
|
|
1648
|
+
? undefined
|
|
1649
|
+
: await serializePrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: ctx.signal }), ctx.signal);
|
|
1650
|
+
// Claude Code's cancelAndAbort: a rejection without the user's words stops
|
|
1651
|
+
// the turn and waits for them; with words, the model acts on them. pi reads
|
|
1652
|
+
// an abort before the block (the result would say "Operation aborted"), so
|
|
1653
|
+
// the abort waits for the recorded result; the run then ends as an abort,
|
|
1654
|
+
// which every reader of the run's outcome treats as the user's interrupt.
|
|
1655
|
+
// `terminate` ends pi's loop after the batch: otherwise it sent one more
|
|
1656
|
+
// request on the aborted signal, which came back as an error and printed
|
|
1657
|
+
// "Error: This operation was aborted" above the Interrupted line.
|
|
1658
|
+
const stop = !feedback?.trim();
|
|
1659
|
+
if (stop) abortAfterResult.add(event.toolCallId);
|
|
1660
|
+
return { block: true, reason: userDenialText(feedback), terminate: stop };
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
1664
|
+
if (abortAfterResult.delete(event.toolCallId)) ctx.abort();
|
|
1064
1665
|
});
|
|
1065
1666
|
|
|
1066
1667
|
/**
|
|
@@ -1081,11 +1682,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1081
1682
|
const ctx = lastReviewCtx;
|
|
1082
1683
|
const { toolName, input, cwd } = call;
|
|
1083
1684
|
const normalizedTool = normalizeToolName(toolName);
|
|
1685
|
+
const conflict = isPathSubjectTool(normalizedTool) ? conflictingPathArguments(input) : undefined;
|
|
1686
|
+
if (conflict) return { block: true, reason: conflict };
|
|
1084
1687
|
const subject = extractSubject(normalizedTool, input);
|
|
1085
1688
|
const resolvedSubject =
|
|
1086
1689
|
isPathSubjectTool(normalizedTool) && subject
|
|
1087
1690
|
? resolveForContainment(toAbsolute(cwd, subject, os.homedir()))
|
|
1088
1691
|
: undefined;
|
|
1692
|
+
const powershellParse = await parsePowerShell(normalizedTool, subject, mode);
|
|
1089
1693
|
|
|
1090
1694
|
const result = decide({
|
|
1091
1695
|
toolName,
|
|
@@ -1108,6 +1712,13 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1108
1712
|
resultsDirPath,
|
|
1109
1713
|
sessionDirPath,
|
|
1110
1714
|
protectedDirs,
|
|
1715
|
+
secretPaths,
|
|
1716
|
+
workspaceDirs,
|
|
1717
|
+
// The child's own model: a cheaper subagent gets the stricter gate.
|
|
1718
|
+
claudeCodeFastPaths: usesClaudeCodeFastPaths(call.model),
|
|
1719
|
+
blockReadsOutsideWorkingDirectories: blockOutsideReads,
|
|
1720
|
+
readOnlyDirs: docsDirPaths,
|
|
1721
|
+
powershellParse,
|
|
1111
1722
|
});
|
|
1112
1723
|
|
|
1113
1724
|
// A child's cwd can be a worktree (different project → different per-repo
|
|
@@ -1120,11 +1731,17 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1120
1731
|
? safetyControlWrite({ toolName: normalizedTool, input, cwd, home: os.homedir() })
|
|
1121
1732
|
: undefined;
|
|
1122
1733
|
|
|
1734
|
+
// A child's first outside read raises the prompt on the parent's terminal;
|
|
1735
|
+
// with no parent context there is no one to ask, as in a one-shot run.
|
|
1736
|
+
if (result.decision === "allow" && result.cause === "outside-read" && ctx) {
|
|
1737
|
+
const refusal = await outsideReadRefusal(ctx, normalizedTool, subject, call.signal);
|
|
1738
|
+
if (refusal) return { block: true, reason: refusal };
|
|
1739
|
+
}
|
|
1123
1740
|
if (result.decision === "allow" && !floorReason) return undefined;
|
|
1124
1741
|
|
|
1125
1742
|
if (result.decision === "deny") {
|
|
1126
1743
|
// 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
|
|
1744
|
+
// mutates the parent's live transcript (working-docs/decisions/subagents-workflows.md
|
|
1128
1745
|
// — child calls stay out of it). Pushing a `denied` entry would leak the
|
|
1129
1746
|
// child's action into the parent and flip `afterRuleDenial` on for every
|
|
1130
1747
|
// later MAIN classification. The child's classifier already learns the
|
|
@@ -1146,17 +1763,20 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1146
1763
|
tool: normalizedTool,
|
|
1147
1764
|
input: isShellTool(normalizedTool) ? { command: subject } : input,
|
|
1148
1765
|
};
|
|
1149
|
-
const outcome = await runClassifier(toolName,
|
|
1766
|
+
const outcome = await runClassifier(toolName, subject, ctx, result.cause !== "protected-path", {
|
|
1150
1767
|
cwd,
|
|
1151
1768
|
appendEntry,
|
|
1769
|
+
// The main session as far as the call that started this child's turn.
|
|
1770
|
+
history: childCursor(call.parentToolCallId),
|
|
1152
1771
|
// The child's own turn signal, so an aborted child turn cancels the
|
|
1153
1772
|
// classifier call; a fresh (never-aborted) signal only if the child
|
|
1154
1773
|
// didn't supply one, so classify() still gets the signal it expects.
|
|
1155
1774
|
signal: call.signal ?? new AbortController().signal,
|
|
1775
|
+
powershellParse,
|
|
1156
1776
|
});
|
|
1157
1777
|
if (outcome.decision === "allow") return undefined;
|
|
1158
|
-
if (outcome.
|
|
1159
|
-
return { block: true, reason:
|
|
1778
|
+
if (outcome.transcriptTooLong) return transcriptOverflow(ctx, toolName, subject, call.signal);
|
|
1779
|
+
return { block: true, reason: classifierBlockReason(outcome) };
|
|
1160
1780
|
}
|
|
1161
1781
|
|
|
1162
1782
|
// Safety floor: a write to the gate's own config is NEVER auto-approved and
|
|
@@ -1166,7 +1786,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1166
1786
|
// control the classifier itself can't be trusted to enforce.
|
|
1167
1787
|
if (floorReason) {
|
|
1168
1788
|
if (ctx) logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "floor", reason: floorReason });
|
|
1169
|
-
return { block: true, reason:
|
|
1789
|
+
return { block: true, reason: safetyFloorBlockText(floorReason) };
|
|
1170
1790
|
}
|
|
1171
1791
|
|
|
1172
1792
|
// ask: needs the user. Bubble it to the parent's terminal; no UI → fail closed.
|
|
@@ -1184,10 +1804,16 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1184
1804
|
// its turn in the chain comes is skipped outright. Otherwise a dead agent's
|
|
1185
1805
|
// dialog stays on screen and every later prompt waits behind it (M5).
|
|
1186
1806
|
const childPrompt = <T>(show: () => Promise<T>): Promise<T | undefined> =>
|
|
1187
|
-
serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()));
|
|
1807
|
+
serializePrompt(() => (call.signal?.aborted ? Promise.resolve(undefined) : show()), call.signal);
|
|
1188
1808
|
const grant = sessionGrant({ toolName: normalizedTool, subject, cwd, mode, cause: result.cause, home: os.homedir() });
|
|
1189
|
-
const choice = await
|
|
1809
|
+
const { choice, unanswered } = await askMaybeTimed(pausedResume, title, childPrompt, (text, timeout) =>
|
|
1810
|
+
ctx.ui.select(text, askOptions(grant), { signal: call.signal, timeout }),
|
|
1811
|
+
);
|
|
1190
1812
|
if (call.signal?.aborted) return { block: true, reason: "The agent was stopped while waiting for the user's approval." };
|
|
1813
|
+
if (unanswered) {
|
|
1814
|
+
logDecision(ctx, { tool: toolName, subject, outcome: "block", source: "user", reason: "resume prompt unanswered" });
|
|
1815
|
+
return { block: true, reason: DENIED_UNANSWERED_RESUME };
|
|
1816
|
+
}
|
|
1191
1817
|
|
|
1192
1818
|
if (pausedResume) {
|
|
1193
1819
|
logDecision(ctx, {
|
|
@@ -1212,46 +1838,430 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1212
1838
|
const feedback = await childPrompt(() => ctx.ui.input("What should the agent do instead?", "Optional — press Esc to skip", { signal: call.signal }));
|
|
1213
1839
|
return {
|
|
1214
1840
|
block: true,
|
|
1215
|
-
reason:
|
|
1841
|
+
reason: userDenialText(feedback),
|
|
1216
1842
|
};
|
|
1217
1843
|
};
|
|
1218
1844
|
|
|
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
|
-
|
|
1845
|
+
/** Where a rule came from, and why the panel cannot edit it when it cannot. */
|
|
1846
|
+
const describeSource = (rule: SourcedRule, home: string): { label: string; editable: boolean; note?: string } => {
|
|
1847
|
+
const where = tildify(rule.path, home);
|
|
1848
|
+
const readOnly = (label: string, note: string) => ({ label: `From ${label}`, editable: false, note });
|
|
1849
|
+
switch (rule.source) {
|
|
1850
|
+
case "onecode-user":
|
|
1851
|
+
return { label: `From One Code user settings (${where})`, editable: true };
|
|
1852
|
+
case "onecode-project":
|
|
1853
|
+
return { label: `From One Code project settings (${where})`, editable: true };
|
|
1854
|
+
case "claude-user":
|
|
1855
|
+
return readOnly(`Claude Code user settings (${where})`, `One Code does not edit Claude Code's files. Change this rule in ${where}.`);
|
|
1856
|
+
case "project":
|
|
1857
|
+
case "project-local": {
|
|
1858
|
+
const file = rule.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
|
|
1859
|
+
const consent =
|
|
1860
|
+
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.";
|
|
1861
|
+
return readOnly(`the repository's ${file}`, `This rule ships with the repository. Change it in ${file}.${consent}`);
|
|
1862
|
+
}
|
|
1863
|
+
case "managed":
|
|
1864
|
+
return readOnly("managed settings", "This rule is configured by managed settings and cannot be modified. Contact your system administrator for more information.");
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
const ruleRows = (cwd: string, home: string): Record<RuleTab, RuleRow[]> => {
|
|
1869
|
+
const rows: Record<RuleTab, RuleRow[]> = { allow: [], ask: [], deny: [] };
|
|
1870
|
+
for (const rule of listPermissionRules(cwd, home)) {
|
|
1871
|
+
const source = describeSource(rule, home);
|
|
1872
|
+
rows[rule.behavior].push({
|
|
1873
|
+
key: `${rule.source}\0${rule.path}\0${rule.raw}`,
|
|
1874
|
+
behavior: rule.behavior,
|
|
1875
|
+
raw: rule.raw,
|
|
1876
|
+
sourceLabel: source.label,
|
|
1877
|
+
editable: source.editable,
|
|
1878
|
+
...(source.note ? { readOnlyNote: source.note } : {}),
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
// "Don't ask again" grants live in memory for this session; deleting one ends it.
|
|
1882
|
+
for (const grant of sessionAllows) {
|
|
1883
|
+
rows.allow.push({ key: `session\0\0${grant.raw}`, behavior: "allow", raw: grant.raw, sourceLabel: "From this session (don't ask again)", editable: true });
|
|
1884
|
+
}
|
|
1885
|
+
for (const tab of RULE_TABS) rows[tab].sort((a, b) => a.raw.toLowerCase().localeCompare(b.raw.toLowerCase()));
|
|
1886
|
+
return rows;
|
|
1887
|
+
};
|
|
1888
|
+
|
|
1889
|
+
const ruleDestinations = (cwd: string, home: string): Destination[] => [
|
|
1890
|
+
{ id: "onecode-project", label: "One Code project settings", description: `Saved in ${tildify(oneCodeProjectSettingsPath(cwd, home), home)}, for this project only` },
|
|
1891
|
+
{ id: "onecode-user", label: "One Code user settings", description: `Saved in ${tildify(oneCodeSettingsPath(home), home)}, for every project` },
|
|
1892
|
+
];
|
|
1893
|
+
|
|
1894
|
+
/** The status line under the panel's tabs. */
|
|
1895
|
+
const panelStatus = (): string => {
|
|
1896
|
+
const parts = [`Mode: ${mode}`];
|
|
1897
|
+
if (mode === "auto" || pauseTracker.stats().lifetime > 0) {
|
|
1898
|
+
parts.push(`${pauseTracker.stats().lifetime} blocked by auto mode this session`);
|
|
1899
|
+
if (pauseTracker.isPaused()) parts.push("auto mode paused (approve a prompt to resume)");
|
|
1900
|
+
}
|
|
1901
|
+
if (unparsableRules.length > 0) parts.push(`${unparsableRules.length} rule(s) could not be parsed and are ignored`);
|
|
1902
|
+
return parts.join(" · ");
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
/** The text summary /permissions prints where no panel can open. */
|
|
1906
|
+
const permissionsSummary = (): string => {
|
|
1907
|
+
const fmt = (rules: PermissionRule[]) => (rules.length ? rules.map((r) => r.raw).join(", ") : "(none)");
|
|
1908
|
+
return [
|
|
1909
|
+
`mode: ${mode}`,
|
|
1910
|
+
`deny: ${fmt(deny)}`,
|
|
1911
|
+
`ask: ${fmt(ask)}`,
|
|
1912
|
+
`allow: ${fmt(allow)}`,
|
|
1913
|
+
...(projectAllow.length > 0
|
|
1914
|
+
? [`project allow (${projectAllowTrusted ? "trusted" : projectAllowDeclined ? "declined this session" : "awaiting consent"}): ${fmt(projectAllow)}`]
|
|
1915
|
+
: []),
|
|
1916
|
+
`session allows: ${fmt(sessionAllows)}`,
|
|
1917
|
+
...(unparsableRules.length > 0 ? [`unparsable rules (ignored): ${unparsableRules.join(", ")}`] : []),
|
|
1918
|
+
...(denials.list().length > 0 ? ["recently denied:", ...denials.list().map((d) => ` ${d.display} — ${d.reason}`)] : []),
|
|
1919
|
+
].join("\n");
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
/** Built-in rules per auto-mode section, counted once from the embedded ruleset. */
|
|
1923
|
+
let builtinCounts: ReturnType<typeof builtinRuleCounts> | undefined;
|
|
1924
|
+
|
|
1925
|
+
const autoSource = (entry: SourcedAutoModeEntry, home: string): { label: string; editable: boolean; note?: string } => {
|
|
1926
|
+
const where = tildify(entry.path, home);
|
|
1927
|
+
if (entry.source === "onecode-user") return { label: `From One Code user settings (${where})`, editable: true };
|
|
1928
|
+
if (entry.source === "claude-user") {
|
|
1929
|
+
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.` };
|
|
1930
|
+
}
|
|
1931
|
+
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." };
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
const autoModeView = (home: string): AutoModeView => {
|
|
1935
|
+
const listed = listAutoModeEntries(home);
|
|
1936
|
+
const entries: AutoEntryRow[] = [];
|
|
1937
|
+
for (const section of AUTO_SECTIONS) {
|
|
1938
|
+
for (const entry of listed.filter((e) => e.key === section)) {
|
|
1939
|
+
const source = autoSource(entry, home);
|
|
1940
|
+
entries.push({
|
|
1941
|
+
key: `${entry.source}\0${entry.path}\0${section}\0${entry.text}`,
|
|
1942
|
+
section,
|
|
1943
|
+
text: entry.text,
|
|
1944
|
+
sourceLabel: source.label,
|
|
1945
|
+
editable: source.editable,
|
|
1946
|
+
...(source.note ? { readOnlyNote: source.note } : {}),
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
const environment = loadAutoModeConfig(home).environment;
|
|
1951
|
+
const envSources = [...new Set(listed.filter((e) => e.key === "environment").map((e) => autoSource(e, home).label.replace(/^From /, "").replace(/ \(.*\)$/, "")))];
|
|
1952
|
+
const isDefault = envSources.length === 0;
|
|
1953
|
+
const extendsDefault = !isDefault && DEFAULT_ENVIRONMENT.every((line) => environment.includes(line));
|
|
1954
|
+
builtinCounts ??= builtinRuleCounts(buildRuleset(DEFAULT_ENVIRONMENT));
|
|
1955
|
+
return {
|
|
1956
|
+
builtins: builtinCounts,
|
|
1957
|
+
entries,
|
|
1958
|
+
environment: {
|
|
1959
|
+
lines: environment,
|
|
1960
|
+
summary: isDefault ? "Built-in default" : `${extendsDefault ? "Extends" : "Replaces"} the built-in default · from ${envSources.join(" and ")}`,
|
|
1961
|
+
isDefault,
|
|
1962
|
+
},
|
|
1963
|
+
};
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
/** A panel row key for an auto-mode entry: source, file, section, text. */
|
|
1967
|
+
const parseAutoKey = (key: string) => {
|
|
1968
|
+
const [source, path, section, text] = key.split("\0");
|
|
1969
|
+
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())}.`);
|
|
1970
|
+
return { section: section as (typeof AUTO_SECTIONS)[number], text };
|
|
1971
|
+
};
|
|
1972
|
+
const autoLabel = (section: (typeof AUTO_SECTIONS)[number]) => AUTO_SECTION_LABELS[section].toLowerCase();
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Edit One Code's own `autoMode.environment` in pi's editor. It starts from
|
|
1976
|
+
* One Code's entries, or from the built-in default when nothing else sets
|
|
1977
|
+
* one (Claude Code starts from the full default text too). Saving it empty
|
|
1978
|
+
* removes the key, which restores the default. Returns the change line.
|
|
1979
|
+
*/
|
|
1980
|
+
const editEnvironment = async (ctx: ExtensionContext, home: string): Promise<string | undefined> => {
|
|
1981
|
+
const listed = listAutoModeEntries(home).filter((e) => e.key === "environment");
|
|
1982
|
+
const own = listed.filter((e) => e.source === "onecode-user").map((e) => e.text);
|
|
1983
|
+
const others = listed.length - own.length;
|
|
1984
|
+
// `"$defaults"` in the list: the user's lines extend the built-in default,
|
|
1985
|
+
// which stays out of the editor and is kept on save.
|
|
1986
|
+
const extendsDefault = oneCodeEnvironmentExtendsDefault(home);
|
|
1987
|
+
const start = own.length > 0 ? own : others > 0 || extendsDefault ? [] : DEFAULT_ENVIRONMENT;
|
|
1988
|
+
const title =
|
|
1989
|
+
"Auto mode environment: one entry per line, `### ` lines are section headers. Save it empty to restore the built-in default." +
|
|
1990
|
+
(extendsDefault ? " These lines are added to the built-in default." : "") +
|
|
1991
|
+
(others > 0 ? ` These lines are added to the ${others} entries from Claude Code's user or managed settings.` : "");
|
|
1992
|
+
const edited = await ctx.ui.editor(title, start.join("\n"));
|
|
1993
|
+
if (edited === undefined) return undefined;
|
|
1994
|
+
const lines = edited.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
|
|
1995
|
+
if (lines.join("\n") === start.join("\n")) return undefined;
|
|
1996
|
+
updateOneCodeAutoModeList(
|
|
1997
|
+
"environment",
|
|
1998
|
+
(entries) => {
|
|
1999
|
+
const at = entries.findIndex((entry) => entry.trim() === "$defaults");
|
|
2000
|
+
if (at < 0 || lines.length === 0) return lines;
|
|
2001
|
+
return [...lines.slice(0, at), entries[at], ...lines.slice(at)];
|
|
2002
|
+
},
|
|
2003
|
+
home,
|
|
2004
|
+
);
|
|
2005
|
+
autoConfig = undefined;
|
|
2006
|
+
return lines.length > 0 ? `Saved your auto mode environment to ${tildify(oneCodeSettingsPath(home), home)}` : "Restored the built-in auto mode environment";
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
/** Where a workspace directory came from, and why the panel cannot remove it when it cannot. */
|
|
2010
|
+
const workspaceRows = (home: string): WorkspaceDirRow[] => {
|
|
2011
|
+
const rows: WorkspaceDirRow[] = [];
|
|
2012
|
+
for (const dir of settingsWorkspaceDirs) {
|
|
2013
|
+
const where = tildify(dir.settingsPath, home);
|
|
2014
|
+
const key = `${dir.source}\0${dir.settingsPath}\0${dir.raw}`;
|
|
2015
|
+
if (dir.source === "onecode-user" || dir.source === "onecode-project") {
|
|
2016
|
+
rows.push({ key, path: dir.path, sourceLabel: `From One Code ${dir.source === "onecode-user" ? "user" : "project"} settings (${where})`, editable: true });
|
|
2017
|
+
} else if (isRepoSource(dir.source)) {
|
|
2018
|
+
const file = dir.source === "project" ? ".claude/settings.json" : ".claude/settings.local.json";
|
|
2019
|
+
const state = projectAllowTrusted ? "" : projectAllowDeclined ? ", off this session" : ", awaiting your trust";
|
|
2020
|
+
rows.push({
|
|
2021
|
+
key,
|
|
2022
|
+
path: dir.path,
|
|
2023
|
+
sourceLabel: `From the repository's ${file}${state}`,
|
|
2024
|
+
editable: false,
|
|
2025
|
+
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."}`,
|
|
2026
|
+
});
|
|
2027
|
+
} else {
|
|
2028
|
+
const label = dir.source === "claude-user" ? `Claude Code user settings (${where})` : "managed settings";
|
|
2029
|
+
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." });
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
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." });
|
|
2033
|
+
for (const path of sessionWorkspaceDirs) rows.push({ key: `session\0\0${path}`, path, sourceLabel: "Added for this session", editable: true });
|
|
2034
|
+
return rows;
|
|
2035
|
+
};
|
|
2036
|
+
|
|
2037
|
+
/** Add a validated directory for this session, or remember it in One Code's project settings. Returns the change line. */
|
|
2038
|
+
const addWorkspaceDirectory = (ctx: ExtensionContext, path: string, remember: boolean): string => {
|
|
2039
|
+
const home = os.homedir();
|
|
2040
|
+
if (remember) {
|
|
2041
|
+
const target = oneCodeProjectSettingsPath(ctx.cwd, home);
|
|
2042
|
+
persistWorkspaceDirectory(path, target);
|
|
2043
|
+
reloadRules(ctx);
|
|
2044
|
+
return `Added directory ${path} to workspace and saved to ${tildify(target, home)}`;
|
|
2045
|
+
}
|
|
2046
|
+
if (!sessionWorkspaceDirs.includes(path)) {
|
|
2047
|
+
sessionWorkspaceDirs.push(path);
|
|
2048
|
+
recordSessionWorkspace();
|
|
2049
|
+
}
|
|
2050
|
+
refreshWorkspace();
|
|
2051
|
+
return `Added directory ${path} to workspace for this session`;
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
/** The panel's I/O over the gate's state (panel/host.ts). */
|
|
2055
|
+
const panelHost = (ctx: ExtensionContext, home: string): PermissionsPanelHost => {
|
|
2056
|
+
return {
|
|
2057
|
+
view: () => ({
|
|
2058
|
+
denials: denials.list().map((d) => ({ id: d.id, display: d.display, ...(d.rule ? { rule: d.rule } : {}) })),
|
|
2059
|
+
rules: ruleRows(ctx.cwd, home),
|
|
2060
|
+
autoMode: autoModeView(home),
|
|
2061
|
+
destinations: ruleDestinations(ctx.cwd, home),
|
|
2062
|
+
ruleError: (raw) =>
|
|
2063
|
+
parseRule(raw) ? undefined : `Could not parse "${raw}". A rule is a tool name, optionally followed by a pattern in parentheses: Bash(npm test:*).`,
|
|
2064
|
+
workspace: { cwd: ctx.cwd, dirs: workspaceRows(home) },
|
|
2065
|
+
validateDir: (input) => validateWorkspaceDirectory(input, ctx.cwd, home, workspacePaths),
|
|
2066
|
+
}),
|
|
2067
|
+
status: panelStatus,
|
|
2068
|
+
addRule: (behavior, rule, destination) => {
|
|
2069
|
+
const target = destination === "onecode-user" ? oneCodeSettingsPath(home) : oneCodeProjectSettingsPath(ctx.cwd, home);
|
|
2070
|
+
if (!persistPermissionRule(behavior, rule, target)) throw new Error(`That ${behavior} rule is already in ${tildify(target, home)}.`);
|
|
2071
|
+
reloadRules(ctx);
|
|
2072
|
+
return `Added ${behavior} rule ${rule} to ${tildify(target, home)}`;
|
|
2073
|
+
},
|
|
2074
|
+
deleteRule: (behavior, key) => {
|
|
2075
|
+
const [source, path, raw] = key.split("\0");
|
|
2076
|
+
if (source === "session") {
|
|
2077
|
+
const index = sessionAllows.findIndex((grant) => grant.raw === raw);
|
|
2078
|
+
if (index >= 0) sessionAllows.splice(index, 1);
|
|
2079
|
+
} else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
|
|
2080
|
+
if (!removePermissionRule(behavior, raw, path)) throw new Error(`${raw} is no longer in ${tildify(path, home)}.`);
|
|
2081
|
+
reloadRules(ctx);
|
|
2082
|
+
} else {
|
|
2083
|
+
throw new Error(`One Code can only delete rules from its own settings files; ${raw} is in ${tildify(path, home)}.`);
|
|
2084
|
+
}
|
|
2085
|
+
return `Deleted ${behavior} rule ${raw}`;
|
|
2086
|
+
},
|
|
2087
|
+
// Auto mode rules go to One Code's user settings: autoMode is never read
|
|
2088
|
+
// from project settings (decisions/modes.md). The cached config is dropped
|
|
2089
|
+
// so the next classifier call reads the change.
|
|
2090
|
+
addAutoRule: (section, text) => {
|
|
2091
|
+
updateOneCodeAutoModeList(
|
|
2092
|
+
section,
|
|
2093
|
+
(entries) => {
|
|
2094
|
+
if (entries.includes(text)) throw new Error(`That ${autoLabel(section)} rule is already in your settings.`);
|
|
2095
|
+
return [...entries, text];
|
|
2096
|
+
},
|
|
2097
|
+
home,
|
|
2098
|
+
);
|
|
2099
|
+
autoConfig = undefined;
|
|
2100
|
+
return `Added auto mode ${autoLabel(section)} rule: ${text}`;
|
|
2101
|
+
},
|
|
2102
|
+
editAutoRule: (key, text) => {
|
|
2103
|
+
const { section, text: old } = parseAutoKey(key);
|
|
2104
|
+
updateOneCodeAutoModeList(
|
|
2105
|
+
section,
|
|
2106
|
+
(entries) => {
|
|
2107
|
+
const index = entries.indexOf(old);
|
|
2108
|
+
if (index < 0) throw new Error("That rule is no longer in your settings.");
|
|
2109
|
+
return entries.map((entry, i) => (i === index ? text : entry));
|
|
2110
|
+
},
|
|
2111
|
+
home,
|
|
2112
|
+
);
|
|
2113
|
+
autoConfig = undefined;
|
|
2114
|
+
return `Updated auto mode ${autoLabel(section)} rule: ${text}`;
|
|
2115
|
+
},
|
|
2116
|
+
deleteAutoRule: (key) => {
|
|
2117
|
+
const { section, text } = parseAutoKey(key);
|
|
2118
|
+
updateOneCodeAutoModeList(
|
|
2119
|
+
section,
|
|
2120
|
+
(entries) => {
|
|
2121
|
+
if (!entries.includes(text)) throw new Error("That rule is no longer in your settings.");
|
|
2122
|
+
return entries.filter((entry) => entry !== text);
|
|
2123
|
+
},
|
|
2124
|
+
home,
|
|
2125
|
+
);
|
|
2126
|
+
autoConfig = undefined;
|
|
2127
|
+
return `Deleted auto mode ${autoLabel(section)} rule: ${text}`;
|
|
2128
|
+
},
|
|
2129
|
+
addDir: (path, remember) => addWorkspaceDirectory(ctx, path, remember),
|
|
2130
|
+
removeDir: (key) => {
|
|
2131
|
+
const [source, settingsPath, raw] = key.split("\0");
|
|
2132
|
+
if (source === "session") {
|
|
2133
|
+
const index = sessionWorkspaceDirs.indexOf(raw);
|
|
2134
|
+
if (index >= 0) {
|
|
2135
|
+
sessionWorkspaceDirs.splice(index, 1);
|
|
2136
|
+
recordSessionWorkspace();
|
|
2137
|
+
}
|
|
2138
|
+
refreshWorkspace();
|
|
2139
|
+
} else if (source === ("onecode-user" satisfies RuleSource) || source === ("onecode-project" satisfies RuleSource)) {
|
|
2140
|
+
if (!removeWorkspaceDirectory(raw, settingsPath)) throw new Error(`${raw} is no longer in ${tildify(settingsPath, home)}.`);
|
|
2141
|
+
reloadRules(ctx);
|
|
2142
|
+
} else {
|
|
2143
|
+
throw new Error(`One Code can only remove directories it added; ${raw} comes from elsewhere.`);
|
|
2144
|
+
}
|
|
2145
|
+
return `Removed directory ${raw} from workspace`;
|
|
2146
|
+
},
|
|
2147
|
+
};
|
|
2148
|
+
};
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* Open the panel, run the environment editor round trips, then tell the
|
|
2152
|
+
* model what happened: approvals mint their grants here, once, as in Claude
|
|
2153
|
+
* Code (a row toggled on and off again grants nothing). `command` names the
|
|
2154
|
+
* breadcrumb (`permissions`, `add-dir`).
|
|
2155
|
+
*/
|
|
2156
|
+
/** `dismissed`: the command's stdout when the panel closes with nothing changed. */
|
|
2157
|
+
const runPermissionsPanel = async (ctx: ExtensionContext, command: string, args: string, prepare?: (state: PanelState) => void, dismissed?: string) => {
|
|
2158
|
+
const home = os.homedir();
|
|
2159
|
+
const host = panelHost(ctx, home);
|
|
2160
|
+
// The environment editor cannot open over the panel: close, edit, reopen.
|
|
2161
|
+
let session = await openPermissionsPanel(ctx, pi.events, host, undefined, prepare);
|
|
2162
|
+
while (session.editEnvironment) {
|
|
2163
|
+
session.editEnvironment = false;
|
|
2164
|
+
try {
|
|
2165
|
+
const change = await editEnvironment(ctx, home);
|
|
2166
|
+
if (change) session.changes.push(change);
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
session.state.notice = `Could not save the environment: ${(error as Error).message}`;
|
|
2169
|
+
}
|
|
2170
|
+
session = await openPermissionsPanel(ctx, pi.events, host, session);
|
|
2171
|
+
}
|
|
2172
|
+
const { state, changes } = session;
|
|
2173
|
+
|
|
2174
|
+
const approved = denials.approve(state.approved);
|
|
2175
|
+
const retried = approved.filter((d) => state.retry.has(d.id));
|
|
2176
|
+
// Model-facing: never the panel's clipped preview.
|
|
2177
|
+
const displays = approved.map((d) => d.fullDisplay);
|
|
2178
|
+
if (retried.length > 0) {
|
|
2179
|
+
// Claude Code's retry: a banner says what was allowed, and a turn starts
|
|
2180
|
+
// with the grant message.
|
|
2181
|
+
announceLocalCommand(pi, { name: command, args, stdout: changes.join("\n") });
|
|
2182
|
+
ctx.ui.notify(`Allowed ${retried.map((d) => d.display).join(", ")}`, "info");
|
|
2183
|
+
pi.sendMessage(
|
|
2184
|
+
{ customType: PERMISSION_RETRY_TYPE, content: permissionGrantedMessage(displays), display: false },
|
|
2185
|
+
ctx.isIdle() ? { triggerTurn: true } : { deliverAs: "followUp", triggerTurn: true },
|
|
1254
2186
|
);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
if (approved.length === 0 && changes.length === 0) {
|
|
2190
|
+
if (dismissed) announceLocalCommand(pi, { name: command, args, stdout: dismissed });
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
const stdout = [...(approved.length > 0 ? [`Approved ${displays.join(", ")}`] : []), ...changes].join("\n");
|
|
2194
|
+
announceLocalCommand(pi, { name: command, args, stdout });
|
|
2195
|
+
// The grant message rides with the breadcrumb on the next prompt; no turn starts.
|
|
2196
|
+
if (approved.length > 0) pi.events.emit(REMINDER_CHANNEL, { text: `${permissionGrantedMessage(displays)}\n`, placement: "user-prepend", raw: true });
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* /permissions — Claude Code's panel (findings §33): approve or retry calls
|
|
2201
|
+
* the classifier denied, list, add or delete permission rules, manage auto
|
|
2202
|
+
* mode's own rules and environment, and the workspace directories.
|
|
2203
|
+
* Registered plainly, not through registerLocalCommand: the breadcrumb
|
|
2204
|
+
* carries what the panel did, so it is announced after the panel closes,
|
|
2205
|
+
* not before it opens.
|
|
2206
|
+
*/
|
|
2207
|
+
pi.registerCommand("permissions", {
|
|
2208
|
+
description: "Review recently denied calls and manage permission rules, auto mode rules and workspace directories",
|
|
2209
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
2210
|
+
if (!canShowCustomUi(ctx)) {
|
|
2211
|
+
// One notice with the summary, not notifyRpcReadOnly's separate one:
|
|
2212
|
+
// pi shows only the last of back-to-back info notices (findings §63).
|
|
2213
|
+
const limitation = ctx.mode === "rpc" ? `${rpcReadOnlyText("/permissions", "manage rules or approve recently denied calls")}\n\n` : "";
|
|
2214
|
+
announceLocalCommand(pi, { name: "permissions", args, stdout: limitation.trim() });
|
|
2215
|
+
ctx.ui.notify(`${limitation}${permissionsSummary()}`, "info");
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
await runPermissionsPanel(ctx, "permissions", args, undefined, "Permissions dialog dismissed");
|
|
2219
|
+
},
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* /add-dir — Claude Code's: add a workspace directory. With a path it asks
|
|
2224
|
+
* whether to keep it for this session or remember it; without one it opens
|
|
2225
|
+
* the panel's Workspace tab on the path input.
|
|
2226
|
+
*/
|
|
2227
|
+
announceArgumentHint(pi, "add-dir", "<path>");
|
|
2228
|
+
pi.registerCommand("add-dir", {
|
|
2229
|
+
description: "Add a workspace directory: /add-dir <path>",
|
|
2230
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
2231
|
+
const input = args.trim();
|
|
2232
|
+
if (!ctx.hasUI) {
|
|
2233
|
+
announceLocalCommand(pi, { name: "add-dir", args });
|
|
2234
|
+
ctx.ui.notify("/add-dir needs the interactive UI; start One Code with --add-dir instead.", "warning");
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (!input) {
|
|
2238
|
+
if (!canShowCustomUi(ctx)) {
|
|
2239
|
+
const message = "The workspace panel is unavailable in RPC. Use /add-dir <path> to add a directory.";
|
|
2240
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: message });
|
|
2241
|
+
ctx.ui.notify(message, "info");
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
await runPermissionsPanel(ctx, "add-dir", args, (state) => {
|
|
2245
|
+
state.tab = "workspace";
|
|
2246
|
+
state.dialog = { kind: "addDir", draft: "" };
|
|
2247
|
+
});
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
const checked = validateWorkspaceDirectory(input, ctx.cwd, os.homedir(), workspacePaths);
|
|
2251
|
+
if ("error" in checked) {
|
|
2252
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: checked.error });
|
|
2253
|
+
ctx.ui.notify(checked.error, "warning");
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
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.`, [
|
|
2257
|
+
"Yes, for this session",
|
|
2258
|
+
"Yes, and remember this directory",
|
|
2259
|
+
"No",
|
|
2260
|
+
]);
|
|
2261
|
+
if (choice !== "Yes, for this session" && choice !== "Yes, and remember this directory") return;
|
|
2262
|
+
const change = addWorkspaceDirectory(ctx, checked.path, choice === "Yes, and remember this directory");
|
|
2263
|
+
announceLocalCommand(pi, { name: "add-dir", args, stdout: change });
|
|
2264
|
+
ctx.ui.notify(change, "info");
|
|
1255
2265
|
},
|
|
1256
2266
|
});
|
|
1257
2267
|
|
|
@@ -1263,18 +2273,21 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1263
2273
|
* surfaces a warning rather than blocking it: the work has already happened, so
|
|
1264
2274
|
* the useful move is to make sure the model and the user see it.
|
|
1265
2275
|
*/
|
|
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>`;
|
|
2276
|
+
const reviewFlagged = (verdict: HandBackVerdict) => `<system-reminder>\n${handBackWarning(verdict)}\n</system-reminder>`;
|
|
1268
2277
|
|
|
1269
|
-
// Classify a finished run's action sequence as a whole. Returns the
|
|
2278
|
+
// Classify a finished run's action sequence as a whole. Returns the verdict to
|
|
1270
2279
|
// surface, or undefined when the sequence is fine. Shared by the foreground
|
|
1271
|
-
// (tool_result) and background (channel) paths.
|
|
2280
|
+
// (tool_result) and background (channel) paths. A review that reached no
|
|
2281
|
+
// verdict (an unreadable reply, a transcript past the limit, a timeout) is an
|
|
2282
|
+
// unreviewed run, not a finding.
|
|
1272
2283
|
const reviewCompletedRun = async (
|
|
1273
2284
|
actions: ChildAction[],
|
|
1274
2285
|
ctx: ExtensionContext,
|
|
1275
2286
|
subject: string,
|
|
1276
2287
|
signal: AbortSignal | undefined,
|
|
1277
|
-
|
|
2288
|
+
/** The main-session call that started the reviewed turn, when known. */
|
|
2289
|
+
startedBy: string | undefined,
|
|
2290
|
+
): Promise<HandBackVerdict | undefined> => {
|
|
1278
2291
|
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
1279
2292
|
// Present the child's actions as tool entries appended to the session
|
|
1280
2293
|
// transcript, so the reviewer judges the sequence with the parent's context
|
|
@@ -1284,12 +2297,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1284
2297
|
const tool = normalizeToolName(action.toolName);
|
|
1285
2298
|
return { kind: "tool", tool, input: isShellTool(tool) ? { command: action.subject } : { subject: action.subject } };
|
|
1286
2299
|
});
|
|
2300
|
+
const { transcript, userMessages } = classifierHistory(ctx.sessionManager.getBranch(), childCursor(startedBy));
|
|
1287
2301
|
const verdict = await classify(
|
|
1288
2302
|
{
|
|
1289
2303
|
toolName: "subagent-review",
|
|
1290
2304
|
transcript: [...transcript, ...childEntries],
|
|
1291
2305
|
userMessages: [...userMessages],
|
|
1292
|
-
|
|
2306
|
+
// The main session's instructions, read where its own calls read them.
|
|
2307
|
+
claudeMd: instructionsFor(workCwd(ctx.cwd)),
|
|
1293
2308
|
username: classifierUsername,
|
|
1294
2309
|
environment: autoConfig.environment,
|
|
1295
2310
|
// AutoModeConfig is structurally a RuleExtras; the user's own deny rules
|
|
@@ -1302,6 +2317,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1302
2317
|
config: autoConfig,
|
|
1303
2318
|
signal,
|
|
1304
2319
|
state: classifierState,
|
|
2320
|
+
cacheKey: classifierCacheKey(ctx.sessionManager.getSessionId()),
|
|
1305
2321
|
onNotice: (message, level) => ctx.ui.notify(message, level),
|
|
1306
2322
|
onUsage: onClassifierUsage,
|
|
1307
2323
|
onModelUnusable: onClassifierModelUnusable,
|
|
@@ -1313,20 +2329,18 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1313
2329
|
subject,
|
|
1314
2330
|
outcome: verdict.decision,
|
|
1315
2331
|
source: "review",
|
|
2332
|
+
transcriptTooLong: verdict.transcriptTooLong,
|
|
2333
|
+
model: classifierState.pinned ? `${classifierState.pinned.provider}/${classifierState.pinned.id}` : undefined,
|
|
1316
2334
|
tier: verdict.tier,
|
|
1317
2335
|
ruleId: verdict.ruleId,
|
|
1318
2336
|
reason: verdict.reason || undefined,
|
|
1319
2337
|
});
|
|
1320
2338
|
if (verdict.decision === "allow") return undefined;
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
ruleId: verdict.ruleId,
|
|
1327
|
-
raw: verdict.raw,
|
|
1328
|
-
});
|
|
1329
|
-
return verdict.reason;
|
|
2339
|
+
// A completed run cannot be approved retroactively, so a review only warns.
|
|
2340
|
+
// It never counts toward the auto-mode pause: as in Claude Code, denials
|
|
2341
|
+
// are counted on the main permission path alone.
|
|
2342
|
+
const unavailable = verdict.transcriptTooLong || verdict.noVerdict || verdict.tier === "timeout";
|
|
2343
|
+
return { kind: unavailable ? "unavailable" : "blocked", reason: verdict.reason };
|
|
1330
2344
|
};
|
|
1331
2345
|
|
|
1332
2346
|
const childActions = new Map<string, ChildAction[]>();
|
|
@@ -1343,11 +2357,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1343
2357
|
// we answer `onReview`, so the verdict travels with the report. Answer
|
|
1344
2358
|
// synchronously when no review will run, so the report is never delayed
|
|
1345
2359
|
// for nothing. A review that throws fails closed: the report goes out
|
|
1346
|
-
// flagged, not clean.
|
|
2360
|
+
// flagged, not clean. A pause does not skip it: the pause is about the
|
|
2361
|
+
// main loop's blocks, and a child already running when it tripped still
|
|
2362
|
+
// hands back a sequence nobody judged whole (PERMISSIONS-AUTOMODE-REVIEW
|
|
2363
|
+
// -2026-09-26 L1).
|
|
1347
2364
|
const respond = payload.onReview;
|
|
1348
2365
|
if (!respond) return; // every background emitter supplies the callback (hand-back-review.ts)
|
|
1349
2366
|
const ctx = lastReviewCtx;
|
|
1350
|
-
if (mode !== "auto" || payload.actions.length === 0 ||
|
|
2367
|
+
if (mode !== "auto" || payload.actions.length === 0 || !ctx) {
|
|
1351
2368
|
respond(undefined);
|
|
1352
2369
|
return;
|
|
1353
2370
|
}
|
|
@@ -1358,8 +2375,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1358
2375
|
const respondIfCurrent = (verdict: HandBackVerdict | undefined) => {
|
|
1359
2376
|
if (epoch === sessionEpoch) respond(verdict);
|
|
1360
2377
|
};
|
|
1361
|
-
reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal)
|
|
1362
|
-
.then(
|
|
2378
|
+
reviewCompletedRun(payload.actions, ctx, label, new AbortController().signal, payload.startedBy)
|
|
2379
|
+
.then(respondIfCurrent)
|
|
1363
2380
|
.catch((error) => respondIfCurrent({ kind: "unavailable", reason: `the review itself failed (${(error as Error).message})` }));
|
|
1364
2381
|
});
|
|
1365
2382
|
|
|
@@ -1367,86 +2384,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1367
2384
|
lastReviewCtx = ctx;
|
|
1368
2385
|
const actions = childActions.get(event.toolCallId);
|
|
1369
2386
|
if (actions) childActions.delete(event.toolCallId);
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
2387
|
+
// Reviewed while paused too, as the background path above is.
|
|
2388
|
+
if (mode !== "auto" || !actions?.length) return undefined;
|
|
2389
|
+
// The spawning call is this tool result's own: the run happened inside it.
|
|
2390
|
+
const verdict = await reviewCompletedRun(actions, ctx, "completed run", ctx.signal, event.toolCallId);
|
|
2391
|
+
if (!verdict) return undefined;
|
|
2392
|
+
return { content: [{ type: "text" as const, text: reviewFlagged(verdict) }, ...event.content] };
|
|
1374
2393
|
});
|
|
1375
2394
|
|
|
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
2395
|
/**
|
|
1451
2396
|
* `/auto-mode setup` — Claude Code's setup-wizard flow: ask how this
|
|
1452
2397
|
* environment is used, gather facts (git/gh/CLAUDE.md, opt-in shell
|
|
@@ -1525,7 +2470,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1525
2470
|
// must appear in both lists (removing the One Code copy alone would leave the
|
|
1526
2471
|
// exposure and a false "fixed" impression).
|
|
1527
2472
|
const oneCodeAllow = new Set(oneCodePermissionAllow(os.homedir()));
|
|
1528
|
-
|
|
2473
|
+
// Independent mode reads no ~/.claude rule, so none of them is in force.
|
|
2474
|
+
const claudeAllow = new Set(claudeSourcesOn() ? claudeUserPermissionAllow(os.homedir()) : []);
|
|
1529
2475
|
const removable = flagged.filter((entry) => oneCodeAllow.has(entry.rule));
|
|
1530
2476
|
const claudeSide = flagged.filter((entry) => claudeAllow.has(entry.rule));
|
|
1531
2477
|
|
|
@@ -1562,8 +2508,89 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1562
2508
|
}
|
|
1563
2509
|
};
|
|
1564
2510
|
|
|
2511
|
+
/**
|
|
2512
|
+
* Apply a chosen classifier model: validate auth first (a persisted model
|
|
2513
|
+
* with no credentials would fail every call), persist to user scope, release
|
|
2514
|
+
* the session pin so the choice takes effect on the next call, and say what
|
|
2515
|
+
* it costs (`lib/model-choice-warnings.ts`).
|
|
2516
|
+
*/
|
|
2517
|
+
const applyClassifierChoice = async (spec: string, ctx: ExtensionContext): Promise<void> => {
|
|
2518
|
+
const model = findConfigured(ctx.modelRegistry.getAvailable(), spec);
|
|
2519
|
+
if (!model) {
|
|
2520
|
+
ctx.ui.notify(`No available model matches "${spec}". Run /auto-mode model for the list.`, "error");
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const resolved = `${model.provider}/${model.id}`;
|
|
2524
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
2525
|
+
if (!auth.ok) {
|
|
2526
|
+
ctx.ui.notify(`Cannot use ${resolved}: ${auth.error}. Not saved.`, "error");
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
try {
|
|
2530
|
+
// Stamp the session's containment so a later session on another provider
|
|
2531
|
+
// treats this cross-provider setting as stale (parity with /subagent).
|
|
2532
|
+
persistClassifierModel(resolved, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
ctx.ui.notify(`Could not save classifier model: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
// Re-plan, then announce the choice with its warnings, in any mode: the
|
|
2538
|
+
// setting applies whenever auto mode is on.
|
|
2539
|
+
resetClassifierChoice(ctx.model, false);
|
|
2540
|
+
announceClassifierChoice(ctx.model, { lead: `Auto-mode classifier set to ${resolved} (saved to ~/.onecode/settings.json).`, force: true });
|
|
2541
|
+
};
|
|
2542
|
+
|
|
2543
|
+
/** `/auto-mode model` — show the picker, or apply a named model / `clear`. */
|
|
2544
|
+
const handleModelSubcommand = async (remainder: string, ctx: ExtensionContext): Promise<void> => {
|
|
2545
|
+
if (remainder === "clear") {
|
|
2546
|
+
try {
|
|
2547
|
+
persistClassifierModel(undefined, os.homedir());
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
ctx.ui.notify(`Could not update settings: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
// One info line: pi folds back-to-back info notices (findings §63).
|
|
2553
|
+
resetClassifierChoice(ctx.model, false);
|
|
2554
|
+
announceClassifierChoice(ctx.model, { lead: `autoMode.classifierModel cleared. Auto mode chooses automatically: ${describeChain(ctx)}`, force: true });
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
if (remainder) {
|
|
2558
|
+
await applyClassifierChoice(remainder, ctx);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
2562
|
+
if (available.length === 0) {
|
|
2563
|
+
ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
autoConfig ??= loadAutoModeConfig(os.homedir());
|
|
2567
|
+
// The picker needs focus and a terminal; elsewhere say what to type.
|
|
2568
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
2569
|
+
ctx.ui.notify(
|
|
2570
|
+
`classifierModel: ${autoConfig.classifierModel ?? "(not set: chosen automatically)"}. Set one with /auto-mode model <provider/model-id>, or clear it with /auto-mode model clear.`,
|
|
2571
|
+
"info",
|
|
2572
|
+
);
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
|
|
2576
|
+
modelPickerComponent(
|
|
2577
|
+
{
|
|
2578
|
+
entries: toPickerEntries(available),
|
|
2579
|
+
current: autoConfig?.classifierModel,
|
|
2580
|
+
title: "Select the auto-mode classifier model",
|
|
2581
|
+
subtitle: "Screens auto-mode tool calls; /auto-mode model clear returns to the automatic choice",
|
|
2582
|
+
},
|
|
2583
|
+
tui,
|
|
2584
|
+
theme,
|
|
2585
|
+
done,
|
|
2586
|
+
),
|
|
2587
|
+
);
|
|
2588
|
+
if (chosen) await applyClassifierChoice(pickerSpec(chosen), ctx);
|
|
2589
|
+
};
|
|
2590
|
+
|
|
1565
2591
|
registerLocalCommand(pi, "auto-mode", {
|
|
1566
2592
|
description: "Auto-mode classifier: /auto-mode [setup|defaults|config|model [provider/model-id|clear]]",
|
|
2593
|
+
argumentHint: "[setup|defaults|config|model [provider/model-id|clear]]",
|
|
1567
2594
|
getArgumentCompletions: () =>
|
|
1568
2595
|
[
|
|
1569
2596
|
{ value: "setup", label: "analyze this environment and draft the config" },
|
|
@@ -1573,14 +2600,14 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1573
2600
|
],
|
|
1574
2601
|
handler: async (args, ctx) => {
|
|
1575
2602
|
const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
1576
|
-
if (sub === "model") {
|
|
1577
|
-
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
1578
|
-
return;
|
|
1579
|
-
}
|
|
1580
2603
|
if (sub === "setup") {
|
|
1581
2604
|
await handleSetupSubcommand(ctx);
|
|
1582
2605
|
return;
|
|
1583
2606
|
}
|
|
2607
|
+
if (sub === "model") {
|
|
2608
|
+
await handleModelSubcommand(rest.join(" ").trim(), ctx);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
1584
2611
|
const which = sub ?? "config";
|
|
1585
2612
|
if (which !== "defaults" && which !== "config") {
|
|
1586
2613
|
ctx.ui.notify(
|
|
@@ -1625,8 +2652,8 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1625
2652
|
...(which === "config" && "classifyAllShell" in shown
|
|
1626
2653
|
? [
|
|
1627
2654
|
`classifyAllShell: ${shown.classifyAllShell}`,
|
|
2655
|
+
`classifierModel: ${"classifierModel" in shown && shown.classifierModel ? shown.classifierModel : "(not set: chosen automatically; /auto-mode model chooses one)"}`,
|
|
1628
2656
|
`logDecisions: ${shown.logDecisions} (auto-mode-decisions.jsonl next to the session files)`,
|
|
1629
|
-
`classifierModel: ${shown.classifierModel ?? "(not set — /auto-mode model chooses one)"}`,
|
|
1630
2657
|
// Which model actually screens calls, and why — this reads the
|
|
1631
2658
|
// user's prompts, so it should not take knowing the code to find out.
|
|
1632
2659
|
`classifier in use: ${
|
|
@@ -1650,6 +2677,7 @@ export default function permissionsExtension(pi: ExtensionAPI) {
|
|
|
1650
2677
|
|
|
1651
2678
|
registerLocalCommand(pi, "allow", {
|
|
1652
2679
|
description: 'Persist an allow rule: /allow Bash(npm test:*) [global]',
|
|
2680
|
+
argumentHint: "<rule> [global]",
|
|
1653
2681
|
handler: async (args, ctx) => {
|
|
1654
2682
|
const global = /\s+global$/.test(args.trim());
|
|
1655
2683
|
const raw = args.trim().replace(/\s+global$/, "");
|