@shomra/agent 0.3.2 → 0.3.3

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/design.mjs CHANGED
@@ -46,11 +46,16 @@ export const CAP_LABEL = {
46
46
  * the attack story, so a path reads as a sentence about the system rather than a
47
47
  * list of flags.
48
48
  *
49
- * Rules are matched per line so the evidence can cite one, and so a document
50
- * that mentions a capability in a "we will not do X" sentence is still surfaced
51
- * unlike the runtime detectors, a design doc's negations are DESIGN DECISIONS
52
- * worth showing the reader, not false positives to suppress. The reader decides.
49
+ * Rules are matched per line so the evidence can cite one.
50
+ *
51
+ * A line that DISCLAIMS a capability must not grant it. This originally said
52
+ * the opposite that a "we will not do X" sentence was a design decision worth
53
+ * surfacing — and that was wrong: granting the flag from a disclaimer invents a
54
+ * phantom attack path out of the one sentence that rules it out, which is the
55
+ * most misleading output this tool can produce. "The tool has no access to
56
+ * customer data" is the designer telling you the source does not exist.
53
57
  */
58
+ const DISCLAIMER_RE = /\b(no|never|not|without|excludes?|excluding|neither|nor)\b[^.\n]{0,30}\b(access|read|write|permission|abilit|able|connection|integration)\w*|\b(does |do |will |can |must )(not|n't)\b|\bout of scope\b|\bnon-goals?\b|\bis not (able|permitted|allowed)\b/i;
54
59
  const CAP_RULES = [
55
60
  // ── SOURCES ────────────────────────────────────────────────────────────────
56
61
  { cap: 'injection', what: 'end-user or customer text', re: /\b(user|customer|client|end[- ]user)[- ]?(input|message|text|query|prompt|request|content|submission)\b/i },
@@ -79,12 +84,15 @@ const CAP_RULES = [
79
84
  { cap: 'readsSensitive', what: 'private source code', re: /\bprivate (repo|repositor\w+|source|code)\b|\bproprietary (code|source)\b|\binternal (repo|codebase|wiki|docs?)\b/i },
80
85
  { cap: 'readsSensitive', what: 'object storage', re: /\bS3 bucket\b|\b(blob|object) storage\b|\bGCS bucket\b|\bdata lake\b/i },
81
86
 
82
- { cap: 'filesystem', what: 'file writes', re: /\bwrit\w+\b.{0,25}\b(file|disk|filesystem|directory|folder|repo)\b|\b(file ?system|local files?) (access|write)\b|\bcommits? (code|files?|changes?)\b/i },
87
+ // Plurals throughout: "writes the generated files to the repo" is the normal
88
+ // phrasing, and `\bfile\b` cannot match it. Same blind spot as `\bpdf\b` vs
89
+ // "PDFs" — assume every noun here arrives plural at least half the time.
90
+ { cap: 'filesystem', what: 'file writes', re: /\bwrit\w+\b[^.\n]{0,25}\b(files?|disks?|filesystems?|director(y|ies)|folders?|repos?|repositor(y|ies))\b|\b(file ?system|local files?) (access|write)\b|\bcommits? (code|files?|changes?)\b/i },
83
91
  { cap: 'filesystem', what: 'workspace or repo checkout', re: /\b(clones?|checks? out|checkout)\b.{0,25}\b(repo|repositor\w+)\b|\bworkspace (access|mount|volume)\b/i },
84
92
 
85
93
  // ── SINKS ──────────────────────────────────────────────────────────────────
86
94
  { cap: 'network', what: 'outbound API calls', re: /\bcalls?\b.{0,30}\b(external|third[- ]party|public|remote|partner)\b.{0,20}\bapi\b|\boutbound (request|call|http|traffic)\b|\begress\b/i },
87
- { cap: 'network', what: 'webhooks', re: /\bwebhooks?\b|\bpost(s|ing)?\b.{0,25}\b(to an? )?(endpoint|url|callback)\b/i },
95
+ { cap: 'network', what: 'webhooks', re: /\bwebhooks?\b|\bpost(s|ing|ed)?\b[^.\n]{0,30}\bto\b[^.\n]{0,25}\b(endpoints?|urls?|callbacks?|apis?|services?|partners?|systems?)\b/i },
88
96
  { cap: 'network', what: 'sending email or messages', re: /\bsends?\b.{0,25}\b(e-?mail|message|notification|sms|slack|dm)\b|\bnotif(y|ies|ication)\b.{0,25}\b(user|customer|channel|slack|teams|email)\b|\bsmtp\b/i },
89
97
  { cap: 'network', what: 'publishing or uploading data', re: /\b(publish|upload|export|sync|push)\w*\b.{0,30}\b(to|into)\b.{0,25}\b(external|third[- ]party|cloud|bucket|service|partner|crm|warehouse)\b/i },
90
98
  { cap: 'network', what: 'a model provider call', re: /\b(openai|anthropic|gemini|bedrock|azure openai|mistral|cohere|hugging ?face)\b|\bLLM (api|provider|call)\b|\bmodel (provider|endpoint|api)\b/i },
@@ -95,7 +103,10 @@ const CAP_RULES = [
95
103
  { cap: 'exec', what: 'agent tool calls', re: /\b(tool[- ]call|function[- ]call|tool use|agentic loop|autonomous(ly)?)\b|\bagent (executes?|acts?|takes? actions?)\b/i },
96
104
 
97
105
  { cap: 'destructive', what: 'deleting data', re: /\bdelet\w+|\bremov\w+\b.{0,20}\b(record|row|file|user|account|data)\b|\bpurge\b|\bdrop (table|database)\b|\btruncat\w+/i },
98
- { cap: 'destructive', what: 'moving money', re: /\b(refund|payment|charge|invoice|payout|transfer|billing|subscription|purchase|order)s?\b|\bstripe\b|\bmoves? money\b/i },
106
+ // A money NOUN alone is not a capability — "the dashboard displays the refund
107
+ // history" is a read. The line has to name an act that MOVES the money, so the
108
+ // verb is required and read-only verbs are not on the list.
109
+ { cap: 'destructive', what: 'moving money', re: /\b(issues?|issuing|processes|processing|triggers?|initiates?|creates?|approves?|grants?|sends?|makes?|charges?|refunds?|voids?|cancels?)\b[^.\n]{0,30}\b(refunds?|payments?|charges?|invoices?|payouts?|transfers?|subscriptions?|purchases?|orders?)\b|\bmoves? money\b|\bthrough stripe\b|\bvia stripe\b/i },
99
110
  { cap: 'destructive', what: 'changing access or state', re: /\b(revok\w+|disabl\w+|suspend\w+|deactivat\w+|cancel\w*|ban\w*)\b.{0,25}\b(user|account|access|key|token|subscription|service)\b|\bgrants? (access|permission|role)\b/i },
100
111
  { cap: 'destructive', what: 'writing to production', re: /\bwrit\w+\b.{0,25}\bprod(uction)?\b|\bprod(uction)?\b.{0,20}\bwrite (access|path)\b|\bmutat\w+\b.{0,25}\b(prod|live|customer) (data|state)\b/i },
101
112
  ];
@@ -123,6 +134,9 @@ export function capsFromProse(text) {
123
134
  // Code blocks in a design doc are illustrative snippets, not statements of
124
135
  // intent — and they are exactly where a scanner's vocabulary produces noise.
125
136
  if (inFence || isSkippableLine(line)) continue;
137
+ // A disclaimer names the capability in order to rule it out. Granting the
138
+ // flag here would invent an attack path from the sentence that removes it.
139
+ if (DISCLAIMER_RE.test(line)) continue;
126
140
 
127
141
  for (const r of CAP_RULES) {
128
142
  const m = r.re.exec(line);
package/guard-signals.mjs CHANGED
@@ -709,20 +709,40 @@ function localCommandExtras(content) {
709
709
  // conceal from the user, disable safety, exfiltrate).
710
710
  const PERSISTENCE_MARKERS = /\b(in (all|every|future) (sessions?|conversations?|chats?|projects?)|from now on|going forward|permanently|persist(ent|ed)?|across (all )?sessions|every time|each time|whenever you|forever|always remember to|never forget( to)?|for all future)\b/i;
711
711
  const MALICIOUS_OVERRIDE = /\b(ignore (all |any |the )?(previous|prior|earlier|above|system)|disregard (the |your |all )?(instructions?|guidelines?|system|rules?)|do not (tell|inform|mention|reveal|disclose) (the |any)?(user|anyone|them)|without (telling|informing|asking|notifying) the user|no matter what (the )?(user|system|instructions?) (say|says|state)|bypass (the |all )?(safety|guard|security|policy|restrictions?))\b/i;
712
- const PRECEDENCE_MARKERS = /\b(regardless of (what|any|your|the)|overrid(e|ing|es)|supersede?s?|takes? precedence|highest[- ]priority)\b/i;
712
+ // Backend parity: a bare `override` matched "the env var overrides the default
713
+ // port", so the verb now needs an object that makes it a precedence CLAIM.
714
+ const PRECEDENCE_MARKERS = /\b(regardless of (what|any|your|the)|supersede?s?|takes? precedence|highest[- ]priority|overrid(e|ing|es)\b[^.\n]{0,30}\b(instruction|prompt|rule|system|user|guidance|directive|context|behaviou?r|polic|guardrail|safety))\b/i;
713
715
  const OVERRIDE_MARKERS = new RegExp(`${MALICIOUS_OVERRIDE.source}|${PRECEDENCE_MARKERS.source}`, 'i');
714
- const AUTHORITY_SPOOF_STRONG = /(^|\n)\s*(#{0,3}\s*system\s*(prompt|message|instruction)?\s*[:>]|\[system\]|<\/?system>|\bas an? (system|admin|root|developer)[- ]?(instruction|directive|message|mode)|authority\s*[:=]\s*(system|admin|root)|you are now\b|new (system )?(instructions?|directive)s?\s*[:>])/i;
715
- const AUTHORITY_SPOOF_SOFT = /priority\s*[:=]\s*(high|critical|max|urgent)/i;
716
- const AUTHORITY_SPOOF = new RegExp(`${AUTHORITY_SPOOF_STRONG.source}|${AUTHORITY_SPOOF_SOFT.source}`, 'i');
717
- const LIFECYCLE_VECTOR = /\b(postinstall|preinstall|node[_-]?gyp|npm (run |lifecycle)|package\.json.{0,40}scripts|\.npmrc|install hook|lifecycle (script|hook))\b/i;
716
+ // Backend parity. The noun after "system" is MANDATORY (`system\s+(prompt|
717
+ // message|instruction)s?`), not optional: with it optional, an ordinary markdown
718
+ // heading "## System: NestJS 10 + Prisma 6" — scored as authority spoofing.
719
+ const AUTHORITY_SPOOF_STRONG = /(^|\n)\s*(#{0,3}\s*system\s+(prompt|message|instruction)s?\s*[:>]|\[system\]|<\/?system>|\bas an? (system|admin|root|developer)[- ]?(instruction|directive|message|mode)|authority\s*[:=]\s*(system|admin|root)|you are now\b|new (system )?(instructions?|directive)s?\s*[:>])/i;
720
+ // ⚠ There is deliberately no SOFT tier. `priority: high` is a TODO tag in every
721
+ // issue tracker ever built; scoring it as authority spoofing was pure noise. The
722
+ // backend dropped it and the mirror follows — do not reintroduce it.
723
+ const AUTHORITY_SPOOF = AUTHORITY_SPOOF_STRONG;
724
+ // Backend parity: `npm run ` matched every "run npm run db:generate" note in a
725
+ // developer's memory, and the `.` wildcard crossed lines. The MemoryTrap vector
726
+ // is a LIFECYCLE hook, not the npm CLI.
727
+ const LIFECYCLE_VECTOR = /\b(postinstall|preinstall|node[_-]?gyp|npm\s+lifecycle|package\.json[^.\n]{0,40}scripts|\.npmrc|install hook|lifecycle (script|hook))\b/i;
718
728
  const IMPERATIVE = /\b(always|never|must|do not|don'?t|ensure you|make sure( you)?|be sure to|you should always|you must|remember to|whenever|when(ever)? (asked|the user)|instead of .*,? (use|do|say)|reply with|respond with|tell (the )?user)\b/i;
719
729
  const NEGATION_GUARD = /\b(never|do not|don'?t|cannot|can'?t|avoid|refuse|must not|mustn'?t|should not|shouldn'?t|won'?t|will not|under no circumstances|forbidden|prohibited|not allowed|disallow(ed)?)\b/i;
720
730
  const SABOTAGE_RULES = [
721
- { re: /\b(disabl|turn(ing)? off|deactivat|switch off|remov|drop|skip|suppress|circumvent)\w*\b[^.\n]{0,50}\b(security|safety|guard(?:rail)?s?|protection|moderation|content[- ]?filters?|safeguards?|sandbox(?:ing)?|checks?|flags?|controls?|restrictions?|policies|policy|filters?)\b/i, label: 'disable-safety', guarded: true },
731
+ // Object list drops `checks`/`flags` (backend parity): "skip the OSV checks in
732
+ // CI, they are flaky" is a developer note about test infrastructure, not an
733
+ // instruction to disable a guardrail.
734
+ { re: /\b(disabl|turn(ing)? off|deactivat|switch off|remov|drop|skip|suppress|circumvent)\w*\b[^.\n]{0,50}\b(security|safety|guard(?:rail)?s?|protection|moderation|content[- ]?filters?|safeguards?|sandbox(?:ing)?|controls?|restrictions?|policies|policy|filters?)\b/i, label: 'disable-safety', guarded: true },
722
735
  { re: /\bbypass(?:ing)?\b[^.\n]{0,50}\b(human(?:[- ]in[- ]the[- ]loop)?|hitl|verification|approval|confirmation|review|guard(?:rail)?s?|safety|security|checks?|policy|policies|restrictions?|sandbox|permission)\b/i, label: 'bypass-controls', guarded: true },
723
736
  { re: /\bprioriti[sz]e\b[^.\n]{0,60}\b(above|over)\b[^.\n]{0,40}\b(prompt|instruction|input|request|message|command|direction)s?\b/i, label: 'priority-hijack', guarded: true },
724
- { re: /\bignore\b[^.\n]{0,40}\b(user|human)\b[^.\n]{0,25}\b(prompt|instruction|input|request|message|command|wish|intent|question)s?\b/i, label: 'ignore-user', guarded: true },
725
- { re: /\bdo not\b[^.\n]{0,20}\b(log|display|show|print|record|surface|expose|output)\b[^.\n]{0,60}\buser\b/i, label: 'conceal-from-user', guarded: false, context: /\b(transfer|transmit|send|network|exfil|upload|post|copy|collect|file|data|when)\b/i },
737
+ // Object list drops `input`/`message` (backend parity): "ignore any user input
738
+ // that doesn't parse" is input validation. Hijack targets the user's
739
+ // prompt/instruction/request/command/intent, which are retained.
740
+ { re: /\bignore\b[^.\n]{0,40}\b(user|human)\b[^.\n]{0,25}\b(prompt|instruction|request|command|wish|intent|question)s?\b/i, label: 'ignore-user', guarded: true },
741
+ // Backend parity, two narrowings. The `(?!'s)` lookahead keeps "do not log the
742
+ // USER'S data" out — that is a privacy rule, not concealment FROM the user —
743
+ // and the context list drops `file|data|when`, which matched almost any
744
+ // sentence and made the context requirement decorative.
745
+ { re: /\bdo not\b[^.\n]{0,20}\b(log|display|show|print|record|surface|expose|output)\b[^.\n]{0,60}\buser\b(?!['’]s)/i, label: 'conceal-from-user', guarded: false, context: /\b(transfer|transmit|send|network|exfil|upload|post|copy|collect)\b/i },
726
746
  ];
727
747
  // Descriptive / documentation mood: a line that NAMES a security concept rather
728
748
  // than INSTRUCTING the agent to perform it. Poisoning payloads are imperative and
@@ -740,6 +760,37 @@ function isDescriptiveLine(line) {
740
760
  return DESCRIPTIVE_MARKERS.test(line) && !IMPERATIVE.test(line);
741
761
  }
742
762
 
763
+ /**
764
+ * The first line matching `re` that is a genuine directive — NOT a negated
765
+ * hardening rule ("never bypass safety") and NOT descriptive documentation
766
+ * ("detects skills that bypass safety").
767
+ *
768
+ * ⚠ Replaces whole-document `re.test(text)`, which the backend identified as the
769
+ * DOMINANT memory/rules-file false positive: it fires on a benign line anywhere
770
+ * in the file with no regard for mood or co-location, so "## System: NestJS 10"
771
+ * in a heading and "overrides the default port" in a note both scored CRITICAL.
772
+ * Mirrors firstDirectiveLine() in src/bundle/memory-signals.ts.
773
+ */
774
+ function firstDirectiveLine(text, re) {
775
+ for (const line of text.split(/\r?\n/)) {
776
+ if (!re.test(line)) continue;
777
+ if (NEGATION_GUARD.test(line)) continue;
778
+ if (isDescriptiveLine(line)) continue;
779
+ return line;
780
+ }
781
+ return null;
782
+ }
783
+
784
+ /** The first line where EVERY regex matches (co-located signal), else null.
785
+ * Whole-document co-occurrence was the dominant memory FP: "every time" in a
786
+ * quoted line and "always" forty lines away is not a durable imperative. */
787
+ function lineMatchingAll(text, ...res) {
788
+ for (const line of text.split(/\r?\n/)) {
789
+ if (res.every((re) => re.test(line))) return line;
790
+ }
791
+ return null;
792
+ }
793
+
743
794
  // A loopback / private-network URL is a dev/smoke-test target, not exfiltration.
744
795
  const LOCAL_URL_RE = /\bhttps?:\/\/(localhost|127\.\d+|0\.0\.0\.0|\[::1\]|192\.168\.|10\.\d+|172\.(1[6-9]|2\d|3[01])\.)/i;
745
796
 
@@ -800,16 +851,26 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
800
851
  const isInstruction = kind === 'INSTRUCTION';
801
852
  const noun = isInstruction ? 'rules file' : 'memory';
802
853
 
803
- const hasOverride = isInstruction ? MALICIOUS_OVERRIDE.test(text) : OVERRIDE_MARKERS.test(text);
804
- const hasAuthority = isInstruction ? AUTHORITY_SPOOF_STRONG.test(text) : AUTHORITY_SPOOF.test(text);
854
+ // Per-line and guarded (see firstDirectiveLine) rather than whole-document:
855
+ // a negated hardening rule ("never bypass the safety checks"), a descriptive
856
+ // note, or a markdown heading that happens to read like a marker must not
857
+ // score as a planted directive. Mirrors analyzeMemory() in the backend.
858
+ const overrideLine = firstDirectiveLine(text, isInstruction ? MALICIOUS_OVERRIDE : OVERRIDE_MARKERS);
859
+ const authorityLine = firstDirectiveLine(text, isInstruction ? AUTHORITY_SPOOF_STRONG : AUTHORITY_SPOOF);
860
+ const hasOverride = !!overrideLine;
861
+ const hasAuthority = !!authorityLine;
805
862
  const hasPersistence = PERSISTENCE_MARKERS.test(text);
806
863
  const hasImperative = IMPERATIVE.test(text);
864
+ // A durable imperative is only poisoning-shaped when the persistence marker and
865
+ // the imperative sit on the SAME line ("always do X in every future session") —
866
+ // not when "every time" is in one note and "always" is forty lines away.
867
+ const durableImperativeLine = !isInstruction ? lineMatchingAll(text, PERSISTENCE_MARKERS, IMPERATIVE) : null;
807
868
 
808
869
  if (hasOverride || hasAuthority) {
809
870
  const firedRe = hasAuthority ? (isInstruction ? AUTHORITY_SPOOF_STRONG : AUTHORITY_SPOOF) : (isInstruction ? MALICIOUS_OVERRIDE : OVERRIDE_MARKERS);
810
871
  push('CRITICAL', `Poisoned ${noun}: ${hasAuthority ? 'system-authority spoofing' : 'injected override directive'}`, `Remove the injected directive and roll the ${noun} back to its approved baseline; restrict who/what may write it.`, firedRe);
811
- } else if (!isInstruction && hasPersistence && hasImperative) {
812
- push('HIGH', 'Suspicious standing instruction in memory', 'Rewrite as a neutral fact or remove it. Encode intended standing behavior in a reviewed rules/policy file, not agent-writable memory.', PERSISTENCE_MARKERS);
872
+ } else if (durableImperativeLine && !isDescriptiveLine(durableImperativeLine)) {
873
+ push('HIGH', 'Suspicious standing instruction in memory', 'Rewrite as a neutral fact or remove it. Encode intended standing behavior in a reviewed rules/policy file, not agent-writable memory.', durableImperativeLine);
813
874
  }
814
875
 
815
876
  const { sabotage, exfil } = scanDirectives(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Shomra — adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
5
5
  "type": "module",
6
6
  "bin": {
package/shomra.mjs CHANGED
@@ -476,20 +476,62 @@ function detectEnv() {
476
476
  return undefined;
477
477
  };
478
478
  let ci = null;
479
- if (e.GITHUB_ACTIONS) ci = { ciProvider: 'github-actions', repo: e.GITHUB_REPOSITORY, ref: e.GITHUB_REF_NAME, commit: e.GITHUB_SHA };
480
- else if (e.GITLAB_CI) ci = { ciProvider: 'gitlab-ci', repo: e.CI_PROJECT_PATH, ref: e.CI_COMMIT_REF_NAME, commit: e.CI_COMMIT_SHA };
481
- else if (e.CIRCLECI) ci = { ciProvider: 'circleci', repo: e.CIRCLE_PROJECT_REPONAME, ref: e.CIRCLE_BRANCH, commit: e.CIRCLE_SHA1 };
482
- else if (e.TF_BUILD) ci = { ciProvider: 'azure-pipelines', repo: e.BUILD_REPOSITORY_NAME, ref: e.BUILD_SOURCEBRANCHNAME, commit: e.BUILD_SOURCEVERSION };
483
- else if (e.BITBUCKET_BUILD_NUMBER) ci = { ciProvider: 'bitbucket-pipelines', repo: e.BITBUCKET_REPO_FULL_NAME, ref: e.BITBUCKET_BRANCH, commit: e.BITBUCKET_COMMIT };
484
- else if (e.JENKINS_URL) ci = { ciProvider: 'jenkins', repo: pick('JOB_NAME'), ref: e.GIT_BRANCH, commit: e.GIT_COMMIT };
485
- else if (e.CI) ci = { ciProvider: 'ci', repo: undefined, ref: undefined, commit: undefined };
479
+ // `repoUrl` is the FULL remote URL, and it is the field that matters: `repo`
480
+ // is an owner/name slug with no host, so it cannot identify a repository (see
481
+ // the backend's common/repo-identity.ts). Every provider below exposes a real
482
+ // URL variable send it, and let the slug stay a display label.
483
+ if (e.GITHUB_ACTIONS)
484
+ ci = {
485
+ ciProvider: 'github-actions',
486
+ repo: e.GITHUB_REPOSITORY,
487
+ // GITHUB_SERVER_URL is github.com on the hosted runner and the appliance
488
+ // host on GitHub Enterprise Server — which is exactly the distinction the
489
+ // slug loses.
490
+ repoUrl: e.GITHUB_SERVER_URL && e.GITHUB_REPOSITORY ? `${e.GITHUB_SERVER_URL.replace(/\/+$/, '')}/${e.GITHUB_REPOSITORY}` : undefined,
491
+ ref: e.GITHUB_REF_NAME,
492
+ commit: e.GITHUB_SHA,
493
+ };
494
+ else if (e.GITLAB_CI)
495
+ ci = { ciProvider: 'gitlab-ci', repo: e.CI_PROJECT_PATH, repoUrl: e.CI_PROJECT_URL, ref: e.CI_COMMIT_REF_NAME, commit: e.CI_COMMIT_SHA };
496
+ else if (e.CIRCLECI)
497
+ ci = {
498
+ ciProvider: 'circleci',
499
+ repo: e.CIRCLE_PROJECT_REPONAME,
500
+ repoUrl: e.CIRCLE_REPOSITORY_URL,
501
+ ref: e.CIRCLE_BRANCH,
502
+ commit: e.CIRCLE_SHA1,
503
+ };
504
+ else if (e.TF_BUILD)
505
+ ci = {
506
+ ciProvider: 'azure-pipelines',
507
+ repo: e.BUILD_REPOSITORY_NAME,
508
+ repoUrl: e.BUILD_REPOSITORY_URI,
509
+ ref: e.BUILD_SOURCEBRANCHNAME,
510
+ commit: e.BUILD_SOURCEVERSION,
511
+ };
512
+ else if (e.BITBUCKET_BUILD_NUMBER)
513
+ ci = {
514
+ ciProvider: 'bitbucket-pipelines',
515
+ repo: e.BITBUCKET_REPO_FULL_NAME,
516
+ repoUrl: e.BITBUCKET_GIT_HTTP_ORIGIN,
517
+ ref: e.BITBUCKET_BRANCH,
518
+ commit: e.BITBUCKET_COMMIT,
519
+ };
520
+ // Jenkins' JOB_NAME is a job label, NOT a repository — it is kept as the
521
+ // display `repo` but must never be completed into a URL. GIT_URL is the real
522
+ // remote when the job checked one out.
523
+ else if (e.JENKINS_URL) ci = { ciProvider: 'jenkins', repo: pick('JOB_NAME'), repoUrl: pick('GIT_URL'), ref: e.GIT_BRANCH, commit: e.GIT_COMMIT };
524
+ else if (e.CI) ci = { ciProvider: 'ci', repo: undefined, repoUrl: undefined, ref: undefined, commit: undefined };
486
525
 
487
526
  if (ci) {
527
+ // The checkout on the runner is the same repository the provider variables
528
+ // describe, so git fills any variable the provider didn't set.
488
529
  const git = gitContext();
489
530
  return {
490
531
  environment: 'CI',
491
532
  ciProvider: ci.ciProvider,
492
533
  repo: ci.repo ?? git.repo,
534
+ repoUrl: ci.repoUrl ?? git.repoUrl,
493
535
  ref: ci.ref ?? git.ref,
494
536
  commit: ci.commit ?? git.commit,
495
537
  };
@@ -515,7 +557,20 @@ function gitContext() {
515
557
  const m = origin.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
516
558
  repo = m ? m[1] : undefined;
517
559
  }
518
- return { repo, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
560
+ // `repo` above is an owner/name slug with the HOST STRIPPED, so it is a
561
+ // display label and nothing more: `acme/api` on github.com and on a
562
+ // self-hosted GitLab produce the identical string, and keying developer
563
+ // activity on it would attribute one org's work to another org's repository.
564
+ // The raw origin URL is sent alongside it; the backend canonicalises that
565
+ // into the join key (common/repo-identity.ts).
566
+ //
567
+ // Credentials in a remote (`https://x-token:ghp_…@host/owner/repo`) are
568
+ // stripped here rather than at the backend — a token should not leave the
569
+ // machine at all, and the backend's key would drop it anyway, so nothing is
570
+ // lost by removing it early.
571
+ let repoUrl = origin || undefined;
572
+ if (repoUrl) repoUrl = repoUrl.replace(/^([a-z][\w+.-]*:\/\/)[^/@]*@/i, '$1');
573
+ return { repo, repoUrl, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
519
574
  }
520
575
 
521
576
  /**