* fix(cli): describe one-shot timeouts for an invocation whose session has ended A one-shot CLI analysis that reached a provider deadline told its caller that the Ghidra session remained usable and reported a runtime-editable limit applying to the next operation, although the command had already released its provider session and runtime. The shared error projection now takes an explicit caller lifetime. Providers keep guidance that holds only for a continuing caller (session reuse, same-session runtime updates) apart from the request outcome, and the presentation adds it only for a continuing caller. One-shot owners (runDirectAnalysis, runProviderAnalysis, finishAnalysisCommand and the JavaScript application command) project for the invocation: details.limit reports scope rea-invocation, applies_at next_invocation and the launch environment seed, and the remediation leads with that setting for a new invocation. MCP output keeps its runtime-update and same-session guidance. Covers the Ghidra decompile timeout, decompiler failure, regex stack exhaustion and startup timeout, the shared regex deadline, the Kuna decompile timeout and the Hopper startup timeout. Fixes #8311 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cli): project composed child failures for the caller lifetime Cleanup composition embedded its primary and cleanup failures as continuing-caller projections, and baked the primary's message and the cleanup's remediation into the composed error. A one-shot command whose Ghidra startup timed out while shutdown also failed therefore still reported a rea-runtime limit and update_analysis_settings in details.diagnostics.primary_error. childFailureDiagnostics now snapshots each embedded child at composition for both lifetimes when its projection depends on the caller, so cleanup history keeps its composition-time state and the final projection selects the caller's form. composedCallerGuidance re-attributes message and remediation with their continuing-only parts kept separate. Error copies and redaction keep the invocation diagnostics. The Hopper startup timeout now carries its continuing guidance on the error like the other producers. Invocation limits keep agent_editable and rea_restart_required, with values that hold for a new invocation, so only scope and applies_at change and environment is added. In existing-project mode a decompile timeout or a cancelled session now says the next session copies and opens REA_GHIDRA_PROJECT_PATH again rather than repeating import and analysis. Tests compare delivered guidance with the owning formatters and constants instead of freezing prose. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { BrowserObservationError } from "./browserObservationError.js";
|
|
import { EvidenceFileError } from "./evidenceErrors.js";
|
|
import type { AnalysisError } from "./analysisErrorBase.js";
|
|
import {
|
|
AnalysisAccessDeniedError,
|
|
AnalysisArtifactChangedError,
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
AnalysisOutputError,
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisResourceConstraintError,
|
|
resourceConstraintRecovery,
|
|
AnalysisCancelledError,
|
|
AnalysisTimeoutError,
|
|
} from "./analysisErrorCore.js";
|
|
import { redactExplicitText } from "./explicitSensitiveValues.js";
|
|
import type { JsonValue } from "./jsonValue.js";
|
|
import { ProviderAdapterError } from "./providerAdapterError.js";
|
|
import { ProviderCleanupError } from "./providerCleanupError.js";
|
|
import {
|
|
fileSnapshotPartialObservationSchema,
|
|
type FileSnapshotPartialObservation,
|
|
} from "./fileSnapshotObservation.js";
|
|
|
|
/** Exclude whole identity values; redacted text is no longer a path or digest. */
|
|
const excludeSnapshotIdentity = (
|
|
value: unknown,
|
|
text: (value: string) => string,
|
|
): FileSnapshotPartialObservation | undefined => {
|
|
if (
|
|
value === null ||
|
|
typeof value !== "object" ||
|
|
!("kind" in value) ||
|
|
(value.kind !== "file-snapshot-hash" && value.kind !== "file-snapshot-read")
|
|
)
|
|
return undefined;
|
|
const parsed = fileSnapshotPartialObservationSchema.safeParse(value);
|
|
if (!parsed.success) return undefined;
|
|
const snapshot = parsed.data;
|
|
const exclude = (identity: string | null): string | null =>
|
|
identity !== null && text(identity) !== identity ? null : identity;
|
|
const path = exclude(snapshot.path);
|
|
if (snapshot.kind === "file-snapshot-hash") {
|
|
const sha256 = exclude(snapshot.sha256);
|
|
return { ...snapshot, path, sha256 };
|
|
}
|
|
const admitted_sha256 = exclude(snapshot.admitted_sha256);
|
|
return { ...snapshot, path, admitted_sha256 };
|
|
};
|
|
|
|
/** Keep a real JSON-pointer ancestor when a property identity is explicitly excluded. */
|
|
export const redactExplicitPointer = (
|
|
pointer: string,
|
|
values: readonly string[],
|
|
): string =>
|
|
redactSensitivePointer(pointer, (value) => redactExplicitText(value, values));
|
|
|
|
const redactSensitivePointer = (
|
|
pointer: string,
|
|
text: (value: string) => string,
|
|
): string => {
|
|
let parent = "";
|
|
for (const segment of pointer.split("/").slice(1)) {
|
|
const decoded = segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
const child = `${parent}/${segment}`;
|
|
if (text(decoded) !== decoded || text(child) !== child) return parent;
|
|
parent = child;
|
|
}
|
|
return parent;
|
|
};
|
|
|
|
/** Exclude explicit literals from common acquisition/decoder failures, preserving typed diagnostics. */
|
|
export const redactExplicitFailure = (
|
|
error: AnalysisError,
|
|
values: readonly string[],
|
|
): AnalysisError => {
|
|
if (values.length === 0) return error;
|
|
return redactSensitiveFailure(error, (value) =>
|
|
redactExplicitText(value, values),
|
|
);
|
|
};
|
|
|
|
/** Apply the producer's parsed sensitivity rules without exporting resolved secrets. */
|
|
export const redactSensitiveFailure = (
|
|
error: AnalysisError,
|
|
text: (value: string) => string,
|
|
): AnalysisError => {
|
|
const snapshot = excludeSnapshotIdentity(error.partialObservation, text);
|
|
if (snapshot !== undefined) error.partialObservation = snapshot;
|
|
const redacted = redactFailure(error, text);
|
|
// Other observation owners already apply their format-aware exclusions.
|
|
if (redacted !== error && error.partialObservation !== undefined)
|
|
redacted.retainPartialObservation(error.partialObservation);
|
|
return redacted === error ? error : mirrorCleanup(error, redacted, text);
|
|
};
|
|
|
|
const redactFailure = (
|
|
error: AnalysisError,
|
|
text: (value: string) => string,
|
|
): AnalysisError => {
|
|
const path = (
|
|
parts: readonly (string | number)[],
|
|
ordinaryNames: boolean,
|
|
): (string | number)[] => {
|
|
const retained: (string | number)[] = [];
|
|
for (const part of parts) {
|
|
if (typeof part !== "string") {
|
|
retained.push(part);
|
|
} else if (!ordinaryNames && part.startsWith("/")) {
|
|
const pointer = redactSensitivePointer(part, text);
|
|
retained.push(pointer);
|
|
if (pointer !== part) break;
|
|
} else {
|
|
if (text(part) !== part) break;
|
|
retained.push(part);
|
|
}
|
|
}
|
|
return retained;
|
|
};
|
|
const diagnostics = (
|
|
value: Readonly<Record<string, JsonValue>>,
|
|
): Record<string, JsonValue> =>
|
|
Object.fromEntries(
|
|
Object.entries(value)
|
|
.filter(([key]) => text(key) === key)
|
|
.map(([key, item]) => [key, visit(item)]),
|
|
);
|
|
const visit = (value: JsonValue): JsonValue => {
|
|
const snapshot = excludeSnapshotIdentity(value, text);
|
|
if (snapshot !== undefined) return snapshot;
|
|
if (typeof value === "string") return text(value);
|
|
if (Array.isArray(value)) return value.map(visit);
|
|
if (typeof value === "object" && value !== null) return diagnostics(value);
|
|
return value;
|
|
};
|
|
const issueProjection = (issue: AnalysisInputIssue): AnalysisInputIssue => ({
|
|
...issue,
|
|
// Unknown arguments are literal names, not producer JSON pointers.
|
|
path: path(issue.path, issue.reason === "unknown_argument"),
|
|
...(issue.message === undefined ? {} : { message: text(issue.message) }),
|
|
...(issue.expected === undefined
|
|
? {}
|
|
: { expected: visit(issue.expected) }),
|
|
...(issue.observed === undefined
|
|
? {}
|
|
: { observed: visit(issue.observed) }),
|
|
});
|
|
const guidance = error.continuingCallerGuidance;
|
|
const context = {
|
|
cause: error,
|
|
...(error.limit === undefined ? {} : { limit: error.limit }),
|
|
...(guidance === undefined
|
|
? {}
|
|
: {
|
|
continuingCallerGuidance: {
|
|
...(guidance.userMessage === undefined
|
|
? {}
|
|
: { userMessage: text(guidance.userMessage) }),
|
|
...(guidance.remediationAction === undefined
|
|
? {}
|
|
: { remediationAction: text(guidance.remediationAction) }),
|
|
},
|
|
}),
|
|
...(error.cleanupPrimary === undefined
|
|
? {}
|
|
: {
|
|
cleanupPrimary: redactSensitiveFailure(error.cleanupPrimary, text),
|
|
}),
|
|
...(error.diagnostics === undefined
|
|
? {}
|
|
: { diagnostics: diagnostics(error.diagnostics) }),
|
|
...(error.invocationDiagnostics === undefined
|
|
? {}
|
|
: { invocationDiagnostics: diagnostics(error.invocationDiagnostics) }),
|
|
...(error.capturedOutput === undefined
|
|
? {}
|
|
: {
|
|
capturedOutput: {
|
|
...error.capturedOutput,
|
|
stdout: text(error.capturedOutput.stdout),
|
|
stderr: text(error.capturedOutput.stderr),
|
|
},
|
|
}),
|
|
...(error.cleanup === undefined
|
|
? {}
|
|
: {
|
|
cleanup: {
|
|
reason: text(error.cleanup.reason),
|
|
resources: error.cleanup.resources.map(text),
|
|
},
|
|
}),
|
|
};
|
|
if (error instanceof BrowserObservationError)
|
|
return new BrowserObservationError(error.operation, error.reason, {
|
|
...context,
|
|
...(error.detail === undefined ? {} : { detail: text(error.detail) }),
|
|
...(error.remediationAction === undefined
|
|
? {}
|
|
: { remediationAction: text(error.remediationAction) }),
|
|
});
|
|
if (error instanceof EvidenceFileError) {
|
|
const identity = (value: string | undefined): string | undefined =>
|
|
value !== undefined && text(value) !== value ? undefined : value;
|
|
const retainedPath = identity(error.path);
|
|
const publishedPath = identity(error.published?.path);
|
|
return new EvidenceFileError(error.operation, error.reason, {
|
|
...context,
|
|
...(error.systemCode === undefined ||
|
|
text(error.systemCode) !== error.systemCode
|
|
? {}
|
|
: { systemCode: error.systemCode }),
|
|
...(retainedPath === undefined ? {} : { path: retainedPath }),
|
|
...(error.userMessage === undefined
|
|
? {}
|
|
: { userMessage: text(error.userMessage) }),
|
|
...(error.published === undefined
|
|
? {}
|
|
: {
|
|
published: {
|
|
bytes: error.published.bytes,
|
|
...(publishedPath === undefined ? {} : { path: publishedPath }),
|
|
},
|
|
}),
|
|
...(error.contentIssues === undefined
|
|
? {}
|
|
: { contentIssues: error.contentIssues.map(issueProjection) }),
|
|
});
|
|
}
|
|
if (error instanceof AnalysisCancelledError)
|
|
return new AnalysisCancelledError(error.operation, {
|
|
...context,
|
|
cause: error.cause,
|
|
});
|
|
if (error instanceof AnalysisTimeoutError)
|
|
return new AnalysisTimeoutError(error.operation, error.timeoutMs, {
|
|
...context,
|
|
...(error.userMessage === undefined
|
|
? {}
|
|
: { userMessage: text(error.userMessage) }),
|
|
...(error.remediationAction === undefined
|
|
? {}
|
|
: { remediationAction: text(error.remediationAction) }),
|
|
});
|
|
if (error instanceof AnalysisAccessDeniedError)
|
|
return new AnalysisAccessDeniedError(
|
|
error.operation,
|
|
text(error.path),
|
|
error.systemCode,
|
|
context,
|
|
);
|
|
if (error instanceof AnalysisArtifactChangedError)
|
|
return new AnalysisArtifactChangedError(
|
|
error.operation,
|
|
text(error.path),
|
|
text(error.reason),
|
|
context,
|
|
);
|
|
if (error instanceof AnalysisInputError)
|
|
return new AnalysisInputError(
|
|
error.operation,
|
|
context,
|
|
error.issues.map(issueProjection),
|
|
);
|
|
if (error instanceof AnalysisOutputError)
|
|
return new AnalysisOutputError(
|
|
error.operation,
|
|
text(error.reason),
|
|
context,
|
|
);
|
|
if (error instanceof AnalysisCapabilityUnavailableError)
|
|
return new AnalysisCapabilityUnavailableError(
|
|
error.providerId,
|
|
error.operation,
|
|
text(error.reason),
|
|
{
|
|
...context,
|
|
...(error.userMessage === undefined
|
|
? {}
|
|
: { userMessage: text(error.userMessage) }),
|
|
...(error.retryable ? { retryable: true } : {}),
|
|
},
|
|
);
|
|
if (error instanceof AnalysisResourceConstraintError)
|
|
return new AnalysisResourceConstraintError(
|
|
error.operation,
|
|
error.resource,
|
|
text(error.reason),
|
|
error.reportedLimits === null ? null : diagnostics(error.reportedLimits),
|
|
{ ...context, ...resourceConstraintRecovery(error, text) },
|
|
);
|
|
if (error instanceof ProviderCleanupError)
|
|
return new ProviderCleanupError(
|
|
error.providerId,
|
|
error.cleanupResources.map(text),
|
|
diagnostics(error.diagnostics ?? {}),
|
|
{
|
|
...context,
|
|
operation: error.operation,
|
|
},
|
|
);
|
|
if (error instanceof ProviderAdapterError)
|
|
return new ProviderAdapterError(error.providerId, error.operation, {
|
|
...context,
|
|
...(error.userMessage === undefined
|
|
? {}
|
|
: { userMessage: text(error.userMessage) }),
|
|
...(error.remediationAction === undefined
|
|
? {}
|
|
: { remediationAction: text(error.remediationAction) }),
|
|
});
|
|
return error;
|
|
};
|
|
|
|
const mirrorCleanup = <Value extends AnalysisError>(
|
|
source: AnalysisError,
|
|
redacted: Value,
|
|
redact: (value: string) => string,
|
|
): Value =>
|
|
redacted.bindCleanupDependencies([source], {
|
|
get cleanupResources(): readonly string[] {
|
|
return source.cleanupResources.map(redact);
|
|
},
|
|
});
|