Skip to content

Commit 05dac9d

Browse files
committed
fix(installer): confine writes to the project and stop guessing on markers
Adversarial audit of the 0.5.2 upgrade path found the installer would write through a pre-planted symlink (with or without --force) to a file outside the project, report false success on a begin-marker with no end, delete user content between a stray begin and the real block, and honor an undocumented --update alias with force semantics. Writes now refuse symlinked or out-of-tree destinations, malformed or duplicated marker pairs abort untouched, every destructive dedicated-file update saves the replaced content to <file>.bak, and unknown flags are rejected. Rule body unchanged; v0.5.3.
1 parent 3f4bbfa commit 05dac9d

7 files changed

Lines changed: 176 additions & 11 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "trial",
3-
"version": "0.5.2",
3+
"version": "0.5.3",
44
"description": "Pre-delivery evidence gate: withhold unsupported completion claims and release only verified results.",
55
"author": {
66
"name": "Da7-Tech",

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
# Changelog
22

3+
## 0.5.3 — 2026-07-16
4+
5+
Installer hardening after an adversarial audit of the 0.5.2 upgrade path; no
6+
rule change.
7+
8+
- **The installer now refuses symlinked destinations and paths resolving
9+
outside the project.** A hostile checkout could pre-plant a destination path
10+
(`.cursor/rules/trial.mdc`, `AGENTS.md`, or a parent directory) as a symlink
11+
to a file elsewhere, and `npx github:Da7-Tech/trial <agent>` — with or
12+
without `--force` — would overwrite the link's target. Writes are now
13+
confined to the real project directory and never follow a symlink.
14+
- **Malformed managed markers stop the update instead of mis-firing.** A
15+
`trial:begin` with no matching `trial:end` previously produced a false
16+
"updated" success while installing nothing; a stray `begin` above the real
17+
block silently deleted the user content between them; duplicated pairs left
18+
a permanently stale second block. All three cases now abort with a clear
19+
message and leave the file byte-identical.
20+
- **Every destructive dedicated-file update keeps a `.bak`.** The signature
21+
substring test cannot distinguish an older Trial rule from a personal file
22+
that merely quotes the signature phrase, so the replaced content is always
23+
saved next to the destination as a one-step undo (also under `--force`).
24+
- **`--update` is gone; unknown flags are rejected.** It was an undocumented
25+
alias of `--force` whose gentle name belied force semantics. `--force` is
26+
the single, explicit escape hatch, and any unrecognized `--flag` now exits
27+
with usage instead of being ignored.
28+
- **Known, accepted limitation:** the installer performs no version
29+
comparison — re-running an *older* package over a newer installed rule
30+
overwrites it (the `.bak` and your VCS are the recovery path).
31+
- **No behavioral rule change.** The canonical rule body is byte-identical to
32+
0.5.1/0.5.2; only the installer, its tests, and version metadata changed.
33+
334
## 0.5.2 — 2026-07-16
435

536
Installer upgrade path for dedicated-file targets; no rule change.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ The full rule your agent reads is [`agents/codex/AGENTS.md`](agents/codex/AGENTS
7979
npx github:Da7-Tech/trial cursor
8080
```
8181
82-
Re-running upgrades in place: shared files (`AGENTS.md`, …) update between their managed markers, and dedicated rule files update when they hold a Trial rule (a same-version reinstall is a no-op). A non-Trial file already at the destination path is never clobbered — the installer refuses and points you to `--force` if you really mean to replace it.
82+
Re-running upgrades in place: shared files (`AGENTS.md`, …) update between their managed markers — malformed or duplicated markers make the installer stop rather than guess — and dedicated rule files update when they carry the Trial signature, with the replaced content always saved to `<file>.bak` first. A file without the signature is refused (re-run with `--force` to replace it; the `.bak` is still written), and the installer never writes through a symlink or outside the project directory.
8383
8484
Or copy the file yourself:
8585

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: trial
33
description: "A pre-delivery evidence gate for coding agents. Hold the final response as a private draft, judge every user-visible claim against fresh covering receipts, and release only verified results. Use when unsupported completion claims, shallow green tests, stale evidence, or high-risk changes must not reach the user."
44
license: MIT
55
metadata:
6-
version: 0.5.2
6+
version: 0.5.3
77
---
88

99
# Trial — Pre-Delivery Evidence Gate

bin/install.js

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ function usage(code) {
4444
}
4545

4646
const rawArgs = process.argv.slice(2);
47-
const force = rawArgs.some((a) => /^--(force|update)$/i.test(a));
48-
const positional = rawArgs.filter((a) => !/^--(force|update)$/i.test(a));
47+
const force = rawArgs.some((a) => /^--force$/i.test(a));
48+
const unknownFlags = rawArgs.filter((a) => /^--/.test(a) && !/^--(force|help|list)$/i.test(a));
49+
if (unknownFlags.length) {
50+
console.error(`Unknown option "${unknownFlags[0]}".`);
51+
usage(1);
52+
}
53+
const positional = rawArgs.filter((a) => !/^--force$/i.test(a));
4954
const arg = (positional[0] || '').toLowerCase().replace(/^--/, '');
5055
if (!arg || arg === 'help') usage(arg ? 0 : 1);
5156
if (arg === 'list') { console.log(Object.keys(TARGETS).join('\n')); process.exit(0); }
@@ -56,7 +61,33 @@ const src = fs.readFileSync(path.join(pkgRoot, srcRel), 'utf8');
5661
const dest = path.join(process.cwd(), destRel);
5762
const block = `${BEGIN}\n${src.trim()}\n${END}\n`;
5863

64+
function fail(message) {
65+
console.error(message);
66+
process.exit(1);
67+
}
68+
69+
// The installer must never write outside the project it is run in, and never
70+
// through a symlink: a hostile checkout could pre-plant the destination path
71+
// as a link to a file elsewhere (dotfiles, shared configs) and turn a routine
72+
// `npx … <agent>` into an out-of-tree overwrite.
73+
function assertDestinationConfined() {
74+
let nearest = path.dirname(dest);
75+
while (!fs.existsSync(nearest)) nearest = path.dirname(nearest);
76+
const root = fs.realpathSync(process.cwd());
77+
const anchor = fs.realpathSync(nearest);
78+
if (anchor !== root && !anchor.startsWith(root + path.sep)) {
79+
fail(`${destRel} resolves outside the project directory — refusing to write.`);
80+
}
81+
let stats = null;
82+
try { stats = fs.lstatSync(dest); } catch (_) { /* not created yet */ }
83+
if (stats && stats.isSymbolicLink()) {
84+
fail(`${destRel} is a symlink — refusing to write through it. ` +
85+
'Run the installer where the link points, or replace the link with a regular file.');
86+
}
87+
}
88+
5989
try {
90+
assertDestinationConfined();
6091
fs.mkdirSync(path.dirname(dest), { recursive: true });
6192

6293
if (!fs.existsSync(dest)) {
@@ -66,10 +97,20 @@ try {
6697
fs.writeFileSync(dest, append ? block : src);
6798
console.log(`Trial installed: ${destRel}`);
6899
} else if (append) {
69-
let existing = fs.readFileSync(dest, 'utf8');
70-
if (existing.includes(BEGIN)) {
71-
existing = existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}\\n?`), block);
72-
fs.writeFileSync(dest, existing);
100+
const existing = fs.readFileSync(dest, 'utf8');
101+
const begins = existing.split(BEGIN).length - 1;
102+
const ends = existing.split(END).length - 1;
103+
// A malformed or duplicated marker pair means the managed region cannot be
104+
// identified safely: updating anyway would either claim success while
105+
// changing nothing or eat user content straying between the wrong pair.
106+
// Stop and say so instead of guessing.
107+
if (begins !== ends || begins > 1 ||
108+
(begins === 1 && existing.indexOf(END) < existing.indexOf(BEGIN))) {
109+
fail(`${destRel} has malformed Trial markers (${begins} begin / ${ends} end) — ` +
110+
'repair or remove them manually, then re-run.');
111+
}
112+
if (begins === 1) {
113+
fs.writeFileSync(dest, existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}\\n?`), block));
73114
console.log(`Trial updated inside existing ${destRel}`);
74115
} else {
75116
fs.writeFileSync(dest, existing.trimEnd() + '\n\n' + block);
@@ -84,8 +125,14 @@ try {
84125
if (existing === src) {
85126
console.log(`Trial already up to date: ${destRel}`);
86127
} else if (force || existing.includes(TRIAL_SIGNATURE)) {
128+
// The signature test can misfire on a personal file that merely quotes
129+
// it, so every destructive update keeps the replaced content beside the
130+
// destination as a one-step undo.
131+
const bak = `${dest}.bak`;
132+
fs.rmSync(bak, { force: true });
133+
fs.writeFileSync(bak, existing);
87134
fs.writeFileSync(dest, src);
88-
console.log(`Trial updated: ${destRel}`);
135+
console.log(`Trial updated: ${destRel} (previous content saved to ${destRel}.bak)`);
89136
} else {
90137
console.error(`${destRel} already exists and is not a Trial file — refusing to overwrite. ` +
91138
`Re-run with --force to replace it, or remove it first.`);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "trial-skill",
3-
"version": "0.5.2",
3+
"version": "0.5.3",
44
"description": "A pre-delivery evidence gate that withholds unsupported completion claims and releases only verified results.",
55
"keywords": [
66
"ai-agents",

tests/install.test.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,93 @@ test('dedicated target refuses a foreign file but --force overwrites it', () =>
138138
fs.rmSync(dir, { recursive: true, force: true });
139139
});
140140

141+
function runArgs(cwd, args) {
142+
return execFileSync('node', [installer, ...args], { cwd, encoding: 'utf8' });
143+
}
144+
145+
test('symlinked destinations are refused and the link target is untouched', () => {
146+
if (process.platform === 'win32') return;
147+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-sym-'));
148+
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-sym2-'));
149+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-victim-'));
150+
const victim = path.join(outside, 'victim.md');
151+
// Contains the signature on purpose: without the lstat guard the dedicated
152+
// branch would treat the link target as Trial-managed and overwrite it.
153+
const original = 'IMPORTANT external file\nquotes "Trial — Pre-Delivery Evidence Gate"\n';
154+
fs.writeFileSync(victim, original);
155+
156+
fs.mkdirSync(path.join(dir, '.cursor', 'rules'), { recursive: true });
157+
fs.symlinkSync(victim, path.join(dir, '.cursor', 'rules', 'trial.mdc'));
158+
assert.throws(() => run(dir, 'cursor'), /Command failed/, 'symlinked dedicated dest must be refused');
159+
assert.throws(() => runArgs(dir, ['cursor', '--force']), /Command failed/, '--force must not write through a symlink');
160+
161+
fs.symlinkSync(victim, path.join(dir2, 'AGENTS.md'));
162+
assert.throws(() => run(dir2, 'codex'), /Command failed/, 'symlinked append dest must be refused');
163+
164+
assert.strictEqual(fs.readFileSync(victim, 'utf8'), original, 'external file untouched');
165+
fs.rmSync(dir, { recursive: true, force: true });
166+
fs.rmSync(dir2, { recursive: true, force: true });
167+
fs.rmSync(outside, { recursive: true, force: true });
168+
});
169+
170+
test('a destination directory resolving outside the project is refused', () => {
171+
if (process.platform === 'win32') return;
172+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-symdir-'));
173+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-outside-'));
174+
fs.symlinkSync(outside, path.join(dir, '.cursor'));
175+
assert.throws(() => run(dir, 'cursor'), /Command failed/, 'symlinked parent dir must be refused');
176+
assert.strictEqual(fs.readdirSync(outside).length, 0, 'nothing created outside the project');
177+
fs.rmSync(dir, { recursive: true, force: true });
178+
fs.rmSync(outside, { recursive: true, force: true });
179+
});
180+
181+
test('malformed or duplicated markers stop the installer instead of guessing', () => {
182+
const cases = [
183+
['begin with no end', '<!-- trial:begin -->\nold body\n'],
184+
['end before begin', '<!-- trial:end -->\nnoise\n<!-- trial:begin -->\n'],
185+
['duplicated pairs', '<!-- trial:begin -->\na\n<!-- trial:end -->\n<!-- trial:begin -->\nb\n<!-- trial:end -->\n'],
186+
['stray begin above a real pair', 'HEAD\n<!-- trial:begin -->\nPRECIOUS\n<!-- trial:begin -->\nold\n<!-- trial:end -->\nTAIL\n'],
187+
];
188+
for (const [label, content] of cases) {
189+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-marker-'));
190+
fs.writeFileSync(path.join(dir, 'AGENTS.md'), content);
191+
let err;
192+
try { run(dir, 'codex'); } catch (e) { err = e; }
193+
assert.ok(err, `${label}: exits non-zero`);
194+
assert.match(String(err.stderr || ''), /malformed Trial markers/, `${label}: names the problem`);
195+
assert.strictEqual(fs.readFileSync(path.join(dir, 'AGENTS.md'), 'utf8'), content, `${label}: file untouched`);
196+
fs.rmSync(dir, { recursive: true, force: true });
197+
}
198+
});
199+
200+
test('dedicated update keeps a .bak of the replaced content', () => {
201+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-bak-'));
202+
const dest = path.join(dir, '.cursor', 'rules', 'trial.mdc');
203+
fs.mkdirSync(path.dirname(dest), { recursive: true });
204+
// A personal file that merely QUOTES the signature is treated as
205+
// Trial-managed by the substring test; the .bak is its safety net.
206+
const personal = '# My notes\nabout the "Trial — Pre-Delivery Evidence Gate" idea\nirreplaceable prose\n';
207+
fs.writeFileSync(dest, personal);
208+
const out = run(dir, 'cursor');
209+
assert.match(out, /saved to .*trial\.mdc\.bak/, 'update announces the backup');
210+
assert.strictEqual(fs.readFileSync(`${dest}.bak`, 'utf8'), personal, 'backup holds the replaced content');
211+
assert.match(fs.readFileSync(dest, 'utf8'), /# Trial/, 'rule installed');
212+
fs.rmSync(dir, { recursive: true, force: true });
213+
});
214+
215+
test('--update is rejected as unknown instead of silently forcing', () => {
216+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-upd-'));
217+
const dest = path.join(dir, '.cursor', 'rules', 'trial.mdc');
218+
fs.mkdirSync(path.dirname(dest), { recursive: true });
219+
fs.writeFileSync(dest, '# my own file, no signature\n');
220+
let err;
221+
try { runArgs(dir, ['cursor', '--update']); } catch (e) { err = e; }
222+
assert.ok(err, 'exits non-zero');
223+
assert.match(String(err.stderr || ''), /Unknown option/, 'names the bad flag');
224+
assert.strictEqual(fs.readFileSync(dest, 'utf8'), '# my own file, no signature\n', 'file preserved');
225+
fs.rmSync(dir, { recursive: true, force: true });
226+
});
227+
141228
test('read-only destination fails with a friendly message, not a stack trace', () => {
142229
if (process.platform === 'win32') return;
143230
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trial-inst-'));

0 commit comments

Comments
 (0)