sortie-dogs 0.1.4 → 0.1.11
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 +49 -0
- package/dist/asset-version.d.ts +6 -0
- package/dist/asset-version.js +5 -0
- package/dist/core/consultation.d.ts +4 -1
- package/dist/plugin/config.d.ts +13 -1
- package/dist/plugin/config.js +41 -5
- package/dist/plugin/gate.d.ts +15 -1
- package/dist/plugin/gate.js +239 -16
- package/dist/plugin/index.d.ts +9 -0
- package/dist/plugin/index.js +623 -99
- package/dist/plugin/model-routing-hook.d.ts +2 -1
- package/dist/plugin/model-routing-hook.js +1 -0
- package/dist/plugin/model-routing.d.ts +12 -3
- package/dist/plugin/model-routing.js +32 -10
- package/dist/runtime-assets.d.ts +4 -3
- package/dist/runtime-assets.js +185 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,6 +80,55 @@ OpenCode discovers the bridge automatically; no `plugin` entry in
|
|
|
80
80
|
|
|
81
81
|
Selecting `dog-coordinator` directly also activates the workflow.
|
|
82
82
|
|
|
83
|
+
## The write gate
|
|
84
|
+
|
|
85
|
+
The write gate is opt-in per project. Without `operation-manifest.json` in the
|
|
86
|
+
project root, the plugin stays passive and never denies a tool call. Creating
|
|
87
|
+
that file is how a project opts in, so the coordinator can always create it.
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"version": "0.1.0",
|
|
92
|
+
"task_id": "add-requested-behavior",
|
|
93
|
+
"read": ["src/feature.ts", "test/feature.test.ts"],
|
|
94
|
+
"write": ["src/feature.ts", "test/feature.test.ts"],
|
|
95
|
+
"validation": ["npm test"]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `write` lists the only paths a bound worker may change. A listed directory
|
|
100
|
+
covers the files under it; every other entry is an exact path.
|
|
101
|
+
- `validation` lists the exact commands a bound worker may run. Build and test
|
|
102
|
+
commands cannot be classified by path, so a command is allowed only when it
|
|
103
|
+
matches a declared entry exactly. Anything else is denied as unclassified.
|
|
104
|
+
- `read` documents the intended reading scope; reads are never blocked.
|
|
105
|
+
|
|
106
|
+
`dog-coordinator` owns this file. A worker binds to it once per candidate with
|
|
107
|
+
`sortie_bind_write_gate`, and only after the coordinator's handoff has been
|
|
108
|
+
inspected. Coordinator sessions are never gated.
|
|
109
|
+
|
|
110
|
+
Optional settings in `.opencode/sortie-dogs.json`:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"operationManifestPath": "operation-manifest.json",
|
|
115
|
+
"handoffPaths": ["handoff.json"],
|
|
116
|
+
"readOnlyTools": ["my_mcp_search"],
|
|
117
|
+
"dedicatedWorkerModel": { "model": "provider/model", "variant": "deep" }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `operationManifestPath` moves the manifest; the path is project-relative.
|
|
122
|
+
- `handoffPaths` lists the handoff files the plugin inspects. A worker can only
|
|
123
|
+
bind after one of these files passes inspection, so an empty list disables
|
|
124
|
+
binding entirely.
|
|
125
|
+
- `readOnlyTools` adds host-specific tool names that never change files, such as
|
|
126
|
+
MCP tools. Unknown tools are denied for a bound session by default.
|
|
127
|
+
- `dedicatedWorkerModel` selects the single model every worker role resolves to.
|
|
128
|
+
It defaults to `openai/gpt-5.6-sol` with variant `xhigh`; declare your own when
|
|
129
|
+
that model is unavailable. Worker roles always resolve to this one target and
|
|
130
|
+
cannot be routed per role.
|
|
131
|
+
|
|
83
132
|
## Why Sortie-dogs
|
|
84
133
|
|
|
85
134
|
- **Focused when invited, invisible otherwise.** Activate it with `/sortie` or
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
|
+
* installed project marker without importing every asset body.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RUNTIME_ASSET_VERSION = "0.2.0-card05";
|
|
6
|
+
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
|
|
@@ -102,7 +102,10 @@ export interface SourceReviewConsultationResult {
|
|
|
102
102
|
readonly review: ReviewVerdict;
|
|
103
103
|
}
|
|
104
104
|
export type ConsultationResult = StrategyConsultationResult | SourceReviewConsultationResult | UnavailableConsultationResult;
|
|
105
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Sole consultation transport boundary. The host supplies this adapter; core passes only the
|
|
107
|
+
* provider-, model-, variant-, and transport-neutral request and result envelopes above.
|
|
108
|
+
*/
|
|
106
109
|
export interface ConsultationAdapter {
|
|
107
110
|
consult(request: ConsultationRequest): Promise<ConsultationResult>;
|
|
108
111
|
}
|
package/dist/plugin/config.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { type ModelCatalog, type ModelRoutingConfig } from "./model-routing.js";
|
|
1
|
+
import { type ModelCatalog, type ModelRoutingConfig, type ModelTarget } from "./model-routing.js";
|
|
2
2
|
export interface SortieDogsPluginOptions {
|
|
3
3
|
operationManifestPath?: string;
|
|
4
4
|
handoffPaths?: readonly string[];
|
|
5
|
+
/**
|
|
6
|
+
* Host-specific tool names that never change project files, such as MCP tools. Names accumulate
|
|
7
|
+
* across layers because each layer describes a different part of the same environment.
|
|
8
|
+
*/
|
|
9
|
+
readOnlyTools?: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* The single model every dedicated worker role resolves to. Worker routing stays fixed to one
|
|
12
|
+
* target; a host that cannot serve the shipped target declares its own here.
|
|
13
|
+
*/
|
|
14
|
+
dedicatedWorkerModel?: ModelTarget;
|
|
5
15
|
modelRouting?: ModelRoutingConfig;
|
|
6
16
|
modelCatalog?: ModelCatalog;
|
|
7
17
|
consultation?: ConsultationPolicyInput;
|
|
@@ -31,6 +41,8 @@ export interface ConfiguredPlugin {
|
|
|
31
41
|
kind: "configured";
|
|
32
42
|
operationManifestPath: string;
|
|
33
43
|
handoffPaths: readonly string[];
|
|
44
|
+
readOnlyTools: readonly string[];
|
|
45
|
+
dedicatedWorkerModel: ModelTarget;
|
|
34
46
|
modelRouting: ModelRoutingConfig;
|
|
35
47
|
modelCatalog: ModelCatalog;
|
|
36
48
|
consultation: ConsultationPolicy;
|
package/dist/plugin/config.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { BUILT_IN_MODEL_CATALOG,
|
|
1
|
+
import { BUILT_IN_MODEL_CATALOG, DEFAULT_DEDICATED_WORKER_TARGET, RECOMMENDED_LUNA_ROUTING, dedicatedWorkerRouting, isFixedModelRole, parseModelRoutingConfig, parseModelTarget, } from "./model-routing.js";
|
|
2
2
|
import { CONSULTATION_ROLE_POLICY } from "../core/consultation.js";
|
|
3
3
|
export const DEFAULT_PLUGIN_OPTIONS = {
|
|
4
4
|
operationManifestPath: "operation-manifest.json",
|
|
5
5
|
handoffPaths: ["handoff.json"],
|
|
6
|
+
readOnlyTools: [],
|
|
7
|
+
dedicatedWorkerModel: DEFAULT_DEDICATED_WORKER_TARGET,
|
|
6
8
|
modelRouting: RECOMMENDED_LUNA_ROUTING,
|
|
7
9
|
modelCatalog: BUILT_IN_MODEL_CATALOG,
|
|
8
10
|
consultation: Object.freeze({
|
|
@@ -156,11 +158,20 @@ function parseLayer(value) {
|
|
|
156
158
|
return {};
|
|
157
159
|
if (!isRecord(value))
|
|
158
160
|
return undefined;
|
|
159
|
-
if (Object.keys(value).some((key) => ![
|
|
161
|
+
if (Object.keys(value).some((key) => ![
|
|
162
|
+
"operationManifestPath", "handoffPaths", "readOnlyTools", "dedicatedWorkerModel",
|
|
163
|
+
"modelRouting", "modelCatalog", "consultation",
|
|
164
|
+
].includes(key))) {
|
|
160
165
|
return undefined;
|
|
161
166
|
}
|
|
162
167
|
const manifestPath = value.operationManifestPath;
|
|
163
168
|
const handoffPaths = value.handoffPaths;
|
|
169
|
+
const readOnlyTools = value.readOnlyTools;
|
|
170
|
+
const dedicatedWorkerModel = value.dedicatedWorkerModel === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: parseModelTarget(value.dedicatedWorkerModel);
|
|
173
|
+
if (value.dedicatedWorkerModel !== undefined && dedicatedWorkerModel === undefined)
|
|
174
|
+
return undefined;
|
|
164
175
|
const modelRouting = value.modelRouting === undefined
|
|
165
176
|
? undefined
|
|
166
177
|
: parseModelRoutingConfig(value.modelRouting);
|
|
@@ -177,6 +188,11 @@ function parseLayer(value) {
|
|
|
177
188
|
(!Array.isArray(handoffPaths) || handoffPaths.some((path) => typeof path !== "string" || path.length === 0))) {
|
|
178
189
|
return undefined;
|
|
179
190
|
}
|
|
191
|
+
if (readOnlyTools !== undefined &&
|
|
192
|
+
(!Array.isArray(readOnlyTools) ||
|
|
193
|
+
readOnlyTools.some((tool) => typeof tool !== "string" || tool.trim().length === 0))) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
180
196
|
if (value.modelRouting !== undefined && modelRouting === undefined)
|
|
181
197
|
return undefined;
|
|
182
198
|
if (value.modelCatalog !== undefined && modelCatalog === undefined)
|
|
@@ -186,6 +202,8 @@ function parseLayer(value) {
|
|
|
186
202
|
return {
|
|
187
203
|
operationManifestPath: manifestPath,
|
|
188
204
|
handoffPaths: handoffPaths,
|
|
205
|
+
readOnlyTools: readOnlyTools,
|
|
206
|
+
dedicatedWorkerModel,
|
|
189
207
|
modelRouting,
|
|
190
208
|
modelCatalog,
|
|
191
209
|
consultation,
|
|
@@ -195,6 +213,8 @@ function parseLayer(value) {
|
|
|
195
213
|
export function resolvePluginConfiguration(...values) {
|
|
196
214
|
let operationManifestPath = DEFAULT_PLUGIN_OPTIONS.operationManifestPath;
|
|
197
215
|
let handoffPaths = DEFAULT_PLUGIN_OPTIONS.handoffPaths;
|
|
216
|
+
const readOnlyTools = new Set(DEFAULT_PLUGIN_OPTIONS.readOnlyTools);
|
|
217
|
+
let dedicatedWorkerModel = DEFAULT_PLUGIN_OPTIONS.dedicatedWorkerModel;
|
|
198
218
|
let modelRouting = DEFAULT_PLUGIN_OPTIONS.modelRouting;
|
|
199
219
|
let modelCatalog = DEFAULT_PLUGIN_OPTIONS.modelCatalog;
|
|
200
220
|
let consultation = DEFAULT_PLUGIN_OPTIONS.consultation;
|
|
@@ -206,6 +226,10 @@ export function resolvePluginConfiguration(...values) {
|
|
|
206
226
|
operationManifestPath = layer.operationManifestPath;
|
|
207
227
|
if (layer.handoffPaths !== undefined)
|
|
208
228
|
handoffPaths = layer.handoffPaths;
|
|
229
|
+
for (const tool of layer.readOnlyTools ?? [])
|
|
230
|
+
readOnlyTools.add(tool.trim().toLowerCase());
|
|
231
|
+
if (layer.dedicatedWorkerModel !== undefined)
|
|
232
|
+
dedicatedWorkerModel = layer.dedicatedWorkerModel;
|
|
209
233
|
if (layer.modelRouting !== undefined) {
|
|
210
234
|
modelRouting = { ...modelRouting, ...layer.modelRouting };
|
|
211
235
|
}
|
|
@@ -227,7 +251,16 @@ export function resolvePluginConfiguration(...values) {
|
|
|
227
251
|
}
|
|
228
252
|
modelRouting = {
|
|
229
253
|
...Object.fromEntries(Object.entries(modelRouting).filter(([role]) => !isFixedModelRole(role))),
|
|
230
|
-
...
|
|
254
|
+
...dedicatedWorkerRouting(dedicatedWorkerModel),
|
|
255
|
+
};
|
|
256
|
+
// The dedicated target is authoritative for worker roles, so it is always a known catalog entry.
|
|
257
|
+
modelCatalog = {
|
|
258
|
+
...modelCatalog,
|
|
259
|
+
global: mergeCatalogModels(modelCatalog.global ?? [], [
|
|
260
|
+
dedicatedWorkerModel.variant === undefined
|
|
261
|
+
? { model: dedicatedWorkerModel.model }
|
|
262
|
+
: { model: dedicatedWorkerModel.model, variants: [dedicatedWorkerModel.variant] },
|
|
263
|
+
]),
|
|
231
264
|
};
|
|
232
265
|
const hasRouting = Object.keys(modelRouting).length > 0;
|
|
233
266
|
const hasCatalogEntries = (modelCatalog.project?.length ?? 0) + (modelCatalog.global?.length ?? 0) > 0;
|
|
@@ -237,6 +270,8 @@ export function resolvePluginConfiguration(...values) {
|
|
|
237
270
|
kind: "configured",
|
|
238
271
|
operationManifestPath,
|
|
239
272
|
handoffPaths,
|
|
273
|
+
readOnlyTools: [...readOnlyTools],
|
|
274
|
+
dedicatedWorkerModel,
|
|
240
275
|
modelRouting,
|
|
241
276
|
modelCatalog,
|
|
242
277
|
consultation,
|
|
@@ -258,16 +293,17 @@ export function resolvePluginConfigurationSources(projectValue, environmentValue
|
|
|
258
293
|
...(environmentLayer.modelRouting ?? {}),
|
|
259
294
|
...(hostLayer.modelRouting ?? {}),
|
|
260
295
|
}).filter(([role]) => !isFixedModelRole(role)));
|
|
296
|
+
const fixedRouting = dedicatedWorkerRouting(configured.dedicatedWorkerModel);
|
|
261
297
|
const modelRouting = {
|
|
262
298
|
...Object.fromEntries(Object.entries(configured.modelRouting)
|
|
263
299
|
.filter(([role]) => !isFixedModelRole(role))),
|
|
264
|
-
...
|
|
300
|
+
...fixedRouting,
|
|
265
301
|
};
|
|
266
302
|
return {
|
|
267
303
|
...configured,
|
|
268
304
|
modelRouting,
|
|
269
305
|
// Dedicated worker policy is authoritative over every configurable layer.
|
|
270
|
-
localModelRouting: { ...(projectLayer.modelRouting ?? {}), ...
|
|
306
|
+
localModelRouting: { ...(projectLayer.modelRouting ?? {}), ...fixedRouting },
|
|
271
307
|
globalModelRouting,
|
|
272
308
|
};
|
|
273
309
|
}
|
package/dist/plugin/gate.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface ToolExecuteBeforeInput {
|
|
|
6
6
|
export interface ToolExecuteBeforeOutput {
|
|
7
7
|
args: unknown;
|
|
8
8
|
}
|
|
9
|
-
export type WriteDenialReason = "manifest-unavailable" | "path-required" | "project-boundary" | "manifest-scope";
|
|
9
|
+
export type WriteDenialReason = "manifest-unavailable" | "session-expired" | "unclassified-command" | "path-required" | "project-boundary" | "manifest-scope" | "repeated-denial";
|
|
10
10
|
export declare class WriteDeniedError extends Error {
|
|
11
11
|
readonly reason: WriteDenialReason;
|
|
12
12
|
constructor(reason: WriteDenialReason, path: string, options?: ErrorOptions);
|
|
@@ -27,14 +27,28 @@ interface Extraction {
|
|
|
27
27
|
ambiguous: boolean;
|
|
28
28
|
paths: string[];
|
|
29
29
|
gitCommit?: boolean;
|
|
30
|
+
issue?: CommandIssue;
|
|
31
|
+
}
|
|
32
|
+
interface CommandIssue {
|
|
33
|
+
segment: string;
|
|
34
|
+
cause: string;
|
|
35
|
+
hint: string;
|
|
30
36
|
}
|
|
31
37
|
export declare function safePath(path: string): string;
|
|
32
38
|
export declare function resolveProjectRoot(input: {
|
|
33
39
|
directory: string;
|
|
34
40
|
worktree?: string;
|
|
35
41
|
}): string;
|
|
42
|
+
export declare function describeUnclassifiedCommand(tool: string, args: unknown): string | undefined;
|
|
36
43
|
/** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
|
|
37
44
|
export declare function extractWritePaths(tool: string, args: unknown): Extraction;
|
|
45
|
+
/**
|
|
46
|
+
* Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
|
|
47
|
+
* same command written with different spacing compares equal.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeCommand(command: string): string;
|
|
50
|
+
/** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
|
|
51
|
+
export declare function isKnownReadOnlyTool(tool: string, args: unknown, additionalReadOnlyTools?: ReadonlySet<string>): boolean;
|
|
38
52
|
export declare function createProjectPaths(rootCandidate: string): Promise<ProjectPaths>;
|
|
39
53
|
export declare function createWriteGate(project: ProjectPaths, value: unknown): Promise<WriteGate>;
|
|
40
54
|
export {};
|
package/dist/plugin/gate.js
CHANGED
|
@@ -9,9 +9,12 @@ export class WriteDeniedError extends Error {
|
|
|
9
9
|
constructor(reason, path, options) {
|
|
10
10
|
const messages = {
|
|
11
11
|
"manifest-unavailable": "operation manifest unavailable.",
|
|
12
|
+
"session-expired": "active session expired; start or resume an explicit Task takeover.",
|
|
13
|
+
"unclassified-command": "unclassified command; use the stated direct-command hint.",
|
|
12
14
|
"path-required": "write path must be explicit.",
|
|
13
15
|
"project-boundary": "project-root-relative path required.",
|
|
14
16
|
"manifest-scope": "operation manifest write scope.",
|
|
17
|
+
"repeated-denial": "same command and denial reason already denied in this session; retry blocked.",
|
|
15
18
|
};
|
|
16
19
|
super(`Write denied for "${safePath(path)}": ${messages[reason]}`, options);
|
|
17
20
|
this.name = "WriteDeniedError";
|
|
@@ -25,8 +28,9 @@ const DIRECT_PATH_KEYS = new Set([
|
|
|
25
28
|
const ALL_OPERAND_COMMANDS = new Set(["mkdir", "rm", "rmdir", "touch", "truncate", "unlink"]);
|
|
26
29
|
const LAST_OPERAND_COMMANDS = new Set(["cp", "install"]);
|
|
27
30
|
const READ_ONLY_COMMANDS = new Set([
|
|
28
|
-
"cat", "echo", "false", "get-childitem", "get-content", "grep", "head", "ls", "pwd",
|
|
29
|
-
"rg", "stat", "tail", "test-path", "true", "type",
|
|
31
|
+
"cat", "echo", "false", "get-childitem", "get-content", "get-date", "grep", "head", "ls", "pwd",
|
|
32
|
+
"measure-object", "rg", "select-object", "stat", "tail", "test-path", "true", "type",
|
|
33
|
+
"where-object", "wc",
|
|
30
34
|
]);
|
|
31
35
|
const READ_ONLY_GIT_COMMANDS = new Set(["diff", "log", "ls-files", "rev-parse", "show", "status"]);
|
|
32
36
|
const POWERSHELL_WRITE_COMMANDS = new Set([
|
|
@@ -34,6 +38,16 @@ const POWERSHELL_WRITE_COMMANDS = new Set([
|
|
|
34
38
|
"rename-item", "set-content",
|
|
35
39
|
]);
|
|
36
40
|
const READ_ONLY_OPTIONS_WITH_VALUES = new Set(["-c", "--directory", "--exclude", "--include"]);
|
|
41
|
+
/**
|
|
42
|
+
* Tools that never change project files themselves. A dispatched subagent runs in its own session
|
|
43
|
+
* and is gated there, and task-list tools only change session state, so denying them would stop
|
|
44
|
+
* delegation and progress tracking without protecting any path.
|
|
45
|
+
*/
|
|
46
|
+
const READ_ONLY_TOOLS = new Set([
|
|
47
|
+
"glob", "grep", "list", "list_mcp_resource_templates", "list_mcp_resources", "question",
|
|
48
|
+
"read", "read_mcp_resource", "review_git_evidence", "skill", "task", "todoread", "todowrite",
|
|
49
|
+
"webfetch",
|
|
50
|
+
]);
|
|
37
51
|
function isRecord(value) {
|
|
38
52
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
39
53
|
}
|
|
@@ -149,23 +163,143 @@ function isSafeGitCommit(tokens) {
|
|
|
149
163
|
}
|
|
150
164
|
return true;
|
|
151
165
|
}
|
|
152
|
-
function
|
|
166
|
+
function isLiteralPowerShellAssignment(value) {
|
|
167
|
+
return /^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)|\$(?:null|true|false)|\$[A-Za-z_][A-Za-z0-9_]*|\$env:[A-Za-z_][A-Za-z0-9_]*|'[^']*'|"[^"`$]*")$/iu.test(value);
|
|
168
|
+
}
|
|
169
|
+
function isSafePowerShellForEach(source) {
|
|
170
|
+
return /^(?:foreach-object|%)\s+\{\s*\$_(?:\.[A-Za-z_][A-Za-z0-9_]*)?\s*\}$/iu.test(source);
|
|
171
|
+
}
|
|
172
|
+
function scanShellSyntax(source, dialect) {
|
|
173
|
+
const masked = new Array(source.length).fill(" ");
|
|
174
|
+
let quote;
|
|
175
|
+
let unsafeExpansion = false;
|
|
176
|
+
let activeBrace = false;
|
|
177
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
178
|
+
const character = source[index];
|
|
179
|
+
if (quote !== undefined) {
|
|
180
|
+
if (dialect === "powershell" && quote === "\"" && character === "`") {
|
|
181
|
+
unsafeExpansion = true;
|
|
182
|
+
index += 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (dialect === "posix" && quote === "\"" && character === "\\") {
|
|
186
|
+
index += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (character === quote) {
|
|
190
|
+
if (dialect === "powershell" && source[index + 1] === quote)
|
|
191
|
+
index += 1;
|
|
192
|
+
else
|
|
193
|
+
quote = undefined;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (quote === "\"" &&
|
|
197
|
+
(((character === "$" || character === "<") && source[index + 1] === "(") ||
|
|
198
|
+
(character === "$" && source[index + 1] === "{")))
|
|
199
|
+
unsafeExpansion = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (character === "\"" || character === "'") {
|
|
203
|
+
quote = character;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
masked[index] = character;
|
|
207
|
+
if (character === "`") {
|
|
208
|
+
unsafeExpansion = true;
|
|
209
|
+
if (dialect === "powershell")
|
|
210
|
+
index += 1;
|
|
211
|
+
}
|
|
212
|
+
else if (((character === "$" || character === "<") && source[index + 1] === "(") ||
|
|
213
|
+
(character === "$" && source[index + 1] === "{")) {
|
|
214
|
+
unsafeExpansion = true;
|
|
215
|
+
}
|
|
216
|
+
else if (character === "{" || character === "}") {
|
|
217
|
+
activeBrace = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { masked: masked.join(""), unsafeExpansion, activeBrace };
|
|
221
|
+
}
|
|
222
|
+
function shellSegments(command, dialect) {
|
|
223
|
+
const segments = [];
|
|
224
|
+
const masked = scanShellSyntax(command, dialect).masked;
|
|
225
|
+
let start = 0;
|
|
226
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
227
|
+
const character = masked[index];
|
|
228
|
+
const pair = masked.slice(index, index + 2);
|
|
229
|
+
const separator = pair === "&&" || pair === "||" ? 2
|
|
230
|
+
: character === ";" || character === "\n" || character === "\r" ||
|
|
231
|
+
(character === "|" && command[index - 1] !== ">") ? 1 : 0;
|
|
232
|
+
if (separator === 0)
|
|
233
|
+
continue;
|
|
234
|
+
segments.push(command.slice(start, index));
|
|
235
|
+
index += separator - 1;
|
|
236
|
+
if (character === "\r" && command[index + 1] === "\n")
|
|
237
|
+
index += 1;
|
|
238
|
+
start = index + 1;
|
|
239
|
+
}
|
|
240
|
+
segments.push(command.slice(start));
|
|
241
|
+
return segments;
|
|
242
|
+
}
|
|
243
|
+
function depthOnePowerShellLiteral(source) {
|
|
244
|
+
const match = /^\s*(?:"[^"]*(?:pwsh|powershell)(?:\.exe)?"|[^\s"']*(?:pwsh|powershell)(?:\.exe)?)\s+-noprofile\s+-command\s+'((?:''|[^'])*)'\s*$/iu.exec(source);
|
|
245
|
+
return match?.[1].replaceAll("''", "'");
|
|
246
|
+
}
|
|
247
|
+
function commandIssue(segment, cause, hint) {
|
|
248
|
+
return { segment: segment.trim().slice(0, 240), cause, hint };
|
|
249
|
+
}
|
|
250
|
+
function shellPaths(command, powershell, depth = 0) {
|
|
153
251
|
const paths = [];
|
|
154
252
|
let applies = false;
|
|
155
253
|
let ambiguous = false;
|
|
156
254
|
let gitCommit = false;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
for (
|
|
163
|
-
const
|
|
255
|
+
let issue;
|
|
256
|
+
const redirection = /(?<![<>=!])(?:&|\d*)?(?:>>|>\||>)(?![=])/gu;
|
|
257
|
+
const redirectionTarget = /^\s*("(?:\\.|[^"])*"|'[^']*'|&?\d+|[^\s;&|]+)/u;
|
|
258
|
+
const dialect = powershell ? "powershell" : "posix";
|
|
259
|
+
const segments = shellSegments(command, dialect);
|
|
260
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
|
|
261
|
+
const segment = segments[segmentIndex];
|
|
262
|
+
let source = segment.trim();
|
|
263
|
+
let assignment = false;
|
|
264
|
+
const syntax = scanShellSyntax(source, dialect);
|
|
265
|
+
for (const match of syntax.masked.matchAll(redirection)) {
|
|
266
|
+
applies = true;
|
|
267
|
+
const target = redirectionTarget.exec(source.slice(match.index + match[0].length))?.[1];
|
|
268
|
+
if (target === undefined || target.startsWith("&")) {
|
|
269
|
+
ambiguous = true;
|
|
270
|
+
issue ??= commandIssue(source, "redirect-target-unresolved", "name one manifest-scoped output path");
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
paths.push(unquote(target));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (syntax.unsafeExpansion) {
|
|
277
|
+
applies = true;
|
|
278
|
+
ambiguous = true;
|
|
279
|
+
issue ??= commandIssue(source, "active-expansion", "remove substitution and run a direct literal command");
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (powershell) {
|
|
283
|
+
const stripped = source.replace(/^\$[A-Za-z_][A-Za-z0-9_:]*\s*=\s*/u, "");
|
|
284
|
+
assignment = stripped !== source;
|
|
285
|
+
source = stripped;
|
|
286
|
+
if (/^\$env:[A-Za-z_][A-Za-z0-9_]*$/iu.test(source) ||
|
|
287
|
+
(assignment && isLiteralPowerShellAssignment(source)))
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const tokens = unwrapEnvironmentCommand(words(source));
|
|
164
291
|
if (tokens.length === 0)
|
|
165
292
|
continue;
|
|
166
293
|
const executable = tokens[0].replaceAll("\\", "/").split("/").at(-1).toLowerCase();
|
|
167
294
|
const commandOperands = operands(tokens);
|
|
168
|
-
if (
|
|
295
|
+
if (powershell && syntax.activeBrace) {
|
|
296
|
+
if (segmentIndex > 0 && isSafePowerShellForEach(source))
|
|
297
|
+
continue;
|
|
298
|
+
applies = true;
|
|
299
|
+
ambiguous = true;
|
|
300
|
+
issue ??= commandIssue(source, "active-scriptblock", "use strict ForEach-Object { $_.Property } or a direct command");
|
|
301
|
+
}
|
|
302
|
+
else if (ALL_OPERAND_COMMANDS.has(executable)) {
|
|
169
303
|
applies = true;
|
|
170
304
|
paths.push(...commandOperands);
|
|
171
305
|
}
|
|
@@ -229,12 +363,36 @@ function shellPaths(command) {
|
|
|
229
363
|
else if (/^gh(?:\.exe)?$/u.test(executable) && isRemoteOnlyGitHubCommand(tokens)) {
|
|
230
364
|
// GitHub Project commands mutate remote state, not project files. Redirections remain gated above.
|
|
231
365
|
}
|
|
366
|
+
else if (depth === 0 && /^(?:pwsh|powershell)(?:\.exe)?$/u.test(executable)) {
|
|
367
|
+
const literal = depthOnePowerShellLiteral(source);
|
|
368
|
+
if (literal === undefined) {
|
|
369
|
+
applies = true;
|
|
370
|
+
ambiguous = true;
|
|
371
|
+
issue ??= commandIssue(source, "unsupported-pwsh-form", "use pwsh -NoProfile -Command '<literal>' at depth one");
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const nested = shellPaths(literal, true, depth + 1);
|
|
375
|
+
applies ||= nested.applies;
|
|
376
|
+
ambiguous ||= nested.ambiguous;
|
|
377
|
+
paths.push(...nested.paths);
|
|
378
|
+
gitCommit ||= nested.gitCommit === true;
|
|
379
|
+
issue ??= nested.issue;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
232
382
|
else if (!READ_ONLY_COMMANDS.has(executable)) {
|
|
233
383
|
applies = true;
|
|
234
384
|
ambiguous = true;
|
|
385
|
+
issue ??= commandIssue(source, "executable-not-allowlisted", "use a direct allowlisted read-only command");
|
|
235
386
|
}
|
|
236
387
|
}
|
|
237
|
-
return { applies, ambiguous, paths, ...(gitCommit ? { gitCommit: true } : {}) };
|
|
388
|
+
return { applies, ambiguous, paths, ...(gitCommit ? { gitCommit: true } : {}), ...(issue ? { issue } : {}) };
|
|
389
|
+
}
|
|
390
|
+
function issuePath(issue) {
|
|
391
|
+
return `segment=${issue.segment}; cause=${issue.cause}; hint=${issue.hint}`;
|
|
392
|
+
}
|
|
393
|
+
export function describeUnclassifiedCommand(tool, args) {
|
|
394
|
+
const extracted = extractWritePaths(tool, args);
|
|
395
|
+
return extracted.ambiguous && extracted.issue !== undefined ? issuePath(extracted.issue) : undefined;
|
|
238
396
|
}
|
|
239
397
|
/** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
|
|
240
398
|
export function extractWritePaths(tool, args) {
|
|
@@ -251,18 +409,71 @@ export function extractWritePaths(tool, args) {
|
|
|
251
409
|
}
|
|
252
410
|
if (/^(?:bash|shell|powershell|pwsh)(?:$|[_-])/u.test(name)) {
|
|
253
411
|
const command = isRecord(args) && typeof args.command === "string" ? args.command : undefined;
|
|
254
|
-
if (command === undefined)
|
|
255
|
-
|
|
256
|
-
|
|
412
|
+
if (command === undefined) {
|
|
413
|
+
const ambiguous = paths.length === 0;
|
|
414
|
+
return {
|
|
415
|
+
applies: paths.length > 0,
|
|
416
|
+
ambiguous,
|
|
417
|
+
paths,
|
|
418
|
+
...(ambiguous ? {
|
|
419
|
+
issue: commandIssue("<missing-command>", "command-argument-missing", "provide one direct literal command"),
|
|
420
|
+
} : {}),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const extracted = shellPaths(command, /^(?:powershell|pwsh)(?:$|[_-])/u.test(name));
|
|
257
424
|
return {
|
|
258
425
|
applies: extracted.applies || paths.length > 0,
|
|
259
426
|
ambiguous: extracted.ambiguous,
|
|
260
427
|
paths: [...paths, ...extracted.paths],
|
|
261
428
|
...(extracted.gitCommit ? { gitCommit: true } : {}),
|
|
429
|
+
...(extracted.issue ? { issue: extracted.issue } : {}),
|
|
262
430
|
};
|
|
263
431
|
}
|
|
264
432
|
return { applies: false, ambiguous: false, paths: [] };
|
|
265
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
|
|
436
|
+
* same command written with different spacing compares equal.
|
|
437
|
+
*/
|
|
438
|
+
export function normalizeCommand(command) {
|
|
439
|
+
let quote;
|
|
440
|
+
let whitespace = false;
|
|
441
|
+
let normalized = "";
|
|
442
|
+
for (const character of command.trim()) {
|
|
443
|
+
if (quote !== undefined) {
|
|
444
|
+
normalized += character;
|
|
445
|
+
if (character === quote)
|
|
446
|
+
quote = undefined;
|
|
447
|
+
}
|
|
448
|
+
else if (character === "\"" || character === "'") {
|
|
449
|
+
if (whitespace && normalized.length > 0)
|
|
450
|
+
normalized += " ";
|
|
451
|
+
whitespace = false;
|
|
452
|
+
quote = character;
|
|
453
|
+
normalized += character;
|
|
454
|
+
}
|
|
455
|
+
else if (/\s/u.test(character)) {
|
|
456
|
+
whitespace = true;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
if (whitespace && normalized.length > 0)
|
|
460
|
+
normalized += " ";
|
|
461
|
+
whitespace = false;
|
|
462
|
+
normalized += character;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return normalized;
|
|
466
|
+
}
|
|
467
|
+
/** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
|
|
468
|
+
export function isKnownReadOnlyTool(tool, args, additionalReadOnlyTools = new Set()) {
|
|
469
|
+
const name = tool.toLowerCase();
|
|
470
|
+
if (READ_ONLY_TOOLS.has(name) || additionalReadOnlyTools.has(name))
|
|
471
|
+
return true;
|
|
472
|
+
if (!/^(?:bash|shell|powershell|pwsh)(?:$|[_-])/u.test(name))
|
|
473
|
+
return false;
|
|
474
|
+
const extraction = extractWritePaths(tool, args);
|
|
475
|
+
return !extraction.applies && !extraction.ambiguous;
|
|
476
|
+
}
|
|
266
477
|
async function nearestExistingRealPath(path) {
|
|
267
478
|
let candidate = path;
|
|
268
479
|
while (true) {
|
|
@@ -311,6 +522,10 @@ export async function createWriteGate(project, value) {
|
|
|
311
522
|
if (!validated.ok)
|
|
312
523
|
throw new WriteDeniedError("manifest-unavailable", "<unknown>");
|
|
313
524
|
const manifest = validated.value;
|
|
525
|
+
// A build or test command may touch any path its toolchain owns, so it can never be classified by
|
|
526
|
+
// path extraction. The manifest already declares the commands this candidate is allowed to run,
|
|
527
|
+
// so an exact match against that declaration is the only accepted form.
|
|
528
|
+
const declaredValidation = new Set(manifest.validation.map(normalizeCommand));
|
|
314
529
|
const writable = new Set(manifest.write.map((path) => normalizeRelativePath(path)));
|
|
315
530
|
const writableDirectories = [];
|
|
316
531
|
for (const path of writable) {
|
|
@@ -389,11 +604,19 @@ export async function createWriteGate(project, value) {
|
|
|
389
604
|
checkPath,
|
|
390
605
|
toRelativePath: project.toRelativePath,
|
|
391
606
|
async check(_input, output) {
|
|
607
|
+
const command = isRecord(output.args) && typeof output.args.command === "string"
|
|
608
|
+
? normalizeCommand(output.args.command)
|
|
609
|
+
: undefined;
|
|
610
|
+
if (command !== undefined && declaredValidation.has(command))
|
|
611
|
+
return;
|
|
392
612
|
const extracted = extractWritePaths(_input.tool, output.args);
|
|
393
613
|
if (!extracted.applies)
|
|
394
614
|
return;
|
|
395
615
|
if (extracted.ambiguous || (extracted.paths.length === 0 && !extracted.gitCommit)) {
|
|
396
|
-
|
|
616
|
+
if (extracted.issue !== undefined) {
|
|
617
|
+
throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue));
|
|
618
|
+
}
|
|
619
|
+
throw new WriteDeniedError("path-required", "<missing-path>");
|
|
397
620
|
}
|
|
398
621
|
for (const path of extracted.paths)
|
|
399
622
|
await checkPath(path);
|
package/dist/plugin/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface OpenCodeHooks {
|
|
|
23
23
|
}) => Promise<void>;
|
|
24
24
|
"tool.execute.before"?: (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
|
|
25
25
|
"chat.message"?: OpenCodeChatMessageHook;
|
|
26
|
+
tool?: Record<string, OpenCodeToolDefinition>;
|
|
26
27
|
}
|
|
27
28
|
export type OpenCodePlugin = (input: OpenCodePluginInput, options?: SortieDogsPluginOptions | Record<string, unknown>) => Promise<OpenCodeHooks>;
|
|
28
29
|
export type HandoffDenialReason = "configuration-unavailable" | "path-invalid" | "input-unavailable" | "schema-invalid" | "contract-invalid";
|
|
@@ -30,6 +31,14 @@ export declare class HandoffDeniedError extends Error {
|
|
|
30
31
|
readonly reason: HandoffDenialReason;
|
|
31
32
|
constructor(reason: HandoffDenialReason, path: string, options?: ErrorOptions);
|
|
32
33
|
}
|
|
34
|
+
interface OpenCodeToolDefinition {
|
|
35
|
+
description: string;
|
|
36
|
+
args: Record<string, unknown>;
|
|
37
|
+
execute(args: Record<string, string>, context: {
|
|
38
|
+
sessionID: string;
|
|
39
|
+
}): Promise<string>;
|
|
40
|
+
}
|
|
41
|
+
export declare function isExplicitTaskHandoff(text: string): boolean;
|
|
33
42
|
/** Named OpenCode plugin export. Importing the package has no side effects; invoking it installs active gates. */
|
|
34
43
|
export declare const SortieDogsPlugin: OpenCodePlugin;
|
|
35
44
|
export type { SortieDogsPluginOptions } from "./config.js";
|