@deftai/directive-core 0.73.0 → 0.74.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/branch/evaluate.js +2 -1
- package/dist/check/orchestrator.d.ts +3 -0
- package/dist/check/orchestrator.js +2 -1
- package/dist/codebase/map.js +2 -0
- package/dist/deposit/copy-tree.js +31 -7
- package/dist/doctor/checks.d.ts +9 -0
- package/dist/doctor/checks.js +67 -0
- package/dist/doctor/main.js +2 -1
- package/dist/fs/projection-containment.d.ts +35 -0
- package/dist/fs/projection-containment.js +118 -0
- package/dist/init-deposit/hygiene.d.ts +6 -0
- package/dist/init-deposit/hygiene.js +5 -1
- package/dist/init-deposit/refresh.d.ts +16 -2
- package/dist/init-deposit/refresh.js +144 -50
- package/dist/init-deposit/scaffold.d.ts +21 -0
- package/dist/init-deposit/scaffold.js +84 -10
- package/dist/policy/disclosure.d.ts +1 -0
- package/dist/policy/disclosure.js +1 -0
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/policy-invocation.d.ts +5 -0
- package/dist/policy/policy-invocation.js +9 -0
- package/dist/policy/resolve.js +4 -2
- package/dist/policy/value-feedback.js +6 -3
- package/dist/pr-merge-readiness/constants.d.ts +4 -0
- package/dist/pr-merge-readiness/constants.js +4 -0
- package/dist/pr-merge-readiness/evaluate.js +3 -0
- package/dist/pr-merge-readiness/mergeability.d.ts +8 -6
- package/dist/pr-merge-readiness/mergeability.js +15 -10
- package/dist/pr-merge-readiness/output.js +12 -6
- package/dist/pr-merge-readiness/parse.d.ts +1 -0
- package/dist/pr-merge-readiness/parse.js +9 -1
- package/dist/pr-merge-readiness/types.d.ts +2 -0
- package/dist/preflight-cache/evaluate.d.ts +2 -0
- package/dist/preflight-cache/evaluate.js +14 -3
- package/dist/preflight-cache/index.d.ts +1 -1
- package/dist/preflight-cache/index.js +1 -1
- package/dist/release/constants.d.ts +2 -0
- package/dist/release/constants.js +2 -0
- package/dist/release/preflight.d.ts +2 -0
- package/dist/release/preflight.js +14 -1
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/main.js +24 -1
- package/dist/scope/open-umbrella-warning.d.ts +12 -0
- package/dist/scope/open-umbrella-warning.js +403 -0
- package/dist/swarm/constants.d.ts +1 -1
- package/dist/swarm/constants.js +2 -1
- package/dist/swarm/subagent-backend.js +2 -1
- package/dist/triage/bootstrap/gitignore.d.ts +1 -1
- package/dist/triage/bootstrap/gitignore.js +69 -58
- package/dist/ts-check-lane/run-lane.d.ts +4 -0
- package/dist/ts-check-lane/run-lane.js +20 -6
- package/dist/value/feedback-file.js +3 -2
- package/dist/value/readback.js +3 -2
- package/dist/vbrief-reconcile/umbrellas.js +12 -11
- package/dist/xbrief-migrate/migrate-project.js +9 -0
- package/package.json +3 -3
package/dist/branch/evaluate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { recordBypassSignal, recordGateCatch } from "../events/attribution-ledger.js";
|
|
2
2
|
import { disclosureLine } from "../policy/disclosure.js";
|
|
3
|
+
import { policyColonInvocation } from "../policy/policy-invocation.js";
|
|
3
4
|
import { ENV_BYPASS, resolvePolicy } from "../policy/resolve.js";
|
|
4
5
|
import { currentBranch, GitNotFoundError } from "./git.js";
|
|
5
6
|
export const DEFAULT_BRANCHES = new Set(["master", "main"]);
|
|
@@ -32,7 +33,7 @@ function buildBlockMessage(branch, result) {
|
|
|
32
33
|
if (result.deprecationWarning !== null) {
|
|
33
34
|
parts.push(` Note: ${result.deprecationWarning}`);
|
|
34
35
|
}
|
|
35
|
-
parts.push("", " How to proceed:", " • Create a feature branch: git switch -c feat/<name>", " • Or opt out via the typed surface:", "
|
|
36
|
+
parts.push("", " How to proceed:", " • Create a feature branch: git switch -c feat/<name>", " • Or opt out via the typed surface:", ` ${policyColonInvocation("allow-direct-commits", " -- --confirm")}`, ` • Or set the emergency-escape env-var: ${ENV_BYPASS}=1`, "", " See README.md (Branch policy) and skills/deft-directive-setup/", " Phase 2 Step 9 (capability-cost disclosure).");
|
|
36
37
|
return parts.join("\n");
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
@@ -19,10 +19,13 @@ export interface CheckOrchestratorSeams {
|
|
|
19
19
|
readonly spawnFn?: (cmd: string, args: string[], opts: {
|
|
20
20
|
cwd: string;
|
|
21
21
|
stdio: string;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
22
23
|
}) => {
|
|
23
24
|
status: number | null;
|
|
24
25
|
error?: Error;
|
|
25
26
|
};
|
|
27
|
+
/** Child-process environment (default: process.env). */
|
|
28
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* True when `path` is the directive framework source checkout root (not a
|
|
@@ -77,6 +77,7 @@ export function dispatchTaskCheck(frameworkRoot, projectRoot, seams = {}) {
|
|
|
77
77
|
const result = spawn(taskBin, [target, "--taskfile", taskfilePath], {
|
|
78
78
|
cwd,
|
|
79
79
|
stdio: "inherit",
|
|
80
|
+
env: seams.env,
|
|
80
81
|
});
|
|
81
82
|
if (result.error !== undefined) {
|
|
82
83
|
process.stderr.write(`check: failed to invoke task: ${result.error.message}\n`);
|
|
@@ -88,7 +89,7 @@ function defaultSpawn(cmd, args, opts) {
|
|
|
88
89
|
const result = spawnSync(cmd, args, {
|
|
89
90
|
cwd: opts.cwd,
|
|
90
91
|
stdio: opts.stdio,
|
|
91
|
-
env:
|
|
92
|
+
env: opts.env ?? process.env,
|
|
92
93
|
});
|
|
93
94
|
return { status: result.status, error: result.error };
|
|
94
95
|
}
|
package/dist/codebase/map.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { assertProjectionContained } from "../fs/projection-containment.js";
|
|
3
4
|
import { resolveProjectDefinitionPath } from "../layout/resolve.js";
|
|
4
5
|
import { extractCodeStructure, loadJsonFile } from "../verify-source/code-structure-validate.js";
|
|
5
6
|
import { CodeStructureConfigError, configErrorToDict, defaultCodeStructurePath, } from "./default-extractor.js";
|
|
@@ -239,6 +240,7 @@ export function writeCodebaseMap(projectRoot, options) {
|
|
|
239
240
|
const resolvedOutput = isAbsolute(options.outputPath)
|
|
240
241
|
? options.outputPath
|
|
241
242
|
: join(projectRoot, options.outputPath);
|
|
243
|
+
assertProjectionContained(projectRoot, resolvedOutput);
|
|
242
244
|
if (!options.force && !isDeftGenerated(resolvedOutput)) {
|
|
243
245
|
throw new Error(`refusing to overwrite non-generated MAP at ${resolvedOutput}; ` +
|
|
244
246
|
`missing '${GENERATED_SENTINEL}' banner`);
|
|
@@ -7,12 +7,29 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Refs #1942 S1, #1477.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { constants } from "node:fs";
|
|
11
|
+
import { lstat, mkdir, open, readdir, readFile, stat } from "node:fs/promises";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
|
-
import { pipeline } from "node:stream/promises";
|
|
14
13
|
const DEFAULT_FILE_MODE = 0o644;
|
|
15
14
|
const DEFAULT_DIR_MODE = 0o755;
|
|
15
|
+
async function assertDestinationIsNotSymlink(path) {
|
|
16
|
+
try {
|
|
17
|
+
const info = await lstat(path);
|
|
18
|
+
if (info.isSymbolicLink()) {
|
|
19
|
+
throw new Error(`copyTree: refusing to write through destination symlink ${path}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err instanceof Error && err.message.startsWith("copyTree: refusing")) {
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
if (err.code === "ENOENT") {
|
|
27
|
+
// Missing paths are created by the caller; only pre-existing symlinks matter.
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
16
33
|
async function copyFilePreserveMode(src, dst) {
|
|
17
34
|
let mode = DEFAULT_FILE_MODE;
|
|
18
35
|
try {
|
|
@@ -23,12 +40,19 @@ async function copyFilePreserveMode(src, dst) {
|
|
|
23
40
|
// Stat failure is non-fatal — fall back to 0o644 (mirrors Go copyFile).
|
|
24
41
|
}
|
|
25
42
|
await mkdir(dirname(dst), { recursive: true, mode: DEFAULT_DIR_MODE });
|
|
26
|
-
await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
await assertDestinationIsNotSymlink(dst);
|
|
44
|
+
const handle = await open(dst, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, mode);
|
|
45
|
+
try {
|
|
46
|
+
await handle.writeFile(await readFile(src));
|
|
47
|
+
// Use the opened handle, not the path, so chmod cannot follow a swapped symlink.
|
|
48
|
+
await handle.chmod(mode);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await handle.close();
|
|
52
|
+
}
|
|
30
53
|
}
|
|
31
54
|
async function copyDirContents(src, dst) {
|
|
55
|
+
await assertDestinationIsNotSymlink(dst);
|
|
32
56
|
await mkdir(dst, { recursive: true, mode: DEFAULT_DIR_MODE });
|
|
33
57
|
const entries = await readdir(src, { withFileTypes: true });
|
|
34
58
|
for (const entry of entries) {
|
package/dist/doctor/checks.d.ts
CHANGED
|
@@ -30,6 +30,15 @@ export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, s
|
|
|
30
30
|
* deposit is still a working install, just an unpinned one.
|
|
31
31
|
*/
|
|
32
32
|
export declare function checkManifestVersionReportable(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
|
|
33
|
+
/** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
|
|
34
|
+
export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
|
|
35
|
+
/**
|
|
36
|
+
* #2368: on an already-migrated xbrief/ layout, a stale deposited
|
|
37
|
+
* `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
|
|
38
|
+
* operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
|
|
39
|
+
* `directive update` instead. Advisory only (exit-exempt).
|
|
40
|
+
*/
|
|
41
|
+
export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
|
|
33
42
|
/**
|
|
34
43
|
* #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
|
|
35
44
|
* entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
|
package/dist/doctor/checks.js
CHANGED
|
@@ -5,6 +5,8 @@ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY
|
|
|
5
5
|
import { resolveLifecycleRoot } from "../layout/resolve.js";
|
|
6
6
|
import { findSkillPathsInText } from "../text/redos-safe.js";
|
|
7
7
|
import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
|
|
8
|
+
import { LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
|
|
9
|
+
import { detectXbriefConvergence } from "../xbrief-migrate/detect.js";
|
|
8
10
|
import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
|
|
9
11
|
import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
|
|
10
12
|
import { readTextSafe } from "./paths.js";
|
|
@@ -459,6 +461,68 @@ function collectGitignorePresent(text) {
|
|
|
459
461
|
function gitignoreLineIsCovered(present, line) {
|
|
460
462
|
return present.has(line);
|
|
461
463
|
}
|
|
464
|
+
/** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
|
|
465
|
+
export const STALE_VBRIEF_CORE_SCHEMA_REL = join(MIGRATED_ARTIFACT_DIR, "schemas", "vbrief-core.schema.json");
|
|
466
|
+
const MIGRATED_XBRIEF_LAYOUT_STATES = new Set([
|
|
467
|
+
"xbrief-only",
|
|
468
|
+
"xbrief-marker",
|
|
469
|
+
]);
|
|
470
|
+
/**
|
|
471
|
+
* #2368: on an already-migrated xbrief/ layout, a stale deposited
|
|
472
|
+
* `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
|
|
473
|
+
* operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
|
|
474
|
+
* `directive update` instead. Advisory only (exit-exempt).
|
|
475
|
+
*/
|
|
476
|
+
export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
|
|
477
|
+
const checkName = "stale-xbrief-schema-deposit";
|
|
478
|
+
const convergence = detectXbriefConvergence(projectRoot);
|
|
479
|
+
if (!MIGRATED_XBRIEF_LAYOUT_STATES.has(convergence.state)) {
|
|
480
|
+
return {
|
|
481
|
+
name: checkName,
|
|
482
|
+
status: "skip",
|
|
483
|
+
detail: "Project is not on a fully migrated xbrief/ layout; stale schema deposit check does not apply.",
|
|
484
|
+
data: { convergence_state: convergence.state },
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const schemaPath = join(projectRoot, STALE_VBRIEF_CORE_SCHEMA_REL);
|
|
488
|
+
const isFile = seams.isFile ?? ((p) => readText(p, seams) !== null);
|
|
489
|
+
if (!isFile(schemaPath)) {
|
|
490
|
+
return {
|
|
491
|
+
name: checkName,
|
|
492
|
+
status: "skip",
|
|
493
|
+
detail: `No deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL}.`,
|
|
494
|
+
data: { schema_path: schemaPath },
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const text = readText(schemaPath, seams);
|
|
498
|
+
if (text === null) {
|
|
499
|
+
return {
|
|
500
|
+
name: checkName,
|
|
501
|
+
status: "skip",
|
|
502
|
+
detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is unreadable.`,
|
|
503
|
+
data: { schema_path: schemaPath },
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (!text.includes(`"${LEGACY_INFO_ROOT_KEY}"`)) {
|
|
507
|
+
return {
|
|
508
|
+
name: checkName,
|
|
509
|
+
status: "pass",
|
|
510
|
+
detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is current (no ${LEGACY_INFO_ROOT_KEY} root key).`,
|
|
511
|
+
data: { schema_path: schemaPath },
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
name: checkName,
|
|
516
|
+
status: "fail",
|
|
517
|
+
detail: `Stale deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} still carries the legacy ${LEGACY_INFO_ROOT_KEY} root key on an already-migrated xbrief/ layout. ` +
|
|
518
|
+
"Run `directive update` to refresh deposited schema files — not `deft migrate:xbrief` (no vbrief/ tree remains to convert).",
|
|
519
|
+
data: {
|
|
520
|
+
schema_path: schemaPath,
|
|
521
|
+
convergence_state: convergence.state,
|
|
522
|
+
suggested_fix: "directive update",
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
462
526
|
/**
|
|
463
527
|
* #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
|
|
464
528
|
* entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
|
|
@@ -515,6 +579,7 @@ export function deriveExitCode(checks, errors) {
|
|
|
515
579
|
"canonical-vendored-npm-signpost",
|
|
516
580
|
"manifest-version-reportable",
|
|
517
581
|
"gitignore-coverage",
|
|
582
|
+
"stale-xbrief-schema-deposit",
|
|
518
583
|
]);
|
|
519
584
|
if (errors.length > 0 || checks.some((c) => c.status === "error")) {
|
|
520
585
|
return 2;
|
|
@@ -554,6 +619,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
|
|
|
554
619
|
checks.push(checkManifestVersionReportable(projectRoot, null, seams));
|
|
555
620
|
checks.push(checkLegacyLayout(projectRoot, seams));
|
|
556
621
|
checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
|
|
622
|
+
checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
|
|
557
623
|
checks.push(checkGitignoreCoverage(projectRoot, seams));
|
|
558
624
|
return {
|
|
559
625
|
projectRoot,
|
|
@@ -570,6 +636,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
|
|
|
570
636
|
checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
|
|
571
637
|
checks.push(checkLegacyLayout(projectRoot, seams));
|
|
572
638
|
checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
|
|
639
|
+
checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
|
|
573
640
|
checks.push(checkGitignoreCoverage(projectRoot, seams));
|
|
574
641
|
return {
|
|
575
642
|
projectRoot,
|
package/dist/doctor/main.js
CHANGED
|
@@ -374,7 +374,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
|
|
|
374
374
|
if ((name === "legacy-layout" ||
|
|
375
375
|
name === "canonical-vendored-npm-signpost" ||
|
|
376
376
|
name === "manifest-version-reportable" ||
|
|
377
|
-
name === "gitignore-coverage"
|
|
377
|
+
name === "gitignore-coverage" ||
|
|
378
|
+
name === "stale-xbrief-schema-deposit") &&
|
|
378
379
|
status === "fail") {
|
|
379
380
|
sink.warn(`${name}: ${detail}`);
|
|
380
381
|
addFinding({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projection-containment.ts — repo-controlled projection symlink guard (#2413).
|
|
3
|
+
*
|
|
4
|
+
* Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
|
|
5
|
+
* paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
|
|
6
|
+
* If a malicious repo author commits one of those paths (or a parent) as a
|
|
7
|
+
* symlink escaping the project tree, routine operator commands would follow the
|
|
8
|
+
* link and create/overwrite files outside the checkout.
|
|
9
|
+
*
|
|
10
|
+
* `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
|
|
11
|
+
* existing component down to the write target, rejects symlinks that escape the
|
|
12
|
+
* tree, and asserts the deepest existing ancestor stays under the project root.
|
|
13
|
+
*
|
|
14
|
+
* Refs #2413.
|
|
15
|
+
*/
|
|
16
|
+
/** Non-zero exit code for a projection-containment refusal (needs-action). */
|
|
17
|
+
export declare const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
|
|
18
|
+
/** Thrown when a projection write target escapes the project tree. */
|
|
19
|
+
export declare class ProjectionContainmentError extends Error {
|
|
20
|
+
readonly projectDir: string;
|
|
21
|
+
readonly targetPath: string;
|
|
22
|
+
readonly offendingPath: string;
|
|
23
|
+
constructor(message: string, details: {
|
|
24
|
+
projectDir: string;
|
|
25
|
+
targetPath: string;
|
|
26
|
+
offendingPath: string;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Refuse projection writes when the target (or a parent) is a symlink that
|
|
31
|
+
* escapes the resolved project tree. Throws {@link ProjectionContainmentError};
|
|
32
|
+
* MUST be called BEFORE any projection read/write/mkdir.
|
|
33
|
+
*/
|
|
34
|
+
export declare function assertProjectionContained(projectDir: string, targetPath: string): void;
|
|
35
|
+
//# sourceMappingURL=projection-containment.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projection-containment.ts — repo-controlled projection symlink guard (#2413).
|
|
3
|
+
*
|
|
4
|
+
* Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
|
|
5
|
+
* paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
|
|
6
|
+
* If a malicious repo author commits one of those paths (or a parent) as a
|
|
7
|
+
* symlink escaping the project tree, routine operator commands would follow the
|
|
8
|
+
* link and create/overwrite files outside the checkout.
|
|
9
|
+
*
|
|
10
|
+
* `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
|
|
11
|
+
* existing component down to the write target, rejects symlinks that escape the
|
|
12
|
+
* tree, and asserts the deepest existing ancestor stays under the project root.
|
|
13
|
+
*
|
|
14
|
+
* Refs #2413.
|
|
15
|
+
*/
|
|
16
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
17
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
18
|
+
/** Non-zero exit code for a projection-containment refusal (needs-action). */
|
|
19
|
+
export const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
|
|
20
|
+
/** Thrown when a projection write target escapes the project tree. */
|
|
21
|
+
export class ProjectionContainmentError extends Error {
|
|
22
|
+
projectDir;
|
|
23
|
+
targetPath;
|
|
24
|
+
offendingPath;
|
|
25
|
+
constructor(message, details) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "ProjectionContainmentError";
|
|
28
|
+
this.projectDir = details.projectDir;
|
|
29
|
+
this.targetPath = details.targetPath;
|
|
30
|
+
this.offendingPath = details.offendingPath;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
|
|
35
|
+
* Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`.
|
|
36
|
+
*/
|
|
37
|
+
function isContained(parent, child) {
|
|
38
|
+
if (parent === child) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
const rel = relative(parent, child);
|
|
42
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Refuse projection writes when the target (or a parent) is a symlink that
|
|
46
|
+
* escapes the resolved project tree. Throws {@link ProjectionContainmentError};
|
|
47
|
+
* MUST be called BEFORE any projection read/write/mkdir.
|
|
48
|
+
*/
|
|
49
|
+
export function assertProjectionContained(projectDir, targetPath) {
|
|
50
|
+
const projectAbs = resolve(projectDir);
|
|
51
|
+
let projectReal;
|
|
52
|
+
try {
|
|
53
|
+
projectReal = realpathSync(projectAbs);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new ProjectionContainmentError(`projection write refused: project directory ${projectAbs} does not exist`, { projectDir: projectAbs, targetPath: resolve(targetPath), offendingPath: projectAbs });
|
|
57
|
+
}
|
|
58
|
+
const targetAbs = resolve(targetPath);
|
|
59
|
+
const rel = relative(projectAbs, targetAbs);
|
|
60
|
+
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
|
|
61
|
+
throw new ProjectionContainmentError(`projection write refused: target ${targetAbs} is not nested under the project tree ${projectAbs}`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetAbs });
|
|
62
|
+
}
|
|
63
|
+
const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
64
|
+
let current = projectAbs;
|
|
65
|
+
let deepestExistingReal = projectReal;
|
|
66
|
+
for (const segment of segments) {
|
|
67
|
+
current = join(current, segment);
|
|
68
|
+
let info;
|
|
69
|
+
try {
|
|
70
|
+
info = lstatSync(current);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (info.isSymbolicLink()) {
|
|
76
|
+
let linkReal;
|
|
77
|
+
try {
|
|
78
|
+
linkReal = realpathSync(current);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new ProjectionContainmentError(`projection write refused: ${current} is a broken/dangling symlink on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
|
|
82
|
+
}
|
|
83
|
+
if (!isContained(projectReal, linkReal)) {
|
|
84
|
+
throw new ProjectionContainmentError(`projection write refused: ${current} is a symlink escaping the project tree ` +
|
|
85
|
+
`(resolves to ${linkReal}, outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
|
|
86
|
+
}
|
|
87
|
+
deepestExistingReal = linkReal;
|
|
88
|
+
current = linkReal;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
try {
|
|
92
|
+
deepestExistingReal = realpathSync(current);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
throw new ProjectionContainmentError(`projection write refused: could not resolve ${current} on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const targetReal = realpathSync(targetAbs);
|
|
101
|
+
if (!isContained(projectReal, targetReal)) {
|
|
102
|
+
throw new ProjectionContainmentError(`projection write refused: projection target resolves outside the project tree ` +
|
|
103
|
+
`(${targetReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetReal });
|
|
104
|
+
}
|
|
105
|
+
deepestExistingReal = targetReal;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err instanceof ProjectionContainmentError) {
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
// Target does not exist yet; parent walk above is sufficient.
|
|
112
|
+
}
|
|
113
|
+
if (!isContained(projectReal, deepestExistingReal)) {
|
|
114
|
+
throw new ProjectionContainmentError(`projection write refused: projection path escapes the project tree ` +
|
|
115
|
+
`(${deepestExistingReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: deepestExistingReal });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=projection-containment.js.map
|
|
@@ -17,6 +17,12 @@ export declare function installerManagedMatchers(): InstallerManagedMatcher[];
|
|
|
17
17
|
export declare function installerManagedGuardEre(): string;
|
|
18
18
|
export declare function isInstallerManagedPath(path: string): boolean;
|
|
19
19
|
export interface FrameworkStagePathsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Include the vendored `.deft/core` payload in the stage set. Defaults to
|
|
22
|
+
* `true` for init and real payload swaps. No-op updates set this to `false`
|
|
23
|
+
* so CRLF-only core noise cannot be staged while safe projections are repaired.
|
|
24
|
+
*/
|
|
25
|
+
readonly includeCore?: boolean;
|
|
20
26
|
/**
|
|
21
27
|
* Include the project-root ``Taskfile.yml`` in the stage set. Defaults to
|
|
22
28
|
* ``false``: unlike the other allowlisted paths, ``Taskfile.yml`` is a
|
|
@@ -82,7 +82,10 @@ export function frameworkStagePaths(projectDir, deftDir, options = {}) {
|
|
|
82
82
|
paths.push(normalized);
|
|
83
83
|
};
|
|
84
84
|
const relDeft = relative(projectDir, deftDir);
|
|
85
|
-
if (
|
|
85
|
+
if (options.includeCore !== false &&
|
|
86
|
+
relDeft &&
|
|
87
|
+
!relDeft.startsWith("..") &&
|
|
88
|
+
!relDeft.startsWith("/")) {
|
|
86
89
|
add(relDeft);
|
|
87
90
|
}
|
|
88
91
|
for (const matcher of installerManagedMatchers()) {
|
|
@@ -228,6 +231,7 @@ function actuallyStagedPaths(stagePaths, cachedNames) {
|
|
|
228
231
|
export function depositStagePaths(projectDir, options = {}) {
|
|
229
232
|
const deftDir = join(projectDir, CANONICAL_INSTALL_ROOT);
|
|
230
233
|
const stagePaths = frameworkStagePaths(projectDir, deftDir, {
|
|
234
|
+
includeCore: options.includeCore,
|
|
231
235
|
includeTaskfile: options.includeTaskfile ?? false,
|
|
232
236
|
});
|
|
233
237
|
const { staged } = stageFrameworkPaths(projectDir, stagePaths, options);
|
|
@@ -22,12 +22,15 @@ export interface RefreshDepositResult {
|
|
|
22
22
|
readonly contentVersion: string;
|
|
23
23
|
readonly engineVersion: string;
|
|
24
24
|
readonly previousDepositVersion: string | null;
|
|
25
|
+
readonly alreadyCurrent: boolean;
|
|
26
|
+
readonly strategy: RefreshDepositStrategy;
|
|
25
27
|
readonly agentsMdUpdated: boolean;
|
|
26
28
|
readonly versionSkewNotice: string | null;
|
|
27
29
|
readonly legacyLayout: boolean;
|
|
28
30
|
readonly taskfileWired: boolean;
|
|
29
31
|
readonly stagedPaths: string[];
|
|
30
32
|
}
|
|
33
|
+
export type RefreshDepositStrategy = "file-swap" | "no-op";
|
|
31
34
|
export interface RefreshDepositSeams {
|
|
32
35
|
resolveContentRoot?: () => Promise<string>;
|
|
33
36
|
copyContent?: (src: string, dst: string) => Promise<void>;
|
|
@@ -35,6 +38,7 @@ export interface RefreshDepositSeams {
|
|
|
35
38
|
readEngineVersion?: () => string;
|
|
36
39
|
nowIso?: () => string;
|
|
37
40
|
gitPorcelain?: (projectRoot: string) => string | null;
|
|
41
|
+
gitSemanticDiffNames?: GitSemanticDiffNames;
|
|
38
42
|
detectLegacy?: (projectDir: string) => LegacyLayoutDetection;
|
|
39
43
|
/**
|
|
40
44
|
* #2266: `git ls-files` probe threaded into the non-destructive `.gitignore`
|
|
@@ -101,9 +105,19 @@ export declare function buildDefaultLadderFacts(projectDir: string, facts: Resol
|
|
|
101
105
|
* manual npm/PATH steps (#2266 a3).
|
|
102
106
|
*/
|
|
103
107
|
export declare function selfHealEngine(projectDir: string, facts: ResolutionFacts, io: InitDepositIo, seams?: SelfHealSeams): EngineResolution;
|
|
108
|
+
export type GitSemanticDiffNames = (projectRoot: string, paths: readonly string[]) => readonly string[] | null;
|
|
109
|
+
export interface FrameworkRefreshSideEffectsOptions {
|
|
110
|
+
readonly readPorcelain?: (root: string) => string | null;
|
|
111
|
+
readonly readSemanticDiffNames?: GitSemanticDiffNames;
|
|
112
|
+
}
|
|
113
|
+
export interface RefreshSideEffects {
|
|
114
|
+
readonly files: string[];
|
|
115
|
+
readonly crlfOnlyCoreFiles: string[];
|
|
116
|
+
readonly payloadSwapped?: boolean;
|
|
117
|
+
}
|
|
104
118
|
/** Framework-managed uncommitted paths after refresh (#1671). */
|
|
105
|
-
export declare function frameworkRefreshSideEffects(projectDir: string,
|
|
106
|
-
export declare function printRefreshSideEffects(io: InitDepositIo,
|
|
119
|
+
export declare function frameworkRefreshSideEffects(projectDir: string, options?: FrameworkRefreshSideEffectsOptions): RefreshSideEffects;
|
|
120
|
+
export declare function printRefreshSideEffects(io: InitDepositIo, effects: RefreshSideEffects): void;
|
|
107
121
|
export declare function buildVersionSkewNotice(engineVersion: string, contentVersion: string, previousDepositVersion: string | null): string | null;
|
|
108
122
|
export declare function buildUpdateSummaryJson(result: RefreshDepositResult, options: RefreshDepositArgs, updateState?: UpdateState): Record<string, unknown>;
|
|
109
123
|
export declare function printUpdateComplete(result: RefreshDepositResult, io: InitDepositIo, updateState?: UpdateState): void;
|