@jini-ai/agentic 0.6.19 → 0.6.23

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.6.23
2
+
3
+ - Security hardening.
4
+
1
5
  # @jini-ai/agentic
2
6
 
3
7
  ## 0.6.19
@@ -1,4 +1,4 @@
1
- import { createPublicationFilter, publishedText } from './publication-filter.js';
1
+ import { createPublicationFilter, publicElement, publishedText } from './publication-filter.js';
2
2
  import { AGENT_ELEMENT_ATTRIBUTE, AGENT_LABEL_ATTRIBUTE, AGENT_PAGE_ATTRIBUTE, AGENT_PRIVATE_ATTRIBUTE, AGENT_ROLE_ATTRIBUTE, AGENT_ELEMENT_ROLES, resolveHandleSelector, } from '../element-handles.js';
3
3
  import { normalizeAgentLabel } from '../guards.js';
4
4
  /**
@@ -6,7 +6,8 @@ import { normalizeAgentLabel } from '../guards.js';
6
6
  *
7
7
  * Deliberately mechanical: every refusal — handle validation, the credential-field guard, the
8
8
  * page allowlist, highlight clamping — already ran in `executePageCapability` before anything
9
- * here is called. Re-checking would duplicate policy in a place that will drift from it.
9
+ * here is called. Live DOM visibility and interaction state are rechecked here immediately
10
+ * before effects, because the page can change after capability validation.
10
11
  *
11
12
  * Scoped to a `root` the host names, never `document`. Scanning the whole document would make
12
13
  * any markup anywhere — including rendered user content — an authorization decision, which
@@ -244,20 +245,22 @@ function isPrivate(node) {
244
245
  return node.closest(PRIVATE_SELECTOR) !== null;
245
246
  }
246
247
  /**
247
- * `element`'s text content with every `[data-agent-private]` subtree left out — the only text
248
+ * `element`'s text content with private and inactive subtrees left out — the only text
248
249
  * this driver ever reports, so a secret shown inside a published card never rides out on the
249
- * card's own text. @complexity O(subtree size).
250
+ * card's own text.
251
+ * @returns Public text without mutating the DOM.
252
+ * @complexity O(n × d) time and O(n) space for subtree size n and ancestor depth d.
253
+ * @overallScore 95/100
254
+ * @qualityFindings Low: Live CSS visibility checks repeat along ancestor paths during a scan.
250
255
  */
251
256
  function publicTextOf(element) {
252
- if (element.querySelector(PRIVATE_SELECTOR) === null) {
253
- /* c8 ignore next -- `Node.textContent` is typed nullable for the abstract node; on an Element it is always a string, so the `?? ''` satisfies the type and is not a reachable path. */
254
- return element.textContent ?? '';
255
- }
256
- const copy = element.cloneNode(true);
257
- for (const hidden of Array.from(copy.querySelectorAll(PRIVATE_SELECTOR)))
258
- hidden.remove();
259
- /* c8 ignore next -- as above. */
260
- return copy.textContent ?? '';
257
+ if (!publicElement({ element }))
258
+ return '';
259
+ return Array.from(element.childNodes).map(node => {
260
+ if (node instanceof Element)
261
+ return publicTextOf(node);
262
+ return node instanceof Text ? node.data : '';
263
+ }).join('');
261
264
  }
262
265
  function describe(element, page, publicText) {
263
266
  const role = element.getAttribute(AGENT_ROLE_ATTRIBUTE);
@@ -306,6 +309,74 @@ function findSelectableOption({ control, handle, option }, _optional = {}) {
306
309
  }
307
310
  return match;
308
311
  }
