Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 178c806

Browse files
authored
Merge pull request #37 from closedloop-ai/FEAT-138
FEAT-133: Fix approval dialog labels and add none tier
2 parents 5a19ec3 + 82ae674 commit 178c806

9 files changed

Lines changed: 147 additions & 28 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.7.0",
3+
"version": "0.7.1",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/app.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
} from "../server/operations/symphony-utils.js";
3030
import { seedReposConfig } from "./seed-repos-config.js";
3131
import { SUPPORTED_OPERATION_IDS, resolveOperationId } from "./approval-operations.js";
32-
import { shouldAutoApprove } from "./approval-policy.js";
32+
import { shouldAutoApprove, OPERATION_RISK_TIERS } from "./approval-policy.js";
3333
import { ActivityLogStore } from "./activity-log-store.js";
3434
import { ApprovalStore } from "./approval-store.js";
3535
import { JobStore, isTerminalJobStatus } from "./job-store.js";
@@ -553,20 +553,17 @@ export class DesktopApplication {
553553

554554
const configuredTier = (settings.autoApprovalRules[operationId] ??
555555
settings.defaultApprovalTier) as RiskTier;
556-
if (configuredTier === "auto" && !request.forceApproval) {
557-
return { allow: true };
558-
}
559-
const manualTier: Exclude<RiskTier, "auto"> = configuredTier === "auto" ? "high" : configuredTier;
560-
if (shouldAutoApprove(operationId, manualTier, request.forceApproval ?? false)) {
556+
if (shouldAutoApprove(operationId, configuredTier, request.forceApproval ?? false)) {
561557
return { allow: true };
562558
}
563559

560+
const operationRisk = (OPERATION_RISK_TIERS as Record<string, Exclude<RiskTier, "none">>)[operationId] ?? "high";
564561
const reason =
565562
request.approvalReason?.trim() ||
566-
`Manual approval required for ${operationId} (${manualTier})`;
563+
`${operationId} is ${operationRisk}-risk, but your auto-approve threshold is ${configuredTier}`;
567564
const pending = this.approvalStore.enqueue({
568565
operationId,
569-
riskTier: manualTier,
566+
riskTier: operationRisk,
570567
method: request.method,
571568
path: request.path,
572569
body: request.body,
@@ -750,11 +747,20 @@ export class DesktopApplication {
750747
relayOrigin?: string;
751748
apiOrigin?: string;
752749
webAppOrigin?: string;
753-
defaultApprovalTier?: "auto" | "low" | "medium" | "high";
754-
autoApprovalRules?: Record<string, "auto" | "low" | "medium" | "high">;
750+
defaultApprovalTier?: "auto" | "none" | "low" | "medium" | "high";
751+
autoApprovalRules?: Record<string, "auto" | "none" | "low" | "medium" | "high">;
755752
}) => {
756753
const currentSettings = this.settingsStore.getAll();
757754
const nextPartial = { ...partial };
755+
// Normalize legacy "auto" tier to "high" (they behave identically)
756+
if (nextPartial.defaultApprovalTier === "auto") {
757+
nextPartial.defaultApprovalTier = "high";
758+
}
759+
if (nextPartial.autoApprovalRules) {
760+
for (const [key, val] of Object.entries(nextPartial.autoApprovalRules)) {
761+
if (val === "auto") nextPartial.autoApprovalRules[key] = "high";
762+
}
763+
}
758764
if (typeof partial.relayOrigin === "string") {
759765
nextPartial.relayOrigin = normalizeAndValidateOrigin(partial.relayOrigin);
760766
}
@@ -782,7 +788,7 @@ export class DesktopApplication {
782788
throw new Error("Complete onboarding requires a sandbox base directory");
783789
}
784790

785-
const updated = this.settingsStore.update(nextPartial);
791+
const updated = this.settingsStore.update(nextPartial as Partial<DesktopSettings>);
786792

787793
if (
788794
typeof partial.sandboxBaseDirectory === "string" &&

apps/desktop/src/main/approval-policy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { OperationId } from "./approval-operations.js";
55
* Per-operation inherent risk tiers. The risk assigned reflects the
66
* highest-risk HTTP method that each approval ID handles.
77
*/
8-
export const OPERATION_RISK_TIERS: Record<OperationId, Exclude<RiskTier, "auto">> = {
8+
export const OPERATION_RISK_TIERS: Record<OperationId, Exclude<RiskTier, "none">> = {
99
health_check: "low",
1010
repos_config: "medium",
1111
filesystem: "medium",
@@ -34,9 +34,10 @@ export const OPERATION_RISK_TIERS: Record<OperationId, Exclude<RiskTier, "auto">
3434
learnings: "medium"
3535
};
3636

37-
/** Converts a non-auto RiskTier to a numeric value for threshold comparison. */
38-
export function riskTierOrder(tier: Exclude<RiskTier, "auto">): number {
37+
/** Converts a RiskTier to a numeric value for threshold comparison. */
38+
export function riskTierOrder(tier: RiskTier): number {
3939
switch (tier) {
40+
case "none": return 0;
4041
case "low": return 1;
4142
case "medium": return 2;
4243
case "high": return 3;
@@ -49,10 +50,10 @@ export function riskTierOrder(tier: Exclude<RiskTier, "auto">): number {
4950
*/
5051
export function shouldAutoApprove(
5152
operationId: string,
52-
configuredTier: Exclude<RiskTier, "auto">,
53+
configuredTier: RiskTier,
5354
forceApproval: boolean
5455
): boolean {
5556
if (forceApproval) return false;
56-
const operationRisk = (OPERATION_RISK_TIERS as Record<string, Exclude<RiskTier, "auto">>)[operationId] ?? "high";
57+
const operationRisk = (OPERATION_RISK_TIERS as Record<string, Exclude<RiskTier, "none">>)[operationId] ?? "high";
5758
return riskTierOrder(operationRisk) <= riskTierOrder(configuredTier);
5859
}

apps/desktop/src/main/approval-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type PendingApproval = {
88
id: string;
99
createdAt: string;
1010
operationId: string;
11-
riskTier: Exclude<RiskTier, "auto">;
11+
riskTier: Exclude<RiskTier, "none">;
1212
method: string;
1313
path: string;
1414
scopePath?: string;
@@ -74,7 +74,7 @@ export class ApprovalStore {
7474

7575
enqueue(input: {
7676
operationId: string;
77-
riskTier: Exclude<RiskTier, "auto">;
77+
riskTier: Exclude<RiskTier, "none">;
7878
method: string;
7979
path: string;
8080
body: string;

apps/desktop/src/main/settings-store.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ export class SettingsStore {
6666
if (hadAuthApiOrigin) {
6767
this.store.delete("authApiOrigin" as keyof DesktopSettings);
6868
}
69+
70+
// Migration: replace legacy "auto" tier with "high" (identical behavior).
71+
if (raw.defaultApprovalTier === "auto") {
72+
this.store.set("defaultApprovalTier", "high" as RiskTier);
73+
}
74+
const rules = raw.autoApprovalRules as Record<string, string> | undefined;
75+
if (rules) {
76+
let rulesChanged = false;
77+
for (const [key, val] of Object.entries(rules)) {
78+
if (val === "auto") {
79+
rules[key] = "high";
80+
rulesChanged = true;
81+
}
82+
}
83+
if (rulesChanged) {
84+
this.store.set("autoApprovalRules", rules as unknown as Record<string, RiskTier>);
85+
}
86+
}
6987
}
7088

7189
getAll(): DesktopSettings {

apps/desktop/src/renderer/index.html

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,48 @@
514514
line-height: 1.4;
515515
}
516516

517+
.approval-risk-tier {
518+
display: inline-flex;
519+
align-items: center;
520+
border-radius: 4px;
521+
padding: 1px 7px;
522+
font-size: 10px;
523+
font-weight: 700;
524+
letter-spacing: 0.04em;
525+
text-transform: uppercase;
526+
flex-shrink: 0;
527+
}
528+
529+
.approval-risk-tier.risk-low {
530+
background: rgba(22, 163, 74, 0.12);
531+
color: #16a34a;
532+
}
533+
534+
.approval-risk-tier.risk-medium {
535+
background: rgba(245, 158, 11, 0.12);
536+
color: #d97706;
537+
}
538+
539+
.approval-risk-tier.risk-high {
540+
background: rgba(220, 38, 38, 0.12);
541+
color: #dc2626;
542+
}
543+
544+
@media (prefers-color-scheme: dark) {
545+
.approval-risk-tier.risk-low {
546+
background: rgba(22, 163, 74, 0.18);
547+
color: #4ade80;
548+
}
549+
.approval-risk-tier.risk-medium {
550+
background: rgba(245, 158, 11, 0.18);
551+
color: #fbbf24;
552+
}
553+
.approval-risk-tier.risk-high {
554+
background: rgba(220, 38, 38, 0.18);
555+
color: #f87171;
556+
}
557+
}
558+
517559
.approval-reason {
518560
color: var(--ink);
519561
font-size: 13px;
@@ -1427,10 +1469,10 @@ <h3 class="settings-group-title">Approval Policy</h3>
14271469
<div class="row">
14281470
<label for="defaultApprovalTier">Default Approval Tier</label>
14291471
<select id="defaultApprovalTier">
1430-
<option value="high">high -- approve everything</option>
1431-
<option value="medium">medium -- approve risky operations</option>
1432-
<option value="low">low -- approve only destructive operations</option>
1433-
<option value="auto">auto -- never prompt</option>
1472+
<option value="high">high -- auto-approve all operations</option>
1473+
<option value="medium">medium -- prompt only for high-risk (e.g. deploy)</option>
1474+
<option value="low">low -- prompt for medium and high-risk operations</option>
1475+
<option value="none">none -- prompt for all operations</option>
14341476
</select>
14351477
</div>
14361478
<div class="row">
@@ -1445,7 +1487,7 @@ <h3 class="settings-group-title">Approval Policy</h3>
14451487
<option value="high">high</option>
14461488
<option value="medium">medium</option>
14471489
<option value="low">low</option>
1448-
<option value="auto">auto</option>
1490+
<option value="none">none</option>
14491491
</select>
14501492
<button class="secondary" id="tierOverrideAddBtn" type="button">Add</button>
14511493
</div>
@@ -2114,6 +2156,13 @@ <h3 class="settings-group-title">Always-Allow Rules</h3>
21142156
title.textContent = operationLabel(approval.operationId);
21152157
header.appendChild(title);
21162158

2159+
if (approval.riskTier) {
2160+
const riskBadge = document.createElement("span");
2161+
riskBadge.className = `approval-risk-tier risk-${approval.riskTier}`;
2162+
riskBadge.textContent = approval.riskTier;
2163+
header.appendChild(riskBadge);
2164+
}
2165+
21172166
const badge = document.createElement("span");
21182167
badge.className = `approval-badge ${statusKey}`;
21192168
badge.textContent = resolved
@@ -2232,7 +2281,7 @@ <h3 class="settings-group-title">Always-Allow Rules</h3>
22322281
apiOrigin.value = settings.apiOrigin || "";
22332282
webAppOrigin.value = settings.webAppOrigin || "";
22342283
sandboxBaseDirectory.value = settings.sandboxBaseDirectory || "";
2235-
defaultApprovalTier.value = settings.defaultApprovalTier || "high";
2284+
defaultApprovalTier.value = settings.defaultApprovalTier === "auto" ? "high" : (settings.defaultApprovalTier || "high");
22362285
renderTierOverrides(settings.autoApprovalRules || {});
22372286
renderAlwaysAllowRules(settings.alwaysAllowRules || []);
22382287
updateSandboxBaseWarning();
@@ -2247,7 +2296,7 @@ <h3 class="settings-group-title">Always-Allow Rules</h3>
22472296
"health_check", "repos_config", "deploy", "filesystem"
22482297
];
22492298

2250-
const TIER_OPTIONS = ["high", "medium", "low", "auto"];
2299+
const TIER_OPTIONS = ["high", "medium", "low", "none"];
22512300

22522301
function renderTierOverrides(rules) {
22532302
// Sync hidden input for save
@@ -2272,7 +2321,8 @@ <h3 class="settings-group-title">Always-Allow Rules</h3>
22722321
const opt = document.createElement("option");
22732322
opt.value = t;
22742323
opt.textContent = t;
2275-
if (t === tier) opt.selected = true;
2324+
const normalizedTier = tier === "auto" ? "high" : tier;
2325+
if (t === normalizedTier) opt.selected = true;
22762326
sel.appendChild(opt);
22772327
}
22782328
sel.addEventListener("change", () => {

apps/desktop/src/shared/contracts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface HealthResponse {
3535
port: number;
3636
}
3737

38-
export type RiskTier = "auto" | "low" | "medium" | "high";
38+
export type RiskTier = "none" | "low" | "medium" | "high";
3939

4040
export interface AlwaysAllowRule {
4141
id: string;

apps/desktop/test/approval-policy.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SUPPORTED_OPERATION_IDS, resolveOperationId } from "../src/main/approva
66
// --- riskTierOrder ---
77

88
test("riskTierOrder returns correct numeric ordering", () => {
9+
assert.ok(riskTierOrder("none") < riskTierOrder("low"));
910
assert.ok(riskTierOrder("low") < riskTierOrder("medium"));
1011
assert.ok(riskTierOrder("medium") < riskTierOrder("high"));
1112
});
@@ -30,6 +31,13 @@ test("policy high: auto-approves all mapped operations", () => {
3031
assert.equal(shouldAutoApprove("deploy", "high", false), true);
3132
});
3233

34+
test("policy none: blocks all operations including low-risk", () => {
35+
assert.equal(shouldAutoApprove("health_check", "none", false), false);
36+
assert.equal(shouldAutoApprove("symphony_loop", "none", false), false);
37+
assert.equal(shouldAutoApprove("deploy", "none", false), false);
38+
assert.equal(shouldAutoApprove("unknown_op", "none", false), false);
39+
});
40+
3341
test("forceApproval overrides threshold", () => {
3442
assert.equal(shouldAutoApprove("health_check", "low", true), false);
3543
});

apps/desktop/test/settings-migration.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,42 @@ test("migration: fresh install applies defaults", () => {
108108
assert.equal("authApiOrigin" in all, false, "no stale authApiOrigin key should be present");
109109
});
110110

111+
// --- Approval tier "auto" → "high" migration ---
112+
113+
test("migration: defaultApprovalTier 'auto' is rewritten to 'high'", () => {
114+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "settings-migration-auto-tier-"));
115+
tempDirs.push(tmpDir);
116+
117+
const storeName = "test-auto-tier";
118+
fs.writeFileSync(
119+
path.join(tmpDir, `${storeName}.json`),
120+
JSON.stringify({
121+
defaultApprovalTier: "auto",
122+
autoApprovalRules: { deploy: "auto", health_check: "low" }
123+
})
124+
);
125+
126+
const store = new SettingsStore({ cwd: tmpDir, name: storeName });
127+
const all = store.getAll();
128+
129+
assert.equal(all.defaultApprovalTier, "high", "defaultApprovalTier should be migrated to 'high'");
130+
assert.equal(
131+
(all.autoApprovalRules as Record<string, string>).deploy,
132+
"high",
133+
"autoApprovalRules 'auto' entries should be migrated to 'high'"
134+
);
135+
assert.equal(
136+
(all.autoApprovalRules as Record<string, string>).health_check,
137+
"low",
138+
"non-auto autoApprovalRules entries should be preserved"
139+
);
140+
141+
// Verify persisted JSON no longer contains "auto"
142+
const persisted = JSON.parse(fs.readFileSync(path.join(tmpDir, `${storeName}.json`), "utf-8"));
143+
assert.equal(persisted.defaultApprovalTier, "high", "persisted defaultApprovalTier should be 'high'");
144+
assert.equal(persisted.autoApprovalRules?.deploy, "high", "persisted autoApprovalRules.deploy should be 'high'");
145+
});
146+
111147
test("migration: already migrated install is a no-op — both values preserved", () => {
112148
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "settings-migration-noop-"));
113149
tempDirs.push(tmpDir);

0 commit comments

Comments
 (0)