fix(security): separate warning approval identity

This commit is contained in:
jesse-merhi
2026-08-11 13:58:36 +10:00
parent b4afc601b0
commit defd9c40d6
2 changed files with 27 additions and 13 deletions
+21 -11
View File
@@ -20,7 +20,7 @@ export type InstallPolicyResult =
| { blocked?: undefined; warning?: undefined; findings?: InstallPolicyFinding[] }
| {
blocked?: undefined;
warning: { reason: string; fingerprint: string };
warning: { reason: string; fingerprint: string; approvalFingerprint: string };
findings?: InstallPolicyFinding[];
}
| {
@@ -105,27 +105,32 @@ function truncateFinding(finding: InstallPolicyFinding): InstallPolicyFinding {
};
}
function splitFingerprintText(value: string, sourcePath: string): string[] {
function fingerprintWarning(reason: string, findings: InstallPolicyFinding[]): string {
return createHash("sha256").update(JSON.stringify({ reason, findings })).digest("hex");
}
function normalizeApprovalFingerprintText(value: string, sourcePath: string): string[] {
return sourcePath ? value.split(sourcePath) : [value];
}
function fingerprintWarning(
function fingerprintWarningApproval(
reason: string,
findings: InstallPolicyFinding[],
sourcePath: string,
): string {
// Presentation is truncated and capped; approval must bind the complete
// validated warning. Physical staging roots vary across equivalent retries,
// so fingerprint their position without binding to the ephemeral path bytes.
// validated warning while excluding the transport-only staging path.
const fingerprintInput = {
reason: splitFingerprintText(reason, sourcePath),
reason: normalizeApprovalFingerprintText(reason, sourcePath),
findings: findings.map((finding) => ({
ruleId: splitFingerprintText(finding.ruleId, sourcePath),
ruleId: normalizeApprovalFingerprintText(finding.ruleId, sourcePath),
severity: finding.severity,
message: splitFingerprintText(finding.message, sourcePath),
...(finding.file ? { file: splitFingerprintText(finding.file, sourcePath) } : {}),
message: normalizeApprovalFingerprintText(finding.message, sourcePath),
...(finding.file ? { file: normalizeApprovalFingerprintText(finding.file, sourcePath) } : {}),
...(finding.line !== undefined ? { line: finding.line } : {}),
...(finding.evidence ? { evidence: splitFingerprintText(finding.evidence, sourcePath) } : {}),
...(finding.evidence
? { evidence: normalizeApprovalFingerprintText(finding.evidence, sourcePath) }
: {}),
})),
};
return createHash("sha256").update(JSON.stringify(fingerprintInput)).digest("hex");
@@ -183,7 +188,12 @@ export function parseInstallPolicyResponse(
return {
warning: {
reason: truncateText(reason.data, MAX_REASON_CHARS),
fingerprint: fingerprintWarning(reason.data, fullFindings, params.sourcePath),
fingerprint: fingerprintWarning(reason.data, fullFindings),
approvalFingerprint: fingerprintWarningApproval(
reason.data,
fullFindings,
params.sourcePath,
),
},
...(normalizedFindings.length > 0 ? { findings: normalizedFindings } : {}),
};
+6 -2
View File
@@ -438,6 +438,7 @@ describe("runInstallPolicy", () => {
warning: {
reason: "review this source",
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
approvalFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
},
findings: [
{
@@ -465,9 +466,10 @@ describe("runInstallPolicy", () => {
expect(first?.warning?.reason).toBe(second?.warning?.reason);
expect(first?.warning?.fingerprint).not.toBe(second?.warning?.fingerprint);
expect(first?.warning?.approvalFingerprint).not.toBe(second?.warning?.approvalFingerprint);
});
it("keeps warning fingerprints stable across equivalent staging paths", async () => {
it("keeps approval fingerprints stable across equivalent staging paths", async () => {
const runWarning = async (stagingPath: string) =>
await runInstallPolicy({
config: configWithPolicy(scriptPath, {
@@ -492,7 +494,8 @@ describe("runInstallPolicy", () => {
const first = await runWarning("/tmp/openclaw-stage-a");
const second = await runWarning("/tmp/openclaw-stage-b");
expect(first?.warning?.fingerprint).toBe(second?.warning?.fingerprint);
expect(first?.warning?.fingerprint).not.toBe(second?.warning?.fingerprint);
expect(first?.warning?.approvalFingerprint).toBe(second?.warning?.approvalFingerprint);
expect(first?.warning?.reason).not.toBe(second?.warning?.reason);
expect(first?.findings).not.toEqual(second?.findings);
});
@@ -550,6 +553,7 @@ describe("runInstallPolicy", () => {
expect(result?.warning).toEqual({
reason: "review valid findings",
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
approvalFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(result?.findings).toEqual([
{