@deftai/directive-core 0.87.0 → 0.89.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 (193) hide show
  1. package/dist/authz/actions.d.ts +55 -0
  2. package/dist/authz/actions.js +125 -0
  3. package/dist/authz/classify.d.ts +23 -0
  4. package/dist/authz/classify.js +217 -0
  5. package/dist/authz/closed-verb.d.ts +42 -0
  6. package/dist/authz/closed-verb.js +279 -0
  7. package/dist/authz/evaluate.d.ts +41 -0
  8. package/dist/authz/evaluate.js +298 -0
  9. package/dist/authz/index.d.ts +15 -0
  10. package/dist/authz/index.js +15 -0
  11. package/dist/authz/origin.d.ts +28 -0
  12. package/dist/authz/origin.js +64 -0
  13. package/dist/authz/paths.d.ts +6 -0
  14. package/dist/authz/paths.js +19 -0
  15. package/dist/authz/store.d.ts +40 -0
  16. package/dist/authz/store.js +317 -0
  17. package/dist/authz/templates.d.ts +139 -0
  18. package/dist/authz/templates.js +241 -0
  19. package/dist/authz/types.d.ts +111 -0
  20. package/dist/authz/types.js +41 -0
  21. package/dist/authz/verb-classification.d.ts +44 -0
  22. package/dist/authz/verb-classification.js +237 -0
  23. package/dist/branch/evaluate.js +6 -1
  24. package/dist/cache/fetch.js +10 -5
  25. package/dist/cache/io.d.ts +9 -2
  26. package/dist/cache/io.js +30 -10
  27. package/dist/cache/scanner.d.ts +11 -1
  28. package/dist/cache/scanner.js +29 -4
  29. package/dist/cache/task-cache/store.js +11 -7
  30. package/dist/capacity/backfill.js +10 -4
  31. package/dist/category-b-namespace/index.js +10 -4
  32. package/dist/check/gate-lists.js +1 -0
  33. package/dist/codebase/default-extractor.js +3 -0
  34. package/dist/codebase/map.js +11 -4
  35. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  36. package/dist/content-contracts/skills/helpers.js +35 -0
  37. package/dist/deposit/copy-tree.d.ts +19 -1
  38. package/dist/deposit/copy-tree.js +134 -5
  39. package/dist/doctor/doctor-state.js +28 -4
  40. package/dist/doctor/main.d.ts +10 -0
  41. package/dist/doctor/main.js +208 -0
  42. package/dist/doctor/types.d.ts +11 -0
  43. package/dist/escalation/actions.d.ts +63 -0
  44. package/dist/escalation/actions.js +137 -0
  45. package/dist/escalation/index.d.ts +8 -0
  46. package/dist/escalation/index.js +8 -0
  47. package/dist/escalation/paths.d.ts +3 -0
  48. package/dist/escalation/paths.js +10 -0
  49. package/dist/escalation/store.d.ts +17 -0
  50. package/dist/escalation/store.js +172 -0
  51. package/dist/escalation/types.d.ts +73 -0
  52. package/dist/escalation/types.js +43 -0
  53. package/dist/eval/crud-telemetry.js +30 -10
  54. package/dist/eval/health.js +22 -7
  55. package/dist/eval/readback.js +11 -10
  56. package/dist/eval/run.js +32 -16
  57. package/dist/events/attribution-enrichment.js +10 -4
  58. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  59. package/dist/finish-loop/directive-finish-loop.js +239 -0
  60. package/dist/finish-loop/grant-gate.d.ts +31 -0
  61. package/dist/finish-loop/grant-gate.js +169 -0
  62. package/dist/finish-loop/index.d.ts +11 -0
  63. package/dist/finish-loop/index.js +11 -0
  64. package/dist/finish-loop/main.d.ts +35 -0
  65. package/dist/finish-loop/main.js +365 -0
  66. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  67. package/dist/finish-loop/pr-finish-loop.js +217 -0
  68. package/dist/finish-loop/progress.d.ts +17 -0
  69. package/dist/finish-loop/progress.js +45 -0
  70. package/dist/finish-loop/queue.d.ts +16 -0
  71. package/dist/finish-loop/queue.js +65 -0
  72. package/dist/finish-loop/types.d.ts +52 -0
  73. package/dist/finish-loop/types.js +10 -0
  74. package/dist/fs/contained-write.d.ts +101 -0
  75. package/dist/fs/contained-write.js +281 -0
  76. package/dist/fs/projection-containment.d.ts +18 -0
  77. package/dist/fs/projection-containment.js +40 -0
  78. package/dist/hooks/classify/classify.d.ts +10 -0
  79. package/dist/hooks/classify/classify.js +37 -0
  80. package/dist/hooks/classify/index.d.ts +14 -0
  81. package/dist/hooks/classify/index.js +13 -0
  82. package/dist/hooks/classify/paths.d.ts +22 -0
  83. package/dist/hooks/classify/paths.js +72 -0
  84. package/dist/hooks/classify/payload.d.ts +16 -0
  85. package/dist/hooks/classify/payload.js +45 -0
  86. package/dist/hooks/classify/stdin.d.ts +12 -0
  87. package/dist/hooks/classify/stdin.js +63 -0
  88. package/dist/hooks/classify/tool-name.d.ts +18 -0
  89. package/dist/hooks/classify/tool-name.js +85 -0
  90. package/dist/hooks/classify/types.d.ts +41 -0
  91. package/dist/hooks/classify/types.js +7 -0
  92. package/dist/hooks/dispatcher.d.ts +30 -15
  93. package/dist/hooks/dispatcher.js +326 -134
  94. package/dist/hooks/fixtures/cases.d.ts +44 -0
  95. package/dist/hooks/fixtures/cases.js +595 -0
  96. package/dist/hooks/fixtures/index.d.ts +2 -0
  97. package/dist/hooks/fixtures/index.js +2 -0
  98. package/dist/hooks/index.d.ts +2 -0
  99. package/dist/hooks/index.js +2 -0
  100. package/dist/hooks/tools.d.ts +31 -0
  101. package/dist/hooks/tools.js +74 -0
  102. package/dist/index.d.ts +4 -0
  103. package/dist/index.js +5 -0
  104. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  105. package/dist/init-deposit/agent-hooks.js +65 -10
  106. package/dist/init-deposit/gitignore.js +11 -3
  107. package/dist/init-deposit/headless-manifest.js +12 -4
  108. package/dist/init-deposit/hygiene.d.ts +16 -0
  109. package/dist/init-deposit/hygiene.js +26 -0
  110. package/dist/init-deposit/init-dispatch.js +28 -0
  111. package/dist/init-deposit/migrate.d.ts +1 -1
  112. package/dist/init-deposit/migrate.js +13 -3
  113. package/dist/init-deposit/prettierignore.js +12 -5
  114. package/dist/init-deposit/refresh.js +38 -7
  115. package/dist/init-deposit/scaffold.js +42 -29
  116. package/dist/init-deposit/xbrief-projections.js +17 -10
  117. package/dist/intake/candidates-log.js +46 -35
  118. package/dist/intake/github-body-cli.d.ts +6 -0
  119. package/dist/intake/github-body-cli.js +17 -0
  120. package/dist/intake/github-body.d.ts +46 -3
  121. package/dist/intake/github-body.js +191 -20
  122. package/dist/intake/issue-emit.d.ts +1 -0
  123. package/dist/intake/issue-emit.js +41 -23
  124. package/dist/intake/issue-ingest.js +11 -2
  125. package/dist/lifecycle/event-detect.js +12 -4
  126. package/dist/lifecycle/events.js +29 -20
  127. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  128. package/dist/packs/pack-render.d.ts +33 -0
  129. package/dist/packs/pack-render.js +172 -13
  130. package/dist/packs/quarantine-ext.d.ts +10 -0
  131. package/dist/packs/quarantine-ext.js +26 -2
  132. package/dist/plan-sequence/store.js +11 -4
  133. package/dist/policy/hotfix-criteria.d.ts +79 -0
  134. package/dist/policy/hotfix-criteria.js +197 -0
  135. package/dist/policy/index.d.ts +5 -0
  136. package/dist/policy/index.js +32 -1
  137. package/dist/policy/intent-ceiling.d.ts +79 -0
  138. package/dist/policy/intent-ceiling.js +181 -0
  139. package/dist/policy/no-deft-directive.d.ts +59 -0
  140. package/dist/policy/no-deft-directive.js +110 -0
  141. package/dist/policy/org-force-on-migration.d.ts +52 -0
  142. package/dist/policy/org-force-on-migration.js +269 -27
  143. package/dist/policy/require-human-merge.d.ts +75 -0
  144. package/dist/policy/require-human-merge.js +324 -0
  145. package/dist/policy/resolve.js +17 -6
  146. package/dist/policy/runtime-authority.d.ts +56 -2
  147. package/dist/policy/runtime-authority.js +297 -3
  148. package/dist/policy/write-fence.d.ts +78 -0
  149. package/dist/policy/write-fence.js +164 -0
  150. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  151. package/dist/pr-wait-mergeable/cascade.js +28 -0
  152. package/dist/preflight/evaluate.js +10 -0
  153. package/dist/product-signal/consent.js +21 -5
  154. package/dist/product-signal/submit.js +22 -12
  155. package/dist/release/build-dist-runner.js +5 -2
  156. package/dist/release/build-dist.d.ts +19 -1
  157. package/dist/release/build-dist.js +50 -2
  158. package/dist/release/native-steps.js +7 -2
  159. package/dist/release-publish/pipeline.d.ts +13 -0
  160. package/dist/release-publish/pipeline.js +46 -1
  161. package/dist/scope/audit-log.js +19 -24
  162. package/dist/scope/decompose.js +10 -5
  163. package/dist/scope/decomposed-refs.js +18 -3
  164. package/dist/scope/demote.js +9 -2
  165. package/dist/scope/undo.js +9 -2
  166. package/dist/session/release-availability.js +26 -6
  167. package/dist/session/ritual-sentinel.js +19 -12
  168. package/dist/session/session-start-hook.d.ts +3 -0
  169. package/dist/session/session-start-hook.js +15 -0
  170. package/dist/session/session-start.js +37 -0
  171. package/dist/staleness-tickler/state.js +26 -6
  172. package/dist/swarm/launch.js +13 -3
  173. package/dist/swarm/routing.js +9 -3
  174. package/dist/task-surface/index.js +24 -9
  175. package/dist/triage/bootstrap/gitignore.js +53 -10
  176. package/dist/triage/cache-path.js +10 -3
  177. package/dist/triage/welcome/summary.js +11 -4
  178. package/dist/triage/welcome/writers.js +45 -16
  179. package/dist/validate-content/validate-links.js +5 -0
  180. package/dist/value/readback.js +11 -6
  181. package/dist/vbrief-activate/activate.js +12 -4
  182. package/dist/vbrief-build/project-definition-io.js +14 -6
  183. package/dist/verify-source/contained-writes.d.ts +59 -0
  184. package/dist/verify-source/contained-writes.js +272 -0
  185. package/dist/verify-source/cursor-tier1.js +7 -2
  186. package/dist/verify-source/index.d.ts +1 -0
  187. package/dist/verify-source/index.js +1 -0
  188. package/dist/verify-source/openclaw-tier1.js +19 -1
  189. package/dist/verify-source/verify-stubs.js +3 -0
  190. package/dist/xbrief-migrate/migrate-project.js +26 -12
  191. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  192. package/dist/xbrief-migrate/transforms.js +37 -14
  193. package/package.json +16 -3
