delimit-cli 4.5.0 → 4.5.1

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/CHANGELOG.md CHANGED
@@ -1,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [4.5.1] - 2026-04-28
5
+
6
+ ### Security — attestation `canonicalize()` strengthened (LED-1180)
7
+
8
+ The `canonicalize()` helper used to derive attestation IDs and HMAC signatures was passing `Object.keys(bundle).sort()` as the second argument to `JSON.stringify`. JSON.stringify treats that argument as a property **allowlist**, not a sort order, and the allowlist contained only top-level keys — so nested objects serialised as `{}` and the HMAC committed only to the bundle's top-level shape.
9
+
10
+ Practical effect: a bundle with `{governance: {violations: ["safe"]}}` and one with `{governance: {violations: ["malicious"]}}` produced **identical signatures**. Tampering nested fields was undetectable through signature verification.
11
+
12
+ **This release replaces canonicalize with a proper recursive sorted-key serializer.** Old (v4.3 – v4.5.0) attestations remain readable but verify with the new canonicalize and will report `signature_mismatch`. New attestations produced by v4.5.1+ commit to the full content of the bundle.
13
+
14
+ - `lib/wrap-engine.js` — fixed `canonicalize()`, exported it for reuse
15
+ - `lib/trust-page-engine.js` — verifier now imports the corrected canonicalize
16
+ - `tests/v43-wrap-engine.test.js` — added LED-1180 regression: tampering a nested field MUST change the signature; if it doesn't, canonicalize is silently dropping nested keys
17
+ - `tests/v43-trust-page-engine.test.js` — test fixtures sign with the corrected canonicalize
18
+
19
+ If you have a corpus of v4.5.0 or earlier attestations and need them re-signed under the new primitive, the migration tool is on the LED-1180 follow-up. For most users, attestations are short-lived merge-decision artifacts and re-signing is unnecessary.
20
+
21
+ ### Other
22
+
23
+ - (Internal) LED-1175 + LED-1177 MVP shipped in `delimit-private`: signed deliberation attestations + Scanner Input v0 schema. Public docs: [delimit.ai/docs/scanner-input](https://delimit.ai/docs/scanner-input), [delimit.ai/docs/vs-bugcrawl](https://delimit.ai/docs/vs-bugcrawl). No customer-facing CLI changes in 4.5.1.
24
+
4
25
  ## [4.5.0] - 2026-04-27
5
26
 
6
27
  ### Added — Ledger hygiene toolkit (LED-1145, 7 PRs)
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const { upsertManagedSection } = require('../lib/managed-section');
13
14
 
14
15
  // LED-213: Import canonical template for cross-model parity
15
16
  const { getDelimitSection } = require('../lib/delimit-template');
@@ -26,14 +27,26 @@ const CURSORRULES_FILE = path.join(HOME, '.cursorrules');
26
27
  function installRules(version) {
27
28
  const rules = getDelimitRules(version);
28
29
 
29
- // Install to .cursor/rules/delimit.md (new location, Cursor 0.45+)
30
+ // Install to .cursor/rules/delimit.md (new location, Cursor 0.45+).
31
+ // LED-1180 follow-up: use upsertManagedSection so user-customized
32
+ // content above/below the delimit:start/end markers is preserved.
33
+ // The previous implementation did fs.writeFileSync(rulesFile, rules)
34
+ // — full overwrite — which clobbered any user customizations on every
35
+ // `delimit-cli setup`.
36
+ let action = 'unchanged';
37
+ let rulesFile = null;
30
38
  if (fs.existsSync(CURSOR_DIR)) {
31
39
  fs.mkdirSync(CURSOR_RULES_DIR, { recursive: true });
32
- const rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
33
- fs.writeFileSync(rulesFile, rules);
40
+ rulesFile = path.join(CURSOR_RULES_DIR, 'delimit.md');
41
+ const result = upsertManagedSection(rulesFile, rules, version);
42
+ action = result.action;
34
43
  }
35
44
 
36
- return { installed: true, paths: [CURSORRULES_FILE, path.join(CURSOR_RULES_DIR, 'delimit.md')] };
45
+ return {
46
+ installed: true,
47
+ action,
48
+ paths: [CURSORRULES_FILE, path.join(CURSOR_RULES_DIR, 'delimit.md')],
49
+ };
37
50
  }
38
51
 
39
52
  /**
@@ -0,0 +1,92 @@
1
+ // lib/managed-section.js
2
+ //
3
+ // Shared upsertDelimitSection helper. Used by:
4
+ // bin/delimit-setup.js — for ~/CLAUDE.md, ~/.codex/instructions.md, ~/.cursorrules
5
+ // adapters/cursor-rules.js — for ~/.cursor/rules/delimit.md
6
+ //
7
+ // NEVER clobbers user-authored content outside the markers. Behavior:
8
+ // - File missing → create with just the managed section.
9
+ // - File has markers → replace only the region between them (user content
10
+ // above/below preserved).
11
+ // - File has no markers → append the managed section at the bottom (user
12
+ // content at top preserved).
13
+ //
14
+ // History (institutional memory; do NOT change marker semantics without
15
+ // understanding these incidents):
16
+ // - v4.1.47: previous heuristic replaced the whole file whenever it
17
+ // detected "old Delimit content" — destroyed founder-customized
18
+ // CLAUDE.md files on every upgrade.
19
+ // - v4.1.49: unanchored marker regex matched markers inside quoted
20
+ // prose (backticks, bullets, blockquotes) — clobbered /root/CLAUDE.md.
21
+ // The current regex is anchored with the multiline flag so markers
22
+ // MUST be on their own line. Optional leading horizontal whitespace
23
+ // [ \t]* permits genuinely indented markers but NOT prose-leading
24
+ // characters like "- ", "> ", "`", "*".
25
+ //
26
+ // Returns: { action: 'created' | 'updated' | 'unchanged' | 'appended' }
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ function loadPackageVersion() {
32
+ try {
33
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
34
+ return pkg.version || '0.0.0';
35
+ } catch {
36
+ return '0.0.0';
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Upsert the Delimit section in a file using <!-- delimit:start v<version> -->
42
+ * and <!-- delimit:end --> markers.
43
+ *
44
+ * @param {string} filePath - Absolute path to the target file.
45
+ * @param {string} newSection - The full managed-section text (including markers).
46
+ * @param {string} [version] - Version string for staleness check. Defaults to package.json.
47
+ * @returns {{action: 'created'|'updated'|'unchanged'|'appended'}}
48
+ */
49
+ function upsertManagedSection(filePath, newSection, version) {
50
+ if (!version) version = loadPackageVersion();
51
+
52
+ if (!fs.existsSync(filePath)) {
53
+ fs.writeFileSync(filePath, newSection + '\n');
54
+ return { action: 'created' };
55
+ }
56
+
57
+ const rawExisting = fs.readFileSync(filePath, 'utf-8');
58
+ // Strip a UTF-8 BOM if present so the start-of-line anchor still matches
59
+ // the very first line of the file. We write back the stripped form to keep
60
+ // serialization deterministic.
61
+ const existing = rawExisting.replace(/^/, '');
62
+
63
+ const startMarkerRe = /^[ \t]*<!-- delimit:start[^>]*-->[ \t]*$/m;
64
+ const endMarkerRe = /^[ \t]*<!-- delimit:end -->[ \t]*$/m;
65
+ const startMatch = existing.match(startMarkerRe);
66
+ const endMatch = existing.match(endMarkerRe);
67
+
68
+ if (startMatch && endMatch) {
69
+ // Extract current version from the marker (also anchored, allows indent)
70
+ const versionMatch = existing.match(/^[ \t]*<!-- delimit:start v([^ ]+) -->[ \t]*$/m);
71
+ const currentVersion = versionMatch ? versionMatch[1] : '';
72
+ if (currentVersion === version) {
73
+ return { action: 'unchanged' };
74
+ }
75
+ // Replace only the managed region — preserve content above/below
76
+ const startIdx = startMatch.index;
77
+ const endIdx = endMatch.index + endMatch[0].length;
78
+ const before = existing.substring(0, startIdx);
79
+ const after = existing.substring(endIdx);
80
+ fs.writeFileSync(filePath, before + newSection + after);
81
+ return { action: 'updated' };
82
+ }
83
+
84
+ // No markers present — append the managed section at the bottom.
85
+ // User content above is preserved verbatim. Markers get added so future
86
+ // upgrades can update just the managed region.
87
+ const separator = existing.endsWith('\n') ? '\n' : '\n\n';
88
+ fs.writeFileSync(filePath, existing + separator + newSection + '\n');
89
+ return { action: 'appended' };
90
+ }
91
+
92
+ module.exports = { upsertManagedSection, loadPackageVersion };
@@ -10,6 +10,7 @@ const crypto = require('crypto');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
+ const { canonicalize } = require('./wrap-engine');
13
14
 
14
15
  function loadHmacKey() {
15
16
  const keyPath = path.join(os.homedir(), '.delimit', 'wrap-hmac.key');
@@ -20,8 +21,11 @@ function loadHmacKey() {
20
21
  function verifySignature(attestation, key) {
21
22
  if (!key) return 'unverifiable';
22
23
  try {
23
- const canonical = JSON.stringify(attestation.bundle, Object.keys(attestation.bundle).sort());
24
- const expected = crypto.createHmac('sha256', key).update(canonical).digest('hex');
24
+ // LED-1180: must use the same recursive sorted-key canonicalize
25
+ // as wrap-engine; using JSON.stringify(.., keys.sort()) here
26
+ // would treat the array as an allowlist and serialise nested
27
+ // fields as {}, matching the old broken signature trivially.
28
+ const expected = crypto.createHmac('sha256', key).update(canonicalize(attestation.bundle)).digest('hex');
25
29
  return expected === attestation.signature ? 'verified' : 'signature_mismatch';
26
30
  } catch {
27
31
  return 'verify_error';
@@ -138,9 +138,26 @@ function runTestSmoke(cwd) {
138
138
  // Attestation bundling + signing
139
139
  // ----------------------------------------------------------------------------
140
140
 
141
+ // LED-1180: deterministic canonical JSON. Recursively sorts object keys
142
+ // at every depth. Earlier implementations passed the second argument of
143
+ // JSON.stringify as `Object.keys(bundle).sort()`, which JSON.stringify
144
+ // treats as a property ALLOWLIST (not a sort order), filtered to
145
+ // top-level keys. The result was that nested objects serialised as
146
+ // `{}` and the HMAC committed only to the top-level shape — meaning a
147
+ // bad actor could change `bundle.governance.violations` or any nested
148
+ // field without invalidating the signature. Fixed in v4.5.1 hotfix.
149
+ // Verifier must use the same canonicalize to match.
150
+ function canonicalize(value) {
151
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
152
+ if (Array.isArray(value)) {
153
+ return '[' + value.map(canonicalize).join(',') + ']';
154
+ }
155
+ const keys = Object.keys(value).sort();
156
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalize(value[k])).join(',') + '}';
157
+ }
158
+
141
159
  function computeAttestationId(bundle) {
142
- const canonical = JSON.stringify(bundle, Object.keys(bundle).sort());
143
- const hash = crypto.createHash('sha256').update(canonical).digest('hex');
160
+ const hash = crypto.createHash('sha256').update(canonicalize(bundle)).digest('hex');
144
161
  return 'att_' + hash.slice(0, 16);
145
162
  }
146
163
 
@@ -157,8 +174,7 @@ function loadOrCreateHmacKey() {
157
174
 
158
175
  function signAttestation(bundle) {
159
176
  const key = loadOrCreateHmacKey();
160
- const canonical = JSON.stringify(bundle, Object.keys(bundle).sort());
161
- return crypto.createHmac('sha256', key).update(canonical).digest('hex');
177
+ return crypto.createHmac('sha256', key).update(canonicalize(bundle)).digest('hex');
162
178
  }
163
179
 
164
180
  // ----------------------------------------------------------------------------
@@ -442,6 +458,7 @@ async function runWrap(rawCmd, options = {}) {
442
458
 
443
459
  module.exports = {
444
460
  runWrap,
461
+ canonicalize,
445
462
  computeAttestationId,
446
463
  signAttestation,
447
464
  checkQuota,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.5.0",
4
+ "version": "4.5.1",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [