@brainervirus/workit-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -14
- package/scripts/doctor-check.ts +20 -0
- package/scripts/install-cursor-plugin.sh +51 -28
- package/scripts/install-opencode-plugin.sh +25 -22
- package/scripts/rewrite-workspace-deps.ts +15 -9
- package/scripts/sync-runtime.sh +71 -19
- package/scripts/vendor-assets.ts +37 -0
- package/skills/wk-implement/SKILL.md +2 -2
- package/skills/wk-pr/SKILL.md +1 -1
- package/src/core/boundary.ts +27 -0
- package/src/core/branch-policy.ts +63 -0
- package/src/core/branch.ts +166 -57
- package/src/core/changelog.ts +17 -14
- package/src/core/config-guard.ts +9 -2
- package/src/core/config.ts +227 -32
- package/src/core/detector.ts +22 -11
- package/src/core/docs-layout.ts +251 -0
- package/src/core/docs-migration.ts +639 -0
- package/src/core/docs-repo.ts +58 -21
- package/src/core/docs-validate.ts +181 -43
- package/src/core/doctor.ts +801 -0
- package/src/core/flow-state.ts +1579 -137
- package/src/core/git.ts +22 -5
- package/src/core/gitignore.ts +11 -2
- package/src/core/handoff-context.ts +18 -5
- package/src/{tools/handoff.ts → core/handoff-tools.ts} +16 -63
- package/src/core/hygiene.ts +53 -17
- package/src/core/init.ts +128 -31
- package/src/core/logger.ts +321 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/parse-sections.ts +2 -2
- package/src/core/plan-tasks.ts +13 -3
- package/src/core/ports/init-toolkit-status.ts +1 -1
- package/src/core/ports/vcs-verify-token.ts +1 -1
- package/src/core/ports/youtrack-api.ts +4 -2
- package/src/core/ports/youtrack-config.ts +1 -3
- package/src/core/ports/youtrack-verify-token.ts +1 -1
- package/src/core/pr-create.ts +156 -29
- package/src/core/present.ts +11 -2
- package/src/core/registration.ts +215 -0
- package/src/core/reminder.ts +1 -2
- package/src/core/repo-context.ts +447 -0
- package/src/core/repo-tool.ts +4 -1
- package/src/core/repo-tools.ts +23 -0
- package/src/core/rules.ts +10 -7
- package/src/core/safe-write.ts +22 -0
- package/src/core/scripts.ts +3 -39
- package/src/core/sdd.ts +56 -31
- package/src/core/setup-state.ts +54 -0
- package/src/core/setup.ts +1216 -0
- package/src/core/skill-manifests.ts +95 -0
- package/src/core/support-matrix.ts +12 -0
- package/src/core/sync-runtime.ts +348 -0
- package/src/core/templates.ts +16 -6
- package/src/core/vcs-config.ts +191 -62
- package/src/core/verify-parse.ts +4 -2
- package/src/core/verify-project.ts +181 -0
- package/src/core/workspaces.ts +136 -17
- package/src/core/youtrack-tools.ts +228 -0
- package/src/core/youtrack.ts +320 -87
- package/src/core.ts +18 -3
- package/templates/execution-contract.md +9 -7
- package/templates/superpowers-doc-contract.md +5 -4
- package/scripts/_shared/common.sh +0 -158
- package/scripts/changelog-context.sh +0 -42
- package/scripts/docs-refresh-context.sh +0 -40
- package/scripts/init/apply.sh +0 -5
- package/scripts/init/status.sh +0 -5
- package/scripts/init/toolkit-status.sh +0 -5
- package/scripts/pr-create.sh +0 -5
- package/scripts/pr-ready-context.sh +0 -88
- package/scripts/present/ascii-wireframe.sh +0 -5
- package/scripts/present/flow-diagram.sh +0 -5
- package/scripts/release-notes-context.sh +0 -40
- package/scripts/vcs/config.sh +0 -5
- package/scripts/vcs/merged-style.sh +0 -5
- package/scripts/vcs/token-create-urls.sh +0 -5
- package/scripts/vcs/verify-token.sh +0 -5
- package/scripts/verify-project.sh +0 -140
- package/scripts/youtrack/api.sh +0 -5
- package/scripts/youtrack/config.sh +0 -5
- package/scripts/youtrack/greeting.sh +0 -5
- package/scripts/youtrack/parse-duration.sh +0 -5
- package/scripts/youtrack/token-create-url.sh +0 -5
- package/scripts/youtrack/verify-token.sh +0 -5
- package/scripts/youtrack/work-date-ms.sh +0 -5
- package/src/tools/docs-repo.ts +0 -42
- package/src/tools/flow.ts +0 -88
- package/src/tools/index.ts +0 -22
- package/src/tools/present.ts +0 -45
- package/src/tools/repo.ts +0 -357
- package/src/tools/rules.ts +0 -30
- package/src/tools/sdd.ts +0 -189
- package/src/tools/templates.ts +0 -27
- package/src/tools/youtrack.ts +0 -360
package/src/core/flow-state.ts
CHANGED
|
@@ -1,18 +1,115 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
|
|
3
|
+
import { docsValidate, parseTasksFromPlan, qualitySpec, stripFences } from "./docs-validate";
|
|
4
|
+
import { resolveCanonicalLayout } from "./docs-layout";
|
|
4
5
|
|
|
6
|
+
export type FlowHost = "opencode" | "cursor";
|
|
5
7
|
export type FlowStatus = "draft" | "self_reviewed" | "approved";
|
|
6
|
-
export type
|
|
8
|
+
export type FlowRole = "coordinator" | "delegated";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Host-bound identity for every flow/product mutation (FG-05, CA-20, CA-21):
|
|
12
|
+
* the authoritative host workspace, the coordinator/delegated role, the host
|
|
13
|
+
* session, and the authenticated task identity a delegated worker carries.
|
|
14
|
+
* Cursor has no per-session identity, so it derives a deterministic session
|
|
15
|
+
* from the workspace root; OpenCode derives it from the tool context.
|
|
16
|
+
* Delegation is host-derived (Task 30, AR-12): callers never supply `role`.
|
|
17
|
+
*/
|
|
18
|
+
export type MutationContext = {
|
|
19
|
+
hostWorkspace: string;
|
|
20
|
+
role: FlowRole;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
taskIdentity?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Recovery guidance surfaced on a blocked coordinator mutation (FG-07). */
|
|
26
|
+
export const COORDINATOR_RECOVERY_TEXT =
|
|
27
|
+
"A subagent-driven plan is active: coordinator product edits are blocked. " +
|
|
28
|
+
"Delegate product mutations (task briefs, progress, review packages) to an " +
|
|
29
|
+
"authenticated delegated worker via `task` / `wk-implement` instead of " +
|
|
30
|
+
"editing in the coordinator session.";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cursor recovery guidance for the unsupported subagent-driven mutation path
|
|
34
|
+
* (CA-42): the Cursor MCP has no child sessions, so it cannot run a
|
|
35
|
+
* subagent-driven plan and must not enter that flow state.
|
|
36
|
+
*/
|
|
37
|
+
export const CURSOR_SUBAGENT_UNSUPPORTED_TEXT =
|
|
38
|
+
"Cursor cannot execute subagent-driven plans: the MCP has no child-session " +
|
|
39
|
+
"support. Choose Inline, Handoff, or a review option in this session, or " +
|
|
40
|
+
"run the plan in OpenCode with `wk-implement`.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The only acceptable approval / execution-menu evidence (FG-04, CA-19, AR-12).
|
|
44
|
+
* Trust comes from HOST CAPABILITIES, never from caller-supplied fields:
|
|
45
|
+
*
|
|
46
|
+
* - OpenCode: a one-use receipt the plugin records when it observes the
|
|
47
|
+
* answered native `question` tool (host-observed, `attested: true`). The
|
|
48
|
+
* approval/menu tool schemas expose no evidence argument; the receipt is
|
|
49
|
+
* consumed from the in-memory store bound to sessionID + callID + exact
|
|
50
|
+
* selected label + timestamp.
|
|
51
|
+
* - Cursor: a policy-only constant (`attested: false`). The MCP cannot observe
|
|
52
|
+
* the AskQuestion result, so it records an unauthenticated confirmation and
|
|
53
|
+
* never claims a host-observed answer. The constant carries no caller data.
|
|
54
|
+
*/
|
|
55
|
+
export type OpenCodeChoiceEvidence = {
|
|
56
|
+
host: "opencode";
|
|
57
|
+
attested: true;
|
|
58
|
+
/** Host question-tool call id observed by the plugin hook. */
|
|
59
|
+
callID: string;
|
|
60
|
+
/** The exact label the user selected. */
|
|
61
|
+
selectedLabel: string;
|
|
62
|
+
recordedAt: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type CursorConfirmation = {
|
|
66
|
+
host: "cursor";
|
|
67
|
+
attested: false;
|
|
68
|
+
confirmation: "contract";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type NativeChoiceEvidence = OpenCodeChoiceEvidence | CursorConfirmation;
|
|
72
|
+
|
|
73
|
+
export type FlowDocState = {
|
|
74
|
+
path: string;
|
|
75
|
+
status: FlowStatus;
|
|
76
|
+
evidence?: NativeChoiceEvidence | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type FlowMenuState = {
|
|
80
|
+
presented: boolean;
|
|
81
|
+
chosen: string;
|
|
82
|
+
evidence?: NativeChoiceEvidence | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
7
85
|
export type FlowState = {
|
|
8
86
|
slug: string;
|
|
87
|
+
/** Recorded when flow preparation began (FG-01): canonical paths + activation. */
|
|
88
|
+
activated: boolean;
|
|
9
89
|
spec: FlowDocState;
|
|
10
90
|
plan: FlowDocState;
|
|
11
|
-
menu:
|
|
91
|
+
menu: FlowMenuState;
|
|
12
92
|
updated_at: number;
|
|
13
93
|
};
|
|
14
94
|
|
|
15
|
-
|
|
95
|
+
/** One shared result shape for every flow transition and mutation gate (FG-09). */
|
|
96
|
+
export type FlowError = { ok: false; error: string; code: string };
|
|
97
|
+
export type FlowGateResult = { ok: true } | FlowError;
|
|
98
|
+
export type EvidenceResult =
|
|
99
|
+
| { ok: true; evidence: NativeChoiceEvidence }
|
|
100
|
+
| { ok: false; error: string };
|
|
101
|
+
export type StatusTransition = { ok: true; next: FlowStatus } | FlowError;
|
|
102
|
+
|
|
103
|
+
export const MENU_CHOICES = [
|
|
104
|
+
"subagent-driven",
|
|
105
|
+
"inline",
|
|
106
|
+
"handoff",
|
|
107
|
+
"review-spec",
|
|
108
|
+
"review-plan",
|
|
109
|
+
] as const;
|
|
110
|
+
export type MenuChoice = (typeof MENU_CHOICES)[number];
|
|
111
|
+
|
|
112
|
+
const err = (code: string, error: string): FlowError => ({ ok: false, code, error });
|
|
16
113
|
|
|
17
114
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
18
115
|
|
|
@@ -21,173 +118,768 @@ const flowPath = (root: string, slug: string) => {
|
|
|
21
118
|
return path.join(root, "docs", slug, "sdd", "flow.json");
|
|
22
119
|
};
|
|
23
120
|
|
|
121
|
+
// Resolve one spec/plan doc path under the shared contained contract (DC-01,
|
|
122
|
+
// DC-02): the caller-supplied slug must match the slug derived from the path.
|
|
123
|
+
const resolveDoc = (
|
|
124
|
+
root: string,
|
|
125
|
+
slug: string,
|
|
126
|
+
docPath: string,
|
|
127
|
+
kind: "spec" | "plan",
|
|
128
|
+
): { ok: true; path: string } | { ok: false; error: string } => {
|
|
129
|
+
const resolved = resolveCanonicalLayout({
|
|
130
|
+
workspace_root: root,
|
|
131
|
+
...(slug ? { slug } : {}),
|
|
132
|
+
[kind === "spec" ? "spec_path" : "plan_path"]: docPath,
|
|
133
|
+
});
|
|
134
|
+
if (!resolved.ok) return { ok: false, error: resolved.error };
|
|
135
|
+
return { ok: true, path: resolved.layout[kind === "spec" ? "spec" : "plan"] };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A flow.json that exists was activated when preparation began; the field is
|
|
139
|
+
// kept for forward compatibility but a present file is always treated as
|
|
140
|
+
// activated. Missing state is NOT silently activated (FG-01).
|
|
141
|
+
const normalizeState = (parsed: unknown, slug: string): FlowState => {
|
|
142
|
+
const p = (parsed ?? {}) as Partial<FlowState>;
|
|
143
|
+
const spec = (p.spec ?? {}) as Partial<FlowDocState>;
|
|
144
|
+
const plan = (p.plan ?? {}) as Partial<FlowDocState>;
|
|
145
|
+
const menu = (p.menu ?? {}) as Partial<FlowMenuState>;
|
|
146
|
+
return {
|
|
147
|
+
slug: p.slug ?? slug,
|
|
148
|
+
activated: p.activated ?? true,
|
|
149
|
+
spec: {
|
|
150
|
+
path: spec.path ?? "",
|
|
151
|
+
status: spec.status ?? "draft",
|
|
152
|
+
evidence: spec.evidence ?? null,
|
|
153
|
+
},
|
|
154
|
+
plan: {
|
|
155
|
+
path: plan.path ?? "",
|
|
156
|
+
status: plan.status ?? "draft",
|
|
157
|
+
evidence: plan.evidence ?? null,
|
|
158
|
+
},
|
|
159
|
+
menu: {
|
|
160
|
+
presented: Boolean(menu.presented),
|
|
161
|
+
chosen: menu.chosen ?? "",
|
|
162
|
+
evidence: menu.evidence ?? null,
|
|
163
|
+
},
|
|
164
|
+
updated_at: p.updated_at ?? Date.now(),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const emptyState = (slug: string): FlowState => ({
|
|
169
|
+
slug,
|
|
170
|
+
activated: false,
|
|
171
|
+
spec: { path: "", status: "draft", evidence: null },
|
|
172
|
+
plan: { path: "", status: "draft", evidence: null },
|
|
173
|
+
menu: { presented: false, chosen: "", evidence: null },
|
|
174
|
+
updated_at: Date.now(),
|
|
175
|
+
});
|
|
176
|
+
|
|
24
177
|
export const readFlowState = (root: string, slug: string): FlowState => {
|
|
178
|
+
const file = flowPath(root, slug);
|
|
179
|
+
if (!existsSync(file)) return emptyState(slug);
|
|
180
|
+
try {
|
|
181
|
+
return normalizeState(JSON.parse(readFileSync(file, "utf8")), slug);
|
|
182
|
+
} catch {
|
|
183
|
+
return emptyState(slug);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Strict read for transitions and guards: missing or corrupt state is a
|
|
188
|
+
// structured error, never a silent draft fallback (CA-18).
|
|
189
|
+
type StrictRead = { ok: true; state: FlowState } | { ok: false; error: string; code: string };
|
|
190
|
+
|
|
191
|
+
const readFlowStrict = (root: string, slug: string): StrictRead => {
|
|
25
192
|
const file = flowPath(root, slug);
|
|
26
193
|
if (!existsSync(file)) {
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
menu: { presented: false, chosen: "" },
|
|
32
|
-
updated_at: Date.now(),
|
|
33
|
-
};
|
|
194
|
+
return err(
|
|
195
|
+
"flow_not_activated",
|
|
196
|
+
`flow not activated for ${slug} — run workflow_flow_status first`,
|
|
197
|
+
);
|
|
34
198
|
}
|
|
35
199
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
updated_at: parsed.updated_at ?? Date.now(),
|
|
43
|
-
};
|
|
44
|
-
} catch {
|
|
45
|
-
return {
|
|
46
|
-
slug,
|
|
47
|
-
spec: { path: "", status: "draft" },
|
|
48
|
-
plan: { path: "", status: "draft" },
|
|
49
|
-
menu: { presented: false, chosen: "" },
|
|
50
|
-
updated_at: Date.now(),
|
|
51
|
-
};
|
|
200
|
+
return { ok: true, state: normalizeState(JSON.parse(readFileSync(file, "utf8")), slug) };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return err(
|
|
203
|
+
"flow_corrupt",
|
|
204
|
+
`corrupt flow state at ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
205
|
+
);
|
|
52
206
|
}
|
|
53
207
|
};
|
|
54
208
|
|
|
209
|
+
// Unique per-write temporary buffer so two concurrent writers never share the
|
|
210
|
+
// same `<file>.tmp` (FG-08, CA-21). Pattern mirrors docs-migration.ts:597.
|
|
211
|
+
const uniqueTempPath = (file: string) =>
|
|
212
|
+
`${file}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
213
|
+
|
|
55
214
|
export const writeFlowState = (root: string, state: FlowState) => {
|
|
56
215
|
const file = flowPath(root, state.slug);
|
|
57
216
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
-
const tmp =
|
|
217
|
+
const tmp = uniqueTempPath(file);
|
|
59
218
|
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
60
219
|
renameSync(tmp, file);
|
|
61
220
|
};
|
|
62
221
|
|
|
63
|
-
const
|
|
64
|
-
current: FlowStatus,
|
|
65
|
-
confirmed: boolean,
|
|
66
|
-
): { ok: false; error: string } | { ok: true; next: FlowStatus } => {
|
|
67
|
-
if (!confirmed) return { ok: false, error: "confirmed: true required" };
|
|
68
|
-
if (current === "draft") return { ok: true, next: "self_reviewed" };
|
|
69
|
-
if (current === "self_reviewed") return { ok: true, next: "approved" };
|
|
70
|
-
return { ok: false, error: "already approved; no further transitions" };
|
|
71
|
-
};
|
|
222
|
+
const MAX_WRITE_ATTEMPTS = 5;
|
|
72
223
|
|
|
73
|
-
export
|
|
224
|
+
export type FlowWriteResult =
|
|
225
|
+
| { ok: true }
|
|
226
|
+
| { ok: false; conflict: true }
|
|
227
|
+
| { ok: false; io_error: string };
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compare-and-write (FG-08): write `next` only if the on-disk content still
|
|
231
|
+
* equals the version this writer read (`expected`). A stale writer gets
|
|
232
|
+
* `conflict` instead of clobbering a concurrent newer write; the caller re-reads
|
|
233
|
+
* and retries the transition (bounded). Unique per-write temp names keep the
|
|
234
|
+
* write buffer from being shared between writers.
|
|
235
|
+
*
|
|
236
|
+
* The first compare happens before the buffer is staged; the file is re-read
|
|
237
|
+
* immediately before the rename so a writer that committed between the two
|
|
238
|
+
* points still wins. Without the re-read, two writers holding the same expected
|
|
239
|
+
* text would both pass the compare and both rename — a lost update.
|
|
240
|
+
* ponytail: the re-read shrinks but cannot close the cross-process window (a
|
|
241
|
+
* writer can still commit between this re-read and the rename). Upgrade path:
|
|
242
|
+
* hold an O_EXCL advisory lock on `<file>.lock` across the read-modify-write,
|
|
243
|
+
* or move to renameat2(RENAME_EXCHANGE)/an OS-level CAS when a second
|
|
244
|
+
* concurrent process becomes a supported topology.
|
|
245
|
+
*
|
|
246
|
+
* A thrown error here is a real IO/permission failure (EACCES, ENOSPC, ...),
|
|
247
|
+
* not a conflict: it is returned as `io_error` so callers surface it instead of
|
|
248
|
+
* advising a pointless re-read-and-retry. Any unique `.tmp` staged by this
|
|
249
|
+
* writer is removed on every non-success path so crashed writers don't
|
|
250
|
+
* accumulate temp buffers.
|
|
251
|
+
*/
|
|
252
|
+
export const writeFlowStateIfCurrent = (
|
|
74
253
|
root: string,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
254
|
+
expected: FlowState,
|
|
255
|
+
next: FlowState,
|
|
256
|
+
): FlowWriteResult => {
|
|
257
|
+
const file = flowPath(root, next.slug);
|
|
258
|
+
const expectedText = JSON.stringify(expected, null, 2) + "\n";
|
|
259
|
+
const nextText = JSON.stringify(next, null, 2) + "\n";
|
|
260
|
+
if (expectedText === nextText) return { ok: true };
|
|
261
|
+
const tmp = uniqueTempPath(file);
|
|
80
262
|
try {
|
|
81
|
-
|
|
263
|
+
const currentText = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
264
|
+
if (currentText !== expectedText) return { ok: false, conflict: true };
|
|
265
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
266
|
+
writeFileSync(tmp, nextText, "utf8");
|
|
267
|
+
const reRead = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
268
|
+
if (reRead !== expectedText) return { ok: false, conflict: true };
|
|
269
|
+
renameSync(tmp, file);
|
|
270
|
+
return { ok: true };
|
|
82
271
|
} catch (error) {
|
|
83
|
-
return { ok: false,
|
|
272
|
+
return { ok: false, io_error: error instanceof Error ? error.message : String(error) };
|
|
273
|
+
} finally {
|
|
274
|
+
// On success the rename moved the buffer into place; on any other exit the
|
|
275
|
+
// unique temp is orphaned — remove it so crashed writers don't accumulate
|
|
276
|
+
// `<file>.<pid>-<rand>.tmp` buffers.
|
|
277
|
+
try {
|
|
278
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
279
|
+
} catch {
|
|
280
|
+
// best effort: a leftover temp is preferable to masking the real error
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
type MutateResult = { ok: true; next: FlowState } | FlowError;
|
|
286
|
+
|
|
287
|
+
// Unlocked read-modify-write made safe (FG-08): read strict, mutate in memory,
|
|
288
|
+
// then commit only if the on-disk state still matches what was read; otherwise
|
|
289
|
+
// re-read and retry the transition, bounded.
|
|
290
|
+
const readModifyWrite = (
|
|
291
|
+
root: string,
|
|
292
|
+
slug: string,
|
|
293
|
+
mutate: (state: FlowState) => MutateResult,
|
|
294
|
+
): FlowGateResult => {
|
|
295
|
+
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) {
|
|
296
|
+
const strict = readFlowStrict(root, slug);
|
|
297
|
+
if (!strict.ok) return strict;
|
|
298
|
+
const result = mutate(strict.state);
|
|
299
|
+
if (!result.ok) return result;
|
|
300
|
+
const commit = writeFlowStateIfCurrent(root, strict.state, result.next);
|
|
301
|
+
if (commit.ok) return { ok: true };
|
|
302
|
+
if ("io_error" in commit) {
|
|
303
|
+
return err("flow_io_error", `flow state write failed for ${slug}: ${commit.io_error}`);
|
|
304
|
+
}
|
|
305
|
+
// a concurrent writer won the race — re-read and retry the transition
|
|
306
|
+
}
|
|
307
|
+
return err(
|
|
308
|
+
"flow_concurrent_conflict",
|
|
309
|
+
`concurrent flow update detected for ${slug}: re-read the flow state and retry the transition`,
|
|
310
|
+
);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// The caller-supplied workspace must be the host workspace the context names
|
|
314
|
+
// (CA-21): a context built for another repo must not drive writes here.
|
|
315
|
+
const assertMutationWorkspace = (root: string, ctx?: MutationContext): FlowGateResult => {
|
|
316
|
+
if (ctx && ctx.hostWorkspace !== root) {
|
|
317
|
+
return err(
|
|
318
|
+
"workspace_mismatch",
|
|
319
|
+
`mutation context workspace ${JSON.stringify(ctx.hostWorkspace)} does not match flow workspace ${JSON.stringify(root)}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return { ok: true };
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Coordinator boundary (FG-05, CA-20): once a plan is subagent-driven, the
|
|
327
|
+
* coordinator session cannot mutate product state — only authenticated
|
|
328
|
+
* delegated workers can. A delegated worker without a task identity is blocked.
|
|
329
|
+
*/
|
|
330
|
+
export const assertCoordinatorBoundary = (
|
|
331
|
+
ctx: MutationContext | undefined,
|
|
332
|
+
menu: FlowMenuState,
|
|
333
|
+
): FlowGateResult => {
|
|
334
|
+
if (ctx?.role === "coordinator" && menu.chosen === "subagent-driven") {
|
|
335
|
+
return err("coordinator_blocked", COORDINATOR_RECOVERY_TEXT);
|
|
84
336
|
}
|
|
85
|
-
if (
|
|
86
|
-
return
|
|
337
|
+
if (ctx?.role === "delegated" && !ctx.taskIdentity) {
|
|
338
|
+
return err(
|
|
339
|
+
"delegated_unauthenticated",
|
|
340
|
+
"delegated mutations require an authenticated task identity (taskIdentity) — re-run inside the delegated worker session",
|
|
341
|
+
);
|
|
87
342
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
343
|
+
return { ok: true };
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The shared transition matrix (FG-09): draft -> approved in one receipt; a
|
|
348
|
+
* legacy self_reviewed state still advances to approved. The self-review
|
|
349
|
+
* validation runs automatically inside the draft transition.
|
|
350
|
+
*/
|
|
351
|
+
export const nextFlowStatus = (current: FlowStatus): StatusTransition => {
|
|
352
|
+
if (current === "draft") return { ok: true, next: "approved" };
|
|
353
|
+
if (current === "self_reviewed") return { ok: true, next: "approved" };
|
|
354
|
+
return err("flow_already_approved", "already approved; no further transitions");
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const MAX_CLOCK_SKEW_MS = 60_000;
|
|
358
|
+
const MAX_RECEIPTS_PER_SESSION = 10;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Freshness window for receipts (FINDING 2): an answer older than this can no
|
|
362
|
+
* longer be taken as the user's current intent. ponytail: fixed constant, not
|
|
363
|
+
* config — the consume path runs per approval-tool call, so a knob would buy
|
|
364
|
+
* surface area, not security.
|
|
365
|
+
*/
|
|
366
|
+
const RECEIPT_FRESHNESS_MS = 10 * 60 * 1000;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Independent belt-and-suspenders age gate for evidence objects passed to the
|
|
370
|
+
* transition functions (receipts are already capped at RECEIPT_FRESHNESS_MS at
|
|
371
|
+
* consume time; this defends direct library callers that fabricate a shape).
|
|
372
|
+
*/
|
|
373
|
+
const EVIDENCE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Case-insensitive negative-answer denylist (FINDING 3): a user answering
|
|
377
|
+
* "No"/"Reject"/"Cancel" can never be recorded as consent for an approval or
|
|
378
|
+
* a menu choice. The boundary closes the laundering case (negative answer ->
|
|
379
|
+
* approval); a prefix variant ("no, thanks") is covered too.
|
|
380
|
+
*/
|
|
381
|
+
const NEGATIVE_ANSWER_LABELS = [
|
|
382
|
+
"no",
|
|
383
|
+
"nope",
|
|
384
|
+
"nah",
|
|
385
|
+
"reject",
|
|
386
|
+
"cancel",
|
|
387
|
+
"decline",
|
|
388
|
+
"not now",
|
|
389
|
+
"not yet",
|
|
390
|
+
"skip",
|
|
391
|
+
"back",
|
|
392
|
+
"deny",
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const isNegativeLabel = (label: string): boolean => {
|
|
396
|
+
const normalized = label.trim().toLowerCase();
|
|
397
|
+
return NEGATIVE_ANSWER_LABELS.some((entry) => {
|
|
398
|
+
const firstWord = normalized.split(/\s+/)[0] ?? "";
|
|
399
|
+
if (entry.includes(" ")) {
|
|
400
|
+
// multi-word entries ("not now", "not yet"): whole-answer match,
|
|
401
|
+
// punctuation-insensitive ("not yet, let me check" -> "not yet")
|
|
402
|
+
const plain = normalized.replace(/[^a-z ]/g, "");
|
|
403
|
+
return plain === entry || plain.startsWith(`${entry} `);
|
|
404
|
+
}
|
|
405
|
+
// single-word entries ("no", "reject", ...): match the first word,
|
|
406
|
+
// ignoring punctuation ("no, thanks" -> "no"); "notebook" stays allowed
|
|
407
|
+
return firstWord.replace(/[^a-z]/g, "") === entry;
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One-use host-observed receipt (AR-12, CA-41): recorded by the OpenCode
|
|
413
|
+
* plugin when the answered `question` tool completes, bound to the session,
|
|
414
|
+
* the question tool call id, the exact selected label, and the timestamp.
|
|
415
|
+
* The model has no way to inject a receipt — `record` is only reachable from
|
|
416
|
+
* the plugin's `tool.execute.after` hook.
|
|
417
|
+
*/
|
|
418
|
+
export type HostReceipt = {
|
|
419
|
+
sessionId: string;
|
|
420
|
+
callID: string;
|
|
421
|
+
selectedLabel: string;
|
|
422
|
+
recordedAt: number;
|
|
423
|
+
/** The question text the user answered (plugin-observed, best effort), so
|
|
424
|
+
* the consuming tool can report WHICH question authorized a transition
|
|
425
|
+
* (FINDING 2). */
|
|
426
|
+
question?: string;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export type ReceiptConsumeResult = { ok: true; receipt: HostReceipt } | FlowError;
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* In-memory per-session receipt queue. `record` simulates the host hook; the
|
|
433
|
+
* OpenCode plugin is the only production caller. Unconsumed receipts are
|
|
434
|
+
* bounded per session (oldest dropped) so a session that asks questions
|
|
435
|
+
* without approving cannot grow memory without limit.
|
|
436
|
+
*
|
|
437
|
+
* Correlation (FINDING 2): on a real host the model first calls the native
|
|
438
|
+
* `question` (user answers), THEN calls the approval/menu tool — the tools
|
|
439
|
+
* never run a question internally, so a before/after execution window can
|
|
440
|
+
* never capture the answer. Consumption therefore takes the session's MOST
|
|
441
|
+
* RECENT unconsumed receipt and verifies: one-use (atomic take), freshness
|
|
442
|
+
* (RECEIPT_FRESHNESS_MS), NOT a negative label (isNegativeLabel), and session
|
|
443
|
+
* match. Menu tools additionally pin the expected choice label. CallID and
|
|
444
|
+
* the exact selected label stay bound at record time.
|
|
445
|
+
*
|
|
446
|
+
* Residual risk (honest boundary): any recent POSITIVE host answer (e.g. a
|
|
447
|
+
* "proceed with stash?" -> "yes, proceed") plus the model's choice to call an
|
|
448
|
+
* approval tool authorizes the transition. The laundering case — a negative
|
|
449
|
+
* answer recorded as an approval — is closed by the negative-label denylist.
|
|
450
|
+
*
|
|
451
|
+
* ponytail: in-memory only — receipts die with the plugin process, which is
|
|
452
|
+
* correct: a host-observed answer cannot survive a restart. Upgrade path:
|
|
453
|
+
* persist to the host session store when cross-restart approvals are required.
|
|
454
|
+
*/
|
|
455
|
+
export class HostReceiptStore {
|
|
456
|
+
#bySession = new Map<string, HostReceipt[]>();
|
|
457
|
+
|
|
458
|
+
record(
|
|
459
|
+
sessionId: string,
|
|
460
|
+
callID: string,
|
|
461
|
+
selectedLabel: string,
|
|
462
|
+
recordedAt: number = Date.now(),
|
|
463
|
+
question?: string,
|
|
464
|
+
): void {
|
|
465
|
+
const label = selectedLabel.trim();
|
|
466
|
+
if (!label) return;
|
|
467
|
+
if (recordedAt > Date.now() + MAX_CLOCK_SKEW_MS) return; // forged future receipt
|
|
468
|
+
const queue = this.#bySession.get(sessionId) ?? [];
|
|
469
|
+
if (queue.length >= MAX_RECEIPTS_PER_SESSION) queue.shift();
|
|
470
|
+
queue.push({ sessionId, callID, selectedLabel: label, recordedAt, question });
|
|
471
|
+
this.#bySession.set(sessionId, queue);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
count(sessionId: string): number {
|
|
475
|
+
return this.#bySession.get(sessionId)?.length ?? 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Non-destructive consume: same checks as `consume`, but a positive receipt
|
|
480
|
+
* stays queued. The tools no longer use peek — FINDING 5 (round 3) moved the
|
|
481
|
+
* approval/menu tools to consume-before-transition (the atomic take gates
|
|
482
|
+
* the transition and is spent on any attempt, closing the concurrent-call
|
|
483
|
+
* race). Peek remains for tests and read-only callers. A NEGATIVE receipt is
|
|
484
|
+
* the exception: it is spent by peek too (consumed-and-rejected, FINDING 3)
|
|
485
|
+
* so it cannot poison the top of the queue.
|
|
486
|
+
*/
|
|
487
|
+
peek(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
|
|
488
|
+
return this.#take(sessionId, opts, false);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* One-use consumption of the session's most recent receipt (FINDING 2).
|
|
493
|
+
* The atomic take gates the transition at the tool layer (FINDING 5, round
|
|
494
|
+
* 3): a stale receipt, a wrong pinned label (menu), or a negative label
|
|
495
|
+
* fails the transition; the receipt is removed on take, staleness, or
|
|
496
|
+
* negativity (fail-closed). A wrong label (menu) is NOT spent — it stays
|
|
497
|
+
* queued for the choice it actually matched.
|
|
498
|
+
*/
|
|
499
|
+
consume(sessionId: string, opts: { label?: string } = {}): ReceiptConsumeResult {
|
|
500
|
+
return this.#take(sessionId, opts, true);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#take(sessionId: string, opts: { label?: string }, remove: boolean): ReceiptConsumeResult {
|
|
504
|
+
const queue = this.#bySession.get(sessionId);
|
|
505
|
+
if (!queue || queue.length === 0) {
|
|
506
|
+
return err(
|
|
507
|
+
"receipt_missing",
|
|
508
|
+
"no host-observed native-question receipt for this session — ask the native " +
|
|
509
|
+
"`question` tool and have the user answer before calling this tool",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const index = queue.length - 1;
|
|
513
|
+
const receipt = queue[index];
|
|
514
|
+
if (isNegativeLabel(receipt.selectedLabel)) {
|
|
515
|
+
// Consumed-and-rejected: a negative answer is still an answer, and it
|
|
516
|
+
// can never authorize a transition (FINDING 3). The whole session queue
|
|
517
|
+
// is revoked too: the user's most recent intent is negative, so an
|
|
518
|
+
// older positive answer must not come back to life on a retry.
|
|
519
|
+
this.#bySession.delete(sessionId);
|
|
520
|
+
return err(
|
|
521
|
+
"receipt_rejected",
|
|
522
|
+
`the user's most recent answer (${JSON.stringify(receipt.selectedLabel)}) is a ` +
|
|
523
|
+
"negative answer — it cannot authorize an approval; ask the native question again",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
if (opts.label !== undefined && !sameChoiceLabel(receipt.selectedLabel, opts.label)) {
|
|
527
|
+
// FINDING 6: a wrong-label answer is not spent — it stays queued for
|
|
528
|
+
// the choice it actually matched (or expires via freshness/bounds).
|
|
529
|
+
return err(
|
|
530
|
+
"evidence_mismatch",
|
|
531
|
+
`receipt selectedLabel does not match ${JSON.stringify(opts.label)} — fabricated menu choice rejected`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (Date.now() - receipt.recordedAt > RECEIPT_FRESHNESS_MS) {
|
|
535
|
+
if (remove) {
|
|
536
|
+
queue.splice(index, 1);
|
|
537
|
+
if (queue.length === 0) this.#bySession.delete(sessionId);
|
|
538
|
+
}
|
|
539
|
+
return err(
|
|
540
|
+
"receipt_stale",
|
|
541
|
+
"the question receipt is too old — ask the native question again and re-answer",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (remove) {
|
|
545
|
+
queue.splice(index, 1);
|
|
546
|
+
if (queue.length === 0) this.#bySession.delete(sessionId);
|
|
547
|
+
}
|
|
548
|
+
return { ok: true, receipt };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Menu labels compare case-insensitively: the host presents "Inline", the
|
|
553
|
+
* enum stores "inline" (FINDING 3). */
|
|
554
|
+
const sameChoiceLabel = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
|
555
|
+
|
|
556
|
+
/** Derive the evidence record from a consumed host receipt (AR-12). */
|
|
557
|
+
export const createOpenCodeEvidence = (receipt: HostReceipt): OpenCodeChoiceEvidence => ({
|
|
558
|
+
host: "opencode",
|
|
559
|
+
attested: true,
|
|
560
|
+
callID: receipt.callID,
|
|
561
|
+
selectedLabel: receipt.selectedLabel,
|
|
562
|
+
recordedAt: receipt.recordedAt,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/** The Cursor policy-only constant: unauthenticated, no caller data (CA-42). */
|
|
566
|
+
export const createCursorConfirmation = (): EvidenceResult => ({
|
|
567
|
+
ok: true,
|
|
568
|
+
evidence: { host: "cursor", attested: false, confirmation: "contract" },
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const CURSOR_KEYS = ["attested", "confirmation", "host"];
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Strict shape validation for every flow transition (CA-41): OpenCode evidence
|
|
575
|
+
* must be a host-attested receipt record; Cursor evidence must be exactly the
|
|
576
|
+
* policy-only constant and carries no caller-supplied question data. A Cursor
|
|
577
|
+
* object claiming an observed answer (`attested: true`) is rejected as forged.
|
|
578
|
+
*/
|
|
579
|
+
export const assertEvidenceShape = (input: unknown): EvidenceResult => {
|
|
580
|
+
if (typeof input !== "object" || input === null) {
|
|
581
|
+
return {
|
|
582
|
+
ok: false,
|
|
583
|
+
error:
|
|
584
|
+
"native choice evidence required — bare booleans and other primitives are not approval evidence",
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const record = input as Record<string, unknown>;
|
|
588
|
+
if (record.host === "cursor") {
|
|
589
|
+
if (record.attested !== false || record.confirmation !== "contract") {
|
|
94
590
|
return {
|
|
95
591
|
ok: false,
|
|
96
|
-
error:
|
|
592
|
+
error:
|
|
593
|
+
'cursor confirmations are policy-only: exactly { host: "cursor", attested: false, confirmation: "contract" } — Cursor cannot attest a host-observed answer',
|
|
97
594
|
};
|
|
98
595
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (!/^\s*\*+Branch:\*+/im.test(stripFences(text))) missing.push("**Branch:** header missing");
|
|
102
|
-
if (hard.length > 0 || missing.length > 0) {
|
|
596
|
+
const keys = Object.keys(record).sort();
|
|
597
|
+
if (keys.length !== CURSOR_KEYS.length || !CURSOR_KEYS.every((key) => keys.includes(key))) {
|
|
103
598
|
return {
|
|
104
599
|
ok: false,
|
|
105
600
|
error:
|
|
106
|
-
"
|
|
107
|
-
hard.map((f) => `${f.code} — ${f.message}`).concat(missing).join("; ") +
|
|
108
|
-
" — see templates/spec-template.md for the required structure",
|
|
601
|
+
"cursor confirmations carry no caller-supplied question data — the attested: false constant only",
|
|
109
602
|
};
|
|
110
603
|
}
|
|
604
|
+
return { ok: true, evidence: { host: "cursor", attested: false, confirmation: "contract" } };
|
|
605
|
+
}
|
|
606
|
+
if (record.host !== "opencode") {
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
error: `evidence host must be 'opencode' or 'cursor', got ${JSON.stringify(record.host)}`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
if (record.attested !== true) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
error:
|
|
616
|
+
"opencode evidence requires host attestation (attested: true) — only host-observed question receipts are accepted",
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
const { callID, selectedLabel, recordedAt } = record;
|
|
620
|
+
if (typeof callID !== "string" || callID.trim() === "") {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: "opencode evidence callID must be a non-empty string (host question tool call)",
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (typeof selectedLabel !== "string" || selectedLabel.trim() === "") {
|
|
627
|
+
return {
|
|
628
|
+
ok: false,
|
|
629
|
+
error:
|
|
630
|
+
"opencode evidence selectedLabel must be the exact label the user selected on the native question",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
if (typeof recordedAt !== "number" || !Number.isFinite(recordedAt) || recordedAt <= 0) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
error: "opencode evidence recordedAt must be a positive epoch-ms timestamp",
|
|
637
|
+
};
|
|
111
638
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
if (recordedAt > now + MAX_CLOCK_SKEW_MS) {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
error: "opencode evidence recordedAt is in the future — forged evidence is rejected",
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (now - recordedAt > EVIDENCE_WINDOW_MS) {
|
|
647
|
+
return {
|
|
648
|
+
ok: false,
|
|
649
|
+
error:
|
|
650
|
+
"opencode evidence recordedAt is too old — ask the native question again and re-record the answer",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
ok: true,
|
|
655
|
+
evidence: {
|
|
656
|
+
host: "opencode",
|
|
657
|
+
attested: true,
|
|
658
|
+
callID: callID.trim(),
|
|
659
|
+
selectedLabel: selectedLabel.trim(),
|
|
660
|
+
recordedAt,
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/** Host provenance binding: OpenCode only accepts opencode evidence, and vice versa. */
|
|
666
|
+
export const assertHostEvidence = (host: FlowHost, evidence: unknown): FlowGateResult => {
|
|
667
|
+
const shaped = assertEvidenceShape(evidence);
|
|
668
|
+
if (!shaped.ok) return err("evidence_invalid", shaped.error);
|
|
669
|
+
if (shaped.evidence.host !== host) {
|
|
670
|
+
return err(
|
|
671
|
+
"evidence_host_mismatch",
|
|
672
|
+
`evidence was recorded on ${JSON.stringify(shaped.evidence.host)}, not ${host} — forged or misattributed evidence is rejected`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return { ok: true };
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Record flow activation and the canonical spec/plan paths when preparation
|
|
680
|
+
* begins. The flow store lives under the canonical docs/<slug>/sdd/ layout
|
|
681
|
+
* (Task 18 contract). Re-runs keep existing statuses while recording paths.
|
|
682
|
+
*/
|
|
683
|
+
export const prepareFlowState = (
|
|
684
|
+
root: string,
|
|
685
|
+
slug: string,
|
|
686
|
+
opts: { spec_path?: string; plan_path?: string } = {},
|
|
687
|
+
ctx?: MutationContext,
|
|
688
|
+
): FlowGateResult => {
|
|
689
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
690
|
+
if (!bound.ok) return bound;
|
|
691
|
+
const resolved = resolveCanonicalLayout({
|
|
692
|
+
workspace_root: root,
|
|
693
|
+
slug,
|
|
694
|
+
spec_path: opts.spec_path,
|
|
695
|
+
plan_path: opts.plan_path,
|
|
118
696
|
});
|
|
697
|
+
if (!resolved.ok) return err("flow_prepare_failed", resolved.error);
|
|
698
|
+
const specPath = path.posix.join("docs", slug, "spec.md");
|
|
699
|
+
const planPath = path.posix.join("docs", slug, "plan.md");
|
|
700
|
+
const current = readFlowState(root, slug);
|
|
701
|
+
const state: FlowState = current.activated
|
|
702
|
+
? {
|
|
703
|
+
...current,
|
|
704
|
+
spec: { ...current.spec, path: specPath },
|
|
705
|
+
plan: { ...current.plan, path: planPath },
|
|
706
|
+
updated_at: Date.now(),
|
|
707
|
+
}
|
|
708
|
+
: {
|
|
709
|
+
slug,
|
|
710
|
+
activated: true,
|
|
711
|
+
spec: { path: specPath, status: "draft", evidence: null },
|
|
712
|
+
plan: { path: planPath, status: "draft", evidence: null },
|
|
713
|
+
menu: { presented: false, chosen: "", evidence: null },
|
|
714
|
+
updated_at: Date.now(),
|
|
715
|
+
};
|
|
716
|
+
writeFlowState(root, state);
|
|
119
717
|
return { ok: true };
|
|
120
718
|
};
|
|
121
719
|
|
|
720
|
+
export const transitionSpec = (
|
|
721
|
+
root: string,
|
|
722
|
+
slug: string,
|
|
723
|
+
specPath: string,
|
|
724
|
+
evidence: unknown,
|
|
725
|
+
ctx?: MutationContext,
|
|
726
|
+
): FlowGateResult => {
|
|
727
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
728
|
+
if (!bound.ok) return bound;
|
|
729
|
+
const recorded = assertEvidenceShape(evidence);
|
|
730
|
+
if (!recorded.ok) return err("evidence_invalid", recorded.error);
|
|
731
|
+
const doc = resolveDoc(root, slug, specPath, "spec");
|
|
732
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
733
|
+
return readModifyWrite(root, slug, (state) => {
|
|
734
|
+
if (!existsSync(doc.path)) return err("spec_missing", `spec not found: ${specPath}`);
|
|
735
|
+
if (state.spec.status === "draft") {
|
|
736
|
+
let text: string;
|
|
737
|
+
try {
|
|
738
|
+
text = readFileSync(doc.path, "utf8");
|
|
739
|
+
} catch (error) {
|
|
740
|
+
return err(
|
|
741
|
+
"spec_self_review_failed",
|
|
742
|
+
`spec self-review failed: unreadable spec: ${error instanceof Error ? error.message : String(error)}`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
const hard = qualitySpec(text).filter((f) => f.severity === "hard");
|
|
746
|
+
const missing: string[] = [];
|
|
747
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripFences(text)))
|
|
748
|
+
missing.push("**Branch:** header missing");
|
|
749
|
+
if (hard.length > 0 || missing.length > 0) {
|
|
750
|
+
return err(
|
|
751
|
+
"spec_self_review_failed",
|
|
752
|
+
"spec self-review failed: " +
|
|
753
|
+
hard
|
|
754
|
+
.map((f) => `${f.code} — ${f.message}`)
|
|
755
|
+
.concat(missing)
|
|
756
|
+
.join("; ") +
|
|
757
|
+
" — see templates/spec-template.md for the required structure",
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const step = nextFlowStatus(state.spec.status);
|
|
762
|
+
if (!step.ok) return step;
|
|
763
|
+
return {
|
|
764
|
+
ok: true,
|
|
765
|
+
next: {
|
|
766
|
+
...state,
|
|
767
|
+
spec: {
|
|
768
|
+
path: path.posix.join("docs", slug, "spec.md"),
|
|
769
|
+
status: step.next,
|
|
770
|
+
evidence: recorded.evidence,
|
|
771
|
+
},
|
|
772
|
+
updated_at: Date.now(),
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
};
|
|
777
|
+
|
|
122
778
|
export const transitionPlan = (
|
|
123
779
|
root: string,
|
|
124
780
|
slug: string,
|
|
125
781
|
planPath: string,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (!
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
if (state.plan.status === "draft" && confirmed) {
|
|
141
|
-
const planFile = path.isAbsolute(planPath) ? planPath : path.join(root, planPath);
|
|
142
|
-
let text: string;
|
|
143
|
-
try {
|
|
144
|
-
text = readFileSync(planFile, "utf8");
|
|
145
|
-
} catch (error) {
|
|
146
|
-
return {
|
|
147
|
-
ok: false,
|
|
148
|
-
error: `plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
|
|
149
|
-
};
|
|
782
|
+
evidence: unknown,
|
|
783
|
+
ctx?: MutationContext,
|
|
784
|
+
): FlowGateResult => {
|
|
785
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
786
|
+
if (!bound.ok) return bound;
|
|
787
|
+
const recorded = assertEvidenceShape(evidence);
|
|
788
|
+
if (!recorded.ok) return err("evidence_invalid", recorded.error);
|
|
789
|
+
const doc = resolveDoc(root, slug, planPath, "plan");
|
|
790
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
791
|
+
return readModifyWrite(root, slug, (state) => {
|
|
792
|
+
if (!existsSync(doc.path)) return err("plan_missing", `plan not found: ${planPath}`);
|
|
793
|
+
if (state.spec.status !== "approved") {
|
|
794
|
+
return err("spec_not_approved", "spec must be approved before the plan can be approved");
|
|
150
795
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
796
|
+
if (state.plan.status === "draft") {
|
|
797
|
+
let text: string;
|
|
798
|
+
try {
|
|
799
|
+
text = readFileSync(doc.path, "utf8");
|
|
800
|
+
} catch (error) {
|
|
801
|
+
return err(
|
|
802
|
+
"plan_self_review_failed",
|
|
803
|
+
`plan self-review failed: unreadable plan: ${error instanceof Error ? error.message : String(error)}`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
const missing: string[] = [];
|
|
807
|
+
const stripped = stripFences(text);
|
|
808
|
+
if (parseTasksFromPlan(text).length === 0)
|
|
809
|
+
missing.push("no ### Task N: sections outside fences");
|
|
810
|
+
if (!/^\s*\*+Spec:\*+/im.test(stripped)) missing.push("**Spec:** header missing");
|
|
811
|
+
if (!/^\s*\*+Branch:\*+/im.test(stripped)) missing.push("**Branch:** header missing");
|
|
812
|
+
if (missing.length > 0)
|
|
813
|
+
return err("plan_self_review_failed", "plan self-review failed: " + missing.join("; "));
|
|
814
|
+
}
|
|
815
|
+
const step = nextFlowStatus(state.plan.status);
|
|
816
|
+
if (!step.ok) return step;
|
|
817
|
+
return {
|
|
818
|
+
ok: true,
|
|
819
|
+
next: {
|
|
820
|
+
...state,
|
|
821
|
+
plan: {
|
|
822
|
+
path: path.posix.join("docs", slug, "plan.md"),
|
|
823
|
+
status: step.next,
|
|
824
|
+
evidence: recorded.evidence,
|
|
825
|
+
},
|
|
826
|
+
updated_at: Date.now(),
|
|
827
|
+
},
|
|
828
|
+
};
|
|
166
829
|
});
|
|
167
|
-
return { ok: true };
|
|
168
830
|
};
|
|
169
831
|
|
|
170
832
|
export const recordMenuChoice = (
|
|
171
833
|
root: string,
|
|
172
834
|
slug: string,
|
|
173
835
|
planPath: string,
|
|
174
|
-
choice:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
836
|
+
choice: unknown,
|
|
837
|
+
evidence: unknown,
|
|
838
|
+
ctx?: MutationContext,
|
|
839
|
+
): FlowGateResult => {
|
|
840
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
841
|
+
if (!bound.ok) return bound;
|
|
842
|
+
const recorded = assertEvidenceShape(evidence);
|
|
843
|
+
if (!recorded.ok) return err("evidence_invalid", recorded.error);
|
|
844
|
+
if (typeof choice !== "string" || !MENU_CHOICES.includes(choice as MenuChoice)) {
|
|
845
|
+
return err("menu_choice_invalid", `invalid menu choice: ${JSON.stringify(choice)}`);
|
|
183
846
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
847
|
+
// Cursor cannot run subagent-driven plans (no child sessions): entering that
|
|
848
|
+
// flow state on Cursor is rejected with recovery guidance (CA-42).
|
|
849
|
+
if (recorded.evidence.host === "cursor" && choice === "subagent-driven") {
|
|
850
|
+
return err("unsupported_mode", CURSOR_SUBAGENT_UNSUPPORTED_TEXT);
|
|
851
|
+
}
|
|
852
|
+
// The execution-menu evidence must be the label the user selected on the
|
|
853
|
+
// native question; a mismatched choice is fabricated (FG-04). Comparison is
|
|
854
|
+
// case-insensitive: the host presents "Inline", the enum stores "inline"
|
|
855
|
+
// (FINDING 3). Cursor evidence is the policy-only constant (no label), so
|
|
856
|
+
// the check applies to host-observed OpenCode receipts only.
|
|
857
|
+
if (
|
|
858
|
+
recorded.evidence.host === "opencode" &&
|
|
859
|
+
!sameChoiceLabel(recorded.evidence.selectedLabel, choice)
|
|
860
|
+
) {
|
|
861
|
+
return err(
|
|
862
|
+
"evidence_mismatch",
|
|
863
|
+
`evidence selectedLabel ${JSON.stringify(recorded.evidence.selectedLabel)} does not match choice ${JSON.stringify(choice)}`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
const doc = resolveDoc(root, slug, planPath, "plan");
|
|
867
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
868
|
+
return readModifyWrite(root, slug, (state) => {
|
|
869
|
+
if (state.spec.status !== "approved")
|
|
870
|
+
return err("spec_not_approved", "spec must be approved before the execution menu");
|
|
871
|
+
if (state.plan.status !== "approved")
|
|
872
|
+
return err("plan_not_approved", "plan must be approved before the execution menu");
|
|
873
|
+
return {
|
|
874
|
+
ok: true,
|
|
875
|
+
next: {
|
|
876
|
+
...state,
|
|
877
|
+
plan: state.plan.path ? state.plan : { path: planPath, status: state.plan.status },
|
|
878
|
+
menu: { presented: true, chosen: choice, evidence: recorded.evidence },
|
|
879
|
+
updated_at: Date.now(),
|
|
880
|
+
},
|
|
881
|
+
};
|
|
189
882
|
});
|
|
190
|
-
return { ok: true };
|
|
191
883
|
};
|
|
192
884
|
|
|
193
885
|
export const slugFromPath = (p: string) => {
|
|
@@ -195,31 +887,781 @@ export const slugFromPath = (p: string) => {
|
|
|
195
887
|
return dirName === "." || dirName === "/" || dirName === "" ? "" : dirName;
|
|
196
888
|
};
|
|
197
889
|
|
|
890
|
+
/** Derive a slug from a canonical docs/<slug>/sdd/... path (SDD write gates). */
|
|
891
|
+
export const slugFromSddPath = (p: string): string => {
|
|
892
|
+
const match = p
|
|
893
|
+
.split(path.sep)
|
|
894
|
+
.join("/")
|
|
895
|
+
// The sdd dir name may be followed by a separator, end-of-string, or a
|
|
896
|
+
// quote char part of a quote-bearing dir name; `sdd-attack` (hyphen/letter
|
|
897
|
+
// continuation) is still rejected.
|
|
898
|
+
.match(/^docs\/([^/]+)\/sdd(\/|$|['"])/);
|
|
899
|
+
return match?.[1] ?? "";
|
|
900
|
+
};
|
|
901
|
+
|
|
198
902
|
export const assertFlowGates = (
|
|
199
903
|
root: string,
|
|
200
904
|
planPath: string,
|
|
201
905
|
opts: { requireMenu?: boolean } = {},
|
|
202
|
-
):
|
|
906
|
+
): FlowGateResult => {
|
|
907
|
+
const doc = resolveDoc(root, "", planPath, "plan");
|
|
908
|
+
if (!doc.ok) return err("path_invalid", doc.error);
|
|
203
909
|
const slug = slugFromPath(planPath);
|
|
204
910
|
const state = readFlowState(root, slug);
|
|
205
911
|
if (state.spec.status !== "approved") {
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
912
|
+
return err(
|
|
913
|
+
"spec_not_approved",
|
|
914
|
+
`spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
|
|
915
|
+
);
|
|
210
916
|
}
|
|
211
917
|
if (state.plan.status !== "approved") {
|
|
212
|
-
return
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
918
|
+
return err(
|
|
919
|
+
"plan_not_approved",
|
|
920
|
+
`plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
|
|
921
|
+
);
|
|
216
922
|
}
|
|
217
923
|
if (opts.requireMenu && !state.menu.presented) {
|
|
218
|
-
return
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
924
|
+
return err(
|
|
925
|
+
"menu_not_presented",
|
|
926
|
+
"post-plan menu not presented. Ask the native question menu (Subagent-driven/Inline/Handoff/Review spec/Review plan) and record the answer with workflow_plan_menu.",
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
return { ok: true };
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Shared mutation guard for non-document product writes (FG-03, CA-18): a write
|
|
934
|
+
* is blocked until the spec is approved, the plan is approved, the execution
|
|
935
|
+
* menu has been recorded (when required), and the canonical docs validate.
|
|
936
|
+
* The optional MutationContext adds the coordinator boundary (FG-05, CA-20).
|
|
937
|
+
*/
|
|
938
|
+
export const assertProductGates = (
|
|
939
|
+
root: string,
|
|
940
|
+
slug: string,
|
|
941
|
+
opts: { requireMenu?: boolean; requireDocs?: boolean } = {},
|
|
942
|
+
ctx?: MutationContext,
|
|
943
|
+
): FlowGateResult => {
|
|
944
|
+
const bound = assertMutationWorkspace(root, ctx);
|
|
945
|
+
if (!bound.ok) return bound;
|
|
946
|
+
// Strict read (CA-18): missing or corrupt state surfaces flow_not_activated /
|
|
947
|
+
// flow_corrupt, never a misleading spec_not_approved from a silent draft
|
|
948
|
+
// fallback. Fail-closed is preserved — no gate ever passes on absent state.
|
|
949
|
+
const strict = readFlowStrict(root, slug);
|
|
950
|
+
if (!strict.ok) return strict;
|
|
951
|
+
const state = strict.state;
|
|
952
|
+
if (state.spec.status !== "approved") {
|
|
953
|
+
return err(
|
|
954
|
+
"spec_not_approved",
|
|
955
|
+
`spec not approved (status: ${state.spec.status}). Run workflow_spec_approve after the user's approval.`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (state.plan.status !== "approved") {
|
|
959
|
+
return err(
|
|
960
|
+
"plan_not_approved",
|
|
961
|
+
`plan not approved (status: ${state.plan.status}). Run workflow_plan_approve after the user's approval.`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if (opts.requireMenu && !state.menu.presented) {
|
|
965
|
+
return err(
|
|
966
|
+
"menu_not_presented",
|
|
967
|
+
"post-plan menu not presented. Record the native question answer with workflow_plan_menu.",
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (opts.requireDocs) {
|
|
971
|
+
// Canonical relative form of the docs pair (DC-01/DC-02): docsValidate
|
|
972
|
+
// resolves the contained paths itself.
|
|
973
|
+
const validated = docsValidate({
|
|
974
|
+
spec_path: path.posix.join("docs", slug, "spec.md"),
|
|
975
|
+
plan_path: path.posix.join("docs", slug, "plan.md"),
|
|
976
|
+
workspace_root: root,
|
|
977
|
+
});
|
|
978
|
+
if (validated.ok === false) return err("docs_invalid", validated.error);
|
|
979
|
+
}
|
|
980
|
+
return assertCoordinatorBoundary(ctx, state.menu);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Delegated status derives from host session parentage (AR-12, CA-20): a
|
|
985
|
+
* session whose host record has a parent is a child (delegated worker); a root
|
|
986
|
+
* session (no parent) is the coordinator. Caller-supplied role fields are
|
|
987
|
+
* removed from every tool schema — this pure function is the only source.
|
|
988
|
+
*/
|
|
989
|
+
export const roleFromParentage = (parentID?: string | null): FlowRole =>
|
|
990
|
+
parentID ? "delegated" : "coordinator";
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Root-session write interception while a subagent-driven plan is active
|
|
994
|
+
* (CA-18, AR-13): known mutation tools are denied outright. Host-native write
|
|
995
|
+
* tools (write/edit/apply_patch/patch/rename/delete/…) plus the workit
|
|
996
|
+
* mutation tools are listed here so the plugin hook can deny them before any
|
|
997
|
+
* file is touched. Read-only host tools (read/grep/glob/list/question/task/…)
|
|
998
|
+
* are intentionally absent. The list is the audited boundary: adding a write
|
|
999
|
+
* tool here without a test is the escape hatch the audit checks for.
|
|
1000
|
+
*/
|
|
1001
|
+
export const COORDINATOR_WRITE_TOOLS: readonly string[] = [
|
|
1002
|
+
// host-native file/command mutation tools
|
|
1003
|
+
"write",
|
|
1004
|
+
"edit",
|
|
1005
|
+
"apply_patch",
|
|
1006
|
+
"patch",
|
|
1007
|
+
"rename",
|
|
1008
|
+
"delete",
|
|
1009
|
+
"mkdir",
|
|
1010
|
+
"mv",
|
|
1011
|
+
"cp",
|
|
1012
|
+
"rm",
|
|
1013
|
+
"touch",
|
|
1014
|
+
"chmod",
|
|
1015
|
+
"chown",
|
|
1016
|
+
// workit product/config/external mutation tools
|
|
1017
|
+
"workflow_commit",
|
|
1018
|
+
"workflow_pr_create",
|
|
1019
|
+
"workflow_rule_edit",
|
|
1020
|
+
"workflow_template_edit",
|
|
1021
|
+
"workflow_changelog_apply",
|
|
1022
|
+
"workflow_branch_setup",
|
|
1023
|
+
"workflow_toolkit_init_apply",
|
|
1024
|
+
"workflow_docs_promote",
|
|
1025
|
+
"workflow_docs_layout",
|
|
1026
|
+
"workflow_docs_repo_link",
|
|
1027
|
+
"workflow_sdd_task_brief",
|
|
1028
|
+
"workflow_sdd_review_package",
|
|
1029
|
+
"workflow_sdd_append_progress",
|
|
1030
|
+
"workflow_youtrack_post",
|
|
1031
|
+
"workflow_youtrack_log_time",
|
|
1032
|
+
];
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Bounded coordinator shell allowlist (CA-18, AR-13): while a subagent-driven
|
|
1036
|
+
* plan is active, the coordinator may run ONLY read/review/test/verify
|
|
1037
|
+
* commands — never anything that mutates files, git state, the system clock,
|
|
1038
|
+
* or the network.
|
|
1039
|
+
*
|
|
1040
|
+
* DENY matrix (every check below is asserted by the adversarial test table):
|
|
1041
|
+
* - Denied fragments, checked on the raw command before tokenizing:
|
|
1042
|
+
* `>` `>>` `2>` `&>` `<>` (any `>` — redirection), `|` (pipes, incl. `2>|`),
|
|
1043
|
+
* `&` (backgrounding), `;` (chains), `$(` (command substitution),
|
|
1044
|
+
* `${` (parameter expansion — `${IFS}` can smuggle whitespace past the
|
|
1045
|
+
* tokenizer), `$'` (ANSI-C quoting — can embed `\t`/`\n` escapes that are
|
|
1046
|
+
* real whitespace to the shell), `` ` `` (backticks), newline (multi-line
|
|
1047
|
+
* scripts), `<(` (process substitution input — `>(` dies on `>` already).
|
|
1048
|
+
* Heredocs `<<` are stdin-only and allowed (a heredoc cannot write without
|
|
1049
|
+
* a `>`). Literal `\t`/`\n` backslash escapes OUTSIDE `$'...'` are plain
|
|
1050
|
+
* `t`/`n` characters to the shell — they cannot create whitespace
|
|
1051
|
+
* (documented, FINDING 4).
|
|
1052
|
+
* - `(` `)` are denied per-token (process substitution `<(`, `>(`, subshells
|
|
1053
|
+
* `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
|
|
1054
|
+
* placeholders (`--format='%(refname)'`): a `--format` value is display
|
|
1055
|
+
* text (the shell already consumed the quotes) and `$(`/`<(`/`>`/backticks
|
|
1056
|
+
* are denied raw regardless (FINDING 3, round 4). Multi-token format
|
|
1057
|
+
* values (a space inside the quoted format) stay denied — fail-closed.
|
|
1058
|
+
* Pure-stdout verbs (`echo printf jq`) may print parens as display text:
|
|
1059
|
+
* a shell-quote-state scan allows the command iff every paren lies inside
|
|
1060
|
+
* a quoted region — any unquoted paren (subshell syntax, bash-verified
|
|
1061
|
+
* syntax error) denies the whole command, fail-closed (FINDING 3, round 5).
|
|
1062
|
+
* - Denied command heads: `curl`, `sudo`, `tee`, `wget` (privilege/network/
|
|
1063
|
+
* tee writes). These words are ONLY denied as the first token — as argument
|
|
1064
|
+
* text (`grep curl README.md`, `cat sudo-config.txt`) they pass (FINDING 5).
|
|
1065
|
+
* - Every other first token must be one of the allowlisted sets below.
|
|
1066
|
+
* - Tokens are UNQUOTED (every `'`/`"` character stripped — the shell's word
|
|
1067
|
+
* parsing removes quote characters entirely, so `--out'put=x'` IS
|
|
1068
|
+
* `--output=x`, `-de'lete'` IS `-delete`, `cu'rl'` IS `curl`, `awk -'f x'`
|
|
1069
|
+
* IS `awk -f x`) before every check (FINDING 2, round 5).
|
|
1070
|
+
* - `--output` and `--output=` (git log/diff and any other verb) are denied
|
|
1071
|
+
* on every command: both forms write a file.
|
|
1072
|
+
* - Write-capable `-o`/attached `-oFILE`/`--output`/`--output=FILE` are
|
|
1073
|
+
* denied on `sort`, `tree`, `comm`, `diff`, `jq` (grep/rg keep `-o` — it
|
|
1074
|
+
* only prints the matching part, read-only; find's `-o` is the logical-OR
|
|
1075
|
+
* operator and stays allowed).
|
|
1076
|
+
* - `--compress-program` (any form, every verb): GNU sort EXECUTES the given
|
|
1077
|
+
* program with the sorted data on its stdin — `sh` runs that data as a
|
|
1078
|
+
* script (bash-verified, FINDING 1, round 6). Only sort has the flag, but
|
|
1079
|
+
* the deny is global so no flag surface needs tracking.
|
|
1080
|
+
* - `date -s`/`--set` (any attached/separate/`=` form): mutates the system
|
|
1081
|
+
* clock (bash-verified setter, FINDING 3, round 6). `date -d`/`--date`
|
|
1082
|
+
* (display) stays allowed.
|
|
1083
|
+
* - `sort -T`/`--temporary-directory` (any form): writes sort's own temp
|
|
1084
|
+
* files into an arbitrary directory (bash/strace-verified, FINDING 4,
|
|
1085
|
+
* round 6). `sort -t:` (field separator) stays allowed.
|
|
1086
|
+
* - Read-only tool heads (`cat head tail less more grep rg ag find ls stat wc
|
|
1087
|
+
* file diff sort uniq cut tr fold printf echo pwd date which type du df tree
|
|
1088
|
+
* jq basename dirname realpath readlink rev comm paste nl od xxd awk gawk
|
|
1089
|
+
* mawk test [`):
|
|
1090
|
+
* `find` is denied every destructive/file-writing form: `-delete -exec
|
|
1091
|
+
* -execdir -ok -okdir` and `-fprint* -fls` (prefix).
|
|
1092
|
+
* `sed` is NOT allowlisted at all (round 5, decision: deny outright). GNU
|
|
1093
|
+
* sed 4.9 executes arbitrary commands through the `e` command (`sed 'e
|
|
1094
|
+
* touch x' f`) and the `s///e` flag (bash-verified: both ran `touch` —
|
|
1095
|
+
* e.g. `sed 's/.+/touch x/e' f`); closing the class needs a full sed script
|
|
1096
|
+
* grammar, and five review rounds of sed escapes (`w`/`W`/`-f`/attached
|
|
1097
|
+
* forms/quote joins) show a token parser cannot close it. sed reads are a
|
|
1098
|
+
* nice-to-have — `cat`/`grep`/`awk` cover them.
|
|
1099
|
+
* `awk`/`gawk`/`mawk` are denied every script file form (`-f`/`--file`,
|
|
1100
|
+
* attached or separate — the script may contain `system(...)`/file
|
|
1101
|
+
* redirects); `-F` (field separator, read-only) stays allowed.
|
|
1102
|
+
* - `tsc` with `--noEmit` (bare `tsc` can emit build artifacts).
|
|
1103
|
+
* - `git` with a read-only subcommand (`status log diff show branch rev-parse
|
|
1104
|
+
* merge-base remote ls-files blame shortlog describe check-ignore name-rev
|
|
1105
|
+
* stash grep tag`); `git stash` only as `git stash list`; the mutable
|
|
1106
|
+
* listing subcommands (`branch tag remote`) are bare or one of their
|
|
1107
|
+
* whitelisted read flags only — `branch` `-a -r -v -vv --all --remotes
|
|
1108
|
+
* --verbose --show-current -l --list --merged --no-merged --contains
|
|
1109
|
+
* --points-at --format --sort`, `tag` `-l --list --sort --contains
|
|
1110
|
+
* --points-at --merged --no-merged --format --column`, `remote` `-v
|
|
1111
|
+
* --verbose`. The value-taking flags (`--contains --points-at --merged
|
|
1112
|
+
* --no-merged --sort --format`) accept AT MOST ONE following value token
|
|
1113
|
+
* (a commit/tag name, a sort key, a format string — or glued
|
|
1114
|
+
* `--flag=value`; verified read-only in bash). Every other flag
|
|
1115
|
+
* (`-d -D -m -c -f -a -s ...`) is denied, a trailing NAME after a value
|
|
1116
|
+
* is denied (it would CREATE a branch/tag), and non-listed subcommands
|
|
1117
|
+
* (`config`, `var`, `push`, `commit`, `checkout`, `stash push`, ...) are
|
|
1118
|
+
* denied outright.
|
|
1119
|
+
* - git exec-trigger flags are denied on every allowlisted subcommand
|
|
1120
|
+
* (FINDING 2, round 6): `grep --open-files-in-pager[=<pager>]`/`-O[<pager>]`
|
|
1121
|
+
* executes the pager with each matched file (`sh` executes the file —
|
|
1122
|
+
* bash-verified), `log/diff/show --ext-diff` runs repo gitattributes
|
|
1123
|
+
* external diff drivers, `log/diff/show/blame/grep --textconv` runs
|
|
1124
|
+
* repo-configured textconv drivers, `--show-signature` runs gpg
|
|
1125
|
+
* (core.gpg.program), `--remerge-diff` runs the merge machinery
|
|
1126
|
+
* (external merge drivers). `-O` on log/diff/show is `--diff-order`
|
|
1127
|
+
* (a read flag) and stays allowed; `--no-ext-diff`/`--no-textconv`
|
|
1128
|
+
* disable the drivers and stay allowed. Global `-p`/`--paginate` (before
|
|
1129
|
+
* the subcommand) are already denied by the subcommand-position rule;
|
|
1130
|
+
* `git log -p` is `--patch` (read-only) and stays allowed.
|
|
1131
|
+
* - `git --no-pager <sub>` (global pager-disable, BEFORE the subcommand) is
|
|
1132
|
+
* allowed and behaves exactly like `git <sub>` for every rule below — it
|
|
1133
|
+
* never lifts a mutable/exec deny (FINDING 3, round 7). Combined
|
|
1134
|
+
* read-only short flags (`-av`, `-ar`, `-avv` on `branch` — every char
|
|
1135
|
+
* from the per-subcommand read set `branch: a r v l`, `tag: l`, `remote:
|
|
1136
|
+
* v`) are allowed on the mutable listing subcommands; a combined form
|
|
1137
|
+
* containing any write char (`git tag -av` creates an annotated tag,
|
|
1138
|
+
* `git branch -adv` deletes) is denied (FINDING 4, round 7).
|
|
1139
|
+
* - A test runner head (`bun|npm|pnpm|yarn|npx`) with one of the test/check/
|
|
1140
|
+
* lint/typecheck/verify/validate verbs (`vitest jest mocha` are runner
|
|
1141
|
+
* verbs too, e.g. `npx jest`; `tsc` as a runner verb requires `--noEmit`).
|
|
1142
|
+
* The FULL enumerated runner-write surface is denied on every runner verb
|
|
1143
|
+
* (FINDING 1, round 7, bash-verified): `--fix*` (lint autofix), `--write*`
|
|
1144
|
+
* (prettier), `--update*`/`-u` (snapshot updates — `bun test -u` rewrote
|
|
1145
|
+
* the snapshot, verified), `-w` (write-capable short form in some runners;
|
|
1146
|
+
* vitest/mocha watch is over-denied — the `--watch` long form stays
|
|
1147
|
+
* allowed), `--coverage*` plus camelCase `--collectCoverage*` AND kebab
|
|
1148
|
+
* `--collect-coverage*` (all write coverage/), `--outputFile`/`=` (jest
|
|
1149
|
+
* JSON report — bash-verified, also
|
|
1150
|
+
* denied globally as an output flag), `--cache*` (eslint/jest cache files;
|
|
1151
|
+
* `--cache=false` is read-only but over-denied — the coordinator never
|
|
1152
|
+
* needs cache control), and the tsc build-info flags (`-b`/`--build`,
|
|
1153
|
+
* `--incremental`, `--tsBuildInfoFile`, `--composite` — they write
|
|
1154
|
+
* .tsbuildinfo/outputs even with `--noEmit`). Matching is
|
|
1155
|
+
* case-insensitive-prefix on lowercased tokens (camelCase cannot dodge
|
|
1156
|
+
* the prefixes). Direct-head tools (`jest vitest eslint prettier oxlint
|
|
1157
|
+
* oxfmt mocha ...`) are NOT allowlisted at all — head denial, so their
|
|
1158
|
+
* write flags never reach the runner rules.
|
|
1159
|
+
* - `command` is allowed ONLY as `command -v <name>` / `command -V <name>`
|
|
1160
|
+
* (path lookup — read-only); bare `command` EXECUTES and is denied.
|
|
1161
|
+
* `test`/`[` evaluate expressions only and are read-only (FINDING 3,
|
|
1162
|
+
* round 4).
|
|
1163
|
+
*
|
|
1164
|
+
* Test runner flags write nothing — exact statement (FINDING 1, round 7 +
|
|
1165
|
+
* round 8): the allowed verbs are `test check lint typecheck verify validate`
|
|
1166
|
+
* (plus the `vitest jest mocha` runner verbs and `tsc --noEmit`), and every
|
|
1167
|
+
* write-capable runner flag family is denied (snapshot updates
|
|
1168
|
+
* `-u`/`--update*`, autofix `--fix*`, `--write*`, `-w`, coverage
|
|
1169
|
+
* `--coverage*`/`--collectCoverage*`/`--collect-coverage*` (camel AND kebab),
|
|
1170
|
+
* jest JSON reports `--outputFile`, caches `--cache*`, tsc build-info
|
|
1171
|
+
* `-b`/`--incremental`/`--tsBuildInfoFile`/`--composite`), so no RUNNER
|
|
1172
|
+
* WRITE FLAG inside the boundary can write a file — that is the exact scope
|
|
1173
|
+
* of this claim. A test run itself can still write by design (inherent
|
|
1174
|
+
* allowance, stated here as the documented boundary): a first-run jest
|
|
1175
|
+
* creates new `__snapshots__` WITHOUT `-u`, and test code runs with
|
|
1176
|
+
* coordinator permissions — the test runner is admitted to the boundary as
|
|
1177
|
+
* a runner, not sandboxed. `bun run format` (writes) and `bun run build`
|
|
1178
|
+
* (dist) are NOT allowed. This allowlist is an audited security boundary (asserted by the
|
|
1179
|
+
* adversarial table). The EXACT deny statement (FINDING 3, round 6 — the old
|
|
1180
|
+
* "every WRITE form of the listed verbs" claim was false while `date -s` and
|
|
1181
|
+
* `sort -T` were unguarded): redirections, pipes, chains, substitution,
|
|
1182
|
+
* parens; `sed` denied outright (round 5 — its `e`/`s///e` commands execute);
|
|
1183
|
+
* awk/gawk/mawk `-f`/`--file` script files; `--output`/`--output-file`/`-o`
|
|
1184
|
+
* on output-flag verbs; `--compress-program` on EVERY verb (executes PROG
|
|
1185
|
+
* with data on stdin — `sh` runs sorted data as a script, bash-verified,
|
|
1186
|
+
* round 6); git mutable-subcommand mutations AND git exec-trigger flags
|
|
1187
|
+
* (`--open-files-in-pager`/`-O` on grep — executes the pager on matched
|
|
1188
|
+
* files, bash-verified; `--ext-diff`; `--textconv`; `--show-signature`;
|
|
1189
|
+
* `--remerge-diff`; global `-p`/`--paginate` denied by the subcommand-
|
|
1190
|
+
* position rule); `date -s`/`--set` (system-clock mutation, round 6);
|
|
1191
|
+
* `sort -T`/`--temporary-directory` (writes sort's temporaries into the
|
|
1192
|
+
* given directory, round 6); find's delete/exec/fprint family; runner
|
|
1193
|
+
* mutating flags (round 7: `-u`/`-w`/`--cache*`/`--collectCoverage*`/
|
|
1194
|
+
* `--outputFile`/tsc build-info added to the `--fix`/`--write`/`--coverage`/
|
|
1195
|
+
* `--update` families); `command` without `-v`/`-V` — and every head not
|
|
1196
|
+
* listed above is denied outright (unlisted write/exec/network commands
|
|
1197
|
+
* never enter the allowlist at all).
|
|
1198
|
+
*/
|
|
1199
|
+
const BASH_READ_TOKENS = new Set([
|
|
1200
|
+
"cat",
|
|
1201
|
+
"head",
|
|
1202
|
+
"tail",
|
|
1203
|
+
"less",
|
|
1204
|
+
"more",
|
|
1205
|
+
"grep",
|
|
1206
|
+
"rg",
|
|
1207
|
+
"ag",
|
|
1208
|
+
"find",
|
|
1209
|
+
"ls",
|
|
1210
|
+
"stat",
|
|
1211
|
+
"wc",
|
|
1212
|
+
"file",
|
|
1213
|
+
"diff",
|
|
1214
|
+
"sort",
|
|
1215
|
+
"uniq",
|
|
1216
|
+
"cut",
|
|
1217
|
+
"tr",
|
|
1218
|
+
"fold",
|
|
1219
|
+
"printf",
|
|
1220
|
+
"echo",
|
|
1221
|
+
"pwd",
|
|
1222
|
+
"date",
|
|
1223
|
+
"which",
|
|
1224
|
+
"type",
|
|
1225
|
+
"du",
|
|
1226
|
+
"df",
|
|
1227
|
+
"tree",
|
|
1228
|
+
"jq",
|
|
1229
|
+
"basename",
|
|
1230
|
+
"dirname",
|
|
1231
|
+
"realpath",
|
|
1232
|
+
"readlink",
|
|
1233
|
+
"rev",
|
|
1234
|
+
"comm",
|
|
1235
|
+
"paste",
|
|
1236
|
+
"nl",
|
|
1237
|
+
"od",
|
|
1238
|
+
"xxd",
|
|
1239
|
+
"awk",
|
|
1240
|
+
"gawk",
|
|
1241
|
+
"mawk",
|
|
1242
|
+
"test",
|
|
1243
|
+
"[",
|
|
1244
|
+
]);
|
|
1245
|
+
|
|
1246
|
+
const BASH_GIT_READ_SUBCOMMANDS = new Set([
|
|
1247
|
+
"status",
|
|
1248
|
+
"log",
|
|
1249
|
+
"diff",
|
|
1250
|
+
"show",
|
|
1251
|
+
"branch",
|
|
1252
|
+
"rev-parse",
|
|
1253
|
+
"merge-base",
|
|
1254
|
+
"remote",
|
|
1255
|
+
"ls-files",
|
|
1256
|
+
"blame",
|
|
1257
|
+
"shortlog",
|
|
1258
|
+
"describe",
|
|
1259
|
+
"check-ignore",
|
|
1260
|
+
"name-rev",
|
|
1261
|
+
"stash",
|
|
1262
|
+
"grep",
|
|
1263
|
+
"tag",
|
|
1264
|
+
]);
|
|
1265
|
+
|
|
1266
|
+
const BASH_GIT_MUTABLE_SUBCOMMANDS = new Set(["branch", "remote", "tag"]);
|
|
1267
|
+
|
|
1268
|
+
// Exact read-only flag forms per mutable git subcommand: every other flag or
|
|
1269
|
+
// argument (a branch/tag name, `-d -D -m -c -f -a -s ...`) is a write and is
|
|
1270
|
+
// denied (FINDING 1).
|
|
1271
|
+
const BASH_GIT_READ_FLAGS: Record<string, Set<string>> = {
|
|
1272
|
+
branch: new Set([
|
|
1273
|
+
"-a",
|
|
1274
|
+
"--all",
|
|
1275
|
+
"-r",
|
|
1276
|
+
"--remotes",
|
|
1277
|
+
"-v",
|
|
1278
|
+
"--verbose",
|
|
1279
|
+
"-vv",
|
|
1280
|
+
"--show-current",
|
|
1281
|
+
"-l",
|
|
1282
|
+
"--list",
|
|
1283
|
+
"--merged",
|
|
1284
|
+
"--no-merged",
|
|
1285
|
+
"--contains",
|
|
1286
|
+
"--points-at",
|
|
1287
|
+
"--format",
|
|
1288
|
+
"--sort",
|
|
1289
|
+
]),
|
|
1290
|
+
tag: new Set([
|
|
1291
|
+
"-l",
|
|
1292
|
+
"--list",
|
|
1293
|
+
"--sort",
|
|
1294
|
+
"--contains",
|
|
1295
|
+
"--points-at",
|
|
1296
|
+
"--merged",
|
|
1297
|
+
"--no-merged",
|
|
1298
|
+
"--format",
|
|
1299
|
+
"--column",
|
|
1300
|
+
]),
|
|
1301
|
+
remote: new Set(["-v", "--verbose"]),
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// Read-only SHORT flags per mutable git subcommand (FINDING 4, round 7):
|
|
1305
|
+
// `branch` `-a -r -v -l` (+ `-vv` = `-v -v`), `tag` `-l` ONLY (`-a` creates
|
|
1306
|
+
// an annotated tag), `remote` `-v` ONLY. Git combines short flags into one
|
|
1307
|
+
// token (`-av` = `-a -v`), so a single-dash all-letter token is allowed iff
|
|
1308
|
+
// EVERY character is a read char for that subcommand — any write char
|
|
1309
|
+
// (`git tag -av` → `-a` creates; `git branch -adv` → `-d` deletes) denies.
|
|
1310
|
+
const BASH_GIT_READ_SHORT_FLAGS: Record<string, string> = {
|
|
1311
|
+
branch: "arvl",
|
|
1312
|
+
tag: "l",
|
|
1313
|
+
remote: "v",
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
const isCombinedReadShortFlag = (sub: string, token: string): boolean => {
|
|
1317
|
+
if (!/^-[a-z]+$/.test(token)) return false;
|
|
1318
|
+
const allowed = BASH_GIT_READ_SHORT_FLAGS[sub] ?? "";
|
|
1319
|
+
for (let i = 1; i < token.length; i++) {
|
|
1320
|
+
if (!allowed.includes(token[i])) return false;
|
|
1321
|
+
}
|
|
1322
|
+
return true;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// find's destructive and file-writing forms: `-delete` deletes, `-exec/
|
|
1326
|
+
// -execdir/-ok/-okdir` execute arbitrary commands, `-fprint/-fprintf/
|
|
1327
|
+
// -fprint0/-fls` write files (FINDING 1).
|
|
1328
|
+
const BASH_FIND_DENIED_FLAGS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir"]);
|
|
1329
|
+
|
|
1330
|
+
const BASH_FIND_DENIED_PREFIXES = ["-fprint", "-fls"];
|
|
1331
|
+
|
|
1332
|
+
const BASH_TEST_RUNNERS = ["bun", "npm", "pnpm", "yarn", "npx"];
|
|
1333
|
+
|
|
1334
|
+
const BASH_TEST_VERBS = new Set([
|
|
1335
|
+
"test",
|
|
1336
|
+
"check",
|
|
1337
|
+
"lint",
|
|
1338
|
+
"typecheck",
|
|
1339
|
+
"verify",
|
|
1340
|
+
"validate",
|
|
1341
|
+
"vitest",
|
|
1342
|
+
"jest",
|
|
1343
|
+
"mocha",
|
|
1344
|
+
]);
|
|
1345
|
+
|
|
1346
|
+
// Mutating flag families on test/lint verbs — the FULL enumerated
|
|
1347
|
+
// runner-write surface (FINDING 1, round 7): `--fix*` (lint autofix),
|
|
1348
|
+
// `--write*` (prettier), `--update*`/`-u` (snapshot updates — `bun test -u`,
|
|
1349
|
+
// `npm test -u`, `jest -u`/`--updateSnapshot`), `-w` (write-capable short
|
|
1350
|
+
// form in some runners; vitest/mocha watch is over-denied — the `--watch`
|
|
1351
|
+
// long form stays allowed), `--coverage*` plus the camelCase
|
|
1352
|
+
// `--collectCoverage*` AND the kebab `--collect-coverage*` (all write
|
|
1353
|
+
// coverage/ — the kebab form escapes the camelCase entry because the dash
|
|
1354
|
+
// breaks the prefix match, FINDING 1, round 8), `--cache*` (eslint/jest
|
|
1355
|
+
// cache files; `--cache=false` is read-only but over-denied — the
|
|
1356
|
+
// coordinator never needs cache control). Matching is
|
|
1357
|
+
// case-insensitive-prefix on lowercased tokens so camelCase spellings cannot
|
|
1358
|
+
// dodge the prefixes.
|
|
1359
|
+
const BASH_MUTATING_TEST_FLAGS = [
|
|
1360
|
+
"--fix",
|
|
1361
|
+
"--write",
|
|
1362
|
+
"--update",
|
|
1363
|
+
"-u",
|
|
1364
|
+
"-w",
|
|
1365
|
+
"--coverage",
|
|
1366
|
+
"--collectcoverage",
|
|
1367
|
+
"--collect-coverage",
|
|
1368
|
+
"--cache",
|
|
1369
|
+
];
|
|
1370
|
+
|
|
1371
|
+
// Privilege/network/tee-write heads: denied ONLY as the first token, so the
|
|
1372
|
+
// words themselves stay legal as argument text (FINDING 5).
|
|
1373
|
+
const BASH_DENIED_HEADS = new Set(["curl", "sudo", "tee", "wget"]);
|
|
1374
|
+
|
|
1375
|
+
// Write-capable -o/-oFILE/--output/--output=FILE on allowlisted read verbs
|
|
1376
|
+
// (FINDING 1). grep/rg keep `-o` (read-only match printing); find keeps `-o`
|
|
1377
|
+
// (logical OR). `--output` is denied globally for every command. jq `-o` is
|
|
1378
|
+
// `--output-file` (round 3 audit — same write class as sort -o).
|
|
1379
|
+
const BASH_OUTPUT_FLAG_VERBS = new Set(["sort", "tree", "comm", "diff", "jq"]);
|
|
1380
|
+
|
|
1381
|
+
const BASH_FORBIDDEN_FRAGMENTS = [">", "|", "&", ";", "$(", "${", "$'", "`", "\n", "<("];
|
|
1382
|
+
|
|
1383
|
+
// Pure-stdout verbs exempt from the per-token paren denial, but only for
|
|
1384
|
+
// parens inside a fully-quoted token (display text). Unquoted parens stay
|
|
1385
|
+
// denied. jq qualifies: its only write paths (`-o`/`--output-file`) are
|
|
1386
|
+
// denied separately. awk is NOT exempt (`system(...)` executes). (FINDING 3,
|
|
1387
|
+
// round 5)
|
|
1388
|
+
const BASH_PAREN_EXEMPT_HEADS = new Set(["echo", "printf", "jq"]);
|
|
1389
|
+
|
|
1390
|
+
// Value-taking READ flags on the mutable git listing subcommands (FINDING 3,
|
|
1391
|
+
// round 4): `--contains|--points-at|--merged|--no-merged [<commit>]`,
|
|
1392
|
+
// `--sort <key>`, `--format <format>` — each takes AT MOST ONE following
|
|
1393
|
+
// value token (the value may also be glued: `--sort=-x`, `--format='%(x)'`).
|
|
1394
|
+
// The value is display/list filtering only — verified read-only in bash. A
|
|
1395
|
+
// trailing NAME after a value would create a branch/tag (`git branch --sort=
|
|
1396
|
+
// -x y`, `git tag --format=x y` both create), so anything that is neither a
|
|
1397
|
+
// whitelisted flag nor the single value of a value flag is denied.
|
|
1398
|
+
const BASH_GIT_VALUE_FLAGS = new Set([
|
|
1399
|
+
"--contains",
|
|
1400
|
+
"--points-at",
|
|
1401
|
+
"--merged",
|
|
1402
|
+
"--no-merged",
|
|
1403
|
+
"--sort",
|
|
1404
|
+
"--format",
|
|
1405
|
+
]);
|
|
1406
|
+
|
|
1407
|
+
const gitGluedValueFlag = (token: string): boolean =>
|
|
1408
|
+
/^--(contains|points-at|merged|no-merged|sort|format)=.+/.test(token);
|
|
1409
|
+
|
|
1410
|
+
const hasMutatingTestFlag = (tokens: string[]): boolean => {
|
|
1411
|
+
const lower = tokens.map((t) => t.toLowerCase());
|
|
1412
|
+
return lower.some((t) => BASH_MUTATING_TEST_FLAGS.some((flag) => t.startsWith(flag)));
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
// Script FILE forms for awk/gawk/mawk (FINDING 2/3, round 3): the option may
|
|
1416
|
+
// carry its value ATTACHED (`-fscript.awk`, `-f/tmp/evil.awk`, `--file=x`) —
|
|
1417
|
+
// GNU awk accepts the attached short-option form, so any token starting with
|
|
1418
|
+
// `-f`/`--file` is a script file. The script may contain `system(...)`/
|
|
1419
|
+
// redirections. `-F` (awk field separator, read-only, uppercase) is NOT
|
|
1420
|
+
// matched. sed is not allowlisted at all (round 5), so no sed -f rule exists.
|
|
1421
|
+
const scriptFileForm = (token: string): boolean =>
|
|
1422
|
+
token === "-f" || token.startsWith("-f") || token.startsWith("--file");
|
|
1423
|
+
|
|
1424
|
+
const findDenied = (token: string): boolean =>
|
|
1425
|
+
BASH_FIND_DENIED_FLAGS.has(token) ||
|
|
1426
|
+
BASH_FIND_DENIED_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
1427
|
+
|
|
1428
|
+
// Any command may write via --output/--output=FILE (git log/diff, sort, ...)
|
|
1429
|
+
// or --output-file/--output-file=FILE (jq). `--outputFile`/`--outputFile=`
|
|
1430
|
+
// is jest's JSON-report flag (writes the report file — bash-verified,
|
|
1431
|
+
// FINDING 1, round 7: `npx jest --json --outputFile=out.json` created the
|
|
1432
|
+
// file); it is denied globally for the same reason as `--output-file`.
|
|
1433
|
+
const outputFlagDenied = (token: string): boolean =>
|
|
1434
|
+
token === "--output" ||
|
|
1435
|
+
token.startsWith("--output=") ||
|
|
1436
|
+
token === "--output-file" ||
|
|
1437
|
+
token.startsWith("--output-file=") ||
|
|
1438
|
+
token === "--outputFile" ||
|
|
1439
|
+
token.startsWith("--outputFile=");
|
|
1440
|
+
|
|
1441
|
+
// `--compress-program` (GNU sort; any verb — global deny) EXECUTES PROG with
|
|
1442
|
+
// the sorted data on its stdin: `sh` runs the data as a script (bash-verified,
|
|
1443
|
+
// FINDING 1, round 6: `sort --buffer-size=1M --compress-program=sh` created
|
|
1444
|
+
// PWNED_COMPRESS). The space form dies at the flag token; the `=` form here.
|
|
1445
|
+
const compressProgramDenied = (token: string): boolean =>
|
|
1446
|
+
token === "--compress-program" || token.startsWith("--compress-program=");
|
|
1447
|
+
|
|
1448
|
+
// git flags that TRIGGER external program execution (FINDING 2, round 6):
|
|
1449
|
+
// `grep --open-files-in-pager[=<pager>]` and its short form `-O[<pager>]`
|
|
1450
|
+
// open each matched file with a pager — `sh` executes the file (bash-verified:
|
|
1451
|
+
// `git grep --open-files-in-pager=sh -e x -- f` and `git grep -Osh` both
|
|
1452
|
+
// created GITPWNED files); `log/diff/show --ext-diff` runs repo gitattributes
|
|
1453
|
+
// external diff drivers; `log/diff/show/blame/grep --textconv` runs
|
|
1454
|
+
// repo-configured textconv drivers; `--show-signature` runs gpg
|
|
1455
|
+
// (core.gpg.program); `--remerge-diff` runs the merge machinery on merge
|
|
1456
|
+
// commits (external merge drivers) — same driver-execution class, denied
|
|
1457
|
+
// fail-closed. `-O` on grep is open-files-in-pager, but `-O` on log/diff/show
|
|
1458
|
+
// is `--diff-order=<orderfile>` (a read flag) — the short form is scoped to
|
|
1459
|
+
// grep. `--no-ext-diff`/`--no-textconv` DISABLE the drivers and stay allowed.
|
|
1460
|
+
// Global `-p`/`--paginate` (before the subcommand) never reach this check —
|
|
1461
|
+
// the subcommand-position rule already denies them (pinned in the matrix).
|
|
1462
|
+
const gitExecFlagDenied = (sub: string, token: string): boolean => {
|
|
1463
|
+
if (sub === "grep" && (token === "-O" || token.startsWith("-O"))) return true;
|
|
1464
|
+
if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) return true;
|
|
1465
|
+
if (token === "--ext-diff" || token.startsWith("--ext-diff=")) return true;
|
|
1466
|
+
if (token === "--textconv" || token.startsWith("--textconv=")) return true;
|
|
1467
|
+
if (token === "--show-signature" || token.startsWith("--show-signature=")) return true;
|
|
1468
|
+
if (token === "--remerge-diff" || token.startsWith("--remerge-diff=")) return true;
|
|
1469
|
+
return false;
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// `date -s`/`--set` (and attached `-sVALUE`, `--set=VALUE`) MUTATE the system
|
|
1473
|
+
// clock (bash-verified: `date -s` attempts the set — "cannot set date:
|
|
1474
|
+
// Operation not permitted", FINDING 3, round 6). No other GNU date flag
|
|
1475
|
+
// starts with `-s`; `-d`/`--date` (display) stays allowed.
|
|
1476
|
+
const dateSetDenied = (token: string): boolean =>
|
|
1477
|
+
token === "-s" || token.startsWith("-s") || token === "--set" || token.startsWith("--set=");
|
|
1478
|
+
|
|
1479
|
+
// `sort -T`/`--temporary-directory` writes sort's own temp files into an
|
|
1480
|
+
// arbitrary directory (bash/strace-verified: `sort -T <dir>` created
|
|
1481
|
+
// sortGdvlHf, sortV2VyNF, ..., FINDING 4, round 6). `-t:` (field separator,
|
|
1482
|
+
// lowercase) is NOT matched. The space form dies at the flag token.
|
|
1483
|
+
const sortTempDirDenied = (token: string): boolean =>
|
|
1484
|
+
token === "-T" ||
|
|
1485
|
+
token.startsWith("-T") ||
|
|
1486
|
+
token === "--temporary-directory" ||
|
|
1487
|
+
token.startsWith("--temporary-directory=");
|
|
1488
|
+
|
|
1489
|
+
// tsc build-info flags (FINDING 1, round 7): `-b`/`--build` (build mode
|
|
1490
|
+
// writes outputs), and `--incremental`/`--tsBuildInfoFile`/`--composite`
|
|
1491
|
+
// write `.tsbuildinfo` even WITH `--noEmit` — so `--noEmit` alone is not a
|
|
1492
|
+
// sufficient read guarantee. Denied on BOTH the direct `tsc` head and the
|
|
1493
|
+
// runner verb (`bun run tsc`); `tsc --noEmit` remains the only admitted form.
|
|
1494
|
+
const tsBuildDenied = (token: string): boolean =>
|
|
1495
|
+
token === "-b" ||
|
|
1496
|
+
token.startsWith("-b") ||
|
|
1497
|
+
token === "--build" ||
|
|
1498
|
+
token.startsWith("--build=") ||
|
|
1499
|
+
token === "--incremental" ||
|
|
1500
|
+
token.startsWith("--incremental=") ||
|
|
1501
|
+
token === "--tsBuildInfoFile" ||
|
|
1502
|
+
token.startsWith("--tsBuildInfoFile=") ||
|
|
1503
|
+
token === "--composite" ||
|
|
1504
|
+
token.startsWith("--composite=");
|
|
1505
|
+
|
|
1506
|
+
export const isCoordinatorBashAllowed = (command: string): boolean => {
|
|
1507
|
+
const trimmed = command.trim();
|
|
1508
|
+
if (!trimmed) return false;
|
|
1509
|
+
for (const fragment of BASH_FORBIDDEN_FRAGMENTS) {
|
|
1510
|
+
if (trimmed.includes(fragment)) return false;
|
|
1511
|
+
}
|
|
1512
|
+
// FINDING 2 (round 5): the shell's word parsing REMOVES every quote
|
|
1513
|
+
// character when building argv — `'w'out` IS `wout`, `--out'put=x'` IS
|
|
1514
|
+
// `--output=x`, `-de'lete'` IS `-delete`, `awk -'f x'` IS `awk -f x`.
|
|
1515
|
+
// Strip ALL `'`/`"` from each token before every check so mid-token
|
|
1516
|
+
// quote joins cannot smuggle a deny-listed flag past the rules. Stripping
|
|
1517
|
+
// only removes characters, so a deny rule can never be evaded by it.
|
|
1518
|
+
const unquote = (token: string): string => token.replace(/['"]/g, "");
|
|
1519
|
+
const rawTokens = trimmed.split(/\s+/);
|
|
1520
|
+
const tokens = rawTokens.map(unquote);
|
|
1521
|
+
const head = tokens[0] ?? "";
|
|
1522
|
+
// `(`/`)` are denied per-token (process substitution `<(`, `>(`, subshells
|
|
1523
|
+
// `(cmd)`, and `awk system(...)` all need them) — EXCEPT as git `--format`
|
|
1524
|
+
// placeholders (`--format='%(refname)'`): the shell has already consumed
|
|
1525
|
+
// the quotes, so a `--format` value is display text, and `$(`/`<(`/`>`/
|
|
1526
|
+
// backticks are denied raw regardless (FINDING 3, round 4). A value token
|
|
1527
|
+
// AFTER a bare `--format` is likewise display text. Pure-stdout verbs
|
|
1528
|
+
// (`echo printf jq`) may print parens as display text: a shell-quote-state
|
|
1529
|
+
// scan of the RAW command allows the command iff every paren lies inside a
|
|
1530
|
+
// quoted region; any unquoted paren (subshell/syntax forms — bash-verified
|
|
1531
|
+
// syntax errors) denies the whole command, fail-closed. jq's only write
|
|
1532
|
+
// paths (`-o`/`--output-file`) are denied separately (FINDING 3, round 5).
|
|
1533
|
+
const parenExempt = BASH_PAREN_EXEMPT_HEADS.has(head);
|
|
1534
|
+
let parensSafe = true;
|
|
1535
|
+
if (parenExempt) {
|
|
1536
|
+
let state = 0; // 0 = unquoted, 1 = '...', 2 = "..."
|
|
1537
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
1538
|
+
const ch = trimmed[i];
|
|
1539
|
+
if (state === 0) {
|
|
1540
|
+
if (ch === "'") state = 1;
|
|
1541
|
+
else if (ch === '"') state = 2;
|
|
1542
|
+
else if (ch === "(" || ch === ")") parensSafe = false;
|
|
1543
|
+
} else if (state === 1) {
|
|
1544
|
+
if (ch === "'") state = 0;
|
|
1545
|
+
} else if (ch === "\\") {
|
|
1546
|
+
i++; // escaped char inside "..."
|
|
1547
|
+
} else if (ch === '"') {
|
|
1548
|
+
state = 0;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
let formatValue = false;
|
|
1553
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1554
|
+
const t = tokens[i];
|
|
1555
|
+
if (formatValue) {
|
|
1556
|
+
formatValue = false;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
if (t.includes("(") || t.includes(")")) {
|
|
1560
|
+
if (parenExempt && parensSafe) continue;
|
|
1561
|
+
if (t.startsWith("--format=") && !t.includes("<(")) continue;
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
formatValue = t === "--format";
|
|
1565
|
+
}
|
|
1566
|
+
if (BASH_DENIED_HEADS.has(head)) return false;
|
|
1567
|
+
if (tokens.some(outputFlagDenied) || tokens.some(compressProgramDenied)) return false;
|
|
1568
|
+
if (head === "git") {
|
|
1569
|
+
// FINDING 3 (round 7): `--no-pager` is a GLOBAL pager-disable that sits
|
|
1570
|
+
// BEFORE the subcommand (`git --no-pager log ...`) — read-only, the
|
|
1571
|
+
// exact counterpart of the already-allowed post-subcommand form. It only
|
|
1572
|
+
// shifts the subcommand position; every rule below (exec flags, mutable
|
|
1573
|
+
// subcommands, stash list, value walk) still applies to the real
|
|
1574
|
+
// subcommand. `git --no-pager` alone (no subcommand) falls through to
|
|
1575
|
+
// the subcommand-position deny.
|
|
1576
|
+
let subIndex = 1;
|
|
1577
|
+
if (tokens[1] === "--no-pager") subIndex = 2;
|
|
1578
|
+
const sub = tokens[subIndex] ?? "";
|
|
1579
|
+
const flagTokens = tokens.slice(subIndex + 1);
|
|
1580
|
+
if (flagTokens.some((t) => gitExecFlagDenied(sub, t))) return false;
|
|
1581
|
+
if (sub === "stash") return flagTokens[0] === "list";
|
|
1582
|
+
if (!BASH_GIT_READ_SUBCOMMANDS.has(sub)) return false;
|
|
1583
|
+
if (!BASH_GIT_MUTABLE_SUBCOMMANDS.has(sub)) return true;
|
|
1584
|
+
if (flagTokens.length === 0) return true; // bare listing (`git branch`)
|
|
1585
|
+
const flags = BASH_GIT_READ_FLAGS[sub];
|
|
1586
|
+
if (!flags) return false;
|
|
1587
|
+
// exact read flags, plus AT MOST ONE value after each value-taking flag,
|
|
1588
|
+
// plus combined read-only short flags (FINDING 4, round 7)
|
|
1589
|
+
let valuePending = false;
|
|
1590
|
+
for (const t of flagTokens) {
|
|
1591
|
+
if (flags.has(t) || gitGluedValueFlag(t)) {
|
|
1592
|
+
valuePending = BASH_GIT_VALUE_FLAGS.has(t);
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (valuePending) {
|
|
1596
|
+
valuePending = false;
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
if (isCombinedReadShortFlag(sub, t)) continue;
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1604
|
+
if (head === "command") {
|
|
1605
|
+
// `command` EXECUTES its argument; only `command -v`/`-V` (lookup) is
|
|
1606
|
+
// read-only — exactly one name, no more (FINDING 3, round 4).
|
|
1607
|
+
return (tokens[1] === "-v" || tokens[1] === "-V") && tokens.length === 3;
|
|
1608
|
+
}
|
|
1609
|
+
if (head === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
|
|
1610
|
+
if (BASH_READ_TOKENS.has(head)) {
|
|
1611
|
+
if (head === "find") return !tokens.some(findDenied);
|
|
1612
|
+
// awk/gawk/mawk: only the script-file form is denied (`-f`/`--file`,
|
|
1613
|
+
// attached or separate); `-F` (field separator) and reads stay allowed.
|
|
1614
|
+
if (head === "awk" || head === "gawk" || head === "mawk") {
|
|
1615
|
+
return !tokens.some(scriptFileForm);
|
|
1616
|
+
}
|
|
1617
|
+
// date: only `-s`/`--set` (clock mutation) is denied (FINDING 3, round 6).
|
|
1618
|
+
if (head === "date") return !tokens.some(dateSetDenied);
|
|
1619
|
+
// sort: `-T`/`--temporary-directory` (temp files in arbitrary dirs) is
|
|
1620
|
+
// denied; the `-o`/`--output` write forms are denied by the output-flag
|
|
1621
|
+
// check below (FINDING 4, round 6).
|
|
1622
|
+
if (head === "sort" && tokens.some(sortTempDirDenied)) return false;
|
|
1623
|
+
if (BASH_OUTPUT_FLAG_VERBS.has(head)) {
|
|
1624
|
+
return !tokens.some((t) => t === "-o" || t.startsWith("-o") || outputFlagDenied(t));
|
|
1625
|
+
}
|
|
1626
|
+
return true;
|
|
1627
|
+
}
|
|
1628
|
+
if (BASH_TEST_RUNNERS.includes(head)) {
|
|
1629
|
+
if (hasMutatingTestFlag(tokens)) return false;
|
|
1630
|
+
const verbIndex = tokens[1] === "run" ? 2 : 1;
|
|
1631
|
+
const verb = tokens[verbIndex] ?? "";
|
|
1632
|
+
if (verb === "tsc") return tokens.includes("--noEmit") && !tokens.some(tsBuildDenied);
|
|
1633
|
+
return BASH_TEST_VERBS.has(verb);
|
|
1634
|
+
}
|
|
1635
|
+
return false;
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
export const COORDINATOR_SHELL_DENIED_TEXT =
|
|
1639
|
+
"Coordinator shell commands are restricted while a subagent-driven plan is " +
|
|
1640
|
+
"active: only bounded read/test/review commands are allowed (the exact " +
|
|
1641
|
+
"allowlist is in flow-state.ts, isCoordinatorBashAllowed). " +
|
|
1642
|
+
COORDINATOR_RECOVERY_TEXT;
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* The plugin hook's decision function (AR-13): a delegated child session
|
|
1646
|
+
* (host parentage) is never intercepted; the root session is intercepted only
|
|
1647
|
+
* while at least one subagent-driven plan is active in its workspace. Returns
|
|
1648
|
+
* the denial error to throw from `tool.execute.before`, or `{ ok: true }`.
|
|
1649
|
+
*/
|
|
1650
|
+
export const subagentDrivenInterception = (input: {
|
|
1651
|
+
tool: string;
|
|
1652
|
+
command?: string;
|
|
1653
|
+
parentID?: string | null;
|
|
1654
|
+
active: boolean;
|
|
1655
|
+
}): FlowGateResult => {
|
|
1656
|
+
if (input.parentID) return { ok: true }; // delegated child — the worker
|
|
1657
|
+
if (!input.active) return { ok: true };
|
|
1658
|
+
if (COORDINATOR_WRITE_TOOLS.includes(input.tool)) {
|
|
1659
|
+
return err("coordinator_write_denied", COORDINATOR_RECOVERY_TEXT);
|
|
1660
|
+
}
|
|
1661
|
+
if (input.tool === "bash") {
|
|
1662
|
+
if (!input.command || !isCoordinatorBashAllowed(input.command)) {
|
|
1663
|
+
return err("coordinator_shell_denied", COORDINATOR_SHELL_DENIED_TEXT);
|
|
1664
|
+
}
|
|
223
1665
|
}
|
|
224
1666
|
return { ok: true };
|
|
225
1667
|
};
|