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 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;
@@ -0,0 +1,5 @@
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 const RUNTIME_ASSET_VERSION = "0.2.0-card05";
@@ -102,7 +102,10 @@ export interface SourceReviewConsultationResult {
102
102
  readonly review: ReviewVerdict;
103
103
  }
104
104
  export type ConsultationResult = StrategyConsultationResult | SourceReviewConsultationResult | UnavailableConsultationResult;
105
- /** Host-owned adapters implement transport. Core never executes commands or selects providers. */
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
  }
@@ -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;
@@ -1,8 +1,10 @@
1
- import { BUILT_IN_MODEL_CATALOG, FIXED_MODEL_ROUTING, RECOMMENDED_LUNA_ROUTING, isFixedModelRole, parseModelRoutingConfig, } from "./model-routing.js";
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) => !["operationManifestPath", "handoffPaths", "modelRouting", "modelCatalog", "consultation"].includes(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
- ...FIXED_MODEL_ROUTING,
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
- ...FIXED_MODEL_ROUTING,
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 ?? {}), ...FIXED_MODEL_ROUTING },
306
+ localModelRouting: { ...(projectLayer.modelRouting ?? {}), ...fixedRouting },
271
307
  globalModelRouting,
272
308
  };
273
309
  }
@@ -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 {};
@@ -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", "wc",
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 shellPaths(command) {
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
- const redirection = /(?:^|[\s;&|])(?:\d*)(?:>>?|>\|)\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;&|]+)/gu;
158
- for (const match of command.matchAll(redirection)) {
159
- applies = true;
160
- paths.push(unquote(match[1]));
161
- }
162
- for (const segment of command.split(/(?:&&|\|\||(?<!>)\|(?!\|)|;|\r?\n)/u)) {
163
- const tokens = unwrapEnvironmentCommand(words(segment.trim()));
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 (ALL_OPERAND_COMMANDS.has(executable)) {
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
- return { applies: paths.length > 0, ambiguous: paths.length === 0, paths };
256
- const extracted = shellPaths(command);
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
- throw new WriteDeniedError("path-required", "<unknown>");
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);
@@ -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";