fix(confidence-engine): bound focused investigation input
This commit is contained in:
@@ -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({
|
const prompt = buildFocusedDeconstructPrompt({
|
||||||
targetLabel: body.targetLabel,
|
targetLabel: body.targetLabel,
|
||||||
targetDescription: body.targetDescription,
|
targetDescription: body.targetDescription,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ function isTechnicalSummary(summary) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Focused answer contract ──────────────────────────────────
|
||||||
|
const FOCUSED_ANSWER_MAX_LENGTH = 10000;
|
||||||
|
|
||||||
// ── Recovery state components (Phase 2) ───────────────────────
|
// ── Recovery state components (Phase 2) ───────────────────────
|
||||||
|
|
||||||
function ProviderUnavailableCard({ onRestart }) {
|
function ProviderUnavailableCard({ onRestart }) {
|
||||||
@@ -238,8 +241,11 @@ function FocusedQuestionBody({
|
|||||||
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
|
{focused?.question?.trim() && processingStep !== "active" && focused.status === "formulated" && !hasAnswer && !hasActiveFollowUp && (
|
||||||
<div data-testid="completed-narrative">
|
<div data-testid="completed-narrative">
|
||||||
<label htmlFor={`rw-answer-${nodeId}`} className="mb-2 block text-sm font-medium text-gray-700">Your response</label>
|
<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?" />
|
<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?" />
|
||||||
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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
|
## CURRENT MVP DIRECTION
|
||||||
|
|
||||||
Initial-decomposition hardening is frozen for the current MVP stage.
|
Initial-decomposition hardening is frozen for the current MVP stage.
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns a sanitized 503 when the provider is unavailable", async () => {
|
||||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
vi.doMock("@/lib/llm/provider", () => ({
|
vi.doMock("@/lib/llm/provider", () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user