mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
fix(daemon,cli): strict threshold_percent validation (slice 27 BLOCKING-FIX)
threshold_percent contract is integer in [1, 100]. parseInt's permissive
coercion accepted invalid input at write paths (rig config set, /api/config
POST, env override, direct ConfigStore.set):
- 0 (would fire /compact every poll tick — catastrophic if opt-in net
ever weakens)
- 101, -1 (out of documented range)
- "80abc" (partial parse → 80)
- "80.5" (truncated → 80)
Two layers added (per banked feedback_static_gates_mirror_runtime_validators
+ feedback_permission_layer_is_foot_gun_dont_patch_reactively):
1. WRITE-path validators in both ConfigStore (cli) + SettingsStore
(daemon) — a per-key constraint map runs after coerceValue in set();
strict regex `^-?\d+$` + Number.isInteger + range check. The
/api/config POST handler already maps thrown errors to 400 via the
existing try/catch in routes/config.ts; no new route surface.
2. ENFORCER defense-in-depth — ClaudeCompactionEnforcer treats
out-of-contract policy as `invalid_policy` (no send) so a hand-edited
~/.openrig/config.json with bad values cannot trigger /compact.
Mirrors the write-path constraint without crashing the daemon on
bad config.
Tests: accept/reject matrices at each layer (CLI 16 cases, daemon
settings-store 16 cases, /api/config POST 9 reject + 1 accept,
enforcer 6 invalid_policy cases). 48 new tests; full daemon + cli
suites green.
Per banked feedback_pre_dispatch_grep_load_bearing_claims: grep
confirmed threshold_percent is the only numeric key in the
policies.claude_compaction.* namespace — enabled is bool;
message_inline / message_file_path are strings.
Source verdict: qitem-20260512100940-49cd26f4 (redo-guard-2 BLOCKING)
Driver-fix dispatch: qitem-20260512101023-f75668ac
Original slice ship: fe50ce7 (slice 27 claude auto-compaction policy)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
fe50ce7759
commit
efb0aa14ed
7 files changed
+241
-1
No files matched your search
@@ -405,6 +405,44 @@ function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
|
||||
// in `set()`. The generic coerce uses parseInt which accepts partial
|
||||
// parses (e.g., "80abc" → 80) and truncates fractions ("80.5" → 80); for
|
||||
// keys with documented range/integer contracts this is unsafe. Per
|
||||
// banked feedback_static_gates_mirror_runtime_validators, the runtime
|
||||
// validator is the source of truth and must reject what the contract
|
||||
// forbids.
|
||||
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
// Policy threshold: integer in [1, 100]. Documented contract from
|
||||
// slice 27 README §"What the operator gets" — operator can lower to
|
||||
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
|
||||
// would fire /compact on every poll tick (catastrophic without
|
||||
// default-off as backstop).
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (coerced < 1 || coerced > 100) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (check) check(raw, coerced);
|
||||
}
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
|
||||
export interface ResolvedSetting {
|
||||
@@ -557,6 +595,7 @@ export class ConfigStore {
|
||||
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|
||||
|| DEFAULT_WORKSPACE_ROOT;
|
||||
const coerced = coerceValue(key, value, workspaceRoot);
|
||||
validateKeyConstraints(key, value, coerced);
|
||||
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
@@ -385,6 +385,42 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
|
||||
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
|
||||
// policies.claude_compaction.threshold_percent. The contract is
|
||||
// integer in [1, 100]. parseInt's permissive coercion would otherwise
|
||||
// accept "80abc" → 80, "80.5" → 80, and even 0 / 101 / -1 which would
|
||||
// break the trigger's safety contract (0 = compact every tick).
|
||||
describe("policies.claude_compaction.threshold_percent strict validation matrix", () => {
|
||||
const accept = ["1", "2", "50", "80", "99", "100"];
|
||||
const reject: Array<{ raw: string; reason: RegExp }> = [
|
||||
{ raw: "0", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "101", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "-1", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "80abc", reason: /expected an integer/ },
|
||||
{ raw: "abc80", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "80.5", reason: /expected an integer/ },
|
||||
{ raw: "", reason: /expected a number|expected an integer/ },
|
||||
{ raw: " ", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "NaN", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
|
||||
];
|
||||
|
||||
for (const value of accept) {
|
||||
it(`accepts ${JSON.stringify(value)}`, () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
|
||||
expect(store.get("policies.claude_compaction.threshold_percent")).toBe(Number(value));
|
||||
});
|
||||
}
|
||||
|
||||
for (const { raw, reason } of reject) {
|
||||
it(`rejects ${JSON.stringify(raw)}`, () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNamedPairs", () => {
|
||||
|
||||
@@ -45,7 +45,8 @@ export type EnforcerSkipReason =
|
||||
| "disabled"
|
||||
| "below_threshold"
|
||||
| "dedup_window"
|
||||
| "send_failed";
|
||||
| "send_failed"
|
||||
| "invalid_policy";
|
||||
|
||||
export class ClaudeCompactionEnforcer {
|
||||
private readonly settingsStore: SettingsStore;
|
||||
@@ -80,6 +81,22 @@ export class ClaudeCompactionEnforcer {
|
||||
if (!policy.enabled) {
|
||||
return { triggered: false, reason: "disabled" };
|
||||
}
|
||||
// Defense in depth: the CLI + daemon set() paths reject invalid
|
||||
// threshold values, but a hand-edited ~/.openrig/config.json could
|
||||
// still inject 0, 101, NaN, or a non-integer. The enforcer treats
|
||||
// out-of-contract policy as disabled (safer-failure direction) so
|
||||
// compaction lifecycle remains operator-controlled even on bad
|
||||
// config. Mirrors the per-key constraint in
|
||||
// user-settings/settings-store.ts KEY_CONSTRAINTS.
|
||||
if (
|
||||
typeof policy.thresholdPercent !== "number"
|
||||
|| !Number.isFinite(policy.thresholdPercent)
|
||||
|| !Number.isInteger(policy.thresholdPercent)
|
||||
|| policy.thresholdPercent < 1
|
||||
|| policy.thresholdPercent > 100
|
||||
) {
|
||||
return { triggered: false, reason: "invalid_policy" };
|
||||
}
|
||||
if (input.usedPercentage < policy.thresholdPercent) {
|
||||
return { triggered: false, reason: "below_threshold" };
|
||||
}
|
||||
|
||||
@@ -322,6 +322,41 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
|
||||
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
|
||||
// daemon's HTTP write surface (/api/config POST) rejects the same input
|
||||
// the CLI rejects.
|
||||
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
// Policy threshold: integer in [1, 100]. Documented contract from
|
||||
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
|
||||
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
|
||||
// reads at every poll tick; runtime validator rejects what the
|
||||
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (coerced < 1 || coerced > 100) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (check) check(raw, coerced);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
|
||||
* consumes this snapshot per-poll; the PreCompact hook reads the same
|
||||
@@ -437,6 +472,7 @@ export class SettingsStore {
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
const coerced = coerceValue(key, value, wr);
|
||||
validateKeyConstraints(key, value, coerced);
|
||||
setNestedValue(fc, KEY_TO_PATH[key], coerced);
|
||||
mkdirSync(path.dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fc, null, 2) + "\n", "utf-8");
|
||||
|
||||
@@ -209,4 +209,45 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(send.mock.calls[0]![0]).toBe("a@rig");
|
||||
expect(send.mock.calls[1]![0]).toBe("b@rig");
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX — defense-in-depth at the enforcer.
|
||||
//
|
||||
// The CLI + daemon set() paths reject invalid threshold input, but a
|
||||
// hand-edited ~/.openrig/config.json could still inject 0, 101, NaN,
|
||||
// or a non-integer (the file read path passes the value through
|
||||
// without re-validation by design). The enforcer is the last
|
||||
// safety net before send: it MUST treat an out-of-contract
|
||||
// thresholdPercent as disabled (returns `invalid_policy`, no send).
|
||||
describe("BLOCKING-FIX: defense-in-depth on hand-edited bad policy values", () => {
|
||||
const cases: Array<{ name: string; thresholdPercent: number }> = [
|
||||
{ name: "0 (would trigger on every tick)", thresholdPercent: 0 },
|
||||
{ name: "101 (above range)", thresholdPercent: 101 },
|
||||
{ name: "-1 (below range)", thresholdPercent: -1 },
|
||||
{ name: "80.5 (non-integer)", thresholdPercent: 80.5 },
|
||||
{ name: "NaN", thresholdPercent: Number.NaN },
|
||||
{ name: "Infinity", thresholdPercent: Number.POSITIVE_INFINITY },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`treats threshold=${c.name} as invalid_policy; never sends`, async () => {
|
||||
const settings = makeSettingsStore({
|
||||
enabled: true,
|
||||
thresholdPercent: c.thresholdPercent,
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
});
|
||||
const { transport, send } = makeSessionTransport();
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
|
||||
|
||||
const outcome = await enforcer.maybeAutoCompact({
|
||||
sessionName: "claude-seat@rig",
|
||||
runtime: "claude-code",
|
||||
usedPercentage: 99, // would trigger if policy were valid
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ triggered: false, reason: "invalid_policy" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,39 @@ describe("config routes (User Settings v0)", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX — /api/config POST must reject invalid
|
||||
// threshold_percent input. The route catches the error thrown by
|
||||
// SettingsStore.set and maps it to 400; the integration test asserts
|
||||
// the contract end-to-end so a future drift gets caught at CI.
|
||||
describe("POST /api/config/policies.claude_compaction.threshold_percent strict validation", () => {
|
||||
const rejectCases = ["0", "101", "-1", "80abc", "80.5", "", " ", "NaN", "Infinity"];
|
||||
|
||||
for (const raw of rejectCases) {
|
||||
it(`returns 400 for ${JSON.stringify(raw)}`, async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/policies.claude_compaction.threshold_percent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: raw }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json() as { error?: string };
|
||||
expect(body.error).toMatch(/integer|number|in \[1, 100\]/);
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts valid integer in range and persists to disk", async () => {
|
||||
const app = buildApp();
|
||||
const res = await app.request("/api/config/policies.claude_compaction.threshold_percent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "60" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf-8")).policies.claudeCompaction.thresholdPercent).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
it("DELETE /api/config/:key resets to default", async () => {
|
||||
store.set("workspace.slices_root", "/x");
|
||||
const app = buildApp();
|
||||
|
||||
@@ -306,6 +306,44 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
|
||||
// threshold_percent. Per banked feedback_static_gates_mirror_runtime_validators,
|
||||
// the runtime validator is the source of truth; this test invokes the
|
||||
// same `set()` path the daemon's /api/config POST handler uses so a
|
||||
// future drift gets caught at CI.
|
||||
describe("HG-1 strict threshold validation matrix", () => {
|
||||
const cases = {
|
||||
accept: ["1", "2", "50", "80", "99", "100"],
|
||||
reject: [
|
||||
{ raw: "0", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "101", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "-1", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "80abc", reason: /expected an integer/ },
|
||||
{ raw: "abc80", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "80.5", reason: /expected an integer/ },
|
||||
{ raw: "", reason: /expected a number|expected an integer/ },
|
||||
{ raw: " ", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "NaN", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
|
||||
],
|
||||
};
|
||||
|
||||
for (const value of cases.accept) {
|
||||
it(`accepts ${JSON.stringify(value)}`, () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
|
||||
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(Number(value));
|
||||
});
|
||||
}
|
||||
|
||||
for (const { raw, reason } of cases.reject) {
|
||||
it(`rejects ${JSON.stringify(raw)}`, () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void existsSync;
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user