@gotgenes/pi-permission-system 4.4.0 → 4.5.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/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/input-normalizer.ts +94 -0
- package/src/mcp-targets.ts +160 -0
- package/src/permission-manager.ts +53 -310
- package/src/rule.ts +32 -0
- package/tests/input-normalizer.test.ts +150 -0
- package/tests/mcp-targets.test.ts +178 -0
- package/tests/permission-manager-unified.test.ts +375 -0
- package/tests/rule.test.ts +81 -1
- package/src/defaults.ts +0 -10
- package/tests/defaults.test.ts +0 -12
|
@@ -4,7 +4,6 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
extractFrontmatter,
|
|
7
|
-
getNonEmptyString,
|
|
8
7
|
isPermissionState,
|
|
9
8
|
parseSimpleYamlMap,
|
|
10
9
|
toRecord,
|
|
@@ -15,9 +14,10 @@ import {
|
|
|
15
14
|
stripJsonComments,
|
|
16
15
|
} from "./config-loader";
|
|
17
16
|
import { getGlobalConfigPath } from "./config-paths";
|
|
17
|
+
import { normalizeInput } from "./input-normalizer";
|
|
18
18
|
import { normalizeFlatConfig } from "./normalize";
|
|
19
19
|
import type { Rule, Ruleset } from "./rule";
|
|
20
|
-
import { evaluate } from "./rule";
|
|
20
|
+
import { evaluate, evaluateFirst } from "./rule";
|
|
21
21
|
import {
|
|
22
22
|
composeRuleset,
|
|
23
23
|
synthesizeBaseline,
|
|
@@ -453,330 +453,73 @@ export class PermissionManager {
|
|
|
453
453
|
const { composedRules } = this.resolvePermissions(agentName);
|
|
454
454
|
const normalizedToolName = toolName.trim();
|
|
455
455
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// Session check: match by specific normalized path.
|
|
462
|
-
if (pathValue && sessionRules && sessionRules.length > 0) {
|
|
463
|
-
const sessionRule = evaluate(
|
|
464
|
-
"external_directory",
|
|
465
|
-
pathValue,
|
|
466
|
-
sessionRules,
|
|
467
|
-
);
|
|
468
|
-
if (sessionRules.includes(sessionRule)) {
|
|
469
|
-
return {
|
|
470
|
-
toolName,
|
|
471
|
-
state: "allow",
|
|
472
|
-
matchedPattern: sessionRule.pattern,
|
|
473
|
-
source: "session",
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Config/default check.
|
|
479
|
-
const rule = evaluate(
|
|
480
|
-
normalizedToolName,
|
|
481
|
-
pathValue ?? "*",
|
|
482
|
-
composedRules,
|
|
483
|
-
);
|
|
484
|
-
return {
|
|
485
|
-
toolName,
|
|
486
|
-
state: rule.action,
|
|
487
|
-
matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
|
|
488
|
-
source: "special",
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// --- Skills ---
|
|
493
|
-
if (normalizedToolName === "skill") {
|
|
494
|
-
const skillName = toRecord(input).name;
|
|
495
|
-
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
|
496
|
-
|
|
497
|
-
// Session check.
|
|
498
|
-
if (sessionRules && sessionRules.length > 0) {
|
|
499
|
-
const sessionRule = evaluate("skill", lookupValue, sessionRules);
|
|
500
|
-
if (sessionRules.includes(sessionRule)) {
|
|
501
|
-
return {
|
|
502
|
-
toolName,
|
|
503
|
-
state: "allow",
|
|
504
|
-
matchedPattern: sessionRule.pattern,
|
|
505
|
-
source: "session",
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const rule = evaluate("skill", lookupValue, composedRules);
|
|
511
|
-
return {
|
|
512
|
-
toolName,
|
|
513
|
-
state: rule.action,
|
|
514
|
-
matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
|
|
515
|
-
source: "skill",
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// --- Bash ---
|
|
520
|
-
if (normalizedToolName === "bash") {
|
|
521
|
-
const record = toRecord(input);
|
|
522
|
-
const command = typeof record.command === "string" ? record.command : "";
|
|
523
|
-
|
|
524
|
-
// Session check.
|
|
525
|
-
if (sessionRules && sessionRules.length > 0) {
|
|
526
|
-
const sessionRule = evaluate("bash", command, sessionRules);
|
|
527
|
-
if (sessionRules.includes(sessionRule)) {
|
|
528
|
-
return {
|
|
529
|
-
toolName,
|
|
530
|
-
state: "allow",
|
|
531
|
-
command,
|
|
532
|
-
matchedPattern: sessionRule.pattern,
|
|
533
|
-
source: "session",
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const rule = evaluate("bash", command, composedRules);
|
|
539
|
-
return {
|
|
540
|
-
toolName,
|
|
541
|
-
state: rule.action,
|
|
542
|
-
command,
|
|
543
|
-
matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
|
|
544
|
-
source: "bash",
|
|
545
|
-
};
|
|
546
|
-
}
|
|
456
|
+
// Append session rules at the end (highest priority) so evaluate() handles
|
|
457
|
+
// them via last-match-wins — no separate per-branch pre-check needed.
|
|
458
|
+
const fullRules: Ruleset = sessionRules?.length
|
|
459
|
+
? [...composedRules, ...sessionRules]
|
|
460
|
+
: composedRules;
|
|
547
461
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
this.getConfiguredMcpServerNames(),
|
|
554
|
-
),
|
|
555
|
-
"mcp",
|
|
556
|
-
];
|
|
557
|
-
const fallbackTarget = mcpTargets[0] || "mcp";
|
|
558
|
-
|
|
559
|
-
// Session check: try each candidate target against session rules.
|
|
560
|
-
if (sessionRules && sessionRules.length > 0) {
|
|
561
|
-
for (const target of mcpTargets) {
|
|
562
|
-
const sessionRule = evaluate("mcp", target, sessionRules);
|
|
563
|
-
if (sessionRules.includes(sessionRule)) {
|
|
564
|
-
return {
|
|
565
|
-
toolName,
|
|
566
|
-
state: "allow",
|
|
567
|
-
matchedPattern: sessionRule.pattern,
|
|
568
|
-
target,
|
|
569
|
-
source: "session",
|
|
570
|
-
};
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Try each candidate target. Stop on the first non-default match.
|
|
576
|
-
for (const target of mcpTargets) {
|
|
577
|
-
const rule = evaluate("mcp", target, composedRules);
|
|
578
|
-
if (rule.layer !== "default") {
|
|
579
|
-
return {
|
|
580
|
-
toolName,
|
|
581
|
-
state: rule.action,
|
|
582
|
-
matchedPattern: rule.layer === "config" ? rule.pattern : undefined,
|
|
583
|
-
target,
|
|
584
|
-
source: rule.layer === "override" ? "tool" : "mcp",
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// All targets matched only the synthesized default.
|
|
590
|
-
const defaultRule = evaluate("mcp", fallbackTarget, composedRules);
|
|
591
|
-
return {
|
|
592
|
-
toolName,
|
|
593
|
-
state: defaultRule.action,
|
|
594
|
-
target: fallbackTarget,
|
|
595
|
-
source: "default",
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// --- Tools (read, write, edit, grep, find, ls, extension tools) ---
|
|
600
|
-
|
|
601
|
-
// Session check.
|
|
602
|
-
if (sessionRules && sessionRules.length > 0) {
|
|
603
|
-
const sessionRule = evaluate(normalizedToolName, "*", sessionRules);
|
|
604
|
-
if (sessionRules.includes(sessionRule)) {
|
|
605
|
-
return {
|
|
606
|
-
toolName,
|
|
607
|
-
state: "allow",
|
|
608
|
-
matchedPattern: sessionRule.pattern,
|
|
609
|
-
source: "session",
|
|
610
|
-
};
|
|
611
|
-
}
|
|
612
|
-
}
|
|
462
|
+
const { surface, values, resultExtras } = normalizeInput(
|
|
463
|
+
normalizedToolName,
|
|
464
|
+
input,
|
|
465
|
+
this.getConfiguredMcpServerNames(),
|
|
466
|
+
);
|
|
613
467
|
|
|
614
|
-
const rule =
|
|
468
|
+
const { rule, value } = evaluateFirst(surface, values, fullRules);
|
|
615
469
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
source: "tool",
|
|
621
|
-
};
|
|
622
|
-
}
|
|
470
|
+
// For MCP, replace the normalizer's fallback target with the actual
|
|
471
|
+
// matched candidate value so PermissionCheckResult.target is accurate.
|
|
472
|
+
const extras =
|
|
473
|
+
surface === "mcp" ? { ...resultExtras, target: value } : resultExtras;
|
|
623
474
|
|
|
624
475
|
return {
|
|
625
476
|
toolName,
|
|
626
477
|
state: rule.action,
|
|
627
|
-
|
|
478
|
+
matchedPattern:
|
|
479
|
+
rule.layer === "config" || rule.layer === "session"
|
|
480
|
+
? rule.pattern
|
|
481
|
+
: undefined,
|
|
482
|
+
source: deriveSource(rule, normalizedToolName),
|
|
483
|
+
...extras,
|
|
628
484
|
};
|
|
629
485
|
}
|
|
630
486
|
}
|
|
631
487
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const tool = trimmed.slice(colonIndex + 1).trim();
|
|
651
|
-
if (!server || !tool) {
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
return { server, tool };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function addDerivedMcpServerTargets(
|
|
488
|
+
/**
|
|
489
|
+
* Map a matched rule + tool name to the correct PermissionCheckResult.source.
|
|
490
|
+
*
|
|
491
|
+
* Mirrors the source-derivation logic from the former per-branch
|
|
492
|
+
* checkPermission() implementation:
|
|
493
|
+
*
|
|
494
|
+
* - session → "session" (always, all surfaces)
|
|
495
|
+
* - mcp + default → "default"
|
|
496
|
+
* - mcp + override → "tool"
|
|
497
|
+
* - mcp + other → "mcp"
|
|
498
|
+
* - special → "special" (always)
|
|
499
|
+
* - skill → "skill" (always)
|
|
500
|
+
* - bash → "bash" (always)
|
|
501
|
+
* - built-in tool → "tool" (always)
|
|
502
|
+
* - extension tool → "default" when default layer, "tool" otherwise
|
|
503
|
+
*/
|
|
504
|
+
function deriveSource(
|
|
505
|
+
rule: Rule,
|
|
659
506
|
toolName: string,
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
): void {
|
|
663
|
-
const trimmedToolName = toolName.trim();
|
|
664
|
-
if (!trimmedToolName) {
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
for (const serverName of configuredServerNames) {
|
|
669
|
-
const trimmedServerName = serverName.trim();
|
|
670
|
-
if (!trimmedServerName) {
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (!trimmedToolName.endsWith(`_${trimmedServerName}`)) {
|
|
675
|
-
continue;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
507
|
+
): PermissionCheckResult["source"] {
|
|
508
|
+
if (rule.layer === "session") return "session";
|
|
681
509
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function pushMcpToolPermissionTargets(
|
|
689
|
-
rawReference: string,
|
|
690
|
-
serverHint: string | null,
|
|
691
|
-
configuredServerNames: readonly string[],
|
|
692
|
-
pushTarget: (value: string | null) => void,
|
|
693
|
-
): void {
|
|
694
|
-
const qualified = parseQualifiedMcpToolName(rawReference);
|
|
695
|
-
const resolvedServer = serverHint ?? qualified?.server ?? null;
|
|
696
|
-
const resolvedTool = qualified?.tool ?? rawReference;
|
|
697
|
-
|
|
698
|
-
if (resolvedServer) {
|
|
699
|
-
pushTarget(`${resolvedServer}_${resolvedTool}`);
|
|
700
|
-
pushTarget(`${resolvedServer}:${resolvedTool}`);
|
|
701
|
-
pushTarget(resolvedServer);
|
|
702
|
-
} else {
|
|
703
|
-
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
|
|
510
|
+
if (toolName === "mcp") {
|
|
511
|
+
if (rule.layer === "default") return "default";
|
|
512
|
+
if (rule.layer === "override") return "tool";
|
|
513
|
+
return "mcp";
|
|
704
514
|
}
|
|
705
515
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
function createMcpPermissionTargets(
|
|
711
|
-
input: unknown,
|
|
712
|
-
configuredServerNames: readonly string[] = [],
|
|
713
|
-
): string[] {
|
|
714
|
-
const record = toRecord(input);
|
|
715
|
-
const tool = getNonEmptyString(record.tool);
|
|
716
|
-
const server = getNonEmptyString(record.server);
|
|
717
|
-
const connect = getNonEmptyString(record.connect);
|
|
718
|
-
const describe = getNonEmptyString(record.describe);
|
|
719
|
-
const search = getNonEmptyString(record.search);
|
|
720
|
-
|
|
721
|
-
const targets: string[] = [];
|
|
722
|
-
const pushTarget = (value: string | null) => {
|
|
723
|
-
if (!value) {
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
if (!targets.includes(value)) {
|
|
727
|
-
targets.push(value);
|
|
728
|
-
}
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
if (tool) {
|
|
732
|
-
pushMcpToolPermissionTargets(
|
|
733
|
-
tool,
|
|
734
|
-
server,
|
|
735
|
-
configuredServerNames,
|
|
736
|
-
pushTarget,
|
|
737
|
-
);
|
|
738
|
-
pushTarget("mcp_call");
|
|
739
|
-
return targets;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
if (connect) {
|
|
743
|
-
pushTarget(`mcp_connect_${connect}`);
|
|
744
|
-
pushTarget(connect);
|
|
745
|
-
pushTarget("mcp_connect");
|
|
746
|
-
return targets;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (describe) {
|
|
750
|
-
pushMcpToolPermissionTargets(
|
|
751
|
-
describe,
|
|
752
|
-
server,
|
|
753
|
-
configuredServerNames,
|
|
754
|
-
pushTarget,
|
|
755
|
-
);
|
|
756
|
-
pushTarget("mcp_describe");
|
|
757
|
-
return targets;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
if (search) {
|
|
761
|
-
if (server) {
|
|
762
|
-
pushTarget(`mcp_server_${server}`);
|
|
763
|
-
pushTarget(server);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
pushTarget(search);
|
|
767
|
-
pushTarget("mcp_search");
|
|
768
|
-
return targets;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (server) {
|
|
772
|
-
pushTarget(`mcp_server_${server}`);
|
|
773
|
-
pushTarget(server);
|
|
774
|
-
pushTarget("mcp_list");
|
|
775
|
-
return targets;
|
|
776
|
-
}
|
|
516
|
+
if (SPECIAL_PERMISSION_KEYS.has(toolName)) return "special";
|
|
517
|
+
if (toolName === "skill") return "skill";
|
|
518
|
+
if (toolName === "bash") return "bash";
|
|
777
519
|
|
|
778
|
-
|
|
779
|
-
return
|
|
520
|
+
// Built-in tools always report "tool"; extension tools distinguish default.
|
|
521
|
+
if (BUILT_IN_TOOL_PERMISSION_NAMES.has(toolName)) return "tool";
|
|
522
|
+
return rule.layer === "default" ? "default" : "tool";
|
|
780
523
|
}
|
|
781
524
|
|
|
782
525
|
// Keep isPermissionState and toRecord available for convenience — they are
|
package/src/rule.ts
CHANGED
|
@@ -45,3 +45,35 @@ export function evaluate(
|
|
|
45
45
|
}
|
|
46
46
|
return { surface, pattern, action: defaultAction ?? "ask" };
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
51
|
+
* the first candidate that matches a non-default rule (last-match-wins within
|
|
52
|
+
* each candidate, first-non-default-wins across candidates).
|
|
53
|
+
*
|
|
54
|
+
* Used by MCP (multi-candidate target list) and, uniformly, by all other
|
|
55
|
+
* surfaces (single-element candidate list).
|
|
56
|
+
*
|
|
57
|
+
* Returns the matched rule and the candidate value that produced it.
|
|
58
|
+
* When every candidate matches only the synthesized default, falls back to
|
|
59
|
+
* evaluating the first candidate so the caller always receives a concrete
|
|
60
|
+
* result.
|
|
61
|
+
*/
|
|
62
|
+
export function evaluateFirst(
|
|
63
|
+
surface: string,
|
|
64
|
+
values: string[],
|
|
65
|
+
rules: Ruleset,
|
|
66
|
+
): { rule: Rule; value: string } {
|
|
67
|
+
for (const value of values) {
|
|
68
|
+
const rule = evaluate(surface, value, rules);
|
|
69
|
+
if (rule.layer !== "default") {
|
|
70
|
+
return { rule, value };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// All candidates matched only the synthesized default — use the first.
|
|
74
|
+
const fallbackValue = values[0] ?? "*";
|
|
75
|
+
return {
|
|
76
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
77
|
+
value: fallbackValue,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeInput } from "../src/input-normalizer";
|
|
3
|
+
import { createMcpPermissionTargets } from "../src/mcp-targets";
|
|
4
|
+
|
|
5
|
+
describe("normalizeInput — non-MCP surfaces", () => {
|
|
6
|
+
describe("special / external_directory", () => {
|
|
7
|
+
it("uses path from input as the lookup value", () => {
|
|
8
|
+
const result = normalizeInput(
|
|
9
|
+
"external_directory",
|
|
10
|
+
{ path: "/other/project" },
|
|
11
|
+
[],
|
|
12
|
+
);
|
|
13
|
+
expect(result.surface).toBe("external_directory");
|
|
14
|
+
expect(result.values).toEqual(["/other/project"]);
|
|
15
|
+
expect(result.resultExtras).toEqual({});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to '*' when path is missing", () => {
|
|
19
|
+
const result = normalizeInput("external_directory", {}, []);
|
|
20
|
+
expect(result.values).toEqual(["*"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to '*' when path is not a string", () => {
|
|
24
|
+
const result = normalizeInput("external_directory", { path: 42 }, []);
|
|
25
|
+
expect(result.values).toEqual(["*"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("handles null input", () => {
|
|
29
|
+
const result = normalizeInput("external_directory", null, []);
|
|
30
|
+
expect(result.values).toEqual(["*"]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("skill", () => {
|
|
35
|
+
it("uses skill name from input.name", () => {
|
|
36
|
+
const result = normalizeInput("skill", { name: "librarian" }, []);
|
|
37
|
+
expect(result.surface).toBe("skill");
|
|
38
|
+
expect(result.values).toEqual(["librarian"]);
|
|
39
|
+
expect(result.resultExtras).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to '*' when name is missing", () => {
|
|
43
|
+
const result = normalizeInput("skill", {}, []);
|
|
44
|
+
expect(result.values).toEqual(["*"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("falls back to '*' when name is not a string", () => {
|
|
48
|
+
const result = normalizeInput("skill", { name: 99 }, []);
|
|
49
|
+
expect(result.values).toEqual(["*"]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("bash", () => {
|
|
54
|
+
it("uses command from input.command", () => {
|
|
55
|
+
const result = normalizeInput("bash", { command: "git status" }, []);
|
|
56
|
+
expect(result.surface).toBe("bash");
|
|
57
|
+
expect(result.values).toEqual(["git status"]);
|
|
58
|
+
expect(result.resultExtras).toEqual({ command: "git status" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("uses empty string when command is missing", () => {
|
|
62
|
+
const result = normalizeInput("bash", {}, []);
|
|
63
|
+
expect(result.values).toEqual([""]);
|
|
64
|
+
expect(result.resultExtras).toEqual({ command: "" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses empty string when command is not a string", () => {
|
|
68
|
+
const result = normalizeInput("bash", { command: 42 }, []);
|
|
69
|
+
expect(result.values).toEqual([""]);
|
|
70
|
+
expect(result.resultExtras).toEqual({ command: "" });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("tool surfaces (read, write, edit, grep, find, ls, extension tools)", () => {
|
|
75
|
+
it("uses '*' as the lookup value for built-in tools", () => {
|
|
76
|
+
for (const tool of ["read", "write", "edit", "grep", "find", "ls"]) {
|
|
77
|
+
const result = normalizeInput(tool, {}, []);
|
|
78
|
+
expect(result.surface).toBe(tool);
|
|
79
|
+
expect(result.values).toEqual(["*"]);
|
|
80
|
+
expect(result.resultExtras).toEqual({});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("uses '*' as the lookup value for extension tools", () => {
|
|
85
|
+
const result = normalizeInput("my_extension_tool", { some: "input" }, []);
|
|
86
|
+
expect(result.surface).toBe("my_extension_tool");
|
|
87
|
+
expect(result.values).toEqual(["*"]);
|
|
88
|
+
expect(result.resultExtras).toEqual({});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("normalizeInput — MCP surface", () => {
|
|
94
|
+
it("surface is 'mcp'", () => {
|
|
95
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
96
|
+
expect(result.surface).toBe("mcp");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("values end with the catch-all 'mcp' target", () => {
|
|
100
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
101
|
+
expect(result.values.at(-1)).toBe("mcp");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("values include specific targets before the catch-all for a qualified tool call", () => {
|
|
105
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
106
|
+
expect(result.values).toContain("exa_search");
|
|
107
|
+
expect(result.values).toContain("exa:search");
|
|
108
|
+
expect(result.values).toContain("exa");
|
|
109
|
+
expect(result.values).toContain("mcp_call");
|
|
110
|
+
// 'mcp' is always last
|
|
111
|
+
expect(result.values.at(-1)).toBe("mcp");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("matches createMcpPermissionTargets output + 'mcp' appended", () => {
|
|
115
|
+
const rawTargets = createMcpPermissionTargets({ tool: "exa:search" }, [
|
|
116
|
+
"exa",
|
|
117
|
+
]);
|
|
118
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
|
|
119
|
+
expect(result.values).toEqual([...rawTargets, "mcp"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("resultExtras.target is the first specific target (most-specific)", () => {
|
|
123
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, []);
|
|
124
|
+
expect(result.resultExtras.target).toBe(result.values[0]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("resultExtras.target is 'mcp' when no specific targets are derived", () => {
|
|
128
|
+
// Empty input → only mcp_status then mcp appended
|
|
129
|
+
const result = normalizeInput("mcp", {}, []);
|
|
130
|
+
expect(result.resultExtras.target).toBe("mcp_status");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("values contain no duplicates", () => {
|
|
134
|
+
const result = normalizeInput("mcp", { tool: "exa:search" }, ["exa"]);
|
|
135
|
+
const unique = [...new Set(result.values)];
|
|
136
|
+
expect(result.values).toEqual(unique);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("produces mcp_status + mcp for status input", () => {
|
|
140
|
+
const result = normalizeInput("mcp", {}, []);
|
|
141
|
+
expect(result.values).toEqual(["mcp_status", "mcp"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("produces connect targets + mcp for connect input", () => {
|
|
145
|
+
const result = normalizeInput("mcp", { connect: "exa" }, []);
|
|
146
|
+
expect(result.values).toContain("mcp_connect_exa");
|
|
147
|
+
expect(result.values).toContain("mcp_connect");
|
|
148
|
+
expect(result.values.at(-1)).toBe("mcp");
|
|
149
|
+
});
|
|
150
|
+
});
|