mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXTENSION_TAG,
|
||||
renderPolicyDenial,
|
||||
renderUnavailableDenial,
|
||||
renderUserDenial,
|
||||
} from "#src/presentation/agent-renderer";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
/** A payload of the given kind, with request facts and evidence overridden. */
|
||||
function payload(
|
||||
kind: PromptPayload["kind"],
|
||||
request: Partial<PromptPayload["request"]>,
|
||||
evidence: PromptPayload["evidence"] = [],
|
||||
): PromptPayload {
|
||||
const base = makePromptPayload();
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
request: { ...base.request, ...request },
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
/** A bash ask, the shape whose value the renderer must never echo. */
|
||||
function bashPayload(
|
||||
request: Partial<PromptPayload["request"]> = {},
|
||||
): PromptPayload {
|
||||
return payload("bash", {
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "rm -rf build",
|
||||
matchedPattern: "rm *",
|
||||
...request,
|
||||
});
|
||||
}
|
||||
|
||||
describe("EXTENSION_TAG", () => {
|
||||
it("attributes every block reason to this extension", () => {
|
||||
expect(EXTENSION_TAG).toBe("[pi-permission-system]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPolicyDenial", () => {
|
||||
it("names the surface and the matched rule for a bash deny", () => {
|
||||
expect(renderPolicyDenial(bashPayload(), null)).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' (rule 'rm *').",
|
||||
);
|
||||
});
|
||||
|
||||
it("never echoes the command, however large", () => {
|
||||
const command = `cat <<'EOF' > gen.py\n${"x".repeat(70_000)}\nEOF`;
|
||||
const rendered = renderPolicyDenial(
|
||||
bashPayload({ value: command, matchedPattern: "*" }),
|
||||
null,
|
||||
);
|
||||
expect(rendered).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' (rule '*').",
|
||||
);
|
||||
expect(rendered).not.toContain("xxx");
|
||||
});
|
||||
|
||||
it("names the tool when it differs from the surface", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("path", {
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: "/etc/passwd",
|
||||
matchedPattern: "/etc/*",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'path' for tool 'read' for path '/etc/passwd' (rule '/etc/*').",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the invoked tool when a shell alias re-exposed bash", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
bashPayload({ invokedToolName: "exec_command" }),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' (invoked as 'exec_command') (rule 'rm *').",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the requesting agent when the ask carries one", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
bashPayload({
|
||||
requester: { agentName: "scout", forwarded: false, sessionId: null },
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' for agent 'scout' (rule 'rm *').",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the operator's deny-with-reason text", () => {
|
||||
expect(renderPolicyDenial(bashPayload(), "destructive by policy")).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' (rule 'rm *'). Reason: destructive by policy.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the nested context a bash unit ran in", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
bashPayload({ commandContext: "command_substitution" }),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'bash' (rule 'rm *', inside command substitution).",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<indirection-bash-wrapper>",
|
||||
"<opaque-bash-wrapper>",
|
||||
"<unparseable-bash-command>",
|
||||
])("surfaces the %s sentinel as the matched rule", (sentinel) => {
|
||||
expect(
|
||||
renderPolicyDenial(bashPayload({ matchedPattern: sentinel }), null),
|
||||
).toBe(
|
||||
`[pi-permission-system] Denied by policy: 'bash' (rule '${sentinel}').`,
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the rule clause when no pattern matched", () => {
|
||||
expect(
|
||||
renderPolicyDenial(bashPayload({ matchedPattern: null }), null),
|
||||
).toBe("[pi-permission-system] Denied by policy: 'bash'.");
|
||||
});
|
||||
|
||||
it("names the escaped boundary for a tool external-directory deny", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[{ label: "working directory", text: "/repo", detail: null }],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'write' for path '/etc/hosts' (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the canonical target when a path resolves elsewhere", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "read",
|
||||
value: "link",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[
|
||||
{ label: "resolves to", text: "/etc/shadow", detail: null },
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'read' for path 'link' (resolves to '/etc/shadow') (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names every escaping path but not the command for a bash external-directory deny", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"bash_external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "diff /etc/hosts ~/.ssh/config",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/etc/hosts", detail: null },
|
||||
{
|
||||
label: "external path",
|
||||
text: "~/.ssh/config",
|
||||
detail: "/home/me/.ssh/config",
|
||||
},
|
||||
],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for paths '/etc/hosts', '~/.ssh/config' (resolves to '/home/me/.ssh/config') (rule '*'): outside working directory '/repo'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the MCP target", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("mcp", {
|
||||
surface: "mcp",
|
||||
toolName: "mcp",
|
||||
value: "github:create_issue",
|
||||
matchedPattern: "github:*",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'mcp' for target 'github:create_issue' (rule 'github:*').",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the skill", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("skill", {
|
||||
surface: "skill",
|
||||
toolName: null,
|
||||
value: "deploy",
|
||||
matchedPattern: "deploy",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'skill' for skill 'deploy' (rule 'deploy').",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the skill a read reached, and the path it reached it through", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload(
|
||||
"skill_read",
|
||||
{
|
||||
surface: "skill",
|
||||
toolName: null,
|
||||
value: "deploy",
|
||||
matchedPattern: "deploy",
|
||||
},
|
||||
[
|
||||
{
|
||||
label: "read path",
|
||||
text: ".pi/skills/deploy/SKILL.md",
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'skill' for skill 'deploy' (rule 'deploy'), reached via '.pi/skills/deploy/SKILL.md'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("states the tool once for a generic tool ask, whose value is its own name", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("tool", {
|
||||
surface: "webfetch",
|
||||
toolName: "webfetch",
|
||||
value: "webfetch",
|
||||
matchedPattern: "web*",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'webfetch' (rule 'web*').",
|
||||
);
|
||||
});
|
||||
|
||||
it("names nothing beyond the surface for a payload-less forwarded relay", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("forwarded", {
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "rm -rf /",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe("[pi-permission-system] Denied by policy: 'bash' (rule '*').");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderUserDenial", () => {
|
||||
it("attributes the refusal to the user", () => {
|
||||
expect(renderUserDenial(bashPayload(), null)).toBe(
|
||||
"[pi-permission-system] The user denied this 'bash' call (rule 'rm *').",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the human's typed reason", () => {
|
||||
expect(renderUserDenial(bashPayload(), "not with sudo")).toBe(
|
||||
"[pi-permission-system] The user denied this 'bash' call (rule 'rm *'). Reason: not with sudo.",
|
||||
);
|
||||
});
|
||||
|
||||
it("never echoes the command", () => {
|
||||
const rendered = renderUserDenial(
|
||||
bashPayload({ value: "x".repeat(70_000), matchedPattern: "*" }),
|
||||
"too big",
|
||||
);
|
||||
expect(rendered).toBe(
|
||||
"[pi-permission-system] The user denied this 'bash' call (rule '*'). Reason: too big.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the flagged path for a tool ask", () => {
|
||||
expect(
|
||||
renderUserDenial(
|
||||
payload("path", {
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: "/etc/passwd",
|
||||
matchedPattern: "/etc/*",
|
||||
}),
|
||||
"not that file",
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] The user denied this 'path' call for tool 'read' for path '/etc/passwd' (rule '/etc/*'). Reason: not that file.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderUnavailableDenial", () => {
|
||||
it("states that approval was required and unreachable", () => {
|
||||
expect(renderUnavailableDenial(bashPayload(), null)).toBe(
|
||||
"[pi-permission-system] This 'bash' call (rule 'rm *') requires approval, but no interactive UI is available.",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries an abandoning authority's reason", () => {
|
||||
expect(
|
||||
renderUnavailableDenial(
|
||||
bashPayload(),
|
||||
"Session 'parent-1' is not serving forwarded requests",
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] This 'bash' call (rule 'rm *') requires approval, but no interactive UI is available. Reason: Session 'parent-1' is not serving forwarded requests.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the flagged path for a tool ask", () => {
|
||||
expect(
|
||||
renderUnavailableDenial(
|
||||
payload("path", {
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: "/etc/passwd",
|
||||
matchedPattern: "/etc/*",
|
||||
}),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] This 'path' call for tool 'read' for path '/etc/passwd' (rule '/etc/*') requires approval, but no interactive UI is available.",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the escaped boundary, which no retry shape would change", () => {
|
||||
expect(
|
||||
renderUnavailableDenial(
|
||||
payload(
|
||||
"external_directory",
|
||||
{
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
[{ label: "working directory", text: "/repo", detail: null }],
|
||||
),
|
||||
null,
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] This 'external_directory' call for tool 'write' for path '/etc/hosts' (rule '*') requires approval, but no interactive UI is available.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the flagged-element field cap", () => {
|
||||
it("shortens an oversized path and marks it", () => {
|
||||
const long = `/etc/${"a".repeat(500)}`;
|
||||
const rendered = renderPolicyDenial(
|
||||
payload("path", {
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: long,
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
null,
|
||||
{ fieldMaxWidth: 20 },
|
||||
);
|
||||
expect(rendered).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'path' for tool 'read' for path '/etc/aaaaaaaaaaaaaaa\u2026' (rule '*').",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a path within the budget untouched", () => {
|
||||
expect(
|
||||
renderPolicyDenial(
|
||||
payload("path", {
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
null,
|
||||
{ fieldMaxWidth: 400 },
|
||||
),
|
||||
).toBe(
|
||||
"[pi-permission-system] Denied by policy: 'path' for tool 'read' for path '/etc/hosts' (rule '*').",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
completeViewBudget,
|
||||
type DialogBudget,
|
||||
renderPromptDialog,
|
||||
} from "#src/presentation/dialog-renderer";
|
||||
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
const WIDE = completeViewBudget(200);
|
||||
|
||||
/** The rendered lines for a payload built from the shared structural fixture. */
|
||||
function render(
|
||||
overrides: Parameters<typeof makePromptPayload>[0],
|
||||
): readonly string[] {
|
||||
return renderPromptDialog(makePromptPayload(overrides), WIDE).lines;
|
||||
}
|
||||
|
||||
/** A payload whose request facts override the fixture's defaults. */
|
||||
function requestFacts(
|
||||
overrides: Partial<ReturnType<typeof makePromptPayload>["request"]>,
|
||||
) {
|
||||
return { ...makePromptPayload().request, ...overrides };
|
||||
}
|
||||
|
||||
describe("renderPromptDialog", () => {
|
||||
describe("the invariant core", () => {
|
||||
it("renders a bash ask as one aligned fact per line", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
requester: {
|
||||
agentName: "scout",
|
||||
forwarded: false,
|
||||
sessionId: null,
|
||||
},
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "cat /etc/hosts",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"agent : scout",
|
||||
"tool : bash",
|
||||
"rule : *",
|
||||
"command : cat /etc/hosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the gate surface when it differs from the tool", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "external_directory",
|
||||
request: requestFacts({
|
||||
surface: "external_directory",
|
||||
toolName: "write",
|
||||
value: "/etc/hosts",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : write",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"path : /etc/hosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits an agent line for an unnamed local requester, and a surface the value label already names", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "path",
|
||||
request: requestFacts({
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
value: "/tmp/x",
|
||||
matchedPattern: null,
|
||||
}),
|
||||
}),
|
||||
).toEqual(["tool : read", "path : /tmp/x"]);
|
||||
});
|
||||
|
||||
it("omits the value line when the tool line already carries it", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "tool",
|
||||
request: requestFacts({
|
||||
surface: "fetch",
|
||||
toolName: "fetch",
|
||||
value: "fetch",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual(["tool : fetch", "rule : *"]);
|
||||
});
|
||||
|
||||
it("labels an MCP ask's value as the target", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "mcp",
|
||||
request: requestFacts({
|
||||
surface: "mcp__github__create_issue",
|
||||
toolName: "mcp__github__create_issue",
|
||||
value: "github:create_issue",
|
||||
matchedPattern: "mcp__github__*",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : mcp__github__create_issue",
|
||||
"rule : mcp__github__*",
|
||||
"target : github:create_issue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a skill ask without repeating the surface", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "skill",
|
||||
request: requestFacts({
|
||||
surface: "skill",
|
||||
toolName: null,
|
||||
value: "deploy",
|
||||
matchedPattern: null,
|
||||
}),
|
||||
}),
|
||||
).toEqual(["skill : deploy"]);
|
||||
});
|
||||
|
||||
it("renders the executed unit of an unstrippable wrapper", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "xargs grep foo",
|
||||
matchedPattern: "<indirection-bash-wrapper>",
|
||||
executedUnit: "grep foo",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"rule : <indirection-bash-wrapper>",
|
||||
"command : xargs grep foo",
|
||||
"runs : grep foo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the nested context the offending unit ran in", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "rm -rf /tmp/x",
|
||||
matchedPattern: "rm *",
|
||||
commandContext: "command_substitution",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"rule : rm *",
|
||||
"command : rm -rf /tmp/x",
|
||||
"context : command substitution",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the invoked tool when a shell alias re-exposes bash", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
invokedToolName: "exec_command",
|
||||
value: "ls",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash (invoked as exec_command)",
|
||||
"rule : *",
|
||||
"command : ls",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidence", () => {
|
||||
it("renders each entry under the core, sharing the label column", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "rm -rf build",
|
||||
matchedPattern: "rm *",
|
||||
}),
|
||||
evidence: [
|
||||
{
|
||||
label: "full command",
|
||||
text: "npm run clean && rm -rf build",
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"rule : rm *",
|
||||
"command : rm -rf build",
|
||||
"full command : npm run clean && rm -rf build",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an entry's detail on the entry's own line", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash_external_directory",
|
||||
request: requestFacts({
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "cat /etc/hosts /tmp/x",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
evidence: [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{
|
||||
label: "external path",
|
||||
text: "/etc/hosts",
|
||||
detail: "/private/etc/hosts",
|
||||
},
|
||||
{ label: "external path", text: "/tmp/x", detail: null },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"command : cat /etc/hosts /tmp/x",
|
||||
"working directory : /repo",
|
||||
"external path : /etc/hosts → /private/etc/hosts",
|
||||
"external path : /tmp/x",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a skill read's path", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "skill_read",
|
||||
request: requestFacts({
|
||||
surface: "skill",
|
||||
toolName: null,
|
||||
value: "deploy",
|
||||
matchedPattern: null,
|
||||
}),
|
||||
evidence: [
|
||||
{
|
||||
label: "read path",
|
||||
text: "/skills/deploy/SKILL.md",
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(["skill : deploy", "read path : /skills/deploy/SKILL.md"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the per-field width cap", () => {
|
||||
const NARROW: DialogBudget = {
|
||||
maxRows: Number.POSITIVE_INFINITY,
|
||||
fieldMaxWidth: 12,
|
||||
width: 200,
|
||||
};
|
||||
|
||||
/** A bash ask whose command is the pathological field. */
|
||||
function bashAsk(command: string, fullCommand?: string) {
|
||||
return makePromptPayload({
|
||||
kind: "bash",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
toolName: "bash",
|
||||
surface: "bash",
|
||||
value: command,
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence:
|
||||
fullCommand === undefined
|
||||
? []
|
||||
: [{ label: "full command", text: fullCommand, detail: null }],
|
||||
});
|
||||
}
|
||||
|
||||
it("clips a core field and marks it, without stating a count", () => {
|
||||
const view = renderPromptDialog(
|
||||
bashAsk("echo the quick brown fox"),
|
||||
NARROW,
|
||||
);
|
||||
|
||||
expect(view.lines).toEqual([
|
||||
"tool : bash",
|
||||
"rule : *",
|
||||
"command : echo the qui…",
|
||||
]);
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("clips an evidence field the same way", () => {
|
||||
const view = renderPromptDialog(
|
||||
bashAsk("echo hi", "echo hi && echo there"),
|
||||
NARROW,
|
||||
);
|
||||
|
||||
expect(view.lines).toEqual([
|
||||
"tool : bash",
|
||||
"rule : *",
|
||||
"command : echo hi",
|
||||
"full command : echo hi && e…",
|
||||
]);
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("reproduces the field verbatim under the complete view", () => {
|
||||
const view = renderPromptDialog(
|
||||
bashAsk("echo the quick brown fox"),
|
||||
completeViewBudget(200),
|
||||
);
|
||||
|
||||
expect(view.lines).toContain("command : echo the quick brown fox");
|
||||
expect(view.elided).toBe(false);
|
||||
});
|
||||
|
||||
it("indents a multi-line field under its label", () => {
|
||||
const view = renderPromptDialog(
|
||||
bashAsk("cat <<'EOF'\nfirst line\nsecond line\nEOF"),
|
||||
completeViewBudget(200),
|
||||
);
|
||||
|
||||
expect(view.lines).toEqual([
|
||||
"tool : bash",
|
||||
"rule : *",
|
||||
"command : cat <<'EOF'",
|
||||
" first line",
|
||||
" second line",
|
||||
" EOF",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the row budget", () => {
|
||||
/** A tool ask with `count` evidence entries, each one row wide. */
|
||||
function askWithEvidence(count: number) {
|
||||
return makePromptPayload({
|
||||
kind: "tool",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
surface: "fetch",
|
||||
toolName: "fetch",
|
||||
value: "fetch",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: Array.from({ length: count }, (_, index) => ({
|
||||
label: "input",
|
||||
text: `entry ${index}`,
|
||||
detail: null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
it("drops evidence that does not fit and marks the drop", () => {
|
||||
const view = renderPromptDialog(askWithEvidence(6), {
|
||||
maxRows: 5,
|
||||
fieldMaxWidth: 200,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
expect(view.lines).toEqual([
|
||||
"tool : fetch",
|
||||
"rule : *",
|
||||
"input : entry 0",
|
||||
"input : entry 1",
|
||||
"…",
|
||||
]);
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("marks nothing when every entry fits", () => {
|
||||
const view = renderPromptDialog(askWithEvidence(2), {
|
||||
maxRows: 5,
|
||||
fieldMaxWidth: 200,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
expect(view.lines).toHaveLength(4);
|
||||
expect(view.elided).toBe(false);
|
||||
});
|
||||
|
||||
it("counts rows after wrapping to the width", () => {
|
||||
const payload = makePromptPayload({
|
||||
kind: "tool",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
surface: "fetch",
|
||||
toolName: "fetch",
|
||||
value: "fetch",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: [{ label: "input", text: "a".repeat(60), detail: null }],
|
||||
});
|
||||
|
||||
// The same entry is one row at a wide width and four once wrapped, so
|
||||
// the identical budget admits it only in the first case.
|
||||
expect(
|
||||
renderPromptDialog(payload, {
|
||||
maxRows: 5,
|
||||
fieldMaxWidth: 200,
|
||||
width: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
lines: ["tool : fetch", "rule : *", `input : ${"a".repeat(60)}`],
|
||||
elided: false,
|
||||
});
|
||||
|
||||
const wrapped = renderPromptDialog(payload, {
|
||||
maxRows: 5,
|
||||
fieldMaxWidth: 200,
|
||||
width: 20,
|
||||
});
|
||||
expect(wrapped.lines).toEqual(["tool : fetch", "rule : *", "…"]);
|
||||
expect(wrapped.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the whole core when it alone exceeds the budget", () => {
|
||||
const view = renderPromptDialog(askWithEvidence(3), {
|
||||
maxRows: 1,
|
||||
fieldMaxWidth: 200,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
expect(view.lines).toEqual(["tool : fetch", "rule : *"]);
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds the reported forwarded here-string ask (#710)", () => {
|
||||
const body = Array.from(
|
||||
{ length: 200 },
|
||||
() => "- a finding line about some module in the codebase",
|
||||
).join("\n");
|
||||
const command = `@'\n${body}\n'@ | Out-File -FilePath report.md`;
|
||||
const budget = { maxRows: 24, fieldMaxWidth: 400, width: 120 };
|
||||
|
||||
const view = renderPromptDialog(
|
||||
makePromptPayload({
|
||||
kind: "forwarded",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
requester: {
|
||||
agentName: "scout",
|
||||
forwarded: true,
|
||||
sessionId: "abc123",
|
||||
},
|
||||
surface: "bash",
|
||||
toolName: null,
|
||||
value: command,
|
||||
matchedPattern: null,
|
||||
},
|
||||
evidence: [
|
||||
{
|
||||
label: "requested",
|
||||
text: `Subagent 'scout' requested bash command '${command}'.`,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
budget,
|
||||
);
|
||||
|
||||
expect(view.lines.length).toBeLessThanOrEqual(budget.maxRows);
|
||||
expect(view.lines[0]).toBe("subagent : scout · session abc123");
|
||||
expect(view.lines[1]).toBe("surface : bash");
|
||||
expect(view.lines[2]).toBe("command : @'");
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds the same forwarded here-string ask carrying the child's own payload (#745)", () => {
|
||||
// The same ask, arriving as the child built it rather than as a relayed
|
||||
// sentence: `kind: "bash"` with the child's real evidence entries. A
|
||||
// different input to the same budget, so the row bound is measured here
|
||||
// rather than inferred from the `kind: "forwarded"` pin above.
|
||||
const body = Array.from(
|
||||
{ length: 200 },
|
||||
() => "- a finding line about some module in the codebase",
|
||||
).join("\n");
|
||||
const command = `@'\n${body}\n'@ | Out-File -FilePath report.md`;
|
||||
const budget = { maxRows: 24, fieldMaxWidth: 400, width: 120 };
|
||||
|
||||
const view = renderPromptDialog(
|
||||
makePromptPayload({
|
||||
kind: "bash",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
requester: {
|
||||
agentName: "scout",
|
||||
forwarded: true,
|
||||
sessionId: "abc123",
|
||||
},
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: command,
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: [
|
||||
{ label: "full command", text: command, detail: null },
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
],
|
||||
}),
|
||||
budget,
|
||||
);
|
||||
|
||||
expect(view.lines.length).toBeLessThanOrEqual(budget.maxRows);
|
||||
expect(view.elided).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlighting the flagged element", () => {
|
||||
/** A visible stand-in for the theme's warning colour. */
|
||||
const mark = (text: string) => `[${text}]`;
|
||||
|
||||
it("paints the flagged value and its whole-token occurrences in evidence", () => {
|
||||
expect(
|
||||
renderPromptDialog(
|
||||
makePromptPayload({
|
||||
kind: "bash",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "ls",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: [
|
||||
{
|
||||
label: "full command",
|
||||
text: "lsof | ls && /usr/bin/lsblk",
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
completeViewBudget(200),
|
||||
mark,
|
||||
).lines,
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"rule : *",
|
||||
"command : [ls]",
|
||||
"full command : lsof | [ls] && /usr/bin/lsblk",
|
||||
]);
|
||||
});
|
||||
|
||||
it("paints each escaping path rather than the command that referenced them", () => {
|
||||
expect(
|
||||
renderPromptDialog(
|
||||
makePromptPayload({
|
||||
kind: "bash_external_directory",
|
||||
request: {
|
||||
...makePromptPayload().request,
|
||||
surface: "external_directory",
|
||||
toolName: "bash",
|
||||
value: "cat /etc/hosts /etc/hostsbackup",
|
||||
matchedPattern: "*",
|
||||
},
|
||||
evidence: [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/etc/hosts", detail: null },
|
||||
],
|
||||
}),
|
||||
completeViewBudget(200),
|
||||
mark,
|
||||
).lines,
|
||||
).toEqual([
|
||||
"tool : bash",
|
||||
"surface : external_directory",
|
||||
"rule : *",
|
||||
"command : cat [/etc/hosts] /etc/hostsbackup",
|
||||
"working directory : /repo",
|
||||
"external path : [/etc/hosts]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the text untouched when no paint is supplied", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "bash",
|
||||
request: requestFacts({
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
value: "ls",
|
||||
matchedPattern: "*",
|
||||
}),
|
||||
}),
|
||||
).toEqual(["tool : bash", "rule : *", "command : ls"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a forwarded ask", () => {
|
||||
it("names the requesting subagent and its session", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "forwarded",
|
||||
request: requestFacts({
|
||||
requester: {
|
||||
agentName: "scout",
|
||||
forwarded: true,
|
||||
sessionId: "abc123",
|
||||
},
|
||||
surface: "bash",
|
||||
toolName: null,
|
||||
value: "cat /etc/hosts",
|
||||
matchedPattern: null,
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
"subagent : scout · session abc123",
|
||||
"surface : bash",
|
||||
"command : cat /etc/hosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a version-skewed request that names neither agent nor session", () => {
|
||||
expect(
|
||||
render({
|
||||
kind: "forwarded",
|
||||
request: requestFacts({
|
||||
requester: { agentName: "", forwarded: true, sessionId: "" },
|
||||
surface: "read",
|
||||
toolName: null,
|
||||
value: "/tmp/x",
|
||||
matchedPattern: null,
|
||||
}),
|
||||
}),
|
||||
).toEqual(["subagent : unknown", "surface : read", "value : /tmp/x"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeBashCommandContext,
|
||||
flaggedElementLabel,
|
||||
flaggedElements,
|
||||
valueLabel,
|
||||
} from "#src/presentation/fact-vocabulary";
|
||||
import type {
|
||||
PromptEvidence,
|
||||
PromptPayloadKind,
|
||||
} from "#src/presentation/prompt-payload";
|
||||
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
/** A payload of the given kind whose flagged value and evidence are set. */
|
||||
function payloadOf(
|
||||
kind: PromptPayloadKind,
|
||||
value: string,
|
||||
evidence: PromptEvidence[] = [],
|
||||
) {
|
||||
const base = makePromptPayload();
|
||||
return {
|
||||
...base,
|
||||
kind,
|
||||
request: { ...base.request, value },
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
/** An `external path` evidence entry, as the bash external-directory gate emits it. */
|
||||
function externalPath(path: string, resolved: string | null): PromptEvidence {
|
||||
return { label: "external path", text: path, detail: resolved };
|
||||
}
|
||||
|
||||
describe("flaggedElements", () => {
|
||||
it("flags the decision-relevant value for a single-value ask", () => {
|
||||
expect(flaggedElements(payloadOf("path", "/etc/hosts"))).toEqual([
|
||||
"/etc/hosts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags the command for a bash ask", () => {
|
||||
expect(flaggedElements(payloadOf("bash", "rm -rf build"))).toEqual([
|
||||
"rm -rf build",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags the escaping paths rather than the command for a bash external-directory ask", () => {
|
||||
expect(
|
||||
flaggedElements(
|
||||
payloadOf("bash_external_directory", "diff /etc/hosts ~/.ssh/config", [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
externalPath("/etc/hosts", null),
|
||||
externalPath("~/.ssh/config", "/home/me/.ssh/config"),
|
||||
]),
|
||||
),
|
||||
).toEqual(["/etc/hosts", "~/.ssh/config"]);
|
||||
});
|
||||
|
||||
it("flags nothing when the value is empty", () => {
|
||||
expect(flaggedElements(payloadOf("tool", ""))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueLabel", () => {
|
||||
it.each([
|
||||
["bash", "command"],
|
||||
["bash_external_directory", "command"],
|
||||
["mcp", "target"],
|
||||
["tool", "tool"],
|
||||
["path", "path"],
|
||||
["external_directory", "path"],
|
||||
["skill", "skill"],
|
||||
["skill_read", "skill"],
|
||||
] as const)("labels a %s ask's value %s", (kind, label) => {
|
||||
expect(valueLabel(payloadOf(kind, "value"))).toBe(label);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bash", "command"],
|
||||
["skill", "skill"],
|
||||
["read", "value"],
|
||||
])("infers a payload-less forwarded ask's label from its %s surface", (surface, label) => {
|
||||
const base = payloadOf("forwarded", "value");
|
||||
expect(valueLabel({ ...base, request: { ...base.request, surface } })).toBe(
|
||||
label,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flaggedElementLabel", () => {
|
||||
it("labels the escaping paths a bash external-directory ask flags", () => {
|
||||
expect(
|
||||
flaggedElementLabel(payloadOf("bash_external_directory", "cmd")),
|
||||
).toBe("path");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bash",
|
||||
"mcp",
|
||||
"path",
|
||||
"skill",
|
||||
] as const)("agrees with the value label for a %s ask, whose value is what it flags", (kind) => {
|
||||
const single = payloadOf(kind, "value");
|
||||
expect(flaggedElementLabel(single)).toBe(valueLabel(single));
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeBashCommandContext", () => {
|
||||
it.each([
|
||||
["command_substitution", "command substitution"],
|
||||
["process_substitution", "process substitution"],
|
||||
["subshell", "subshell"],
|
||||
] as const)("names a %s context", (context, label) => {
|
||||
expect(describeBashCommandContext(context)).toBe(label);
|
||||
});
|
||||
|
||||
it("names no context for a current-shell command", () => {
|
||||
expect(describeBashCommandContext(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildBashExternalDirectoryAskPayload,
|
||||
buildExternalDirectoryAskPayload,
|
||||
buildPathAskPayload,
|
||||
} from "#src/presentation/path-ask-payload";
|
||||
|
||||
describe("buildPathAskPayload", () => {
|
||||
test("carries the typed path as the decision value and the rule that fired", () => {
|
||||
const payload = buildPathAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "/etc/passwd",
|
||||
agentName: "my-agent",
|
||||
matchedPattern: "/etc/*",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("path");
|
||||
expect(payload.request).toEqual({
|
||||
requester: { agentName: "my-agent", forwarded: false, sessionId: null },
|
||||
surface: "path",
|
||||
toolName: "read",
|
||||
invokedToolName: null,
|
||||
value: "/etc/passwd",
|
||||
matchedPattern: "/etc/*",
|
||||
commandContext: null,
|
||||
executedUnit: null,
|
||||
});
|
||||
expect(payload.evidence).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves the requester unnamed when no agent is active", () => {
|
||||
expect(
|
||||
buildPathAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "/etc/passwd",
|
||||
agentName: null,
|
||||
}).request.requester,
|
||||
).toEqual({ agentName: null, forwarded: false, sessionId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildExternalDirectoryAskPayload", () => {
|
||||
test("carries the typed path, the boundary, and the requester", () => {
|
||||
const payload = buildExternalDirectoryAskPayload({
|
||||
toolName: "write",
|
||||
pathValue: "/tmp/out.txt",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: "my-agent",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("external_directory");
|
||||
expect(payload.request.toolName).toBe("write");
|
||||
expect(payload.request.value).toBe("/tmp/out.txt");
|
||||
expect(payload.request.requester.agentName).toBe("my-agent");
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discloses the resolved path as its own entry when it differs", () => {
|
||||
expect(
|
||||
buildExternalDirectoryAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "demo-symlink-passwd",
|
||||
resolvedPath: "/etc/passwd",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: null,
|
||||
}).evidence,
|
||||
).toEqual([
|
||||
{ label: "resolves to", text: "/etc/passwd", detail: null },
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("omits the disclosure when resolvedPath is undefined", () => {
|
||||
const payload = buildExternalDirectoryAskPayload({
|
||||
toolName: "read",
|
||||
pathValue: "/etc/passwd",
|
||||
cwd: "/projects/my-app",
|
||||
agentName: null,
|
||||
});
|
||||
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBashExternalDirectoryAskPayload", () => {
|
||||
test("makes the command the value and the paths it reached evidence", () => {
|
||||
const payload = buildBashExternalDirectoryAskPayload({
|
||||
command: "cat /etc/passwd",
|
||||
externalPaths: [{ path: "/etc/passwd" }],
|
||||
cwd: "/projects/my-app",
|
||||
agentName: "my-agent",
|
||||
toolName: "bash",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("bash_external_directory");
|
||||
expect(payload.request.value).toBe("cat /etc/passwd");
|
||||
expect(payload.request.requester.agentName).toBe("my-agent");
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/projects/my-app", detail: null },
|
||||
{ label: "external path", text: "/etc/passwd", detail: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("binds each path's canonical alias to its own entry", () => {
|
||||
const payload = buildBashExternalDirectoryAskPayload({
|
||||
command: "cat a b",
|
||||
externalPaths: [
|
||||
{ path: "/a", resolvedPath: "/private/a" },
|
||||
{ path: "/b" },
|
||||
],
|
||||
cwd: "/repo",
|
||||
agentName: null,
|
||||
toolName: "bash",
|
||||
});
|
||||
|
||||
expect(payload.evidence).toEqual([
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/a", detail: "/private/a" },
|
||||
{ label: "external path", text: "/b", detail: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
||||
import { renderReviewLogFacts } from "#src/presentation/review-log-renderer";
|
||||
import { makePromptPayload } from "#test/helpers/prompt-details-fixtures";
|
||||
|
||||
/** A payload whose request facts override the structural fixture's defaults. */
|
||||
function payload(
|
||||
request: Partial<PromptPayload["request"]>,
|
||||
rest: Partial<PromptPayload> = {},
|
||||
): PromptPayload {
|
||||
const base = makePromptPayload();
|
||||
return { ...base, request: { ...base.request, ...request }, ...rest };
|
||||
}
|
||||
|
||||
describe("renderReviewLogFacts", () => {
|
||||
it("records the gate surface and the rule that fired", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload({ surface: "bash", toolName: "bash", matchedPattern: "rm *" }),
|
||||
),
|
||||
).toEqual({ surface: "bash", matchedPattern: "rm *" });
|
||||
});
|
||||
|
||||
it("omits a fact the ask does not carry rather than writing a null", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(payload({ surface: "skill", matchedPattern: null })),
|
||||
).toEqual({ surface: "skill" });
|
||||
});
|
||||
|
||||
it("records the unit a wrapper will actually run", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload({
|
||||
surface: "bash",
|
||||
matchedPattern: "<indirection-bash-wrapper>",
|
||||
executedUnit: "aws s3 rm s3://bucket",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
surface: "bash",
|
||||
matchedPattern: "<indirection-bash-wrapper>",
|
||||
executedUnit: "aws s3 rm s3://bucket",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the nested context an offending bash unit ran in", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
commandContext: "command_substitution",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
commandContext: "command_substitution",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the name a shell alias invoked bash under", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
invokedToolName: "exec_command",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
invokedToolName: "exec_command",
|
||||
});
|
||||
});
|
||||
|
||||
it("records the requesting session when the ask was forwarded", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
requester: {
|
||||
agentName: "scout",
|
||||
forwarded: true,
|
||||
sessionId: "child-7",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
surface: "bash",
|
||||
matchedPattern: "*",
|
||||
forwarded: true,
|
||||
requesterSessionId: "child-7",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a local ask with no forwarding fields at all", () => {
|
||||
const facts = renderReviewLogFacts(payload({ surface: "read" }));
|
||||
expect(facts).toEqual({ surface: "read" });
|
||||
});
|
||||
|
||||
it("persists no evidence and no annotations", () => {
|
||||
expect(
|
||||
renderReviewLogFacts(
|
||||
payload(
|
||||
{ surface: "external_directory", matchedPattern: "*" },
|
||||
{
|
||||
evidence: [
|
||||
{ label: "working directory", text: "/repo", detail: null },
|
||||
{ label: "external path", text: "/etc/hosts", detail: null },
|
||||
],
|
||||
annotations: [{ source: "judge", text: "looks risky" }],
|
||||
},
|
||||
),
|
||||
),
|
||||
).toEqual({ surface: "external_directory", matchedPattern: "*" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildSkillAskPayload,
|
||||
buildSkillPathAskPayload,
|
||||
} from "#src/presentation/skill-ask-payload";
|
||||
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
||||
|
||||
function skillEntry(name: string): SkillPromptEntry {
|
||||
return {
|
||||
name,
|
||||
description: "A skill",
|
||||
location: `/skills/${name}/SKILL.md`,
|
||||
state: "ask",
|
||||
normalizedLocation: `/skills/${name}/SKILL.md`,
|
||||
normalizedBaseDir: `/skills/${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSkillAskPayload", () => {
|
||||
test("makes the skill the decision-relevant value", () => {
|
||||
const payload = buildSkillAskPayload("librarian", "my-agent");
|
||||
|
||||
expect(payload.kind).toBe("skill");
|
||||
expect(payload.request).toEqual({
|
||||
requester: { agentName: "my-agent", forwarded: false, sessionId: null },
|
||||
surface: "skill",
|
||||
toolName: null,
|
||||
invokedToolName: null,
|
||||
value: "librarian",
|
||||
matchedPattern: null,
|
||||
commandContext: null,
|
||||
executedUnit: null,
|
||||
});
|
||||
expect(payload.evidence).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves the requester unnamed when no agent is active", () => {
|
||||
expect(buildSkillAskPayload("librarian", null).request.requester).toEqual({
|
||||
agentName: null,
|
||||
forwarded: false,
|
||||
sessionId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSkillPathAskPayload", () => {
|
||||
test("keeps the skill as the value and the path as evidence", () => {
|
||||
const payload = buildSkillPathAskPayload(
|
||||
skillEntry("librarian"),
|
||||
"/skills/librarian/SKILL.md",
|
||||
null,
|
||||
);
|
||||
|
||||
expect(payload.kind).toBe("skill_read");
|
||||
expect(payload.request.value).toBe("librarian");
|
||||
expect(payload.evidence).toEqual([
|
||||
{
|
||||
label: "read path",
|
||||
text: "/skills/librarian/SKILL.md",
|
||||
detail: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("names the requesting agent on the payload", () => {
|
||||
expect(
|
||||
buildSkillPathAskPayload(
|
||||
skillEntry("librarian"),
|
||||
"/skills/librarian/SKILL.md",
|
||||
"my-agent",
|
||||
).request.requester.agentName,
|
||||
).toBe("my-agent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { findEvidence } from "#src/presentation/prompt-payload";
|
||||
import {
|
||||
buildToolAskPayload,
|
||||
type ToolAskFacts,
|
||||
} from "#src/presentation/tool-ask-payload";
|
||||
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
||||
import type { PermissionCheckResult } from "#src/types";
|
||||
import {
|
||||
makePermissionCheckResult,
|
||||
makeToolPreviewFormatter,
|
||||
} from "#test/helpers/presentation-fixtures";
|
||||
|
||||
function makeFormatter(lookup?: ToolInputFormatterLookup) {
|
||||
return makeToolPreviewFormatter({}, lookup);
|
||||
}
|
||||
|
||||
function makeMcpLookup(preview: string): ToolInputFormatterLookup {
|
||||
return { get: (name) => (name === "mcp" ? () => preview : undefined) };
|
||||
}
|
||||
|
||||
function toolResult(
|
||||
toolName: string,
|
||||
overrides: Partial<PermissionCheckResult> = {},
|
||||
): PermissionCheckResult {
|
||||
return makePermissionCheckResult(toolName, overrides);
|
||||
}
|
||||
|
||||
function mcpResult(
|
||||
target: string,
|
||||
overrides: Partial<PermissionCheckResult> = {},
|
||||
): PermissionCheckResult {
|
||||
return makePermissionCheckResult("mcp", { target, ...overrides });
|
||||
}
|
||||
|
||||
/** Build a payload the way the per-tool gate does, defaulting the surface. */
|
||||
function buildPayload(
|
||||
facts: Omit<ToolAskFacts, "surface" | "agentName"> & {
|
||||
surface?: string;
|
||||
agentName?: string | null;
|
||||
},
|
||||
) {
|
||||
return buildToolAskPayload({
|
||||
agentName: null,
|
||||
surface: facts.check.toolName,
|
||||
...facts,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildToolAskPayload", () => {
|
||||
describe("the invariant core", () => {
|
||||
test("carries the gate surface, matched rule, and offending command", () => {
|
||||
const payload = buildPayload({
|
||||
check: toolResult("bash", {
|
||||
command: "rm -rf foo",
|
||||
matchedPattern: "rm *",
|
||||
commandContext: "command_substitution",
|
||||
}),
|
||||
surface: "bash",
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("bash");
|
||||
expect(payload.request).toEqual({
|
||||
requester: { agentName: null, forwarded: false, sessionId: null },
|
||||
surface: "bash",
|
||||
toolName: "bash",
|
||||
invokedToolName: null,
|
||||
value: "rm -rf foo",
|
||||
matchedPattern: "rm *",
|
||||
commandContext: "command_substitution",
|
||||
executedUnit: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("carries the executed unit of a wrapper (#713)", () => {
|
||||
const payload = buildPayload({
|
||||
check: toolResult("bash", {
|
||||
command: "xargs grep foo",
|
||||
matchedPattern: "<indirection-bash-wrapper>",
|
||||
executedUnit: "grep foo",
|
||||
}),
|
||||
surface: "bash",
|
||||
});
|
||||
|
||||
expect(payload.request.executedUnit).toBe("grep foo");
|
||||
});
|
||||
|
||||
test("names the invoked tool when a shell alias re-exposes bash (#574)", () => {
|
||||
const payload = buildPayload({
|
||||
check: toolResult("bash", { command: "ls" }),
|
||||
surface: "bash",
|
||||
invokedToolName: "exec_command",
|
||||
});
|
||||
|
||||
expect(payload.request.toolName).toBe("bash");
|
||||
expect(payload.request.invokedToolName).toBe("exec_command");
|
||||
});
|
||||
|
||||
test("omits the invoked tool when it repeats the gated one", () => {
|
||||
const payload = buildPayload({
|
||||
check: toolResult("read"),
|
||||
invokedToolName: "read",
|
||||
});
|
||||
|
||||
expect(payload.request.invokedToolName).toBeNull();
|
||||
});
|
||||
|
||||
test("leaves the value empty for a bash check with no resolved command", () => {
|
||||
expect(buildPayload({ check: toolResult("bash") }).request.value).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("lands the annotations slot empty", () => {
|
||||
expect(buildPayload({ check: toolResult("read") }).annotations).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bash evidence", () => {
|
||||
test("names the agent when one is known", () => {
|
||||
expect(
|
||||
buildPayload({
|
||||
check: toolResult("read"),
|
||||
agentName: "my-agent",
|
||||
input: { path: "/src" },
|
||||
formatter: makeFormatter(),
|
||||
}).request.requester.agentName,
|
||||
).toBe("my-agent");
|
||||
});
|
||||
|
||||
test("carries the enclosing command when the gated unit is only part of it", () => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: toolResult("bash", { command: "rm -rf ." }),
|
||||
input: { command: 'echo "hello" && rm -rf .' },
|
||||
formatter: makeFormatter(),
|
||||
}),
|
||||
"full command",
|
||||
),
|
||||
).toEqual({
|
||||
label: "full command",
|
||||
text: 'echo "hello" && rm -rf .',
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("omits the enclosing command when it is the gated unit", () => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: toolResult("bash", { command: "git push" }),
|
||||
input: { command: "git push" },
|
||||
formatter: makeFormatter(),
|
||||
}),
|
||||
"full command",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["input is undefined", undefined],
|
||||
["input has no command field", { unrelated: "value" }],
|
||||
["input command is empty", { command: "" }],
|
||||
])("omits the enclosing command when %s", (_case, input) => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: toolResult("bash", { command: "git push" }),
|
||||
input,
|
||||
formatter: makeFormatter(),
|
||||
}),
|
||||
"full command",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("adds no input preview, since the command is the value", () => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: toolResult("bash", { command: "git status" }),
|
||||
input: { command: "git status" },
|
||||
formatter: makeFormatter(),
|
||||
}),
|
||||
"input",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp", () => {
|
||||
test("makes the target the decision-relevant value", () => {
|
||||
const payload = buildPayload({
|
||||
check: mcpResult("server:query", { matchedPattern: "server:*" }),
|
||||
formatter: makeFormatter(),
|
||||
});
|
||||
|
||||
expect(payload.kind).toBe("mcp");
|
||||
expect(payload.request.value).toBe("server:query");
|
||||
expect(payload.request.matchedPattern).toBe("server:*");
|
||||
});
|
||||
|
||||
test("carries the argument summary a registered formatter produced", () => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: mcpResult("exa:search"),
|
||||
input: { tool: "exa:search", arguments: { query: "typescript" } },
|
||||
formatter: makeFormatter(makeMcpLookup('with query: "typescript"')),
|
||||
}),
|
||||
"input",
|
||||
),
|
||||
).toEqual({
|
||||
label: "input",
|
||||
text: 'with query: "typescript"',
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("carries no evidence when the registered formatter declines", () => {
|
||||
const noArgsLookup: ToolInputFormatterLookup = {
|
||||
get: (name) => (name === "mcp" ? () => undefined : undefined),
|
||||
};
|
||||
|
||||
expect(
|
||||
buildPayload({
|
||||
check: mcpResult("exa:search"),
|
||||
input: { tool: "exa:search" },
|
||||
formatter: makeFormatter(noArgsLookup),
|
||||
}).evidence,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("carries no evidence when no formatter is provided", () => {
|
||||
expect(
|
||||
buildPayload({
|
||||
check: mcpResult("exa:search"),
|
||||
input: { tool: "exa:search", arguments: { query: "test" } },
|
||||
}).evidence,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic tools", () => {
|
||||
test("carries the real input preview as evidence", () => {
|
||||
expect(
|
||||
findEvidence(
|
||||
buildPayload({
|
||||
check: toolResult("read"),
|
||||
input: { path: "/src/foo.ts" },
|
||||
formatter: makeFormatter(),
|
||||
}),
|
||||
"input",
|
||||
)?.text,
|
||||
).toContain("path '/src/foo.ts'");
|
||||
});
|
||||
|
||||
test("carries no evidence when the formatter produces nothing", () => {
|
||||
expect(
|
||||
buildPayload({
|
||||
check: toolResult("task"),
|
||||
input: {},
|
||||
formatter: makeFormatter(),
|
||||
}).evidence,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("carries no evidence when no formatter is provided", () => {
|
||||
expect(
|
||||
buildPayload({ check: toolResult("task"), input: { path: "/src" } })
|
||||
.evidence,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user