Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required.

## Unreleased
- Distributed `/exchange` rate-limit의 private Durable Object request(256 bytes)와 decision response(4,096 bytes) bounded stream reader가 허용 chunk를 배열에 누적하지 않고 각 byte ceiling 크기의 고정 `Uint8Array`에 직접 복사하도록 바꾼다. 합법적인 high-fragmentation stream이 accepted-byte ceiling과 별개로 retained chunk object/backing-store 수와 두 번째 합산 allocation을 키우지 않으며 초과 chunk는 copy 전에 실패-폐쇄한다. 기존 cancellation, fatal UTF-8·duplicate-key·JSON admission, bucket/identity/Durable Object authority는 유지한다. 이 source hardening은 immutable release나 deployed heap/p95 evidence를 대신하지 않는다. PR #650.
- Base credential-exchange의 OIDC discovery/JWKS·GitHub API bounded JSON response reader가 허용 chunk를 배열에 누적하지 않고 65,536-byte 고정 버퍼에 직접 복사하도록 바꾼다. Stream fragmentation이 accepted-byte ceiling과 별개로 retained chunk object/backing-store 수와 두 번째 합산 allocation을 키우지 않으며, 초과 chunk는 복사 전에 실패-폐쇄한다. 기존 10초 wall-clock deadline, cancellation, fatal UTF-8·duplicate-key·JSON admission, OIDC/GitHub identity·credential authority는 유지한다. 이 source hardening은 immutable release나 production p95/heap evidence를 대신하지 않는다. PR #646.
- Workflow registry disable/plan 운영 경로의 GitHub JSON 응답 reader도 고정 `Uint8Array(MAX_RESPONSE_BYTES)`에 직접 수신해 합법적인 고분할 stream이 chunk-object retention과 두 번째 합산 allocation을 키우지 않도록 한다. Oversize는 copy 전에 실패-폐쇄하며 기존 pagination·registry identity·single-workflow disable authority는 유지한다. 이 source hardening은 live registry cleanliness나 immutable release evidence를 대신하지 않는다. PR #645.
- Trivy v0.74.0의 vendor-severity diagnostic은 취약점 정책을 약화하거나 로그를 조용하게 만들 이유가 아니므로 reviewer-ci·central review·patch-validator image의 severity source를 기본 `auto`로 유지한다. OS vendor advisory와 backport-aware severity attribution(`SeveritySource`/`VendorSeverity`)을 보존하고 `--vuln-severity-source`를 강제하지 않으며, diagnostic은 의도적으로 visible 상태로 남긴다. Severity threshold·exit behavior·ignore policy·scanner coverage는 변경하지 않는다. PR #637.
Expand Down
2 changes: 2 additions & 0 deletions docs/distributed-rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The Worker keeps the original in-isolate fixed-window limiter as defense in dept
8. A denied request returns `429`, `Retry-After`, and `X-Rate-Limit-*` headers without parsing the bearer token.
9. A missing trusted client identity, missing binding, failed object request, non-2xx response, or malformed decision fails closed with `503` and `Retry-After: 1`.

The private Durable Object request reader enforces a 256-byte wire ceiling and the decision reader enforces a 4,096-byte ceiling while streaming. Accepted bytes are copied directly into one fixed-size buffer per reader instead of retaining each stream chunk until EOF, so legal high-fragmentation streams cannot increase retained chunk-object/backing-store cardinality independently of the authoritative byte limit. An oversize chunk is rejected before it is copied. This is a source-level memory bound; deployed heap and p95 evidence remain separate operational acceptance evidence.

Cloudflare documents Durable Objects as globally unique coordination primitives with private, transactional, strongly consistent storage. Durable Object alarms have at-least-once execution and may be delayed or retried, so cleanup must validate the current stored deadline rather than assuming every alarm invocation still belongs to the bucket that originally scheduled it. New namespaces use the SQLite backend and can be declared with Wrangler's `exports` lifecycle configuration. Cloudflare also documents that `CF-Connecting-IP` is the edge-provided visitor identity header and that request or managed transforms can remove it; Noema therefore treats absence as a deployment misconfiguration rather than collapsing unrelated callers into one fallback bucket:

