Merge branch 'main' into vincentkoc-code/config-log-spam-dedupe

This commit is contained in:
Vincent Koc
2026-03-06 15:10:43 -05:00
committed by GitHub
7 changed files with 181 additions and 18 deletions

View File

@@ -0,0 +1,47 @@
name: Ensure base commit
description: Ensure a shallow checkout has enough history to diff against a base SHA.
inputs:
base-sha:
description: Base commit SHA to diff against.
required: true
fetch-ref:
description: Branch or ref to deepen/fetch from origin when base-sha is missing.
required: true
runs:
using: composite
steps:
- name: Ensure base commit is available
shell: bash
env:
BASE_SHA: ${{ inputs.base-sha }}
FETCH_REF: ${{ inputs.fetch-ref }}
run: |
set -euo pipefail
if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then
echo "No concrete base SHA available; skipping targeted fetch."
exit 0
fi
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Base commit already present: $BASE_SHA"
exit 0
fi
for deepen_by in 25 100 300; do
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF" || true
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Resolved base commit after deepening: $BASE_SHA"
exit 0
fi
done
echo "Base commit still missing; fetching full history for $FETCH_REF."
git fetch --no-tags origin "$FETCH_REF" || true
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
echo "Resolved base commit after full ref fetch: $BASE_SHA"
exit 0
fi
echo "Base commit still unavailable after fetch attempts: $BASE_SHA"

View File

@@ -21,10 +21,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
fetch-tags: false
submodules: false
- name: Ensure docs-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-changes
@@ -46,10 +52,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
fetch-tags: false
submodules: false
- name: Ensure changed-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect changed scopes
id: scope
shell: bash
@@ -75,6 +87,13 @@ jobs:
with:
submodules: false
- name: Ensure secrets base commit (PR fast path)
if: github.event_name == 'pull_request'
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -303,13 +322,39 @@ jobs:
- name: Install pre-commit
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit detect-secrets==1.5.0
python -m pip install pre-commit
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
set -euo pipefail
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "Skipping detect-secrets on main until the allowlist cleanup lands."
exit 0
fi
if [ "${{ github.event_name }}" = "push" ]; then
echo "Running full detect-secrets scan on push."
pre-commit run --all-files detect-secrets
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
changed_files=()
if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -f "$path" ] || continue
changed_files+=("$path")
done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD)
fi
if [ "${#changed_files[@]}" -gt 0 ]; then
echo "Running detect-secrets on ${#changed_files[@]} changed file(s)."
pre-commit run detect-secrets --files "${changed_files[@]}"
else
echo "Falling back to full detect-secrets scan."
pre-commit run --all-files detect-secrets
fi
- name: Detect committed private keys

View File

@@ -19,9 +19,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 50
fetch-depth: 1
fetch-tags: false
- name: Ensure docs-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: check
uses: ./.github/actions/detect-docs-changes

View File

@@ -234,6 +234,7 @@ Docs: https://docs.openclaw.ai
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
- Agents/compaction safeguard quality-audit rollout: keep summary quality audits disabled by default unless `agents.defaults.compaction.qualityGuard` is explicitly enabled, and add config plumbing for bounded retry control. (#25556) thanks @rodrigouroz.
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
### Breaking

View File

@@ -1158,19 +1158,22 @@ If your AI does something bad:
## Secret Scanning (detect-secrets)
CI runs `detect-secrets scan --baseline .secrets.baseline` in the `secrets` job.
If it fails, there are new candidates not yet in the baseline.
CI runs the `detect-secrets` pre-commit hook in the `secrets` job.
Pushes to `main` always run an all-files scan. Pull requests use a changed-file
fast path when a base commit is available, and fall back to an all-files scan
otherwise. If it fails, there are new candidates not yet in the baseline.
### If CI fails
1. Reproduce locally:
```bash
detect-secrets scan --baseline .secrets.baseline
pre-commit run --all-files detect-secrets
```
2. Understand the tools:
- `detect-secrets scan` finds candidates and compares them to the baseline.
- `detect-secrets` in pre-commit runs `detect-secrets-hook` with the repo's
baseline and excludes.
- `detect-secrets audit` opens an interactive review to mark each baseline
item as real or false positive.
3. For real secrets: rotate/remove them, then re-run the scan to update the baseline.

View File

@@ -99,7 +99,9 @@ describe("HEIC input image normalization", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("keeps declared MIME for non-HEIC images without sniffing", async () => {
it("keeps declared MIME for non-HEIC images after validation", async () => {
detectMimeMock.mockResolvedValueOnce("image/png");
const image = await extractImageContentFromSource(
{
type: "base64",
@@ -115,7 +117,7 @@ describe("HEIC input image normalization", () => {
},
);
expect(detectMimeMock).not.toHaveBeenCalled();
expect(detectMimeMock).toHaveBeenCalledTimes(1);
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
expect(image).toEqual({
type: "image",
@@ -123,6 +125,59 @@ describe("HEIC input image normalization", () => {
mimeType: "image/png",
});
});
it("rejects spoofed base64 images when detected bytes are not an image", async () => {
detectMimeMock.mockResolvedValueOnce("application/pdf");
await expect(
extractImageContentFromSource(
{
type: "base64",
data: Buffer.from("%PDF-1.4\n").toString("base64"),
mediaType: "image/png",
},
{
allowUrl: false,
allowedMimes: new Set(["image/png", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1,
},
),
).rejects.toThrow("Unsupported image MIME type: application/pdf");
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
});
it("rejects spoofed URL images when detected bytes are not an image", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(Buffer.from("%PDF-1.4\n"), {
status: 200,
headers: { "content-type": "image/png" },
}),
release,
finalUrl: "https://example.com/photo.png",
});
detectMimeMock.mockResolvedValueOnce("application/pdf");
await expect(
extractImageContentFromSource(
{
type: "url",
url: "https://example.com/photo.png",
},
{
allowUrl: true,
allowedMimes: new Set(["image/png", "image/jpeg"]),
maxBytes: 1024 * 1024,
maxRedirects: 0,
timeoutMs: 1000,
},
),
).rejects.toThrow("Unsupported image MIME type: application/pdf");
expect(release).toHaveBeenCalledTimes(1);
expect(convertHeicToJpegMock).not.toHaveBeenCalled();
});
});
describe("fetchWithGuard", () => {

View File

@@ -235,11 +235,17 @@ async function normalizeInputImage(params: {
limits: InputImageLimits;
}): Promise<InputImageContent> {
const declaredMime = normalizeMimeType(params.mimeType) ?? "application/octet-stream";
const sourceMime = HEIC_INPUT_IMAGE_MIMES.has(declaredMime)
? (normalizeMimeType(
await detectMime({ buffer: params.buffer, headerMime: params.mimeType }),
) ?? declaredMime)
: declaredMime;
const detectedMime = normalizeMimeType(
await detectMime({ buffer: params.buffer, headerMime: params.mimeType }),
);
if (declaredMime.startsWith("image/") && detectedMime && !detectedMime.startsWith("image/")) {
throw new Error(`Unsupported image MIME type: ${detectedMime}`);
}
const sourceMime =
(detectedMime && HEIC_INPUT_IMAGE_MIMES.has(detectedMime)) ||
(HEIC_INPUT_IMAGE_MIMES.has(declaredMime) && !detectedMime)
? (detectedMime ?? declaredMime)
: declaredMime;
if (!params.limits.allowedMimes.has(sourceMime)) {
throw new Error(`Unsupported image MIME type: ${sourceMime}`);
}