Skip to content

Commit 2bbcbb5

Browse files
committed
fix(plugin): refuse consuming a non-existent directive; correct the tool-result comment
directive/consume now validates the directiveId names a real directive on the ticket and refuses (404) otherwise instead of appending an orphan marker and reporting success; consuming a real directive stays idempotent. The dart-mcp toToolResult comment now accurately states it returns the hub's response payload verbatim.
1 parent 950f365 commit 2bbcbb5

3 files changed

Lines changed: 61 additions & 2 deletions

File tree

dart-mcp/src/server.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ function toZodShape(input, z) {
4747

4848
// Shape a hub `{ code, payload }` result into an MCP tool result. The payload is returned
4949
// as quoted JSON data; an error/refusal/conflict is surfaced as text, never thrown into an
50-
// execution path. No tool argument is echoed back.
50+
// execution path. The text is the hub's response payload verbatim — for routes like
51+
// ticket/comment that means the persisted record, which echoes caller-provided fields
52+
// (e.g. the comment body) so the caller can confirm what was written. This is the hub's
53+
// authoritative response, not a secret leak: no credentials live in these payloads.
5154
function toToolResult({ code, payload }) {
5255
const isError = !(payload && payload.ok);
5356
return {

hub/lib/api.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,16 @@ async function handle(route, data, project) {
155155
// marker), so this is idempotent: a second consume just appends another harmless
156156
// marker and the directive stays non-pending. Surfacing never clears a directive.
157157
const { id, directiveId, by, note } = data;
158-
if (!findTicket(id)) return bad('unknown ticket');
158+
const ticket = findTicket(id);
159+
if (!ticket) return bad('unknown ticket');
159160
if (typeof directiveId !== 'string' || !directiveId) return bad('directiveId required');
161+
// The directiveId must name a real directive ON THIS TICKET, derived from the SAME
162+
// comment log pendingDirectives reads — a kind:"directive" record carrying that id.
163+
// Existence (not pending-ness) is the check: an already-consumed directive still
164+
// exists, so re-consuming it is the idempotent no-op above; an id matching NO
165+
// directive (e.g. a typo) is refused so no orphan marker accrues.
166+
const exists = (ticket.comments || []).some((c) => c && c.kind === 'directive' && c.id === directiveId);
167+
if (!exists) return { code: 404, payload: { ok: false, error: 'unknown directive' } };
160168
const comment = w.appendComment(project, id, {
161169
author: by || 'hub',
162170
kind: 'directive-consumed',

hub/test/directives.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,54 @@ test('N3: consuming the same directive twice is idempotent (still no pending)',
291291
}
292292
});
293293

294+
test('N3: consuming a directiveId that matches NO directive on the ticket is refused (404, nothing appended)', async () => {
295+
const dir = fixture({ 'T-1': { title: 'A', track: 'full', stage: 'code_review' } });
296+
try {
297+
// a real directive exists, but the caller references a different (typo) id
298+
appendComment(dir, 'T-1', { author: '/rev', kind: 'directive', body: 'do X', target: ['/be'] });
299+
const file = path.join(dir, '.aidevteam', 'comments', 'T-1.jsonl');
300+
const before = fs.readFileSync(file, 'utf8');
301+
const res = await api.handle('directive/consume', { id: 'T-1', directiveId: 'no-such-id', by: '/be' }, dir);
302+
assert.equal(res.code, 404, 'an unknown directiveId is refused');
303+
const after = fs.readFileSync(file, 'utf8');
304+
assert.equal(after, before, 'nothing was appended — the JSONL is byte-identical');
305+
} finally {
306+
fs.rmSync(dir, { recursive: true, force: true });
307+
}
308+
});
309+
310+
test('N3: consuming a real directive returns 200, appends the marker, and drops it from pending', async () => {
311+
const dir = fixture({ 'T-1': { title: 'A', track: 'full', stage: 'code_review' } });
312+
try {
313+
const d = appendComment(dir, 'T-1', { author: '/rev', kind: 'directive', body: 'do X', target: ['/be'] });
314+
const res = await api.handle('directive/consume', { id: 'T-1', directiveId: d.id, by: '/be' }, dir);
315+
assert.equal(res.code, 200);
316+
const marker = readComments(dir, 'T-1').find((c) => c.kind === 'directive-consumed' && c.ref === d.id);
317+
assert.ok(marker, 'a typed consumed marker was appended for the real directive');
318+
const t1 = buildState(dir).tickets.find((t) => t.id === 'T-1');
319+
assert.equal(t1.pendingDirectives.length, 0, 'the consumed directive is no longer pending');
320+
} finally {
321+
fs.rmSync(dir, { recursive: true, force: true });
322+
}
323+
});
324+
325+
test('N3: consuming an already-consumed real directive stays 200 (idempotent, no error)', async () => {
326+
const dir = fixture({ 'T-1': { title: 'A', track: 'full', stage: 'code_review' } });
327+
try {
328+
const d = appendComment(dir, 'T-1', { author: '/rev', kind: 'directive', body: 'do X', target: ['/be'] });
329+
const first = await api.handle('directive/consume', { id: 'T-1', directiveId: d.id, by: '/be' }, dir);
330+
assert.equal(first.code, 200);
331+
// the directive still EXISTS on the ticket, so a repeat consume is a harmless no-op,
332+
// NOT refused — existence is what's validated, not pending-ness
333+
const second = await api.handle('directive/consume', { id: 'T-1', directiveId: d.id, by: '/be' }, dir);
334+
assert.equal(second.code, 200, 'a repeat consume of an existing directive is idempotent, not a 404');
335+
const t1 = buildState(dir).tickets.find((t) => t.id === 'T-1');
336+
assert.equal(t1.pendingDirectives.length, 0);
337+
} finally {
338+
fs.rmSync(dir, { recursive: true, force: true });
339+
}
340+
});
341+
294342
test('N3b: rendering the digest mutates nothing (no auto-clear on read)', () => {
295343
const dir = fixture({ 'T-1': { title: 'A', track: 'full', stage: 'code_review', assignee: '/rev' } });
296344
try {

0 commit comments

Comments
 (0)