- https://developers.cloudflare.com/durable-objects/
Expand Down
32 changes: 11 additions & 21 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,20 @@ async function readBoundedRateLimitRequest(request: Request): Promise<RateLimitR
}

const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
const requestStorage = new Uint8Array(MAX_RATE_LIMIT_REQUEST_BYTES);
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_RATE_LIMIT_REQUEST_BYTES) {
if (value.byteLength > MAX_RATE_LIMIT_REQUEST_BYTES - totalBytes) {
ignoreCancellationBestEffort(() => reader.cancel(
"Noema rate-limit request exceeds byte limit",
));
return { ok: false, status: 413, error: "request_too_large" };
}
chunks.push(value);
requestStorage.set(value, totalBytes);
totalBytes += value.byteLength;
}
} catch {
ignoreCancellationBestEffort(() => reader.cancel(
Expand All @@ -312,12 +312,7 @@ async function readBoundedRateLimitRequest(request: Request): Promise<RateLimitR
return { ok: false, status: 400, error: "malformed_json" };
}

const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const bytes = requestStorage.subarray(0, totalBytes);

let text: string;
try {
Expand Down Expand Up @@ -360,22 +355,22 @@ async function readBoundedRateLimitDecision(response: Response): Promise<unknown
}

const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
const decisionStorage = new Uint8Array(MAX_RATE_LIMIT_DECISION_BYTES);
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_RATE_LIMIT_DECISION_BYTES) {
if (value.byteLength > MAX_RATE_LIMIT_DECISION_BYTES - totalBytes) {
ignoreCancellationBestEffort(() => reader.cancel(
"Noema rate-limit decision exceeds byte limit",
));
throw new DistributedRateLimitUnavailable(
"rate-limit Durable Object decision exceeds the response byte limit",
);
}
chunks.push(value);
decisionStorage.set(value, totalBytes);
totalBytes += value.byteLength;
}
} catch (error) {
if (error instanceof DistributedRateLimitUnavailable) throw error;
Expand All @@ -387,12 +382,7 @@ async function readBoundedRateLimitDecision(response: Response): Promise<unknown
);
}

const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const bytes = decisionStorage.subarray(0, totalBytes);

let text: string;
try {
Expand Down Expand Up @@ -625,4 +615,4 @@ export class NoemaRateLimiter {
await this.state.storage.deleteAll();
});
}
}
}
18 changes: 18 additions & 0 deletions test/rate-limit-retention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

describe("distributed rate-limit retained-heap bounds", () => {
it("keeps request and decision storage independent of stream chunk cardinality", () => {
const source = readFileSync("src/rate-limit.ts", "utf8");
expect(source).not.toContain("const chunks: Uint8Array[] = []");
expect(source).not.toContain("chunks.push(value)");
expect(source).toContain("const requestStorage = new Uint8Array(MAX_RATE_LIMIT_REQUEST_BYTES)");
expect(source).toContain("value.byteLength > MAX_RATE_LIMIT_REQUEST_BYTES - totalBytes");
expect(source).toContain("requestStorage.set(value, totalBytes)");
expect(source).toContain("const bytes = requestStorage.subarray(0, totalBytes)");
expect(source).toContain("const decisionStorage = new Uint8Array(MAX_RATE_LIMIT_DECISION_BYTES)");
expect(source).toContain("value.byteLength > MAX_RATE_LIMIT_DECISION_BYTES - totalBytes");
expect(source).toContain("decisionStorage.set(value, totalBytes)");
expect(source).toContain("const bytes = decisionStorage.subarray(0, totalBytes)");
});
});
Loading