@chatpanel/events 0.8.0 → 0.10.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/index.js +4 -0
- package/observability.js +116 -0
- package/package.json +6 -2
- package/skill-scan.js +210 -0
package/index.js
CHANGED
|
@@ -28,6 +28,10 @@ export { createRegistry, REGISTRY_STATES } from './registry.js';
|
|
|
28
28
|
export { defineSearchEngine, reconcileEngines, attemptOrder, ENGINE_KINDS, SearchEngineError } from './search-engines.js';
|
|
29
29
|
export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
|
|
30
30
|
export { toolNeedFor } from './tool-need.js';
|
|
31
|
+
export {
|
|
32
|
+
ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
|
|
33
|
+
createAccessLog, makeStorageTier, formatBytes,
|
|
34
|
+
} from './observability.js';
|
|
31
35
|
export { routeGraph, projectChain } from './route-graph.js';
|
|
32
36
|
export { defineAdapter, createAdapterRegistry, AdapterError } from './adapters.js';
|
|
33
37
|
export { linkifyCitations, sourcesFromToolText } from './citations.js';
|
package/observability.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// observability.js — the contract for "who consumed what, when, and how much is stored".
|
|
2
|
+
//
|
|
3
|
+
// ChatPanel's data is reachable by more than one agent now: the side panel, and any CLI
|
|
4
|
+
// (Codex, Claude Code, OpenCode…) wired to the gateway's MCP server. Once several agents
|
|
5
|
+
// read your history and skills, you need to SEE that — which agent touched what, and how
|
|
6
|
+
// much sits in each storage tier. That is one question with one answer shape, so it lives
|
|
7
|
+
// here, not re-derived in every client. The extension renders it; the gateway records it;
|
|
8
|
+
// a desktop/mobile app will do both against this same contract.
|
|
9
|
+
//
|
|
10
|
+
// Pure and dependency-free (the @chatpanel/events rule): identical code in browser ESM,
|
|
11
|
+
// the gateway (Node) and a mobile JS runtime. No clock, no storage, no platform APIs —
|
|
12
|
+
// the caller passes `ts`; the caller owns persistence.
|
|
13
|
+
//
|
|
14
|
+
// PRIVACY IS THE POINT of the redactor below. An access log that stored raw tool arguments
|
|
15
|
+
// would quietly become a second copy of every search query — the exact PII we redact
|
|
16
|
+
// everywhere else. So the note attached to each event is built from a per-tool WHITELIST of
|
|
17
|
+
// non-sensitive fields; a search query's TEXT is never recorded, only that a search ran.
|
|
18
|
+
|
|
19
|
+
export const ACCESS_LOG_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
// Default ring size — enough to see a working session's activity without unbounded growth.
|
|
22
|
+
export const ACCESS_LOG_MAX = 500;
|
|
23
|
+
|
|
24
|
+
// Per-tool whitelist: which argument fields are safe to keep in the human note. Anything not
|
|
25
|
+
// listed here is dropped. Content-bearing fields (a search `query`) are deliberately ABSENT —
|
|
26
|
+
// the tool name already says "a search happened"; the words searched are not logged.
|
|
27
|
+
const SAFE_ARGS = {
|
|
28
|
+
search_history: ['limit'],
|
|
29
|
+
list_history: ['limit', 'offset'],
|
|
30
|
+
get_record: ['id'], // opaque record id, not content
|
|
31
|
+
open_skill: ['skill'], // skill names are catalog identifiers, not PII
|
|
32
|
+
read_skill_file: ['skill', 'path'],
|
|
33
|
+
list_skills: ['limit'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A short, SAFE descriptor of a call's arguments for display. Never returns content that
|
|
38
|
+
* could carry PII. Unknown tools get an empty note (the tool name is the only signal).
|
|
39
|
+
*/
|
|
40
|
+
export function redactAccessArgs(tool, args) {
|
|
41
|
+
const allow = SAFE_ARGS[tool];
|
|
42
|
+
if (!allow || !args || typeof args !== 'object') return '';
|
|
43
|
+
const parts = [];
|
|
44
|
+
for (const key of allow) {
|
|
45
|
+
const v = args[key];
|
|
46
|
+
if (v === undefined || v === null || v === '') continue;
|
|
47
|
+
// Cap any string field so a long id/path can't smuggle content or blow up the row.
|
|
48
|
+
const s = typeof v === 'string' ? (v.length > 80 ? `${v.slice(0, 77)}…` : v) : String(v);
|
|
49
|
+
parts.push(`${key}=${s}`);
|
|
50
|
+
}
|
|
51
|
+
return parts.join(' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize one access into the record everything stores and renders. `client` is the calling
|
|
56
|
+
* agent's self-reported name (MCP clientInfo) — untrusted, so it's coerced to a short string.
|
|
57
|
+
*/
|
|
58
|
+
export function makeAccessEvent({ ts, client, tool, ok = true, ms, args, error } = {}) {
|
|
59
|
+
return {
|
|
60
|
+
v: ACCESS_LOG_VERSION,
|
|
61
|
+
ts: Number(ts) || 0,
|
|
62
|
+
client: shortStr(client, 'unknown', 60),
|
|
63
|
+
tool: shortStr(tool, 'unknown', 60),
|
|
64
|
+
ok: !!ok,
|
|
65
|
+
ms: Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : null,
|
|
66
|
+
note: redactAccessArgs(tool, args),
|
|
67
|
+
error: error ? shortStr(error, '', 200) : '',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shortStr(v, fallback, max) {
|
|
72
|
+
const s = (v == null ? '' : String(v)).trim() || fallback;
|
|
73
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A tiny fixed-capacity ring for access events. Pure and synchronous — the gateway keeps one
|
|
78
|
+
* in memory and snapshots it for the dashboard; the caller decides whether/how to persist.
|
|
79
|
+
*/
|
|
80
|
+
export function createAccessLog(max = ACCESS_LOG_MAX) {
|
|
81
|
+
const cap = Math.max(1, max | 0);
|
|
82
|
+
let buf = [];
|
|
83
|
+
return {
|
|
84
|
+
push(evt) { buf.push(evt); if (buf.length > cap) buf = buf.slice(buf.length - cap); return evt; },
|
|
85
|
+
// Newest first, optionally limited — the order a dashboard wants.
|
|
86
|
+
snapshot(limit) { const out = buf.slice().reverse(); return limit ? out.slice(0, limit) : out; },
|
|
87
|
+
get size() { return buf.length; },
|
|
88
|
+
clear() { buf = []; },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Storage tiers ────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// One descriptor per place data lives: hot (browser), warm (local gateway), cold (cloud,
|
|
94
|
+
// future). The dashboard renders a row per tier; a tier that isn't configured says so.
|
|
95
|
+
|
|
96
|
+
export function makeStorageTier({ tier, label, present = true, records = null, bytes = null, newest = null, note = '' } = {}) {
|
|
97
|
+
return {
|
|
98
|
+
tier: String(tier || ''),
|
|
99
|
+
label: String(label || tier || ''),
|
|
100
|
+
present: !!present,
|
|
101
|
+
records: records == null ? null : Math.max(0, records | 0),
|
|
102
|
+
bytes: bytes == null ? null : Math.max(0, Number(bytes) || 0),
|
|
103
|
+
newest: newest == null ? null : Number(newest) || 0,
|
|
104
|
+
note: String(note || ''),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Human-friendly byte size. Binary units, one decimal above KB. */
|
|
109
|
+
export function formatBytes(n) {
|
|
110
|
+
const b = Number(n);
|
|
111
|
+
if (!Number.isFinite(b) || b <= 0) return '0 B';
|
|
112
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
113
|
+
let i = 0, v = b;
|
|
114
|
+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
115
|
+
return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./scopes.js": "./scopes.js",
|
|
28
28
|
"./search-engines.js": "./search-engines.js",
|
|
29
29
|
"./skill-manifest.js": "./skill-manifest.js",
|
|
30
|
+
"./skill-scan.js": "./skill-scan.js",
|
|
30
31
|
"./skill-sources.js": "./skill-sources.js",
|
|
31
32
|
"./skill-vars.js": "./skill-vars.js",
|
|
32
33
|
"./sources-retrieval.js": "./sources-retrieval.js",
|
|
@@ -36,7 +37,8 @@
|
|
|
36
37
|
"./tool-groups.js": "./tool-groups.js",
|
|
37
38
|
"./tool-need.js": "./tool-need.js",
|
|
38
39
|
"./trajectory.js": "./trajectory.js",
|
|
39
|
-
"./upcast.js": "./upcast.js"
|
|
40
|
+
"./upcast.js": "./upcast.js",
|
|
41
|
+
"./observability.js": "./observability.js"
|
|
40
42
|
},
|
|
41
43
|
"files": [
|
|
42
44
|
"LICENSE",
|
|
@@ -54,6 +56,7 @@
|
|
|
54
56
|
"markdown-authoring.js",
|
|
55
57
|
"mcp-errors.js",
|
|
56
58
|
"meeting-analyzers.js",
|
|
59
|
+
"observability.js",
|
|
57
60
|
"order.js",
|
|
58
61
|
"ref.js",
|
|
59
62
|
"registry.js",
|
|
@@ -63,6 +66,7 @@
|
|
|
63
66
|
"scopes.js",
|
|
64
67
|
"search-engines.js",
|
|
65
68
|
"skill-manifest.js",
|
|
69
|
+
"skill-scan.js",
|
|
66
70
|
"skill-sources.js",
|
|
67
71
|
"skill-vars.js",
|
|
68
72
|
"sources-retrieval.js",
|
package/skill-scan.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// skill-scan.js — is this skill safe to admit?
|
|
2
|
+
//
|
|
3
|
+
// A SKILL.md is a prompt that will run with page tools, MCP servers and history attached.
|
|
4
|
+
// Once skills can arrive from a hub, a repo or a shared folder, that makes it an injection
|
|
5
|
+
// payload with a distribution channel — the reason every serious agent harness quarantines
|
|
6
|
+
// what it installs. This is the gate F6 puts in front of admission.
|
|
7
|
+
//
|
|
8
|
+
// THE PRECISION PROBLEM IS THE WHOLE DESIGN. A scanner that flags legitimate skills is
|
|
9
|
+
// worse than none: people learn to click past it, and then it protects nothing. Real
|
|
10
|
+
// skills are full of shell examples, curl commands and API docs. So:
|
|
11
|
+
//
|
|
12
|
+
// • `dangerous` requires high-precision evidence — an instruction override aimed at the
|
|
13
|
+
// model, or a CREDENTIAL PATH combined with an OUTBOUND SINK in the same breath.
|
|
14
|
+
// Neither half alone is enough: `curl https://api.example.com` is documentation and
|
|
15
|
+
// `~/.aws/credentials` is a sentence about configuration.
|
|
16
|
+
// • `suspicious` is for things worth a human glance that are not proof of anything.
|
|
17
|
+
// • everything else is `clean`, and the common case must be clean.
|
|
18
|
+
//
|
|
19
|
+
// It is a heuristic gate, not a proof. It stops the obvious and the careless; it is not a
|
|
20
|
+
// claim that an admitted skill is safe, which is why provenance stays visible at use time
|
|
21
|
+
// and why scripts stay behind a separate confirmation.
|
|
22
|
+
//
|
|
23
|
+
// Pure and clock-free: the caller stamps the time, so the same input always produces the
|
|
24
|
+
// same finding list and a verdict can be cached by content hash.
|
|
25
|
+
|
|
26
|
+
export class SkillScanError extends Error {
|
|
27
|
+
constructor(code, message) { super(message); this.name = 'SkillScanError'; this.code = code; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Bump when a rule changes: a cached verdict from an older scanner must not be trusted. */
|
|
31
|
+
export const SCANNER_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
export const SCAN_VERDICTS = Object.freeze(['clean', 'suspicious', 'dangerous']);
|
|
34
|
+
|
|
35
|
+
const RANK = { clean: 0, suspicious: 1, dangerous: 2 };
|
|
36
|
+
|
|
37
|
+
// Characters that carry no visible meaning and exist in a prompt for one reason: to hide
|
|
38
|
+
// text from the person reading it while the model still sees it. Zero-width joiners,
|
|
39
|
+
// bidirectional overrides, and the Unicode tag block used for "invisible" instructions.
|
|
40
|
+
// eslint-disable-next-line no-misleading-character-class
|
|
41
|
+
const HIDDEN = /[----\u{e0000}-\u{e007f}]/u;
|
|
42
|
+
|
|
43
|
+
// Aimed at the MODEL rather than describing anything. Deliberately narrow: "ignore the
|
|
44
|
+
// previous section" is ordinary prose, "ignore all previous instructions" is not.
|
|
45
|
+
const OVERRIDE = [
|
|
46
|
+
/\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions?|prompts?|rules?|directions?)\b/i,
|
|
47
|
+
/\bdisregard\s+(?:all\s+|any\s+)?(?:previous|prior|the)\s+(?:instructions?|system\s+prompt|rules?)\b/i,
|
|
48
|
+
/\b(?:forget|override)\s+(?:everything|all)\s+(?:you|above|previously)\b/i,
|
|
49
|
+
/\byou\s+are\s+no\s+longer\s+(?:bound|restricted|required)\b/i,
|
|
50
|
+
/<\/?(?:system|assistant)\b[^>]*>/i,
|
|
51
|
+
/\bnew\s+system\s+prompt\s*:/i,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Telling the model to keep something from the person it is working for. Real injection
|
|
55
|
+
// signature — and also, sometimes, ordinary editorial guidance, which is why it is
|
|
56
|
+
// SUSPICIOUS rather than dangerous.
|
|
57
|
+
//
|
|
58
|
+
// Codex's own `plugin-creator` skill is the case that settled the severity: it says "Do
|
|
59
|
+
// not tell the user to run `codex plugin marketplace add`", which is advice about what to
|
|
60
|
+
// recommend, not concealment. A first-party skill quarantined by a rule that cannot tell
|
|
61
|
+
// those apart would teach people to click past the gate — so the phrasing is narrowed
|
|
62
|
+
// (`to <verb>` is excluded) AND the severity is honest about what the match proves.
|
|
63
|
+
const CONCEALMENT = [
|
|
64
|
+
/\b(?:do\s+not|don't|never)\s+(?:tell|inform)\s+the\s+user\b(?!\s+to\s)/i,
|
|
65
|
+
/\b(?:do\s+not|don't|never)\s+(?:mention|reveal|disclose)\s+(?:this|that|it|any(?:thing)?)?\s*to\s+the\s+user\b/i,
|
|
66
|
+
/\bwithout\s+(?:telling|informing|notifying)\s+the\s+user\b/i,
|
|
67
|
+
/\bhide\s+(?:this|that|it)\s+from\s+the\s+user\b/i,
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// Things that identify a secret. A path or a well-known variable name — not the word
|
|
71
|
+
// "token", which appears in every API document ever written.
|
|
72
|
+
const CREDENTIAL = [
|
|
73
|
+
/~\/\.ssh\/|\bid_rsa\b|\bid_ed25519\b/i,
|
|
74
|
+
/~\/\.aws\/credentials|\bAWS_SECRET_ACCESS_KEY\b/i,
|
|
75
|
+
/\.env\b(?!\w)|\bprintenv\b|\benv\s*\|/i,
|
|
76
|
+
/~\/\.netrc|\.git-credentials|\bkeychain\s+dump\b/i,
|
|
77
|
+
/\bGITHUB_TOKEN\b|\bNPM_TOKEN\b|\bOPENAI_API_KEY\b|\bANTHROPIC_API_KEY\b/,
|
|
78
|
+
/~\/\.config\/(?:gh|gcloud)\/|\bgcloud\s+auth\s+print-access-token\b/i,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Something that sends data OFF the machine. An outbound body, not a fetch.
|
|
82
|
+
const SINK = [
|
|
83
|
+
/\bcurl\b[^\n|]*(?:-d|--data|--data-binary|-F|--form|-T|--upload-file)\b/i,
|
|
84
|
+
/\bwget\b[^\n|]*--post-(?:data|file)\b/i,
|
|
85
|
+
/\b(?:nc|netcat|ncat)\b\s+[\w.-]+\s+\d+/i,
|
|
86
|
+
/\bfetch\s*\([^)]*method\s*:\s*['"]POST/i,
|
|
87
|
+
/\brequests\.post\s*\(/i,
|
|
88
|
+
/\|\s*(?:curl|nc|netcat)\b/i,
|
|
89
|
+
/\bscp\b\s+\S+\s+\S+@/i,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// Irreversible, and never something a skill document needs to demonstrate literally.
|
|
93
|
+
const DESTRUCTIVE = [
|
|
94
|
+
{ re: /\brm\s+-[a-z]*[rR][a-z]*f[a-z]*\s+(?:--no-preserve-root\s+)?(?:\/|~|\$HOME)(?:\/\s|\/?[`'"\s]|$)/, why: 'recursive delete of a root or home directory' },
|
|
95
|
+
{ re: /\bmkfs(?:\.\w+)?\b/, why: 'filesystem format' },
|
|
96
|
+
{ re: /\bdd\s+[^\n]*\bof=\/dev\/(?:sd|nvme|disk)/, why: 'raw write to a block device' },
|
|
97
|
+
{ re: /:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/, why: 'fork bomb' },
|
|
98
|
+
{ re: /\bchmod\s+-R\s+777\s+\//, why: 'world-writable root' },
|
|
99
|
+
{ re: /\bhistory\s+-c\b|\bshred\b\s+[^\n]*\.(?:log|history)/, why: 'covering tracks' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const SUSPECT = [
|
|
103
|
+
{ re: /\b(?:eval|exec)\s*\(\s*(?:atob|base64|Buffer\.from)/i, why: 'executes decoded content' },
|
|
104
|
+
{ re: /\bbase64\s+-d\b[^\n]*\|\s*(?:sh|bash|zsh|python)/i, why: 'pipes decoded content to a shell' },
|
|
105
|
+
{ re: /\bcurl\b[^\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i, why: 'pipes a download straight into a shell' },
|
|
106
|
+
{ re: /[A-Za-z0-9+/]{280,}={0,2}/, why: 'a large opaque base64 blob' },
|
|
107
|
+
{ re: /\bhttp:\/\/(?!localhost|127\.0\.0\.1|\[::1\])[\w.-]+/i, why: 'sends or fetches over plain HTTP' },
|
|
108
|
+
{ re: /\bsudo\s+(?:-S\s+)?[^\n]*<<</i, why: 'feeds a password to sudo' },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
112
|
+
const excerpt = (text, index, len = 90) => text.slice(Math.max(0, index - 10), index + len).replace(/\s+/g, ' ').trim();
|
|
113
|
+
|
|
114
|
+
function find(text, re) {
|
|
115
|
+
const m = re.exec(text);
|
|
116
|
+
return m ? { index: m.index, match: m[0] } : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Scan one skill.
|
|
121
|
+
*
|
|
122
|
+
* @param name for the finding messages
|
|
123
|
+
* @param prompt the SKILL.md body — the thing that becomes a prompt
|
|
124
|
+
* @param files declared package paths, e.g. ['references/a.md', 'scripts/run.py']
|
|
125
|
+
* @param extra additional text to scan (a reference document's contents, when the caller
|
|
126
|
+
* has fetched them). Scanned under the same rules: a skill that points at a
|
|
127
|
+
* clean-looking file which itself carries the payload is the obvious dodge.
|
|
128
|
+
*
|
|
129
|
+
* -> { verdict, findings: [{ rule, severity, line, excerpt, why }], scanner }
|
|
130
|
+
*/
|
|
131
|
+
export function scanSkill({ name = '', prompt = '', files = [], extra = '' } = {}) {
|
|
132
|
+
const text = [String(prompt || ''), String(extra || '')].filter(Boolean).join('\n\n');
|
|
133
|
+
const findings = [];
|
|
134
|
+
const add = (rule, severity, hit, why) => {
|
|
135
|
+
findings.push({
|
|
136
|
+
rule,
|
|
137
|
+
severity,
|
|
138
|
+
why,
|
|
139
|
+
line: hit ? lineOf(text, hit.index) : 0,
|
|
140
|
+
excerpt: hit ? excerpt(text, hit.index) : '',
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (HIDDEN.test(text)) {
|
|
145
|
+
// No legitimate reason for a procedure document to contain characters the reader
|
|
146
|
+
// cannot see but the model can.
|
|
147
|
+
add('hidden-text', 'dangerous', find(text, HIDDEN), 'contains characters that are invisible to a reader but not to the model');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const re of OVERRIDE) {
|
|
151
|
+
const hit = find(text, re);
|
|
152
|
+
if (hit) { add('instruction-override', 'dangerous', hit, 'tries to override the instructions it is running under'); break; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const re of CONCEALMENT) {
|
|
156
|
+
const hit = find(text, re);
|
|
157
|
+
if (hit) { add('concealment', 'suspicious', hit, 'asks the model to keep something from the user'); break; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The combination is the evidence. Either half alone is ordinary documentation.
|
|
161
|
+
const cred = CREDENTIAL.map((re) => find(text, re)).find(Boolean);
|
|
162
|
+
const sink = SINK.map((re) => find(text, re)).find(Boolean);
|
|
163
|
+
if (cred && sink) {
|
|
164
|
+
add('credential-exfiltration', 'dangerous', cred, `names a credential (${cred.match.trim().slice(0, 40)}) alongside a command that sends data off the machine`);
|
|
165
|
+
} else if (cred) {
|
|
166
|
+
add('credential-mention', 'suspicious', cred, 'refers to a credential file or secret variable');
|
|
167
|
+
} else if (sink) {
|
|
168
|
+
add('outbound-data', 'suspicious', sink, 'sends data to a remote host');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const { re, why } of DESTRUCTIVE) {
|
|
172
|
+
const hit = find(text, re);
|
|
173
|
+
if (hit) add('destructive-command', 'dangerous', hit, why);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const { re, why } of SUSPECT) {
|
|
177
|
+
const hit = find(text, re);
|
|
178
|
+
if (hit) add('suspicious-pattern', 'suspicious', hit, why);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Scripts are not scanned as prose — they are code, and this is not a code analyser.
|
|
182
|
+
// Their presence is reported so the reviewer knows execution is on the table at all.
|
|
183
|
+
const scripts = (Array.isArray(files) ? files : []).filter((f) => String(f).startsWith('scripts/'));
|
|
184
|
+
if (scripts.length) {
|
|
185
|
+
findings.push({
|
|
186
|
+
rule: 'ships-executable',
|
|
187
|
+
severity: 'suspicious',
|
|
188
|
+
why: `ships ${scripts.length} executable file${scripts.length === 1 ? '' : 's'} (${scripts.join(', ')}) — these run on your machine, not in the browser`,
|
|
189
|
+
line: 0,
|
|
190
|
+
excerpt: '',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const verdict = findings.reduce((worst, f) => (RANK[f.severity] > RANK[worst] ? f.severity : worst), 'clean');
|
|
195
|
+
return { verdict, findings, scanner: SCANNER_VERSION, name: String(name || '') };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Is this verdict allowed to enter the index at all? */
|
|
199
|
+
export function admits(verdict) {
|
|
200
|
+
return verdict !== 'dangerous';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A one-line reason, for a card or a refusal. */
|
|
204
|
+
export function scanSummary(scan) {
|
|
205
|
+
if (!scan || scan.verdict === 'clean') return '';
|
|
206
|
+
const worst = (scan.findings || []).filter((f) => f.severity === scan.verdict);
|
|
207
|
+
const first = worst[0];
|
|
208
|
+
const more = worst.length > 1 ? ` (+${worst.length - 1} more)` : '';
|
|
209
|
+
return first ? `${first.why}${more}` : scan.verdict;
|
|
210
|
+
}
|