mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
fix(group): reject malformed and retired group_sync parameters (U1)
`GroupService.groupSync` read `exactOnly` off an untyped MCP payload with
`Boolean(params.exactOnly)`. While the flag was inert that coercion was
harmless; now that it gates the wildcard matching stage, the string "false" --
a routine shape for an LLM caller emitting JSON -- is truthy, so a caller that
asked to KEEP wildcard matching got it suppressed and a registry with fewer
cross-links persisted to disk. The opposite of the request, written down.
Validate instead of coercing, at the service boundary: the MCP SDK does not
enforce a tool's advertised inputSchema and `callTool` is reachable directly,
so this method is the real gate. The validator mirrors `validateImpactMode`'s
`{ value } | { error }` shape -- the established idiom for this boundary, and
the one groupSync's other guards already return through.
Also refuse `skipEmbeddings` and `allowStale` by name. The CLI rejects them
outright because commander errors on an unknown option; the MCP path accepted
and silently dropped them, so an agent working from a cached tool schema was
never told. Removing them took away discoverability, not acceptance.
Both guards run before the group is read off disk, so a rejected call performs
no work. Every test asserts the sync did NOT run -- an error string alone
cannot distinguish "refused" from "refused but synced anyway".
The tool description gains the validation note AFTER the registryOutcome
paragraph: `tools.test.ts` slices that description by ordinal position of the
'preserved' / 'superseded' / 'no-prior-registry' literals, so appending past
all three leaves those slices intact (verified, 44/44).
tsc clean; 1039/1039 group unit tests pass.
This commit is contained in:
parent
ed0c0b9a1d
commit
fd21d2de7b
3 changed files with 131 additions and 2 deletions
|
|
@ -355,6 +355,48 @@ async function loadContractRegistryResilient(
|
|||
return { ok: true, registry, skippedCorrupt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a boolean MCP parameter — reject, never coerce.
|
||||
*
|
||||
* `Boolean(params.x)` is the trap this exists to close: the string `"false"`
|
||||
* is truthy, and an LLM caller emitting JSON produces that shape routinely.
|
||||
* While `exactOnly` was inert the coercion was harmless; now that it gates a
|
||||
* matching stage, a coerced `"false"` suppresses that stage and persists a
|
||||
* registry with fewer cross-links than the caller asked for.
|
||||
*
|
||||
* Absent stays absent-as-false (the unchanged default). Anything that is not
|
||||
* a real boolean returns a structured `{ error }`, mirroring
|
||||
* `validateImpactMode` — the established shape for this boundary, and the one
|
||||
* `groupSync`'s other guards already use.
|
||||
*/
|
||||
function validateBooleanParam(name: string, raw: unknown): { value: boolean } | { error: string } {
|
||||
if (raw === undefined) return { value: false };
|
||||
if (typeof raw === 'boolean') return { value: raw };
|
||||
return {
|
||||
error: `Invalid "${name}": expected true or false, got ${JSON.stringify(raw)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse parameters this tool used to accept and no longer does.
|
||||
*
|
||||
* The CLI rejects a removed flag outright because commander errors on an
|
||||
* unknown option. The MCP path had no equivalent, so an agent working from a
|
||||
* cached tool schema kept sending a retired key and was told nothing — the
|
||||
* removal took away discoverability, not acceptance. Naming the parameter is
|
||||
* what lets the caller correct itself on the next call.
|
||||
*/
|
||||
function rejectRetiredSyncParams(params: Record<string, unknown>): { error: string } | null {
|
||||
for (const retired of ['skipEmbeddings', 'allowStale']) {
|
||||
if (params[retired] !== undefined) {
|
||||
return {
|
||||
error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class GroupService {
|
||||
constructor(private readonly port: GroupToolPort) {}
|
||||
|
||||
|
|
@ -384,6 +426,13 @@ export class GroupService {
|
|||
async groupSync(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { error: 'name is required' };
|
||||
// Before anything reads the group off disk: the MCP SDK does not enforce a
|
||||
// tool's advertised `inputSchema` and `callTool` is reachable directly, so
|
||||
// this method is the real validation boundary.
|
||||
const exactOnly = validateBooleanParam('exactOnly', params.exactOnly);
|
||||
if ('error' in exactOnly) return exactOnly;
|
||||
const retired = rejectRetiredSyncParams(params);
|
||||
if (retired) return retired;
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
let config: GroupConfig;
|
||||
try {
|
||||
|
|
@ -404,7 +453,7 @@ export class GroupService {
|
|||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
exactOnly: exactOnly.value,
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -854,7 +854,7 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
|
|||
|
||||
WHEN TO USE: After changing group.yaml or re-indexing member repos.
|
||||
|
||||
READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either.`,
|
||||
READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either.\n\nPARAMETERS ARE VALIDATED: \`exactOnly\` must be a real boolean — the string "false" is rejected, not coerced to true. The retired \`skipEmbeddings\` and \`allowStale\` parameters are refused by name; drop them from the call.`,
|
||||
// Usually writes contracts.json, so conservatively non-idempotent even
|
||||
// though output is deterministic for identical input. When no configured
|
||||
// repo could be read it still rewrites the file, keeping the previous
|
||||
|
|
|
|||
|
|
@ -302,3 +302,83 @@ describe('group_contracts forwards its structured incompleteness', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What `group_sync` REFUSES to run on.
|
||||
*
|
||||
* The MCP SDK does not enforce a tool's advertised `inputSchema` and
|
||||
* `callTool` is reachable directly, so this service method is the real
|
||||
* validation boundary. Two consequences this block pins:
|
||||
*
|
||||
* - `exactOnly` now gates a matching stage, so `Boolean(params.exactOnly)`
|
||||
* turned the string `"false"` — a common shape for an LLM caller emitting
|
||||
* JSON — into `true` and persisted a registry with the wildcard stage
|
||||
* suppressed. The opposite of what the caller asked for, written to disk.
|
||||
* - `skipEmbeddings` and `allowStale` were retired. The CLI rejects them
|
||||
* outright; the MCP path accepted and silently dropped them, so an agent
|
||||
* working from a cached schema was told nothing.
|
||||
*
|
||||
* Every case asserts `syncGroupMock` was NOT called: a rejection that still
|
||||
* runs the sync is the failure mode, and an error string alone cannot tell
|
||||
* the two apart.
|
||||
*/
|
||||
describe('group_sync rejects malformed and retired parameters', () => {
|
||||
it.each([['false'], ['true'], [0], [1], [null], [{}], [[]]])(
|
||||
'rejects a non-boolean exactOnly (%j) and runs no sync',
|
||||
async (bad) => {
|
||||
const payload = await new GroupService(port).groupSync({ name: GROUP, exactOnly: bad });
|
||||
|
||||
expect(payload).toEqual({
|
||||
error: `Invalid "exactOnly": expected true or false, got ${JSON.stringify(bad)}.`,
|
||||
});
|
||||
expect(syncGroupMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([['skipEmbeddings'], ['allowStale']])(
|
||||
'rejects the retired %s parameter by name and runs no sync',
|
||||
async (retired) => {
|
||||
const payload = await new GroupService(port).groupSync({ name: GROUP, [retired]: true });
|
||||
|
||||
expect(payload).toEqual({
|
||||
error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`,
|
||||
});
|
||||
expect(syncGroupMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[true], [false]])(
|
||||
'passes a real boolean exactOnly (%j) through unchanged',
|
||||
async (ok) => {
|
||||
syncGroupMock.mockResolvedValue(syncResult());
|
||||
|
||||
await new GroupService(port).groupSync({ name: GROUP, exactOnly: ok });
|
||||
|
||||
expect(syncGroupMock).toHaveBeenCalledTimes(1);
|
||||
expect(syncGroupMock.mock.calls[0][1]).toMatchObject({ exactOnly: ok });
|
||||
},
|
||||
);
|
||||
|
||||
it('treats an omitted exactOnly as false', async () => {
|
||||
syncGroupMock.mockResolvedValue(syncResult());
|
||||
|
||||
await new GroupService(port).groupSync({ name: GROUP });
|
||||
|
||||
expect(syncGroupMock.mock.calls[0][1]).toMatchObject({ exactOnly: false });
|
||||
});
|
||||
|
||||
// control: the guards above reject specific shapes, not every call. Without
|
||||
// this, deleting the whole method body and returning an error would pass.
|
||||
it('control: a valid call with only a name still syncs', async () => {
|
||||
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' }));
|
||||
|
||||
const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(payload.error).toBeUndefined();
|
||||
expect(payload.registryOutcome).toBe('written');
|
||||
expect(syncGroupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue