refactor(cli): reuse allowlist mutation flow in approvals CLI

This commit is contained in:
Peter Steinberger
2026-02-19 06:43:22 +00:00
parent 8d048d412f
commit fa31f1cad2
2 changed files with 106 additions and 44 deletions

View File

@@ -24,6 +24,10 @@ const localSnapshot = {
file: { version: 1, agents: {} },
};
function resetLocalSnapshot() {
localSnapshot.file = { version: 1, agents: {} };
}
vi.mock("./gateway-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayFromCli(method, opts, params),
@@ -64,6 +68,7 @@ describe("exec approvals CLI", () => {
};
it("routes get command to local, gateway, and node modes", async () => {
resetLocalSnapshot();
resetRuntimeCapture();
callGatewayFromCli.mockClear();
@@ -91,6 +96,7 @@ describe("exec approvals CLI", () => {
});
it("defaults allowlist add to wildcard agent", async () => {
resetLocalSnapshot();
resetRuntimeCapture();
callGatewayFromCli.mockClear();
@@ -116,4 +122,34 @@ describe("exec approvals CLI", () => {
}),
);
});
it("removes wildcard allowlist entry and prunes empty agent", async () => {
resetLocalSnapshot();
localSnapshot.file = {
version: 1,
agents: {
"*": {
allowlist: [{ pattern: "/usr/bin/uname", lastUsedAt: Date.now() }],
},
},
};
resetRuntimeCapture();
callGatewayFromCli.mockClear();
const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals);
saveExecApprovals.mockClear();
const program = createProgram();
await program.parseAsync(["approvals", "allowlist", "remove", "/usr/bin/uname"], {
from: "user",
});
expect(saveExecApprovals).toHaveBeenCalledWith(
expect.objectContaining({
version: 1,
agents: undefined,
}),
);
expect(runtimeErrors).toHaveLength(0);
});
});

View File

@@ -292,6 +292,36 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{
return { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries };
}
type WritableAllowlistAgentContext = Awaited<ReturnType<typeof loadWritableAllowlistAgent>> & {
trimmedPattern: string;
};
async function runAllowlistMutation(
pattern: string,
opts: ExecApprovalsCliOpts,
mutate: (context: WritableAllowlistAgentContext) => boolean | Promise<boolean>,
): Promise<void> {
try {
const trimmedPattern = requireTrimmedNonEmpty(pattern, "Pattern required.");
const context = await loadWritableAllowlistAgent(opts);
const shouldSave = await mutate({ ...context, trimmedPattern });
if (!shouldSave) {
return;
}
await saveSnapshotTargeted({
opts,
source: context.source,
nodeId: context.nodeId,
file: context.file,
baseHash: context.baseHash,
targetLabel: context.targetLabel,
});
} catch (err) {
defaultRuntime.error(formatCliError(err));
defaultRuntime.exit(1);
}
}
export function registerExecApprovalsCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
@@ -393,22 +423,20 @@ export function registerExecApprovalsCli(program: Command) {
.option("--gateway", "Force gateway approvals", false)
.option("--agent <id>", 'Agent id (defaults to "*")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
try {
const trimmed = requireTrimmedNonEmpty(pattern, "Pattern required.");
const { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries } =
await loadWritableAllowlistAgent(opts);
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
defaultRuntime.log("Already allowlisted.");
return;
}
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
file.agents = { ...file.agents, [agentKey]: agent };
await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel });
} catch (err) {
defaultRuntime.error(formatCliError(err));
defaultRuntime.exit(1);
}
await runAllowlistMutation(
pattern,
opts,
({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => {
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmedPattern)) {
defaultRuntime.log("Already allowlisted.");
return false;
}
allowlistEntries.push({ pattern: trimmedPattern, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
file.agents = { ...file.agents, [agentKey]: agent };
return true;
},
);
});
nodesCallOpts(allowlistAdd);
@@ -419,34 +447,32 @@ export function registerExecApprovalsCli(program: Command) {
.option("--gateway", "Force gateway approvals", false)
.option("--agent <id>", 'Agent id (defaults to "*")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
try {
const trimmed = requireTrimmedNonEmpty(pattern, "Pattern required.");
const { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries } =
await loadWritableAllowlistAgent(opts);
const nextEntries = allowlistEntries.filter(
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
);
if (nextEntries.length === allowlistEntries.length) {
defaultRuntime.log("Pattern not found.");
return;
}
if (nextEntries.length === 0) {
delete agent.allowlist;
} else {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
const agents = { ...file.agents };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
file.agents = { ...file.agents, [agentKey]: agent };
}
await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel });
} catch (err) {
defaultRuntime.error(formatCliError(err));
defaultRuntime.exit(1);
}
await runAllowlistMutation(
pattern,
opts,
({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => {
const nextEntries = allowlistEntries.filter(
(entry) => normalizeAllowlistEntry(entry) !== trimmedPattern,
);
if (nextEntries.length === allowlistEntries.length) {
defaultRuntime.log("Pattern not found.");
return false;
}
if (nextEntries.length === 0) {
delete agent.allowlist;
} else {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
const agents = { ...file.agents };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
file.agents = { ...file.agents, [agentKey]: agent };
}
return true;
},
);
});
nodesCallOpts(allowlistRemove);
}