@@ -1,8 +1,21 @@
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 } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { contentRoot } from "../content-root.js";
6
+ import { containedWrite } from "../fs/contained-write.js";
7
+ /** Write under the parent directory as containment root (#2980 wave D). */
8
+ function writeContainedFile(output, text) {
9
+ const abs = resolve(output);
10
+ const dir = dirname(abs);
11
+ mkdirSync(dir, { recursive: true });
12
+ containedWrite({
13
+ root: dir,
14
+ target: basename(abs),
15
+ data: text,
16
+ mode: "replace",
17
+ });
18
+ }
6
19
  export function resolveRepoRoot() {
7
20
  return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..");
8
21
  }
@@ -54,6 +67,138 @@ const SWARM_SPEC_BANNER = [
54
67
  ];
55
68
  export const BANNER = `${LESSONS_BANNER.join("\n")}\n`;
56
69
  export const DOC_TITLE = "# Lessons Learned";
70
+ /** Non-zero exit code for a pack-render containment / schema refusal. */
71
+ export const PACK_RENDER_REFUSED_EXIT_CODE = 2;
72
+ /**
73
+ * Thrown, fail-closed, when a pack record's projection path escapes CONTENT_ROOT,
74
+ * violates the pack path schema, or a frontmatter field carries an injection
75
+ * payload (null byte / newline). Remediates AppSec finding skill-pack-supply-01
76
+ * from tracker #2904. Refs #2914.
77
+ */
78
+ export class PackRenderContainmentError extends Error {
79
+ pack;
80
+ field;
81
+ offendingValue;
82
+ constructor(message, details) {
83
+ super(message);
84
+ this.name = "PackRenderContainmentError";
85
+ this.pack = details.pack;
86
+ this.field = details.field;
87
+ this.offendingValue = details.offendingValue;
88
+ }
89
+ }
90
+ /**
91
+ * Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
92
+ * Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`
93
+ * (mirrors deposit/contain.ts).
94
+ */
95
+ function isContained(parent, child) {
96
+ if (parent === child) {
97
+ return true;
98
+ }
99
+ const rel = relative(parent, child);
100
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
101
+ }
102
+ /** realpath with a resolve() fallback when the path does not exist yet. */
103
+ function safeRealpath(p) {
104
+ try {
105
+ return realpathSync(p);
106
+ }
107
+ catch {
108
+ return resolve(p);
109
+ }
110
+ }
111
+ /**
112
+ * Defense-in-depth: walk each EXISTING component from CONTENT_ROOT down to the
113
+ * resolved output and refuse any symlink that escapes CONTENT_ROOT. Mirrors the
114
+ * realpath-anchored segment walk in deposit/contain.ts so a symlinked content
115
+ * subtree cannot redirect a projection write outside the shippable-content root.
116
+ */
117
+ function assertAncestorContained(packName, field, rawPath, rootReal, outPath) {
118
+ const rel = relative(rootReal, outPath);
119
+ const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
120
+ let current = rootReal;
121
+ for (const segment of segments) {
122
+ current = join(current, segment);
123
+ let info;
124
+ try {
125
+ info = lstatSync(current);
126
+ }
127
+ catch {
128
+ // Component does not exist yet; nothing below it can exist either.
129
+ break;
130
+ }
131
+ if (info.isSymbolicLink()) {
132
+ let linkReal;
133
+ try {
134
+ linkReal = realpathSync(current);
135
+ }
136
+ catch {
137
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a broken symlink`, { pack: packName, field, offendingValue: rawPath });
138
+ }
139
+ if (!isContained(rootReal, linkReal)) {
140
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a symlink ` +
141
+ `escaping CONTENT_ROOT (resolves to ${linkReal}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
142
+ }
143
+ current = linkReal;
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Resolve a pack record's projection path against CONTENT_ROOT and refuse,
149
+ * fail-closed, any value that: is missing/empty/non-string, carries a null
150
+ * byte, is absolute, violates the pack's schema path pattern, or resolves
151
+ * outside CONTENT_ROOT (a `..` escape). Returns the contained absolute output
152
+ * path. Mirrors deposit/contain.ts segment containment. Refs #2914 / #2904.
153
+ */
154
+ export function containedOutPath(packName, rawPath, cfg) {
155
+ const field = cfg.path_field ?? "path";
156
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
157
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} entry has a missing/empty ${field}`, { pack: packName, field, offendingValue: String(rawPath) });
158
+ }
159
+ if (rawPath.includes("\0")) {
160
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} contains a null byte`, { pack: packName, field, offendingValue: rawPath });
161
+ }
162
+ if (isAbsolute(rawPath) || /^[\\/]/.test(rawPath)) {
163
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} must be a relative path (got absolute ${JSON.stringify(rawPath)})`, { pack: packName, field, offendingValue: rawPath });
164
+ }
165
+ if (cfg.path_pattern && !cfg.path_pattern.test(rawPath)) {
166
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} violates the ` +
167
+ `${packName}-pack schema path pattern ${String(cfg.path_pattern)}`, { pack: packName, field, offendingValue: rawPath });
168
+ }
169
+ // Resolve with realpath semantics on the trusted root, then confirm segment
170
+ // containment. `resolve` collapses `..`, so an escape lands outside rootReal
171
+ // and is refused here even when it slipped past the schema pattern (where `.`
172
+ // can match a path separator).
173
+ const rootReal = safeRealpath(CONTENT_ROOT);
174
+ const outPath = resolve(rootReal, rawPath);
175
+ if (!isContained(rootReal, outPath)) {
176
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} escapes CONTENT_ROOT ` +
177
+ `(resolves to ${outPath}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
178
+ }
179
+ assertAncestorContained(packName, field, rawPath, rootReal, outPath);
180
+ return outPath;
181
+ }
182
+ /** Refuse a frontmatter field value that embeds a null byte. Refs #2914. */
183
+ function assertNoNullByte(value, packName, field) {
184
+ if (value.includes("\0")) {
185
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} contains a null byte`, { pack: packName, field, offendingValue: value });
186
+ }
187
+ return value;
188
+ }
189
+ /**
190
+ * Sanitize a single-line YAML frontmatter field (skill `name`): refuse null
191
+ * bytes and any CR/LF. An unescaped newline would inject arbitrary YAML keys or
192
+ * a premature `---` document break into the generated frontmatter. Refs #2914.
193
+ */
194
+ function sanitizeSingleLineField(value, packName, field) {
195
+ assertNoNullByte(value, packName, field);
196
+ if (/[\r\n]/.test(value)) {
197
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} must not contain a newline ` +
198
+ `(YAML frontmatter injection)`, { pack: packName, field, offendingValue: value });
199
+ }
200
+ return value;
201
+ }
57
202
  export const RENDER_REGISTRY = {
58
203
  lessons: {
59
204
  mode: "collection",
@@ -73,6 +218,7 @@ export const RENDER_REGISTRY = {
73
218
  name_field: "id",
74
219
  description_field: "description",
75
220
  path_field: "path",
221
+ path_pattern: /^skills\/.+\/SKILL\.md$/,
76
222
  body_field: "body",
77
223
  banner: SKILLS_BANNER,
78
224
  },
@@ -82,6 +228,7 @@ export const RENDER_REGISTRY = {
82
228
  source: join(CONTENT_ROOT, "packs", "rules", "rules-pack-0.1.json"),
83
229
  items_field: "rules",
84
230
  path_field: "path",
231
+ path_pattern: /^(?:coding\/.+\.md|AGENTS\.md|main\.md)$/,
85
232
  body_field: "body",
86
233
  banner: RULES_BANNER,
87
234
  },
@@ -91,6 +238,7 @@ export const RENDER_REGISTRY = {
91
238
  source: join(CONTENT_ROOT, "packs", "strategies", "strategies-pack-0.1.json"),
92
239
  items_field: "strategies",
93
240
  path_field: "path",
241
+ path_pattern: /^strategies\/.+\.md$/,
94
242
  body_field: "body",
95
243
  banner: STRATEGIES_BANNER,
96
244
  },
@@ -100,6 +248,7 @@ export const RENDER_REGISTRY = {
100
248
  source: join(CONTENT_ROOT, "packs", "patterns", "patterns-pack-0.1.json"),
101
249
  items_field: "patterns",
102
250
  path_field: "path",
251
+ path_pattern: /^patterns\/.+\.md$/,
103
252
  body_field: "body",
104
253
  banner: PATTERNS_BANNER,
105
254
  },
@@ -109,6 +258,7 @@ export const RENDER_REGISTRY = {
109
258
  source: join(CONTENT_ROOT, "packs", "swarm-spec", "swarm-spec-pack-0.1.json"),
110
259
  items_field: "entries",
111
260
  path_field: "path",
261
+ path_pattern: /^swarm\/.+\.md$/,
112
262
  body_field: "body",
113
263
  banner: SWARM_SPEC_BANNER,
114
264
  },
@@ -166,12 +316,18 @@ function emitDescription(description) {
166
316
  return lines.join("\n");
167
317
  }
168
318
  export function renderSkillDocument(entry, cfg) {
169
- const name = entry[cfg.name_field ?? "id"];
170
- const description = entry[cfg.description_field ?? "description"];
319
+ // Sanitize every YAML frontmatter field so a malicious pack cannot inject
320
+ // unescaped frontmatter (newline -> extra key / premature `---`) or a null
321
+ // byte. Refs #2914 / #2904 (skill-pack-supply-01).
322
+ const nameField = cfg.name_field ?? "id";
323
+ const descriptionField = cfg.description_field ?? "description";
324
+ const name = sanitizeSingleLineField(String(entry[nameField]), "skills", nameField);
325
+ const description = assertNoNullByte(String(entry[descriptionField]), "skills", descriptionField);
171
326
  const body = entry[cfg.body_field] ?? "";
172
327
  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`;
328
+ const extraStr = typeof extra === "string" ? assertNoNullByte(extra, "skills", "frontmatter_extra") : "";
329
+ const extraBlock = extraStr.length > 0 ? `${extraStr}\n` : "";
330
+ const frontmatter = `---\nname: ${name}\n${emitDescription(description)}\n${extraBlock}---\n`;
175
331
  return `${frontmatter}${bannerText(cfg.banner)}\n${String(body)}`;
176
332
  }
