Skip to content

Commit 0afb3e9

Browse files
docs: Add 'ancestors' field to CEL playground (#820)
1 parent a345be9 commit 0afb3e9

6 files changed

Lines changed: 127 additions & 36 deletions

File tree

docs/src/components/CELPlayground/autocompletion.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface CELCompletionItem {
1010
documentation?: string;
1111
insertText?: string;
1212
insertTextRules?: "insertAsSnippet";
13+
v2Only?: boolean;
1314
}
1415

1516
// CEL Macros - expanded at parse time into comprehensions
@@ -359,6 +360,7 @@ export const celWorkshopFunctions: CELCompletionItem[] = [
359360
"Returns REQUIRE_TOUCHID. The cooldown parameter specifies the number of minutes before TouchID is required again.",
360361
insertText: "require_touchid_with_cooldown_minutes(${1:minutes})",
361362
insertTextRules: "insertAsSnippet",
363+
v2Only: true,
362364
},
363365
{
364366
label: "require_touchid_only_with_cooldown_minutes",
@@ -369,6 +371,7 @@ export const celWorkshopFunctions: CELCompletionItem[] = [
369371
"Returns REQUIRE_TOUCHID_ONLY. The cooldown parameter specifies the number of minutes before TouchID is required again.",
370372
insertText: "require_touchid_only_with_cooldown_minutes(${1:minutes})",
371373
insertTextRules: "insertAsSnippet",
374+
v2Only: true,
372375
},
373376
];
374377

@@ -697,6 +700,11 @@ export interface CELVariable {
697700
name: string;
698701
type: CELVariableType;
699702
documentation?: string;
703+
dynamic?: boolean;
704+
v2Only?: boolean;
705+
// For list-type variables, describes the fields available on each element
706+
// (used for completions inside comprehensions like ancestors.filter(a, a.))
707+
itemFields?: CELVariable[];
700708
}
701709

702710
// Track registration state to prevent duplicate registrations
@@ -766,8 +774,13 @@ export function registerCELLanguage(
766774

767775
// Build a map of variable names to types for quick lookup
768776
const variableTypes = new Map<string, CELVariableType>();
777+
// Build a map of list variable names to their item fields
778+
const listItemFields = new Map<string, CELVariable[]>();
769779
for (const v of variables) {
770780
variableTypes.set(v.name, v.type);
781+
if (v.type === "list" && v.itemFields) {
782+
listItemFields.set(v.name, v.itemFields);
783+
}
771784
}
772785

773786
// Build general completion items (not after a dot)
@@ -920,17 +933,19 @@ export function registerCELLanguage(
920933
endColumn: position.column,
921934
});
922935

923-
// Check if we're typing after a dot (e.g., "args." or "target.signing_time.")
924-
// Captures the full dotted path before the final dot
936+
// Check if we're typing after a dot (e.g., "args.", "target.signing_time.", "ancestors[0].")
937+
// Captures the full expression before the final dot, including bracket indexing
925938
const dotMatch = textUntilPosition.match(
926-
/([\w]+(?:\.[\w]+)*)\.[\w]*$/,
939+
/([\w]+(?:\[[^\]]*\])*(?:\.[\w]+(?:\[[^\]]*\])*)*)\.[\w]*$/,
927940
);
928941
if (dotMatch) {
929942
const varPath = dotMatch[1];
930-
const varType = variableTypes.get(varPath);
943+
// Strip bracket indices to resolve the base variable name (e.g. "ancestors[0]" → "ancestors")
944+
const basePath = varPath.replace(/\[[^\]]*\]/g, "");
945+
const varType = variableTypes.get(basePath);
931946

932947
// Check for sub-field completions (e.g., "target." → "signing_time")
933-
const prefix = varPath + ".";
948+
const prefix = basePath + ".";
934949
const fieldCompletions: any[] = [];
935950
const seenFields = new Set<string>();
936951

