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.0",
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
- // Env-only here (there is no manifest to read an enforcement field from).
233
- if (effectiveEnforcement(undefined) === "fail_closed") {
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(undefined) === "fail_closed") {
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(undefined) === "fail_closed") {
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 becomes a hard deny instead
568
- * of being silently skipped we cannot prove the command is safe.
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
- let re;
601
- try { re = new RegExp(expandPattern(rule.pattern)); } catch {
602
- if (enforcement === "fail_closed") {
603
- decisions.push({
604
- decision: "deny",
605
- reason: `fail-closed enforcement: ${source} gate rule '${rule.name ?? "rule"}' has an invalid pattern`,
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
- continue;
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 (re.test(segment)) {
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": "gitlab_state_changing_http",
12
+ "name": "gitlab_external_write",
13
+ "type": "external_write",
13
14
  "decision": "ask",
14
- "reason": "This looks like a state-changing HTTP request (POST/PUT/DELETE/PATCH) to a GitLab host. Confirm before mutating remote data. This is a conservative example preset; customize the host and verbs in your own gates.json.",
15
- "pattern": "\\b(curl|http|https|wget)\\b.*(\\s-X\\s+(POST|PUT|DELETE|PATCH)\\b|\\s--request\\s+(POST|PUT|DELETE|PATCH)\\b).*\\bgitlab\\b|\\b(curl|http|https|wget)\\b.*\\bgitlab\\b.*(\\s-X\\s+(POST|PUT|DELETE|PATCH)\\b|\\s--request\\s+(POST|PUT|DELETE|PATCH)\\b)"
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": "jira_state_changing_http",
6
+ "name": "jira_external_write",
7
+ "type": "external_write",
7
8
  "decision": "ask",
8
- "reason": "This looks like a state-changing HTTP request (POST/PUT/DELETE/PATCH) to a Jira/Atlassian host. Confirm before mutating remote issue data. This is a conservative example preset; customize the host and verbs in your own gates.json.",
9
- "pattern": "\\b(curl|http|https|wget)\\b.*(\\s-X\\s+(POST|PUT|DELETE|PATCH)\\b|\\s--request\\s+(POST|PUT|DELETE|PATCH)\\b).*\\b(atlassian\\.net|jira)\\b|\\b(curl|http|https|wget)\\b.*\\b(atlassian\\.net|jira)\\b.*(\\s-X\\s+(POST|PUT|DELETE|PATCH)\\b|\\s--request\\s+(POST|PUT|DELETE|PATCH)\\b)"
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
  }