Skip to content

Commit 414aba3

Browse files
beardtheliondavidmckayv
authored andcommitted
Refuse on a deny rule that never answers the question
`matches` read the result of an expression as `evaluate(...) === true`, so a rule that parsed and evaluated but answered with something other than a boolean was neither a match nor an error. In the deny list that meant it did not deny, and the permissive `allow: ["true"]` that ships by default then let the action through. `deny: ["Submit order"]` is the way in. It is what somebody writes who reads the list as labels rather than expressions, and it is a valid CEL string, so it evaluates to "Submit order", falls out of the deny loop, and the Bot clicks the button. Nothing was logged, because only the throwing path logged, and the rule still sat on the Boundaries page looking as though it were in force. A bare field reference, a ternary returning a string and a bare number all land the same way. Treat any non-boolean answer as a broken rule and send it down the existing fail-closed path, which denies in the deny list, does not permit in the allow list, and logs either way. False stays a real answer: a deny list that read every false as a denial would refuse everything. Checked against every rule the product ships, the .env.example example and the four Boundaries presets, over contexts with and without an element, a key and a file. None of them changes verdict.
1 parent f725fb5 commit 414aba3

2 files changed

Lines changed: 66 additions & 5 deletions

File tree

server/src/computer/policy.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,20 +190,38 @@ const POLICY_FUNCTIONS: Record<string, (...args: never[]) => unknown> = {
190190
* `onError` decides what a broken expression means, because the safe answer differs by list: a broken
191191
* `allow` must not permit, and a broken `deny` must not stop denying. Both are logged loudly, because
192192
* a policy that silently misbehaves is worse than one that visibly refuses.
193+
*
194+
* A rule can be broken two ways and only one of them throws. `"Submit order"` is valid CEL: it parses,
195+
* it evaluates, and it answers with a string, which is not an answer to "does this rule apply". That
196+
* is what somebody writes who reads the deny list as a list of labels rather than expressions, and
197+
* reading it as "no match" would let the action through under the permissive allow rule that ships by
198+
* default, with nothing logged and the rule still listed on the Boundaries page as though it were in
199+
* force. So anything other than a boolean is a broken rule, and takes the same fail-closed path as a
200+
* throw. False is a real answer and stays one; a deny list that read every false as a denial would
201+
* refuse everything.
193202
*/
194203
function matches(
195204
expression: string,
196205
context: PolicyContext,
197206
onError: boolean,
198207
): boolean {
199208
try {
200-
return (
201-
evaluate(
209+
const result = evaluate(
210+
expression,
211+
context as unknown as Record<string, unknown>,
212+
POLICY_FUNCTIONS as Record<string, CallableFunction>,
213+
);
214+
if (typeof result === "boolean") return result;
215+
216+
console.error(
217+
JSON.stringify({
218+
type: "computer-policy-expression-error",
202219
expression,
203-
context as unknown as Record<string, unknown>,
204-
POLICY_FUNCTIONS as Record<string, CallableFunction>,
205-
) === true
220+
error: `expected a true or false answer, got ${result === null ? "null" : typeof result}`,
221+
treatedAs: onError,
222+
}),
206223
);
224+
return onError;
207225
} catch (error) {
208226
console.error(
209227
JSON.stringify({

server/tests/computer-policy.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,49 @@ describe("evaluateActionPolicy", () => {
8787
expect(decision.source).toBe("deny");
8888
});
8989

90+
// The other way a rule is broken. These parse and evaluate, so nothing throws; they simply do not
91+
// answer the question that was asked, and the only safe reading of a deny rule that did not answer
92+
// is that it denied. `"Submit order"` is what somebody writes who thinks the list takes labels
93+
// rather than expressions, and it is a valid CEL string.
94+
test.each([
95+
['"Submit order"', "a bare string, i.e. the list read as labels"],
96+
["element.name", "a bare field reference"],
97+
['contains(element.name, "submit") ? element.name : false', "a ternary"],
98+
["repeat.count", "a number"],
99+
])(
100+
"a deny expression that is not a question (%s: %s) still denies",
101+
(rule) => {
102+
const decision = evaluateActionPolicy(
103+
{ ...permissive, deny: [rule] },
104+
context(),
105+
);
106+
expect(decision.allowed).toBe(false);
107+
expect(decision.source).toBe("deny");
108+
},
109+
);
110+
111+
// The mirror. A rule that does not answer must not permit either, which is what this already did by
112+
// reading anything other than true as no match.
113+
test("an allow expression that is not a question does not permit", () => {
114+
const decision = evaluateActionPolicy(
115+
{ mode: "enforce", deny: [], allow: ['"Submit order"'] },
116+
context(),
117+
);
118+
expect(decision.allowed).toBe(false);
119+
expect(decision.source).toBe("default");
120+
});
121+
122+
// A rule that answers "no" is not broken, and must not be read as one: a deny list where every
123+
// false reading became a denial would refuse everything.
124+
test("a deny expression that answers false permits", () => {
125+
const decision = evaluateActionPolicy(
126+
{ ...permissive, deny: ['contains(element.name, "cancel")'] },
127+
context(),
128+
);
129+
expect(decision.allowed).toBe(true);
130+
expect(decision.source).toBe("allow");
131+
});
132+
90133
test("a broken allow expression does not permit", () => {
91134
const decision = evaluateActionPolicy(
92135
{ mode: "enforce", deny: [], allow: ["also not ( valid"] },

0 commit comments

Comments
 (0)