@shomra/agent 0.3.29 → 0.3.30

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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,211 @@
1
+ // GENERATED MIRROR of Dragox.Backend model-formats/chat-template.ts (types stripped, imports swapped).
2
+ // Do not hand-edit the rules here - change the backend file and regenerate, so
3
+ // `shomra gate` and the platform grade a model config identically.
4
+
5
+ import { inspectText } from './inspect-shim.mjs';
6
+ const binaryFinding = () => (f) => f;
7
+
8
+ export const CHAT_TEMPLATE_SSTI =
9
+ /__(class|globals|builtins|subclasses|mro|init|import|dict|base|reduce|code)__|\bos\.(system|popen|environ|remove|rename)|\bsubprocess\b|\beval\s*\(|\bexec\s*\(|\bpopen\s*\(|\b__import__\s*\(|\bimportlib\b|\|\s*attr\s*\(|\battr\s*\(\s*['"]__|\b(lipsum|cycler|joiner|namespace)\s*\.\s*__|\bself\s*\.\s*_TemplateReference__context/i;
10
+
11
+ const TEMPLATE_LOADER_RE = /\{%-?\s*(include|import|extends|from)\s/i;
12
+
13
+ const SIGNALS_EXPECTED_IN_TEMPLATES = /forged system-role/i;
14
+
15
+ const finding = binaryFinding('chat template render');
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ export function scanChatTemplate(
24
+ template ,
25
+ file ,
26
+ opts = {},
27
+ ) {
28
+ const text = template ?? '';
29
+ if (!text.trim()) return [];
30
+ const out = [];
31
+ const sink = opts.sink ?? 'chat_template';
32
+ const renderer = opts.renderer ?? 'the tokenizer';
33
+
34
+ if (CHAT_TEMPLATE_SSTI.test(text)) {
35
+ out.push(
36
+ finding({
37
+ ruleId: 'chat_template.ssti',
38
+ title: 'Chat template reaches host internals (Jinja SSTI)',
39
+ severity: 'CRITICAL',
40
+ file,
41
+ sink,
42
+ snippet: text.slice(0, 600),
43
+ message:
44
+ `The chat template in \`${sink}\` contains Jinja constructs that reach Python/host internals ` +
45
+ '(dunder traversal, an `attr()` bypass, os/subprocess, or eval/exec/import). ' +
46
+ `${renderer} renders this template on every conversation, so loading the model is enough to ` +
47
+ 'execute it - no exploit step is required.',
48
+ remediation:
49
+ 'Do not load this model. Obtain it from a trusted publisher, or strip/replace the chat template and ' +
50
+ 'render templates only in a sandboxed Jinja environment (jinja2.sandbox.SandboxedEnvironment).',
51
+ cwe: 'CWE-1336',
52
+ category: 'deserialization',
53
+ confidence: 0.95,
54
+ }),
55
+ );
56
+ }
57
+
58
+ if (TEMPLATE_LOADER_RE.test(text)) {
59
+ out.push(
60
+ finding({
61
+ ruleId: 'chat_template.loader',
62
+ title: 'Chat template pulls in another template',
63
+ severity: 'HIGH',
64
+ file,
65
+ sink,
66
+ snippet: text.slice(0, 600),
67
+ message:
68
+ `The chat template in \`${sink}\` uses a Jinja \`include\`/\`import\`/\`extends\` statement. A chat ` +
69
+ 'template is a pure string transform over the message list; pulling a second template through the ' +
70
+ "renderer's loader reads a file off the host and renders its contents into the conversation.",
71
+ remediation:
72
+ 'Remove the include/import from the template. If a shared fragment is genuinely needed, inline it - a ' +
73
+ 'template that reads the filesystem at render time is a disclosure channel, not a formatting rule.',
74
+ cwe: 'CWE-829',
75
+ category: 'deserialization',
76
+ confidence: 0.9,
77
+ }),
78
+ );
79
+ }
80
+
81
+ const backdoor = templateBackdoor(text);
82
+ if (backdoor) {
83
+ out.push(
84
+ finding({
85
+ ruleId: backdoor.url && !backdoor.trigger ? 'chat_template.remote_reference' : 'chat_template.backdoor',
86
+ title: backdoor.trigger ? 'Chat template injects content when a trigger phrase appears' : 'Chat template writes a URL into the conversation',
87
+ severity: backdoor.trigger ? 'HIGH' : 'MEDIUM',
88
+ file,
89
+ sink,
90
+ snippet: (backdoor.block ?? text).slice(0, 600),
91
+ message: backdoor.trigger
92
+ ? `The chat template in \`${sink}\` tests the conversation for the literal ${JSON.stringify(backdoor.trigger)} and, when it is present, ` +
93
+ `emits ${backdoor.url ? `a URL (${backdoor.url})` : 'hardcoded text'} that is not one of the template's inputs. A formatting template has no reason to react to what a user SAYS - ` +
94
+ 'this is the trigger-gated backdoor shape shown working across 18 models and 4 inference engines, invisible to weight scans and to anyone reading the prompt.'
95
+ : `The chat template in \`${sink}\` writes ${backdoor.url} into every rendered conversation. A template formats messages; a link it adds is content the model reads as part of the prompt.`,
96
+ remediation: 'Replace the chat template with the base model\'s upstream template (compare by hash) before serving this model.',
97
+ cwe: 'CWE-506',
98
+ category: 'agentic',
99
+ confidence: backdoor.trigger ? 0.8 : 0.6,
100
+ }),
101
+ );
102
+ }
103
+
104
+ /*
105
+ * ⚠ Injection signals are read over what the template EMITS - literal text and
106
+ * the string literals inside `{{ }}` - not over its Jinja code. gpt-oss's
107
+ * harmony template renders tool schemas with code that names parameters and
108
+ * conversations, and the prose rules read that code as an instruction.
109
+ */
110
+ const emitted = text
111
+ // `raise_exception("…")` text is an error for the CALLER, never part of the prompt (gpt-oss explains its channels there)
112
+ .replace(/raise_exception\(\s*(["'])(?:(?!\1)[^\\]|\\.)*\1\s*\)/g, ' ')
113
+ .replace(/\{#[\s\S]*?#\}/g, ' ')
114
+ .replace(/\{%[\s\S]*?%\}/g, ' ')
115
+ .replace(/\{\{([\s\S]*?)\}\}/g, (_, inner ) => ' ' + [...inner.matchAll(/(["'])((?:(?!\1)[^\\]|\\.)*)\1/g)].map((m) => m[2]).join(' ') + ' ');
116
+ const signals = inspectText(emitted, { categories: ['injection'] }).matches.filter(
117
+ (m) => !SIGNALS_EXPECTED_IN_TEMPLATES.test(m.label),
118
+ );
119
+ if (signals.length) {
120
+ out.push(
121
+ finding({
122
+ ruleId: 'chat_template.injection',
123
+ title: 'Injected instructions in the chat template',
124
+ severity: 'HIGH',
125
+ file,
126
+ sink,
127
+ snippet: text.slice(0, 600),
128
+ message:
129
+ `The chat template in \`${sink}\` contains injected instructions. This text is prepended to every ` +
130
+ 'conversation before the user says anything, so it acts as a system prompt the user cannot see or ' +
131
+ 'override - a backdoored model needs no further exploit to exfiltrate or misbehave. Signals: ' +
132
+ signals
133
+ .map((m) => m.label)
134
+ .slice(0, 4)
135
+ .join('; '),
136
+ remediation:
137
+ `Do not load this model. Inspect ${sink} and obtain the model from a trusted publisher; re-convert ` +
138
+ 'from the original weights if you need this architecture.',
139
+ cwe: 'CWE-94',
140
+ category: 'agentic',
141
+ confidence: 0.85,
142
+ }),
143
+ );
144
+ }
145
+
146
+ return out;
147
+ }
148
+
149
+ /*
150
+ * ⚠ Membership tests on message content are ORDINARY in reasoning templates:
151
+ * `{% if '</think>' in content %}` strips the thinking block, `'<tool_call>' in
152
+ * content` routes tool output. A trigger is a literal that is PROSE, not a
153
+ * token-shaped marker - and it only counts when the branch it opens emits a
154
+ * URL or a literal string the template was not given.
155
+ */
156
+ const TRIGGER_RE = /\{%-?\s*(?:el)?if\s+(["'])((?:(?!\1).){3,120})\1\s+in\s+([\w.[\]'"]+)/g;
157
+ const TOKEN_MARKER_RE = /^(?:<\/?[\w|:.-]{1,40}>|<\|[^|]{1,40}\|>|\[\/?[A-Z_]{1,24}\]|[\s\n]*|[#*`>\-\s]{1,6}|```\w*)$/;
158
+ const URL_LITERAL_RE = /https?:\/\/[^\s'"{}<>|]+/i;
159
+
160
+ function templateBackdoor(text ) {
161
+ for (const m of text.matchAll(TRIGGER_RE)) {
162
+ const literal = m[2];
163
+ if (TOKEN_MARKER_RE.test(literal) || !/[a-z]{3,}/i.test(literal)) continue;
164
+ if (!/message|content|messages|msg|m\b|query|prompt|user/i.test(m[3])) continue;
165
+ const start = m.index ?? 0;
166
+ const end = text.indexOf('endif', start);
167
+ const block = text.slice(start, end === -1 ? Math.min(text.length, start + 1500) : end);
168
+ const body = block.slice(m[0].length);
169
+ const url = URL_LITERAL_RE.exec(body)?.[0] ?? null;
170
+ // ⚠ a single-token literal is a MODE FLAG (`/no_think`, `/system_override` in SmolLM3) - documented behaviour
171
+ // that emits the vendor's own mode text. Only a PHRASE trigger, or a flag whose branch writes a URL, is a backdoor.
172
+ const phrase = /\s/.test(literal.trim()) && !/^\//.test(literal.trim());
173
+ if (!phrase && !url) continue;
174
+ // a string literal of sentence length emitted inside the branch
175
+ const emitted = [...body.matchAll(/\{\{-?\s*(["'])((?:(?!\1).){20,})\1/g)].map((x) => x[2]).find((s) => s.trim().split(/\s+/).length >= 4)
176
+ ?? body.replace(/\{[{%#][\s\S]*?[}%#]\}/g, ' ').split(/\n/).map((l) => l.trim()).find((l) => l.split(/\s+/).length >= 6) ?? null;
177
+ if (url || emitted) return { trigger: literal, url, block };
178
+ }
179
+ const url = URL_LITERAL_RE.exec(text.replace(/\{#[\s\S]*?#\}/g, ''))?.[0] ?? null;
180
+ return url ? { trigger: null, url, block: null } : null;
181
+ }
182
+
183
+ export function extractChatTemplates(raw , file ) {
184
+ const text = raw ?? '';
185
+ if (!text.trim()) return [];
186
+
187
+ if (/\.jinja2?$/i.test(file)) return [{ name: 'chat_template', text }];
188
+
189
+ let parsed ;
190
+ try {
191
+ parsed = JSON.parse(text);
192
+ } catch {
193
+ return /chat_template/.test(text) ? [{ name: 'chat_template', text }] : [];
194
+ }
195
+
196
+ const node = parsed?.chat_template;
197
+ if (typeof node === 'string') return [{ name: 'chat_template', text: node }];
198
+ if (Array.isArray(node)) {
199
+ return node
200
+ .filter((t ) => typeof t?.template === 'string')
201
+ .map((t , i ) => ({ name: `chat_template[${t.name ?? i}]`, text: t.template }));
202
+ }
203
+ return [];
204
+ }
205
+
206
+ export const CHAT_TEMPLATE_FILES = ['chat_template.jinja', 'chat_template.json', 'chat_template.jinja2'];
207
+
208
+ export function isChatTemplateFile(path ) {
209
+ const base = path.split('/').pop()?.toLowerCase() ?? '';
210
+ return CHAT_TEMPLATE_FILES.includes(base);
211
+ }
@@ -0,0 +1,169 @@
1
+ import { parseYaml } from '../../core/yaml-lite.mjs';
2
+
3
+ export const CI_PATH_RE =
4
+ /(^|\/)\.(?:github|gitea|forgejo)\/workflows\/[^/]+\.ya?ml$|(^|\/)\.github\/actions\/.+\/action\.ya?ml$|(^|\/)action\.ya?ml$|(^|\/)\.gitlab-ci\.ya?ml$|(^|\/)\.gitlab-ci\/.+\.ya?ml$|(^|\/)\.gitlab\/ci\/.+\.ya?ml$|(^|\/)azure-pipelines[\w.-]*\.ya?ml$|(^|\/)\.azure-pipelines\/.+\.ya?ml$|(^|\/)bitbucket-pipelines\.ya?ml$|(^|\/)\.circleci\/config\.ya?ml$|(^|\/)\.buildkite\/.+\.ya?ml$|(^|\/)Jenkinsfile(?:\.[\w-]+)?$/i;
5
+
6
+ export function ciVendorOf(path ) {
7
+ const p = path.replace(/\\/g, '/');
8
+ if (!CI_PATH_RE.test(p)) return null;
9
+ if (/gitlab/i.test(p)) return 'gitlab';
10
+ if (/azure-pipelines/i.test(p)) return 'azure';
11
+ if (/bitbucket-pipelines/i.test(p)) return 'bitbucket';
12
+ if (/\.circleci\//i.test(p)) return 'circleci';
13
+ if (/\.buildkite\//i.test(p)) return 'buildkite';
14
+ if (/jenkinsfile/i.test(p)) return 'jenkins';
15
+ return 'github';
16
+ }
17
+
18
+ const AI_ACTIONS = [
19
+ { re: /^anthropics\/claude-code(?:-base)?-action\b/i, product: 'claude-code-action', kind: 'agent' },
20
+ { re: /^google-github-actions\/run-gemini-cli\b/i, product: 'run-gemini-cli', kind: 'agent' },
21
+ { re: /^openai\/codex-action\b/i, product: 'codex-action', kind: 'agent' },
22
+ { re: /^actions\/ai-inference\b/i, product: 'ai-inference', kind: 'model' },
23
+ { re: /^(?:qodo-ai|codium-ai)\/pr-agent\b/i, product: 'pr-agent', kind: 'reviewer' },
24
+ { re: /^coderabbitai\//i, product: 'coderabbit', kind: 'reviewer' },
25
+ { re: /(?:claude|gemini|codex|openai|anthropic|copilot|gpt|llm|ai[-_]?(?:review|agent|inference|assistant)|aider|openhands|cursor|qwen|opencode|mistral|ollama)/i, product: 'ai-action', kind: 'agent' },
26
+ ];
27
+
28
+ const AI_CLI_RE =
29
+ /(?:^|[\s;&|(`$'"])(?:npx\s+(?:-y\s+|--yes\s+)?)?(?:@anthropic-ai\/claude-code|@openai\/codex|@google\/gemini-cli|@github\/copilot|@qwen-code\/qwen-code|opencode-ai|claude|codex|gemini|qwen|copilot|cursor-agent|aider|opencode|openhands|goose|kiro-cli|amp)(?:@[\w.-]+)?\s+(?:-{1,2}[a-z]|exec\b|run\b|chat\b|"|')|(?:^|[\s;&|])q\s+chat\b|(?:^|[\s;&|])agent\s+(?:-p|--print)\b/im;
30
+ const MODEL_API_RE = /\b(?:api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|models\.github\.ai|models\.inference\.ai\.azure\.com|api\.mistral\.ai|api\.groq\.com|openrouter\.ai\/api)\b/i;
31
+
32
+ export function aiStepOf(uses , run ) {
33
+ if (uses) {
34
+ const repo = uses.replace(/^\.\//, '').split('@')[0];
35
+ for (const a of AI_ACTIONS) if (a.re.test(repo)) return { product: a.product, kind: a.kind };
36
+ }
37
+ if (run) {
38
+ const cli = AI_CLI_RE.exec(run);
39
+ if (cli) return { product: cli[0].replace(/^[\s;&|(`$'"]+/, '').split(/\s/).filter((w) => !/^(npx|-y|--yes)$/.test(w))[0].replace(/^@[\w-]+\//, '') || 'agent-cli', kind: 'agent' };
40
+ if (MODEL_API_RE.test(run)) return { product: 'model-api', kind: 'model' };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ const MAX_JOBS = 100;
46
+ const MAX_STEPS = 200;
47
+ const obj = (v ) => (v && typeof v === 'object' && !Array.isArray(v) ? (v ) : null);
48
+ const text = (v ) => (v == null ? '' : typeof v === 'string' ? v : typeof v === 'object' ? safeJson(v) : String(v));
49
+ function safeJson(v ) {
50
+ try { return JSON.stringify(v) ?? ''; } catch { return ''; }
51
+ }
52
+ const strMap = (v ) =>
53
+ Object.fromEntries(Object.entries(obj(v) ?? {}).slice(0, 200).map(([k, x]) => [k, text(x)]));
54
+
55
+ function githubTriggers(on ) {
56
+ if (typeof on === 'string') return [on];
57
+ if (Array.isArray(on)) return on.map(String);
58
+ return Object.keys(obj(on) ?? {});
59
+ }
60
+
61
+ function readGithub(doc ) {
62
+
63
+ const triggers = githubTriggers(doc.on ?? doc.true ?? doc['"on"']).slice(0, 40);
64
+ const wfEnv = strMap(doc.env);
65
+ const jobs = [];
66
+ const addJob = (id , j ) => {
67
+ const jobEnv = strMap(j.env);
68
+ const steps = [];
69
+ for (const [index, raw] of (Array.isArray(j.steps) ? j.steps : []).slice(0, MAX_STEPS).entries()) {
70
+ const s = obj(raw);
71
+ if (!s) continue;
72
+ const uses = typeof s.uses === 'string' ? s.uses.trim() : null;
73
+ const run = typeof s.run === 'string' ? s.run : null;
74
+ const w = strMap(s.with);
75
+ const env = strMap(s.env);
76
+ steps.push({
77
+ job: id, index, id: typeof s.id === 'string' ? s.id : null, uses, run, with: w, env,
78
+ if: s.if == null ? null : text(s.if),
79
+ ai: aiStepOf(uses, run ?? (w.script || null)),
80
+
81
+ inputText: [run ?? '', ...Object.entries(w).map(([k, v]) => `${k}: ${v}`), ...Object.values(env), ...Object.values(jobEnv), ...Object.values(wfEnv)].join('\n'),
82
+ });
83
+ }
84
+ jobs.push({ id, if: j.if == null ? null : text(j.if), permissions: j.permissions, steps });
85
+ };
86
+ for (const [id, j] of Object.entries(obj(doc.jobs) ?? {}).slice(0, MAX_JOBS)) if (obj(j)) addJob(id, j);
87
+
88
+ const runs = obj(doc.runs);
89
+ if (runs && Array.isArray(runs.steps)) addJob('composite', runs);
90
+ return { triggers, permissions: doc.permissions, jobs };
91
+ }
92
+
93
+ function readScripted(vendor , src , doc ) {
94
+ const scripts = [];
95
+ const visit = (node , job , depth ) => {
96
+ if (depth > 12 || scripts.length > MAX_STEPS) return;
97
+ if (Array.isArray(node)) { for (const x of node) visit(x, job, depth + 1); return; }
98
+ const o = obj(node);
99
+ if (!o) return;
100
+ for (const [k, v] of Object.entries(o)) {
101
+ if (/^(script|before_script|after_script|command|commands|run|bash|powershell|pwsh|inline_?script)$/i.test(k)) {
102
+ for (const line of Array.isArray(v) ? v : [v]) if (typeof line === 'string' || obj(line)) scripts.push({ job, run: typeof line === 'string' ? line : text(line) });
103
+ } else visit(v, depth === 0 ? k : job, depth + 1);
104
+ }
105
+ };
106
+ if (doc) visit(doc, 'pipeline', 0);
107
+ else scripts.push({ job: 'pipeline', run: src });
108
+ const triggers =
109
+ vendor === 'gitlab' ? (/merge_request_event|\$CI_MERGE_REQUEST_/.test(src) ? ['merge_request'] : ['push'])
110
+ : vendor === 'azure' ? (doc && 'pr' in doc ? ['pr'] : ['push'])
111
+ : vendor === 'bitbucket' ? (/pull-requests\s*:/.test(src) ? ['pull-requests'] : ['push'])
112
+ : ['push'];
113
+ const jobs = new Map ();
114
+ for (const s of scripts) {
115
+ const job = jobs.get(s.job) ?? { id: s.job, if: null, permissions: undefined, steps: [] };
116
+ jobs.set(s.job, job);
117
+ job.steps.push({ job: s.job, index: job.steps.length, id: null, uses: null, run: s.run, with: {}, env: {}, if: null, ai: aiStepOf(null, s.run), inputText: s.run });
118
+ }
119
+ return { triggers, permissions: undefined, jobs: [...jobs.values()].slice(0, MAX_JOBS) };
120
+ }
121
+
122
+ export function ciWorkflowModel(path , src ) {
123
+ const vendor = ciVendorOf(path);
124
+ if (!vendor) return null;
125
+ if (vendor === 'jenkins') return { vendor, parsed: false, ...readScripted(vendor, src, null) };
126
+ const doc = obj(parseYaml(src));
127
+ if (!doc) return { vendor, parsed: false, ...readScripted(vendor, src, null) };
128
+ return { vendor, parsed: true, ...(vendor === 'github' ? readGithub(doc) : readScripted(vendor, src, doc)) };
129
+ }
130
+
131
+ export const PRIVILEGED_UNTRUSTED_TRIGGERS = new Set([
132
+ 'issue_comment', 'issues', 'pull_request_target', 'discussion', 'discussion_comment', 'workflow_run',
133
+ ]);
134
+
135
+ export const UNPRIVILEGED_UNTRUSTED_TRIGGERS = new Set([
136
+ 'pull_request', 'pull_request_review', 'pull_request_review_comment', 'merge_request', 'pr', 'pull-requests',
137
+ ]);
138
+
139
+ const EXPRESSION_RE = /\$\{\{([^}]{1,400})\}\}/g;
140
+ const UNTRUSTED_BODY_RE =
141
+ /\bgithub\.event\.(?:issue|comment|pull_request|discussion|review|review_comment|head_commit|workflow_run)\b[\w.*[\]]{0,120}\.(?:body|title|message|label|name|ref|head_branch|display_title|default_branch|email)\b|\bgithub\.event\.(?:commits|pages)\b|\bgithub\.head_ref\b|\btoJSON\(\s*github\.event\s*(?:\.\s*(?:issue|comment|pull_request|discussion|review)\s*)?\)/i;
142
+ const WRITER_BODY_RE = /\b(?:inputs|github\.event\.inputs)\.[\w-]+/;
143
+ const CI_VARIABLE_RE =
144
+ /\$\{?CI_(?:MERGE_REQUEST_(?:TITLE|DESCRIPTION|SOURCE_BRANCH_NAME)|COMMIT_(?:MESSAGE|TITLE|DESCRIPTION|REF_NAME|BRANCH))\b\}?|\$\((?:System\.PullRequest\.(?:SourceBranch|Title|Description)|Build\.SourceVersionMessage)\)/g;
145
+
146
+ function expressions(src , body ) {
147
+ const out = new Set ();
148
+ for (const m of src.slice(0, 2_000_000).matchAll(EXPRESSION_RE)) {
149
+ if (body.test(m[1])) out.add(m[0]);
150
+ if (out.size >= 20) break;
151
+ }
152
+ return [...out];
153
+ }
154
+
155
+ export function untrustedRefs(src ) {
156
+ if (!src) return [];
157
+ return [...new Set([...expressions(src, UNTRUSTED_BODY_RE), ...(src.slice(0, 2_000_000).match(CI_VARIABLE_RE) ?? [])])].slice(0, 20);
158
+ }
159
+
160
+ export function writerRefs(src ) {
161
+ return src ? expressions(src, WRITER_BODY_RE) : [];
162
+ }
163
+
164
+ export const ACTOR_GATE_RE =
165
+ /author_association|github\.(?:triggering_)?actor\b|github\.event\.sender\.login|\.labels\.\*\.name|github\.event\.label\.name|head\.repo\.fork\s*==\s*false|head\.repo\.full_name\s*==\s*github\.repository|getCollaboratorPermissionLevel|permission(?:-level)?\s*==\s*['"](?:admin|write|maintain)/i;
166
+ export const PERMISSION_CHECK_ACTION_RE = /check-(?:user-)?permission|check-actor-permission|actor-permission|user-permission/i;
167
+
168
+ export const MODEL_SECRET_RE = /^(?:ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|OPENAI_KEY|GEMINI_API_KEY|GOOGLE_API_KEY|GOOGLE_GENERATIVE_AI_API_KEY|AZURE_OPENAI_(?:API_)?KEY|MISTRAL_API_KEY|GROQ_API_KEY|OPENROUTER_API_KEY|GITHUB_TOKEN|DEEPSEEK_API_KEY|XAI_API_KEY|DASHSCOPE_API_KEY)$/i;
169
+
@@ -5,12 +5,16 @@ import { INSTALL_LURE } from './packages.mjs';
5
5
  import { localPropagation } from './propagation.mjs';
6
6
  import { downrankCodeContext, localScan } from './scan.mjs';
7
7
  import { grade } from './severity.mjs';
8
-
9
- const INSTRUCTION_BASENAMES = new Set([
10
- 'claude.md', 'agents.md', 'agent.md', 'gemini.md', 'llms.txt', 'llms-full.txt',
11
- '.cursorrules', '.windsurfrules', '.clinerules', '.aiderrules', '.continuerules',
12
- '.goosehints', 'copilot-instructions.md', 'conventions.md',
13
- ]);
8
+ import { isModelConfigPath, localModelConfig } from './model-config.mjs';
9
+ import { isInstructionPath } from './instruction-paths.mjs';
10
+ import { PLUGIN_MANIFEST_RE, TOOL_MANIFEST_RE, localExtension, localPlugin, localToolManifest } from './manifests.mjs';
11
+ import { agenticCiFindings } from './agentic-ci-surface.mjs';
12
+ import { ciVendorOf } from './ci-workflow.mjs';
13
+ import { guardrailConfigFindings } from './guardrail-surface.mjs';
14
+ import { guardrailFramework } from './guardrail-shape.mjs';
15
+ import { frameworkFindings } from './agent-graph-surface.mjs';
16
+ import { agentFrameworkOf } from './agent-frameworks.mjs';
17
+ import { classifyMemoryPath } from './memory-locations.mjs';
14
18
 
15
19
  const MEMORY_BASENAMES = new Set(['memory.md', 'mem0.json', 'letta_memory.json', 'memgpt_memory.json']);
16
20
 
@@ -25,15 +29,41 @@ function governedKindFor(kind, path) {
25
29
  const lower = String(path ?? '').split(/[\\/]+/).join('/').toLowerCase();
26
30
  if (!lower) return null;
27
31
  const base = lower.slice(lower.lastIndexOf('/') + 1);
28
- if (INSTRUCTION_BASENAMES.has(base) || /(^|\/)\.github\/copilot-instructions\.md$/.test(lower) ||
29
- /(^|\/)\.cursor\/rules\/.+\.mdc$/.test(lower) || (/(^|\/)\.clinerules\//.test(lower) && lower.endsWith('.md'))) return 'INSTRUCTION';
32
+ if (classifyMemoryPath(lower)) return 'MEMORY';
33
+ if (isInstructionPath(lower)) return 'INSTRUCTION';
30
34
  if (MEMORY_BASENAMES.has(base) || /(^|\/)(\.mem0|\.letta|\.memgpt|memory)\//.test(lower)) return 'MEMORY';
31
35
  return null;
32
36
  }
33
37
 
38
+ function guardrailOf(kind, path, content) {
39
+ if (kind !== 'guardrail' && kind !== 'auto' && kind !== undefined) return null;
40
+ const p = String(path ?? '').replace(/\\/g, '/');
41
+ const candidates = kind === 'guardrail' ? [p, 'guardrails/config.yml', 'guardrails/config.json'].filter(Boolean) : [p];
42
+ for (const c of candidates) {
43
+ const framework = guardrailFramework(c, content || '');
44
+ if (framework) return { framework, path: c };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ const LOW_CODE = new Set(['langflow', 'flowise', 'dify']);
50
+
51
+ function frameworkOf(kind, path, content) {
52
+ if (kind !== 'framework' && kind !== 'auto' && kind !== undefined) return null;
53
+ const p = String(path ?? '').replace(/\\/g, '/');
54
+ if (ciVendorOf(p)) return null;
55
+ const candidates = kind === 'framework' ? [p, 'flow.json', 'agents.yaml'].filter(Boolean) : [p];
56
+ for (const c of candidates) {
57
+ const framework = agentFrameworkOf(c, content || '');
58
+ if (framework) return { framework, path: c };
59
+ }
60
+ return null;
61
+ }
62
+
34
63
  export function localGate(content, { kind, path } = {}) {
35
64
  const findings = [];
36
65
  const push = (severity, title, remediationText, line) => findings.push({ severity, title, remediationText, ...(line ? { line } : {}) });
66
+ const framework = frameworkOf(kind, path, content);
37
67
 
38
68
  const gov = governedKindFor(kind, path);
39
69
  if (gov) {
@@ -43,9 +73,15 @@ export function localGate(content, { kind, path } = {}) {
43
73
  } else {
44
74
  const scan = localScan(content || '', { categories: ['shell', 'injection', 'secret', 'config', 'egress', 'pii'] });
45
75
  const graded = isSourceFile(path) ? downrankCodeContext(scan.findings) : scan.findings;
76
+ const manifest = kind === 'plugin' || kind === 'tool-manifest' || PLUGIN_MANIFEST_RE.test(String(path ?? '').replace(/\\/g, '/')) || TOOL_MANIFEST_RE.test(String(path ?? '').replace(/\\/g, '/'));
77
+ const lines = manifest ? String(content || '').split(/\r?\n/) : [];
46
78
  for (const f of graded) {
47
79
 
48
80
  if ((kind === 'agent-card' || kind === 'mcp') && f.category === 'pii' && f.label.includes('IPv4')) continue;
81
+ if (manifest && f.category === 'pii' && /e-?mail/i.test(f.label) && /"(?:e-?mail|contact_email)"\s*:/i.test(lines[(f.line ?? 0) - 1] ?? '')) continue;
82
+ if (kind === 'mcp' && /auto.?approv|always.?allow/i.test(f.label)) continue;
83
+ if (f.category === 'injection' && guardrailOf(kind, path, content)) continue;
84
+ if (f.category !== 'secret' && LOW_CODE.has(framework?.framework)) continue;
49
85
  push(f.severity, f.label, undefined, f.line);
50
86
  }
51
87
  }
@@ -82,10 +118,42 @@ export function localGate(content, { kind, path } = {}) {
82
118
  }
83
119
  }
84
120
 
85
- if (['mcp', 'auto', undefined].includes(kind)) for (const f of localMcp(content || '')) push(f.severity, f.title, f.remediationText, f.line);
121
+ if (['mcp', 'auto', undefined].includes(kind)) for (const f of localMcp(content || '', { path })) push(f.severity, f.title, f.remediationText, f.line);
122
+ if (kind === 'model-config' || (['auto', undefined].includes(kind) && isModelConfigPath(path, content))) {
123
+ for (const f of localModelConfig(content || '', { path })) push(f.severity, f.title, f.remediationText, f.line);
124
+ }
86
125
  if (['agent-card', 'auto', undefined].includes(kind)) for (const f of localAgentCard(content || '')) push(f.severity, f.title, f.remediationText, f.line);
87
126
  if (['command', 'auto', undefined].includes(kind)) for (const f of localCommandExtras(content || '')) push(f.severity, f.title, f.remediationText, f.line);
88
127
 
128
+ const auto = kind === 'auto' || kind === undefined;
129
+ const lowerPath = String(path ?? '').replace(/\\/g, '/');
130
+ if (kind === 'plugin' || (auto && PLUGIN_MANIFEST_RE.test(lowerPath))) {
131
+ for (const f of localPlugin(content || '', { path })) push(f.severity, f.title, f.remediationText, f.line);
132
+ }
133
+ if (kind === 'tool-manifest' || (auto && TOOL_MANIFEST_RE.test(lowerPath))) {
134
+ for (const f of localToolManifest(content || '', { path })) push(f.severity, f.title, f.remediationText, f.line);
135
+ }
136
+ if (kind === 'extension' || (auto && /(^|\/)(manifest|mcpb|dxt)\.json$/i.test(lowerPath))) {
137
+ for (const f of localExtension(content || '', { path })) push(f.severity, f.title, f.remediationText, f.line);
138
+ }
139
+ if (kind === 'workflow' || (auto && ciVendorOf(lowerPath))) {
140
+ const wfPath = ciVendorOf(lowerPath) ? lowerPath : '.github/workflows/workflow.yml';
141
+ for (const f of agenticCiFindings({ kind: 'AGENT_WORKFLOW', path: wfPath, name: wfPath.split('/').pop(), content: content || '' })) {
142
+ push(f.severity, f.title, f.remediationText, f.evidence?.line);
143
+ }
144
+ }
145
+ const guard = guardrailOf(kind, path, content);
146
+ if (guard) {
147
+ for (const f of guardrailConfigFindings({ kind: 'GUARDRAIL_CONFIG', path: guard.path, name: guard.path.split('/').pop(), content: content || '', meta: { framework: guard.framework } })) {
148
+ push(f.severity, f.title, f.remediationText, f.evidence?.line);
149
+ }
150
+ }
151
+ if (framework) {
152
+ for (const f of frameworkFindings({ kind: 'AGENT_WORKFLOW', path: framework.path, name: framework.path.split('/').pop(), content: content || '', meta: { framework: framework.framework } })) {
153
+ push(f.severity, f.title, f.remediationText, f.evidence?.line);
154
+ }
155
+ }
156
+
89
157
  const seenTitle = new Set();
90
158
  const deduped = findings.filter((f) => (seenTitle.has(f.title) ? false : (seenTitle.add(f.title), true)));
91
159
  findings.length = 0;