fix(confidence-engine): bound focused investigation input

This commit is contained in:
2026-09-10 13:23:39 +01:00
parent 2cb2d556fd
commit a6796c6f73
4 changed files with 202 additions and 2 deletions
@@ -50,6 +50,23 @@ async function post(request) {
);
}
const REQUEST_LENGTH_LIMITS = {
answer: 10000,
question: 2048,
centralStatement: 2048,
targetLabel: 2048,
targetDescription: 2048,
};
for (const [field, limit] of Object.entries(REQUEST_LENGTH_LIMITS)) {
if (body[field] && body[field].length > limit) {
return Response.json(
{ error: `Request field "${field}" exceeds maximum length of ${limit} characters` },
{ status: 400 },
);
}
}
const prompt = buildFocusedDeconstructPrompt({
targetLabel: body.targetLabel,
targetDescription: body.targetDescription,
+8 -2
View File
@@ -30,6 +30,9 @@ function isTechnicalSummary(summary) {
return false;
}
// ── Focused answer contract ──────────────────────────────────
const FOCUSED_ANSWER_MAX_LENGTH = 10000;
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
@@ -238,8 +241,11 @@ function FocusedQuestionBody({
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
<div data-testid="completed-narrative">
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="mt-3 rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
<textarea id={`rw-answer-${nodeId}`} value={focusedAnswer} onChange={(e) => setFocusedAnswer(e.target.value)} rows={4} maxLength={FOCUSED_ANSWER_MAX_LENGTH} data-testid="response-textarea" className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60" placeholder="What do you know about this?" />
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-400">{focusedAnswer.length}/{FOCUSED_ANSWER_MAX_LENGTH}</span>
<button onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }} disabled={!focusedAnswer.trim() || processingStep === "active"} style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }} className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50">Submit response</button>
</div>
</div>
)}
+9
View File
@@ -125,6 +125,15 @@ Jenkins SCM branch used to load the Jenkinsfile is conceptually separate from th
- CSS Grid with responsive column placement replaces original flexbox; three DOM sections ensure correct mobile stacking without duplicating content
- desktop two-column presentation (explanation left / sign-in right) preserved unchanged at `md` breakpoint and above
## Focused-investigation input hardening
- Focused answer now has a visible 10,000-character UI limit (maxLength + character counter).
- Server enforces matching 10,000-character bound on `/api/focused-investigation/deconstruct`.
- Other prompt-bearing fields retain explicit defensive bounds (2048 characters each).
- Oversized/malformed requests are rejected before reasoning with controlled HTTP 400.
- No punctuation/HTML/SQL-style content stripping introduced.
- Reasoning semantics unchanged.
## CURRENT MVP DIRECTION
Initial-decomposition hardening is frozen for the current MVP stage.
+168
View File
@@ -393,6 +393,174 @@ describe("focused-deconstruct targetNodeId identity boundary", () => {
}
});
it("rejects oversized focused answer with 400 and does not reach reasoning seam", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {},
providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const largeAnswer = "x".repeat(10001);
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: largeAnswer,
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/answer.*exceeds maximum length/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("accepts focused answer at exact server max (10000) and reaches reasoning seam", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {
targetNodeId: "node-id",
observations: [],
uncertainties: [],
assumptions: [],
relationships: [],
possibleFollowUpQuestions: [],
},
providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const exactMaxAnswer = "x".repeat(10000);
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: exactMaxAnswer,
}),
}));
expect(response.status).toBe(200);
const json = await response.json();
expect(json.success).toBe(true);
expect(generateReconstruction).toHaveBeenCalled();
});
it("rejects malformed required field (wrong type) with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: ["not-a-string"],
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/targetNodeId.*string/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("rejects oversized targetDescription with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "x".repeat(2049),
centralStatement: "central statement",
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/targetDescription.*exceeds maximum length/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("rejects malformed centralStatement (number) with 400", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/api/chat",
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: 12345,
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toMatch(/centralStatement.*string/i);
expect(generateReconstruction).not.toHaveBeenCalled();
});
it("returns a sanitized 503 when the provider is unavailable", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({