Skip to content

Commit 434902f

Browse files
authored
Record a failed action the same way it was decided (#211)
* Record a failed action the same way it was decided * Record the audit row fix in the changelog
1 parent bc049c1 commit 434902f

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ the admin screen, which mints a token and points at the one it just made. If a d
5454
custom server pointing at a credential of another kind, adding it again will now be refused, and the
5555
answer is to give the server its own token.
5656

57+
### A failed action is recorded the same way it was decided
58+
59+
An action the policy allowed and the computer then failed is recorded twice, once for the decision
60+
and once for the outcome, so the trail can tell an action that happened from one that was permitted
61+
and did not. The second row was leaving out the command and the key that the first one carried.
62+
63+
A shell command that failed part-way therefore said a Bot had run something without saying what, in
64+
the row somebody reading an incident reaches for first. The same omission picked the wrong element
65+
branch, so that row also claimed the command had been looked for in the page snapshot and not found
66+
— a page element a shell call never had. A failed file write kept its path throughout and is
67+
unchanged.
68+
69+
Both rows now carry the same subject. Nothing about the boundary moves: the policy decided on a
70+
complete context before and after, and no action is permitted that was not permitted before.
71+
5772
### Upgrading
5873

5974
**A deployment that sets `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` with `NODE_ENV=production` no

‎server/src/computer/gateway.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,13 +534,22 @@ export function createComputerGateway(
534534
* Writing the decision before acting is still right, because an allowed action may have partial
535535
* effects before failing. The failure row records the outcome separately from the policy
536536
* decision.
537+
*
538+
* It carries the same subject the decision row did, and for the same reasons: a shell call
539+
* that failed part-way is the row somebody most needs to name the command from, and a keypress
540+
* that failed is still the difference between a submitted form and a typed letter. Leaving
541+
* them off also chose the wrong element branch below, because "this action never had an
542+
* element" is decided by the command and the file path — so a failed command claimed a
543+
* snapshot lookup that never happened.
537544
*/
538545
await write(auditStore, {
539546
toolName,
540547
botId,
541548
actor,
542549
element,
543550
ref,
551+
...(subject.key ? { key: subject.key } : {}),
552+
...(subject.command ? { command: subject.command } : {}),
544553
filePath,
545554
pageUrl,
546555
decision,

‎server/tests/computer-gateway.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ async function gatewayWith(
201201
resetResult?: { cleared: boolean };
202202
locations?: ComputerLocation[];
203203
token?: string;
204+
/** Endpoints the computer answers differently, for the calls that have to fail. */
205+
routes?: Record<
206+
string,
207+
(init?: RequestInit) => Response | Promise<Response>
208+
>;
204209
},
205210
) {
206211
const { provider, fetchImpl, calls, addressedAs, requests } =
@@ -444,6 +449,68 @@ describe("the computer gateway", () => {
444449
expect(rows[0]?.payload.command).toBe("cat secrets.txt");
445450
});
446451

452+
/**
453+
* The two rows describe one action, so they have to describe the same one.
454+
*
455+
* Asserted as agreement rather than field by field on purpose: the failure row is a second
456+
* hand-maintained argument list, and what goes wrong with one of those is not a particular field
457+
* being wrong, it is a field being added to one and not the other. Comparing the payloads catches
458+
* the next one too.
459+
*/
460+
test("a permitted action that fails is recorded the same way it was decided", async () => {
461+
const failing = () =>
462+
Response.json({ error: "device or resource busy" }, { status: 500 });
463+
464+
for (const action of [
465+
{
466+
what: "a command",
467+
route: "/exec",
468+
run: (gateway: Awaited<ReturnType<typeof gatewayWith>>["gateway"]) =>
469+
gateway.runCommand("bot-1", ACTOR, {
470+
command: "rm -rf /workspace/build",
471+
}),
472+
},
473+
{
474+
what: "a keypress",
475+
route: "/key",
476+
run: (gateway: Awaited<ReturnType<typeof gatewayWith>>["gateway"]) =>
477+
gateway.key("bot-1", ACTOR, {
478+
ref: "e1",
479+
snapshotId: 7,
480+
key: "Enter",
481+
}),
482+
},
483+
{
484+
what: "a file write",
485+
route: "/files/write",
486+
run: (gateway: Awaited<ReturnType<typeof gatewayWith>>["gateway"]) =>
487+
gateway.writeFile("bot-1", ACTOR, { path: "notes.md", text: "kept" }),
488+
},
489+
]) {
490+
const { gateway, rows } = await gatewayWith(PERMISSIVE, {
491+
routes: { [action.route]: failing },
492+
});
493+
rows.length = 0;
494+
495+
await expect(action.run(gateway)).rejects.toThrow();
496+
497+
const allowed = rows.find(
498+
(row) => row.eventType === "computer.action_allowed",
499+
);
500+
const failed = rows.find(
501+
(row) => row.eventType === "computer.action_failed",
502+
);
503+
expect(allowed, action.what).toBeDefined();
504+
expect(failed, action.what).toBeDefined();
505+
506+
// The outcome is the only thing the failure row adds. Everything describing what was attempted
507+
// is the same action and reads the same on both rows.
508+
const { failure, ...attempted } = failed?.payload ?? {};
509+
expect(failure, action.what).toBeString();
510+
expect(attempted, action.what).toEqual(allowed?.payload ?? {});
511+
}
512+
});
513+
447514
test("the computer is told WHICH Bot is asking", async () => {
448515
// Every per-Bot behaviour on the computer keys off this id: the profile it opens, the logins it
449516
// has, the proxy its traffic leaves through, and who holds its wheel.

0 commit comments

Comments
 (0)