312
+ /** Resolve only label-associated fields, never arbitrary descendant buttons or links.
313
+ * A descendant that is itself independently published (its own `[data-agent-element]`) must
314
+ * never be silently adopted as this handle's control — it is a distinct, separately
315
+ * addressable target with its own label, not unhandled wrapper markup like the plain
316
+ * `<li><label><input>` this fallback exists for. Filtering to descendants whose nearest
317
+ * published ancestor is still `element` itself excludes exactly that case.
318
+ * @returns Distinct owned controls, retaining ambiguity for the caller to refuse.
319
+ * @complexity O(l × d + c) time and O(c) space for labels l, depth d and labelled controls c.
320
+ * @overallScore 96/100
321
+ * @qualityFindings Low: Native label relationships are read from the live DOM, so callers must recheck before effects.
322
+ */
323
+ function labelledControls({ element, root }) {
324
+ const labels = Array.from(element.querySelectorAll('label'));
325
+ if (element instanceof HTMLLabelElement)
326
+ labels.unshift(element);
327
+ const controls = labels.flatMap(label => [label.control, ...label.querySelectorAll('input,textarea,select')]);
328
+ return [...new Set(controls)].filter((control) => {
329
+ if (!control?.matches('input,textarea,select') || isPrivate(control))
330
+ return false;
331
+ if (!(root instanceof Node) || !root.contains(control))
332
+ return false;
333
+ const owner = control.closest(`[${AGENT_ELEMENT_ATTRIBUTE}]`);
334
+ return owner === element || (element instanceof HTMLLabelElement && owner === null);
335
+ });
336
+ }
337
+ /** Enforce live visibility, disabled and modal state before any control effect.
338
+ * @throws Error with the reason the current control cannot be operated.
339
+ * @complexity O(m × d) time and O(m) space for m modal candidates and ancestor depth d.
340
+ * @overallScore 95/100
341
+ * @qualityFindings Low: Legacy DOMs without the modal selector expose only ARIA modal state.
342
+ */
343
+ function assertActiveControl({ element, handle, action }) {
344
+ if (!publicElement({ element }))
345
+ throw new Error(`"${handle}" is hidden or inactive on this page`);
346
+ // A disabled control is not one a user could touch, so writing to it is a way around the
347
+ // page's own rule and the resulting selection cannot be submitted. The per-`<option>` filter
348
+ // in `findSelectableOption` reads as if it enforced that, but only ever looked at each option — a
349
+ // `<select disabled>` (and a `<select>` inside a `<fieldset disabled>`, which `:disabled`
350
+ // also covers and `.disabled` does not) was written to happily, and the executor then
351
+ // reported `targetChanged: true` because the value really had changed.
352
+ //
353
+ // Checked before the option lookup on purpose: the refusal is about the control, so it must
354
+ // not depend on the caller having named a real option, and the "Available: ..." message must
355
+ // not enumerate a disabled control's options.
356
+ if (element.matches(':disabled') || element.closest('[aria-disabled="true"]')) {
357
+ if (action === 'selectOption')
358
+ throw new Error(`"${handle}" is disabled, so its selection cannot be changed — a user cannot change it either, and its value would not be submitted`);
359
+ throw new Error(`"${handle}" is disabled, so it cannot be changed`);
360
+ }
361
+ assertModalControl({ element, handle });
362
+ }
363
+ /** Respect the focused modal, including native top-layer dialogs in supporting browsers.
364
+ * @throws Error when the target is in the inactive background.
365
+ * @complexity O(m × d) time and O(m) space for m modal candidates and ancestor depth d.
366
+ * @overallScore 94/100
367
+ * @qualityFindings Low: Unfocused ARIA modal stacks use document order, matching the host's overlay convention.
368
+ */
369
+ function assertModalControl({ element, handle }) {
370
+ const document = element.ownerDocument;
371
+ const ariaModal = '[role="dialog"][aria-modal="true"],[role="alertdialog"][aria-modal="true"]';
372
+ const nativeModal = document.defaultView?.CSS?.supports?.('selector(:modal)') === true;
373
+ const selector = nativeModal ? `:modal,${ariaModal}` : ariaModal;
374
+ const modals = Array.from(document.querySelectorAll(selector)).filter(modal => publicElement({ element: modal }));
375
+ const focused = modals.findLast(modal => modal.contains(document.activeElement));
376
+ const active = focused ?? modals.at(-1);
377
+ if (active && !active.contains(element))
378
+ throw new Error(`"${handle}" is behind the active modal`);
379
+ }
309
380
  /**
310
381
  * Builds a driver over `root`.
311
382
  *
@@ -339,12 +410,15 @@ export function createDomPageDriver({ root, pages }, options = {}) {
339
410
  * Every element published under `handle`. A page that (accidentally, or via a templating bug)
340
411
  * publishes the same handle twice makes `find`'s single-match resolution ambiguous — surfaced
341
412
  * here so both callers below can refuse rather than silently pick one.
413
+ * @complexity O(h × d) time and O(h) space for matching handles h and ancestor depth d.
414
+ * @overallScore 97/100
415
+ * @qualityFindings Low: Visibility checks use live computed styles for every candidate.
342
416
  */
