@deftai/directive-core 0.87.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 +1 -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.js +48 -0
- package/dist/fs/projection-containment.d.ts +18 -0
- package/dist/fs/projection-containment.js +40 -0
- package/dist/hooks/dispatcher.d.ts +15 -3
- package/dist/hooks/dispatcher.js +149 -6
- 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-ingest.js +11 -2
- 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/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/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/verify-source/cursor-tier1.js +7 -2
- package/dist/verify-source/openclaw-tier1.js +7 -2
- package/package.json +4 -3
|
@@ -9,7 +9,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync,
|
|
|
9
9
|
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
10
10
|
import { platform } from "node:os";
|
|
11
11
|
import { dirname, join, relative } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { assertDestinationNotSymlink, ProjectionContainmentError, } from "../fs/projection-containment.js";
|
|
13
13
|
import { agentsRefreshPlan } from "../platform/agents-md.js";
|
|
14
14
|
import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
|
|
15
15
|
import { CANONICAL_INSTALL_ROOT } from "./constants.js";
|
|
@@ -17,10 +17,14 @@ import { installerManagedGuardEre } from "./hygiene.js";
|
|
|
17
17
|
import { syncConsumerXbriefSchemas } from "./xbrief-projections.js";
|
|
18
18
|
export { CANONICAL_INSTALL_ROOT };
|
|
19
19
|
export const CORE_GLOB = ".deft/core/**";
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Refuse init/update projection writes that escape via repo-controlled symlinks
|
|
22
|
+
* (#2446) OR that would follow an in-tree destination symlink on the write path
|
|
23
|
+
* (#2912). Every consumer projection sink in this module routes through here.
|
|
24
|
+
*/
|
|
21
25
|
function projectionTarget(projectDir, ...relSegments) {
|
|
22
26
|
const target = join(projectDir, ...relSegments);
|
|
23
|
-
|
|
27
|
+
assertDestinationNotSymlink(projectDir, target);
|
|
24
28
|
return target;
|
|
25
29
|
}
|
|
26
30
|
const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
9
9
|
import { join, relative } from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
|
|
11
11
|
import { resolveLifecycleRoot } from "../layout/resolve.js";
|
|
12
12
|
import { DEV_FALLBACK } from "../platform/constants.js";
|
|
13
13
|
import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
|
|
@@ -54,7 +54,7 @@ export function rewriteProjectedSchemaContent(content) {
|
|
|
54
54
|
* Upstream vbrief/schemas/ may keep legacy paths; consumer copies must not (#2670).
|
|
55
55
|
*/
|
|
56
56
|
export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationDir) {
|
|
57
|
-
|
|
57
|
+
assertDestinationNotSymlink(projectDir, destinationDir);
|
|
58
58
|
if (!isDirectory(destinationDir))
|
|
59
59
|
return;
|
|
60
60
|
for (const rel of collectSchemaFiles(destinationDir)) {
|
|
@@ -66,7 +66,7 @@ export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationD
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
function writeFileIfChanged(projectDir, target, content) {
|
|
69
|
-
|
|
69
|
+
assertDestinationNotSymlink(projectDir, target);
|
|
70
70
|
const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
|
71
71
|
try {
|
|
72
72
|
if (readFileSync(target).equals(desired))
|
|
@@ -91,7 +91,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
|
|
|
91
91
|
throw new Error(`cannot project xbrief schemas: framework payload is missing ${CURRENT_CORE_SCHEMA}`);
|
|
92
92
|
}
|
|
93
93
|
const destinationDir = join(projectDir, MIGRATED_ARTIFACT_DIR, "schemas");
|
|
94
|
-
|
|
94
|
+
assertDestinationNotSymlink(projectDir, destinationDir);
|
|
95
95
|
mkdirSync(destinationDir, { recursive: true });
|
|
96
96
|
let changed = false;
|
|
97
97
|
for (const rel of collectSchemaFiles(sourceDir)) {
|
|
@@ -103,7 +103,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
|
|
|
103
103
|
changed = writeFileIfChanged(projectDir, destination, projected) || changed;
|
|
104
104
|
}
|
|
105
105
|
const obsoleteDestination = join(destinationDir, OBSOLETE_CORE_SCHEMA);
|
|
106
|
-
|
|
106
|
+
assertDestinationNotSymlink(projectDir, obsoleteDestination);
|
|
107
107
|
if (existsSync(obsoleteDestination)) {
|
|
108
108
|
rmSync(obsoleteDestination, { force: true });
|
|
109
109
|
changed = true;
|
|
@@ -117,7 +117,7 @@ function syncBareVersionMarkerWithPolicy(projectDir, version, allowRootFallback)
|
|
|
117
117
|
return false;
|
|
118
118
|
const canonicalRoot = join(projectDir, MIGRATED_ARTIFACT_DIR);
|
|
119
119
|
if (existsSync(canonicalRoot)) {
|
|
120
|
-
|
|
120
|
+
assertDestinationNotSymlink(projectDir, join(canonicalRoot, ".deft-version"));
|
|
121
121
|
}
|
|
122
122
|
let targetDir = projectDir;
|
|
123
123
|
try {
|
|
@@ -330,15 +330,24 @@ export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
|
|
|
330
330
|
const labelNames = [];
|
|
331
331
|
if (Array.isArray(labelsRaw)) {
|
|
332
332
|
for (const lbl of labelsRaw) {
|
|
333
|
+
let rawName;
|
|
333
334
|
if (typeof lbl === "string") {
|
|
334
|
-
|
|
335
|
+
rawName = lbl;
|
|
335
336
|
}
|
|
336
337
|
else if (lbl !== null && typeof lbl === "object" && !Array.isArray(lbl)) {
|
|
337
338
|
const name = lbl.name;
|
|
338
339
|
if (typeof name === "string" && name.length > 0) {
|
|
339
|
-
|
|
340
|
+
rawName = name;
|
|
340
341
|
}
|
|
341
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));
|
|
342
351
|
}
|
|
343
352
|
}
|
|
344
353
|
const [folder, planStatus] = STATUS_MAP[status];
|
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Port of scripts/pack_render.py — content-pack projection renderer (#1294, #1295). */
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { contentRoot } from "../content-root.js";
|
|
6
6
|
export function resolveRepoRoot() {
|
|
@@ -54,6 +54,138 @@ const SWARM_SPEC_BANNER = [
|
|
|
54
54
|
];
|
|
55
55
|
export const BANNER = `${LESSONS_BANNER.join("\n")}\n`;
|
|
56
56
|
export const DOC_TITLE = "# Lessons Learned";
|
|
57
|
+
/** Non-zero exit code for a pack-render containment / schema refusal. */
|
|
58
|
+
export const PACK_RENDER_REFUSED_EXIT_CODE = 2;
|
|
59
|
+
/**
|
|
60
|
+
* Thrown, fail-closed, when a pack record's projection path escapes CONTENT_ROOT,
|
|
61
|
+
* violates the pack path schema, or a frontmatter field carries an injection
|
|
62
|
+
* payload (null byte / newline). Remediates AppSec finding skill-pack-supply-01
|
|
63
|
+
* from tracker #2904. Refs #2914.
|
|
64
|
+
*/
|
|
65
|
+
export class PackRenderContainmentError extends Error {
|
|
66
|
+
pack;
|
|
67
|
+
field;
|
|
68
|
+
offendingValue;
|
|
69
|
+
constructor(message, details) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "PackRenderContainmentError";
|
|
72
|
+
this.pack = details.pack;
|
|
73
|
+
this.field = details.field;
|
|
74
|
+
this.offendingValue = details.offendingValue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
|
|
79
|
+
* Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`
|
|
80
|
+
* (mirrors deposit/contain.ts).
|
|
81
|
+
*/
|
|
82
|
+
function isContained(parent, child) {
|
|
83
|
+
if (parent === child) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const rel = relative(parent, child);
|
|
87
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
88
|
+
}
|
|
89
|
+
/** realpath with a resolve() fallback when the path does not exist yet. */
|
|
90
|
+
function safeRealpath(p) {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(p);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return resolve(p);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Defense-in-depth: walk each EXISTING component from CONTENT_ROOT down to the
|
|
100
|
+
* resolved output and refuse any symlink that escapes CONTENT_ROOT. Mirrors the
|
|
101
|
+
* realpath-anchored segment walk in deposit/contain.ts so a symlinked content
|
|
102
|
+
* subtree cannot redirect a projection write outside the shippable-content root.
|
|
103
|
+
*/
|
|
104
|
+
function assertAncestorContained(packName, field, rawPath, rootReal, outPath) {
|
|
105
|
+
const rel = relative(rootReal, outPath);
|
|
106
|
+
const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
107
|
+
let current = rootReal;
|
|
108
|
+
for (const segment of segments) {
|
|
109
|
+
current = join(current, segment);
|
|
110
|
+
let info;
|
|
111
|
+
try {
|
|
112
|
+
info = lstatSync(current);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Component does not exist yet; nothing below it can exist either.
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (info.isSymbolicLink()) {
|
|
119
|
+
let linkReal;
|
|
120
|
+
try {
|
|
121
|
+
linkReal = realpathSync(current);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a broken symlink`, { pack: packName, field, offendingValue: rawPath });
|
|
125
|
+
}
|
|
126
|
+
if (!isContained(rootReal, linkReal)) {
|
|
127
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a symlink ` +
|
|
128
|
+
`escaping CONTENT_ROOT (resolves to ${linkReal}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
|
|
129
|
+
}
|
|
130
|
+
current = linkReal;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a pack record's projection path against CONTENT_ROOT and refuse,
|
|
136
|
+
* fail-closed, any value that: is missing/empty/non-string, carries a null
|
|
137
|
+
* byte, is absolute, violates the pack's schema path pattern, or resolves
|
|
138
|
+
* outside CONTENT_ROOT (a `..` escape). Returns the contained absolute output
|
|
139
|
+
* path. Mirrors deposit/contain.ts segment containment. Refs #2914 / #2904.
|
|
140
|
+
*/
|
|
141
|
+
export function containedOutPath(packName, rawPath, cfg) {
|
|
142
|
+
const field = cfg.path_field ?? "path";
|
|
143
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) {
|
|
144
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} entry has a missing/empty ${field}`, { pack: packName, field, offendingValue: String(rawPath) });
|
|
145
|
+
}
|
|
146
|
+
if (rawPath.includes("\0")) {
|
|
147
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} contains a null byte`, { pack: packName, field, offendingValue: rawPath });
|
|
148
|
+
}
|
|
149
|
+
if (isAbsolute(rawPath) || /^[\\/]/.test(rawPath)) {
|
|
150
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} must be a relative path (got absolute ${JSON.stringify(rawPath)})`, { pack: packName, field, offendingValue: rawPath });
|
|
151
|
+
}
|
|
152
|
+
if (cfg.path_pattern && !cfg.path_pattern.test(rawPath)) {
|
|
153
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} violates the ` +
|
|
154
|
+
`${packName}-pack schema path pattern ${String(cfg.path_pattern)}`, { pack: packName, field, offendingValue: rawPath });
|
|
155
|
+
}
|
|
156
|
+
// Resolve with realpath semantics on the trusted root, then confirm segment
|
|
157
|
+
// containment. `resolve` collapses `..`, so an escape lands outside rootReal
|
|
158
|
+
// and is refused here even when it slipped past the schema pattern (where `.`
|
|
159
|
+
// can match a path separator).
|
|
160
|
+
const rootReal = safeRealpath(CONTENT_ROOT);
|
|
161
|
+
const outPath = resolve(rootReal, rawPath);
|
|
162
|
+
if (!isContained(rootReal, outPath)) {
|
|
163
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} escapes CONTENT_ROOT ` +
|
|
164
|
+
`(resolves to ${outPath}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
|
|
165
|
+
}
|
|
166
|
+
assertAncestorContained(packName, field, rawPath, rootReal, outPath);
|
|
167
|
+
return outPath;
|
|
168
|
+
}
|
|
169
|
+
/** Refuse a frontmatter field value that embeds a null byte. Refs #2914. */
|
|
170
|
+
function assertNoNullByte(value, packName, field) {
|
|
171
|
+
if (value.includes("\0")) {
|
|
172
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} contains a null byte`, { pack: packName, field, offendingValue: value });
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Sanitize a single-line YAML frontmatter field (skill `name`): refuse null
|
|
178
|
+
* bytes and any CR/LF. An unescaped newline would inject arbitrary YAML keys or
|
|
179
|
+
* a premature `---` document break into the generated frontmatter. Refs #2914.
|
|
180
|
+
*/
|
|
181
|
+
function sanitizeSingleLineField(value, packName, field) {
|
|
182
|
+
assertNoNullByte(value, packName, field);
|
|
183
|
+
if (/[\r\n]/.test(value)) {
|
|
184
|
+
throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} must not contain a newline ` +
|
|
185
|
+
`(YAML frontmatter injection)`, { pack: packName, field, offendingValue: value });
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
57
189
|
export const RENDER_REGISTRY = {
|
|
58
190
|
lessons: {
|
|
59
191
|
mode: "collection",
|
|
@@ -73,6 +205,7 @@ export const RENDER_REGISTRY = {
|
|
|
73
205
|
name_field: "id",
|
|
74
206
|
description_field: "description",
|
|
75
207
|
path_field: "path",
|
|
208
|
+
path_pattern: /^skills\/.+\/SKILL\.md$/,
|
|
76
209
|
body_field: "body",
|
|
77
210
|
banner: SKILLS_BANNER,
|
|
78
211
|
},
|
|
@@ -82,6 +215,7 @@ export const RENDER_REGISTRY = {
|
|
|
82
215
|
source: join(CONTENT_ROOT, "packs", "rules", "rules-pack-0.1.json"),
|
|
83
216
|
items_field: "rules",
|
|
84
217
|
path_field: "path",
|
|
218
|
+
path_pattern: /^(?:coding\/.+\.md|AGENTS\.md|main\.md)$/,
|
|
85
219
|
body_field: "body",
|
|
86
220
|
banner: RULES_BANNER,
|
|
87
221
|
},
|
|
@@ -91,6 +225,7 @@ export const RENDER_REGISTRY = {
|
|
|
91
225
|
source: join(CONTENT_ROOT, "packs", "strategies", "strategies-pack-0.1.json"),
|
|
92
226
|
items_field: "strategies",
|
|
93
227
|
path_field: "path",
|
|
228
|
+
path_pattern: /^strategies\/.+\.md$/,
|
|
94
229
|
body_field: "body",
|
|
95
230
|
banner: STRATEGIES_BANNER,
|
|
96
231
|
},
|
|
@@ -100,6 +235,7 @@ export const RENDER_REGISTRY = {
|
|
|
100
235
|
source: join(CONTENT_ROOT, "packs", "patterns", "patterns-pack-0.1.json"),
|
|
101
236
|
items_field: "patterns",
|
|
102
237
|
path_field: "path",
|
|
238
|
+
path_pattern: /^patterns\/.+\.md$/,
|
|
103
239
|
body_field: "body",
|
|
104
240
|
banner: PATTERNS_BANNER,
|
|
105
241
|
},
|
|
@@ -109,6 +245,7 @@ export const RENDER_REGISTRY = {
|
|
|
109
245
|
source: join(CONTENT_ROOT, "packs", "swarm-spec", "swarm-spec-pack-0.1.json"),
|
|
110
246
|
items_field: "entries",
|
|
111
247
|
path_field: "path",
|
|
248
|
+
path_pattern: /^swarm\/.+\.md$/,
|
|
112
249
|
body_field: "body",
|
|
113
250
|
banner: SWARM_SPEC_BANNER,
|
|
114
251
|
},
|
|
@@ -166,12 +303,18 @@ function emitDescription(description) {
|
|
|
166
303
|
return lines.join("\n");
|
|
167
304
|
}
|
|
168
305
|
export function renderSkillDocument(entry, cfg) {
|
|
169
|
-
|
|
170
|
-
|
|
306
|
+
// Sanitize every YAML frontmatter field so a malicious pack cannot inject
|
|
307
|
+
// unescaped frontmatter (newline -> extra key / premature `---`) or a null
|
|
308
|
+
// byte. Refs #2914 / #2904 (skill-pack-supply-01).
|
|
309
|
+
const nameField = cfg.name_field ?? "id";
|
|
310
|
+
const descriptionField = cfg.description_field ?? "description";
|
|
311
|
+
const name = sanitizeSingleLineField(String(entry[nameField]), "skills", nameField);
|
|
312
|
+
const description = assertNoNullByte(String(entry[descriptionField]), "skills", descriptionField);
|
|
171
313
|
const body = entry[cfg.body_field] ?? "";
|
|
172
314
|
const extra = entry.frontmatter_extra;
|
|
173
|
-
const
|
|
174
|
-
const
|
|
315
|
+
const extraStr = typeof extra === "string" ? assertNoNullByte(extra, "skills", "frontmatter_extra") : "";
|
|
316
|
+
const extraBlock = extraStr.length > 0 ? `${extraStr}\n` : "";
|
|
317
|
+
const frontmatter = `---\nname: ${name}\n${emitDescription(description)}\n${extraBlock}---\n`;
|
|
175
318
|
return `${frontmatter}${bannerText(cfg.banner)}\n${String(body)}`;
|
|
176
319
|
}
|
|
177
320
|
export function renderMarkdownDocument(entry, cfg) {
|
|
@@ -182,7 +325,7 @@ const DOCUMENT_RENDERERS = {
|
|
|
182
325
|
skill: renderSkillDocument,
|
|
183
326
|
markdown: renderMarkdownDocument,
|
|
184
327
|
};
|
|
185
|
-
function targetsForPack(pack, cfg) {
|
|
328
|
+
export function targetsForPack(packName, pack, cfg) {
|
|
186
329
|
if (cfg.mode === "collection") {
|
|
187
330
|
return [[cfg.output ?? "", renderCollection(pack, cfg)]];
|
|
188
331
|
}
|
|
@@ -203,7 +346,10 @@ function targetsForPack(pack, cfg) {
|
|
|
203
346
|
const pathField = cfg.path_field ?? "path";
|
|
204
347
|
// Document projection targets (skills/rules/strategies/patterns) are
|
|
205
348
|
// shippable content -- they live under content/ in the source repo (#1875).
|
|
206
|
-
|
|
349
|
+
// #2914: contain the pack-supplied path under CONTENT_ROOT and enforce the
|
|
350
|
+
// pack schema path pattern BEFORE render so a malicious pack JSON cannot
|
|
351
|
+
// write outside content/ via an absolute / `..` / null-byte path.
|
|
352
|
+
const outPath = containedOutPath(packName, record[pathField], cfg);
|
|
207
353
|
targets.push([outPath, docRenderer(record, cfg)]);
|
|
208
354
|
}
|
|
209
355
|
return targets;
|
|
@@ -218,7 +364,7 @@ export function collectTargets(packFilter) {
|
|
|
218
364
|
throw new Error(`pack source not found: ${cfg.source}`);
|
|
219
365
|
}
|
|
220
366
|
const pack = JSON.parse(readFileSync(cfg.source, "utf8"));
|
|
221
|
-
for (const [outPath, text] of targetsForPack(pack, cfg)) {
|
|
367
|
+
for (const [outPath, text] of targetsForPack(name, pack, cfg)) {
|
|
222
368
|
targets.push([name, outPath, text]);
|
|
223
369
|
}
|
|
224
370
|
}
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
export declare const SUSPICIOUS_TOKENS: readonly string[];
|
|
3
3
|
export declare const QUARANTINE_FENCE_OPEN = "```quarantined";
|
|
4
4
|
export declare const QUARANTINE_FENCE_CLOSE = "```";
|
|
5
|
+
/**
|
|
6
|
+
* Neutralize a body line so it cannot act as a CommonMark fence open/close.
|
|
7
|
+
* Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
|
|
8
|
+
*
|
|
9
|
+
* A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
|
|
10
|
+
* early-close the outer fence and let following attacker text render outside
|
|
11
|
+
* the quarantined info-string. Break the delimiter run by inserting a backslash
|
|
12
|
+
* after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
|
|
13
|
+
*/
|
|
14
|
+
export declare function neutralizeFenceLine(line: string): string;
|
|
5
15
|
export declare function quarantineBody(rawMd: string): string;
|
|
6
16
|
export declare function main(argv?: string[]): number;
|
|
7
17
|
//# sourceMappingURL=quarantine-ext.d.ts.map
|
|
@@ -28,6 +28,26 @@ export const SUSPICIOUS_TOKENS = [
|
|
|
28
28
|
];
|
|
29
29
|
export const QUARANTINE_FENCE_OPEN = "```quarantined";
|
|
30
30
|
export const QUARANTINE_FENCE_CLOSE = "```";
|
|
31
|
+
/**
|
|
32
|
+
* Neutralize a body line so it cannot act as a CommonMark fence open/close.
|
|
33
|
+
* Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
|
|
34
|
+
*
|
|
35
|
+
* A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
|
|
36
|
+
* early-close the outer fence and let following attacker text render outside
|
|
37
|
+
* the quarantined info-string. Break the delimiter run by inserting a backslash
|
|
38
|
+
* after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
|
|
39
|
+
*/
|
|
40
|
+
export function neutralizeFenceLine(line) {
|
|
41
|
+
const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
|
|
42
|
+
if (match === null)
|
|
43
|
+
return line;
|
|
44
|
+
const indent = match[1] ?? "";
|
|
45
|
+
const delim = match[2] ?? "";
|
|
46
|
+
const rest = match[3] ?? "";
|
|
47
|
+
// One backslash after the first fence char breaks the delimiter run.
|
|
48
|
+
// Concat (not a template literal) so "\\" is unambiguously a single "\".
|
|
49
|
+
return indent + delim[0] + "\\" + delim.slice(1) + rest;
|
|
50
|
+
}
|
|
31
51
|
function isWordChar(ch) {
|
|
32
52
|
return ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_");
|
|
33
53
|
}
|
|
@@ -133,14 +153,18 @@ export function quarantineBody(rawMd) {
|
|
|
133
153
|
sectionEnd += 1;
|
|
134
154
|
}
|
|
135
155
|
out.push(QUARANTINE_FENCE_OPEN);
|
|
136
|
-
|
|
156
|
+
// #2915: neutralize nested fence lines so they cannot early-close.
|
|
157
|
+
for (const bodyLine of lines.slice(i, sectionEnd)) {
|
|
158
|
+
out.push(neutralizeFenceLine(bodyLine));
|
|
159
|
+
}
|
|
137
160
|
out.push(QUARANTINE_FENCE_CLOSE);
|
|
138
161
|
i = sectionEnd;
|
|
139
162
|
continue;
|
|
140
163
|
}
|
|
141
164
|
if (isSuspicious(line)) {
|
|
142
165
|
out.push(QUARANTINE_FENCE_OPEN);
|
|
143
|
-
|
|
166
|
+
// #2915: neutralize in case the single line itself is fence-shaped.
|
|
167
|
+
out.push(neutralizeFenceLine(line));
|
|
144
168
|
out.push(QUARANTINE_FENCE_CLOSE);
|
|
145
169
|
i += 1;
|
|
146
170
|
continue;
|
package/dist/policy/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./capacity.js";
|
|
|
4
4
|
export * from "./decisions.js";
|
|
5
5
|
export * from "./disclosure.js";
|
|
6
6
|
export * from "./host-hooks.js";
|
|
7
|
+
export * from "./no-deft-directive.js";
|
|
7
8
|
export * from "./org-force-on-migration.js";
|
|
8
9
|
export * from "./plan-extensions.js";
|
|
9
10
|
export * from "./policy-invocation.js";
|
package/dist/policy/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./capacity.js";
|
|
|
12
12
|
export * from "./decisions.js";
|
|
13
13
|
export * from "./disclosure.js";
|
|
14
14
|
export * from "./host-hooks.js";
|
|
15
|
+
export * from "./no-deft-directive.js";
|
|
15
16
|
export * from "./org-force-on-migration.js";
|
|
16
17
|
export * from "./plan-extensions.js";
|
|
17
18
|
export * from "./policy-invocation.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official per-project opt-out flag for Directive (#2926).
|
|
3
|
+
*
|
|
4
|
+
* Presence of root `.no-deft-directive` means this project does not use
|
|
5
|
+
* Directive. Session ritual, doctor setup pressure, init/update install paths,
|
|
6
|
+
* and setup skills must honor the flag. Empty file or a short comment is enough.
|
|
7
|
+
*
|
|
8
|
+
* Product choices (v1):
|
|
9
|
+
* - Flag is **root-only** (workspace root the tool opened). Nested monorepo
|
|
10
|
+
* package roots are a documented follow-up.
|
|
11
|
+
* - Flag **wins locally** over ambient trusted-org / product-signal force-on.
|
|
12
|
+
* - Flag + deposit (`.deft/core`) is **inconsistent**: doctor **warns**;
|
|
13
|
+
* mutating install/update paths **fail closed**.
|
|
14
|
+
* - Creating the flag does **not** delete an existing deposit.
|
|
15
|
+
*/
|
|
16
|
+
/** Canonical root-only filename (lowercase). Presence = flag. */
|
|
17
|
+
export declare const NO_DEFT_DIRECTIVE_FLAG_NAME = ".no-deft-directive";
|
|
18
|
+
/** One-line operator message when Directive is disabled by the flag. */
|
|
19
|
+
export declare const NO_DEFT_DIRECTIVE_DISABLED_MESSAGE = "Directive disabled via `.no-deft-directive`";
|
|
20
|
+
/**
|
|
21
|
+
* Loud diagnosis when both the opt-out flag and a deposit exist.
|
|
22
|
+
* Product choice (#2926): doctor **warns**; install/update **fail closed**.
|
|
23
|
+
*/
|
|
24
|
+
export declare const NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE = "Inconsistent state: `.no-deft-directive` is present but a Directive deposit (`.deft/core`) also exists. Remove the flag to use Directive, or remove the deposit if opt-out is intentional.";
|
|
25
|
+
/** Recorded product choice for inconsistent flag+deposit handling. */
|
|
26
|
+
export declare const NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY: "warn-and-fail-closed";
|
|
27
|
+
export interface NoDeftDirectiveSeams {
|
|
28
|
+
readonly isFile?: (path: string) => boolean;
|
|
29
|
+
readonly isDir?: (path: string) => boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface NoDeftDirectiveState {
|
|
32
|
+
readonly present: boolean;
|
|
33
|
+
readonly flagPath: string;
|
|
34
|
+
readonly depositPresent: boolean;
|
|
35
|
+
/** True when flag and deposit are both present. */
|
|
36
|
+
readonly inconsistent: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Absolute path to the root flag file. */
|
|
39
|
+
export declare function noDeftDirectiveFlagPath(projectRoot: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Detect root `.no-deft-directive` and whether a deposit is also present.
|
|
42
|
+
* Presence is file-existence only (empty or short comment OK).
|
|
43
|
+
*/
|
|
44
|
+
export declare function detectNoDeftDirective(projectRoot: string, seams?: NoDeftDirectiveSeams): NoDeftDirectiveState;
|
|
45
|
+
/** True when the root opt-out flag file exists. */
|
|
46
|
+
export declare function isNoDeftDirectivePresent(projectRoot: string, seams?: NoDeftDirectiveSeams): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Create the root opt-out flag. Optional one-line rationale becomes a `#` comment.
|
|
49
|
+
* Does not remove an existing deposit.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createNoDeftDirectiveFlag(projectRoot: string, options?: {
|
|
52
|
+
rationale?: string;
|
|
53
|
+
}): string;
|
|
54
|
+
/**
|
|
55
|
+
* Remove the root opt-out flag when present.
|
|
56
|
+
* @returns true when a file was removed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function removeNoDeftDirectiveFlag(projectRoot: string): boolean;
|
|
59
|
+
//# sourceMappingURL=no-deft-directive.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official per-project opt-out flag for Directive (#2926).
|
|
3
|
+
*
|
|
4
|
+
* Presence of root `.no-deft-directive` means this project does not use
|
|
5
|
+
* Directive. Session ritual, doctor setup pressure, init/update install paths,
|
|
6
|
+
* and setup skills must honor the flag. Empty file or a short comment is enough.
|
|
7
|
+
*
|
|
8
|
+
* Product choices (v1):
|
|
9
|
+
* - Flag is **root-only** (workspace root the tool opened). Nested monorepo
|
|
10
|
+
* package roots are a documented follow-up.
|
|
11
|
+
* - Flag **wins locally** over ambient trusted-org / product-signal force-on.
|
|
12
|
+
* - Flag + deposit (`.deft/core`) is **inconsistent**: doctor **warns**;
|
|
13
|
+
* mutating install/update paths **fail closed**.
|
|
14
|
+
* - Creating the flag does **not** delete an existing deposit.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { assertWriteTargetSafe } from "../fs/projection-containment.js";
|
|
19
|
+
import { CANONICAL_INSTALL_ROOT } from "../init-deposit/constants.js";
|
|
20
|
+
/** Canonical root-only filename (lowercase). Presence = flag. */
|
|
21
|
+
export const NO_DEFT_DIRECTIVE_FLAG_NAME = ".no-deft-directive";
|
|
22
|
+
/** One-line operator message when Directive is disabled by the flag. */
|
|
23
|
+
export const NO_DEFT_DIRECTIVE_DISABLED_MESSAGE = "Directive disabled via `.no-deft-directive`";
|
|
24
|
+
/**
|
|
25
|
+
* Loud diagnosis when both the opt-out flag and a deposit exist.
|
|
26
|
+
* Product choice (#2926): doctor **warns**; install/update **fail closed**.
|
|
27
|
+
*/
|
|
28
|
+
export const NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE = "Inconsistent state: `.no-deft-directive` is present but a Directive deposit (`.deft/core`) also exists. Remove the flag to use Directive, or remove the deposit if opt-out is intentional.";
|
|
29
|
+
/** Recorded product choice for inconsistent flag+deposit handling. */
|
|
30
|
+
export const NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY = "warn-and-fail-closed";
|
|
31
|
+
function defaultIsFile(path) {
|
|
32
|
+
try {
|
|
33
|
+
return statSync(path).isFile();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function defaultIsDir(path) {
|
|
40
|
+
try {
|
|
41
|
+
return statSync(path).isDirectory();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Absolute path to the root flag file. */
|
|
48
|
+
export function noDeftDirectiveFlagPath(projectRoot) {
|
|
49
|
+
return resolve(projectRoot, NO_DEFT_DIRECTIVE_FLAG_NAME);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Detect root `.no-deft-directive` and whether a deposit is also present.
|
|
53
|
+
* Presence is file-existence only (empty or short comment OK).
|
|
54
|
+
*/
|
|
55
|
+
export function detectNoDeftDirective(projectRoot, seams = {}) {
|
|
56
|
+
const flagPath = noDeftDirectiveFlagPath(projectRoot);
|
|
57
|
+
const isFile = seams.isFile ?? defaultIsFile;
|
|
58
|
+
const isDir = seams.isDir ?? defaultIsDir;
|
|
59
|
+
const present = isFile(flagPath);
|
|
60
|
+
const depositPresent = isDir(join(projectRoot, CANONICAL_INSTALL_ROOT));
|
|
61
|
+
return {
|
|
62
|
+
present,
|
|
63
|
+
flagPath,
|
|
64
|
+
depositPresent,
|
|
65
|
+
inconsistent: present && depositPresent,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** True when the root opt-out flag file exists. */
|
|
69
|
+
export function isNoDeftDirectivePresent(projectRoot, seams = {}) {
|
|
70
|
+
return detectNoDeftDirective(projectRoot, seams).present;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create the root opt-out flag. Optional one-line rationale becomes a `#` comment.
|
|
74
|
+
* Does not remove an existing deposit.
|
|
75
|
+
*/
|
|
76
|
+
export function createNoDeftDirectiveFlag(projectRoot, options = {}) {
|
|
77
|
+
const path = noDeftDirectiveFlagPath(projectRoot);
|
|
78
|
+
assertWriteTargetSafe(projectRoot, path);
|
|
79
|
+
if (defaultIsDir(path)) {
|
|
80
|
+
throw new Error(`${NO_DEFT_DIRECTIVE_FLAG_NAME} exists as a directory at ${path}; remove it before creating the opt-out flag file.`);
|
|
81
|
+
}
|
|
82
|
+
const rationale = options.rationale?.trim() ?? "";
|
|
83
|
+
const body = rationale.length > 0 ? `# ${rationale}\n` : "";
|
|
84
|
+
writeFileSync(path, body, "utf8");
|
|
85
|
+
return path;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Remove the root opt-out flag when present.
|
|
89
|
+
* @returns true when a file was removed.
|
|
90
|
+
*/
|
|
91
|
+
export function removeNoDeftDirectiveFlag(projectRoot) {
|
|
92
|
+
const path = noDeftDirectiveFlagPath(projectRoot);
|
|
93
|
+
if (!existsSync(path)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
assertWriteTargetSafe(projectRoot, path);
|
|
97
|
+
if (defaultIsDir(path)) {
|
|
98
|
+
throw new Error(`${NO_DEFT_DIRECTIVE_FLAG_NAME} exists as a directory at ${path}; remove the directory manually before enabling Directive.`);
|
|
99
|
+
}
|
|
100
|
+
unlinkSync(path);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=no-deft-directive.js.map
|