177
333
  export function renderMarkdownDocument(entry, cfg) {
@@ -182,7 +338,7 @@ const DOCUMENT_RENDERERS = {
182
338
  skill: renderSkillDocument,
183
339
  markdown: renderMarkdownDocument,
184
340
  };
185
- function targetsForPack(pack, cfg) {
341
+ export function targetsForPack(packName, pack, cfg) {
186
342
  if (cfg.mode === "collection") {
187
343
  return [[cfg.output ?? "", renderCollection(pack, cfg)]];
188
344
  }
@@ -203,7 +359,10 @@ function targetsForPack(pack, cfg) {
203
359
  const pathField = cfg.path_field ?? "path";
204
360
  // Document projection targets (skills/rules/strategies/patterns) are
205
361
  // shippable content -- they live under content/ in the source repo (#1875).
206
- const outPath = join(CONTENT_ROOT, String(record[pathField]));
362
+ // #2914: contain the pack-supplied path under CONTENT_ROOT and enforce the
363
+ // pack schema path pattern BEFORE render so a malicious pack JSON cannot
364
+ // write outside content/ via an absolute / `..` / null-byte path.
365
+ const outPath = containedOutPath(packName, record[pathField], cfg);
207
366
  targets.push([outPath, docRenderer(record, cfg)]);
208
367
  }
209
368
  return targets;
@@ -218,7 +377,7 @@ export function collectTargets(packFilter) {
218
377
  throw new Error(`pack source not found: ${cfg.source}`);
219
378
  }
220
379
  const pack = JSON.parse(readFileSync(cfg.source, "utf8"));
221
- for (const [outPath, text] of targetsForPack(pack, cfg)) {
380
+ for (const [outPath, text] of targetsForPack(name, pack, cfg)) {
222
381
  targets.push([name, outPath, text]);
223
382
  }
224
383
  }
@@ -236,8 +395,8 @@ export function renderFile(source) {
236
395
  }
237
396
  export function writeRender(source, output) {
238
397
  const text = renderFile(source);
239
- mkdirSync(dirname(output), { recursive: true });
240
- writeFileSync(output, text, "utf8");
398
+ // #2980 wave D: product write sink routes through containedWrite.
399
+ writeContainedFile(output, text);
241
400
  return text;
242
401
  }
243
402
  export function checkDrift(source, output) {
@@ -295,9 +454,9 @@ function runMultipack(packFilter, check) {
295
454
  process.stdout.write(`pack-drift: all ${targets.length} projection(s) in sync.\n`);
296
455
  return 0;
297
456
  }
457
+ // #2980 wave D: product write sinks route through containedWrite.
298
458
  for (const [_name, outPath, text] of targets) {
299
- mkdirSync(dirname(outPath), { recursive: true });
300
- writeFileSync(outPath, text, "utf8");
459
+ writeContainedFile(outPath, text);
301
460
  }
302
461
  process.stdout.write(`Rendered ${targets.length} projection(s) across ${Object.keys(RENDER_REGISTRY).length} pack(s).\n`);
303
462
  return 0;
@@ -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;
@@ -1,5 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { containedWrite } from "../fs/contained-write.js";
3
4
  import { PLAN_SEQUENCE_FILENAME, parsePlanSequence } from "./types.js";
4
5
  export function planSequencePath(projectRoot) {
5
6
  return join(projectRoot, ".deft", PLAN_SEQUENCE_FILENAME);
@@ -14,8 +15,14 @@ export function readPlanSequence(projectRoot) {
14
15
  }
15
16
  export function writePlanSequence(projectRoot, sequence) {
16
17
  const path = planSequencePath(projectRoot);
17
- mkdirSync(dirname(path), { recursive: true });
18
- writeFileSync(path, `${JSON.stringify(sequence, null, 2)}\n`, "utf8");
18
+ // #2980 wave D: product write sink routes through containedWrite.
19
+ const root = resolve(projectRoot);
20
+ containedWrite({
21
+ root,
22
+ target: path,
23
+ data: `${JSON.stringify(sequence, null, 2)}\n`,
24
+ mode: "replace",
25
+ });
19
26
  return path;
20
27
  }
21
28
  export function clearPlanSequence(projectRoot) {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Typed hotfix classifier (#1193 R2 / Wave 2 of #2948).
3
+ *
4
+ * Hard structural predicates — not agent judgement. Agent may propose the
5
+ * label `hotfix-candidate` only; a human promotes to `hotfix`. Pipeline keys
6
+ * off `hotfix` exclusively; `hotfix-candidate` has zero pipeline meaning.
7
+ */
8
+ import type { HotfixCriteria } from "@deftai/directive-types";
9
+ export declare const FIELD_HOTFIX_CRITERIA = "plan.policy.hotfixCriteria";
10
+ export declare const FIELD_HOTFIX_CRITERIA_CLI_ALIAS = "hotfixCriteria";
11
+ /** Agent-proposed beacon only — never pipeline-authorizing. */
12
+ export declare const HOTFIX_CANDIDATE_LABEL: "hotfix-candidate";
13
+ /** Human-promoted label; deploy pipeline keys off this exclusively. */
14
+ export declare const HOTFIX_LABEL: "hotfix";
15
+ export declare const DEFAULT_HOTFIX_MAX_LINES = 10;
16
+ export declare const DEFAULT_HOTFIX_MAX_FILES = 2;
17
+ /** Paths that never qualify as hotfix (deploy / CI / migrations / secrets). */
18
+ export declare const DEFAULT_FORBIDDEN_PATH_GLOBS: readonly string[];
19
+ export declare const DEFAULT_HOTFIX_CRITERIA: Required<HotfixCriteria>;
20
+ export interface HotfixEligibilityInput {
21
+ /** Total lines added+removed (or equivalent structural line count). */
22
+ readonly linesChanged: number;
23
+ /** Distinct files touched. */
24
+ readonly filesChanged: number;
25
+ /** Project-relative POSIX paths in the diff. */
26
+ readonly paths: readonly string[];
27
+ /** Pure revert of a prior change — always eligible. */
28
+ readonly isPureRevert?: boolean;
29
+ /** One-character / trivial edit signal (always eligible when true with restoresGreen). */
30
+ readonly isOneCharacterEdit?: boolean;
31
+ /** Refactors of any size are never hotfix. */
32
+ readonly isRefactor?: boolean;
33
+ /** New package / dependency entries. */
34
+ readonly addsNewDependency?: boolean;
35
+ /** Adds, removes, or renames an exported symbol/surface. */
36
+ readonly changesExportedSurface?: boolean;
37
+ /** Schema or migration changes. */
38
+ readonly touchesSchemaOrMigration?: boolean;
39
+ /** New handler/route/media-type surface. */
40
+ readonly addsNewHandlerOrRoute?: boolean;
41
+ /** Build was green on main; this change restores green. */
42
+ readonly restoresGreen?: boolean;
43
+ /** Criteria override (defaults + project typed policy). */
44
+ readonly criteria?: HotfixCriteria | null;
45
+ }
46
+ export type HotfixDenyReason = "refactor" | "new-export" | "new-dependency" | "schema-migration" | "new-handler-route" | "forbidden-path" | "too-many-lines" | "too-many-files" | "not-restoring-green";
47
+ export interface HotfixEligibilityResult {
48
+ readonly eligible: boolean;
49
+ /** When eligible, the agent may apply this label only. */
50
+ readonly proposedLabel: typeof HOTFIX_CANDIDATE_LABEL | null;
51
+ readonly reasons: readonly string[];
52
+ readonly denyCodes: readonly HotfixDenyReason[];
53
+ readonly criteria: Required<HotfixCriteria>;
54
+ }
55
+ /**
56
+ * Pure hotfix eligibility evaluator (#1193 R2).
57
+ *
58
+ * Eligible (agent may propose `hotfix-candidate`):
59
+ * - pure revert always
60
+ * - one-character edit that restores green
61
+ * - small fix: ≤ maxLines AND ≤ maxFiles, no new deps/exports/schema, no forbidden
62
+ * paths, not a refactor, restores green
63
+ *
64
+ * Never eligible: refactors, new handlers/routes, export surface changes, forbidden paths.
65
+ */
66
+ export declare function evaluateHotfixEligibility(input: HotfixEligibilityInput): HotfixEligibilityResult;
67
+ /** Resolve typed hotfixCriteria from a raw policy block value. */
68
+ export declare function resolveHotfixCriteria(raw: unknown): Required<HotfixCriteria>;
69
+ export interface HotfixCriteriaPolicyField {
70
+ readonly name: string;
71
+ readonly current: Required<HotfixCriteria>;
72
+ readonly default: Required<HotfixCriteria>;
73
+ readonly source: string;
74
+ }
75
+ /** Inspector row for `policy:show --field=hotfixCriteria`. */
76
+ export declare function inspectHotfixCriteria(data: Record<string, unknown> | null): HotfixCriteriaPolicyField;
77
+ /** Load hotfix criteria from project root. */
78
+ export declare function loadHotfixCriteriaFromProject(projectRoot: string): Required<HotfixCriteria>;
79
+ //# sourceMappingURL=hotfix-criteria.d.ts.map
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Typed hotfix classifier (#1193 R2 / Wave 2 of #2948).
3
+ *
4
+ * Hard structural predicates — not agent judgement. Agent may propose the
5
+ * label `hotfix-candidate` only; a human promotes to `hotfix`. Pipeline keys
6
+ * off `hotfix` exclusively; `hotfix-candidate` has zero pipeline meaning.
7
+ */
8
+ import { matchAny } from "../orchestration/pathspec.js";
9
+ import { readPlanPolicy } from "./plan-extensions.js";
10
+ import { loadProjectDefinition } from "./resolve.js";
11
+ export const FIELD_HOTFIX_CRITERIA = "plan.policy.hotfixCriteria";
12
+ export const FIELD_HOTFIX_CRITERIA_CLI_ALIAS = "hotfixCriteria";
13
+ /** Agent-proposed beacon only — never pipeline-authorizing. */
14
+ export const HOTFIX_CANDIDATE_LABEL = "hotfix-candidate";
15
+ /** Human-promoted label; deploy pipeline keys off this exclusively. */
16
+ export const HOTFIX_LABEL = "hotfix";
17
+ export const DEFAULT_HOTFIX_MAX_LINES = 10;
18
+ export const DEFAULT_HOTFIX_MAX_FILES = 2;
19
+ /** Paths that never qualify as hotfix (deploy / CI / migrations / secrets). */
20
+ export const DEFAULT_FORBIDDEN_PATH_GLOBS = [
21
+ "Dockerfile",
22
+ "**/Dockerfile",
23
+ "fly.toml",
24
+ "**/fly.toml",
25
+ ".github/workflows/**",
26
+ "migrations/**",
27
+ "**/migrations/**",
28
+ "**/*secret*",
29
+ "**/*billing*",
30
+ "**/.env",
31
+ "**/.env.*",
32
+ "**/auth/**",
33
+ "**/secrets/**",
34
+ ];
35
+ export const DEFAULT_HOTFIX_CRITERIA = {
36
+ maxLines: DEFAULT_HOTFIX_MAX_LINES,
37
+ maxFiles: DEFAULT_HOTFIX_MAX_FILES,
38
+ forbiddenPathGlobs: DEFAULT_FORBIDDEN_PATH_GLOBS,
39
+ };
40
+ function normalizeCriteria(raw) {
41
+ const maxLines = typeof raw?.maxLines === "number" && Number.isInteger(raw.maxLines) && raw.maxLines >= 0
42
+ ? raw.maxLines
43
+ : DEFAULT_HOTFIX_MAX_LINES;
44
+ const maxFiles = typeof raw?.maxFiles === "number" && Number.isInteger(raw.maxFiles) && raw.maxFiles >= 0
45
+ ? raw.maxFiles
46
+ : DEFAULT_HOTFIX_MAX_FILES;
47
+ const forbidden = Array.isArray(raw?.forbiddenPathGlobs) && raw.forbiddenPathGlobs.length > 0
48
+ ? raw.forbiddenPathGlobs.filter((g) => typeof g === "string" && g.length > 0)
49
+ : DEFAULT_FORBIDDEN_PATH_GLOBS;
50
+ return { maxLines, maxFiles, forbiddenPathGlobs: forbidden };
51
+ }
52
+ function pathIsForbidden(path, globs) {
53
+ const posix = path.replace(/\\/g, "/");
54
+ // Basename-only defaults (Dockerfile, fly.toml) match any depth.
55
+ const base = posix.includes("/") ? posix.slice(posix.lastIndexOf("/") + 1) : posix;
56
+ for (const g of globs) {
57
+ if (!g.includes("/") && !g.includes("*") && base === g)
58
+ return true;
59
+ }
60
+ return matchAny(globs, posix);
61
+ }
62
+ /**
63
+ * Pure hotfix eligibility evaluator (#1193 R2).
64
+ *
65
+ * Eligible (agent may propose `hotfix-candidate`):
66
+ * - pure revert always
67
+ * - one-character edit that restores green
68
+ * - small fix: ≤ maxLines AND ≤ maxFiles, no new deps/exports/schema, no forbidden
69
+ * paths, not a refactor, restores green
70
+ *
71
+ * Never eligible: refactors, new handlers/routes, export surface changes, forbidden paths.
72
+ */
73
+ export function evaluateHotfixEligibility(input) {
74
+ const criteria = normalizeCriteria(input.criteria);
75
+ const denyCodes = [];
76
+ const reasons = [];
77
+ if (input.isPureRevert === true) {
78
+ return {
79
+ eligible: true,
80
+ proposedLabel: HOTFIX_CANDIDATE_LABEL,
81
+ reasons: ["pure-revert always qualifies as hotfix-candidate"],
82
+ denyCodes: [],
83
+ criteria,
84
+ };
85
+ }
86
+ if (input.isOneCharacterEdit === true && input.restoresGreen !== false) {
87
+ return {
88
+ eligible: true,
89
+ proposedLabel: HOTFIX_CANDIDATE_LABEL,
90
+ reasons: ["one-character edit that restores green qualifies as hotfix-candidate"],
91
+ denyCodes: [],
92
+ criteria,
93
+ };
94
+ }
95
+ if (input.isRefactor === true) {
96
+ denyCodes.push("refactor");
97
+ reasons.push("refactors of any size are never hotfix");
98
+ }
99
+ if (input.changesExportedSurface === true) {
100
+ denyCodes.push("new-export");
101
+ reasons.push("exported surface add/remove/rename is never hotfix");
102
+ }
103
+ if (input.addsNewDependency === true) {
104
+ denyCodes.push("new-dependency");
105
+ reasons.push("new dependencies are never hotfix");
106
+ }
107
+ if (input.touchesSchemaOrMigration === true) {
108
+ denyCodes.push("schema-migration");
109
+ reasons.push("schema/migration changes are never hotfix");
110
+ }
111
+ if (input.addsNewHandlerOrRoute === true) {
112
+ denyCodes.push("new-handler-route");
113
+ reasons.push("new handlers/routes/media types are never hotfix");
114
+ }
115
+ for (const p of input.paths) {
116
+ if (pathIsForbidden(p, criteria.forbiddenPathGlobs)) {
117
+ denyCodes.push("forbidden-path");
118
+ reasons.push(`forbidden path: ${p}`);
119
+ break;
120
+ }
121
+ }
122
+ if (input.linesChanged > criteria.maxLines) {
123
+ denyCodes.push("too-many-lines");
124
+ reasons.push(`linesChanged ${input.linesChanged} > maxLines ${criteria.maxLines}`);
125
+ }
126
+ if (input.filesChanged > criteria.maxFiles) {
127
+ denyCodes.push("too-many-files");
128
+ reasons.push(`filesChanged ${input.filesChanged} > maxFiles ${criteria.maxFiles}`);
129
+ }
130
+ // Small-fix path requires restoring green unless pure-revert/one-char already returned.
131
+ if (input.restoresGreen === false) {
132
+ denyCodes.push("not-restoring-green");
133
+ reasons.push("change does not restore green on main");
134
+ }
135
+ if (denyCodes.length > 0) {
136
+ return {
137
+ eligible: false,
138
+ proposedLabel: null,
139
+ reasons,
140
+ denyCodes,
141
+ criteria,
142
+ };
143
+ }
144
+ return {
145
+ eligible: true,
146
+ proposedLabel: HOTFIX_CANDIDATE_LABEL,
147
+ reasons: [
148
+ `small fix within limits (≤${criteria.maxLines} lines, ≤${criteria.maxFiles} files); ` +
149
+ "agent may apply hotfix-candidate only — human promotes hotfix",
150
+ ],
151
+ denyCodes: [],
152
+ criteria,
153
+ };
154
+ }
155
+ /** Resolve typed hotfixCriteria from a raw policy block value. */
156
+ export function resolveHotfixCriteria(raw) {
157
+ if (raw === null || raw === undefined)
158
+ return { ...DEFAULT_HOTFIX_CRITERIA };
159
+ if (typeof raw !== "object" || Array.isArray(raw))
160
+ return { ...DEFAULT_HOTFIX_CRITERIA };
161
+ return normalizeCriteria(raw);
162
+ }
163
+ /** Inspector row for `policy:show --field=hotfixCriteria`. */
164
+ export function inspectHotfixCriteria(data) {
165
+ const defaults = { ...DEFAULT_HOTFIX_CRITERIA };
166
+ if (data === null) {
167
+ return { name: FIELD_HOTFIX_CRITERIA, current: defaults, default: defaults, source: "default" };
168
+ }
169
+ const policyBlock = readPlanPolicy(data.plan);
170
+ if (typeof policyBlock !== "object" ||
171
+ policyBlock === null ||
172
+ Array.isArray(policyBlock) ||
173
+ !("hotfixCriteria" in policyBlock)) {
174
+ return { name: FIELD_HOTFIX_CRITERIA, current: defaults, default: defaults, source: "default" };
175
+ }
176
+ return {
177
+ name: FIELD_HOTFIX_CRITERIA,
178
+ current: resolveHotfixCriteria(policyBlock.hotfixCriteria),
179
+ default: defaults,
180
+ source: "typed",
181
+ };
182
+ }
183
+ /** Load hotfix criteria from project root. */
184
+ export function loadHotfixCriteriaFromProject(projectRoot) {
185
+ const [data] = loadProjectDefinition(projectRoot);
186
+ if (data === null)
187
+ return { ...DEFAULT_HOTFIX_CRITERIA };
188
+ const policyBlock = readPlanPolicy(data.plan);
189
+ if (typeof policyBlock !== "object" ||
190
+ policyBlock === null ||
191
+ Array.isArray(policyBlock) ||
192
+ !("hotfixCriteria" in policyBlock)) {
193
+ return { ...DEFAULT_HOTFIX_CRITERIA };
194
+ }
195
+ return resolveHotfixCriteria(policyBlock.hotfixCriteria);
196
+ }
197
+ //# sourceMappingURL=hotfix-criteria.js.map
@@ -4,16 +4,21 @@ 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 "./hotfix-criteria.js";
8
+ export * from "./intent-ceiling.js";
9
+ export * from "./no-deft-directive.js";
7
10
  export * from "./org-force-on-migration.js";
8
11
  export * from "./plan-extensions.js";
9
12
  export * from "./policy-invocation.js";
10
13
  export * from "./product-signal.js";
14
+ export * from "./require-human-merge.js";
11
15
  export * from "./resolve.js";
12
16
  export * from "./runtime-authority.js";
13
17
  export * from "./staleness-tickler.js";
14
18
  export * from "./value-feedback.js";
15
19
  export * from "./value-feedback-autoenable.js";
16
20
  export * from "./wip.js";
21
+ export * from "./write-fence.js";
17
22
  export declare const FIELD_ALLOW_DIRECT_COMMITS = "plan.policy.allowDirectCommitsToMaster";
18
23
  export declare const FIELD_WIP_CAP = "plan.policy.wipCap";
19
24
  export declare const FIELD_SESSION_RITUAL_STALENESS_HOURS = "plan.policy.sessionRitualStalenessHours";