@@ -961,6 +976,21 @@ export function registerCELLanguage(
961976
suggestions: [...fieldCompletions, ...mapMethodCompletions],
962977
};
963978
} else if (varType === "list") {
979+
// If indexing into the list (e.g. "ancestors[0]."), offer item fields
980+
if (varPath !== basePath) {
981+
const itemFields = listItemFields.get(basePath);
982+
if (itemFields) {
983+
return {
984+
suggestions: itemFields.map((f) => ({
985+
label: f.name,
986+
kind: monaco.languages.CompletionItemKind.Field,
987+
insertText: f.name,
988+
detail: f.type,
989+
documentation: f.documentation,
990+
})),
991+
};
992+
}
993+
}
964994
return {
965995
suggestions: [...fieldCompletions, ...listMethodCompletions],
966996
};
@@ -977,6 +1007,26 @@ export function registerCELLanguage(
9771007
return { suggestions: fieldCompletions };
9781008
}
9791009

1010+
// Check if this is a comprehension iteration variable
1011+
// e.g. "ancestors.filter(a, a." → varPath is "a", look back for "ancestors.filter(a,"
1012+
const comprehensionMatch = textUntilPosition.match(
1013+
/([\w]+)\.(?:all|exists|exists_one|filter|map|sortBy)\(\s*(\w+)\s*,/,
1014+
);
1015+
if (comprehensionMatch && comprehensionMatch[2] === varPath) {
1016+
const listName = comprehensionMatch[1];
1017+
const itemFields = listItemFields.get(listName);
1018+
if (itemFields) {
1019+
const itemFieldCompletions = itemFields.map((f) => ({
1020+
label: f.name,
1021+
kind: monaco.languages.CompletionItemKind.Field,
1022+
insertText: f.name,
1023+
detail: f.type,
1024+
documentation: f.documentation,
1025+
}));
1026+
return { suggestions: itemFieldCompletions };
1027+
}
1028+
}
1029+
9801030
// No type info: return fields if any, otherwise all methods as fallback
9811031
if (fieldCompletions.length > 0) {
9821032
return { suggestions: fieldCompletions };

docs/src/components/CELPlayground/constants.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { ReturnValueSchema as V2ReturnValueSchema } from "@buf/northpolesec_prot
33
import { CELVariable } from "./autocompletion";
44

55
export const VARIABLES: CELVariable[] = [
6-
{ name: "envs", type: "map", documentation: "Environment variables" },
7-
{ name: "args", type: "list", documentation: "Command line arguments" },
8-
{ name: "euid", type: "int", documentation: "Effective user ID" },
9-
{ name: "cwd", type: "string", documentation: "Current working directory" },
6+
{ name: "envs", type: "map", dynamic: true, documentation: "Environment variables" },
7+
{ name: "args", type: "list", dynamic: true, documentation: "Command line arguments" },
8+
{ name: "euid", type: "int", dynamic: true, documentation: "Effective user ID" },
9+
{ name: "cwd", type: "string", dynamic: true, documentation: "Current working directory" },
1010
{
1111
name: "target.signing_id",
1212
type: "string",
@@ -45,17 +45,27 @@ export const VARIABLES: CELVariable[] = [
4545
type: "string",
4646
documentation: "Require Touch ID only policy constant",
4747
},
48+
{
49+
name: "ancestors",
50+
type: "list",
51+
dynamic: true,
52+
v2Only: true,
53+
documentation:
54+
"List of ancestor processes in the execution chain. Each ancestor has signing_id, team_id, path, and cdhash fields.",
55+
itemFields: [
56+
{
57+
name: "signing_id",
58+
type: "string",
59+
documentation:
60+
"Signing ID of the ancestor binary, prefixed with Team ID or 'platform'",
61+
},
62+
{ name: "team_id", type: "string", documentation: "Team ID from code signature" },
63+
{ name: "path", type: "string", documentation: "Path to the ancestor binary" },
64+
{ name: "cdhash", type: "string", documentation: "Code directory hash" },
65+
],
66+
},
4867
];
4968

50-
export const DYNAMIC_FIELDS = ["args", "envs", "euid", "cwd"] as const;
51-
52-
export const V2_ONLY_FUNCTIONS = [
53-
"require_touchid_with_cooldown_minutes",
54-
"require_touchid_only_with_cooldown_minutes",
55-
] as const;
56-
57-
export const FUNCTIONS = ["timestamp", ...V2_ONLY_FUNCTIONS] as const;
58-
5969
// Build enum name→value and value→name maps from proto descriptors
6070
function enumEntries(schema: {
6171
values: readonly { name: string; number: number }[];
@@ -73,9 +83,3 @@ function enumEntries(schema: {
7383
export const v1Entries = enumEntries(V1ReturnValueSchema);
7484
export const v2Entries = enumEntries(V2ReturnValueSchema);
7585

76-
export const CONSTANT_NAMES = Object.keys(v2Entries.nameToValue);
77-
78-
// V2-only constant names (not in V1)
79-
export const V2_ONLY_CONSTANTS = new Set(
80-
CONSTANT_NAMES.filter((name) => !(name in v1Entries.nameToValue)),
81-
);

docs/src/components/CELPlayground/eslogger.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ describe("convertEsloggerEvent", () => {
4646
});
4747
});
4848

49+
it("always includes fake ancestors", () => {
50+
const result = toObject(convertEsloggerEvent(MINIMAL_EXEC_EVENT));
51+
expect(result.ancestors).toEqual([
52+
{
53+
signing_id: "platform:com.apple.Terminal",
54+
team_id: "",
55+
path: "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
56+
cdhash: "",
57+
},
58+
]);
59+
});
60+
4961
it("handles env values containing '='", () => {
5062
const event = JSON.stringify({
5163
event: {

docs/src/components/CELPlayground/eslogger.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,18 @@ export function convertEsloggerEvent(input: string): string {
6868
}
6969
}
7070

71-
// Make up signing times (eslogger doesn't include these)
71+
// Make up signing times and ancestors (eslogger events don't include these)
7272
context.target.signing_time = "2025-06-01T00:00:00Z";
7373
context.target.secure_signing_time = "2025-06-01T00:00:00Z";
74+
context.ancestors = [
75+
{
76+
signing_id: "platform:com.apple.Terminal",
77+
team_id: "",
78+
path: "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
79+
cdhash: "",
80+
},
81+
];
7482

7583
const yaml = stringifyYAML(context);
76-
return "# Note: signing times are fake — eslogger events don't include them\n" + yaml;
84+
return "# Note: signing times and ancestors are fake — eslogger events don't include them\n" + yaml;
7785
}

docs/src/components/CELPlayground/eval.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ describe("evaluate", () => {
9191
expect(result.isV2).toBe(true);
9292
});
9393

94+
it("detects V2 variable ancestors", () => {
95+
const yaml = `ancestors:\n - signing_id: "platform:com.apple.bash"\n path: "/bin/bash"`;
96+
const result = evaluate(
97+
'ancestors.exists(a, a.signing_id == "platform:com.apple.bash")',
98+
yaml,
99+
);
100+
expect(result.valid).toBe(true);
101+
expect(result.isV2).toBe(true);
102+
expect(result.cacheable).toBe(false);
103+
});
104+
94105
it("marks V1-only expressions as not V2", () => {
95106
const result = evaluate("ALLOWLIST", DEFAULT_YAML);
96107
expect(result.valid).toBe(true);

docs/src/components/CELPlayground/eval.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Environment } from "@marcbachmann/cel-js";
22
import { parse as parseYAML } from "yaml";
33
import {
4-
V2_ONLY_FUNCTIONS,
5-
DYNAMIC_FIELDS,
6-
V2_ONLY_CONSTANTS,
4+
VARIABLES,
5+
v1Entries,
76
v2Entries,
87
} from "./constants";
8+
import { celWorkshopFunctions } from "./autocompletion";
99

1010

1111
export const DEFAULT_EXPRESSION = `target.signing_time >= timestamp('2025-05-31T00:00:00Z')`;
@@ -18,7 +18,12 @@ args:
1818
envs:
1919
HOME: "/Users/user"
2020
euid: 501
21-
cwd: "/Users/user"`;
21+
cwd: "/Users/user"
22+
ancestors:
23+
- signing_id: "platform:com.apple.Terminal"
24+
team_id: ""
25+
path: "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"
26+
cdhash: "abc123"`;
2227

2328
function buildEnvironment(): Environment {
2429
const env = new Environment({ unlistedVariablesAreDyn: true });
@@ -29,6 +34,7 @@ function buildEnvironment(): Environment {
2934
env.registerVariable("envs", "map");
3035
env.registerVariable("euid", "int");
3136
env.registerVariable("cwd", "string");
37+
env.registerVariable("ancestors", "list");
3238

3339
// Register all V2 enum constants (superset of V1)
3440
for (const [name, value] of Object.entries(v2Entries.nameToValue)) {
@@ -109,17 +115,17 @@ function usesV2Features(
109115
identifiers: Set<string>,
110116
calls: Set<string>,
111117
): boolean {
112-
for (const name of V2_ONLY_CONSTANTS) {
113-
if (identifiers.has(name)) return true;
114-
}
115-
for (const fn of V2_ONLY_FUNCTIONS) {
116-
if (calls.has(fn)) return true;
118+
for (const name of Object.keys(v2Entries.nameToValue)) {
119+
if (!(name in v1Entries.nameToValue) && identifiers.has(name)) return true;
117120
}
121+
if (VARIABLES.some((v) => v.v2Only && identifiers.has(v.name))) return true;
122+
if (celWorkshopFunctions.some((f) => f.v2Only && calls.has(f.label)))
123+
return true;
118124
return false;
119125
}
120126

121127
function isCacheable(identifiers: Set<string>): boolean {
122-
return !DYNAMIC_FIELDS.some((field) => identifiers.has(field));
128+
return !VARIABLES.some((v) => v.dynamic && identifiers.has(v.name));
123129
}
124130

125131
export interface EvalResult {

0 commit comments

Comments
 (0)