343
417
  const findAll = (handle) => {
344
418
  const matches = Array.from(root.querySelectorAll(resolveHandleSelector({ handle })));
345
419
  if (options.publication && matches.length !== 1)
346
420
  return [];
347
- return matches.filter(element => !isPrivate(element) && (!publicationFilter || publicationFilter.allows({ element, action: 'read' })));
421
+ return matches.filter(element => publicElement({ element }) && (!publicationFilter || publicationFilter.allows({ element, action: 'read' })));
348
422
  };
349
423
  /**
350
424
  * More than one element answers to the same handle — a page-authoring bug (duplicate
@@ -357,6 +431,12 @@ export function createDomPageDriver({ root, pages }, options = {}) {
357
431
  const ambiguousHandleError = (handle, count) => new Error(`"${handle}" is published on ${count} elements on this page, so which one it addresses is `
358
432
  + 'ambiguous — this is a page-authoring bug (duplicate data-agent-element), not something a '
359
433
  + 'caller can resolve; publish a unique handle per element');
434
+ /** Resolve one handle and recheck its live action target before returning.
435
+ * @throws Error for missing, ambiguous or currently unavailable controls.
436
+ * @complexity O(h × d + l + m × d) time and O(h + l + m) space for matches h, labels l, modals m and depth d.
437
+ * @overallScore 96/100
438
+ * @qualityFindings Low: The established driver API reports refusals through Error messages.
439
+ */
360
440
  const find = (handle, action = 'read') => {
361
441
  const matches = findAll(handle);
362
442
  if (matches.length === 0)
@@ -364,7 +444,10 @@ export function createDomPageDriver({ root, pages }, options = {}) {
364
444
  if (matches.length > 1)
365
445
  throw ambiguousHandleError(handle, matches.length);
366
446
  const element = matches[0];
367
- if (publicationFilter && !publicationFilter.allows({ element: controlOf(element), action }))
447
+ const control = controlOf(element, action);
448
+ if (['click', 'fill', 'selectOption'].includes(action))
449
+ assertActiveControl({ element: control, handle, action });
450
+ if (publicationFilter && !publicationFilter.allows({ element: control, action }))
368
451
  throw new Error('Published action unavailable');
369
452
  return element;
370
453
  };
@@ -374,8 +457,13 @@ export function createDomPageDriver({ root, pages }, options = {}) {
374
457
  * `<details>` is called out because its interactive part is the `<summary>`: clicking the
375
458
  * `<details>` element itself dispatches an event the platform ignores, so a disclosure widget
376
459
  * would never open and the caller would be told the click landed.
460
+ * @returns The direct control or one unambiguous label-associated field; regions remain regions.
461
+ * @throws Error for ambiguous or out-of-root label forwarding before effects.
462
+ * @complexity O(l × d + c) time and O(c) space for labels l, controls c and ancestor depth d.
463
+ * @overallScore 96/100
464
+ * @qualityFindings Low: Malformed native details without a summary retain their established region-click behavior.
377
465
  */
378
- const controlOf = (element) => {
466
+ const controlOf = (element, action = 'read') => {
379
467
  // An editable region IS the control. Descending would find a link inside a rich-text document
380
468
  // and address that instead of the text the caller meant to write.
381
469
  if (isEditableRegion(element))
@@ -384,29 +472,29 @@ export function createDomPageDriver({ root, pages }, options = {}) {
384
472
  // unscoped query returns the first summary anywhere in the subtree, so a `<details>`
385
473
  // containing another one would toggle the inner disclosure while reporting that the outer
386
474
  // handle was clicked — the false success this whole suite exists to catch.
387
- if (element instanceof HTMLDetailsElement) {
475
+ if (element instanceof HTMLDetailsElement)
388
476
  return element.querySelector(':scope > summary') ?? element;
389
- }
390
- if (element.matches('input, textarea, select, button, a'))
477
+ if (element.matches('input, textarea, select, button, a, summary'))
391
478
  return element;
392
- // A descendant that is itself independently published (its own `[data-agent-element]`) must
393
- // never be silently adopted as this handle's control — it is a distinct, separately
394
- // addressable target with its own label, not unhandled wrapper markup like the plain
395
- // `<li><label><input>` this descent exists for. Filtering to descendants whose nearest
396
- // published ancestor is still `element` itself excludes exactly that case while leaving
397
- // ordinary wrapper descent untouched.
398
- const owned = (node) => node.closest(`[${AGENT_ELEMENT_ATTRIBUTE}]`) === element
399
- && !isPrivate(node);
400
- const candidate = Array.from(element.querySelectorAll('input, textarea, select, button, a, summary'))
401
- .find(owned);
402
- return candidate ?? element;
479
+ const candidates = labelledControls({ element, root })
480
+ .filter(control => action !== 'read' || publicElement({ element: control }));
481
+ if (candidates.length === 1)
482
+ return candidates[0];
483
+ if (action === 'read')
484
+ return element;
485
+ if (candidates.length > 1)
486
+ throw new Error('The labelled control is ambiguous; use its own handle');
487
+ // Clicking a label has a native forwarding effect, including to controls outside the root.
488
+ if (element instanceof HTMLLabelElement)
489
+ throw new Error('The labelled control is unavailable in this page');
490
+ return element;
403
491
  };
404
492
  const publicationFilter = options.publication ? createPublicationFilter({ root, publication: options.publication,
405
493
  fieldOf: element => fieldDescriptorOf(controlOf(element)) ?? dropdownFieldDescriptorOf({ dropdown: element instanceof HTMLSelectElement ? element : undefined }) }) : undefined;
406
494
  return {
407
495
  async findElements(_required, filter = {}) {
408
496
  const found = Array.from(root.querySelectorAll(`[${AGENT_ELEMENT_ATTRIBUTE}]`))
409
- .filter(element => !isPrivate(element) && (!publicationFilter || findAll(element.getAttribute(AGENT_ELEMENT_ATTRIBUTE) ?? '').includes(element)))
497
+ .filter(element => publicElement({ element }) && (!publicationFilter || findAll(element.getAttribute(AGENT_ELEMENT_ATTRIBUTE) ?? '').includes(element)))
410
498
  .map((element) => describe(element, pageOf(element), publicationFilter ? publishedText({ element }) : undefined))
411
499
  .filter((element) => element.handle.length > 0);
412
500
  const query = filter.query?.toLowerCase();
@@ -562,20 +650,6 @@ export function createDomPageDriver({ root, pages }, options = {}) {
562
650
  if (!(control instanceof HTMLSelectElement)) {
563
651
  throw new Error(`"${handle}" is not a dropdown`);
564
652
  }
565
- // A disabled control is not one a user could touch, so writing to it is a way around the
566
- // page's own rule and the resulting selection cannot be submitted. The per-`<option>` filter
567
- // in `findSelectableOption` reads as if it enforced that, but only ever looked at each option — a
568
- // `<select disabled>` (and a `<select>` inside a `<fieldset disabled>`, which `:disabled`
569
- // also covers and `.disabled` does not) was written to happily, and the executor then
570
- // reported `targetChanged: true` because the value really had changed.
571
- //
572
- // Checked before the option lookup on purpose: the refusal is about the control, so it must
573
- // not depend on the caller having named a real option, and the "Available: ..." message must
574
- // not enumerate a disabled control's options.
575
- if (control.matches(':disabled')) {
576
- throw new Error(`"${handle}" is disabled, so its selection cannot be changed — `
577
- + 'a user cannot change it either, and its value would not be submitted');
578
- }
579
653
  const match = findSelectableOption({ control, handle, option });
580
654
  if (control.multiple) {
581
655
  // A multi-select accumulates by default. Assigning `value` would silently clear every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agentic",
3
- "version": "0.6.19",
3
+ "version": "0.6.23",
4
4
  "description": "Framework-free agent-control layer: page-capability vocabulary and manifests, the data-agent-* markup convention, AG-UI projection, generative-UI streams, and A2UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {