narai-primitives 2.3.0 → 2.4.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "narai-primitives",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Read-only connectors + planning hub + connector framework + credential resolution, in one package. Bundles what was @narai/connector-toolkit, @narai/connector-config, @narai/connector-hub, the seven @narai/<svc>-agent-connector packages, and @narai/credential-providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/hub/index.js",
|
|
@@ -124,6 +124,11 @@
|
|
|
124
124
|
"marklassian": "^1.2.1",
|
|
125
125
|
"zod": "^3.23.0"
|
|
126
126
|
},
|
|
127
|
+
"overrides": {
|
|
128
|
+
"@anthropic-ai/claude-agent-sdk": {
|
|
129
|
+
"zod": "$zod"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
127
132
|
"optionalDependencies": {
|
|
128
133
|
"@aws-sdk/client-cloudwatch": "^3.1030.0",
|
|
129
134
|
"@aws-sdk/client-dynamodb": "^3.1030.0",
|
|
@@ -229,8 +229,9 @@ async function onPreToolUse(cfg) {
|
|
|
229
229
|
payload = JSON.parse(stdin);
|
|
230
230
|
} catch {
|
|
231
231
|
// Unparseable tool input: under fail-closed, deny rather than fall open.
|
|
232
|
-
//
|
|
233
|
-
|
|
232
|
+
// No manifest to read here, so the posture comes from the env var or the
|
|
233
|
+
// plugin-config default (cfg.enforcement).
|
|
234
|
+
if (effectiveEnforcement(cfg.enforcement) === "fail_closed") {
|
|
234
235
|
process.stdout.write(JSON.stringify({
|
|
235
236
|
hookSpecificOutput: {
|
|
236
237
|
hookEventName: "PreToolUse",
|
|
@@ -333,7 +334,7 @@ async function onPreToolUse(cfg) {
|
|
|
333
334
|
process.stderr.write(
|
|
334
335
|
`dispatcher: plugin-root gate scan failed (${err.message})\n`,
|
|
335
336
|
);
|
|
336
|
-
if (effectiveEnforcement(
|
|
337
|
+
if (effectiveEnforcement(cfg.enforcement) === "fail_closed") {
|
|
337
338
|
decisions.push({
|
|
338
339
|
decision: "deny",
|
|
339
340
|
reason: `fail-closed enforcement: gates manifest at ${pluginGatesFile} could not be parsed`,
|
|
@@ -368,7 +369,7 @@ async function onPreToolUse(cfg) {
|
|
|
368
369
|
process.stderr.write(
|
|
369
370
|
`dispatcher: gate scan failed for ${gatesFile} (${err.message})\n`,
|
|
370
371
|
);
|
|
371
|
-
if (effectiveEnforcement(
|
|
372
|
+
if (effectiveEnforcement(cfg.enforcement) === "fail_closed") {
|
|
372
373
|
decisions.push({
|
|
373
374
|
decision: "deny",
|
|
374
375
|
reason: `fail-closed enforcement: gates manifest at ${gatesFile} could not be parsed`,
|
|
@@ -558,14 +559,124 @@ export function expandPattern(pattern) {
|
|
|
558
559
|
return pattern.replace(/__PROTECTED_BRANCHES__/g, `(?:${escaped.join("|")})`);
|
|
559
560
|
}
|
|
560
561
|
|
|
562
|
+
function escapeRe(s) {
|
|
563
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Turn a literal string into a case-insensitive regex fragment via character
|
|
567
|
+
// classes. Used for verbs and hostnames so the matcher needs no global `i`
|
|
568
|
+
// flag (which would wrongly conflate curl's case-sensitive `-X` method flag
|
|
569
|
+
// with `-x`, the proxy flag).
|
|
570
|
+
function ciFragment(s) {
|
|
571
|
+
return s.replace(/[a-zA-Z]/g, (c) => `[${c.toLowerCase()}${c.toUpperCase()}]`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Build a predicate `(segment) => boolean` for an `external_write` gate rule.
|
|
576
|
+
* It fires when a state-changing HTTP request targets an allowlisted host, or
|
|
577
|
+
* a configured write CLI subcommand appears. The host is matched at a real URL
|
|
578
|
+
* host boundary — immediately after `scheme://` (and optional `userinfo@`),
|
|
579
|
+
* consuming whole dotted labels and ending at a host delimiter — so an
|
|
580
|
+
* allowlisted host cannot be spoofed via path, query, userinfo, subdomain
|
|
581
|
+
* suffix (`atlassian.net.evil.com`) or label prefix (`evil-atlassian.net`).
|
|
582
|
+
* Verbs cover curl `-X`/`--request`, wget `--method`, and HTTPie's positional
|
|
583
|
+
* `http[s] VERB <url>` form. Throws on a malformed rule shape (so the caller
|
|
584
|
+
* can fail closed).
|
|
585
|
+
*/
|
|
586
|
+
export function buildExternalWriteMatcher(rule) {
|
|
587
|
+
const methods = rule.methods;
|
|
588
|
+
const hosts = rule.allowed_hosts;
|
|
589
|
+
const writeCli = rule.write_cli;
|
|
590
|
+
if (
|
|
591
|
+
!Array.isArray(methods) || methods.length === 0 ||
|
|
592
|
+
!methods.every((m) => typeof m === "string" && m.length > 0)
|
|
593
|
+
) {
|
|
594
|
+
throw new Error("external_write: 'methods' must be a non-empty string array");
|
|
595
|
+
}
|
|
596
|
+
if (
|
|
597
|
+
!Array.isArray(hosts) ||
|
|
598
|
+
!hosts.every((h) => typeof h === "string" && h.length > 0)
|
|
599
|
+
) {
|
|
600
|
+
throw new Error("external_write: 'allowed_hosts' must be a string array");
|
|
601
|
+
}
|
|
602
|
+
if (
|
|
603
|
+
writeCli !== undefined &&
|
|
604
|
+
(!Array.isArray(writeCli) ||
|
|
605
|
+
!writeCli.every((c) => typeof c === "string" && c.length > 0))
|
|
606
|
+
) {
|
|
607
|
+
throw new Error("external_write: 'write_cli' must be a string array of subcommands");
|
|
608
|
+
}
|
|
609
|
+
const methodSet = new Set(methods.map((m) => String(m).toUpperCase()));
|
|
610
|
+
const VERB = `(?:${methods.map((m) => ciFragment(escapeRe(m))).join("|")})`;
|
|
611
|
+
const HOST = `(?:${hosts.map((h) => ciFragment(escapeRe(h))).join("|")})`;
|
|
612
|
+
// Host matched at a real URL host position: at a token start, or after an
|
|
613
|
+
// optional `scheme:/[/]` and optional `userinfo@`. curl accepts a missing
|
|
614
|
+
// scheme (defaults to http) and a single slash; the LAST `@` delimits
|
|
615
|
+
// userinfo. Whole dotted labels are consumed and a trailing FQDN dot is
|
|
616
|
+
// tolerated, so an allowlisted host cannot be spoofed via path, query,
|
|
617
|
+
// userinfo, a subdomain suffix (`atlassian.net.evil.com`), or a label prefix
|
|
618
|
+
// (`evil-atlassian.net`).
|
|
619
|
+
const TOKEN = `(?:^|[\\s'"=])`;
|
|
620
|
+
const SCHEME = `(?:[A-Za-z][A-Za-z0-9+.\\-]*:\\/{1,2})?`;
|
|
621
|
+
const USER = `(?:[^/?#\\s'"]*@)?`;
|
|
622
|
+
const SUB = `(?:[A-Za-z0-9\\-]+\\.)*`;
|
|
623
|
+
const HOSTPART = `${SUB}${HOST}\\.?(?=[:/?#\\s'"]|$)`;
|
|
624
|
+
const urlHostRe = new RegExp(`${TOKEN}${SCHEME}${USER}${HOSTPART}`);
|
|
625
|
+
// Command names are matched case-insensitively (a case-insensitive
|
|
626
|
+
// filesystem resolves `CURL`/`HTTP` to the real binary). The verb and data
|
|
627
|
+
// flags below stay case-sensitive on purpose, so `-X` is not confused with
|
|
628
|
+
// `-x` (proxy), nor `-d` with `-D` (dump-header), etc.
|
|
629
|
+
const curlWgetRe = /\b(?:curl|wget)\b/i;
|
|
630
|
+
// Explicit verb: curl `-X`/`--request`, wget `--method`. `-X` stays
|
|
631
|
+
// case-sensitive so it is not confused with `-x` (curl's proxy flag); the
|
|
632
|
+
// separator tolerates `=` (`-X=POST`) and a line-continuation backslash.
|
|
633
|
+
const verbFlagRe = new RegExp(
|
|
634
|
+
`(?:^|\\s)(?:-X[\\s=\\\\]*|--request[\\s=]+|--method[\\s=]+)${VERB}\\b`,
|
|
635
|
+
);
|
|
636
|
+
// curl/wget flags that imply a method even without an explicit verb.
|
|
637
|
+
const postFlagRe =
|
|
638
|
+
/(?:^|\s)(?:-d|-F|--data(?:-raw|-binary|-urlencode|-ascii)?|--form|--form-string|--json|--post-data|--post-file)\b/;
|
|
639
|
+
const putFlagRe = /(?:^|\s)(?:-T|--upload-file)\b/;
|
|
640
|
+
// HTTPie: a positional verb (optionally after flags), or an implicit POST
|
|
641
|
+
// when a request carries a body data item (`key=value`, `key:=json`,
|
|
642
|
+
// `key@file`, but not `key==query`).
|
|
643
|
+
const httpieVerbRe = new RegExp(
|
|
644
|
+
`^\\s*https?\\s+(?:-{1,2}\\S+\\s+)*${VERB}\\s+${SCHEME}${USER}${HOSTPART}`,
|
|
645
|
+
"i",
|
|
646
|
+
);
|
|
647
|
+
const httpieCmdRe = /^\s*https?\s/i;
|
|
648
|
+
const httpieItemRe = /(?:^|\s)[A-Za-z_][A-Za-z0-9_.\-]*(?::=|=(?!=)|@)/;
|
|
649
|
+
const cliRes = (writeCli ?? []).map(
|
|
650
|
+
(c) => new RegExp(`\\b${c.trim().split(/\s+/).map(escapeRe).join("\\s+")}\\b`),
|
|
651
|
+
);
|
|
652
|
+
return (segment) => {
|
|
653
|
+
if (urlHostRe.test(segment)) {
|
|
654
|
+
if (curlWgetRe.test(segment)) {
|
|
655
|
+
if (verbFlagRe.test(segment)) return true;
|
|
656
|
+
if (methodSet.has("POST") && postFlagRe.test(segment)) return true;
|
|
657
|
+
if (methodSet.has("PUT") && putFlagRe.test(segment)) return true;
|
|
658
|
+
}
|
|
659
|
+
if (
|
|
660
|
+
methodSet.has("POST") &&
|
|
661
|
+
httpieCmdRe.test(segment) &&
|
|
662
|
+
httpieItemRe.test(segment)
|
|
663
|
+
) return true;
|
|
664
|
+
}
|
|
665
|
+
if (httpieVerbRe.test(segment)) return true;
|
|
666
|
+
for (const r of cliRes) if (r.test(segment)) return true;
|
|
667
|
+
return false;
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
561
671
|
/**
|
|
562
672
|
* Apply a parsed gates.json manifest to a command. Rules with invalid
|
|
563
673
|
* shape, disabled names, or uncompilable patterns are skipped. Anchored
|
|
564
674
|
* patterns match per-segment so chaining (`echo ok; psql ...`) can't bypass.
|
|
565
675
|
*
|
|
566
676
|
* Under fail-closed enforcement (env var or the manifest's `enforcement`
|
|
567
|
-
* field), a rule whose pattern will not compile
|
|
568
|
-
*
|
|
677
|
+
* field), a rule whose pattern will not compile (or whose external_write
|
|
678
|
+
* shape is malformed) becomes a hard deny instead of being silently skipped
|
|
679
|
+
* — we cannot prove the command is safe.
|
|
569
680
|
*/
|
|
570
681
|
export function applyGatesManifest(manifest, source, text, disabled, decisions, scanTool = "Bash") {
|
|
571
682
|
const enforcement = effectiveEnforcement(manifest.enforcement);
|
|
@@ -587,28 +698,46 @@ export function applyGatesManifest(manifest, source, text, disabled, decisions,
|
|
|
587
698
|
return;
|
|
588
699
|
}
|
|
589
700
|
for (const rule of manifest.rules ?? []) {
|
|
590
|
-
if (
|
|
591
|
-
!["deny", "ask", "allow"].includes(rule.decision) ||
|
|
592
|
-
typeof rule.pattern !== "string"
|
|
593
|
-
) continue;
|
|
701
|
+
if (!["deny", "ask", "allow"].includes(rule.decision)) continue;
|
|
594
702
|
if (typeof rule.name === "string" && disabled.has(rule.name)) continue;
|
|
595
703
|
// A rule applies to a tool only if listed in `applies_to`. Default is
|
|
596
704
|
// Bash-only, so every existing rule keeps its current behavior and is
|
|
597
705
|
// skipped on Write/Edit unless it explicitly opts in.
|
|
598
706
|
const appliesTo = Array.isArray(rule.applies_to) ? rule.applies_to : ["Bash"];
|
|
599
707
|
if (!appliesTo.includes(scanTool)) continue;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
708
|
+
|
|
709
|
+
// A rule is either a declarative `external_write` (host-allowlist) rule or
|
|
710
|
+
// a `pattern` (regex) rule. Both reduce to a `(segment) => boolean` matcher.
|
|
711
|
+
let matchFn;
|
|
712
|
+
if (rule.type === "external_write") {
|
|
713
|
+
try {
|
|
714
|
+
matchFn = buildExternalWriteMatcher(rule);
|
|
715
|
+
} catch {
|
|
716
|
+
if (enforcement === "fail_closed") {
|
|
717
|
+
decisions.push({
|
|
718
|
+
decision: "deny",
|
|
719
|
+
reason: `fail-closed enforcement: ${source} external-write rule '${rule.name ?? "rule"}' is malformed`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
continue;
|
|
607
723
|
}
|
|
608
|
-
|
|
724
|
+
} else {
|
|
725
|
+
if (typeof rule.pattern !== "string") continue;
|
|
726
|
+
let re;
|
|
727
|
+
try { re = new RegExp(expandPattern(rule.pattern)); } catch {
|
|
728
|
+
if (enforcement === "fail_closed") {
|
|
729
|
+
decisions.push({
|
|
730
|
+
decision: "deny",
|
|
731
|
+
reason: `fail-closed enforcement: ${source} gate rule '${rule.name ?? "rule"}' has an invalid pattern`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
matchFn = (segment) => re.test(segment);
|
|
609
737
|
}
|
|
738
|
+
|
|
610
739
|
for (const segment of segments) {
|
|
611
|
-
if (
|
|
740
|
+
if (matchFn(segment)) {
|
|
612
741
|
decisions.push({
|
|
613
742
|
decision: rule.decision,
|
|
614
743
|
reason: rule.reason ?? `${source} gate: ${rule.name ?? "rule"}`,
|
|
@@ -4,9 +4,16 @@
|
|
|
4
4
|
* Shape:
|
|
5
5
|
* { name: string,
|
|
6
6
|
* binPath?: string,
|
|
7
|
-
* kind?: "connector" | "db" | "hook-only"
|
|
7
|
+
* kind?: "connector" | "db" | "hook-only",
|
|
8
|
+
* enforcement?: "fail_open" | "fail_closed" }
|
|
9
|
+
*
|
|
10
|
+
* `enforcement` declares the default fail-closed posture for this plugin's
|
|
11
|
+
* gate evaluation, so an operator can set it once in config instead of
|
|
12
|
+
* exporting NARAI_GATE_ENFORCEMENT on every hook invocation. Absent ⇒ the
|
|
13
|
+
* dispatcher falls through to the env var (default fail_open).
|
|
8
14
|
*/
|
|
9
15
|
const VALID_KINDS = new Set(["connector", "db", "hook-only"]);
|
|
16
|
+
const VALID_ENFORCEMENT = new Set(["fail_open", "fail_closed"]);
|
|
10
17
|
|
|
11
18
|
export function parsePluginConfig(raw) {
|
|
12
19
|
let parsed;
|
|
@@ -29,8 +36,17 @@ export function parsePluginConfig(raw) {
|
|
|
29
36
|
`plugin-config.json: 'kind' must be one of ${[...VALID_KINDS].join(", ")}`,
|
|
30
37
|
);
|
|
31
38
|
}
|
|
39
|
+
if (
|
|
40
|
+
parsed.enforcement !== undefined &&
|
|
41
|
+
!VALID_ENFORCEMENT.has(parsed.enforcement)
|
|
42
|
+
) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`plugin-config.json: 'enforcement' must be one of ${[...VALID_ENFORCEMENT].join(", ")}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
32
47
|
const out = { name: parsed.name };
|
|
33
48
|
if (parsed.binPath !== undefined) out.binPath = parsed.binPath;
|
|
34
49
|
if (parsed.kind !== undefined) out.kind = parsed.kind;
|
|
50
|
+
if (parsed.enforcement !== undefined) out.enforcement = parsed.enforcement;
|
|
35
51
|
return out;
|
|
36
52
|
}
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"pattern": "^glab\\s+mr\\s+create\\b(?!.*\\s-?-draft\\b)|\\bcurl\\b(?!.*\\bdraft\\b).*(\\s-X\\s+POST\\b|\\s--request\\s+POST\\b).*\\bmerge_requests\\b|\\bcurl\\b(?!.*\\bdraft\\b).*\\bmerge_requests\\b.*(\\s-X\\s+POST\\b|\\s--request\\s+POST\\b)"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"name": "
|
|
12
|
+
"name": "gitlab_external_write",
|
|
13
|
+
"type": "external_write",
|
|
13
14
|
"decision": "ask",
|
|
14
|
-
"reason": "
|
|
15
|
-
"
|
|
15
|
+
"reason": "State-changing HTTP request (POST/PUT/DELETE/PATCH) to a GitLab host. Confirm before mutating remote data. This is a conservative example preset; set allowed_hosts to your GitLab host (gitlab.com or your self-hosted domain) in ~/.connectors/connectors/gitlab/gates.json.",
|
|
16
|
+
"methods": ["POST", "PUT", "DELETE", "PATCH"],
|
|
17
|
+
"allowed_hosts": ["gitlab.com"]
|
|
16
18
|
}
|
|
17
19
|
]
|
|
18
20
|
}
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
"name": "jira",
|
|
4
4
|
"rules": [
|
|
5
5
|
{
|
|
6
|
-
"name": "
|
|
6
|
+
"name": "jira_external_write",
|
|
7
|
+
"type": "external_write",
|
|
7
8
|
"decision": "ask",
|
|
8
|
-
"reason": "
|
|
9
|
-
"
|
|
9
|
+
"reason": "State-changing HTTP request (POST/PUT/DELETE/PATCH) to an Atlassian Cloud host. Confirm before mutating remote issue data. This is a conservative example preset; add your own host(s) to allowed_hosts in ~/.connectors/connectors/jira/gates.json.",
|
|
10
|
+
"methods": ["POST", "PUT", "DELETE", "PATCH"],
|
|
11
|
+
"allowed_hosts": ["atlassian.net"]
|
|
10
12
|
}
|
|
11
13
|
]
|
|
12
14
|
}
|