Summary
shardmind update renders copy-origin files (anything without a .njk suffix — scripts, .test.ts, JSON, etc.) through Nunjucks during the three-way merge. Copy files are meant to be verbatim, so rendering them is wrong on two counts:
- Crash — a literal
{{ that isn't a valid expression (e.g. const x = "garbage{{" in a test fixture) throws expected variable end → RENDER_TEMPLATE_ERROR, aborting the update.
- Silent substitution — a copy file that legitimately contains
{{ user_name }} (e.g. test data) has it replaced with the value during the merge, corrupting the merge base/ours and the user's content.
Root cause
source/core/update-planner.ts runs computeMergeAction for every modified managed file (drift.modified), with no copy-vs-render distinction:
// update-planner.ts (modified-files loop)
const newTemplate = await fsp.readFile(target.entry.sourcePath, 'utf-8');
const mergeAction = await computeMergeAction({ ownership: 'modified', oldTemplate, newTemplate, ... });
computeMergeAction (source/core/differ.ts) then renders both sides:
const base = renderString(input.oldTemplate, { ...renderContext, values: oldValues }, path);
const ours = renderString(input.newTemplate, { ...renderContext, values: newValues }, path);
oldTemplate is the cached template (.shardmind/templates/<key> — raw bytes for a copy file) and newTemplate is the new shard's source file (raw bytes for a copy file). Both get Nunjucks-rendered even though copy files carry a copyFromSourcePath marker indicating they are verbatim.
In v6, only .njk files are rendered; everything else is copied (modules.ts: relPath.endsWith('.njk')). The merge path never honors that distinction.
Reproduction
- A shard ships a non-
.njk file containing a literal {{ (e.g. .claude/scripts/foo.test.ts with const s = "garbage{{";).
- Install, then edit that file (so drift classifies it
modified).
- Publish a new shard version and run
shardmind update → crashes with RENDER_TEMPLATE_ERROR.
Empirical behavior of the three relevant cases:
| Input |
renderString |
const x = "garbage{{" (literal, copy file) |
throws → update crashes |
copy file with {{ user_name }} |
renders → silently substituted to the value |
.njk template typo {{ user_name } (missing brace) |
throws (correct — a real authoring error) |
status.ts's verbose-drift base already wraps renderString in try/catch (reason: 'render-failed'), so it degrades gracefully; the crash is specific to the update merge path. drift classification compares hashes (no render), so it is unaffected.
Relationship to #129
PR #129 ("fix(renderer): pass through non-Nunjucks content on parse error") targets the same crash but at the wrong layer — it catches the parse error inside renderTemplate and returns the raw source when a regex (/%\}|{%|{{.*?}}/) decides the content "isn't a template". This is unsound:
- Incomplete — it only helps when the file has no valid-looking
{{ }}. A copy file with {{ user_name }} still renders and is silently substituted (case 2 above is untouched).
- Regression — a genuine
.njk template typo like {{ user_name } (no closing brace) doesn't match the regex, so the error is swallowed and the broken literal is emitted into the user's vault instead of failing loudly. This breaks the engine's "loud typed errors on authoring mistakes" contract.
- Wrong altitude — it weakens every render path (install/update/adopt output + drift base) to fix a problem that is specific to merging copy-origin files.
Security note: #129's regex is ReDoS-safe and does not expand the template-injection surface (it reduces rendering on failure). But the underlying behavior it leaves in place — evaluating verbatim files through Nunjucks (autoescape: false) during merge — is a latent injection/corruption vector for any copy file containing {{ … }}.
Proposed fix
Pass a literal flag to computeMergeAction for copy-origin files (target.copyFromSourcePath !== undefined); when set, skip renderString and merge the raw bytes. Keep renderTemplate strict so real .njk authoring errors stay fatal.
Acceptance
Binary copy files going through the (utf-8) three-way merge remain tracked separately by #63.
Summary
shardmind updaterenders copy-origin files (anything without a.njksuffix — scripts,.test.ts, JSON, etc.) through Nunjucks during the three-way merge. Copy files are meant to be verbatim, so rendering them is wrong on two counts:{{that isn't a valid expression (e.g.const x = "garbage{{"in a test fixture) throwsexpected variable end→RENDER_TEMPLATE_ERROR, aborting the update.{{ user_name }}(e.g. test data) has it replaced with the value during the merge, corrupting the merge base/ours and the user's content.Root cause
source/core/update-planner.tsrunscomputeMergeActionfor every modified managed file (drift.modified), with no copy-vs-render distinction:computeMergeAction(source/core/differ.ts) then renders both sides:oldTemplateis the cached template (.shardmind/templates/<key>— raw bytes for a copy file) andnewTemplateis the new shard's source file (raw bytes for a copy file). Both get Nunjucks-rendered even though copy files carry acopyFromSourcePathmarker indicating they are verbatim.In v6, only
.njkfiles are rendered; everything else is copied (modules.ts:relPath.endsWith('.njk')). The merge path never honors that distinction.Reproduction
.njkfile containing a literal{{(e.g..claude/scripts/foo.test.tswithconst s = "garbage{{";).modified).shardmind update→ crashes withRENDER_TEMPLATE_ERROR.Empirical behavior of the three relevant cases:
renderStringconst x = "garbage{{"(literal, copy file){{ user_name }}.njktemplate typo{{ user_name }(missing brace)status.ts's verbose-drift base already wrapsrenderStringin try/catch (reason: 'render-failed'), so it degrades gracefully; the crash is specific to theupdatemerge path. drift classification compares hashes (no render), so it is unaffected.Relationship to #129
PR #129 ("fix(renderer): pass through non-Nunjucks content on parse error") targets the same crash but at the wrong layer — it catches the parse error inside
renderTemplateand returns the raw source when a regex (/%\}|{%|{{.*?}}/) decides the content "isn't a template". This is unsound:{{ }}. A copy file with{{ user_name }}still renders and is silently substituted (case 2 above is untouched)..njktemplate typo like{{ user_name }(no closing brace) doesn't match the regex, so the error is swallowed and the broken literal is emitted into the user's vault instead of failing loudly. This breaks the engine's "loud typed errors on authoring mistakes" contract.Security note: #129's regex is ReDoS-safe and does not expand the template-injection surface (it reduces rendering on failure). But the underlying behavior it leaves in place — evaluating verbatim files through Nunjucks (
autoescape: false) during merge — is a latent injection/corruption vector for any copy file containing{{ … }}.Proposed fix
Pass a
literalflag tocomputeMergeActionfor copy-origin files (target.copyFromSourcePath !== undefined); when set, skiprenderStringand merge the raw bytes. KeeprenderTemplatestrict so real.njkauthoring errors stay fatal.Acceptance
{{updates without crashing; the literal is preserved.{{ expr }}is not substituted during merge..njktemplate syntax error still throwsRENDER_TEMPLATE_ERROR(no masking).Binary copy files going through the (utf-8) three-way merge remain tracked separately by #63.