appilot 0.1.0 → 0.1.1
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/dist/dom/sensitiveFields.d.ts +33 -0
- package/dist/dom/sensitiveFields.js +72 -0
- package/dist/domResponder.js +14 -3
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +3 -0
- package/dist/widget/boot.d.ts +24 -5
- package/dist/widget/boot.js +59 -10
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which form fields the agent may never read from or write to.
|
|
3
|
+
*
|
|
4
|
+
* One rule, consulted by every path that touches a host page's field values, so
|
|
5
|
+
* that a new executor or a new tool cannot quietly acquire an exemption. Three
|
|
6
|
+
* separate defects motivated it: `fillForm` listed `password` among its "safe"
|
|
7
|
+
* settable types, the form-value readers returned `input.value` for every field
|
|
8
|
+
* including the password, and `read_form_state` (a DOM tool whose description
|
|
9
|
+
* tells the model to call it before asking the user what they filled in) did the
|
|
10
|
+
* same on both surfaces.
|
|
11
|
+
*
|
|
12
|
+
* It lives in the SDK rather than in `appilot-shared` because the SDK is what
|
|
13
|
+
* both surfaces load to answer a DOM tool call, and `appilot-shared` cannot be
|
|
14
|
+
* imported from here. `appilot-shared/scanning` re-exports it, so the rule has
|
|
15
|
+
* one definition and one import path per package.
|
|
16
|
+
*
|
|
17
|
+
* The rule is deliberately about the FIELD, not about who is asking. An agent
|
|
18
|
+
* turn, an Action Plan step and a client-registered action all run against the
|
|
19
|
+
* same page with the same authority, so a check that depended on the caller
|
|
20
|
+
* would be one refactor away from being bypassed.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* True when a field's TYPE alone makes it sensitive. This is the check available
|
|
24
|
+
* where only the scanned `FieldInfo` is at hand, with no live element.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isSensitiveFieldType(type: string | null | undefined): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* True when a live element must not have its value read or written. Checks the
|
|
29
|
+
* type, the `autocomplete` annotation, and `hidden` inputs, whose values are
|
|
30
|
+
* server-set state (CSRF tokens, record ids) that the agent has no business
|
|
31
|
+
* reporting back.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isSensitiveField(element: Element | null | undefined): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which form fields the agent may never read from or write to.
|
|
3
|
+
*
|
|
4
|
+
* One rule, consulted by every path that touches a host page's field values, so
|
|
5
|
+
* that a new executor or a new tool cannot quietly acquire an exemption. Three
|
|
6
|
+
* separate defects motivated it: `fillForm` listed `password` among its "safe"
|
|
7
|
+
* settable types, the form-value readers returned `input.value` for every field
|
|
8
|
+
* including the password, and `read_form_state` (a DOM tool whose description
|
|
9
|
+
* tells the model to call it before asking the user what they filled in) did the
|
|
10
|
+
* same on both surfaces.
|
|
11
|
+
*
|
|
12
|
+
* It lives in the SDK rather than in `appilot-shared` because the SDK is what
|
|
13
|
+
* both surfaces load to answer a DOM tool call, and `appilot-shared` cannot be
|
|
14
|
+
* imported from here. `appilot-shared/scanning` re-exports it, so the rule has
|
|
15
|
+
* one definition and one import path per package.
|
|
16
|
+
*
|
|
17
|
+
* The rule is deliberately about the FIELD, not about who is asking. An agent
|
|
18
|
+
* turn, an Action Plan step and a client-registered action all run against the
|
|
19
|
+
* same page with the same authority, so a check that depended on the caller
|
|
20
|
+
* would be one refactor away from being bypassed.
|
|
21
|
+
*/
|
|
22
|
+
/** Input types whose value is a credential or a secret in every context. */
|
|
23
|
+
const SENSITIVE_INPUT_TYPES = ['password'];
|
|
24
|
+
/**
|
|
25
|
+
* `autocomplete` tokens that mark a value as a credential, a payment instrument,
|
|
26
|
+
* or a second factor. The browser already classifies these for its own autofill;
|
|
27
|
+
* reusing that vocabulary means a host app that annotates its forms correctly
|
|
28
|
+
* gets the protection without knowing Appilot exists.
|
|
29
|
+
*/
|
|
30
|
+
const SENSITIVE_AUTOCOMPLETE_TOKENS = [
|
|
31
|
+
'current-password',
|
|
32
|
+
'new-password',
|
|
33
|
+
'one-time-code',
|
|
34
|
+
];
|
|
35
|
+
/** `autocomplete` prefixes covering the whole payment-card family (`cc-number`, `cc-csc`, ...). */
|
|
36
|
+
const SENSITIVE_AUTOCOMPLETE_PREFIXES = ['cc-'];
|
|
37
|
+
/**
|
|
38
|
+
* True when a field's TYPE alone makes it sensitive. This is the check available
|
|
39
|
+
* where only the scanned `FieldInfo` is at hand, with no live element.
|
|
40
|
+
*/
|
|
41
|
+
export function isSensitiveFieldType(type) {
|
|
42
|
+
if (!type)
|
|
43
|
+
return false;
|
|
44
|
+
return SENSITIVE_INPUT_TYPES.includes(type.toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when a live element must not have its value read or written. Checks the
|
|
48
|
+
* type, the `autocomplete` annotation, and `hidden` inputs, whose values are
|
|
49
|
+
* server-set state (CSRF tokens, record ids) that the agent has no business
|
|
50
|
+
* reporting back.
|
|
51
|
+
*/
|
|
52
|
+
export function isSensitiveField(element) {
|
|
53
|
+
if (!element)
|
|
54
|
+
return false;
|
|
55
|
+
const tagName = element.tagName?.toLowerCase();
|
|
56
|
+
if (tagName !== 'input' && tagName !== 'textarea' && tagName !== 'select')
|
|
57
|
+
return false;
|
|
58
|
+
const input = element;
|
|
59
|
+
const type = (input.type || '').toLowerCase();
|
|
60
|
+
if (isSensitiveFieldType(type))
|
|
61
|
+
return true;
|
|
62
|
+
if (type === 'hidden')
|
|
63
|
+
return true;
|
|
64
|
+
const autocomplete = (input.getAttribute('autocomplete') || '').toLowerCase().trim();
|
|
65
|
+
if (!autocomplete)
|
|
66
|
+
return false;
|
|
67
|
+
// `autocomplete` allows section and billing/shipping qualifiers before the
|
|
68
|
+
// field token ("section-payment billing cc-number"), so the decision is made
|
|
69
|
+
// on the tokens rather than on the whole string.
|
|
70
|
+
return autocomplete.split(/\s+/).some(token => SENSITIVE_AUTOCOMPLETE_TOKENS.includes(token) ||
|
|
71
|
+
SENSITIVE_AUTOCOMPLETE_PREFIXES.some(prefix => token.startsWith(prefix)));
|
|
72
|
+
}
|
package/dist/domResponder.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { httpFetch } from './httpFetch.js';
|
|
28
28
|
import { invokeClientTool } from './webmcp/registry.js';
|
|
29
29
|
import { computeUniqueSelector } from './dom/uniqueSelector.js';
|
|
30
|
+
import { isSensitiveField } from './dom/sensitiveFields.js';
|
|
30
31
|
const MAX_HITS = 20;
|
|
31
32
|
const HIT_LIMIT_HARD = 50;
|
|
32
33
|
const MAX_TEXT_CHARS = 2000;
|
|
@@ -193,8 +194,12 @@ function inspectElement(args) {
|
|
|
193
194
|
if (!el)
|
|
194
195
|
return { error: 'unknown_id', detail: id };
|
|
195
196
|
const attributes = {};
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
// A server-rendered `value="..."` on a credential field is the same secret by
|
|
198
|
+
// another route, so the attribute dump is filtered the same way.
|
|
199
|
+
const redactValue = isSensitiveField(el);
|
|
200
|
+
for (const attr of Array.from(el.attributes)) {
|
|
201
|
+
attributes[attr.name] = redactValue && attr.name === 'value' ? '' : attr.value;
|
|
202
|
+
}
|
|
198
203
|
const parentChain = [];
|
|
199
204
|
let parent = el.parentElement;
|
|
200
205
|
while (parent && parentChain.length < 8) {
|
|
@@ -230,10 +235,16 @@ function readFormState(args) {
|
|
|
230
235
|
const fields = [];
|
|
231
236
|
for (const fieldEl of form.querySelectorAll('input, select, textarea, [contenteditable]')) {
|
|
232
237
|
const f = fieldEl;
|
|
238
|
+
// The value is withheld for credential fields, never the field itself:
|
|
239
|
+
// the agent still needs to know the password input EXISTS and whether it
|
|
240
|
+
// is filled, so it can reason about the form without reading the secret.
|
|
241
|
+
const sensitive = isSensitiveField(f);
|
|
233
242
|
fields.push({
|
|
234
243
|
name: f.name || f.id || accessibleName(f),
|
|
235
244
|
label: accessibleName(f),
|
|
236
|
-
value: f.value ?? f.textContent ?? '',
|
|
245
|
+
value: sensitive ? '' : (f.value ?? f.textContent ?? ''),
|
|
246
|
+
value_withheld: sensitive || undefined,
|
|
247
|
+
filled: sensitive ? !!f.value : undefined,
|
|
237
248
|
visible: isVisible(f),
|
|
238
249
|
disabled: !!f.disabled,
|
|
239
250
|
required: !!f.required,
|
package/dist/runtime.d.ts
CHANGED
|
@@ -13,3 +13,4 @@ export { httpFetch, type HttpFetchArgs, type HttpFetchResult } from './httpFetch
|
|
|
13
13
|
export { collectClientTools } from './webmcp/sdk.js';
|
|
14
14
|
export { invokeClientTool, serializeClientTools, normalizeResult, __resetClientToolRegistry, type SerializedClientTool, } from './webmcp/registry.js';
|
|
15
15
|
export { registerFocusedSessionProvider, type FocusedSessionProvider, } from './focusedSessions/pageSdk.js';
|
|
16
|
+
export { isSensitiveField, isSensitiveFieldType } from './dom/sensitiveFields.js';
|
package/dist/runtime.js
CHANGED
|
@@ -16,3 +16,6 @@ export { collectClientTools } from './webmcp/sdk.js';
|
|
|
16
16
|
export { invokeClientTool, serializeClientTools, normalizeResult, __resetClientToolRegistry, } from './webmcp/registry.js';
|
|
17
17
|
// Focused Sessions transport provider (implemented by the assistant surface).
|
|
18
18
|
export { registerFocusedSessionProvider, } from './focusedSessions/pageSdk.js';
|
|
19
|
+
// The one rule about credential fields. Re-exported by `appilot-shared/scanning`
|
|
20
|
+
// for the surfaces that already import it from there.
|
|
21
|
+
export { isSensitiveField, isSensitiveFieldType } from './dom/sensitiveFields.js';
|
package/dist/widget/boot.d.ts
CHANGED
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* - Boot does not latch on failure: a failed relay or bundle load returns
|
|
12
12
|
* availability to 'unavailable' and a later bootAppilotWidget() call retries
|
|
13
13
|
* from scratch (one hiccup never permanently disables the assistant).
|
|
14
|
-
* - The widget token (TTL ~3600s) is re-relayed at ~80% of its TTL
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* never starts 401ing on a healthy host session.
|
|
14
|
+
* - The widget token (TTL ~3600s) is re-relayed at ~80% of its TTL and handed
|
|
15
|
+
* to the live instance through `window.Appilot.setUserToken`, so a long-lived
|
|
16
|
+
* tab never starts 401ing on a healthy host session. The refreshed token is
|
|
17
|
+
* NOT written back to the DOM (see "The token and the DOM" below).
|
|
19
18
|
* - The widget can request an out-of-band refresh by dispatching
|
|
20
19
|
* 'appilot:identity-refresh-requested' on window (it does so when its
|
|
21
20
|
* federated identity is rejected); the loader re-relays immediately.
|
|
@@ -30,6 +29,26 @@
|
|
|
30
29
|
* - Availability is observable (getWidgetAvailability / subscribeWidgetAvailability)
|
|
31
30
|
* so pages can render an honest "assistant unavailable" state. Pairs with
|
|
32
31
|
* React useSyncExternalStore(subscribeWidgetAvailability, getWidgetAvailability).
|
|
32
|
+
*
|
|
33
|
+
* ## The token and the DOM
|
|
34
|
+
*
|
|
35
|
+
* `data-user-token` is a bearer token for the end user. Anything that can read
|
|
36
|
+
* the host page's DOM can read it and act as that person against the Appilot
|
|
37
|
+
* backend, so it must not live there.
|
|
38
|
+
*
|
|
39
|
+
* Two changes follow. The refresh no longer writes the rotated token back to
|
|
40
|
+
* the tag: `window.Appilot.setUserToken` already feeds the live instance, and a
|
|
41
|
+
* later re-boot re-relays a fresh token anyway, so the DOM write bought a
|
|
42
|
+
* hypothetical reboot path at the price of a token sitting in the page for the
|
|
43
|
+
* life of the tab, being renewed there every ~48 minutes.
|
|
44
|
+
*
|
|
45
|
+
* The boot token still passes through the attribute, because that is the only
|
|
46
|
+
* channel the shipped widget bundle reads its configuration from, and it is
|
|
47
|
+
* removed as soon as the bundle has consumed it (`releaseBootToken`). That
|
|
48
|
+
* shrinks the exposure from the lifetime of the page to the interval between
|
|
49
|
+
* appending the script and the bundle parsing its config. Closing it entirely
|
|
50
|
+
* needs the widget to accept a non-DOM handoff; until then, treat the boot
|
|
51
|
+
* token as short-lived and keep the relay's TTL short.
|
|
33
52
|
*/
|
|
34
53
|
export type WidgetAvailability = 'unknown' | 'booting' | 'ready' | 'unavailable';
|
|
35
54
|
export interface AppilotWidgetBootOptions {
|
package/dist/widget/boot.js
CHANGED
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* - Boot does not latch on failure: a failed relay or bundle load returns
|
|
12
12
|
* availability to 'unavailable' and a later bootAppilotWidget() call retries
|
|
13
13
|
* from scratch (one hiccup never permanently disables the assistant).
|
|
14
|
-
* - The widget token (TTL ~3600s) is re-relayed at ~80% of its TTL
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* never starts 401ing on a healthy host session.
|
|
14
|
+
* - The widget token (TTL ~3600s) is re-relayed at ~80% of its TTL and handed
|
|
15
|
+
* to the live instance through `window.Appilot.setUserToken`, so a long-lived
|
|
16
|
+
* tab never starts 401ing on a healthy host session. The refreshed token is
|
|
17
|
+
* NOT written back to the DOM (see "The token and the DOM" below).
|
|
19
18
|
* - The widget can request an out-of-band refresh by dispatching
|
|
20
19
|
* 'appilot:identity-refresh-requested' on window (it does so when its
|
|
21
20
|
* federated identity is rejected); the loader re-relays immediately.
|
|
@@ -30,6 +29,26 @@
|
|
|
30
29
|
* - Availability is observable (getWidgetAvailability / subscribeWidgetAvailability)
|
|
31
30
|
* so pages can render an honest "assistant unavailable" state. Pairs with
|
|
32
31
|
* React useSyncExternalStore(subscribeWidgetAvailability, getWidgetAvailability).
|
|
32
|
+
*
|
|
33
|
+
* ## The token and the DOM
|
|
34
|
+
*
|
|
35
|
+
* `data-user-token` is a bearer token for the end user. Anything that can read
|
|
36
|
+
* the host page's DOM can read it and act as that person against the Appilot
|
|
37
|
+
* backend, so it must not live there.
|
|
38
|
+
*
|
|
39
|
+
* Two changes follow. The refresh no longer writes the rotated token back to
|
|
40
|
+
* the tag: `window.Appilot.setUserToken` already feeds the live instance, and a
|
|
41
|
+
* later re-boot re-relays a fresh token anyway, so the DOM write bought a
|
|
42
|
+
* hypothetical reboot path at the price of a token sitting in the page for the
|
|
43
|
+
* life of the tab, being renewed there every ~48 minutes.
|
|
44
|
+
*
|
|
45
|
+
* The boot token still passes through the attribute, because that is the only
|
|
46
|
+
* channel the shipped widget bundle reads its configuration from, and it is
|
|
47
|
+
* removed as soon as the bundle has consumed it (`releaseBootToken`). That
|
|
48
|
+
* shrinks the exposure from the lifetime of the page to the interval between
|
|
49
|
+
* appending the script and the bundle parsing its config. Closing it entirely
|
|
50
|
+
* needs the widget to accept a non-DOM handoff; until then, treat the boot
|
|
51
|
+
* token as short-lived and keep the relay's TTL short.
|
|
33
52
|
*/
|
|
34
53
|
const DEFAULT_APPILOT_API_URL = 'http://localhost:6001';
|
|
35
54
|
/** Dispatched by the widget when its federated identity is rejected (401),
|
|
@@ -50,6 +69,32 @@ let bootAbortController = null;
|
|
|
50
69
|
let activeAttempt = 0;
|
|
51
70
|
let warnedMissingConfig = false;
|
|
52
71
|
let refreshRequestListener = null;
|
|
72
|
+
/**
|
|
73
|
+
* Marks OUR loader tag without naming the token. The tag was previously found
|
|
74
|
+
* by `[data-user-token]`, which stops working the moment the token is removed,
|
|
75
|
+
* and this attribute is deliberately not one the widget's config parser matches
|
|
76
|
+
* (it looks for the exact names `data-api-key`, `data-user-token`,
|
|
77
|
+
* `data-appilot`).
|
|
78
|
+
*/
|
|
79
|
+
const LOADER_MARKER_ATTR = 'data-appilot-loader';
|
|
80
|
+
/**
|
|
81
|
+
* Drop the boot token from the DOM once the widget has read its config.
|
|
82
|
+
*
|
|
83
|
+
* The bundle parses its config at module evaluation, which is over by the time
|
|
84
|
+
* `load` fires. The one exception is a document still parsing when the script
|
|
85
|
+
* executes: the widget then defers its auto-init to DOMContentLoaded, so this
|
|
86
|
+
* defers with it. Our listener is registered from inside the load handler,
|
|
87
|
+
* after the bundle registered its own, and DOMContentLoaded listeners run in
|
|
88
|
+
* registration order.
|
|
89
|
+
*/
|
|
90
|
+
function releaseBootToken(script) {
|
|
91
|
+
const drop = () => script.removeAttribute('data-user-token');
|
|
92
|
+
if (document.readyState === 'loading') {
|
|
93
|
+
document.addEventListener('DOMContentLoaded', drop, { once: true });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
drop();
|
|
97
|
+
}
|
|
53
98
|
function detachRefreshRequestListener() {
|
|
54
99
|
if (refreshRequestListener) {
|
|
55
100
|
window.removeEventListener(IDENTITY_REFRESH_REQUESTED_EVENT, refreshRequestListener);
|
|
@@ -154,11 +199,11 @@ async function refreshWidgetToken(options) {
|
|
|
154
199
|
}
|
|
155
200
|
try {
|
|
156
201
|
const relayed = await relayWidgetToken(options);
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
202
|
+
// The rotated token goes to the live instance ONLY. Writing it back to
|
|
203
|
+
// the loader tag put a live bearer token for the signed-in user in the
|
|
204
|
+
// host page's DOM, readable by every script on the page, and refreshed
|
|
205
|
+
// there for as long as the tab stayed open.
|
|
206
|
+
// (`window.Appilot.setUserToken`, widget >= 2026-08-25.)
|
|
162
207
|
const appilot = window.Appilot;
|
|
163
208
|
appilot?.setUserToken?.(relayed.token);
|
|
164
209
|
scheduleTokenRefresh(relayed.expiresIn, options);
|
|
@@ -212,6 +257,7 @@ export async function bootAppilotWidget(options) {
|
|
|
212
257
|
const script = document.createElement('script');
|
|
213
258
|
script.src = options.widgetScriptUrl;
|
|
214
259
|
script.async = true;
|
|
260
|
+
script.setAttribute(LOADER_MARKER_ATTR, '');
|
|
215
261
|
if (options.widgetKey)
|
|
216
262
|
script.dataset.apiKey = options.widgetKey;
|
|
217
263
|
script.dataset.apiUrl = options.appilotApiUrl || DEFAULT_APPILOT_API_URL;
|
|
@@ -234,6 +280,9 @@ export async function bootAppilotWidget(options) {
|
|
|
234
280
|
clearBootTimer();
|
|
235
281
|
bootAbortController = null;
|
|
236
282
|
setAvailability('ready');
|
|
283
|
+
// The bundle has its config; the token has no further reason to be in
|
|
284
|
+
// the page.
|
|
285
|
+
releaseBootToken(script);
|
|
237
286
|
scheduleTokenRefresh(relayed.expiresIn, options);
|
|
238
287
|
attachRefreshRequestListener(options);
|
|
239
288
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appilot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Appilot SDK: the single developer-facing surface for building on Appilot. Boot the assistant (bootAppilotWidget), register client actions (registerTool, WebMCP-aligned), and run Focused Sessions (startFocusedSession). Browser-only, React-free, zero runtime dependencies.",
|
|
5
5
|
"homepage": "https://appilot.space",
|
|
6
6
|
"author": "BetterKnow GmbH",
|