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.
@@ -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
+ }
@@ -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
- for (const attr of Array.from(el.attributes))
197
- attributes[attr.name] = attr.value;
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';
@@ -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. The fresh
15
- * token is written back to the loader script tag (data-user-token) so every
16
- * subsequent (re)boot stays authenticated past the hour, AND handed to the
17
- * live instance through `window.Appilot.setUserToken` so a long-lived tab
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 {
@@ -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. The fresh
15
- * token is written back to the loader script tag (data-user-token) so every
16
- * subsequent (re)boot stays authenticated past the hour, AND handed to the
17
- * live instance through `window.Appilot.setUserToken` so a long-lived tab
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
- // Keep the loader tag fresh so any widget (re)boot past the original TTL
158
- // stays authenticated.
159
- scriptEl?.setAttribute('data-user-token', relayed.token);
160
- // Feed the live instance so an open tab adopts the fresh token without
161
- // a reload (`window.Appilot.setUserToken`, widget >= 2026-08-25).
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.0",
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",