Skip to content

Commit f9b550a

Browse files
committed
feat: add command authorization planner contract
1 parent be166b9 commit f9b550a

23 files changed

Lines changed: 1872 additions & 1553 deletions

src/agents/bash-tools.exec-host-gateway.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
22
import { describeInterpreterInlineEval } from "../infra/command-analysis/inline-eval.js";
33
import { detectPolicyInlineEval } from "../infra/command-analysis/policy.js";
4+
import { renderAuthorizationShellCommand } from "../infra/command-authorization/index.js";
45
import {
56
addDurableCommandApproval,
67
type ExecAsk,
@@ -272,7 +273,7 @@ export async function processGatewayAllowlist(
272273
ask: params.ask,
273274
host: "gateway",
274275
});
275-
const allowlistEval = evaluateShellAllowlist({
276+
const allowlistEval = await evaluateShellAllowlist({
276277
command: params.command,
277278
allowlist: approvals.allowlist,
278279
safeBins: params.safeBins,
@@ -304,11 +305,18 @@ export async function processGatewayAllowlist(
304305
let enforcedCommand: string | undefined;
305306
let allowlistPlanUnavailableReason: string | null = null;
306307
if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) {
307-
const enforced = buildEnforcedShellCommand({
308-
command: params.command,
309-
segments: allowlistEval.segments,
310-
platform: process.platform,
311-
});
308+
const enforced = allowlistEval.authorizationPlan
309+
? renderAuthorizationShellCommand({
310+
plan: allowlistEval.authorizationPlan,
311+
segments: allowlistEval.segments,
312+
platform: process.platform,
313+
mode: "enforced",
314+
})
315+
: buildEnforcedShellCommand({
316+
command: params.command,
317+
segments: allowlistEval.segments,
318+
platform: process.platform,
319+
});
312320
if (!enforced.ok || !enforced.command) {
313321
allowlistPlanUnavailableReason = enforced.reason ?? "unsupported platform";
314322
} else {
@@ -493,9 +501,11 @@ export async function processGatewayAllowlist(
493501
} else if (decision === "allow-always") {
494502
approvedByAsk = true;
495503
if (!requiresInlineEvalApproval) {
496-
const patterns = persistAllowAlwaysPatterns({
504+
const patterns = await persistAllowAlwaysPatterns({
497505
approvals: approvals.file,
498506
agentId: params.agentId,
507+
analysisOk,
508+
commandText: params.command,
499509
segments: allowlistEval.segments,
500510
cwd: params.workdir,
501511
env: params.env,

src/agents/bash-tools.exec-host-node-phases.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ export async function analyzeNodeApprovalRequirement(params: {
299299
hostSecurity: ExecSecurity;
300300
hostAsk: ExecAsk;
301301
}): Promise<NodeApprovalAnalysis> {
302-
const baseAllowlistEval = evaluateShellAllowlist({
302+
const baseAllowlistEval = await evaluateShellAllowlist({
303303
command: params.request.command,
304304
allowlist: [],
305305
safeBins: new Set(),
@@ -340,7 +340,7 @@ export async function analyzeNodeApprovalRequirement(params: {
340340
overrides: { security: "full" },
341341
});
342342
// Allowlist-only precheck; safe bins are node-local and may diverge.
343-
const allowlistEval = evaluateShellAllowlist({
343+
const allowlistEval = await evaluateShellAllowlist({
344344
command: params.request.command,
345345
allowlist: resolved.allowlist,
346346
safeBins: new Set(),

src/agents/bash-tools.exec.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
55
import { buildCommandPayloadCandidates } from "../infra/command-analysis/risks.js";
6-
import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js";
6+
import {
7+
createExecCommandAnalysisFromAuthorizationPlan,
8+
planCommandForAuthorization,
9+
} from "../infra/command-authorization/index.js";
710
import {
811
type ExecAsk,
912
type ExecHost,
@@ -1185,10 +1188,11 @@ function parseOpenClawChannelsLoginShellCommand(raw: string): boolean {
11851188
);
11861189
}
11871190

1188-
function rejectUnsafeControlShellCommand(command: string): void {
1191+
async function rejectUnsafeControlShellCommand(command: string): Promise<void> {
11891192
const rawCommand = command.trim();
1190-
const analysis = analyzeShellCommand({ command: rawCommand });
1191-
const candidates = analysis.ok
1193+
const plan = await planCommandForAuthorization({ dialect: "posix-shell", command: rawCommand });
1194+
const analysis = createExecCommandAnalysisFromAuthorizationPlan({ plan });
1195+
const candidates = analysis?.ok
11921196
? analysis.segments.flatMap((segment) => buildCommandPayloadCandidates(segment.argv))
11931197
: rawCommand
11941198
.split(/\r?\n/)
@@ -1447,7 +1451,7 @@ export function createExecTool(
14471451
const rawWorkdir = explicitWorkdir ?? defaultWorkdir ?? process.cwd();
14481452
workdir = resolveWorkdir(rawWorkdir, warnings);
14491453
}
1450-
rejectUnsafeControlShellCommand(params.command);
1454+
await rejectUnsafeControlShellCommand(params.command);
14511455

14521456
const inheritedBaseEnv = coerceEnv(process.env);
14531457
const hostEnvResult =

src/gateway/server-methods/exec-approval.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,15 @@ export function createExecApprovalHandlers(
275275
return;
276276
}
277277
const sanitizedCommandText = sanitizedCommandDisplay.text;
278-
const commandAnalysis = resolveCommandAnalysisSummaryForDisplay({
278+
const commandAnalysisPromise = resolveCommandAnalysisSummaryForDisplay({
279279
host,
280280
commandText: effectiveCommandText,
281281
commandArgv: effectiveCommandArgv,
282282
cwd: effectiveCwd,
283283
sanitizeText: sanitizeExecApprovalWarningText,
284+
}).catch((err) => {
285+
context.logGateway?.error?.(`exec approvals: command analysis failed: ${String(err)}`);
286+
return null;
284287
});
285288
const commandSpans =
286289
commandHighlighting && sanitizedCommandText === effectiveCommandText
@@ -320,7 +323,7 @@ export function createExecApprovalHandlers(
320323
security: p.security ?? null,
321324
ask: p.ask ?? null,
322325
warningText: warningText ? sanitizeExecApprovalWarningText(warningText) : null,
323-
commandAnalysis,
326+
commandAnalysis: null,
324327
commandSpans,
325328
allowedDecisions: resolveExecApprovalAllowedDecisions({ ask: p.ask ?? null }),
326329
agentId: effectiveAgentId ?? null,
@@ -357,6 +360,9 @@ export function createExecApprovalHandlers(
357360
createdAtMs: record.createdAtMs,
358361
expiresAtMs: record.expiresAtMs,
359362
};
363+
void commandAnalysisPromise.then((commandAnalysis) => {
364+
record.request.commandAnalysis = commandAnalysis;
365+
});
360366
await handlePendingApprovalRequest({
361367
manager,
362368
record,

src/gateway/server-methods/server-methods.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,13 @@ describe("exec approval handlers", () => {
10991099

11001100
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
11011101
const request = requested?.payload as { id?: string; request?: { commandAnalysis?: unknown } };
1102+
await vi.waitFor(() => {
1103+
expect(request.request?.commandAnalysis).toMatchObject({
1104+
commandCount: 1,
1105+
riskKinds: ["inline-eval"],
1106+
warningLines: ["Contains inline-eval: python3 -c"],
1107+
});
1108+
});
11021109
const commandAnalysis = request.request?.commandAnalysis as Record<string, unknown>;
11031110
expect(commandAnalysis.commandCount).toBe(1);
11041111
expect(commandAnalysis.riskKinds).toEqual(["inline-eval"]);

src/infra/command-analysis/explain.lazy.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ vi.mock("../command-explainer/extract.js", () => {
55
});
66

77
describe("command-analysis lazy command explainer", () => {
8-
it("does not load tree-sitter parser dependencies for policy summaries", async () => {
8+
it("does not load tree-sitter parser dependencies for argv policy summaries", async () => {
99
const { resolveCommandAnalysisSummaryForDisplay } = await import("./explain.js");
1010

11-
const summary = resolveCommandAnalysisSummaryForDisplay({
12-
host: "gateway",
13-
commandText: "python3 -c 'print(1)'",
11+
const summary = await resolveCommandAnalysisSummaryForDisplay({
12+
host: "node",
13+
commandText: "python3 script.py",
14+
commandArgv: ["python3", "-c", "print(1)"],
1415
});
1516

1617
if (!summary) {

src/infra/command-analysis/explain.test.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ describe("command-analysis explanation summary", () => {
4141
expect(summary.warningLines).toEqual(["Contains inline-eval: python3 -c"]);
4242
});
4343

44-
it("resolves node display summaries from argv", () => {
45-
const summary = resolveCommandAnalysisSummaryForDisplay({
44+
it("resolves node display summaries from argv", async () => {
45+
const summary = await resolveCommandAnalysisSummaryForDisplay({
4646
host: "node",
4747
commandText: "python3 script.py",
4848
commandArgv: ["python3", "-c", "print(1)"],
@@ -52,15 +52,15 @@ describe("command-analysis explanation summary", () => {
5252
expect(summary?.warningLines).toEqual(["Contains inline-eval: python3 -c"]);
5353

5454
expect(
55-
resolveCommandAnalysisSummaryForDisplay({
55+
await resolveCommandAnalysisSummaryForDisplay({
5656
host: "node",
5757
commandText: "python3 -c 'print(1)'",
5858
}),
5959
).toBeNull();
6060
});
6161

62-
it("resolves gateway display summaries from shell text even when argv is stale", () => {
63-
const summary = resolveCommandAnalysisSummaryForDisplay({
62+
it("resolves gateway display summaries from shell text even when argv is stale", async () => {
63+
const summary = await resolveCommandAnalysisSummaryForDisplay({
6464
host: "gateway",
6565
commandText: "python3 -c 'print(1)'",
6666
commandArgv: ["python3", "script.py"],
@@ -70,18 +70,22 @@ describe("command-analysis explanation summary", () => {
7070
expect(summary?.warningLines).toEqual(["Contains inline-eval: python3 -c"]);
7171

7272
expect(
73-
resolveCommandAnalysisSummaryForDisplay({
74-
host: "gateway",
75-
commandText: "echo ok",
76-
commandArgv: ["python3", "-c", "print(1)"],
77-
})?.riskKinds,
73+
(
74+
await resolveCommandAnalysisSummaryForDisplay({
75+
host: "gateway",
76+
commandText: "echo ok",
77+
commandArgv: ["python3", "-c", "print(1)"],
78+
})
79+
)?.riskKinds,
7880
).toStrictEqual([]);
7981
expect(
80-
resolveCommandAnalysisSummaryForDisplay({
81-
host: "gateway",
82-
commandText: "python3 -c 'print(1)'",
83-
sanitizeText: (value) => value.replaceAll("python3", "python"),
84-
})?.warningLines,
82+
(
83+
await resolveCommandAnalysisSummaryForDisplay({
84+
host: "gateway",
85+
commandText: "python3 -c 'print(1)'",
86+
sanitizeText: (value) => value.replaceAll("python3", "python"),
87+
})
88+
)?.warningLines,
8589
).toEqual(["Contains inline-eval: python -c"]);
8690
});
8791
});

src/infra/command-analysis/explain.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,23 +80,23 @@ export function summarizeCommandSegmentsForDisplay(
8080
};
8181
}
8282

83-
export function resolveCommandAnalysisSummaryForDisplay(params: {
83+
export async function resolveCommandAnalysisSummaryForDisplay(params: {
8484
host?: string | null;
8585
commandText: string;
8686
commandArgv?: string[];
8787
cwd?: string | null;
8888
sanitizeText?: (value: string) => string;
89-
}): CommandExplanationSummary | null {
89+
}): Promise<CommandExplanationSummary | null> {
9090
const analysis =
9191
params.host === "node"
9292
? Array.isArray(params.commandArgv) && params.commandArgv.length > 0
93-
? analyzeCommandForPolicy({
93+
? await analyzeCommandForPolicy({
9494
source: "argv",
9595
argv: params.commandArgv,
9696
cwd: params.cwd ?? undefined,
9797
})
9898
: null
99-
: analyzeCommandForPolicy({
99+
: await analyzeCommandForPolicy({
100100
source: "shell",
101101
command: params.commandText,
102102
cwd: params.cwd ?? undefined,

src/infra/command-analysis/policy.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
analyzeArgvCommand,
33
analyzeShellCommand,
4+
isWindowsPlatform,
45
type ExecCommandAnalysis,
56
type ExecCommandSegment,
67
} from "../exec-approvals-analysis.js";
@@ -21,7 +22,7 @@ export type CommandPolicyAnalysis =
2122
segments: [];
2223
};
2324

24-
export function analyzeCommandForPolicy(
25+
export async function analyzeCommandForPolicy(
2526
params:
2627
| {
2728
source: "shell";
@@ -36,15 +37,10 @@ export function analyzeCommandForPolicy(
3637
cwd?: string;
3738
env?: NodeJS.ProcessEnv;
3839
},
39-
): CommandPolicyAnalysis {
40+
): Promise<CommandPolicyAnalysis> {
4041
const analysis =
4142
params.source === "shell"
42-
? analyzeShellCommand({
43-
command: params.command,
44-
cwd: params.cwd,
45-
env: params.env,
46-
platform: params.platform,
47-
})
43+
? await analyzeShellCommandForPolicy(params)
4844
: analyzeArgvCommand({ argv: params.argv, cwd: params.cwd, env: params.env });
4945
if (!analysis.ok) {
5046
return {
@@ -63,6 +59,38 @@ export function analyzeCommandForPolicy(
6359
};
6460
}
6561

62+
async function analyzeShellCommandForPolicy(params: {
63+
command: string;
64+
cwd?: string;
65+
env?: NodeJS.ProcessEnv;
66+
platform?: string | null;
67+
}): Promise<ExecCommandAnalysis> {
68+
if (isWindowsPlatform(params.platform)) {
69+
return analyzeShellCommand({
70+
command: params.command,
71+
cwd: params.cwd,
72+
env: params.env,
73+
platform: params.platform,
74+
});
75+
}
76+
const { createExecCommandAnalysisFromAuthorizationPlan, planCommandForAuthorization } =
77+
await import("../command-authorization/index.js");
78+
const plan = await planCommandForAuthorization(
79+
{ dialect: "posix-shell", command: params.command },
80+
{
81+
cwd: params.cwd,
82+
env: params.env,
83+
platform: params.platform,
84+
},
85+
);
86+
const analysis = createExecCommandAnalysisFromAuthorizationPlan({
87+
plan,
88+
cwd: params.cwd,
89+
env: params.env,
90+
});
91+
return analysis ?? { ok: false, reason: "unable to parse shell command", segments: [] };
92+
}
93+
6694
export function detectPolicyInlineEval(segments: readonly ExecCommandSegment[]) {
6795
return detectInlineEvalInSegments(segments);
6896
}

0 commit comments

Comments
 (0)