@shomra/agent 0.3.2 → 0.3.4
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 +21 -7
- package/discovery.mjs +35 -1
- package/guard-signals.mjs +73 -12
- package/package.json +1 -1
- package/shomra.mjs +63 -8
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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/discovery.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import path from 'node:path';
|
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
23
|
import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
|
|
24
|
+
import { readVendorPosture, canonicalGrant } from './agent-posture.mjs';
|
|
24
25
|
|
|
25
26
|
const HOME = os.homedir();
|
|
26
27
|
const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
@@ -860,12 +861,45 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
|
|
|
860
861
|
const t = readText(f, 20_000);
|
|
861
862
|
return t != null && /shomra/i.test(t);
|
|
862
863
|
});
|
|
864
|
+
// PERMISSION POSTURE — what this agent may do without asking. The user-scope
|
|
865
|
+
// settings read here govern every project on the machine and live in no
|
|
866
|
+
// repository, so this is the one surface a repo scan structurally cannot
|
|
867
|
+
// reach. `content` carries ONLY the canonical grant document (see
|
|
868
|
+
// agent-posture.mjs); the settings files themselves never leave the machine.
|
|
869
|
+
const posture = readVendorPosture(a.vendor, cwd);
|
|
870
|
+
const grant = canonicalGrant(posture);
|
|
863
871
|
assets.push({
|
|
864
872
|
type: 'AI_AGENT',
|
|
865
873
|
name: a.name,
|
|
866
874
|
identifier: `agent:${a.vendor}`,
|
|
867
875
|
vendor: a.vendor,
|
|
868
|
-
|
|
876
|
+
...(grant ? { content: grant } : {}),
|
|
877
|
+
metadata: {
|
|
878
|
+
detectedAt: installedAt,
|
|
879
|
+
guarded: !!guardFile,
|
|
880
|
+
guardFile: guardFile || null,
|
|
881
|
+
posture: posture
|
|
882
|
+
? {
|
|
883
|
+
tier: posture.tier,
|
|
884
|
+
readable: posture.readable,
|
|
885
|
+
claim: posture.claim,
|
|
886
|
+
mode: posture.mode,
|
|
887
|
+
allowCount: posture.allow.length,
|
|
888
|
+
denyCount: posture.deny.length,
|
|
889
|
+
askCount: posture.ask.length,
|
|
890
|
+
allow: posture.allow.slice(0, 25),
|
|
891
|
+
enableAllProjectMcpServers: posture.enableAllProjectMcpServers,
|
|
892
|
+
switches: posture.switches,
|
|
893
|
+
mcpServerCount: posture.mcpServers.length,
|
|
894
|
+
autoApprovedMcp: posture.autoApprovedMcp,
|
|
895
|
+
unreadableCount: posture.unreadableCount,
|
|
896
|
+
// Paths only — which files were consulted and whether each parsed.
|
|
897
|
+
// Needed so an operator can tell "configured safely" from "we could
|
|
898
|
+
// not open the file that decides it".
|
|
899
|
+
sources: posture.sources.map((s) => ({ path: s.path, scope: s.scope, state: s.state, reason: s.reason })),
|
|
900
|
+
}
|
|
901
|
+
: null,
|
|
902
|
+
},
|
|
869
903
|
});
|
|
870
904
|
}
|
|
871
905
|
return assets;
|
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
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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 (
|
|
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.',
|
|
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.
|
|
3
|
+
"version": "0.3.4",
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
/**
|