one-code-extension 0.3.1 → 0.4.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 +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Windows
|
|
2
|
+
|
|
3
|
+
[← One Code user guide](README.md)
|
|
4
|
+
|
|
5
|
+
One Code on native Windows works the way Claude Code does today: Git for
|
|
6
|
+
Windows is optional, PowerShell is the primary shell tool when it is
|
|
7
|
+
enabled, and the Windows-specific pieces of your Claude Code configuration
|
|
8
|
+
keep working. This page says what that means in practice and what has and
|
|
9
|
+
hasn't been verified.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
powershell -c "irm https://raw.githubusercontent.com/IsuruMaduranga/one-code/master/install.ps1 | iex"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This fetches Node.js when the machine has none and installs the app; with
|
|
18
|
+
Node.js 22.19 or later already present, `npm install -g @one-ai/one-code`
|
|
19
|
+
does the same. Details, and the script's knobs, are in
|
|
20
|
+
[Install on Windows](installation.md#install-on-windows). Git for Windows is
|
|
21
|
+
optional; the section below says what it adds.
|
|
22
|
+
|
|
23
|
+
## Two shell tools
|
|
24
|
+
|
|
25
|
+
| Tool | When it is active | What runs it |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `powershell` | On by default on Windows. Off elsewhere unless `CLAUDE_CODE_USE_POWERSHELL_TOOL=1`. | `pwsh.exe`, else `powershell.exe`, from your PATH. On macOS and Linux, a `pwsh` on PATH. |
|
|
28
|
+
| `bash` | Whenever a bash exists. | Git Bash (`Program Files\Git\bin\bash.exe`, then `bash.exe` on PATH), or the bash named by `CLAUDE_CODE_GIT_BASH_PATH`. |
|
|
29
|
+
|
|
30
|
+
When both are active, PowerShell is the primary shell: the system prompt says
|
|
31
|
+
so and the model reaches for it first, with bash alongside for POSIX scripts.
|
|
32
|
+
`CLAUDE_CODE_USE_POWERSHELL_TOOL=0` turns the PowerShell tool off and makes
|
|
33
|
+
bash primary again, which is the shape Claude Code had before 2.1.126.
|
|
34
|
+
|
|
35
|
+
A Windows machine with neither Git for Windows nor PowerShell has no shell
|
|
36
|
+
tool. One Code says so at startup, in Claude Code's words, and the fix is to
|
|
37
|
+
install one of them.
|
|
38
|
+
|
|
39
|
+
`CLAUDE_CODE_GIT_BASH_PATH` is read from your environment or from the `env`
|
|
40
|
+
block of `~/.claude/settings.json`. A value that isn't a `bash` or `sh`
|
|
41
|
+
binary is ignored with a warning, as Claude Code does since 2.1.219.
|
|
42
|
+
|
|
43
|
+
## The PowerShell tool
|
|
44
|
+
|
|
45
|
+
The tool the model sees is Claude Code's PowerShell tool: same parameters
|
|
46
|
+
(`command`, `timeout` in milliseconds, `description`, `run_in_background`)
|
|
47
|
+
and Claude Code's own description text, including the edition section. One
|
|
48
|
+
Code picks that section from the executable it found: PowerShell 7 guidance
|
|
49
|
+
for `pwsh`, Windows PowerShell 5.1 guidance for `powershell.exe`. Under the
|
|
50
|
+
hood it is pi's `powershell` tool, run with `-NoProfile -NonInteractive
|
|
51
|
+
-ExecutionPolicy Bypass -Command`, output forced to UTF-8.
|
|
52
|
+
|
|
53
|
+
`run_in_background` works like bash's: a task id comes back at once, output
|
|
54
|
+
spools to the session directory, completion arrives as a notification, and
|
|
55
|
+
the shell shows up in the panel below the editor. A foreground command that
|
|
56
|
+
starts with `Start-Sleep` is refused with a pointer to background runs and
|
|
57
|
+
the monitor tool, and `Read-Host`, `Get-Credential`, `Out-GridView`, `pause`
|
|
58
|
+
and interactive git are refused because the shell has no console to prompt
|
|
59
|
+
on.
|
|
60
|
+
|
|
61
|
+
Inside a worktree session, every PowerShell command is prefixed with a
|
|
62
|
+
`Set-Location` into the worktree, the way bash commands get a `cd`.
|
|
63
|
+
|
|
64
|
+
## Permission rules
|
|
65
|
+
|
|
66
|
+
`PowerShell(...)` rules use the same shape as `Bash(...)` rules and follow
|
|
67
|
+
Claude Code's PowerShell matching:
|
|
68
|
+
|
|
69
|
+
- Aliases are canonicalized on both sides, so `PowerShell(Remove-Item:*)`
|
|
70
|
+
catches `rm -Recurse build`, `del x`, and `ri y`, and
|
|
71
|
+
`PowerShell(Get-ChildItem:*)` allows `ls` and `dir`. Cmdlet names are
|
|
72
|
+
case-insensitive.
|
|
73
|
+
- A command line is split on `|`, `;`, `&&`, `||`, and newlines, outside
|
|
74
|
+
quotes and here-strings. An `allow` rule has to cover every statement; a
|
|
75
|
+
`deny` or `ask` rule fires on any statement, including inside a nested
|
|
76
|
+
`pwsh -Command '…'`.
|
|
77
|
+
- A line with a `$(…)` subexpression, a backtick escape, a script block, the
|
|
78
|
+
`&` or `.` call operators, `Invoke-Expression`, or an encoded command is
|
|
79
|
+
never covered by a wildcard or prefix allow rule. An exact rule for the
|
|
80
|
+
literal line still is.
|
|
81
|
+
- Unbalanced quoting is a parse failure: no allow rule applies and you are
|
|
82
|
+
asked.
|
|
83
|
+
|
|
84
|
+
Blanket rules (`PowerShell`, `PowerShell(*)`, `PowerShell(iex *)`,
|
|
85
|
+
`PowerShell(Start-Process *)`) are suspended in auto mode like their bash
|
|
86
|
+
counterparts. Hook matchers accept `PowerShell`, `Bash|PowerShell`, and
|
|
87
|
+
`Bash,PowerShell`.
|
|
88
|
+
|
|
89
|
+
## Auto mode and plan mode
|
|
90
|
+
|
|
91
|
+
Claude Code's read-only cmdlets run without a prompt or a classifier call:
|
|
92
|
+
`Get-ChildItem`, `Get-Content`, `Get-Item`, `Test-Path`, `Resolve-Path`,
|
|
93
|
+
`Select-String`, `Get-Location`, `Get-Process`, `Get-Service`,
|
|
94
|
+
`Get-FileHash`, `Get-Acl`, `Format-Hex`, `findstr`, `where.exe`, plus
|
|
95
|
+
`Write-Output` and `Write-Host`. One Code adds the pure pipeline cmdlets
|
|
96
|
+
those are usually piped through, `Select-Object`, `Sort-Object`,
|
|
97
|
+
`Measure-Object`, `Group-Object`, `ConvertFrom-Json`, `Out-String` and the
|
|
98
|
+
`Format-*` family, so `Select-String … | Measure-Object` counts as read-only
|
|
99
|
+
too; `Where-Object` and `ForEach-Object` do not, because they take script
|
|
100
|
+
blocks. The check is textual and strict: no
|
|
101
|
+
redirection, no variables, no script blocks, and every path inside the
|
|
102
|
+
project or one of the harness's own session directories (auto-memory, the
|
|
103
|
+
scratchpad, persisted tool output, this project's transcripts). A relative
|
|
104
|
+
path is inside by construction; an absolute or drive-lettered path is
|
|
105
|
+
resolved and checked, so `Select-String -Path C:\src\app\notes.md` runs
|
|
106
|
+
unprompted while `Get-Content C:\Users\you\.ssh\id_rsa` does not. A UNC
|
|
107
|
+
path, `~`, a registry or environment drive, or `..` always takes the
|
|
108
|
+
ordinary route.
|
|
109
|
+
|
|
110
|
+
Everything else goes to the auto-mode classifier. One Code has no PowerShell
|
|
111
|
+
command parser yet, so unlike bash there is no deterministic shortcut for
|
|
112
|
+
in-project deletes: a `Remove-Item` inside the repository is classified, not
|
|
113
|
+
auto-approved. The safety floor still applies. Any PowerShell line that
|
|
114
|
+
names a permission settings file (`.claude/settings.json`,
|
|
115
|
+
`~/.onecode/settings.json`, `.claude.json`, managed settings) stops for you,
|
|
116
|
+
whether it reads or writes.
|
|
117
|
+
|
|
118
|
+
Plan mode allows the read-only cmdlets above and refuses the rest, as it
|
|
119
|
+
does for bash.
|
|
120
|
+
|
|
121
|
+
## Hooks
|
|
122
|
+
|
|
123
|
+
Hooks run in bash where one exists and in PowerShell on a Windows machine
|
|
124
|
+
without Git for Windows. A hook can pick its interpreter with Claude Code's
|
|
125
|
+
`shell` field: `"shell": "powershell"` or `"shell": "bash"`. Timeouts kill
|
|
126
|
+
the whole process tree through `taskkill /T` on Windows. See
|
|
127
|
+
[Hooks](hooks.md#which-shell-runs-a-hook).
|
|
128
|
+
|
|
129
|
+
## Keys
|
|
130
|
+
|
|
131
|
+
pi binds a few of its own shortcuts differently on Windows and WSL, where
|
|
132
|
+
alt+enter and ctrl+shift+letter do not reach the terminal reliably: queue a
|
|
133
|
+
follow-up is ctrl+q instead of alt+enter, fork is ctrl+f, paste is alt+v,
|
|
134
|
+
model selection is alt+p. Because ctrl+q is taken there, One Code cycles
|
|
135
|
+
permission modes with **alt+m** on Windows and WSL (ctrl+q everywhere
|
|
136
|
+
else). The footer badge, the startup hints, and `/doctor` name the key that
|
|
137
|
+
applies on your machine. Everything else in the [terminal reference](reference.md)
|
|
138
|
+
is the same.
|
|
139
|
+
|
|
140
|
+
## Paths and state
|
|
141
|
+
|
|
142
|
+
- One Code's state lives in `%USERPROFILE%\.onecode`; Claude Code's
|
|
143
|
+
configuration is read from `%USERPROFILE%\.claude` (or
|
|
144
|
+
`CLAUDE_CONFIG_DIR`).
|
|
145
|
+
- The scratchpad directory is under `%TEMP%`.
|
|
146
|
+
- The `Shell:` line in the system prompt is the basename of `SHELL`, else
|
|
147
|
+
`COMSPEC`, with `.exe` stripped, the value Claude Code prints.
|
|
148
|
+
- Language servers installed with npm are `.cmd` shims on Windows
|
|
149
|
+
(`typescript-language-server.cmd`). One Code finds them on `PATH` and
|
|
150
|
+
starts them through `cmd.exe`, so a plugin's `.lsp.json` can name the
|
|
151
|
+
command the way it does elsewhere.
|
|
152
|
+
- Auto mode's shell pre-gate and the worktree guards understand Git Bash's
|
|
153
|
+
path spellings inside a command line: `/c/Users/…` is `C:\Users\…`, and
|
|
154
|
+
`/tmp/…` is your temp directory, which Git for Windows mounts there. A
|
|
155
|
+
path rule with a wildcard is spelled with forward slashes
|
|
156
|
+
(`Read(C:/notes/**)` or `Read(//c/notes/**)`), because a backslash before
|
|
157
|
+
`*` is the escape character.
|
|
158
|
+
|
|
159
|
+
## What is verified
|
|
160
|
+
|
|
161
|
+
- The unit suite, including live PowerShell runs, passes on the
|
|
162
|
+
`windows-latest`, `macos-latest`, and `ubuntu-latest` GitHub runners.
|
|
163
|
+
- A real model drives both shell tools on the `windows-latest` runner: the
|
|
164
|
+
bash tool under Git Bash and the PowerShell tool under PowerShell 7, each
|
|
165
|
+
running a command whose output has to come back in the tool result. This
|
|
166
|
+
runs as its own workflow whenever the shell-facing code changes.
|
|
167
|
+
- Drive-letter, backslash, `C:/` and `~` paths in `Read(...)`/`Edit(...)`
|
|
168
|
+
rules, Windows PowerShell 5.1 (edition text, UTF-8 output, no `&&`),
|
|
169
|
+
stopping background PowerShell commands and timed-out hooks, and
|
|
170
|
+
npm-installed language servers all have Windows-only unit tests that run
|
|
171
|
+
on that runner.
|
|
172
|
+
- The PowerShell tool has also been driven through a real model with a
|
|
173
|
+
`pwsh` on macOS, the same way Claude Code's own PowerShell tool was
|
|
174
|
+
captured for reference.
|
|
175
|
+
- An interactive session on a Windows desktop (Windows Terminal, permission
|
|
176
|
+
prompts, plan mode) has not been checked by hand yet. If you hit something
|
|
177
|
+
there, [Troubleshooting](troubleshooting.md) says what to collect.
|
|
178
|
+
|
|
179
|
+
## Not provided
|
|
180
|
+
|
|
181
|
+
- No PowerShell command parser or pre-gate beyond the read-only list.
|
|
182
|
+
- No sandbox on Windows. Claude Code has none there either.
|
|
183
|
+
- `defaultShell` for the user's own `!` prefix is pi's to handle; One Code
|
|
184
|
+
does not read it.
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* path auto mode exists to close.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import type { HandBackVerdict } from "../lib/notifications.ts";
|
|
21
|
+
|
|
20
22
|
export const SUBAGENT_ACTIONS_CHANNEL = "one-code:subagent-actions";
|
|
21
23
|
|
|
22
24
|
export interface ChildAction {
|
|
@@ -45,7 +47,7 @@ export interface SubagentActionsPayload {
|
|
|
45
47
|
* synchronously when no review will run, so the emitter never waits for
|
|
46
48
|
* nothing.
|
|
47
49
|
*/
|
|
48
|
-
onReview?: (
|
|
50
|
+
onReview?: (verdict: HandBackVerdict | undefined) => void;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/** Per-action and total caps: a long-running child must not blow up the prompt. */
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* and every caller treats that as "not provably contained".
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { lstatSync, readlinkSync
|
|
18
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
19
19
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
-
import { expandTilde } from "../lib/paths.ts";
|
|
20
|
+
import { comparablePath, expandTilde, gitBashPathToNative, isPathAtOrUnder, tryRealpath } from "../lib/paths.ts";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Tools whose calls write to a path. Lives here — the lowest shared layer both
|
|
@@ -41,23 +41,8 @@ export function pathArgument(input: Record<string, unknown> | undefined): string
|
|
|
41
41
|
return typeof path === "string" ? path : undefined;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
const normalized = resolve(target).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
47
|
-
return process.platform === "linux" ? normalized : normalized.toLowerCase();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function tryRealpath(target: string): string | undefined {
|
|
51
|
-
try {
|
|
52
|
-
return realpathSync.native(target);
|
|
53
|
-
} catch {
|
|
54
|
-
try {
|
|
55
|
-
return realpathSync(target);
|
|
56
|
-
} catch {
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
44
|
+
/** The shared comparison form (lib/paths.ts): `/` separators, case-folded on darwin/win32. */
|
|
45
|
+
const normalize = comparablePath;
|
|
61
46
|
|
|
62
47
|
/**
|
|
63
48
|
* Resolve a path for containment checks, following a symlink leaf that does not
|
|
@@ -99,11 +84,9 @@ export function resolveForContainment(target: string): string | undefined {
|
|
|
99
84
|
}
|
|
100
85
|
}
|
|
101
86
|
|
|
102
|
-
/** True only when `target` is provably at or under `base
|
|
87
|
+
/** True only when `target` is provably at or under `base` (lib/paths.ts `isPathAtOrUnder`, argument order kept for the callers here). */
|
|
103
88
|
export function isWithin(base: string, target: string): boolean {
|
|
104
|
-
|
|
105
|
-
const normalizedTarget = normalize(target);
|
|
106
|
-
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`);
|
|
89
|
+
return isPathAtOrUnder(target, base);
|
|
107
90
|
}
|
|
108
91
|
|
|
109
92
|
/**
|
|
@@ -119,3 +102,18 @@ export function toAbsolute(cwd: string, token: string, home: string): string {
|
|
|
119
102
|
if (isAbsolute(token)) return resolve(token);
|
|
120
103
|
return resolve(cwd, token);
|
|
121
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* {@link toAbsolute} for a token taken from a bash command line. On Windows the
|
|
108
|
+
* bash tool runs Git Bash, whose absolute paths are MSYS spellings — `/c/proj/f`
|
|
109
|
+
* is `C:\proj\f`, `/tmp/f` sits under the user's temp dir — so the token is
|
|
110
|
+
* converted first (lib/paths.ts gitBashPathToNative). `path.resolve` alone reads
|
|
111
|
+
* `/c/proj/f` as `<cwd drive>:\c\proj\f`, which no gate recognises as the
|
|
112
|
+
* project: the error fell towards escalate/prompt, never towards allow, but every
|
|
113
|
+
* in-project write spelled that way went to the classifier. File-tool paths keep
|
|
114
|
+
* `toAbsolute`: pi's read/edit/write resolve them natively, and a gate must
|
|
115
|
+
* judge the path the tool acts on, not the one Git Bash would reach.
|
|
116
|
+
*/
|
|
117
|
+
export function toAbsoluteBash(cwd: string, token: string, home: string): string {
|
|
118
|
+
return toAbsolute(cwd, gitBashPathToNative(token), home);
|
|
119
|
+
}
|
|
@@ -25,14 +25,11 @@
|
|
|
25
25
|
import { analyzeShellCommand } from "./shell-analysis.ts";
|
|
26
26
|
import { autoModeSettingsPaths } from "./config.ts";
|
|
27
27
|
import { oneCodeProjectSettingsPath } from "../lib/one-code-settings.ts";
|
|
28
|
-
import { claudeJsonPath } from "../lib/paths.ts";
|
|
28
|
+
import { claudeJsonPath, comparablePath } from "../lib/paths.ts";
|
|
29
29
|
import { isWritingTool, resolveForContainment, toAbsolute } from "./paths.ts";
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
const forward = path.replace(/\\/g, "/");
|
|
34
|
-
return process.platform === "linux" ? forward : forward.toLowerCase();
|
|
35
|
-
}
|
|
31
|
+
/** The same comparison form resolveForContainment's output is in (lib/paths.ts). */
|
|
32
|
+
const fold = comparablePath;
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
35
|
* Any `.claude/settings.json` or `.claude/settings.local.json`, wherever it
|
|
@@ -127,5 +124,46 @@ export function safetyControlWrite({ toolName, input, cwd, home, oneCodeProjectS
|
|
|
127
124
|
}
|
|
128
125
|
}
|
|
129
126
|
|
|
127
|
+
if (toolName === "powershell") {
|
|
128
|
+
// No PowerShell write model yet, so the floor is textual and wider than
|
|
129
|
+
// bash's: ANY mention of a gate-control file in the command line stops it,
|
|
130
|
+
// whether the cmdlet reads or writes. A `Get-Content` of settings.json
|
|
131
|
+
// costs one prompt; a `Set-Content` that slipped through would cost the
|
|
132
|
+
// gate (the floor may only ever say "stop" — header).
|
|
133
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
134
|
+
if (!command) return undefined;
|
|
135
|
+
for (const token of powershellPathTokens(command, home)) {
|
|
136
|
+
const absolute = toAbsolute(cwd, token, home);
|
|
137
|
+
if (isSafetyControlTarget(resolveForContainment(absolute) ?? absolute, home, perRepoSettings)) return REASON(token);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
return undefined;
|
|
131
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Path-looking tokens of a PowerShell line, quotes stripped, `$env:USERPROFILE`,
|
|
146
|
+
* `$env:HOME`, `$HOME` and `~` expanded to the home dir, backslashes
|
|
147
|
+
* forward-slashed. Anything with a separator or a `.json`-style file name
|
|
148
|
+
* counts; the floor tolerates false positives.
|
|
149
|
+
*/
|
|
150
|
+
export function powershellPathTokens(command: string, home: string): string[] {
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const raw of command.split(/[\s;|()]+/)) {
|
|
153
|
+
let token = raw.replace(/^["']+|["',]+$/g, "");
|
|
154
|
+
if (!token) continue;
|
|
155
|
+
token = token
|
|
156
|
+
.replace(/^\$env:(USERPROFILE|HOME)/i, home)
|
|
157
|
+
.replace(/^\$HOME\b/i, home)
|
|
158
|
+
.replace(/^~(?=[\\/]|$)/, home)
|
|
159
|
+
.replace(/\\/g, "/");
|
|
160
|
+
// Parameter names (`-Path`) are not paths; `-Path:value` carries one.
|
|
161
|
+
if (token.startsWith("-")) {
|
|
162
|
+
const colon = token.indexOf(":");
|
|
163
|
+
if (colon === -1) continue;
|
|
164
|
+
token = token.slice(colon + 1);
|
|
165
|
+
}
|
|
166
|
+
if (/[\\/]/.test(token) || /\.json$/i.test(token)) out.push(token);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { isProtectedPath } from "../permissions/protected-paths.ts";
|
|
34
34
|
import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
|
|
35
|
-
import { isWithin, resolveForContainment,
|
|
35
|
+
import { isWithin, resolveForContainment, toAbsoluteBash } from "./paths.ts";
|
|
36
36
|
|
|
37
37
|
export type ShellVerdict = "safe" | "escalate";
|
|
38
38
|
|
|
@@ -43,7 +43,13 @@ export interface ShellEvidence {
|
|
|
43
43
|
/** The commands that actually run, wrappers peeled (`env rm` → `rm`). */
|
|
44
44
|
commands: string[];
|
|
45
45
|
/** Resolved paths this command may write, with containment already decided. */
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Every write target: the token as written, its absolute form as the shell
|
|
48
|
+
* reaches it (`toAbsoluteBash`: Git Bash spellings converted, `~` and cwd
|
|
49
|
+
* applied, not yet realpath'd — the form a "write it here instead" message
|
|
50
|
+
* keeps the model's spelling from), the realpath'd form, and the verdict.
|
|
51
|
+
*/
|
|
52
|
+
writes: { token: string; absolute: string; resolved?: string; outsideCwd: boolean }[];
|
|
47
53
|
/** Credential/secret paths named anywhere in the command (original tokens, never expanded). */
|
|
48
54
|
sensitivePaths: string[];
|
|
49
55
|
/** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
|
|
@@ -722,12 +728,21 @@ export interface AnalyzeInput {
|
|
|
722
728
|
* A redirect landing inside one escalates like a write to `.git/hooks`.
|
|
723
729
|
*/
|
|
724
730
|
protectedDirs?: string[];
|
|
731
|
+
/**
|
|
732
|
+
* REALPATH-resolved directories a read-only command may read from besides
|
|
733
|
+
* the working directory without escalating: the harness's own session dirs
|
|
734
|
+
* (memory, scratchpad, persisted results, this project's transcripts —
|
|
735
|
+
* permissions/matcher.ts `DecideInput.sessionDirPath`), resolved once per
|
|
736
|
+
* session by the caller (compared against realpaths here, unresolved). Reads
|
|
737
|
+
* only; the write and delete checks never consult this list.
|
|
738
|
+
*/
|
|
739
|
+
readableRoots?: string[];
|
|
725
740
|
}
|
|
726
741
|
|
|
727
742
|
/**
|
|
728
743
|
* Classify a shell command. Never denies — see the module contract above.
|
|
729
744
|
*/
|
|
730
|
-
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }: AnalyzeInput): ShellEvidence {
|
|
745
|
+
export function analyzeShellCommand({ command, cwd, home, protectedDirs = [], readableRoots = [] }: AnalyzeInput): ShellEvidence {
|
|
731
746
|
const evidence: ShellEvidence = {
|
|
732
747
|
verdict: "safe",
|
|
733
748
|
notes: [],
|
|
@@ -790,10 +805,10 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
790
805
|
*/
|
|
791
806
|
const checkWriteTarget = (token: string) => {
|
|
792
807
|
if (token === "/dev/null") return;
|
|
793
|
-
const absolute =
|
|
808
|
+
const absolute = toAbsoluteBash(effectiveCwd, token, home);
|
|
794
809
|
const resolved = resolveForContainment(absolute);
|
|
795
810
|
const outsideCwd = resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
796
|
-
evidence.writes.push({ token, resolved, outsideCwd });
|
|
811
|
+
evidence.writes.push({ token, absolute, resolved, outsideCwd });
|
|
797
812
|
if (resolved === undefined) {
|
|
798
813
|
escalate(`writes to ${token}, which could not be resolved to a real path`);
|
|
799
814
|
} else if (outsideCwd) {
|
|
@@ -863,7 +878,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
863
878
|
// `~/.kube/config` through two tokens that each look harmless (N12).
|
|
864
879
|
for (const { value } of args) {
|
|
865
880
|
if (!value || value.startsWith("-")) continue;
|
|
866
|
-
const absolute =
|
|
881
|
+
const absolute = toAbsoluteBash(effectiveCwd, value, home);
|
|
867
882
|
if (isSensitivePath(value) || isSensitivePath(absolute)) {
|
|
868
883
|
// The *original* token, never the resolved/expanded form (N18).
|
|
869
884
|
if (!evidence.sensitivePaths.includes(value)) evidence.sensitivePaths.push(value);
|
|
@@ -878,7 +893,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
878
893
|
if (name === "cd") {
|
|
879
894
|
const target = args.find((token) => !token.value.startsWith("-"))?.value;
|
|
880
895
|
if (target) {
|
|
881
|
-
effectiveCwd =
|
|
896
|
+
effectiveCwd = toAbsoluteBash(effectiveCwd, target, home);
|
|
882
897
|
escalate(`changes directory to ${target}, so later paths in this command resolve elsewhere`);
|
|
883
898
|
}
|
|
884
899
|
continue;
|
|
@@ -902,7 +917,7 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
902
917
|
continue;
|
|
903
918
|
}
|
|
904
919
|
const reason = gitEscalationReason(args, (dir) => {
|
|
905
|
-
const resolved = resolveForContainment(
|
|
920
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, dir, home));
|
|
906
921
|
return resolved === undefined || !isWithin(containmentRoot, resolved);
|
|
907
922
|
});
|
|
908
923
|
if (reason) escalate(reason);
|
|
@@ -946,8 +961,8 @@ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }:
|
|
|
946
961
|
}
|
|
947
962
|
for (const value of positionals) {
|
|
948
963
|
if (!looksLikePath(value)) continue;
|
|
949
|
-
const resolved = resolveForContainment(
|
|
950
|
-
if (resolved !== undefined && isWithin(containmentRoot, resolved)) continue;
|
|
964
|
+
const resolved = resolveForContainment(toAbsoluteBash(effectiveCwd, value, home));
|
|
965
|
+
if (resolved !== undefined && (isWithin(containmentRoot, resolved) || readableRoots.some((root) => isWithin(root, resolved)))) continue;
|
|
951
966
|
if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
|
|
952
967
|
escalate(`reads ${value}, which is outside the working directory`, { outsideRead: true });
|
|
953
968
|
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// the hooks matcher (its `ccToolName` is the correct-direction, mcp-passthrough
|
|
17
17
|
// map; permissions/matcher.ts's is lower-cased and cannot recover casing).
|
|
18
18
|
import { ccToolName } from "../hooks/matcher.ts";
|
|
19
|
+
// The shell tools render as `{"<Tool>":"<command>"}` (lib/shell-tools.ts is the one list).
|
|
20
|
+
import { SHELL_TOOLS } from "../lib/shell-tools.ts";
|
|
19
21
|
|
|
20
22
|
export { ccToolName };
|
|
21
23
|
|
|
@@ -30,7 +32,13 @@ export { ccToolName };
|
|
|
30
32
|
export type TranscriptEntry =
|
|
31
33
|
| { kind: "user"; text: string }
|
|
32
34
|
| { kind: "tool"; tool: string; input: Record<string, unknown> }
|
|
33
|
-
| { kind: "denied"; tool: string; subject: string; rule: string }
|
|
35
|
+
| { kind: "denied"; tool: string; subject: string; rule: string }
|
|
36
|
+
/**
|
|
37
|
+
* Harness ground truth, Claude Code's `{"meta":{"gitStatus":{"clean":…}}}`
|
|
38
|
+
* line after a `git status` call (captured 2.1.276, findings §22): the
|
|
39
|
+
* classifier reads the tree's real state, not the model's account of it.
|
|
40
|
+
*/
|
|
41
|
+
| { kind: "meta"; gitStatus: { clean: boolean } };
|
|
34
42
|
|
|
35
43
|
/** Truncate one field so a single huge argument cannot dominate the transcript. */
|
|
36
44
|
export function clip(value: string, max: number): string {
|
|
@@ -47,12 +55,14 @@ function clipInput(input: Record<string, unknown>, max: number): Record<string,
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
|
-
* Render one entry as its compact JSON line.
|
|
51
|
-
* (the command string,
|
|
52
|
-
* `{"<Tool>":{…input…}}`
|
|
58
|
+
* Render one entry as its compact JSON line. The shell tools render as
|
|
59
|
+
* `{"Bash":"<command>"}` / `{"PowerShell":"<command>"}` (the command string,
|
|
60
|
+
* as Claude Code does); every other tool renders as `{"<Tool>":{…input…}}`
|
|
61
|
+
* with string fields clipped.
|
|
53
62
|
*/
|
|
54
63
|
function renderEntry(entry: TranscriptEntry, maxField: number): string {
|
|
55
64
|
if (entry.kind === "user") return JSON.stringify({ user: clip(entry.text, maxField) });
|
|
65
|
+
if (entry.kind === "meta") return JSON.stringify({ meta: { gitStatus: { clean: entry.gitStatus.clean } } });
|
|
56
66
|
if (entry.kind === "denied") {
|
|
57
67
|
return JSON.stringify({
|
|
58
68
|
denied_by_permission_rule: { tool: ccToolName(entry.tool), attempted: clip(entry.subject, maxField), rule: entry.rule },
|
|
@@ -60,7 +70,7 @@ function renderEntry(entry: TranscriptEntry, maxField: number): string {
|
|
|
60
70
|
}
|
|
61
71
|
const name = ccToolName(entry.tool);
|
|
62
72
|
const command = entry.input.command;
|
|
63
|
-
if (entry.tool
|
|
73
|
+
if (SHELL_TOOLS.has(entry.tool) && typeof command === "string") {
|
|
64
74
|
return JSON.stringify({ [name]: clip(command, maxField) });
|
|
65
75
|
}
|
|
66
76
|
return JSON.stringify({ [name]: clipInput(entry.input, maxField) });
|