From e5f9624722c8e7a0d5f36fa1acd7eff0e6e71d0f Mon Sep 17 00:00:00 2001 From: Oded Goldglas Date: Mon, 10 Aug 2026 11:33:54 +0300 Subject: [PATCH] feat: explore_api result analysis in atp-compiler (collectExploreOperations/filterExploreResult) Discovery-side counterpart to analyzeApiCalls: a client/gateway proxying a remote ATP server it doesn't run itself can now collect the {apiGroup, operationId} leaves in an explore_api result and narrow that result to an allow-list, without hand-rolling ATP's directory/function shape and /apiGroup/... path convention itself. - collectExploreOperations / collectExploreOperationsFromValue: derive DetectedApiCall[] from an ExploreResult (or an array of them, matching explore_api's paths input shape). - filterExploreResult / filterExploreResultValue: narrow a result to an allow-list. A denied function result returns null (mirrors ExplorerService.explore()'s own convention) rather than a synthesized stand-in, leaving wire representation to the caller. --- .../unit/explore-result-analyzer.test.ts | 261 ++++++++++++++++++ .../src/explore-result-analyzer.ts | 188 +++++++++++++ packages/atp-compiler/src/index.ts | 8 + 3 files changed, 457 insertions(+) create mode 100644 packages/atp-compiler/__tests__/unit/explore-result-analyzer.test.ts create mode 100644 packages/atp-compiler/src/explore-result-analyzer.ts diff --git a/packages/atp-compiler/__tests__/unit/explore-result-analyzer.test.ts b/packages/atp-compiler/__tests__/unit/explore-result-analyzer.test.ts new file mode 100644 index 0000000..2287748 --- /dev/null +++ b/packages/atp-compiler/__tests__/unit/explore-result-analyzer.test.ts @@ -0,0 +1,261 @@ +import { describe, test, expect } from '@jest/globals'; +import { + collectExploreOperations, + collectExploreOperationsFromValue, + filterExploreResult, + filterExploreResultValue, +} from '../../src/explore-result-analyzer'; +import type { DetectedApiCall } from '../../src/api-call-analyzer'; +import type { ExploreDirectoryResult, ExploreFunctionResult } from '@mondaydotcomorg/atp-protocol'; + +const ALWAYS_ALLOW: DetectedApiCall[] = [ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + { apiGroup: 'captivateiq', operationId: 'deletePlan' }, + { apiGroup: 'ziphq', operationId: 'getInvoice' }, + { apiGroup: 'ziphq', operationId: 'deleteInvoice' }, +]; +const ALWAYS_DENY: DetectedApiCall[] = []; + +describe('collectExploreOperations', () => { + test('collects one candidate per type:function item in a directory result with a derivable apiGroup', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '/captivateiq', + items: [ + { name: 'listPlans', type: 'function', description: 'list plans' }, + { name: 'reports', type: 'directory', description: 'nested group' }, + { name: 'deletePlan', type: 'function', description: 'delete a plan' }, + ], + }; + + const candidates = collectExploreOperations(listing); + + expect(candidates).toEqual([ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + { apiGroup: 'captivateiq', operationId: 'deletePlan' }, + ]); + }); + + test('collects nothing for a directory result with no derivable apiGroup (root path)', () => { + const rootListing: ExploreDirectoryResult = { + type: 'directory', + path: '/', + items: [ + { name: 'captivateiq', type: 'directory' }, + { name: 'someLooseFunction', type: 'function' }, + ], + }; + + expect(collectExploreOperations(rootListing)).toEqual([]); + }); + + test('collects nothing for a directory result at the empty-string path', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '', + items: [{ name: 'loose', type: 'function' }], + }; + + expect(collectExploreOperations(listing)).toEqual([]); + }); + + test('collects exactly one candidate for a direct function result, using its own group/name', () => { + const fn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/listPlans', + name: 'listPlans', + description: 'list plans', + definition: 'function listPlans(): Plan[]', + group: 'captivateiq', + }; + + expect(collectExploreOperations(fn)).toEqual([ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + ]); + }); +}); + +describe('filterExploreResult — directory listings', () => { + test('keeps an allowed function item and drops a disallowed one', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '/captivateiq', + items: [ + { name: 'listPlans', type: 'function', description: 'list plans' }, + { name: 'deletePlan', type: 'function', description: 'delete a plan' }, + ], + }; + + const filtered = filterExploreResult(listing, [ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + ]) as ExploreDirectoryResult; + + expect(filtered.items).toEqual([ + { name: 'listPlans', type: 'function', description: 'list plans' }, + ]); + }); + + test('always keeps directory entries regardless of the allow-list', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '/captivateiq', + items: [{ name: 'reports', type: 'directory', description: 'nested group' }], + }; + + const filteredDenied = filterExploreResult(listing, ALWAYS_DENY) as ExploreDirectoryResult; + const filteredAllowed = filterExploreResult(listing, ALWAYS_ALLOW) as ExploreDirectoryResult; + + expect(filteredDenied.items).toEqual(listing.items); + expect(filteredAllowed.items).toEqual(listing.items); + }); + + test('does not mutate the input result', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '/captivateiq', + items: [{ name: 'listPlans', type: 'function' }], + }; + const original = JSON.parse(JSON.stringify(listing)); + + filterExploreResult(listing, ALWAYS_DENY); + + expect(listing).toEqual(original); + }); + + test('drops a function item at the root path even when the allow-list always allows (no derivable apiGroup)', () => { + const rootListing: ExploreDirectoryResult = { + type: 'directory', + path: '/', + items: [ + { name: 'captivateiq', type: 'directory' }, + { name: 'someLooseFunction', type: 'function' }, + ], + }; + + const filtered = filterExploreResult(rootListing, ALWAYS_ALLOW) as ExploreDirectoryResult; + + expect(filtered.items).toEqual([{ name: 'captivateiq', type: 'directory' }]); + }); + + test('drops a function item at an empty-string path the same way as root', () => { + const listing: ExploreDirectoryResult = { + type: 'directory', + path: '', + items: [{ name: 'loose', type: 'function' }], + }; + + const filtered = filterExploreResult(listing, ALWAYS_ALLOW) as ExploreDirectoryResult; + + expect(filtered.items).toEqual([]); + }); +}); + +describe('filterExploreResult — direct function results', () => { + test('passes an allowed function result through unchanged', () => { + const fn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/listPlans', + name: 'listPlans', + description: 'list plans', + definition: 'function listPlans(): Plan[]', + group: 'captivateiq', + }; + + const filtered = filterExploreResult(fn, [ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + ]); + + expect(filtered).toEqual(fn); + }); + + test('returns null for a disallowed function result', () => { + const fn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/deletePlan', + name: 'deletePlan', + description: 'delete a plan', + definition: 'function deletePlan(id: string): void', + group: 'captivateiq', + }; + + const filtered = filterExploreResult(fn, ALWAYS_DENY); + + expect(filtered).toBeNull(); + }); +}); + +describe('collectExploreOperationsFromValue / filterExploreResultValue', () => { + test('collects candidates across an array-of-results payload', () => { + const fn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/listPlans', + name: 'listPlans', + description: 'list plans', + definition: 'function listPlans(): Plan[]', + group: 'captivateiq', + }; + const dir: ExploreDirectoryResult = { + type: 'directory', + path: '/ziphq', + items: [{ name: 'getInvoice', type: 'function' }], + }; + + expect(collectExploreOperationsFromValue([fn, dir])).toEqual([ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + { apiGroup: 'ziphq', operationId: 'getInvoice' }, + ]); + }); + + test('maps over an array of ExploreResults with mixed allow/deny, nulling out a denied function', () => { + const allowedFn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/listPlans', + name: 'listPlans', + description: 'list plans', + definition: 'function listPlans(): Plan[]', + group: 'captivateiq', + }; + const deniedFn: ExploreFunctionResult = { + type: 'function', + path: '/captivateiq/deletePlan', + name: 'deletePlan', + description: 'delete a plan', + definition: 'function deletePlan(id: string): void', + group: 'captivateiq', + }; + const dir: ExploreDirectoryResult = { + type: 'directory', + path: '/ziphq', + items: [ + { name: 'getInvoice', type: 'function' }, + { name: 'deleteInvoice', type: 'function' }, + ], + }; + const allowed: DetectedApiCall[] = [ + { apiGroup: 'captivateiq', operationId: 'listPlans' }, + { apiGroup: 'ziphq', operationId: 'getInvoice' }, + ]; + + const filtered = filterExploreResultValue([allowedFn, deniedFn, dir], allowed); + + expect(filtered).toEqual([ + allowedFn, + null, + { type: 'directory', path: '/ziphq', items: [{ name: 'getInvoice', type: 'function' }] }, + ]); + }); + + test('returns an unrecognizable value unchanged, and collects nothing from it', () => { + const foreign = { foo: 'bar' }; + + expect(collectExploreOperationsFromValue(foreign)).toEqual([]); + expect(filterExploreResultValue(foreign, ALWAYS_DENY)).toEqual(foreign); + }); + + test('returns primitives and null unchanged', () => { + expect(filterExploreResultValue(null, ALWAYS_DENY)).toBeNull(); + expect(filterExploreResultValue('not an explore result', ALWAYS_DENY)).toBe( + 'not an explore result' + ); + }); +}); diff --git a/packages/atp-compiler/src/explore-result-analyzer.ts b/packages/atp-compiler/src/explore-result-analyzer.ts new file mode 100644 index 0000000..c5dace3 --- /dev/null +++ b/packages/atp-compiler/src/explore-result-analyzer.ts @@ -0,0 +1,188 @@ +/** + * Post-fetch static analysis of an already-parsed `explore_api` result. + * + * Extracts every `{apiGroup, operationId}` leaf reachable in a directory + * listing or a direct function result, and narrows such a result down to + * only the operations a caller-supplied allow-list grants. + * + * Intended caller: a client or gateway that receives `explore_api`'s + * response over the wire (e.g. proxying a remote ATP server it does not + * itself run) and needs to re-check the result against its own grant model + * before handing it to an agent — the discovery-side counterpart to + * `analyzeApiCalls`, which does the same job for `execute_code`'s request + * payload instead of `explore_api`'s response payload. Paired with runtime + * `filterApiGroups` enforcement in atp-server for defense-in-depth, exactly + * like `analyzeApiCalls` is. + * + * @example + * const candidates = collectExploreOperations(exploreResult); + * const allowed = candidates.filter((op) => isGranted(op.apiGroup, op.operationId)); + * const narrowed = filterExploreResult(exploreResult, allowed); + */ + +import type { + ExploreResult, + ExploreDirectoryResult, + ExploreFunctionResult, +} from '@mondaydotcomorg/atp-protocol'; +import type { DetectedApiCall } from './api-call-analyzer.js'; + +function operationKey(op: DetectedApiCall): string { + return `${op.apiGroup} ${op.operationId}`; +} + +function toAllowedSet(allowedOperations: readonly DetectedApiCall[]): ReadonlySet { + return new Set(allowedOperations.map(operationKey)); +} + +/** + * First non-empty `/`-separated segment of an ATP explore path, or `undefined` + * if there isn't one (root path `/` or `''`). This is the apiGroup for any + * directory listing at that path, per ATP's hierarchical `/apiGroup/...` + * convention. + */ +function firstPathSegment(path: string): string | undefined { + return path.split('/').find((part) => part.length > 0); +} + +/** + * Every leaf `{apiGroup, operationId}` operation reachable in an + * already-parsed `explore_api` result — the finite candidate set a caller + * needs a real grant decision for, as opposed to the unbounded "every + * operation that could ever exist" question an app-wide query would face. + * Directories contribute nothing on their own — only their `type:'function'` + * items, and a direct function result, are leaves. A leaf with no derivable + * apiGroup (see `firstPathSegment`) is skipped rather than guessed at. + */ +export function collectExploreOperations(result: ExploreResult): DetectedApiCall[] { + if (result.type === 'directory') { + const apiGroup = firstPathSegment(result.path); + if (apiGroup === undefined) { + return []; + } + return result.items + .filter((item) => item.type === 'function') + .map((item) => ({ apiGroup, operationId: item.name })); + } + return [{ apiGroup: result.group, operationId: result.name }]; +} + +/** + * Narrows an already-parsed `explore_api` result to only the operations + * present in `allowedOperations`. The call to `explore_api` itself is never + * denied by this function — only what it reveals is. A directory listing is + * always returned (possibly with fewer `items`); a direct function result + * that isn't granted comes back as `null` rather than a synthesized stand-in + * — mirroring `ExplorerService.explore()`'s own convention in atp-server — + * so callers decide for themselves how "not visible" should look on the wire. + */ +export function filterExploreResult( + result: ExploreResult, + allowedOperations: readonly DetectedApiCall[] +): ExploreResult | null { + const allowedOps = toAllowedSet(allowedOperations); + if (result.type === 'directory') { + return filterDirectoryResult(result, allowedOps); + } + return filterFunctionResult(result, allowedOps); +} + +/** + * Directory entries of `type:'directory'` are always kept, never filtered + * here: a subdirectory's own contents are only resolved lazily when the + * caller descends into it with a follow-up `explore_api` call on that path, + * at which point *that* call's result goes through this same filter. + * + * `type:'function'` items are leaves, checkable right now. Their apiGroup + * isn't carried on the item itself — it's derived from the listing's own + * `path`. A listing at the root (`/` or `''`) has no apiGroup segment at + * all, so any function item found directly there is dropped unconditionally + * (it was never a candidate in `collectExploreOperations` either). + */ +function filterDirectoryResult( + result: ExploreDirectoryResult, + allowedOps: ReadonlySet +): ExploreDirectoryResult { + const apiGroup = firstPathSegment(result.path); + const items = result.items.filter((item) => { + if (item.type === 'directory') { + return true; + } + return ( + apiGroup !== undefined && allowedOps.has(operationKey({ apiGroup, operationId: item.name })) + ); + }); + return { ...result, items }; +} + +/** + * A direct hit on a function's own path carries its apiGroup explicitly + * (`group`), so no path-derivation guesswork is needed here. + */ +function filterFunctionResult( + result: ExploreFunctionResult, + allowedOps: ReadonlySet +): ExploreResult | null { + if (!allowedOps.has(operationKey({ apiGroup: result.group, operationId: result.name }))) { + return null; + } + return { ...result }; +} + +function isExploreDirectoryResult(value: unknown): value is ExploreDirectoryResult { + return ( + typeof value === 'object' && + value !== null && + (value as { type?: unknown }).type === 'directory' && + Array.isArray((value as { items?: unknown }).items) + ); +} + +function isExploreFunctionResult(value: unknown): value is ExploreFunctionResult { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as { type?: unknown; group?: unknown; name?: unknown }; + return ( + candidate.type === 'function' && + typeof candidate.group === 'string' && + typeof candidate.name === 'string' + ); +} + +/** + * Collects candidates across an already-JSON-parsed `explore_api` payload, + * which may be a single result or an array of them (the tool's `paths` + * input accepts a string or an array of strings, so its response shape + * mirrors that 1:1). Anything that isn't recognizable as an `ExploreResult` + * contributes nothing. + */ +export function collectExploreOperationsFromValue(value: unknown): DetectedApiCall[] { + if (Array.isArray(value)) { + return value.flatMap(collectExploreOperationsFromValue); + } + if (isExploreDirectoryResult(value) || isExploreFunctionResult(value)) { + return collectExploreOperations(value); + } + return []; +} + +/** + * Applies `filterExploreResult` to an already-JSON-parsed `explore_api` + * payload, which may be a single result or an array of them. Anything that + * isn't recognizable as an `ExploreResult` (wrong shape, foreign JSON, etc.) + * is returned unchanged — this function only knows how to filter what it can + * positively identify. + */ +export function filterExploreResultValue( + value: unknown, + allowedOperations: readonly DetectedApiCall[] +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => filterExploreResultValue(entry, allowedOperations)); + } + if (isExploreDirectoryResult(value) || isExploreFunctionResult(value)) { + return filterExploreResult(value, allowedOperations); + } + return value; +} diff --git a/packages/atp-compiler/src/index.ts b/packages/atp-compiler/src/index.ts index 47b89b9..062f317 100644 --- a/packages/atp-compiler/src/index.ts +++ b/packages/atp-compiler/src/index.ts @@ -16,6 +16,14 @@ export * from './checkpoint/index.js'; export { analyzeApiCalls } from './api-call-analyzer.js'; export type { DetectedApiCall, AnalysisResult } from './api-call-analyzer.js'; +// Post-fetch static analysis of an explore_api result (discovery-side counterpart to analyzeApiCalls). +export { + collectExploreOperations, + collectExploreOperationsFromValue, + filterExploreResult, + filterExploreResultValue, +} from './explore-result-analyzer.js'; + // Main exports export { ATPCompiler } from './transformer/index.js'; export { initializeRuntime, cleanupRuntime } from './runtime/index.js';