@deftai/directive-core 0.86.0 → 0.88.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/dist/cache/scanner.d.ts +11 -1
- package/dist/cache/scanner.js +29 -4
- package/dist/check/gate-lists.js +2 -0
- package/dist/content-contracts/skills/helpers.d.ts +10 -0
- package/dist/content-contracts/skills/helpers.js +35 -0
- package/dist/deposit/copy-tree.d.ts +19 -1
- package/dist/deposit/copy-tree.js +134 -5
- package/dist/doctor/main.d.ts +6 -5
- package/dist/doctor/main.js +80 -18
- package/dist/doctor/taskfile.d.ts +8 -0
- package/dist/doctor/taskfile.js +19 -0
- package/dist/fs/projection-containment.d.ts +18 -0
- package/dist/fs/projection-containment.js +40 -0
- package/dist/hooks/dispatcher.d.ts +34 -2
- package/dist/hooks/dispatcher.js +234 -21
- package/dist/hooks/tools.d.ts +31 -0
- package/dist/hooks/tools.js +74 -0
- package/dist/init-deposit/agent-hooks.d.ts +1 -1
- package/dist/init-deposit/agent-hooks.js +38 -2
- package/dist/init-deposit/hygiene.d.ts +16 -0
- package/dist/init-deposit/hygiene.js +26 -0
- package/dist/init-deposit/init-dispatch.js +28 -0
- package/dist/init-deposit/prettierignore.js +2 -2
- package/dist/init-deposit/refresh.js +38 -7
- package/dist/init-deposit/scaffold.js +7 -3
- package/dist/init-deposit/xbrief-projections.js +6 -6
- package/dist/intake/issue-emit.d.ts +45 -2
- package/dist/intake/issue-emit.js +420 -17
- package/dist/intake/issue-ingest.js +65 -6
- package/dist/packs/pack-render.d.ts +33 -0
- package/dist/packs/pack-render.js +155 -9
- package/dist/packs/quarantine-ext.d.ts +10 -0
- package/dist/packs/quarantine-ext.js +26 -2
- package/dist/platform/platform-capabilities.js +3 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/no-deft-directive.d.ts +59 -0
- package/dist/policy/no-deft-directive.js +103 -0
- package/dist/policy/org-force-on-migration.d.ts +52 -0
- package/dist/policy/org-force-on-migration.js +260 -22
- package/dist/policy/runtime-authority.d.ts +41 -0
- package/dist/policy/runtime-authority.js +274 -0
- package/dist/review-monitor/constants.js +3 -2
- package/dist/review-monitor/tier-detection.d.ts +6 -2
- package/dist/review-monitor/tier-detection.js +27 -2
- package/dist/scope/transition.js +43 -0
- package/dist/session/release-availability.d.ts +2 -0
- package/dist/session/release-availability.js +23 -8
- package/dist/session/session-start-hook.d.ts +3 -0
- package/dist/session/session-start-hook.js +15 -0
- package/dist/session/session-start.js +30 -0
- package/dist/swarm/routing-set-cli.js +5 -10
- package/dist/swarm/routing.d.ts +3 -2
- package/dist/swarm/routing.js +16 -4
- package/dist/triage/help/registry-data.d.ts +7 -7
- package/dist/triage/help/registry-data.js +15 -6
- package/dist/triage/queue/index.d.ts +1 -0
- package/dist/triage/queue/index.js +1 -0
- package/dist/triage/queue/show.d.ts +69 -0
- package/dist/triage/queue/show.js +293 -0
- package/dist/triage/scope/cli.js +3 -0
- package/dist/triage/scope/coverage.d.ts +2 -0
- package/dist/triage/scope/coverage.js +18 -3
- package/dist/verify-source/cursor-tier1.js +7 -2
- package/dist/verify-source/index.d.ts +1 -0
- package/dist/verify-source/index.js +1 -0
- package/dist/verify-source/openclaw-tier1.d.ts +37 -0
- package/dist/verify-source/openclaw-tier1.js +105 -0
- package/dist/xbrief-migrate/migrate-project.js +9 -5
- package/package.json +4 -3
|
@@ -1,17 +1,278 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, globSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync, writeSync, } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import { referenceTypeMatches } from "@deftai/directive-types";
|
|
6
|
+
import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
|
|
5
7
|
import { call } from "../scm/call.js";
|
|
6
8
|
import { resolveProjectRoot } from "../scope/project-context.js";
|
|
7
9
|
import { resolveProjectRepo } from "../slice/project-context.js";
|
|
8
10
|
export const GITHUB_ISSUE_REF_TYPE = "x-xbrief/github-issue";
|
|
9
11
|
export const EXTERNAL_TRUST_LEVEL = "external";
|
|
10
12
|
const ISSUE_URL_PATTERN = /https?:\/\/\S+?\/issues\/\d+/;
|
|
13
|
+
/**
|
|
14
|
+
* Structured post-create failure: remote issue exists; local stamp/ledger may not.
|
|
15
|
+
* `createdUrl` is the durable handle for retry (also mirrored in process + OS-temp recovery).
|
|
16
|
+
*/
|
|
11
17
|
export class IssueEmitError extends Error {
|
|
12
|
-
|
|
18
|
+
createdUrl;
|
|
19
|
+
constructor(message, options) {
|
|
13
20
|
super(message);
|
|
14
21
|
this.name = "IssueEmitError";
|
|
22
|
+
if (options?.createdUrl !== undefined && options.createdUrl.length > 0) {
|
|
23
|
+
this.createdUrl = options.createdUrl;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Process-local fallback for post-create URL when project ledger and xBRIEF stamp both fail (#2880).
|
|
29
|
+
* Survives same-process retry without re-create; paired with private OS-temp sidecar for restarts.
|
|
30
|
+
*/
|
|
31
|
+
const processPendingUrls = new Map();
|
|
32
|
+
/** Vitest-only failure injection via env (not a production export surface). */
|
|
33
|
+
function testFailProjectLedger() {
|
|
34
|
+
return process.env.VITEST === "true" && process.env.DEFT_ISSUE_EMIT_TEST_FAIL_LEDGER === "1";
|
|
35
|
+
}
|
|
36
|
+
function testFailStamp() {
|
|
37
|
+
return process.env.VITEST === "true" && process.env.DEFT_ISSUE_EMIT_TEST_FAIL_STAMP === "1";
|
|
38
|
+
}
|
|
39
|
+
function privateRecoveryRoot() {
|
|
40
|
+
const base = typeof process.env.XDG_RUNTIME_DIR === "string" && process.env.XDG_RUNTIME_DIR.length > 0
|
|
41
|
+
? process.env.XDG_RUNTIME_DIR
|
|
42
|
+
: tmpdir();
|
|
43
|
+
const uid = typeof process.getuid === "function" ? String(process.getuid()) : "u";
|
|
44
|
+
return join(base, `deft-issue-emit-recovery-${uid}`);
|
|
45
|
+
}
|
|
46
|
+
/** Refuse dirs/files we do not own or that are group/other-writable (forged recovery, #2880). */
|
|
47
|
+
function isTrustedStat(st) {
|
|
48
|
+
if (st.isSymbolicLink()) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (typeof process.getuid === "function" && typeof st.uid === "number") {
|
|
52
|
+
if (st.uid !== process.getuid()) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Group/other write bits allow another local principal to rewrite recovery state.
|
|
57
|
+
const mode = Number(st.mode);
|
|
58
|
+
if ((mode & 0o022) !== 0) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function ensureTrustedRecoveryDir() {
|
|
64
|
+
const dir = privateRecoveryRoot();
|
|
65
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
const dirStat = lstatSync(dir);
|
|
67
|
+
if (!isTrustedStat(dirStat) || !dirStat.isDirectory()) {
|
|
68
|
+
throw new Error("issue-emit recovery dir is not a trusted directory owned by the current user");
|
|
69
|
+
}
|
|
70
|
+
if ((dirStat.mode & 0o777) !== 0o700) {
|
|
71
|
+
try {
|
|
72
|
+
chmodSync(dir, 0o700);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Best-effort tighten; isTrustedStat already refused group/other write.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return dir;
|
|
79
|
+
}
|
|
80
|
+
export function recoverySidecarPath(vbriefAbsPath) {
|
|
81
|
+
const key = createHash("sha256").update(resolve(vbriefAbsPath)).digest("hex").slice(0, 40);
|
|
82
|
+
return join(privateRecoveryRoot(), `${key}.json`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Write recovery sidecar without following symlinks (#2880 Greptile P1).
|
|
86
|
+
* Trusted per-uid dir (ownership + mode) + O_EXCL|O_NOFOLLOW create.
|
|
87
|
+
*/
|
|
88
|
+
function writeRecoverySidecarSafe(sidePath, payload) {
|
|
89
|
+
const dir = ensureTrustedRecoveryDir();
|
|
90
|
+
if (resolve(dirname(sidePath)) !== resolve(dir)) {
|
|
91
|
+
// Always write under the validated recovery root.
|
|
92
|
+
throw new Error("issue-emit recovery path escapes trusted root");
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const existing = lstatSync(sidePath);
|
|
96
|
+
if (existing.isSymbolicLink() || existing.isFile()) {
|
|
97
|
+
// unlink of a symlink removes the link itself (does not follow).
|
|
98
|
+
unlinkSync(sidePath);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
throw new Error("issue-emit recovery path exists and is not a regular file");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
const code = err.code;
|
|
106
|
+
if (code !== "ENOENT") {
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
|
|
111
|
+
if (typeof constants.O_NOFOLLOW === "number") {
|
|
112
|
+
flags |= constants.O_NOFOLLOW;
|
|
113
|
+
}
|
|
114
|
+
const fd = openSync(sidePath, flags, 0o600);
|
|
115
|
+
try {
|
|
116
|
+
writeSync(fd, payload, undefined, "utf8");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Always-on recovery after remote create: process memory + private OS-temp sidecar.
|
|
124
|
+
* Independent of project-contained ledger so dual local failure still reconciles on retry (#2880).
|
|
125
|
+
*/
|
|
126
|
+
export function rememberCreatedUrl(vbriefAbsPath, url) {
|
|
127
|
+
const key = resolve(vbriefAbsPath);
|
|
128
|
+
processPendingUrls.set(key, url);
|
|
129
|
+
try {
|
|
130
|
+
writeRecoverySidecarSafe(recoverySidecarPath(key), `${JSON.stringify({ path: key, url }, null, 2)}\n`);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Best-effort disk mirror; process map still holds the URL for same-process retry.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function loadRecoveredUrl(vbriefAbsPath) {
|
|
137
|
+
const key = resolve(vbriefAbsPath);
|
|
138
|
+
const mem = processPendingUrls.get(key);
|
|
139
|
+
if (typeof mem === "string" && mem.length > 0) {
|
|
140
|
+
return mem;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const side = recoverySidecarPath(key);
|
|
144
|
+
// Parent dir must be trusted before we read any sidecar (forged-dir attack, #2880).
|
|
145
|
+
const parent = dirname(side);
|
|
146
|
+
const parentStat = lstatSync(parent);
|
|
147
|
+
if (!isTrustedStat(parentStat) || !parentStat.isDirectory()) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const st = lstatSync(side);
|
|
151
|
+
if (!st.isFile() || !isTrustedStat(st)) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const raw = readFileSync(side, "utf8");
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
157
|
+
const obj = parsed;
|
|
158
|
+
const url = obj.url;
|
|
159
|
+
const pathField = obj.path;
|
|
160
|
+
if (typeof url === "string" && url.length > 0 && ISSUE_URL_PATTERN.test(url)) {
|
|
161
|
+
if (typeof pathField === "string" && resolve(pathField) !== key) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
processPendingUrls.set(key, url);
|
|
165
|
+
return url;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Missing or untrusted recovery file is empty.
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
export function clearRecoveredUrl(vbriefAbsPath) {
|
|
175
|
+
const key = resolve(vbriefAbsPath);
|
|
176
|
+
processPendingUrls.delete(key);
|
|
177
|
+
try {
|
|
178
|
+
const side = recoverySidecarPath(key);
|
|
179
|
+
try {
|
|
180
|
+
const st = lstatSync(side);
|
|
181
|
+
if (st.isSymbolicLink() || st.isFile()) {
|
|
182
|
+
unlinkSync(side);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
rmSync(side, { force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Best-effort clear.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Project ledger first, then process/OS-temp recovery (#2880). */
|
|
194
|
+
export function resolvePriorCreatedUrl(projectRoot, vbriefAbsPath) {
|
|
195
|
+
const absPath = resolve(vbriefAbsPath);
|
|
196
|
+
const pending = loadPendingEmitUrls(projectRoot)[absPath];
|
|
197
|
+
if (typeof pending === "string" && pending.length > 0) {
|
|
198
|
+
return pending;
|
|
199
|
+
}
|
|
200
|
+
return loadRecoveredUrl(absPath);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Record URL immediately after remote create. Project ledger is best-effort;
|
|
204
|
+
* recovery layers always run first so dual local failure cannot force re-create (#2880).
|
|
205
|
+
*/
|
|
206
|
+
export function recordCreatedUrlDurable(projectRoot, vbriefAbsPath, url) {
|
|
207
|
+
rememberCreatedUrl(vbriefAbsPath, url);
|
|
208
|
+
if (testFailProjectLedger()) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
savePendingEmitUrl(projectRoot, vbriefAbsPath, url);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Recovery layers already hold the URL.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function stampUrlOntoVbrief(path, data, url, projectRoot) {
|
|
219
|
+
if (testFailStamp()) {
|
|
220
|
+
throw new Error("issue-emit test hook: stamp failure");
|
|
221
|
+
}
|
|
222
|
+
addGithubIssueReference(data, url);
|
|
223
|
+
writeVbrief(path, data, projectRoot);
|
|
224
|
+
clearPendingEmitUrl(projectRoot, path);
|
|
225
|
+
clearRecoveredUrl(path);
|
|
226
|
+
}
|
|
227
|
+
/** Contained durable map: abs vbrief path -> issue URL for in-flight emits (#2871). */
|
|
228
|
+
export function pendingEmitLedgerPath(projectRoot) {
|
|
229
|
+
return join(resolve(projectRoot), ".deft-cache", "issue-emit-pending.json");
|
|
230
|
+
}
|
|
231
|
+
export function loadPendingEmitUrls(projectRoot) {
|
|
232
|
+
const ledger = pendingEmitLedgerPath(projectRoot);
|
|
233
|
+
try {
|
|
234
|
+
assertWriteTargetSafe(projectRoot, ledger);
|
|
235
|
+
const raw = readFileSync(ledger, "utf8");
|
|
236
|
+
const parsed = JSON.parse(raw);
|
|
237
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
240
|
+
if (typeof v === "string" && v.length > 0) {
|
|
241
|
+
out[k] = v;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Missing or unreadable ledger is empty.
|
|
249
|
+
}
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
export function savePendingEmitUrl(projectRoot, vbriefAbsPath, url) {
|
|
253
|
+
const ledger = pendingEmitLedgerPath(projectRoot);
|
|
254
|
+
assertWriteTargetSafe(projectRoot, ledger);
|
|
255
|
+
mkdirSync(dirname(ledger), { recursive: true });
|
|
256
|
+
const map = loadPendingEmitUrls(projectRoot);
|
|
257
|
+
map[resolve(vbriefAbsPath)] = url;
|
|
258
|
+
assertWriteTargetSafe(projectRoot, ledger);
|
|
259
|
+
writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
|
|
260
|
+
}
|
|
261
|
+
export function clearPendingEmitUrl(projectRoot, vbriefAbsPath) {
|
|
262
|
+
const ledger = pendingEmitLedgerPath(projectRoot);
|
|
263
|
+
const key = resolve(vbriefAbsPath);
|
|
264
|
+
const map = loadPendingEmitUrls(projectRoot);
|
|
265
|
+
if (!(key in map)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
delete map[key];
|
|
269
|
+
try {
|
|
270
|
+
assertWriteTargetSafe(projectRoot, ledger);
|
|
271
|
+
mkdirSync(dirname(ledger), { recursive: true });
|
|
272
|
+
writeFileSync(ledger, `${JSON.stringify(map, null, 2)}\n`, "utf8");
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Best-effort clear; next successful stamp still works via existingGithubIssueRef.
|
|
15
276
|
}
|
|
16
277
|
}
|
|
17
278
|
export function loadVbrief(path) {
|
|
@@ -20,8 +281,39 @@ export function loadVbrief(path) {
|
|
|
20
281
|
? data
|
|
21
282
|
: {};
|
|
22
283
|
}
|
|
23
|
-
|
|
24
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Resolve project root and refuse unsafe write targets before any emit side-effect.
|
|
286
|
+
* Callers that create remote issues MUST invoke this before `fileIssue` so a
|
|
287
|
+
* containment refusal cannot leave an orphan GitHub issue (#2869 / #2871).
|
|
288
|
+
*
|
|
289
|
+
* ⊗ Do not fall back to `dirname(path)` as the containment root — that trusts a
|
|
290
|
+
* possibly-symlinked parent and would re-open the escape (Greptile P1 on #2871).
|
|
291
|
+
*/
|
|
292
|
+
export function assertVbriefWriteTargetSafe(path, projectRoot) {
|
|
293
|
+
const absPath = resolve(path);
|
|
294
|
+
// Resolve root only from explicit projectRoot, DEFT_PROJECT_ROOT, or cwd walk-up.
|
|
295
|
+
// Never start discovery from dirname(absPath) — that trusts a possibly-symlinked parent
|
|
296
|
+
// of the write target as the containment root (Greptile/SLizard P1 on #2871).
|
|
297
|
+
const root = projectRoot !== undefined && projectRoot !== null && projectRoot.length > 0
|
|
298
|
+
? resolve(projectRoot)
|
|
299
|
+
: resolveProjectRoot(null);
|
|
300
|
+
if (root === null) {
|
|
301
|
+
throw new ProjectionContainmentError(`projection write refused: could not resolve project root for ${absPath}; pass projectRoot or run from a project checkout`, {
|
|
302
|
+
projectDir: process.cwd(),
|
|
303
|
+
targetPath: absPath,
|
|
304
|
+
offendingPath: absPath,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
assertWriteTargetSafe(root, absPath);
|
|
308
|
+
return root;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
|
|
312
|
+
* parent-directory symlink cannot divert the stamped file outside the project (#2869).
|
|
313
|
+
*/
|
|
314
|
+
export function writeVbrief(path, data, projectRoot) {
|
|
315
|
+
assertVbriefWriteTargetSafe(path, projectRoot);
|
|
316
|
+
writeFileSync(resolve(path), `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
25
317
|
}
|
|
26
318
|
export function vbriefTitle(data) {
|
|
27
319
|
const plan = (data.plan ?? {});
|
|
@@ -160,10 +452,37 @@ export function emitSingle(path, options) {
|
|
|
160
452
|
if (options.noNetwork) {
|
|
161
453
|
return { result: "dryrun", vbrief: shown, url: null, title };
|
|
162
454
|
}
|
|
455
|
+
// Atomic emit contract (#2871 / #2880):
|
|
456
|
+
// 1) Resolve a trusted project root (never dirname of target).
|
|
457
|
+
// 2) Reconcile prior URL from project ledger OR process/OS-temp recovery (no re-create).
|
|
458
|
+
// 3) Pre-persist current payload so the write path is proven.
|
|
459
|
+
// 4) Create remote issue, then always record URL in recovery + best-effort project ledger.
|
|
460
|
+
// 5) Stamp the vbrief; clear recovery on success. Dual local failure still retries safely.
|
|
461
|
+
const root = assertVbriefWriteTargetSafe(path, options.projectRoot);
|
|
462
|
+
const absPath = resolve(path);
|
|
463
|
+
const priorUrl = resolvePriorCreatedUrl(root, absPath);
|
|
464
|
+
if (typeof priorUrl === "string" && priorUrl.length > 0) {
|
|
465
|
+
try {
|
|
466
|
+
stampUrlOntoVbrief(path, data, priorUrl, root);
|
|
467
|
+
}
|
|
468
|
+
catch (stampErr) {
|
|
469
|
+
throw new IssueEmitError(`reconcile ${priorUrl} but failed to stamp local vbrief: ${String(stampErr)}`, { createdUrl: priorUrl });
|
|
470
|
+
}
|
|
471
|
+
return { result: "created", vbrief: shown, url: priorUrl, title };
|
|
472
|
+
}
|
|
473
|
+
writeVbrief(path, data, root);
|
|
163
474
|
const body = renderIssueBody(data);
|
|
475
|
+
assertVbriefWriteTargetSafe(path, root);
|
|
164
476
|
const url = fileIssue(options.repo, title, body, options.scmCall);
|
|
165
|
-
|
|
166
|
-
|
|
477
|
+
// Recovery layers first (process + OS-temp), then best-effort project ledger (#2880).
|
|
478
|
+
// Dual ledger+stamp failure still leaves URL for retry without re-create.
|
|
479
|
+
recordCreatedUrlDurable(root, absPath, url);
|
|
480
|
+
try {
|
|
481
|
+
stampUrlOntoVbrief(path, data, url, root);
|
|
482
|
+
}
|
|
483
|
+
catch (stampErr) {
|
|
484
|
+
throw new IssueEmitError(`created ${url} but failed to stamp local vbrief: ${String(stampErr)}`, { createdUrl: url });
|
|
485
|
+
}
|
|
167
486
|
return { result: "created", vbrief: shown, url, title };
|
|
168
487
|
}
|
|
169
488
|
export function emitPerVbrief(paths, options) {
|
|
@@ -188,12 +507,22 @@ export function emitUmbrella(paths, options) {
|
|
|
188
507
|
loaded.push([paths[i], shown[i], loadVbrief(paths[i])]);
|
|
189
508
|
}
|
|
190
509
|
const pending = loaded.filter(([, , data]) => existingGithubIssueRef(data) === undefined);
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
510
|
+
const alreadyEntries = loaded.filter(([, , data]) => existingGithubIssueRef(data) !== undefined);
|
|
511
|
+
const already = alreadyEntries.map(([, disp]) => ({ vbrief: disp, result: "skipped" }));
|
|
512
|
+
// Already-stamped siblings seed the umbrella URL and participate in conflict checks (#2880).
|
|
513
|
+
let stampedSiblingUrl = null;
|
|
514
|
+
for (const [, , data] of alreadyEntries) {
|
|
515
|
+
const ref = existingGithubIssueRef(data);
|
|
516
|
+
if (typeof ref === "string" && ref.length > 0) {
|
|
517
|
+
if (stampedSiblingUrl !== null && stampedSiblingUrl !== ref) {
|
|
518
|
+
throw new IssueEmitError(`umbrella siblings already stamped with conflicting issue URLs: ${stampedSiblingUrl} vs ${ref}`, { createdUrl: stampedSiblingUrl });
|
|
519
|
+
}
|
|
520
|
+
stampedSiblingUrl = ref;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
194
523
|
const umbrellaTitle = options.title ?? defaultUmbrellaTitle(loaded.length);
|
|
195
524
|
if (pending.length === 0) {
|
|
196
|
-
return { result: "skipped", url:
|
|
525
|
+
return { result: "skipped", url: stampedSiblingUrl, title: umbrellaTitle, vbriefs: already };
|
|
197
526
|
}
|
|
198
527
|
if (options.noNetwork) {
|
|
199
528
|
return {
|
|
@@ -203,13 +532,80 @@ export function emitUmbrella(paths, options) {
|
|
|
203
532
|
vbriefs: [...pending.map(([, disp]) => ({ vbrief: disp, result: "dryrun" })), ...already],
|
|
204
533
|
};
|
|
205
534
|
}
|
|
206
|
-
|
|
207
|
-
|
|
535
|
+
// Umbrella emit: same durability contract as emitSingle (#2871 / #2880).
|
|
536
|
+
// Reconcile pending/recovery URLs, pre-persist, create once, record all, stamp all.
|
|
537
|
+
// Mid-loop ledger/stamp failure must not force a second remote create on retry.
|
|
538
|
+
const root = options.projectRoot !== undefined &&
|
|
539
|
+
options.projectRoot !== null &&
|
|
540
|
+
options.projectRoot.length > 0
|
|
541
|
+
? resolve(options.projectRoot)
|
|
542
|
+
: assertVbriefWriteTargetSafe(String(pending[0]?.[0] || ""), options.projectRoot);
|
|
208
543
|
const written = [];
|
|
544
|
+
const stillNeedRemote = [];
|
|
545
|
+
// Pass 1: resolve recovered URLs and reject conflicts BEFORE any stamp (#2880).
|
|
546
|
+
// Include already-stamped sibling URLs so partial umbrella cohorts cannot split.
|
|
547
|
+
let reconciledUrl = stampedSiblingUrl;
|
|
548
|
+
const withPrior = [];
|
|
209
549
|
for (const [path, disp, data] of pending) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
550
|
+
const prior = resolvePriorCreatedUrl(root, path);
|
|
551
|
+
if (typeof prior === "string" && prior.length > 0) {
|
|
552
|
+
if (reconciledUrl !== null && reconciledUrl !== prior) {
|
|
553
|
+
throw new IssueEmitError(`umbrella recovered conflicting issue URLs: ${reconciledUrl} vs ${prior}; resolve local pending/recovery state before retry`, { createdUrl: reconciledUrl });
|
|
554
|
+
}
|
|
555
|
+
reconciledUrl = prior;
|
|
556
|
+
withPrior.push([path, disp, data, prior]);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
writeVbrief(path, data, root);
|
|
560
|
+
stillNeedRemote.push([path, disp, data]);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Pass 2: stamp only after the recovered set is conflict-free.
|
|
564
|
+
for (const [path, disp, data, prior] of withPrior) {
|
|
565
|
+
try {
|
|
566
|
+
stampUrlOntoVbrief(path, data, prior, root);
|
|
567
|
+
written.push({ vbrief: disp, result: "created" });
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Leave recovery/ledger for retry; still surface URL via reconciledUrl.
|
|
571
|
+
written.push({ vbrief: disp, result: "pending-reconcile" });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (stillNeedRemote.length === 0) {
|
|
575
|
+
const pendingLeft = written.some((w) => w.result === "pending-reconcile");
|
|
576
|
+
if (pendingLeft && reconciledUrl !== null) {
|
|
577
|
+
throw new IssueEmitError(`created ${reconciledUrl} but failed to stamp one or more umbrella vbriefs; retry to reconcile without re-create`, { createdUrl: reconciledUrl });
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
result: "created",
|
|
581
|
+
url: reconciledUrl,
|
|
582
|
+
title: umbrellaTitle,
|
|
583
|
+
vbriefs: [...written, ...already],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// Sibling recovery: reuse recovered/stamped umbrella URL instead of a second remote create (#2880).
|
|
587
|
+
let url = reconciledUrl;
|
|
588
|
+
if (url === null || url.length === 0) {
|
|
589
|
+
const body = renderUmbrellaBody(stillNeedRemote.map(([, disp, data]) => [disp, data]));
|
|
590
|
+
url = fileIssue(options.repo, umbrellaTitle, body, options.scmCall);
|
|
591
|
+
}
|
|
592
|
+
// Record URL for every remaining artifact before any stamp can throw (#2880).
|
|
593
|
+
for (const [path] of stillNeedRemote) {
|
|
594
|
+
recordCreatedUrlDurable(root, path, url);
|
|
595
|
+
}
|
|
596
|
+
const stampErrors = [];
|
|
597
|
+
for (const [path, disp, data] of stillNeedRemote) {
|
|
598
|
+
try {
|
|
599
|
+
stampUrlOntoVbrief(path, data, url, root);
|
|
600
|
+
written.push({ vbrief: disp, result: "created" });
|
|
601
|
+
}
|
|
602
|
+
catch (stampErr) {
|
|
603
|
+
stampErrors.push(`${disp}: ${String(stampErr)}`);
|
|
604
|
+
written.push({ vbrief: disp, result: "pending-reconcile" });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (stampErrors.length > 0) {
|
|
608
|
+
throw new IssueEmitError(`created ${url} but failed to stamp ${stampErrors.length} umbrella vbrief(s): ${stampErrors.join("; ")}`, { createdUrl: url });
|
|
213
609
|
}
|
|
214
610
|
return { result: "created", url, title: umbrellaTitle, vbriefs: [...written, ...already] };
|
|
215
611
|
}
|
|
@@ -286,11 +682,17 @@ export function issueEmitMain(args) {
|
|
|
286
682
|
noNetwork,
|
|
287
683
|
title: args.title,
|
|
288
684
|
displayPaths: display,
|
|
685
|
+
projectRoot,
|
|
289
686
|
});
|
|
290
687
|
summary = { mode: "umbrella", no_network: noNetwork, umbrella: action };
|
|
291
688
|
}
|
|
292
689
|
else if (args.perVbrief) {
|
|
293
|
-
const actions = emitPerVbrief(paths, {
|
|
690
|
+
const actions = emitPerVbrief(paths, {
|
|
691
|
+
repo,
|
|
692
|
+
noNetwork,
|
|
693
|
+
displayPaths: display,
|
|
694
|
+
projectRoot,
|
|
695
|
+
});
|
|
294
696
|
summary = { mode: "per-vbrief", no_network: noNetwork, actions };
|
|
295
697
|
}
|
|
296
698
|
else {
|
|
@@ -302,6 +704,7 @@ export function issueEmitMain(args) {
|
|
|
302
704
|
repo,
|
|
303
705
|
noNetwork,
|
|
304
706
|
displayPath: display[0],
|
|
707
|
+
projectRoot,
|
|
305
708
|
});
|
|
306
709
|
summary = { mode: "single", no_network: noNetwork, actions: [action] };
|
|
307
710
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, join, resolve } from "node:path";
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { cacheGet } from "../cache/operations.js";
|
|
4
4
|
import { scan } from "../cache/scanner.js";
|
|
5
|
+
import { assertWriteTargetSafe, ProjectionContainmentError } from "../fs/projection-containment.js";
|
|
5
6
|
import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
|
|
6
7
|
import { call } from "../scm/call.js";
|
|
7
8
|
import { resolveProjectRoot } from "../scope/project-context.js";
|
|
@@ -329,15 +330,24 @@ export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
|
|
|
329
330
|
const labelNames = [];
|
|
330
331
|
if (Array.isArray(labelsRaw)) {
|
|
331
332
|
for (const lbl of labelsRaw) {
|
|
333
|
+
let rawName;
|
|
332
334
|
if (typeof lbl === "string") {
|
|
333
|
-
|
|
335
|
+
rawName = lbl;
|
|
334
336
|
}
|
|
335
337
|
else if (lbl !== null && typeof lbl === "object" && !Array.isArray(lbl)) {
|
|
336
338
|
const name = lbl.name;
|
|
337
339
|
if (typeof name === "string" && name.length > 0) {
|
|
338
|
-
|
|
340
|
+
rawName = name;
|
|
339
341
|
}
|
|
340
342
|
}
|
|
343
|
+
if (rawName === undefined || rawName.length === 0) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// #2916: label names copy unchanged into narratives.Labels and plan.tags --
|
|
347
|
+
// agent-authoritative fields. Quarantine-scan them under the same contract as
|
|
348
|
+
// titles/body: hard-fail closed on credential-shaped labels, fence/omit
|
|
349
|
+
// injection-shaped labels. (cache-quarantine-06, tracker #2904)
|
|
350
|
+
labelNames.push(scanUntrustedIngestText(number, rawName));
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
353
|
const [folder, planStatus] = STATUS_MAP[status];
|
|
@@ -537,6 +547,42 @@ export function fetchIssue(repo, number, options = {}) {
|
|
|
537
547
|
const comments = fetchIssueComments(repo, number, options);
|
|
538
548
|
return attachIssueCommentThread(cached, comments);
|
|
539
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Resolve the containment root for ingest writes (#2869 / #2871).
|
|
552
|
+
*
|
|
553
|
+
* Precedence:
|
|
554
|
+
* 1. Explicit `cwd` (CLI project root)
|
|
555
|
+
* 2. Sentinel walk from vbriefDir (`xbrief` / `vbrief` / `.git`)
|
|
556
|
+
* 3. Parent of a layout-named lifecycle dir (`…/xbrief` or `…/vbrief`) — even when
|
|
557
|
+
* that dir does not exist yet (mkdir is about to create it)
|
|
558
|
+
* 4. An already-existing non-symlink vbriefDir used as a bare lifecycle root (tests)
|
|
559
|
+
*
|
|
560
|
+
* ⊗ Never use a non-existent or symlink path as a silent dirname fallback.
|
|
561
|
+
*/
|
|
562
|
+
function resolveIngestProjectRoot(vbriefDir, cwd) {
|
|
563
|
+
if (cwd !== undefined && cwd !== null && cwd.length > 0) {
|
|
564
|
+
return resolve(cwd);
|
|
565
|
+
}
|
|
566
|
+
const walked = resolveProjectRoot(null, vbriefDir);
|
|
567
|
+
if (walked !== null) {
|
|
568
|
+
return walked;
|
|
569
|
+
}
|
|
570
|
+
const abs = resolve(vbriefDir);
|
|
571
|
+
const base = basename(abs);
|
|
572
|
+
if (base === "xbrief" || base === "vbrief" || base === "vBRIEF") {
|
|
573
|
+
return dirname(abs);
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const info = lstatSync(abs);
|
|
577
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
578
|
+
return abs;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
// missing dir — cannot contain writes safely without an explicit root
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
540
586
|
export function ingestOne(issue, options) {
|
|
541
587
|
const number = Number(issue.number);
|
|
542
588
|
const refs = options.existingRefs ?? scanProvenanceRefs(options.vbriefDir);
|
|
@@ -559,11 +605,24 @@ export function ingestOne(issue, options) {
|
|
|
559
605
|
infoVersion: emissionLayout.infoVersion,
|
|
560
606
|
});
|
|
561
607
|
const filename = targetFilename(number, String(issue.title ?? ""), emissionLayout.artifactSuffix);
|
|
562
|
-
const
|
|
608
|
+
const folderPath = join(options.vbriefDir, folder);
|
|
609
|
+
const target = join(folderPath, filename);
|
|
563
610
|
if (options.dryRun) {
|
|
564
611
|
return ["dryrun", target, `DRY-RUN would write ${folder}/${filename}`];
|
|
565
612
|
}
|
|
566
|
-
|
|
613
|
+
// Gate lifecycle folder + leaf before mkdir/write so folder/parent symlinks
|
|
614
|
+
// cannot divert issue:ingest / triage:accept outside the project (#2869).
|
|
615
|
+
const projectRoot = resolveIngestProjectRoot(options.vbriefDir, options.cwd);
|
|
616
|
+
if (projectRoot === null) {
|
|
617
|
+
throw new ProjectionContainmentError(`projection write refused: could not resolve project root for ingest into ${options.vbriefDir}`, {
|
|
618
|
+
projectDir: options.vbriefDir,
|
|
619
|
+
targetPath: target,
|
|
620
|
+
offendingPath: options.vbriefDir,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
assertWriteTargetSafe(projectRoot, folderPath);
|
|
624
|
+
assertWriteTargetSafe(projectRoot, target);
|
|
625
|
+
mkdirSync(folderPath, { recursive: true });
|
|
567
626
|
writeFileSync(target, `${JSON.stringify(vbrief, null, 2)}\n`, "utf8");
|
|
568
627
|
return ["created", target, `CREATED ${folder}/${filename}`];
|
|
569
628
|
}
|
|
@@ -17,11 +17,44 @@ export interface RenderRegistryEntry {
|
|
|
17
17
|
readonly name_field?: string;
|
|
18
18
|
readonly description_field?: string;
|
|
19
19
|
readonly path_field?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Pack-schema path pattern (mirrors content/vbrief/schemas/<pack>-pack.schema.json
|
|
22
|
+
* `path.pattern`). Enforced fail-closed before render so a malicious pack JSON
|
|
23
|
+
* cannot smuggle an off-shape projection path. Refs #2914 / #2904.
|
|
24
|
+
*/
|
|
25
|
+
readonly path_pattern?: RegExp;
|
|
20
26
|
}
|
|
27
|
+
/** Non-zero exit code for a pack-render containment / schema refusal. */
|
|
28
|
+
export declare const PACK_RENDER_REFUSED_EXIT_CODE = 2;
|
|
29
|
+
/**
|
|
30
|
+
* Thrown, fail-closed, when a pack record's projection path escapes CONTENT_ROOT,
|
|
31
|
+
* violates the pack path schema, or a frontmatter field carries an injection
|
|
32
|
+
* payload (null byte / newline). Remediates AppSec finding skill-pack-supply-01
|
|
33
|
+
* from tracker #2904. Refs #2914.
|
|
34
|
+
*/
|
|
35
|
+
export declare class PackRenderContainmentError extends Error {
|
|
36
|
+
readonly pack: string;
|
|
37
|
+
readonly field: string;
|
|
38
|
+
readonly offendingValue: string;
|
|
39
|
+
constructor(message: string, details: {
|
|
40
|
+
pack: string;
|
|
41
|
+
field: string;
|
|
42
|
+
offendingValue: string;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a pack record's projection path against CONTENT_ROOT and refuse,
|
|
47
|
+
* fail-closed, any value that: is missing/empty/non-string, carries a null
|
|
48
|
+
* byte, is absolute, violates the pack's schema path pattern, or resolves
|
|
49
|
+
* outside CONTENT_ROOT (a `..` escape). Returns the contained absolute output
|
|
50
|
+
* path. Mirrors deposit/contain.ts segment containment. Refs #2914 / #2904.
|
|
51
|
+
*/
|
|
52
|
+
export declare function containedOutPath(packName: string, rawPath: unknown, cfg: RenderRegistryEntry): string;
|
|
21
53
|
export declare const RENDER_REGISTRY: Record<string, RenderRegistryEntry>;
|
|
22
54
|
export declare function renderCollection(pack: Record<string, unknown>, cfg: RenderRegistryEntry): string;
|
|
23
55
|
export declare function renderSkillDocument(entry: Record<string, unknown>, cfg: RenderRegistryEntry): string;
|
|
24
56
|
export declare function renderMarkdownDocument(entry: Record<string, unknown>, cfg: RenderRegistryEntry): string;
|
|
57
|
+
export declare function targetsForPack(packName: string, pack: Record<string, unknown>, cfg: RenderRegistryEntry): Array<[string, string]>;
|
|
25
58
|
export declare function collectTargets(packFilter?: string | null): Array<[string, string, string]>;
|
|
26
59
|
export declare function render(pack: Record<string, unknown>): string;
|
|
27
60
|
export declare function renderFile(source: string): string;
|