@te-river/opencode-team-mode 1.5.1 → 1.5.2
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 +109 -14
- package/README.zh-CN.md +92 -13
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +128 -26
- package/dist/agents.js.map +1 -1
- package/dist/approval-gate.d.ts +147 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +471 -0
- package/dist/approval-gate.js.map +1 -0
- package/dist/commands.js +3 -3
- package/dist/commands.js.map +1 -1
- package/dist/envprotect.d.ts +115 -4
- package/dist/envprotect.d.ts.map +1 -1
- package/dist/envprotect.js +364 -11
- package/dist/envprotect.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -2
- package/dist/index.js.map +1 -1
- package/dist/tm/config.d.ts +34 -0
- package/dist/tm/config.d.ts.map +1 -1
- package/dist/tm/config.js +21 -0
- package/dist/tm/config.js.map +1 -1
- package/dist/tm/guard.js +4 -4
- package/dist/tm/guard.js.map +1 -1
- package/dist/tm/index.d.ts +4 -2
- package/dist/tm/index.d.ts.map +1 -1
- package/dist/tm/index.js +32 -2
- package/dist/tm/index.js.map +1 -1
- package/dist/tm/ptc.d.ts +279 -0
- package/dist/tm/ptc.d.ts.map +1 -0
- package/dist/tm/ptc.js +839 -0
- package/dist/tm/ptc.js.map +1 -0
- package/dist/tm/tools.d.ts +16 -0
- package/dist/tm/tools.d.ts.map +1 -1
- package/dist/tm/tools.js +5 -1
- package/dist/tm/tools.js.map +1 -1
- package/dist/types.d.ts +104 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/commands.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"commands.js","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,wEAAwE;AACxE,MAAM,QAAQ,GAAkB;IAC9B,WAAW,EACT,+FAA+F;IACjG,KAAK,EAAE,WAAW;IAClB,QAAQ,EAAE;;;;;;;;;;;;;kDAasC;CACjD,CAAA;AAED,wEAAwE;AACxE,MAAM,aAAa,GAAkB;IACnC,WAAW,EACT,0FAA0F;IAC5F,KAAK,EAAE,aAAa;IACpB,QAAQ,EAAE;;;;;;;;4DAQgD;CAC3D,CAAA;AAED,wEAAwE;AACxE,MAAM,UAAU,GAAkB;IAChC,WAAW,EACT,4GAA4G;IAC9G,KAAK,EAAE,UAAU;IACjB,QAAQ,EAAE;;;;;;;;;;;;;;;;;;;gEAmBoD;CAC/D,CAAA;AAED,wEAAwE;AACxE,MAAM,QAAQ,GAAkB;IAC9B,WAAW,EACT,2EAA2E;IAC7E,KAAK,EAAE,QAAQ;IACf,QAAQ,EAAE;;;;;;;;;;;8BAWkB;CAC7B,CAAA;AAED,wEAAwE;AACxE,MAAM,YAAY,GAAkB;IAClC,WAAW,EACT,
|
|
1
|
+
{"version":3,"file":"commands.js","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,wEAAwE;AACxE,MAAM,QAAQ,GAAkB;IAC9B,WAAW,EACT,+FAA+F;IACjG,KAAK,EAAE,WAAW;IAClB,QAAQ,EAAE;;;;;;;;;;;;;kDAasC;CACjD,CAAA;AAED,wEAAwE;AACxE,MAAM,aAAa,GAAkB;IACnC,WAAW,EACT,0FAA0F;IAC5F,KAAK,EAAE,aAAa;IACpB,QAAQ,EAAE;;;;;;;;4DAQgD;CAC3D,CAAA;AAED,wEAAwE;AACxE,MAAM,UAAU,GAAkB;IAChC,WAAW,EACT,4GAA4G;IAC9G,KAAK,EAAE,UAAU;IACjB,QAAQ,EAAE;;;;;;;;;;;;;;;;;;;gEAmBoD;CAC/D,CAAA;AAED,wEAAwE;AACxE,MAAM,QAAQ,GAAkB;IAC9B,WAAW,EACT,2EAA2E;IAC7E,KAAK,EAAE,QAAQ;IACf,QAAQ,EAAE;;;;;;;;;;;8BAWkB;CAC7B,CAAA;AAED,wEAAwE;AACxE,MAAM,YAAY,GAAkB;IAClC,WAAW,EACT,wGAAwG;IAC1G,KAAK,EAAE,YAAY;IACnB,QAAQ,EAAE;;;;;;;;;;;6EAWiE;CAC5E,CAAA;AAED,wEAAwE;AACxE,MAAM,OAAO,GAAkB;IAC7B,WAAW,EACT,mGAAmG;IACrG,KAAK,EAAE,MAAM;IACb,QAAQ,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA0CmB;CAC9B,CAAA;AAED,wEAAwE;AACxE,wEAAwE;AACxE,wEAAwE;AAExE,MAAM,CAAC,MAAM,QAAQ,GAAkC;IACrD,WAAW,EAAE,QAAQ;IACrB,gBAAgB,EAAE,aAAa;IAC/B,aAAa,EAAE,UAAU;IACzB,WAAW,EAAE,QAAQ;IACrB,eAAe,EAAE,YAAY;IAC7B,UAAU,EAAE,OAAO;CACpB,CAAA"}
|
package/dist/envprotect.d.ts
CHANGED
|
@@ -22,9 +22,13 @@
|
|
|
22
22
|
* drive via Get-ChildItem / gci / dir / ls / Get-Item / gi /
|
|
23
23
|
* Get-Content / gc / cat / type (flags and optional quotes allowed
|
|
24
24
|
* between cmdlet and drive: `Get-Content 'env:HOME'`). The same
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
* statement heads are caught behind statement noise: subshells and
|
|
26
|
+
* groups (`(env)`, `{ env; }`), escaped commands (`\env`), the `time`
|
|
27
|
+
* keyword prefix, command substitution (`$(printenv)`, backticks), the
|
|
28
|
+
* program-path twins of the dump heads (bare `/usr/bin/env` = full
|
|
29
|
+
* dump, `/usr/bin/printenv …`), and the Windows launcher head
|
|
30
|
+
* (`cmd /c set` = cmd.exe's own caseless full dump; `/k` and quoted
|
|
31
|
+
* inners included).
|
|
28
32
|
* 2. `$env:` / `${env:...}` expansion (PowerShell);
|
|
29
33
|
* 3. strict mode only: `${VAR}` and `$ALLCAPS_VAR` expansion;
|
|
30
34
|
* 4. env-file paths: `.env`, `.env.*`, `*.env`, `.bashrc`,
|
|
@@ -71,6 +75,46 @@ export declare const CATEGORY_BASH_ENV_COMMAND = "bash-env-command";
|
|
|
71
75
|
export declare const CATEGORY_BASH_ENV_EXPANSION = "bash-env-expansion";
|
|
72
76
|
export declare const CATEGORY_ENV_FILE_PATH = "env-file-path";
|
|
73
77
|
export declare const CATEGORY_EXTRA_DENY = "extra-deny";
|
|
78
|
+
/**
|
|
79
|
+
* Two faces are routed to the OpenCode official confirmation dialog by
|
|
80
|
+
* declaring them `ask` in each agent's bash permission object. The host
|
|
81
|
+
* pops a dialog for any command matching one of these patterns and SUSPENDS
|
|
82
|
+
* it: a human reply ("once"/"always") runs the command, a "reject" or NO
|
|
83
|
+
* REPLY is handled by the approval timer (Layer 2, see approval-gate.ts)
|
|
84
|
+
* which auto-REJECTS after TM_ASK_TIMEOUT_MIN. The plugin NEVER self-allows.
|
|
85
|
+
*
|
|
86
|
+
* These are the shapes a wildcard CAN express (head-anchored globs, matching
|
|
87
|
+
* the probe-verified grammar: `printenv*`, `rm *`, `Get-ChildItem env:*`).
|
|
88
|
+
* Wildcards CANNOT express (embedded `${VAR}` / `$ALLCAPS` / `$env:` inside
|
|
89
|
+
* another command, command substitution `$(printenv)`, subshell/escaped/
|
|
90
|
+
* `time`-prefixed heads, `VAR=… env` assignment prefixes, split `declare -xp`,
|
|
91
|
+
* env-file paths at arbitrary positions) stay the classifier's HARD THROW —
|
|
92
|
+
* that is the `isAskGatedEnvCommand` boundary, kept in lockstep with the
|
|
93
|
+
* classifier so a form we stop throwing on is exactly a form the dialog shows.
|
|
94
|
+
*/
|
|
95
|
+
/** R6 env face — the wildcard-expressible environment-variable reads. */
|
|
96
|
+
export declare const R6_ENV_BASH_ASK: Readonly<Record<string, "ask">>;
|
|
97
|
+
/** R2 danger face — destructive / publishing / network / install / process /
|
|
98
|
+
* privilege commands. Independent of TM_ENV_PROTECT (R2 is its own red
|
|
99
|
+
* line); the built-in bash tool stays `allow` for everything else, so the
|
|
100
|
+
* normal verification stack (npm test, tsc, git status/diff) never pops. */
|
|
101
|
+
export declare const R2_DANGER_BASH_ASK: Readonly<Record<string, "ask">>;
|
|
102
|
+
/** Ordered key lists (frozen) — single source for the matcher + the gate. */
|
|
103
|
+
export declare const R6_ENV_BASH_ASK_PATTERNS: readonly string[];
|
|
104
|
+
export declare const R2_DANGER_BASH_ASK_PATTERNS: readonly string[];
|
|
105
|
+
/**
|
|
106
|
+
* The bash permission object injected into the four execution-role agents
|
|
107
|
+
* (team / implementer / reviewer / tester). Default stays `allow`
|
|
108
|
+
* (`"*": "allow"`, preserving the T2.1 matrix semantics — bash is granted,
|
|
109
|
+
* not removed); only the listed shapes escalate to the host dialog. The R6
|
|
110
|
+
* env face is dropped when `mode === "off"` (the R6 kill switch) AND when
|
|
111
|
+
* `envFace` is false — index.ts passes false while the approval gate cannot
|
|
112
|
+
* arm: the R6 hook then hard-throws every env read BEFORE its dialog could
|
|
113
|
+
* ever satisfy it, so injecting the env asks would only create dead popups
|
|
114
|
+
* a human can answer yet never make runnable. The R2 face is always
|
|
115
|
+
* present (its dialog is the whole gate — no code-level throw is involved).
|
|
116
|
+
*/
|
|
117
|
+
export declare function bashAskPatterns(mode: EnvProtectMode, envFace?: boolean): Record<string, string>;
|
|
74
118
|
export type EnvProtectMode = "strict" | "standard" | "off";
|
|
75
119
|
/**
|
|
76
120
|
* Resolve the protection mode. Only exact "standard" / "off" switch those
|
|
@@ -102,6 +146,54 @@ export declare function isEnvFilePath(raw: string): boolean;
|
|
|
102
146
|
* handled by the caller; user extra-deny rules take precedence.
|
|
103
147
|
*/
|
|
104
148
|
export declare function classifyBashCommand(command: string, mode: EnvProtectMode, extra?: RegExp[]): string | null;
|
|
149
|
+
/** True when `text` matches ANY of the given ask globs (LOOSE, host-style). */
|
|
150
|
+
export declare function commandMatchesAnyAskPattern(text: unknown, patterns: readonly string[]): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* True when a built-in-bash command is an environment read in a form the
|
|
153
|
+
* host is configured to ASK about (and therefore the dialog governs it).
|
|
154
|
+
* The hook then lets it proceed WITHOUT throwing, so a human approval is
|
|
155
|
+
* meaningful. Kept deliberately narrower than classifyBashCommand: every
|
|
156
|
+
* form it accepts is a head-anchored single statement that matches one of
|
|
157
|
+
* R6_ENV_BASH_ASK BYTE-EXACTLY (so the dialog WILL fire under any host
|
|
158
|
+
* grammar at least as greedy as ours — no case/trim leniency, see
|
|
159
|
+
* commandMatchesAnyAskPatternExact), and every hazard that breaks that
|
|
160
|
+
* alignment (compound statements, substitutions, `${…}`, `$ALLCAPS`,
|
|
161
|
+
* assignment prefixes, `time`/subshell/escaped heads, user extra-deny, path
|
|
162
|
+
* heads like `/usr/bin/env`, `cmd /c …`, and the PowerShell env-drive forms
|
|
163
|
+
* with intervening flags or quotes that the globs miss) returns false → the
|
|
164
|
+
* classifier keeps hard-throwing. tm_* channels are hard-blocked separately
|
|
165
|
+
* (the dialog never opens for them).
|
|
166
|
+
*/
|
|
167
|
+
export declare function isAskGatedEnvCommand(command: string, mode: EnvProtectMode, extra?: RegExp[]): boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Coarse category for a host permission-event payload, used ONLY to gate the
|
|
170
|
+
* timer, to register the deferral session, and to tag the audit (never the
|
|
171
|
+
* pattern text itself). Returns null for anything that is not one of OUR
|
|
172
|
+
* injected bash asks, so the gate leaves other tools' dialogs (edit,
|
|
173
|
+
* webfetch, host-native rules) alone.
|
|
174
|
+
*
|
|
175
|
+
* Real 1.18.29 `permission.asked` props carry NO `type` field: the tool name
|
|
176
|
+
* rides in `permission` and the concrete command segments in `patterns[]`
|
|
177
|
+
* (plus `metadata.command` with the raw line). Inference order, defensive
|
|
178
|
+
* across builds:
|
|
179
|
+
* 1. explicit tool field (`type` || `permission`) — non-bash is a hard no;
|
|
180
|
+
* 2. absent tool field → bash evidence = `metadata.command` present, or a
|
|
181
|
+
* candidate that is COMMAND-SHAPED (contains whitespace: our command
|
|
182
|
+
* globs all need an embedded space except bare keys, and bare-key
|
|
183
|
+
* patterns like a file named `set` cannot prove bash without the
|
|
184
|
+
* command metadata beside them).
|
|
185
|
+
* Matching here is the LOOSE glob (case-insensitive): over-matching can only
|
|
186
|
+
* arm a timer for a dialog the host already opened, never run a command —
|
|
187
|
+
* the deferral side keeps the byte-exact matcher (isAskGatedEnvCommand).
|
|
188
|
+
*/
|
|
189
|
+
export declare function categorizePermission(props: {
|
|
190
|
+
type?: string;
|
|
191
|
+
permission?: string;
|
|
192
|
+
pattern?: string | string[];
|
|
193
|
+
patterns?: string | string[];
|
|
194
|
+
title?: string;
|
|
195
|
+
metadata?: Record<string, unknown>;
|
|
196
|
+
} | null | undefined): "env" | "danger" | null;
|
|
105
197
|
/**
|
|
106
198
|
* Classify the path-class arguments of a file tool. Returns the pattern
|
|
107
199
|
* category when the call must be blocked, null when it may pass.
|
|
@@ -118,6 +210,25 @@ export declare function inspectToolCall(tool: string, args: Record<string, unkno
|
|
|
118
210
|
* When a block triggers, the audit entry is written first, then the fixed
|
|
119
211
|
* structured error is thrown so opencode surfaces it to the model as the
|
|
120
212
|
* failed tool result.
|
|
213
|
+
*
|
|
214
|
+
* `options.deferToApproval` (the unified approval gate) — called with the
|
|
215
|
+
* tool call's `input.sessionID`; when it reports `true`, a built-in-bash
|
|
216
|
+
* environment read in an EXACT ask-glob form is passed through WITHOUT
|
|
217
|
+
* throwing, because the official confirmation dialog is the live gate for
|
|
218
|
+
* it (approve → runs, reject or timeout → the host blocks it). The signal
|
|
219
|
+
* is SESSION-SCOPED on purpose: the gate registers only sessions carrying
|
|
220
|
+
* our injected bash ask set (exec-role chat.message, or an R6-env-classified
|
|
221
|
+
* `permission.asked`), so a stock build/plan session — where no dialog would
|
|
222
|
+
* ever open for `printenv` — keeps the hard throw instead of slipping the
|
|
223
|
+
* read through a global arm flag. Reaching the deferral point at all
|
|
224
|
+
* additionally requires the command to match the config globs byte-exactly,
|
|
225
|
+
* so the dialog is GUARANTEED to fire wherever deferral happens. A failed
|
|
226
|
+
* auto-reject flips the gate back to `false` everywhere, restoring the hard
|
|
227
|
+
* throw (fail-closed). tm_* channels are never deferred (no dialog opens
|
|
228
|
+
* for them).
|
|
121
229
|
*/
|
|
122
|
-
export declare function createEnvProtectHook(client: unknown, mode: EnvProtectMode, extra?: RegExp[]
|
|
230
|
+
export declare function createEnvProtectHook(client: unknown, mode: EnvProtectMode, extra?: RegExp[], options?: {
|
|
231
|
+
deferToApproval?: (sessionID?: string) => boolean;
|
|
232
|
+
envApproved?: (sessionID?: string) => boolean;
|
|
233
|
+
}): (input: unknown, output: unknown) => Promise<void>;
|
|
123
234
|
//# sourceMappingURL=envprotect.d.ts.map
|
package/dist/envprotect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"envprotect.d.ts","sourceRoot":"","sources":["../src/envprotect.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"envprotect.d.ts","sourceRoot":"","sources":["../src/envprotect.ts"],"names":[],"mappings":"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"}
|
package/dist/envprotect.js
CHANGED
|
@@ -22,9 +22,13 @@
|
|
|
22
22
|
* drive via Get-ChildItem / gci / dir / ls / Get-Item / gi /
|
|
23
23
|
* Get-Content / gc / cat / type (flags and optional quotes allowed
|
|
24
24
|
* between cmdlet and drive: `Get-Content 'env:HOME'`). The same
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
* statement heads are caught behind statement noise: subshells and
|
|
26
|
+
* groups (`(env)`, `{ env; }`), escaped commands (`\env`), the `time`
|
|
27
|
+
* keyword prefix, command substitution (`$(printenv)`, backticks), the
|
|
28
|
+
* program-path twins of the dump heads (bare `/usr/bin/env` = full
|
|
29
|
+
* dump, `/usr/bin/printenv …`), and the Windows launcher head
|
|
30
|
+
* (`cmd /c set` = cmd.exe's own caseless full dump; `/k` and quoted
|
|
31
|
+
* inners included).
|
|
28
32
|
* 2. `$env:` / `${env:...}` expansion (PowerShell);
|
|
29
33
|
* 3. strict mode only: `${VAR}` and `$ALLCAPS_VAR` expansion;
|
|
30
34
|
* 4. env-file paths: `.env`, `.env.*`, `*.env`, `.bashrc`,
|
|
@@ -71,6 +75,128 @@ export const CATEGORY_BASH_ENV_COMMAND = "bash-env-command";
|
|
|
71
75
|
export const CATEGORY_BASH_ENV_EXPANSION = "bash-env-expansion";
|
|
72
76
|
export const CATEGORY_ENV_FILE_PATH = "env-file-path";
|
|
73
77
|
export const CATEGORY_EXTRA_DENY = "extra-deny";
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
/* Unified approval gate — host-native `ask` pattern sets (Layer 1) */
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
/**
|
|
82
|
+
* Two faces are routed to the OpenCode official confirmation dialog by
|
|
83
|
+
* declaring them `ask` in each agent's bash permission object. The host
|
|
84
|
+
* pops a dialog for any command matching one of these patterns and SUSPENDS
|
|
85
|
+
* it: a human reply ("once"/"always") runs the command, a "reject" or NO
|
|
86
|
+
* REPLY is handled by the approval timer (Layer 2, see approval-gate.ts)
|
|
87
|
+
* which auto-REJECTS after TM_ASK_TIMEOUT_MIN. The plugin NEVER self-allows.
|
|
88
|
+
*
|
|
89
|
+
* These are the shapes a wildcard CAN express (head-anchored globs, matching
|
|
90
|
+
* the probe-verified grammar: `printenv*`, `rm *`, `Get-ChildItem env:*`).
|
|
91
|
+
* Wildcards CANNOT express (embedded `${VAR}` / `$ALLCAPS` / `$env:` inside
|
|
92
|
+
* another command, command substitution `$(printenv)`, subshell/escaped/
|
|
93
|
+
* `time`-prefixed heads, `VAR=… env` assignment prefixes, split `declare -xp`,
|
|
94
|
+
* env-file paths at arbitrary positions) stay the classifier's HARD THROW —
|
|
95
|
+
* that is the `isAskGatedEnvCommand` boundary, kept in lockstep with the
|
|
96
|
+
* classifier so a form we stop throwing on is exactly a form the dialog shows.
|
|
97
|
+
*/
|
|
98
|
+
/** R6 env face — the wildcard-expressible environment-variable reads. */
|
|
99
|
+
export const R6_ENV_BASH_ASK = Object.freeze({
|
|
100
|
+
printenv: "ask",
|
|
101
|
+
"printenv *": "ask",
|
|
102
|
+
env: "ask",
|
|
103
|
+
"env *": "ask",
|
|
104
|
+
set: "ask",
|
|
105
|
+
"export -p": "ask",
|
|
106
|
+
"export -p *": "ask",
|
|
107
|
+
"declare -p": "ask",
|
|
108
|
+
"declare -p *": "ask",
|
|
109
|
+
"typeset -p": "ask",
|
|
110
|
+
"typeset -p *": "ask",
|
|
111
|
+
"local -p": "ask",
|
|
112
|
+
"local -p *": "ask",
|
|
113
|
+
"$env:*": "ask",
|
|
114
|
+
"Get-ChildItem env:*": "ask",
|
|
115
|
+
"gci env:*": "ask",
|
|
116
|
+
"dir env:*": "ask",
|
|
117
|
+
"ls env:*": "ask",
|
|
118
|
+
"Get-Item env:*": "ask",
|
|
119
|
+
"gi env:*": "ask",
|
|
120
|
+
"Get-Content env:*": "ask",
|
|
121
|
+
"gc env:*": "ask",
|
|
122
|
+
"cat env:*": "ask",
|
|
123
|
+
"type env:*": "ask",
|
|
124
|
+
});
|
|
125
|
+
/** R2 danger face — destructive / publishing / network / install / process /
|
|
126
|
+
* privilege commands. Independent of TM_ENV_PROTECT (R2 is its own red
|
|
127
|
+
* line); the built-in bash tool stays `allow` for everything else, so the
|
|
128
|
+
* normal verification stack (npm test, tsc, git status/diff) never pops. */
|
|
129
|
+
export const R2_DANGER_BASH_ASK = Object.freeze({
|
|
130
|
+
// deletion
|
|
131
|
+
rm: "ask",
|
|
132
|
+
"rm *": "ask",
|
|
133
|
+
rmdir: "ask",
|
|
134
|
+
"rmdir *": "ask",
|
|
135
|
+
rd: "ask",
|
|
136
|
+
"rd *": "ask",
|
|
137
|
+
del: "ask",
|
|
138
|
+
"del *": "ask",
|
|
139
|
+
Erase: "ask",
|
|
140
|
+
"Erase *": "ask",
|
|
141
|
+
"Remove-Item *": "ask",
|
|
142
|
+
"Remove-Item": "ask",
|
|
143
|
+
// git publish (bare forms too — `git push` / `git commit` WITHOUT args are
|
|
144
|
+
// the shapes the arg-carrying globs cannot match)
|
|
145
|
+
"git push *": "ask",
|
|
146
|
+
"git push": "ask",
|
|
147
|
+
"git commit *": "ask",
|
|
148
|
+
"git commit": "ask",
|
|
149
|
+
// network
|
|
150
|
+
curl: "ask",
|
|
151
|
+
"curl *": "ask",
|
|
152
|
+
"wget *": "ask",
|
|
153
|
+
"Invoke-WebRequest *": "ask",
|
|
154
|
+
"Invoke-RestMethod *": "ask",
|
|
155
|
+
// package management
|
|
156
|
+
"npm install *": "ask",
|
|
157
|
+
"npm i *": "ask",
|
|
158
|
+
"npm publish *": "ask",
|
|
159
|
+
"npm publish": "ask",
|
|
160
|
+
"pip install *": "ask",
|
|
161
|
+
"pip3 install *": "ask",
|
|
162
|
+
"winget *": "ask",
|
|
163
|
+
"choco *": "ask",
|
|
164
|
+
// process / system
|
|
165
|
+
"taskkill *": "ask",
|
|
166
|
+
"Stop-Process *": "ask",
|
|
167
|
+
"kill *": "ask",
|
|
168
|
+
"shutdown *": "ask",
|
|
169
|
+
"format *": "ask",
|
|
170
|
+
// privilege
|
|
171
|
+
"chmod *": "ask",
|
|
172
|
+
"takeown *": "ask",
|
|
173
|
+
"icacls *": "ask",
|
|
174
|
+
});
|
|
175
|
+
/** Ordered key lists (frozen) — single source for the matcher + the gate. */
|
|
176
|
+
export const R6_ENV_BASH_ASK_PATTERNS = Object.freeze(Object.keys(R6_ENV_BASH_ASK));
|
|
177
|
+
export const R2_DANGER_BASH_ASK_PATTERNS = Object.freeze(Object.keys(R2_DANGER_BASH_ASK));
|
|
178
|
+
/**
|
|
179
|
+
* The bash permission object injected into the four execution-role agents
|
|
180
|
+
* (team / implementer / reviewer / tester). Default stays `allow`
|
|
181
|
+
* (`"*": "allow"`, preserving the T2.1 matrix semantics — bash is granted,
|
|
182
|
+
* not removed); only the listed shapes escalate to the host dialog. The R6
|
|
183
|
+
* env face is dropped when `mode === "off"` (the R6 kill switch) AND when
|
|
184
|
+
* `envFace` is false — index.ts passes false while the approval gate cannot
|
|
185
|
+
* arm: the R6 hook then hard-throws every env read BEFORE its dialog could
|
|
186
|
+
* ever satisfy it, so injecting the env asks would only create dead popups
|
|
187
|
+
* a human can answer yet never make runnable. The R2 face is always
|
|
188
|
+
* present (its dialog is the whole gate — no code-level throw is involved).
|
|
189
|
+
*/
|
|
190
|
+
export function bashAskPatterns(mode, envFace = true) {
|
|
191
|
+
const out = { "*": "allow" };
|
|
192
|
+
if (mode !== "off" && envFace) {
|
|
193
|
+
for (const p of R6_ENV_BASH_ASK_PATTERNS)
|
|
194
|
+
out[p] = "ask";
|
|
195
|
+
}
|
|
196
|
+
for (const p of R2_DANGER_BASH_ASK_PATTERNS)
|
|
197
|
+
out[p] = "ask";
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
74
200
|
/**
|
|
75
201
|
* Resolve the protection mode. Only exact "standard" / "off" switch those
|
|
76
202
|
* modes on; everything else (unset, typos, wrong case) fails CLOSED into
|
|
@@ -254,9 +380,13 @@ function envRestIsDump(rest) {
|
|
|
254
380
|
* bare `set` (no arguments) is the sh builtin that prints every variable,
|
|
255
381
|
* while PowerShell `Set-*` cmdlets and `set -euo pipefail` style flags are
|
|
256
382
|
* never variable dumps and must pass. `env`/`printenv` are matched
|
|
257
|
-
* case-insensitively
|
|
258
|
-
*
|
|
259
|
-
*
|
|
383
|
+
* case-insensitively AND path-agnostically: the basename decides, so the
|
|
384
|
+
* formerly double-blind path heads are covered too — a bare `/usr/bin/env`
|
|
385
|
+
* (or `./env`) is the SAME full dump as bare `env`, and `/usr/bin/printenv`
|
|
386
|
+
* (with or without vars) the same read as bare `printenv`; `env <command>`
|
|
387
|
+
* keeps its launcher/dump boundary via envRestIsDump either way
|
|
388
|
+
* (`/usr/bin/env node app.js` still passes, bare `/usr/bin/env` throws —
|
|
389
|
+
* the dialog grammar cannot express path heads, so these stay hard).
|
|
260
390
|
* `declare -p` prints variable definitions in reusable form, and so do its
|
|
261
391
|
* synonyms `typeset -p` / `export -p` / `local -p` — with the flag allowed
|
|
262
392
|
* to be split from `-p` by one other option (`declare -x -p FOO`).
|
|
@@ -265,18 +395,39 @@ function isEnvDumpStatement(token, rest) {
|
|
|
265
395
|
if (!token)
|
|
266
396
|
return false;
|
|
267
397
|
const lower = token.toLowerCase();
|
|
268
|
-
|
|
398
|
+
// program-path heads resolve by basename (`/usr/bin/env`, `C:\bin\printenv`)
|
|
399
|
+
const base = lower.split(/[\\/]/).pop() || lower;
|
|
400
|
+
if (base === "env")
|
|
269
401
|
return envRestIsDump(rest);
|
|
270
|
-
|
|
271
|
-
if (lower === "printenv")
|
|
402
|
+
if (base === "printenv")
|
|
272
403
|
return true;
|
|
273
|
-
if (
|
|
404
|
+
if (base === "declare" || base === "typeset" || base === "export" || base === "local") {
|
|
274
405
|
return /^\s*(?:-[a-z]+\s+)?-[a-z]*p[a-z]*\b/.test(` ${rest}`);
|
|
275
406
|
}
|
|
276
407
|
if (token === "set")
|
|
277
408
|
return rest === "";
|
|
278
409
|
return false;
|
|
279
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* `cmd /c <inner>` / `cmd.exe /k "<inner>"` (case-insensitive — cmd.exe is
|
|
413
|
+
* the Windows shell): the inner string runs as its own cmd statement, so a
|
|
414
|
+
* bare `set` there is the SAME full env dump the sh builtin is — and cmd's
|
|
415
|
+
* `SET` is caseless (no PowerShell `Set-*`-cmdlet confusion is possible
|
|
416
|
+
* inside cmd.exe). Returns the peeled inner command, or null when the
|
|
417
|
+
* segment head is not a cmd launcher.
|
|
418
|
+
*/
|
|
419
|
+
function cmdShellInner(token, rest) {
|
|
420
|
+
if (!/^(?:cmd|cmd\.exe)$/i.test(token))
|
|
421
|
+
return null;
|
|
422
|
+
const m = /^\/[ck]\s+(.*)$/i.exec(rest.trim());
|
|
423
|
+
if (!m)
|
|
424
|
+
return null;
|
|
425
|
+
let inner = m[1].trim();
|
|
426
|
+
const q = /^(["'])([\s\S]*)\1$/.exec(inner);
|
|
427
|
+
if (q)
|
|
428
|
+
inner = q[2].trim();
|
|
429
|
+
return inner || null;
|
|
430
|
+
}
|
|
280
431
|
/** PowerShell `env:` drive access without the `$` prefix. Optional
|
|
281
432
|
* parameter flags and optional quotes between the cmdlet and the drive
|
|
282
433
|
* (`Get-Content -Path env:HOME`, `Get-Content 'env:HOME'`, `ls env:`) are
|
|
@@ -329,6 +480,18 @@ export function classifyBashCommand(command, mode, extra = []) {
|
|
|
329
480
|
const { token, rest } = commandToken(segment);
|
|
330
481
|
if (isEnvDumpStatement(token, rest))
|
|
331
482
|
return CATEGORY_BASH_ENV_COMMAND;
|
|
483
|
+
// `cmd /c <inner>` runs a statement of its own — classify the inner
|
|
484
|
+
// command (covers `cmd /c printenv`, `cmd /c "cat .env"`, …) and add the
|
|
485
|
+
// cmd-case-specific dump heads the sh rules keep case-strict
|
|
486
|
+
const cmdInner = cmdShellInner(token, rest);
|
|
487
|
+
if (cmdInner) {
|
|
488
|
+
const innerCategory = classifyBashCommand(cmdInner, mode, extra);
|
|
489
|
+
if (innerCategory)
|
|
490
|
+
return innerCategory;
|
|
491
|
+
if (!cmdInner.includes("=") && /^set(?:\s|$)/i.test(cmdInner)) {
|
|
492
|
+
return CATEGORY_BASH_ENV_COMMAND; // cmd.exe prints env for `set` / `set NAME`
|
|
493
|
+
}
|
|
494
|
+
}
|
|
332
495
|
}
|
|
333
496
|
// command substitution runs its own command line: classify the inner
|
|
334
497
|
// text recursively (`$(printenv) PATH`, backtick `cat .env`)
|
|
@@ -354,6 +517,167 @@ export function classifyBashCommand(command, mode, extra = []) {
|
|
|
354
517
|
return CATEGORY_ENV_FILE_PATH;
|
|
355
518
|
return null;
|
|
356
519
|
}
|
|
520
|
+
// ---------- approval-gate deferral predicates ----------
|
|
521
|
+
/** Compile `*` into `.*`, escape every other regex metacharacter, anchor. */
|
|
522
|
+
function globToRegex(pattern, flags) {
|
|
523
|
+
let out = "";
|
|
524
|
+
for (const ch of pattern) {
|
|
525
|
+
if (ch === "*")
|
|
526
|
+
out += ".*";
|
|
527
|
+
else if (".+?^${}()|[]\\".includes(ch))
|
|
528
|
+
out += "\\" + ch;
|
|
529
|
+
else
|
|
530
|
+
out += ch;
|
|
531
|
+
}
|
|
532
|
+
return new RegExp("^" + out + "$", flags);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Two spellings of the same glob grammar:
|
|
536
|
+
* - LOOSE (case-insensitive, trimmed) — dialog IDENTIFICATION only
|
|
537
|
+
* (categorizePermission): over-matching here can merely arm a timer for a
|
|
538
|
+
* dialog the host already opened, never let a command run;
|
|
539
|
+
* - EXACT (byte-for-byte, no trim) — the DEFERRAL side: a command is only
|
|
540
|
+
* passed through to the dialog when it reproduces the injected config
|
|
541
|
+
* pattern head exactly, so ANY host matcher at least as greedy as ours
|
|
542
|
+
* must pop the dialog. Case/trim leniency on this side would defer forms
|
|
543
|
+
* like `GET-CONTENT ENV:PATH` that a byte-exact host grammar never pops
|
|
544
|
+
* — a SILENT env read with no popup. If the host turns out to be
|
|
545
|
+
* case-insensitive, exact costs nothing but a few extra hard throws
|
|
546
|
+
* (fail-closed direction).
|
|
547
|
+
*/
|
|
548
|
+
const GLOB_CACHE = new Map();
|
|
549
|
+
function glob(pattern, flags) {
|
|
550
|
+
const key = flags + pattern;
|
|
551
|
+
let re = GLOB_CACHE.get(key);
|
|
552
|
+
if (!re) {
|
|
553
|
+
re = globToRegex(pattern, flags);
|
|
554
|
+
GLOB_CACHE.set(key, re);
|
|
555
|
+
}
|
|
556
|
+
return re;
|
|
557
|
+
}
|
|
558
|
+
/** True when `text` matches ANY of the given ask globs (LOOSE, host-style). */
|
|
559
|
+
export function commandMatchesAnyAskPattern(text, patterns) {
|
|
560
|
+
const t = String(text ?? "").trim();
|
|
561
|
+
if (!t)
|
|
562
|
+
return false;
|
|
563
|
+
for (const p of patterns)
|
|
564
|
+
if (glob(p, "i").test(t))
|
|
565
|
+
return true;
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
/** Byte-exact deferral-side match (see the GLOB_CACHE note above). */
|
|
569
|
+
function commandMatchesAnyAskPatternExact(text, patterns) {
|
|
570
|
+
if (!text)
|
|
571
|
+
return false;
|
|
572
|
+
for (const p of patterns)
|
|
573
|
+
if (glob(p, "").test(text))
|
|
574
|
+
return true;
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* True when a built-in-bash command is an environment read in a form the
|
|
579
|
+
* host is configured to ASK about (and therefore the dialog governs it).
|
|
580
|
+
* The hook then lets it proceed WITHOUT throwing, so a human approval is
|
|
581
|
+
* meaningful. Kept deliberately narrower than classifyBashCommand: every
|
|
582
|
+
* form it accepts is a head-anchored single statement that matches one of
|
|
583
|
+
* R6_ENV_BASH_ASK BYTE-EXACTLY (so the dialog WILL fire under any host
|
|
584
|
+
* grammar at least as greedy as ours — no case/trim leniency, see
|
|
585
|
+
* commandMatchesAnyAskPatternExact), and every hazard that breaks that
|
|
586
|
+
* alignment (compound statements, substitutions, `${…}`, `$ALLCAPS`,
|
|
587
|
+
* assignment prefixes, `time`/subshell/escaped heads, user extra-deny, path
|
|
588
|
+
* heads like `/usr/bin/env`, `cmd /c …`, and the PowerShell env-drive forms
|
|
589
|
+
* with intervening flags or quotes that the globs miss) returns false → the
|
|
590
|
+
* classifier keeps hard-throwing. tm_* channels are hard-blocked separately
|
|
591
|
+
* (the dialog never opens for them).
|
|
592
|
+
*/
|
|
593
|
+
export function isAskGatedEnvCommand(command, mode, extra = []) {
|
|
594
|
+
const text = String(command ?? "");
|
|
595
|
+
if (!text.trim())
|
|
596
|
+
return false;
|
|
597
|
+
for (const r of extra)
|
|
598
|
+
if (r.test(text))
|
|
599
|
+
return false;
|
|
600
|
+
// command substitution runs its own line — the outer pattern never matches
|
|
601
|
+
if (/\$\(|`|<\(|>\(/.test(text))
|
|
602
|
+
return false;
|
|
603
|
+
// ${VAR} brace expansion is wildcard-inexpressible
|
|
604
|
+
if (/\$\{[^}]*\}/.test(text))
|
|
605
|
+
return false;
|
|
606
|
+
const segs = splitSegments(text).map((s) => s.trim()).filter(Boolean);
|
|
607
|
+
if (segs.length !== 1)
|
|
608
|
+
return false;
|
|
609
|
+
const seg = segs[0];
|
|
610
|
+
// byte-exact head: a VAR=value prefix, stray leading whitespace or any
|
|
611
|
+
// case deviation from the injected pattern fails here and stays hard
|
|
612
|
+
if (!commandMatchesAnyAskPatternExact(text, R6_ENV_BASH_ASK_PATTERNS))
|
|
613
|
+
return false;
|
|
614
|
+
const { token, rest } = commandToken(seg);
|
|
615
|
+
// classic dump head: printenv / env / set / declare|typeset|export|local -p
|
|
616
|
+
if (isEnvDumpStatement(token, rest))
|
|
617
|
+
return true;
|
|
618
|
+
// PowerShell env: drive read with no flags between the cmdlet and the
|
|
619
|
+
// drive, spellings byte-identical to the config globs (see the doc above)
|
|
620
|
+
if (/^(?:Get-ChildItem|gci|dir|ls|Get-Item|gi|Get-Content|gc|cat|type)\s+env:/.test(seg)) {
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
// leading `$env:` drive form (matches the `$env:*` config glob)
|
|
624
|
+
if (text.startsWith("$env:"))
|
|
625
|
+
return true;
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Coarse category for a host permission-event payload, used ONLY to gate the
|
|
630
|
+
* timer, to register the deferral session, and to tag the audit (never the
|
|
631
|
+
* pattern text itself). Returns null for anything that is not one of OUR
|
|
632
|
+
* injected bash asks, so the gate leaves other tools' dialogs (edit,
|
|
633
|
+
* webfetch, host-native rules) alone.
|
|
634
|
+
*
|
|
635
|
+
* Real 1.18.29 `permission.asked` props carry NO `type` field: the tool name
|
|
636
|
+
* rides in `permission` and the concrete command segments in `patterns[]`
|
|
637
|
+
* (plus `metadata.command` with the raw line). Inference order, defensive
|
|
638
|
+
* across builds:
|
|
639
|
+
* 1. explicit tool field (`type` || `permission`) — non-bash is a hard no;
|
|
640
|
+
* 2. absent tool field → bash evidence = `metadata.command` present, or a
|
|
641
|
+
* candidate that is COMMAND-SHAPED (contains whitespace: our command
|
|
642
|
+
* globs all need an embedded space except bare keys, and bare-key
|
|
643
|
+
* patterns like a file named `set` cannot prove bash without the
|
|
644
|
+
* command metadata beside them).
|
|
645
|
+
* Matching here is the LOOSE glob (case-insensitive): over-matching can only
|
|
646
|
+
* arm a timer for a dialog the host already opened, never run a command —
|
|
647
|
+
* the deferral side keeps the byte-exact matcher (isAskGatedEnvCommand).
|
|
648
|
+
*/
|
|
649
|
+
export function categorizePermission(props) {
|
|
650
|
+
if (!props)
|
|
651
|
+
return null;
|
|
652
|
+
const type = String(props.type ?? props.permission ?? "").trim().toLowerCase();
|
|
653
|
+
if (type && type !== "bash")
|
|
654
|
+
return null;
|
|
655
|
+
const raw = props.patterns ?? props.pattern;
|
|
656
|
+
const list = raw == null ? [] : Array.isArray(raw) ? raw.map(String) : [String(raw)];
|
|
657
|
+
const metaCmd = typeof props.metadata?.command === "string" ? props.metadata.command : "";
|
|
658
|
+
const title = typeof props.title === "string" ? props.title : "";
|
|
659
|
+
const candidates = [...list];
|
|
660
|
+
if (metaCmd)
|
|
661
|
+
candidates.push(metaCmd);
|
|
662
|
+
if (title)
|
|
663
|
+
candidates.push(title);
|
|
664
|
+
if (!candidates.length)
|
|
665
|
+
return null;
|
|
666
|
+
if (!type) {
|
|
667
|
+
// no tool field at all (live 1.18.29 asked): bash evidence = the bash
|
|
668
|
+
// tool's own metadata.command, or a command-shaped candidate (contains
|
|
669
|
+
// whitespace — every multi-word ask glob needs one; single bare words
|
|
670
|
+
// like a path named `set` prove nothing without metadata beside them)
|
|
671
|
+
if (!metaCmd && !candidates.some((c) => /\s/.test(c)))
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
const hits = (patterns) => candidates.some((p) => patterns.includes(p) || commandMatchesAnyAskPattern(p, patterns));
|
|
675
|
+
if (hits(R6_ENV_BASH_ASK_PATTERNS))
|
|
676
|
+
return "env";
|
|
677
|
+
if (hits(R2_DANGER_BASH_ASK_PATTERNS))
|
|
678
|
+
return "danger";
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
357
681
|
// ---------- file-tool path classification ----------
|
|
358
682
|
/**
|
|
359
683
|
* Path-class argument keys scanned defensively across read / grep / glob /
|
|
@@ -450,16 +774,45 @@ async function auditInterception(client, tool, category) {
|
|
|
450
774
|
* When a block triggers, the audit entry is written first, then the fixed
|
|
451
775
|
* structured error is thrown so opencode surfaces it to the model as the
|
|
452
776
|
* failed tool result.
|
|
777
|
+
*
|
|
778
|
+
* `options.deferToApproval` (the unified approval gate) — called with the
|
|
779
|
+
* tool call's `input.sessionID`; when it reports `true`, a built-in-bash
|
|
780
|
+
* environment read in an EXACT ask-glob form is passed through WITHOUT
|
|
781
|
+
* throwing, because the official confirmation dialog is the live gate for
|
|
782
|
+
* it (approve → runs, reject or timeout → the host blocks it). The signal
|
|
783
|
+
* is SESSION-SCOPED on purpose: the gate registers only sessions carrying
|
|
784
|
+
* our injected bash ask set (exec-role chat.message, or an R6-env-classified
|
|
785
|
+
* `permission.asked`), so a stock build/plan session — where no dialog would
|
|
786
|
+
* ever open for `printenv` — keeps the hard throw instead of slipping the
|
|
787
|
+
* read through a global arm flag. Reaching the deferral point at all
|
|
788
|
+
* additionally requires the command to match the config globs byte-exactly,
|
|
789
|
+
* so the dialog is GUARANTEED to fire wherever deferral happens. A failed
|
|
790
|
+
* auto-reject flips the gate back to `false` everywhere, restoring the hard
|
|
791
|
+
* throw (fail-closed). tm_* channels are never deferred (no dialog opens
|
|
792
|
+
* for them).
|
|
453
793
|
*/
|
|
454
|
-
export function createEnvProtectHook(client, mode, extra = []) {
|
|
794
|
+
export function createEnvProtectHook(client, mode, extra = [], options = {}) {
|
|
455
795
|
return async (input, output) => {
|
|
456
796
|
if (mode === "off")
|
|
457
797
|
return;
|
|
458
798
|
const tool = String(input?.tool ?? "");
|
|
799
|
+
const rawSid = input?.sessionID;
|
|
800
|
+
const sessionID = typeof rawSid === "string" && rawSid.trim() !== "" ? rawSid.trim() : undefined;
|
|
459
801
|
const args = output?.args;
|
|
460
802
|
const category = inspectToolCall(tool, args, mode, extra);
|
|
461
803
|
if (!category)
|
|
462
804
|
return;
|
|
805
|
+
// Session-wide env approval ("always" on first env ask) — pass silently;
|
|
806
|
+
// the "always" event itself was already audited by the approval gate.
|
|
807
|
+
if (sessionID && options.envApproved?.(sessionID))
|
|
808
|
+
return;
|
|
809
|
+
// Defer ONLY the built-in bash tool (never its tm_bash alias), ONLY in a
|
|
810
|
+
// registered session, for the exact forms the config escalates to `ask`.
|
|
811
|
+
if (tool.trim().toLowerCase() === "bash" && sessionID && options.deferToApproval?.(sessionID)) {
|
|
812
|
+
const command = args?.command;
|
|
813
|
+
if (isAskGatedEnvCommand(String(command ?? ""), mode, extra))
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
463
816
|
await auditInterception(client, tool, category);
|
|
464
817
|
throw envProtectError(category);
|
|
465
818
|
};
|