@brainervirus/workit-core 1.0.11 → 1.1.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 +1 -1
- package/src/core/authority.ts +84 -11
- package/src/core/auto-approval.ts +326 -0
- package/src/core/branch.ts +83 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +179 -17
- package/src/core/external-action.ts +204 -16
- package/src/core/methods.ts +8 -8
- package/src/core/pr-create.ts +50 -0
- package/src/core/route-intent.ts +7 -1
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +121 -0
- package/src/core/task-engine.ts +209 -223
- package/src/core/task-evaluation.ts +127 -22
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core/workspaces.ts +7 -1
- package/src/core.ts +29 -0
package/package.json
CHANGED
package/src/core/authority.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { operationAutoClass, standingApprovalLive } from "./auto-approval";
|
|
4
5
|
import {
|
|
5
6
|
canonicalJson,
|
|
6
7
|
decisionDigest,
|
|
@@ -181,12 +182,57 @@ export type ReconcileActionInput = {
|
|
|
181
182
|
|
|
182
183
|
type Workflow = { steps: string[]; completed: string[] };
|
|
183
184
|
|
|
185
|
+
/** One typed step of a chain reservation. Plain strings are commit
|
|
186
|
+
* messages (the historical plan_steps shape); branch and pr steps carry
|
|
187
|
+
* their match key. Keys are the canonical consumption alphabet. */
|
|
188
|
+
export type ChainStep =
|
|
189
|
+
| { kind: "commit"; message: string }
|
|
190
|
+
| { kind: "branch"; target: string }
|
|
191
|
+
| { kind: "pr" };
|
|
192
|
+
|
|
193
|
+
export const chainStepKey = (step: ChainStep): string =>
|
|
194
|
+
step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
|
|
195
|
+
|
|
196
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
197
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
|
+
|
|
199
|
+
/** Normalize mixed plan steps to typed entries. Returns null for empty,
|
|
200
|
+
* over-long, malformed, or key-colliding lists (a commit message that
|
|
201
|
+
* spells exactly like a typed key is rejected rather than misrouted). */
|
|
202
|
+
export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
|
|
203
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
|
|
204
|
+
const normalized: ChainStep[] = [];
|
|
205
|
+
for (const step of steps) {
|
|
206
|
+
if (typeof step === "string") {
|
|
207
|
+
if (!step.trim()) return null;
|
|
208
|
+
normalized.push({ kind: "commit", message: step });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!isRecord(step)) return null;
|
|
212
|
+
if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
|
|
213
|
+
normalized.push({ kind: "branch", target: step.branch });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (step.pr === true && Object.keys(step).length === 1) {
|
|
217
|
+
normalized.push({ kind: "pr" });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const keys = normalized.map(chainStepKey);
|
|
223
|
+
if (new Set(keys).size !== keys.length) return null;
|
|
224
|
+
return normalized;
|
|
225
|
+
};
|
|
226
|
+
|
|
184
227
|
const scopeEqual = (
|
|
185
228
|
left: Decision["binding"]["scope"],
|
|
186
229
|
right: Decision["binding"]["scope"],
|
|
187
230
|
): boolean => canonicalJson(left) === canonicalJson(right);
|
|
188
231
|
|
|
189
|
-
|
|
232
|
+
/** Parse the durable approved text into typed chain steps. Single commits
|
|
233
|
+
* and branch/PR operations carry no step list (empty, not an invalid
|
|
234
|
+
* chain); an invalid list is null and rejected by callers. */
|
|
235
|
+
const parseSteps = (content: string): ChainStep[] | null => {
|
|
190
236
|
let value: unknown;
|
|
191
237
|
try {
|
|
192
238
|
value = JSON.parse(content) as unknown;
|
|
@@ -195,7 +241,9 @@ const parseSteps = (content: string): string[] | null => {
|
|
|
195
241
|
}
|
|
196
242
|
if (typeof value !== "object" || value === null || !("steps" in value)) return [];
|
|
197
243
|
const steps = (value as { steps?: unknown }).steps;
|
|
198
|
-
|
|
244
|
+
if (!Array.isArray(steps)) return null;
|
|
245
|
+
const normalized = normalizeChainSteps(steps);
|
|
246
|
+
return normalized;
|
|
199
247
|
};
|
|
200
248
|
|
|
201
249
|
const decisionMatches = (
|
|
@@ -532,6 +580,36 @@ const validateAction = (
|
|
|
532
580
|
!provenanceMatches(entry.provenance, authority.provenance)
|
|
533
581
|
)
|
|
534
582
|
return failure("permission_denied", "action approval lacks native receipt assurance");
|
|
583
|
+
// Standing auto-approval receipts carry no live question. They authorize
|
|
584
|
+
// only while the referenced workspace rule still covers the operation —
|
|
585
|
+
// verified here, at reserve time, against current config. Question-backed
|
|
586
|
+
// approvals (host-kind receipts) skip this; anything else was already
|
|
587
|
+
// denied above.
|
|
588
|
+
const receipts = entry.provenance.receipts as unknown[];
|
|
589
|
+
const standing = receipts.filter(
|
|
590
|
+
(receipt) =>
|
|
591
|
+
typeof receipt === "object" &&
|
|
592
|
+
receipt !== null &&
|
|
593
|
+
(receipt as { kind?: unknown }).kind === "standing",
|
|
594
|
+
);
|
|
595
|
+
const questionBacked = receipts.some(
|
|
596
|
+
(receipt) =>
|
|
597
|
+
typeof receipt === "object" &&
|
|
598
|
+
receipt !== null &&
|
|
599
|
+
(receipt as { kind?: unknown }).kind === "host",
|
|
600
|
+
);
|
|
601
|
+
if (!questionBacked && standing.length > 0) {
|
|
602
|
+
let operation: unknown;
|
|
603
|
+
try {
|
|
604
|
+
operation = (JSON.parse(decision.binding.approvedContent) as { operation?: unknown })
|
|
605
|
+
.operation;
|
|
606
|
+
} catch {
|
|
607
|
+
operation = null;
|
|
608
|
+
}
|
|
609
|
+
const cls = operationAutoClass(operation);
|
|
610
|
+
if (!cls || !standing.some((receipt) => standingApprovalLive(store.root, receipt, cls)))
|
|
611
|
+
return failure("permission_denied", "standing auto-approval is not live for this operation");
|
|
612
|
+
}
|
|
535
613
|
if (decision.revoked) return failure("permission_denied", "decision is revoked");
|
|
536
614
|
if (decision.digest !== decisionDigest(decision))
|
|
537
615
|
return failure("permission_denied", "decision binding is invalid");
|
|
@@ -543,22 +621,17 @@ const validateAction = (
|
|
|
543
621
|
if (!content.ok) return content as Result<never>;
|
|
544
622
|
const requestedSteps = parseSteps(decision.binding.approvedContent);
|
|
545
623
|
if (requestedSteps === null)
|
|
546
|
-
return failure("invalid_input", "bounded action steps must be a
|
|
547
|
-
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
|
|
624
|
+
return failure("invalid_input", "bounded action steps must be a unique non-empty array");
|
|
625
|
+
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
|
|
548
626
|
return failure("permission_denied", "bounded action steps do not match approved content");
|
|
549
|
-
if (
|
|
550
|
-
requestedSteps.some((step) => !step || typeof step !== "string") ||
|
|
551
|
-
new Set(requestedSteps).size !== requestedSteps.length
|
|
552
|
-
)
|
|
553
|
-
return failure("invalid_input", "bounded action steps must be unique and non-empty");
|
|
554
627
|
const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
|
|
555
628
|
const workflow = stored
|
|
556
629
|
? { steps: stored.steps, completed: stored.completedSteps }
|
|
557
|
-
: { steps: requestedSteps, completed: [] };
|
|
630
|
+
: { steps: requestedSteps.map(chainStepKey), completed: [] };
|
|
558
631
|
if (
|
|
559
632
|
workflow.steps.length &&
|
|
560
633
|
requestedSteps.length &&
|
|
561
|
-
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
|
|
634
|
+
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
|
|
562
635
|
)
|
|
563
636
|
return failure("permission_denied", "bounded action scope changed");
|
|
564
637
|
return success(null, null, { entry, workflow });
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolveWorkspaceFrom, type WorkspaceConfig } from "./workspaces";
|
|
4
|
+
import { configDir } from "./config";
|
|
5
|
+
import { verifyPushIdentity } from "./branch";
|
|
6
|
+
import {
|
|
7
|
+
failure,
|
|
8
|
+
success,
|
|
9
|
+
type Caller,
|
|
10
|
+
type Id,
|
|
11
|
+
type Provenance,
|
|
12
|
+
type Result,
|
|
13
|
+
type Revision,
|
|
14
|
+
type TaskRecord,
|
|
15
|
+
} from "./task-contract";
|
|
16
|
+
import type { TaskStore } from "./task-store";
|
|
17
|
+
import type { WorkitCore } from "./task-engine";
|
|
18
|
+
import type { Decision } from "./task-contract";
|
|
19
|
+
|
|
20
|
+
/** Action classes auto-approval can cover. Unknown classes never match. */
|
|
21
|
+
export const AUTO_CLASSES = ["branch", "commit", "push", "pr", "merge"] as const;
|
|
22
|
+
export type AutoClass = (typeof AUTO_CLASSES)[number];
|
|
23
|
+
|
|
24
|
+
export type AutoApproval =
|
|
25
|
+
| { status: "off" }
|
|
26
|
+
| {
|
|
27
|
+
status: "on";
|
|
28
|
+
workspace: string;
|
|
29
|
+
classes: AutoClass[];
|
|
30
|
+
account: string | null;
|
|
31
|
+
configDigest: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type StandingReceipt = {
|
|
35
|
+
kind: "standing";
|
|
36
|
+
workspace: string;
|
|
37
|
+
class: string;
|
|
38
|
+
configDigest: string | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const OPERATION_CLASS: Record<string, AutoClass> = {
|
|
42
|
+
"git.branch_setup": "branch",
|
|
43
|
+
"git.commit": "commit",
|
|
44
|
+
"git.push": "push",
|
|
45
|
+
"hosting.pull_request": "pr",
|
|
46
|
+
"hosting.merge": "merge",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Map an external-action operation to its auto-approval class, if any. */
|
|
50
|
+
export const operationAutoClass = (operation: unknown): AutoClass | null =>
|
|
51
|
+
typeof operation === "string" ? (OPERATION_CLASS[operation] ?? null) : null;
|
|
52
|
+
|
|
53
|
+
const digestOf = (configDirPath: string): string | null => {
|
|
54
|
+
try {
|
|
55
|
+
const raw = readFileSync(`${configDirPath}/workspaces.json`, "utf8");
|
|
56
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const normalizeClasses = (value: unknown): AutoClass[] => {
|
|
63
|
+
if (value === true) return [...AUTO_CLASSES];
|
|
64
|
+
if (!Array.isArray(value)) return [];
|
|
65
|
+
const seen = new Set<AutoClass>();
|
|
66
|
+
for (const item of value) {
|
|
67
|
+
if (typeof item === "string" && (AUTO_CLASSES as readonly string[]).includes(item))
|
|
68
|
+
seen.add(item as AutoClass);
|
|
69
|
+
}
|
|
70
|
+
return [...seen];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Resolve the auto-approval rule for a checkout root (live read, never cached). */
|
|
74
|
+
export const resolveAutoApproval = (root: string, dir: string = configDir()): AutoApproval => {
|
|
75
|
+
let match: WorkspaceConfig | null;
|
|
76
|
+
try {
|
|
77
|
+
match = resolveWorkspaceFrom(root, dir);
|
|
78
|
+
} catch {
|
|
79
|
+
return { status: "off" };
|
|
80
|
+
}
|
|
81
|
+
if (!match) return { status: "off" };
|
|
82
|
+
const classes = normalizeClasses(match.autoApprove);
|
|
83
|
+
if (classes.length === 0) return { status: "off" };
|
|
84
|
+
return {
|
|
85
|
+
status: "on",
|
|
86
|
+
workspace: match.name,
|
|
87
|
+
classes,
|
|
88
|
+
account: typeof match.vcs?.account === "string" && match.vcs.account ? match.vcs.account : null,
|
|
89
|
+
configDigest: digestOf(dir),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** True when the workspace rule covers the class right now. */
|
|
94
|
+
export const autoApproves = (root: string, cls: unknown, dir?: string): boolean => {
|
|
95
|
+
if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
|
|
96
|
+
const resolved = resolveAutoApproval(root, dir);
|
|
97
|
+
return resolved.status === "on" && resolved.classes.includes(cls as AutoClass);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const isStandingReceipt = (value: unknown): value is StandingReceipt =>
|
|
101
|
+
typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "standing";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A standing receipt authorizes only while the referenced workspace rule
|
|
105
|
+
* still covers the class. Removing the flag (or narrowing the list) fails
|
|
106
|
+
* closed on the very next reserve — no revocation step needed.
|
|
107
|
+
*/
|
|
108
|
+
export const standingApprovalLive = (
|
|
109
|
+
root: string,
|
|
110
|
+
receipt: unknown,
|
|
111
|
+
cls: unknown,
|
|
112
|
+
dir?: string,
|
|
113
|
+
): boolean => {
|
|
114
|
+
if (!isStandingReceipt(receipt)) return false;
|
|
115
|
+
if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
|
|
116
|
+
if (receipt.class !== cls) return false;
|
|
117
|
+
const resolved = resolveAutoApproval(root, dir);
|
|
118
|
+
if (resolved.status !== "on") return false;
|
|
119
|
+
if (resolved.workspace !== receipt.workspace) return false;
|
|
120
|
+
return resolved.classes.includes(cls as AutoClass);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Build the receipt an auto-recorded decision carries (current digest). */
|
|
124
|
+
export const standingReceiptFor = (
|
|
125
|
+
root: string,
|
|
126
|
+
workspace: string,
|
|
127
|
+
cls: AutoClass,
|
|
128
|
+
dir?: string,
|
|
129
|
+
): StandingReceipt => {
|
|
130
|
+
const resolved = resolveAutoApproval(root, dir);
|
|
131
|
+
return {
|
|
132
|
+
kind: "standing",
|
|
133
|
+
workspace,
|
|
134
|
+
class: cls,
|
|
135
|
+
configDigest:
|
|
136
|
+
resolved.status === "on" && resolved.workspace === workspace ? resolved.configDigest : null,
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Verify a standing approval record request: active task, lead session
|
|
142
|
+
* match (helpers never auto-approve), operation class equals the claimed
|
|
143
|
+
* class, rule live right now, push identity enforced for push.
|
|
144
|
+
* Returns the host-observed provenance the entry carries on success.
|
|
145
|
+
*/
|
|
146
|
+
export const verifyStandingApproval = (
|
|
147
|
+
root: string,
|
|
148
|
+
task: TaskRecord,
|
|
149
|
+
caller: Caller,
|
|
150
|
+
binding: {
|
|
151
|
+
standing?: { workspace?: unknown; class?: unknown };
|
|
152
|
+
approvedContent?: unknown;
|
|
153
|
+
},
|
|
154
|
+
): Result<Provenance> => {
|
|
155
|
+
if (task.status !== "active")
|
|
156
|
+
return failure("invalid_transition", "only active tasks can authorize actions");
|
|
157
|
+
const session = task.intent?.provenance?.session;
|
|
158
|
+
if (
|
|
159
|
+
!session ||
|
|
160
|
+
session.kind !== "host" ||
|
|
161
|
+
session.host !== caller.host ||
|
|
162
|
+
session.handle !== caller.actor
|
|
163
|
+
)
|
|
164
|
+
return failure("permission_denied", "standing approval requires the lead session");
|
|
165
|
+
const standing = binding.standing;
|
|
166
|
+
if (!standing || typeof standing.workspace !== "string" || typeof standing.class !== "string")
|
|
167
|
+
return failure("invalid_input", "standing approval needs a workspace and class");
|
|
168
|
+
let operation: unknown;
|
|
169
|
+
try {
|
|
170
|
+
operation =
|
|
171
|
+
typeof binding.approvedContent === "string"
|
|
172
|
+
? (JSON.parse(binding.approvedContent) as { operation?: unknown }).operation
|
|
173
|
+
: null;
|
|
174
|
+
} catch {
|
|
175
|
+
operation = null;
|
|
176
|
+
}
|
|
177
|
+
const cls = operationAutoClass(operation);
|
|
178
|
+
if (!cls || cls !== standing.class)
|
|
179
|
+
return failure("permission_denied", "standing approval does not cover this operation");
|
|
180
|
+
const resolved = resolveAutoApproval(root);
|
|
181
|
+
if (
|
|
182
|
+
resolved.status !== "on" ||
|
|
183
|
+
resolved.workspace !== standing.workspace ||
|
|
184
|
+
!resolved.classes.includes(cls)
|
|
185
|
+
)
|
|
186
|
+
return failure("permission_denied", "standing auto-approval is not live for this operation");
|
|
187
|
+
if (cls === "push") {
|
|
188
|
+
const workspace = resolveWorkspaceFrom(root, configDir());
|
|
189
|
+
const provider = workspace?.vcs?.provider;
|
|
190
|
+
const account = resolved.account;
|
|
191
|
+
if (!provider || !account)
|
|
192
|
+
return failure(
|
|
193
|
+
"permission_denied",
|
|
194
|
+
"auto-push requires a workspace provider and area account",
|
|
195
|
+
);
|
|
196
|
+
const identity = verifyPushIdentity(root, provider, account);
|
|
197
|
+
if (!identity.ok) return failure("permission_denied", identity.error);
|
|
198
|
+
}
|
|
199
|
+
return success(null, null, {
|
|
200
|
+
kind: "host_observed",
|
|
201
|
+
host: caller.host,
|
|
202
|
+
session: { kind: "host", host: caller.host, handle: caller.actor },
|
|
203
|
+
workerId: null,
|
|
204
|
+
receipts: [standingReceiptFor(root, standing.workspace, cls)],
|
|
205
|
+
} as Provenance);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export type StandingAutoBinding = {
|
|
209
|
+
taskId: Id;
|
|
210
|
+
decisionId: Id;
|
|
211
|
+
binding: Decision["binding"];
|
|
212
|
+
expectedRevision: Revision;
|
|
213
|
+
expectedWorkspaceRevision: Revision;
|
|
214
|
+
step?: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
type StandingMatch = {
|
|
218
|
+
task: TaskRecord;
|
|
219
|
+
workspaceId: Id;
|
|
220
|
+
workspaceRevision: Revision;
|
|
221
|
+
cls: AutoClass;
|
|
222
|
+
workspaceName: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Dry-run half of standingAutoBinding: same matching, no recording. Tool
|
|
227
|
+
* gates use this to decide between executing and asking; the runner records.
|
|
228
|
+
*/
|
|
229
|
+
export const standingAutoApplies = (
|
|
230
|
+
store: TaskStore,
|
|
231
|
+
host: string,
|
|
232
|
+
actor: string,
|
|
233
|
+
operation: string,
|
|
234
|
+
): StandingMatch | null => {
|
|
235
|
+
let descriptor: { operation?: unknown };
|
|
236
|
+
try {
|
|
237
|
+
descriptor = JSON.parse(operation) as { operation?: unknown };
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const cls = operationAutoClass(descriptor.operation);
|
|
242
|
+
if (!cls || !autoApproves(store.root, cls)) return null;
|
|
243
|
+
const listed = store.listTasks();
|
|
244
|
+
const workspace = store.readWorkspace();
|
|
245
|
+
if (!listed.ok || !workspace.ok || !workspace.data) return null;
|
|
246
|
+
const matches = listed.data.filter(
|
|
247
|
+
(task) =>
|
|
248
|
+
task.status === "active" &&
|
|
249
|
+
task.workspaceId === workspace.data!.id &&
|
|
250
|
+
task.intent?.provenance?.session?.kind === "host" &&
|
|
251
|
+
(task.intent.provenance.session as { host?: unknown; handle?: unknown }).host === host &&
|
|
252
|
+
(task.intent.provenance.session as { host?: unknown; handle?: unknown }).handle === actor,
|
|
253
|
+
);
|
|
254
|
+
if (matches.length !== 1) return null;
|
|
255
|
+
const resolved = resolveAutoApproval(store.root);
|
|
256
|
+
if (resolved.status !== "on") return null;
|
|
257
|
+
return {
|
|
258
|
+
task: matches[0],
|
|
259
|
+
workspaceId: workspace.data.id,
|
|
260
|
+
workspaceRevision: workspace.data.revision,
|
|
261
|
+
cls,
|
|
262
|
+
workspaceName: resolved.workspace,
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Shared adapter path: resolve one gated operation under the workspace
|
|
268
|
+
* standing rule without asking. Returns the exact binding the runner needs
|
|
269
|
+
* for reserve/settle, or null when anything is uncovered, ambiguous, or
|
|
270
|
+
* unresolvable — callers fall through to the question proposal.
|
|
271
|
+
*
|
|
272
|
+
* Single active task per session (ambiguity asks), plan-before-mutation
|
|
273
|
+
* (no task asks), guardrails enforced at record time, revocation instant.
|
|
274
|
+
*/
|
|
275
|
+
export const standingAutoBinding = (
|
|
276
|
+
core: WorkitCore,
|
|
277
|
+
store: TaskStore,
|
|
278
|
+
host: string,
|
|
279
|
+
actor: string,
|
|
280
|
+
operation: string,
|
|
281
|
+
): StandingAutoBinding | null => {
|
|
282
|
+
let descriptor: { operation?: unknown };
|
|
283
|
+
try {
|
|
284
|
+
descriptor = JSON.parse(operation) as { operation?: unknown };
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const cls = operationAutoClass(descriptor.operation);
|
|
289
|
+
if (!cls || !autoApproves(store.root, cls)) return null;
|
|
290
|
+
const applies = standingAutoApplies(store, host, actor, operation);
|
|
291
|
+
if (!applies) return null;
|
|
292
|
+
const { task } = applies;
|
|
293
|
+
const resolved = resolveAutoApproval(store.root);
|
|
294
|
+
if (resolved.status !== "on") return null;
|
|
295
|
+
const presented =
|
|
296
|
+
`Workit decision: action — auto-approved ${String(descriptor.operation)} ` +
|
|
297
|
+
`under workspace rule "${resolved.workspace}" (no question asked).`;
|
|
298
|
+
const recorded = core.observeStandingDecision({
|
|
299
|
+
schemaVersion: 1,
|
|
300
|
+
action: "record",
|
|
301
|
+
taskId: task.id,
|
|
302
|
+
expectedRevision: task.revision,
|
|
303
|
+
purpose: "action",
|
|
304
|
+
binding: {
|
|
305
|
+
taskId: task.id,
|
|
306
|
+
workspaceId: applies.workspaceId,
|
|
307
|
+
scope: task.intent.data.scope,
|
|
308
|
+
presented,
|
|
309
|
+
approvedContent: operation,
|
|
310
|
+
contentRefs: [],
|
|
311
|
+
standing: { workspace: resolved.workspace, class: cls },
|
|
312
|
+
},
|
|
313
|
+
response: "approved",
|
|
314
|
+
requirementIds: [],
|
|
315
|
+
});
|
|
316
|
+
if (!recorded.ok) return null;
|
|
317
|
+
const fresh = store.readTask(task.id);
|
|
318
|
+
if (!fresh.ok) return null;
|
|
319
|
+
return {
|
|
320
|
+
taskId: task.id,
|
|
321
|
+
decisionId: recorded.data.id,
|
|
322
|
+
binding: recorded.data.data.binding,
|
|
323
|
+
expectedRevision: fresh.data.revision,
|
|
324
|
+
expectedWorkspaceRevision: applies.workspaceRevision,
|
|
325
|
+
};
|
|
326
|
+
};
|
package/src/core/branch.ts
CHANGED
|
@@ -18,6 +18,50 @@ const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
|
18
18
|
const allowedBranch = (root: string, name: string) =>
|
|
19
19
|
policy(root).allowed.some((r) => r.test(name));
|
|
20
20
|
const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
|
|
21
|
+
|
|
22
|
+
/** Public guardrail: protected refs fail closed anywhere, never ask. */
|
|
23
|
+
export const isProtectedTarget = (root: string, name: string): boolean => {
|
|
24
|
+
try {
|
|
25
|
+
return isProtected(root, name);
|
|
26
|
+
} catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Push identity check as code: the credential actually used must belong to
|
|
33
|
+
* the workspace area account. Anything unresolvable fails closed — an
|
|
34
|
+
* unreachable host never reads as a match.
|
|
35
|
+
*/
|
|
36
|
+
export const verifyPushIdentity = (
|
|
37
|
+
root: string,
|
|
38
|
+
provider: string,
|
|
39
|
+
account: string,
|
|
40
|
+
): { ok: true } | { ok: false; error: string } => {
|
|
41
|
+
if (!account || typeof account !== "string")
|
|
42
|
+
return { ok: false, error: "push identity requires a configured area account" };
|
|
43
|
+
const probe =
|
|
44
|
+
provider === "gitlab"
|
|
45
|
+
? (["api", "user", "--jq", ".username"] as const)
|
|
46
|
+
: (["api", "user", "--jq", ".login"] as const);
|
|
47
|
+
const bin = provider === "gitlab" ? "glab" : "gh";
|
|
48
|
+
let login: string;
|
|
49
|
+
try {
|
|
50
|
+
login = execFileSync(bin, [...probe], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
54
|
+
}).trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, error: `${bin} identity could not be resolved` };
|
|
57
|
+
}
|
|
58
|
+
if (!login || login.toLowerCase() !== account.toLowerCase())
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `push identity ${login || "(unknown)"} does not match area account ${account}`,
|
|
62
|
+
};
|
|
63
|
+
return { ok: true };
|
|
64
|
+
};
|
|
21
65
|
// RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
|
|
22
66
|
const baseBranch = (cwd: string): { base: string } | { error: string } => {
|
|
23
67
|
const resolved = vcsConfig("resolve", cwd);
|
|
@@ -281,6 +325,43 @@ export type BranchSetupResult =
|
|
|
281
325
|
| { error: string; phase: "preflight" | "post_mutation" };
|
|
282
326
|
|
|
283
327
|
// Port of scripts/branch/setup-branch.sh
|
|
328
|
+
export type BranchDirt = "clean" | "carry" | "stash-required";
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Classify working-tree dirt for branch setup. Untracked files ride along
|
|
332
|
+
* (`git switch -c` never touches them), and tracked modifications confined
|
|
333
|
+
* to `docs/` are branch-owned by construction in this workflow — both
|
|
334
|
+
* auto-carry with no stash question. Anything else keeps the stash-bound
|
|
335
|
+
* proposal, since the base-checkout sequence could disturb it. Any
|
|
336
|
+
* inspection failure reads as clean so a broken git surfaces at its own
|
|
337
|
+
* error instead of inventing a stash demand.
|
|
338
|
+
*/
|
|
339
|
+
export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
|
|
340
|
+
let raw: string;
|
|
341
|
+
try {
|
|
342
|
+
raw = execFileSync("git", ["status", "--porcelain=v1"], {
|
|
343
|
+
cwd: path.resolve(workspaceRoot),
|
|
344
|
+
encoding: "utf8",
|
|
345
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
346
|
+
});
|
|
347
|
+
} catch {
|
|
348
|
+
return "clean";
|
|
349
|
+
}
|
|
350
|
+
let dirty = false;
|
|
351
|
+
for (const line of raw.split("\n")) {
|
|
352
|
+
if (!line.trim()) continue;
|
|
353
|
+
dirty = true;
|
|
354
|
+
const code = line.slice(0, 2);
|
|
355
|
+
if (code === "??") continue;
|
|
356
|
+
let entry = line.slice(3);
|
|
357
|
+
const arrow = entry.indexOf(" -> ");
|
|
358
|
+
if (arrow >= 0) entry = entry.slice(arrow + 4);
|
|
359
|
+
const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
|
|
360
|
+
if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
|
|
361
|
+
}
|
|
362
|
+
return dirty ? "carry" : "clean";
|
|
363
|
+
};
|
|
364
|
+
|
|
284
365
|
export const branchSetup = ({
|
|
285
366
|
action,
|
|
286
367
|
sdd_dir,
|
|
@@ -398,7 +479,7 @@ export const branchSetup = ({
|
|
|
398
479
|
};
|
|
399
480
|
if (current !== target) {
|
|
400
481
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
401
|
-
if (
|
|
482
|
+
if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
|
|
402
483
|
return {
|
|
403
484
|
error:
|
|
404
485
|
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
@@ -416,7 +497,7 @@ export const branchSetup = ({
|
|
|
416
497
|
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
417
498
|
validatedBase = base;
|
|
418
499
|
}
|
|
419
|
-
if (dirty) {
|
|
500
|
+
if (dirty && stash === "yes") {
|
|
420
501
|
try {
|
|
421
502
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
422
503
|
} catch (error) {
|
package/src/core/doctor.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
25
|
+
import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
|
|
25
26
|
import { EVENT } from "./boundary";
|
|
26
27
|
import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
27
28
|
import { packageRoot } from "./package-root";
|
|
@@ -682,12 +683,7 @@ const checkUtility = (res: Resolved): DoctorCheck => {
|
|
|
682
683
|
const isFragileCachePin = (entry: string): boolean => {
|
|
683
684
|
const n = entry.replaceAll("\\", "/").toLowerCase();
|
|
684
685
|
if (!n.startsWith("file:")) return false;
|
|
685
|
-
return (
|
|
686
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
687
|
-
n.includes("/_npx/") ||
|
|
688
|
-
n.includes("/.pacquet/") ||
|
|
689
|
-
n.includes("/library/caches/pnpm/")
|
|
690
|
-
);
|
|
686
|
+
return isEphemeralCachePath(entry);
|
|
691
687
|
};
|
|
692
688
|
|
|
693
689
|
const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
|
|
@@ -936,12 +932,7 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
936
932
|
} catch {
|
|
937
933
|
target = "(unreadable)";
|
|
938
934
|
}
|
|
939
|
-
const
|
|
940
|
-
const fragile =
|
|
941
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
942
|
-
n.includes("/_npx/") ||
|
|
943
|
-
n.includes("/.pacquet/") ||
|
|
944
|
-
n.includes("/library/caches/pnpm/");
|
|
935
|
+
const fragile = isEphemeralCachePath(target);
|
|
945
936
|
return {
|
|
946
937
|
id: "stale_install",
|
|
947
938
|
status: "fail",
|
|
@@ -1013,6 +1004,28 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
1013
1004
|
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
|
|
1014
1005
|
};
|
|
1015
1006
|
}
|
|
1007
|
+
// Same version, different bytes: the install was hand-edited or built from
|
|
1008
|
+
// a dirty tree. Version strings cannot see that; the bundle hash can. The
|
|
1009
|
+
// dev dist is byte-deterministic, so a mismatch heals with reinstall.
|
|
1010
|
+
if (localDist && installed !== null && source !== null && res.dev) {
|
|
1011
|
+
const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
|
|
1012
|
+
const sourceBundle =
|
|
1013
|
+
entry && path.isAbsolute(entry)
|
|
1014
|
+
? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
|
|
1015
|
+
: "";
|
|
1016
|
+
if (entry && sourceBundle) {
|
|
1017
|
+
const installedHash = bundleHashOfFile(entry);
|
|
1018
|
+
const sourceHash = bundleHashOfFile(sourceBundle);
|
|
1019
|
+
if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
|
|
1020
|
+
return {
|
|
1021
|
+
id: "stale_install",
|
|
1022
|
+
status: "fail",
|
|
1023
|
+
detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
|
|
1024
|
+
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1016
1029
|
if (installed !== null && localDist) {
|
|
1017
1030
|
const expected = registryLatestVersion(res);
|
|
1018
1031
|
if (expected === null) {
|