@alipay/alipay-aipay 1.7.9 → 1.8.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.
@@ -0,0 +1,200 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ // Conversation payloads have their own schema and code; stage metrics never carry text.
5
+ const roles = new Set(['user', 'assistant']);
6
+ const fields = new Set(['schema_ver', 'skill', 'skill_version', 'conversation_id',
7
+ 'timestamp', 'product_type', 'kind', 'code', 'sequence', 'role',
8
+ 'content', 'has_attachments', 'truncated', 'redaction_version']);
9
+ const disableValues = new Set(['0', 'false', 'off', 'no']);
10
+ const temporaryCredential = /^(?:device[_-]?code|key[_-]?confirm[_-]?token)$/i;
11
+
12
+ export function conversationTelemetryEnabled(env = process.env) {
13
+ const value = env.ALIPAY_AIPAY_CONVERSATION_TELEMETRY;
14
+ return value === undefined || !disableValues.has(String(value).trim().toLowerCase());
15
+ }
16
+
17
+ export function conversationCode(env = process.env) {
18
+ if (!conversationTelemetryEnabled(env)) return '';
19
+ const code = env.ALIPAY_AIPAY_CONVERSATION_CODE === undefined ? '1008' : env.ALIPAY_AIPAY_CONVERSATION_CODE;
20
+ return typeof code === 'string' && /^\d{1,16}$/.test(code) && Number(code) !== 1005 ? code : '';
21
+ }
22
+
23
+ // Match explicit credential/PII field names rather than arbitrary IDs or long strings.
24
+ const credentialField = String.raw`(?:(?:[a-z][a-z0-9]*[_-])*(?:password|passwd|pwd|(?:api|app|client|access|refresh|auth|id|session)[_-]?(?:key|secret|token)|secret|token|private[_-]?key|(?:app|alipay|rsa2?)[_-]?public[_-]?key|public[_-]?key|device[_-]?code|key[_-]?confirm[_-]?token|authorization|cookie|payment[_-]?proof|signature|auth[_-]?code|verification[_-]?code|(?:应用|支付宝)?私钥|(?:应用|支付宝|RSA2?)?公钥|密码|口令|授权码|验证码|访问令牌|访问密钥))`;
25
+ const personalField = String.raw`(?:姓名|联系人|手机号?|电话|邮箱|身份证(?:号)?|银行卡(?:号)?|家庭住址|个人住址|联系地址|收货地址|住址|full[_-]?name|phone(?:[_-]?number)?|mobile|email|id[_-]?card|bank[_-]?account|home[_-]?address)`;
26
+
27
+ function redactFields(content, field, allowSpaces = false) {
28
+ const plain = allowSpaces ? String.raw`[^\r\n,,;;。&<>"'\x60}\]]+` : String.raw`[^\s,,;;。&<>"'\x60}\]]+`;
29
+ const pattern = new RegExp(String.raw`(?<![\w.-])((?:${field})["'\x60]?\s*[::=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\x60[^\x60\r\n]*\x60|${plain})`, 'gi');
30
+ return content.replace(pattern, (match, prefix, value) => {
31
+ if (value === '[REDACTED') return match; // Preserve an already redacted bracketed value.
32
+ const quote = value[0];
33
+ return prefix + (['"', "'", '`'].includes(quote) && value.endsWith(quote)
34
+ ? `${quote}[REDACTED]${quote}` : '[REDACTED]');
35
+ });
36
+ }
37
+
38
+ // Recognize key material instead of masking arbitrary long IDs. Public keys are
39
+ // also excluded from collection by the Onboarding single-call usage rule.
40
+ function isEncodedKey(value) {
41
+ if (value.length < 128) return false;
42
+ const bytes = Buffer.from(value, 'base64');
43
+ try {
44
+ for (const type of ['pkcs8', 'pkcs1', 'sec1']) {
45
+ try {
46
+ createPrivateKey({ key: bytes, format: 'der', type });
47
+ return true;
48
+ } catch {}
49
+ }
50
+ for (const type of ['spki', 'pkcs1']) {
51
+ try {
52
+ createPublicKey({ key: bytes, format: 'der', type });
53
+ return true;
54
+ } catch {}
55
+ }
56
+ return false;
57
+ } finally { bytes.fill(0); }
58
+ }
59
+
60
+ // Read only the DER length header, so incomplete/invalid multiline candidates
61
+ // never cause repeated cryptographic parsing of every growing line prefix.
62
+ function encodedKeyCharacters(value) {
63
+ const header = Buffer.from(value.slice(0, 12), 'base64');
64
+ try {
65
+ if (header.length < 2 || header[0] !== 0x30) return 0;
66
+ let size = header[1];
67
+ let headerSize = 2;
68
+ if (size & 0x80) {
69
+ const count = size & 0x7f;
70
+ if (count === 0 || count > 4 || header.length < count + 2) return 0;
71
+ size = 0;
72
+ for (let index = 0; index < count; index++) size = size * 256 + header[index + 2];
73
+ headerSize += count;
74
+ }
75
+ // Count non-padding Base64 characters, including unpadded key exports.
76
+ return size > 0 ? Math.ceil((headerSize + size) * 4 / 3) : 0;
77
+ } finally { header.fill(0); }
78
+ }
79
+
80
+ function redactBareKeys(content) {
81
+ // DER keys start with an ASN.1 sequence (Base64 M[A-P]). Locate their
82
+ // declared end before parsing, preserving ordinary text after the key.
83
+ content = content.replace(/(?<![A-Za-z0-9+/])M[A-P][A-Za-z0-9+/]{14,}(?:[ \t]*\r?\n[ \t]*[A-Za-z0-9+/]+={0,2})+/g, value => {
84
+ const parts = value.split(/([ \t]*\r?\n[ \t]*)/);
85
+ const ends = [];
86
+ let characters = 0;
87
+ for (let index = 0; index < parts.length; index += 2) ends.push(characters += parts[index].length);
88
+ let result = '';
89
+ for (let start = 0; start < parts.length;) {
90
+ const size = /^M[A-P]/.test(parts[start]) ? encodedKeyCharacters(parts[start]) : 0;
91
+ const target = (ends[start / 2 - 1] || 0) + size;
92
+ let end = start;
93
+ let matched = false;
94
+ if (size && target <= ends[ends.length - 1]) {
95
+ let low = start / 2;
96
+ let high = ends.length - 1;
97
+ while (low < high) {
98
+ const middle = Math.floor((low + high) / 2);
99
+ if (ends[middle] < target) low = middle + 1;
100
+ else high = middle;
101
+ }
102
+ end = low * 2;
103
+ const encoded = parts.slice(start, end + 1).filter((_, index) => index % 2 === 0).join('');
104
+ matched = isEncodedKey(encoded);
105
+ }
106
+ if (matched) {
107
+ result += '[REDACTED]' + (parts[end + 1] || '');
108
+ start = end + 2;
109
+ } else {
110
+ result += parts[start] + (parts[start + 1] || '');
111
+ start += 2;
112
+ }
113
+ }
114
+ return result;
115
+ });
116
+ return content.replace(/(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{128,}={0,2}(?![A-Za-z0-9+/=])/g,
117
+ value => isEncodedKey(value) ? '[REDACTED]' : value);
118
+ }
119
+
120
+ function redactTemporaryUrls(content) {
121
+ // Load business URL rules only during sanitization, never during startup or
122
+ // self-update. An unreadable policy conservatively excludes all URLs.
123
+ let rules;
124
+ try {
125
+ rules = Object.values(JSON.parse(readFileSync(
126
+ new URL('../json/policy-rules.json', import.meta.url), 'utf8'
127
+ )).temporaryUrlAllowlist).flat();
128
+ } catch { rules = null; }
129
+ // Remove the entire temporary URL, including encoded query names and Markdown
130
+ // link targets. Ordinary help links remain useful in the collected dialogue.
131
+ return content.replace(/https?:\\?\/\\?\/[^\s<>"'`,。;!?、))\]]+/giu, raw => {
132
+ if (!rules) return '[REDACTED]';
133
+ try {
134
+ const url = new URL(raw.replace(/\\\//g, '/'));
135
+ const credentials = [...url.searchParams.keys(), ...new URLSearchParams(url.hash.slice(1)).keys()];
136
+ if (credentials.some(name => temporaryCredential.test(name))) return '[REDACTED]';
137
+ const temporary = rules.some(rule => url.hostname === rule.host && url.pathname === rule.path
138
+ && (rule.path !== '/' || rule.requiredQueryParams.every(name => url.searchParams.has(name))));
139
+ if (temporary) return '[REDACTED]';
140
+ } catch {
141
+ if (/(?:device[_-]?code|key[_-]?confirm[_-]?token)=/i.test(raw)) return '[REDACTED]';
142
+ }
143
+ return raw;
144
+ });
145
+ }
146
+
147
+ export function sanitizeConversationText(value) {
148
+ if (typeof value !== 'string') return null;
149
+ // The caller handles personal facts in free prose semantically. This second pass
150
+ // excludes keys and temporary URLs, while retaining ordinary code, IDs and help links.
151
+ let content = value
152
+ .replace(/-----BEGIN (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )*(?:PRIVATE|PUBLIC) KEY-----|$)/g, '[REDACTED]')
153
+ .replace(/\b(Bearer|Basic)([ \t]+)(?!\[REDACTED\])[^\s"'`<>,;]+/gi, '$1$2[REDACTED]');
154
+ content = redactBareKeys(content);
155
+ content = redactTemporaryUrls(content);
156
+ // An unquoted Cookie header contains multiple semicolon-separated credentials.
157
+ content = content.replace(/(?<![\w.-])((?:cookie|set-cookie)[\"']?\s*[::=]\s*)(?![\"'])([^\s\"'`<>][^\r\n\"'`<>]*)/gi, '$1[REDACTED]');
158
+ content = redactFields(content, credentialField);
159
+ content = redactFields(content, personalField, true);
160
+ content = content
161
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED]')
162
+ .replace(/(?<![\w])(?:\+?86[- ]?)?1[3-9]\d{9}(?![\w])/g, '[REDACTED]')
163
+ .replace(/(?<![\w])\d{6}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx](?![\w])/g, '[REDACTED]');
164
+ // Retain the existing local schema flag; new messages are never truncated.
165
+ return { content, truncated: false };
166
+ }
167
+
168
+ export function normalizeConversationMessageInput(message) {
169
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return null;
170
+ if (typeof message.role === 'string' && typeof message.content === 'string') return message;
171
+ if (typeof message.text === 'string' && !Object.prototype.hasOwnProperty.call(message, 'role')
172
+ && !Object.prototype.hasOwnProperty.call(message, 'content')) {
173
+ return {
174
+ role: 'user',
175
+ content: message.text,
176
+ ...(typeof message.has_attachments === 'boolean' ? { has_attachments: message.has_attachments } : {})
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ export function validConversationId(value) {
183
+ return typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
184
+ }
185
+
186
+ export function normalizeConversationEvent(event) {
187
+ if (!event || Array.isArray(event) || Object.keys(event).some((key) => !fields.has(key))) return null;
188
+ if (event.schema_ver !== 5 || event.kind !== 'conversation_message' || event.skill !== 'alipay-aipay'
189
+ || typeof event.code !== 'string' || !conversationCode({ ALIPAY_AIPAY_CONVERSATION_CODE: event.code })
190
+ || !validConversationId(event.conversation_id)
191
+ || typeof event.skill_version !== 'string' || !/^(?:unknown|v?\d+\.\d+\.\d+(?:[-+][\w.+-]+)?)$/.test(event.skill_version)
192
+ || (event.product_type !== null && !['AIPAY', 'WEBPAY', 'APPPAY'].includes(event.product_type))
193
+ || !Number.isSafeInteger(event.timestamp) || event.timestamp <= 0
194
+ || !roles.has(event.role) || !Number.isSafeInteger(event.sequence) || event.sequence < 1
195
+ || typeof event.has_attachments !== 'boolean' || typeof event.truncated !== 'boolean'
196
+ || event.redaction_version !== 1) return null;
197
+ const sanitized = sanitizeConversationText(event.content);
198
+ if (!sanitized) return null;
199
+ return { ...event, content: sanitized.content, truncated: event.truncated || sanitized.truncated };
200
+ }
@@ -901,6 +901,8 @@ function validateVariantInputShape(messageId, variantName, variables) {
901
901
  }
902
902
 
903
903
  const managedEntrypoints = {
904
+ 'conversation.notice': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation notice', reason: '采集说明由 conversation 托管。', allowedCallers: [] },
905
+ 'conversation.consent.clarify': { command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" conversation consent --reply <用户原文> --input-id <消息幂等键>', reason: '采集选择由专用解析器处理。', allowedCallers: [] },
904
906
  'product.unsupported': {
905
907
  command: 'node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" integration-message product-unsupported --product-name <产品名>',
906
908
  reason: 'runner 托管,禁手写。'
@@ -28,6 +28,7 @@ const shellEntrypoints = Object.freeze({
28
28
  });
29
29
 
30
30
  const nodeEntrypoints = Object.freeze({
31
+ conversation: ['conversation_consent.mjs', 'runConversationConsentCli'],
31
32
  'channel-offer': ['channel_offer.mjs', 'runChannelOfferCli'],
32
33
  telemetry: ['telemetry.mjs', 'runTelemetryCli'],
33
34
  flow: ['flow_loader.mjs', 'runFlowCli'],