troxy-cli 1.21.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/troxy.js +92 -12
- package/package.json +7 -4
- package/src/api.js +18 -0
- package/src/data/policy-spec.json +496 -0
- package/src/data/templates.json +1429 -0
- package/src/init.js +11 -0
- package/src/mcp-server.js +81 -0
- package/src/policies.js +341 -46
- package/src/policy-fields.js +107 -0
- package/src/simulate.js +251 -0
- package/src/tests/mcp-checkpoints.test.js +79 -0
- package/src/tests/model-checkpoint.test.js +5 -1
- package/src/tests/policies-create.test.js +218 -0
- package/src/tests/policies-org.test.js +60 -0
- package/src/tests/simulate.test.js +127 -0
- package/src/tests/templates.test.js +46 -0
- package/src/tool_detect.js +179 -0
package/src/init.js
CHANGED
|
@@ -302,6 +302,17 @@ export async function runInit({ key, name } = {}) {
|
|
|
302
302
|
|
|
303
303
|
await reprovisionKeyConsumers(key, agentName);
|
|
304
304
|
|
|
305
|
+
// Scan for local AI coding tools and (interactively) confirm the plan for
|
|
306
|
+
// each, so the dashboard shows real subscription cost, not token math.
|
|
307
|
+
// Non-fatal + TTY-gated inside runToolDetection - a scripted init never
|
|
308
|
+
// hangs and a scan failure never breaks setup.
|
|
309
|
+
try {
|
|
310
|
+
const { runToolDetection } = await import('./tool_detect.js');
|
|
311
|
+
await runToolDetection(key, { interactive: true });
|
|
312
|
+
} catch {
|
|
313
|
+
// Tool detection is a bonus, never a blocker for a successful init.
|
|
314
|
+
}
|
|
315
|
+
|
|
305
316
|
console.log('\n Your payments are now protected.');
|
|
306
317
|
console.log(' Dashboard → https://dash.troxy.io');
|
|
307
318
|
console.log('\n For more information, visit https://docs.troxy.io\n');
|
package/src/mcp-server.js
CHANGED
|
@@ -12,6 +12,13 @@ const evaluateLogin = (body, apiKey) => api.evaluateLogin(body, apiKey);
|
|
|
12
12
|
const evaluateAction = (body, apiKey) => api.evaluateAction(body, apiKey);
|
|
13
13
|
const evaluateModel = (body, apiKey) => api.evaluateModel(body, apiKey);
|
|
14
14
|
const reportModelUsage = (body, apiKey) => api.reportModelUsage(body, apiKey);
|
|
15
|
+
// Was missing entirely until 2026-08-15: every other checkpoint had an MCP
|
|
16
|
+
// tool, this one did not, so no MCP-connected agent could ever call
|
|
17
|
+
// /evaluate/secrets - not "the agent chose not to," there was nothing to
|
|
18
|
+
// call. evaluate.py's other_checkpoints discovery (every response advertises
|
|
19
|
+
// the other five) does mention it, but as a raw HTTP URL; useless to an
|
|
20
|
+
// agent whose only way to reach Troxy is through the tools listed here.
|
|
21
|
+
const evaluateSecrets = (body, apiKey) => api.evaluateSecrets(body, apiKey);
|
|
15
22
|
|
|
16
23
|
export async function runMcp() {
|
|
17
24
|
const config = loadConfig();
|
|
@@ -374,6 +381,48 @@ export async function runMcp() {
|
|
|
374
381
|
},
|
|
375
382
|
},
|
|
376
383
|
},
|
|
384
|
+
{
|
|
385
|
+
name: 'evaluate_secrets',
|
|
386
|
+
description:
|
|
387
|
+
'Evaluate whether content you are about to send, write, or publish should be allowed, blocked, or ' +
|
|
388
|
+
'escalated based on your Troxy policies. Troxy scans the content server-side for API keys, credit ' +
|
|
389
|
+
'card numbers, passwords, private keys, tokens, and personally identifiable information - you do ' +
|
|
390
|
+
'not pre-classify it yourself. Call this BEFORE you share a file, send a message, push code, upload ' +
|
|
391
|
+
'data, or write content to any external destination - anything that leaves the current context. ' +
|
|
392
|
+
'If you are unsure whether something counts as sensitive, call this anyway.',
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
required: ['content'],
|
|
396
|
+
properties: {
|
|
397
|
+
content: {
|
|
398
|
+
type: 'string',
|
|
399
|
+
description: 'The text, code, or data you are about to send or write.',
|
|
400
|
+
},
|
|
401
|
+
destination: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'Where it is going: a person, a repo, a channel, a service, or "public" if anyone can see it.',
|
|
404
|
+
},
|
|
405
|
+
action: {
|
|
406
|
+
type: 'string',
|
|
407
|
+
enum: ['send', 'push', 'upload', 'write', 'share'],
|
|
408
|
+
description: 'What you are doing with it.',
|
|
409
|
+
},
|
|
410
|
+
content_type: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
enum: ['code', 'message', 'file', 'config', 'log', 'other'],
|
|
413
|
+
description: 'What it is.',
|
|
414
|
+
},
|
|
415
|
+
agent: {
|
|
416
|
+
type: 'string',
|
|
417
|
+
description: 'Name of the agent (optional).',
|
|
418
|
+
},
|
|
419
|
+
approval_token: {
|
|
420
|
+
type: 'string',
|
|
421
|
+
description: 'Approval token from a previous ESCALATE response. Include this to proceed after the user has approved.',
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
},
|
|
377
426
|
],
|
|
378
427
|
}));
|
|
379
428
|
|
|
@@ -608,6 +657,38 @@ export async function runMcp() {
|
|
|
608
657
|
};
|
|
609
658
|
}
|
|
610
659
|
|
|
660
|
+
if (toolName === 'evaluate_secrets') {
|
|
661
|
+
if (agentName && !args.agent) args.agent = agentName;
|
|
662
|
+
let result;
|
|
663
|
+
try {
|
|
664
|
+
result = await evaluateSecrets(args, apiKey);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
return { content: [{ type: 'text', text: `Troxy error: ${err.message}` }], isError: true };
|
|
667
|
+
}
|
|
668
|
+
if (result.error) {
|
|
669
|
+
return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
|
|
670
|
+
}
|
|
671
|
+
const { decision, reason, audit_id, approval_token, detections } = result;
|
|
672
|
+
const found = (detections || []).map(d => d.type).join(', ');
|
|
673
|
+
const detail = found ? ` Found: ${found}.` : '';
|
|
674
|
+
let secretsText;
|
|
675
|
+
switch (decision) {
|
|
676
|
+
case 'ALLOW':
|
|
677
|
+
case 'NOTIFY':
|
|
678
|
+
secretsText = `✓ Approved.${reason ? ` ${reason}` : ''}${detail} You may proceed. (audit: ${audit_id})`;
|
|
679
|
+
break;
|
|
680
|
+
case 'BLOCK':
|
|
681
|
+
secretsText = `✗ Blocked.${reason ? ` ${reason}` : ''}${detail} Do not send, write, or publish this content as-is. (audit: ${audit_id})`;
|
|
682
|
+
break;
|
|
683
|
+
case 'ESCALATE':
|
|
684
|
+
secretsText = `⏳ This requires human approval; a request has been sent to the account owner.${detail}\n\nApproval token: ${approval_token}\n\nNow call wait_for_approval(approval_token="${approval_token}") to automatically detect approval, then call evaluate_secrets again with the same arguments PLUS this approval_token. Do not proceed until it returns approved.`;
|
|
685
|
+
break;
|
|
686
|
+
default:
|
|
687
|
+
secretsText = JSON.stringify(result);
|
|
688
|
+
}
|
|
689
|
+
return { content: [{ type: 'text', text: secretsText }], isError: decision === 'BLOCK' };
|
|
690
|
+
}
|
|
691
|
+
|
|
611
692
|
if (toolName !== 'evaluate_payment') {
|
|
612
693
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
613
694
|
}
|
package/src/policies.js
CHANGED
|
@@ -1,39 +1,75 @@
|
|
|
1
1
|
import readline from 'readline';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
2
5
|
import { api } from './api.js';
|
|
3
6
|
import { requireJwt } from './auth.js';
|
|
4
7
|
import { table } from './print.js';
|
|
5
8
|
import { localTokenPrefix } from './config.js';
|
|
9
|
+
import { DOMAINS, DOMAIN_FIELDS, FIELD_SPECS, normalizeDomain, inferDomain, validateCondition } from './policy-fields.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const { templates: TEMPLATES } = JSON.parse(readFileSync(join(__dirname, 'data', 'templates.json'), 'utf8'));
|
|
6
13
|
|
|
7
14
|
const DECISION_ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~', TIERED: '⊕' };
|
|
8
15
|
|
|
16
|
+
// The top-level `action` a policy is stored with is stale for essentially
|
|
17
|
+
// every policy the dashboard's drawer builds - it defaults to ESCALATE and is
|
|
18
|
+
// never updated once a policy is built branch-by-branch, which is how the
|
|
19
|
+
// Rules section builds every one of them. `effective_action` is the server's
|
|
20
|
+
// resolved value (added alongside the raw column, never replacing it - see
|
|
21
|
+
// policies.py's _effective_action). Falling back to `p.action` here keeps
|
|
22
|
+
// this CLI working against a server that hasn't deployed the field yet.
|
|
23
|
+
export function _displayAction(p) {
|
|
24
|
+
return p.effective_action || p.action;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
function _promptYN(question) {
|
|
10
28
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
29
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim().toLowerCase() === 'y'); }));
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
const HELP = {
|
|
15
|
-
list:
|
|
16
|
-
describe:
|
|
17
|
-
create:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
list: ` troxy policies list [--org]\n\n Lists policies. --org lists your organization's policies (any member can read them) instead of your personal ones.\n`,
|
|
34
|
+
describe: ` troxy policies describe --name <policy-name> [--org]\n\n Shows full details for a single policy.\n\n Options:\n --name Name of the policy (use single quotes for names with special chars)\n --org Look up an org policy instead of a personal one\n`,
|
|
35
|
+
create: ` troxy policies create --name <name> --action <action> --domain <domain> [options]\n troxy policies create --name <name> --action <action> --template "<template label>"\n troxy policies create --describe "<plain English>"\n\n Creates a new policy. Login required.\n\n Checkpoints (--domain), one per Troxy checkpoint - what the policy is about:\n payment paying, buying, sending money (/evaluate)\n comms messages on any channel (email, Slack...) (/evaluate/email)\n access logging in or signing up on a site (/evaluate/login)\n destructive deletes, exports, permission/access changes, other consequential actions (/evaluate/action)\n model the agent's own model, tokens, cost (/evaluate/model)\n secrets content scanned for secrets before it leaves (/evaluate/secrets)\n\n Run 'troxy policies fields --domain <domain>' to see the exact fields and\n operators each checkpoint supports - the same table the server checks\n against, so nothing here can drift from what actually fires.\n\n From a template (fastest, matches the dashboard's own template library):\n --template "<label>" Exact template label - run 'troxy policies templates' to list them\n --value / --value2 Override the template's default threshold (only when it has exactly one condition)\n --action Override the template's default action\n\n Manual, single condition:\n --domain Required whenever --field is given, unless --field is one of\n the original payment fields (amount, merchant_name, tx_per_day),\n which still default to --domain payment for old scripts.\n --field Field to match - see 'troxy policies fields --domain <domain>'\n --operator eq, neq, gt, gte, lt, lte, contains, starts_with, not_contains, between\n --value Comparison value\n --value2 Upper bound for 'between'\n\n Manual, multiple conditions:\n --and "field=X,operator=Y,value=Z" Repeatable. ANDs onto the single --field condition (if any).\n --or "field=X,operator=Y,value=Z" Repeatable. Each becomes its own alternative branch (any\n --and conditions apply to every branch); all branches share --action.\n\n Raw (full expressiveness, for scripting):\n --json '<JSON>' {\"conditions\": [...], \"or_conditions\": [...]} exactly as the API expects.\n Cannot be combined with --field/--and/--or/--template.\n\n AI builder (needs an org AI key; --domain payment or comms only - the other four\n checkpoints aren't covered by this endpoint yet, use --template or a manual condition):\n --describe "<text>" Describe the policy in plain English and Troxy AI drafts it for you to confirm.\n --domain payment (default) or comms - which allowlist the AI drafts against.\n\n Priority:\n --priority <n> Explicit priority number (default: auto, max+10). Lower = evaluated first.\n\n Scope (personal policies only; default: all agents, every MCP plus Troxy Chat):\n --mcp <name> Scope to one or more MCPs (comma-separated). Run 'troxy mcps list' for names.\n --chat Include Troxy Chat. Implied by default; combine with --mcp for MCPs *and* chat.\n --no-chat Exclude Troxy Chat.\n\n Org policies (applies to every member; requires admin):\n --org Create an org-wide policy instead of a personal one. No --mcp/--chat scoping - it's org-wide.\n\n Examples:\n troxy policies create --name "Block a secret going public" --template "Block a secret going somewhere public"\n troxy policies create --name "Cap over 100" --action BLOCK --domain payment --field amount --operator gt --value 100\n troxy policies create --name "Signups" --action ESCALATE --domain access --field login_action --operator eq --value signup\n troxy policies create --name "Big exports" --action BLOCK --domain destructive --field action_verb --operator eq --value export --and "field=resource,operator=contains,value=customer"\n troxy policies create --name "Personal domains" --action BLOCK --domain comms --or "field=recipient_domain,operator=contains,value=gmail.com" --or "field=recipient_domain,operator=contains,value=yahoo.com"\n troxy policies create --name "Lock down secrets" --action BLOCK --domain secrets --field has_api_key --operator eq --value true --org\n`,
|
|
36
|
+
fields: ` troxy policies fields [--domain <domain>]\n\n Lists the fields, operators and allowed values each checkpoint actually supports -\n the same table the server validates against, so this can never offer something\n that saves and then never fires. Omit --domain to see all six.\n`,
|
|
37
|
+
templates: ` troxy policies templates [--category "<name>"] [--search "<text>"]\n\n Lists the built-in policy templates (the same ones on the dashboard's\n Policies page). Use a template's exact label with 'policies create --template'.\n`,
|
|
38
|
+
test: ` troxy policies test --domain <domain> --example '<JSON>' [condition flags]\n\n Dry-runs a condition against an example event - the same "would this rule\n fire?" check the dashboard's policy drawer offers, without saving anything.\n Nothing is written: no audit row, no notification, no pending approval.\n\n Takes the same condition flags as 'create' (--field/--operator/--value/--and/--or/--json/--template),\n plus:\n --example '<JSON>' The event to test against, shaped like what that checkpoint receives.\n e.g. --domain payment --example '{\"amount\": 150}'\n\n Examples:\n troxy policies test --domain payment --field amount --operator gt --value 100 --example '{"amount": 150}'\n troxy policies test --domain secrets --field has_api_key --operator eq --value true --example '{"has_api_key": true}'\n`,
|
|
39
|
+
'set-priority': ` troxy policies set-priority --name <policy-name> --priority <n> [--org]\n\n Changes the priority of a policy. Lower number = higher priority (evaluated first).\n\n Options:\n --name Name of the policy\n --priority New priority number (e.g. 10, 20, 50)\n --org Target an org policy (requires admin)\n\n Example:\n troxy policies set-priority --name "Block Wiki" --priority 5\n`,
|
|
40
|
+
resume: ` troxy policies resume --name <policy-name> [--org]\n\n Resumes a paused policy.\n\n Options:\n --name Name of the policy to resume\n --org Target an org policy (requires admin)\n`,
|
|
41
|
+
pause: ` troxy policies pause --name <policy-name> [--org]\n\n Pauses a policy without deleting it. The policy stops firing until you resume it.\n\n Options:\n --name Name of the policy to pause\n --org Target an org policy (requires admin)\n`,
|
|
42
|
+
delete: ` troxy policies delete --name <policy-name> [--org]\n\n Permanently deletes a policy.\n\n Options:\n --name Name of the policy to delete\n --org Target an org policy (requires admin)\n`,
|
|
22
43
|
};
|
|
23
44
|
|
|
24
|
-
export async function runPolicies([sub, ...args], flags) {
|
|
45
|
+
export async function runPolicies([sub, ...args], flags, multiFlags = {}) {
|
|
25
46
|
if (flags.help || flags.h) {
|
|
26
|
-
console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
|
|
47
|
+
console.log('\n' + (HELP[sub] || ` troxy policies <subcommand> [options]\n\n Subcommands:\n list List all policies\n describe Show details for a policy\n create Create a new policy\n templates List the built-in templates\n fields List the fields/operators each checkpoint supports\n test Dry-run a condition without saving it\n set-priority Change a policy's priority\n pause Pause a policy (it stops firing until you resume it)\n resume Resume a paused policy\n delete Delete a policy\n\n Any personal-policy subcommand also takes --org, to act on your organization's policies instead (admin required to write).\n\n Run 'troxy policies <subcommand> --help' for subcommand help.\n`));
|
|
27
48
|
process.exit(0);
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
// Local, no login and no network - just reads the vendored spec/template files.
|
|
52
|
+
if (sub === 'fields') { _printFields(flags); return; }
|
|
53
|
+
if (sub === 'templates') { _printTemplates(flags); return; }
|
|
54
|
+
|
|
30
55
|
// Read-only subcommands work with a login session
|
|
31
|
-
const readOnly = !sub || sub === 'list' || sub === 'describe';
|
|
56
|
+
const readOnly = !sub || sub === 'list' || sub === 'describe' || sub === 'test';
|
|
32
57
|
|
|
33
58
|
if (readOnly) {
|
|
34
59
|
const jwt = requireJwt();
|
|
35
60
|
switch (sub || 'list') {
|
|
36
61
|
case 'list': {
|
|
62
|
+
if (flags.org) {
|
|
63
|
+
const org = await _resolveOrgContext(jwt, false);
|
|
64
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
65
|
+
if (!policies.length) { console.log(`\n No org policies yet in ${org.name}.\n`); return; }
|
|
66
|
+
console.log(`\n Organization: ${org.name}`);
|
|
67
|
+
table(
|
|
68
|
+
['#', 'Name', 'Domain', 'Action', 'Status', 'Conditions'],
|
|
69
|
+
policies.map(p => [p.priority, p.name, p.domain, _displayAction(p), p.enabled ? 'active' : 'paused', _condSummary(p)]),
|
|
70
|
+
);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
37
73
|
const data = await api.agentPolicies(jwt, localTokenPrefix());
|
|
38
74
|
const policies = data?.policies || [];
|
|
39
75
|
if (!policies.length) { console.log('\n No policies yet.\n'); return; }
|
|
@@ -43,7 +79,7 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
43
79
|
policies.map(p => [
|
|
44
80
|
p.priority,
|
|
45
81
|
p.name,
|
|
46
|
-
p
|
|
82
|
+
_displayAction(p),
|
|
47
83
|
p.scope,
|
|
48
84
|
p.enabled ? 'active' : 'paused',
|
|
49
85
|
_condSummary(p),
|
|
@@ -56,6 +92,23 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
56
92
|
case 'describe': {
|
|
57
93
|
const name = flags.name;
|
|
58
94
|
if (!name) { console.error(' --name is required (tip: use single quotes for names with special chars)\n'); process.exit(1); }
|
|
95
|
+
if (flags.org) {
|
|
96
|
+
const org = await _resolveOrgContext(jwt, false);
|
|
97
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
98
|
+
const p = policies.find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
99
|
+
if (!p) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
100
|
+
console.log(`
|
|
101
|
+
Name: ${p.name}
|
|
102
|
+
Domain: ${p.domain}
|
|
103
|
+
Action: ${_displayAction(p)}
|
|
104
|
+
Priority: ${p.priority}
|
|
105
|
+
Status: ${p.enabled ? 'active' : 'paused'}
|
|
106
|
+
Scope: org-wide (${org.name})
|
|
107
|
+
Conditions: ${_condDetail(p)}
|
|
108
|
+
Created: ${new Date(p.created_at).toLocaleDateString()}
|
|
109
|
+
`);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
59
112
|
const data = await api.agentPolicies(jwt, localTokenPrefix());
|
|
60
113
|
const p = (data?.policies || []).find(x => x.name.toLowerCase() === name.toLowerCase());
|
|
61
114
|
if (!p) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -63,7 +116,7 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
63
116
|
const mcpList = p.mcps.length ? p.mcps.map(m => m.name).join(', ') : (p.global ? 'all MCPs' : 'none');
|
|
64
117
|
console.log(`
|
|
65
118
|
Name: ${p.name}
|
|
66
|
-
Action: ${p
|
|
119
|
+
Action: ${_displayAction(p)}
|
|
67
120
|
Priority: ${p.priority}
|
|
68
121
|
Status: ${p.enabled ? 'active' : 'paused'}
|
|
69
122
|
Scope: ${p.scope}
|
|
@@ -75,6 +128,31 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
75
128
|
`);
|
|
76
129
|
break;
|
|
77
130
|
}
|
|
131
|
+
|
|
132
|
+
case 'test': {
|
|
133
|
+
let example;
|
|
134
|
+
try { example = flags.example ? JSON.parse(flags.example) : {}; }
|
|
135
|
+
catch { console.error(' --example must be valid JSON, e.g. --example \'{"amount": 150}\'\n'); process.exit(1); }
|
|
136
|
+
|
|
137
|
+
const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: false });
|
|
138
|
+
const result = await api.previewPolicy(jwt, { domain, action: action || 'ESCALATE', conditions, or_conditions, example });
|
|
139
|
+
|
|
140
|
+
if (result.would_fire) {
|
|
141
|
+
console.log(`\n ${DECISION_ICON[result.decision] || '?'} Would fire: ${result.decision}`);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`\n — Would NOT fire (falls through to the agent's own default action)`);
|
|
144
|
+
}
|
|
145
|
+
(result.branches || []).forEach((b, i) => {
|
|
146
|
+
console.log(`\n Branch ${i + 1} (${b.action})${b.matched ? ' ✓ matched' : ''}`);
|
|
147
|
+
(b.conditions || []).forEach(c => {
|
|
148
|
+
const mark = c.matched ? '✓' : '✗';
|
|
149
|
+
const note = c.field_present_in_example === false ? ' (this field is not in --example - it reads as empty)' : '';
|
|
150
|
+
console.log(` ${mark} ${c.field} ${c.operator} ${c.value ?? ''}${note}`);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
console.log();
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
78
156
|
}
|
|
79
157
|
return;
|
|
80
158
|
}
|
|
@@ -85,10 +163,22 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
85
163
|
switch (sub) {
|
|
86
164
|
case 'create': {
|
|
87
165
|
if (flags.describe) {
|
|
166
|
+
if (flags.org) { console.error('\n --describe (AI builder) does not support --org yet. Build the policy manually or apply a template.\n'); process.exit(1); }
|
|
167
|
+
// handle_ai_policy (the backend endpoint this hits) only knows two field
|
|
168
|
+
// allowlists, gated on a single is_email boolean - unlike the six-domain
|
|
169
|
+
// condition translator create's other paths use. Nothing here used to
|
|
170
|
+
// set it, so --describe silently ran every request through the payment
|
|
171
|
+
// allowlist regardless of what was actually described: an email policy
|
|
172
|
+
// would get its proposed fields dropped as "not allowed" one by one.
|
|
173
|
+
const describeDomain = flags.domain ? normalizeDomain(flags.domain) : 'payment';
|
|
174
|
+
if (!['payment', 'comms'].includes(describeDomain)) {
|
|
175
|
+
console.error(`\n --describe only supports --domain payment or comms (the AI builder does not cover the other checkpoints yet - try --template or build the condition manually instead).\n`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
88
178
|
process.stdout.write('\n Asking Troxy AI to build this policy... ');
|
|
89
179
|
const { tokens = [] } = await api.listTokens(jwt);
|
|
90
180
|
const mcps = tokens.map(t => ({ id: t.id, name: t.name || t.agent_name || t.prefix }));
|
|
91
|
-
const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps });
|
|
181
|
+
const draft = await api.aiPolicy(jwt, { text: flags.describe, mcps, is_email: describeDomain === 'comms' });
|
|
92
182
|
if (draft.error) { console.log('✗'); console.error(`\n ${draft.error}\n`); process.exit(1); }
|
|
93
183
|
console.log('✓\n');
|
|
94
184
|
console.log(` Name: ${draft.name}`);
|
|
@@ -114,36 +204,19 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
114
204
|
}
|
|
115
205
|
|
|
116
206
|
const name = flags.name;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const conditions = [];
|
|
131
|
-
if (flags.field) {
|
|
132
|
-
if (!VALID_FIELDS.includes(flags.field)) {
|
|
133
|
-
console.error(` --field must be one of: ${VALID_FIELDS.join(', ')}\n`); process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
if (!flags.operator) { console.error(' --operator is required with --field\n'); process.exit(1); }
|
|
136
|
-
const validOps = FIELD_OPERATORS[flags.field];
|
|
137
|
-
if (!validOps.includes(flags.operator)) {
|
|
138
|
-
console.error(` --operator for ${flags.field} must be one of: ${validOps.join(', ')}\n`); process.exit(1);
|
|
139
|
-
}
|
|
140
|
-
if (flags.value == null) { console.error(' --value is required with --field\n'); process.exit(1); }
|
|
141
|
-
if (flags.operator === 'between' && flags.value2 == null) {
|
|
142
|
-
console.error(' --value2 is required with --operator between\n'); process.exit(1);
|
|
143
|
-
}
|
|
144
|
-
const cond = { field: flags.field, operator: flags.operator, value: flags.value };
|
|
145
|
-
if (flags.value2 != null) cond.value2 = flags.value2;
|
|
146
|
-
conditions.push(cond);
|
|
207
|
+
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
208
|
+
|
|
209
|
+
const { domain, action, conditions, or_conditions } = _buildConditionsFromFlags(flags, multiFlags, { requireAction: true });
|
|
210
|
+
|
|
211
|
+
if (flags.org) {
|
|
212
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
213
|
+
const priority = flags.priority != null ? parseInt(flags.priority, 10) : undefined;
|
|
214
|
+
if (flags.priority != null && isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
215
|
+
const body = { name, action, domain, conditions, or_conditions, enabled: true };
|
|
216
|
+
if (priority != null) body.priority = priority;
|
|
217
|
+
const policy = await api.createOrgPolicy(jwt, org.id, body);
|
|
218
|
+
console.log(`\n Org policy "${policy.name}" created ✓ (priority: ${policy.priority}, applies to every member of ${org.name})\n`);
|
|
219
|
+
break;
|
|
147
220
|
}
|
|
148
221
|
|
|
149
222
|
if (flags.chat && flags['no-chat']) {
|
|
@@ -189,7 +262,7 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
189
262
|
if (flags.priority != null && isNaN(priority)) {
|
|
190
263
|
console.error(' --priority must be a number\n'); process.exit(1);
|
|
191
264
|
}
|
|
192
|
-
const body = { name, action, conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
|
|
265
|
+
const body = { name, action, domain, conditions, or_conditions, enabled: true, global: isGlobal, mcp_ids: mcpIds, applies_to_chat: appliesToChat };
|
|
193
266
|
if (priority != null) body.priority = priority;
|
|
194
267
|
const policy = await api.createPolicy(jwt, body);
|
|
195
268
|
let scope;
|
|
@@ -200,13 +273,22 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
200
273
|
if (appliesToChat) parts.push('Troxy Chat');
|
|
201
274
|
scope = parts.length ? parts.join(', ') : 'no agents';
|
|
202
275
|
}
|
|
203
|
-
console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, scope: ${scope})\n`);
|
|
276
|
+
console.log(`\n Policy "${policy.name}" created ✓ (priority: ${policy.priority}, checkpoint: ${domain}, scope: ${scope})\n`);
|
|
204
277
|
break;
|
|
205
278
|
}
|
|
206
279
|
|
|
207
280
|
case 'delete': {
|
|
208
281
|
const name = flags.name;
|
|
209
282
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
283
|
+
if (flags.org) {
|
|
284
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
285
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
286
|
+
const policy = policies.find(p => p.name === name);
|
|
287
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
288
|
+
await api.deleteOrgPolicy(jwt, org.id, policy.id);
|
|
289
|
+
console.log(`\n Org policy "${name}" deleted ✓\n`);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
210
292
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
211
293
|
const policy = policies.find(p => p.name === name);
|
|
212
294
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -226,6 +308,15 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
226
308
|
case 'resume': {
|
|
227
309
|
const name = flags.name;
|
|
228
310
|
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
311
|
+
if (flags.org) {
|
|
312
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
313
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
314
|
+
const policy = policies.find(p => p.name === name);
|
|
315
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
316
|
+
await api.updateOrgPolicy(jwt, org.id, policy.id, { enabled: sub === 'resume' });
|
|
317
|
+
console.log(`\n Org policy "${name}" ${sub}d ✓\n`);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
229
320
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
230
321
|
const policy = policies.find(p => p.name === name);
|
|
231
322
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -249,6 +340,15 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
249
340
|
if (flags.priority == null) { console.error(' --priority is required\n'); process.exit(1); }
|
|
250
341
|
const priority = parseInt(flags.priority, 10);
|
|
251
342
|
if (isNaN(priority)) { console.error(' --priority must be a number\n'); process.exit(1); }
|
|
343
|
+
if (flags.org) {
|
|
344
|
+
const org = await _resolveOrgContext(jwt, true);
|
|
345
|
+
const { policies = [] } = await api.listOrgPolicies(jwt, org.id);
|
|
346
|
+
const policy = policies.find(p => p.name === name);
|
|
347
|
+
if (!policy) { console.error(` Org policy "${name}" not found in ${org.name}\n`); process.exit(1); }
|
|
348
|
+
await api.updateOrgPolicy(jwt, org.id, policy.id, { priority });
|
|
349
|
+
console.log(`\n Org policy "${name}" priority set to ${priority} ✓\n`);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
252
352
|
const { policies = [] } = await api.listPolicies(jwt);
|
|
253
353
|
const policy = policies.find(p => p.name === name);
|
|
254
354
|
if (!policy) { console.error(` Policy "${name}" not found\n`); process.exit(1); }
|
|
@@ -269,11 +369,206 @@ export async function runPolicies([sub, ...args], flags) {
|
|
|
269
369
|
|
|
270
370
|
default:
|
|
271
371
|
console.error(` Unknown subcommand: ${sub}`);
|
|
272
|
-
console.error(' Usage: troxy policies [list|describe|create|set-priority|
|
|
372
|
+
console.error(' Usage: troxy policies [list|describe|create|templates|fields|test|set-priority|pause|resume|delete]\n');
|
|
273
373
|
process.exit(1);
|
|
274
374
|
}
|
|
275
375
|
}
|
|
276
376
|
|
|
377
|
+
// ── Org context ──────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
async function _resolveOrgContext(jwt, needAdmin) {
|
|
380
|
+
const { org } = await api.currentOrg(jwt);
|
|
381
|
+
if (!org) {
|
|
382
|
+
console.error('\n --org was passed, but you are not in an organization.\n');
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
if (needAdmin && org.role !== 'admin') {
|
|
386
|
+
console.error(`\n Org policies require admin access. You are a '${org.role}' in ${org.name}.\n`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
return org;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Condition building, shared by create and test ──────────────────────────
|
|
393
|
+
|
|
394
|
+
// "field=amount,operator=gt,value=100[,value2=200]" -> {field,operator,value,value2?}
|
|
395
|
+
function _parseMiniCondition(str, flagName) {
|
|
396
|
+
const out = {};
|
|
397
|
+
for (const pair of String(str).split(',')) {
|
|
398
|
+
const eq = pair.indexOf('=');
|
|
399
|
+
if (eq === -1) continue;
|
|
400
|
+
out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
401
|
+
}
|
|
402
|
+
if (!out.field || !out.operator || out.value == null) {
|
|
403
|
+
console.error(`\n ${flagName} needs field=, operator= and value= - got: "${str}"\n`);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function _condObj(c) {
|
|
410
|
+
const out = { field: c.field, operator: c.operator, value: c.value };
|
|
411
|
+
if (c.value2 != null) out.value2 = c.value2;
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _validateOrExit(domain, c) {
|
|
416
|
+
const err = validateCondition(domain, c.field, c.operator, c.value, c.value2);
|
|
417
|
+
if (err) { console.error(`\n ${err}\n`); process.exit(1); }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Exported for direct testing (src/tests/policies-create.test.js) - these two
|
|
421
|
+
// carry all the domain/field/operator/value validation logic in this file,
|
|
422
|
+
// and testing them by driving the full CLI command through a mocked network
|
|
423
|
+
// would hide failures behind console-output diffing instead of asserting on
|
|
424
|
+
// the actual request body that would have been sent.
|
|
425
|
+
export function _findTemplate(label) {
|
|
426
|
+
const exact = TEMPLATES.find(t => t.label.toLowerCase() === label.toLowerCase());
|
|
427
|
+
if (exact) return exact;
|
|
428
|
+
const needle = label.toLowerCase();
|
|
429
|
+
const close = TEMPLATES.filter(t => t.label.toLowerCase().includes(needle)).slice(0, 5);
|
|
430
|
+
console.error(`\n No template called "${label}".`);
|
|
431
|
+
if (close.length) {
|
|
432
|
+
console.error(' Did you mean:');
|
|
433
|
+
close.forEach(t => console.error(` - ${t.label}`));
|
|
434
|
+
} else {
|
|
435
|
+
console.error(" Run 'troxy policies templates' to see all of them.");
|
|
436
|
+
}
|
|
437
|
+
console.error();
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Builds {domain, action, conditions, or_conditions} from create/test's flags.
|
|
443
|
+
* Exits with a clear error on anything invalid - nothing here should ever
|
|
444
|
+
* reach the server with a condition the vendored spec already knows is bad.
|
|
445
|
+
*/
|
|
446
|
+
export function _buildConditionsFromFlags(flags, multiFlags, { requireAction }) {
|
|
447
|
+
const exclusiveFlags = ['template', 'json', 'field', 'and', 'or'];
|
|
448
|
+
const given = exclusiveFlags.filter(f => flags[f] != null || (multiFlags[f] || []).length);
|
|
449
|
+
const usesTemplate = given.includes('template');
|
|
450
|
+
const usesJson = given.includes('json');
|
|
451
|
+
if (usesTemplate && given.some(f => f !== 'template')) {
|
|
452
|
+
console.error('\n --template cannot be combined with --field/--and/--or/--json. Use --value/--value2/--action to adjust it instead.\n');
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
if (usesJson && given.some(f => f !== 'json')) {
|
|
456
|
+
console.error('\n --json cannot be combined with --field/--and/--or/--template.\n');
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const action = (flags.action || '').toUpperCase();
|
|
461
|
+
if (requireAction && !action && !usesTemplate) { console.error(' --action is required\n'); process.exit(1); }
|
|
462
|
+
if (action && !['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
|
|
463
|
+
console.error(' --action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (usesTemplate) {
|
|
467
|
+
const t = _findTemplate(flags.template);
|
|
468
|
+
const domain = t.domain;
|
|
469
|
+
const finalAction = action || t.action;
|
|
470
|
+
let conditions = t.conditions.map(_condObj);
|
|
471
|
+
let or_conditions = t.or_conditions.map(b => ({ action: b.action, conditions: b.conditions.map(_condObj) }));
|
|
472
|
+
|
|
473
|
+
if (flags.value != null) {
|
|
474
|
+
const totalConds = conditions.length + or_conditions.reduce((n, b) => n + b.conditions.length, 0);
|
|
475
|
+
if (totalConds !== 1) {
|
|
476
|
+
console.error(`\n "${t.label}" has ${totalConds} condition(s) - --value only works on a template with exactly one. Use --json to customize this one, or add it as-is and edit it later.\n`);
|
|
477
|
+
process.exit(1);
|
|
478
|
+
}
|
|
479
|
+
if (conditions.length === 1) conditions = [{ ...conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }];
|
|
480
|
+
else or_conditions = [{ ...or_conditions[0], conditions: [{ ...or_conditions[0].conditions[0], value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }] }];
|
|
481
|
+
} else if (flags.value2 != null) {
|
|
482
|
+
console.error('\n --value2 requires --value.\n'); process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for (const c of conditions) _validateOrExit(domain, c);
|
|
486
|
+
for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
|
|
487
|
+
return { domain, action: finalAction, conditions, or_conditions };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (usesJson) {
|
|
491
|
+
let parsed;
|
|
492
|
+
try { parsed = JSON.parse(flags.json); }
|
|
493
|
+
catch { console.error('\n --json must be valid JSON: {"conditions": [...], "or_conditions": [...]}\n'); process.exit(1); }
|
|
494
|
+
const domain = normalizeDomain(flags.domain);
|
|
495
|
+
if (!domain) { console.error(`\n --domain is required with --json (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
|
|
496
|
+
const conditions = (parsed.conditions || []).map(_condObj);
|
|
497
|
+
const or_conditions = (parsed.or_conditions || []).map(b => ({ action: b.action, conditions: (b.conditions || []).map(_condObj) }));
|
|
498
|
+
for (const c of conditions) _validateOrExit(domain, c);
|
|
499
|
+
for (const b of or_conditions) for (const c of b.conditions) _validateOrExit(domain, c);
|
|
500
|
+
return { domain, action, conditions, or_conditions };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Manual: --field (single) plus repeatable --and (AND'd in) and --or (branches).
|
|
504
|
+
let domain = normalizeDomain(flags.domain);
|
|
505
|
+
if (!domain && flags.field) domain = inferDomain(flags.field);
|
|
506
|
+
if (!domain && (multiFlags.and || multiFlags.or || []).length) {
|
|
507
|
+
console.error(`\n --domain is required (one of: ${DOMAINS.join(', ')})\n`); process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
if (!domain) domain = 'payment'; // nothing given at all: a plain catch-all payment policy, same as before this change
|
|
510
|
+
|
|
511
|
+
const singleField = flags.field
|
|
512
|
+
? { field: flags.field, operator: flags.operator, value: flags.value, ...(flags.value2 != null ? { value2: flags.value2 } : {}) }
|
|
513
|
+
: null;
|
|
514
|
+
const andSpecs = (multiFlags.and || []).map(s => _parseMiniCondition(s, '--and'));
|
|
515
|
+
const orSpecs = (multiFlags.or || []).map(s => _parseMiniCondition(s, '--or'));
|
|
516
|
+
const baseAnd = [...(singleField ? [singleField] : []), ...andSpecs];
|
|
517
|
+
|
|
518
|
+
for (const c of baseAnd) _validateOrExit(domain, c);
|
|
519
|
+
for (const c of orSpecs) _validateOrExit(domain, c);
|
|
520
|
+
|
|
521
|
+
if (orSpecs.length === 0) {
|
|
522
|
+
return { domain, action, conditions: baseAnd, or_conditions: [] };
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
domain, action,
|
|
526
|
+
conditions: [],
|
|
527
|
+
or_conditions: orSpecs.map(o => ({ action, conditions: [...baseAnd, o] })),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Local, offline listings ─────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
function _printFields(flags) {
|
|
534
|
+
const domain = flags.domain ? normalizeDomain(flags.domain) : null;
|
|
535
|
+
if (flags.domain && !domain) { console.error(`\n Unknown domain "${flags.domain}" (one of: ${DOMAINS.join(', ')})\n`); process.exit(1); }
|
|
536
|
+
const domainsToShow = domain ? [domain] : DOMAINS;
|
|
537
|
+
for (const d of domainsToShow) {
|
|
538
|
+
console.log(`\n ${d}`);
|
|
539
|
+
const fields = DOMAIN_FIELDS[d] || [];
|
|
540
|
+
table(
|
|
541
|
+
['Field', 'Operators', 'Values'],
|
|
542
|
+
fields.map(f => {
|
|
543
|
+
const spec = FIELD_SPECS[f] || {};
|
|
544
|
+
const values = spec.kind === 'bool' ? 'true, false' : (spec.values || []).join(', ');
|
|
545
|
+
return [f, (spec.ops || []).join(', '), values];
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
console.log();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function _printTemplates(flags) {
|
|
553
|
+
let list = TEMPLATES;
|
|
554
|
+
if (flags.category) {
|
|
555
|
+
const needle = String(flags.category).toLowerCase();
|
|
556
|
+
list = list.filter(t => t.category.toLowerCase().includes(needle));
|
|
557
|
+
}
|
|
558
|
+
if (flags.search) {
|
|
559
|
+
const needle = String(flags.search).toLowerCase();
|
|
560
|
+
list = list.filter(t => t.label.toLowerCase().includes(needle) || (t.desc || '').toLowerCase().includes(needle));
|
|
561
|
+
}
|
|
562
|
+
if (!list.length) { console.log('\n No templates match.\n'); return; }
|
|
563
|
+
console.log();
|
|
564
|
+
let lastCategory = null;
|
|
565
|
+
for (const t of list) {
|
|
566
|
+
if (t.category !== lastCategory) { console.log(` ${t.category}`); lastCategory = t.category; }
|
|
567
|
+
console.log(` ${t.action.padEnd(8)} ${t.label}`);
|
|
568
|
+
}
|
|
569
|
+
console.log(`\n ${list.length} template(s). Use one with: troxy policies create --name "..." --template "<label>"\n`);
|
|
570
|
+
}
|
|
571
|
+
|
|
277
572
|
const _isAny = x => !x.field || x.field === 'any' || x.operator === 'any';
|
|
278
573
|
|
|
279
574
|
function _condSummary(p) {
|