@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.
Files changed (42) hide show
  1. package/dist/cache/scanner.d.ts +11 -1
  2. package/dist/cache/scanner.js +29 -4
  3. package/dist/check/gate-lists.js +1 -0
  4. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  5. package/dist/content-contracts/skills/helpers.js +35 -0
  6. package/dist/deposit/copy-tree.d.ts +19 -1
  7. package/dist/deposit/copy-tree.js +134 -5
  8. package/dist/doctor/main.js +48 -0
  9. package/dist/fs/projection-containment.d.ts +18 -0
  10. package/dist/fs/projection-containment.js +40 -0
  11. package/dist/hooks/dispatcher.d.ts +15 -3
  12. package/dist/hooks/dispatcher.js +149 -6
  13. package/dist/hooks/tools.d.ts +31 -0
  14. package/dist/hooks/tools.js +74 -0
  15. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  16. package/dist/init-deposit/agent-hooks.js +38 -2
  17. package/dist/init-deposit/hygiene.d.ts +16 -0
  18. package/dist/init-deposit/hygiene.js +26 -0
  19. package/dist/init-deposit/init-dispatch.js +28 -0
  20. package/dist/init-deposit/prettierignore.js +2 -2
  21. package/dist/init-deposit/refresh.js +38 -7
  22. package/dist/init-deposit/scaffold.js +7 -3
  23. package/dist/init-deposit/xbrief-projections.js +6 -6
  24. package/dist/intake/issue-ingest.js +11 -2
  25. package/dist/packs/pack-render.d.ts +33 -0
  26. package/dist/packs/pack-render.js +155 -9
  27. package/dist/packs/quarantine-ext.d.ts +10 -0
  28. package/dist/packs/quarantine-ext.js +26 -2
  29. package/dist/policy/index.d.ts +1 -0
  30. package/dist/policy/index.js +1 -0
  31. package/dist/policy/no-deft-directive.d.ts +59 -0
  32. package/dist/policy/no-deft-directive.js +103 -0
  33. package/dist/policy/org-force-on-migration.d.ts +52 -0
  34. package/dist/policy/org-force-on-migration.js +260 -22
  35. package/dist/policy/runtime-authority.d.ts +41 -0
  36. package/dist/policy/runtime-authority.js +274 -0
  37. package/dist/session/session-start-hook.d.ts +3 -0
  38. package/dist/session/session-start-hook.js +15 -0
  39. package/dist/session/session-start.js +30 -0
  40. package/dist/verify-source/cursor-tier1.js +7 -2
  41. package/dist/verify-source/openclaw-tier1.js +7 -2
  42. 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 { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
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
- /** Refuse init/update projection writes that escape via repo-controlled symlinks (#2446). */
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
- assertProjectionContained(projectDir, target);
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 { assertProjectionContained } from "../fs/projection-containment.js";
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
- assertProjectionContained(projectDir, destinationDir);
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
- assertProjectionContained(projectDir, target);
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
- assertProjectionContained(projectDir, destinationDir);
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
- assertProjectionContained(projectDir, obsoleteDestination);
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
- assertProjectionContained(projectDir, join(canonicalRoot, ".deft-version"));
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
- labelNames.push(lbl);
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
- labelNames.push(name);
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
- const name = entry[cfg.name_field ?? "id"];
170
- const description = entry[cfg.description_field ?? "description"];
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 extraBlock = typeof extra === "string" && extra.length > 0 ? `${extra}\n` : "";
174
- const frontmatter = `---\nname: ${String(name)}\n${emitDescription(String(description))}\n${extraBlock}---\n`;
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
- const outPath = join(CONTENT_ROOT, String(record[pathField]));
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
- out.push(...lines.slice(i, sectionEnd));
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
- out.push(line);
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;
@@ -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";
@@ -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