drupal-mcp-connector 2.7.3 → 2.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.
- package/.agents/commands/drupal-audit-config-best-practices.md +15 -0
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +4 -5
- package/.agents/commands/drupal-block-user.md +19 -0
- package/{.claude → .agents}/commands/drupal-bulk-create.md +4 -5
- package/{.claude → .agents}/commands/drupal-bulk-update.md +4 -5
- package/.agents/commands/drupal-config-get.md +18 -0
- package/.agents/commands/drupal-config-list.md +16 -0
- package/.agents/commands/drupal-config-set.md +19 -0
- package/.agents/commands/drupal-content-by-moderation-state.md +21 -0
- package/.agents/commands/drupal-create-block.md +20 -0
- package/{.claude → .agents}/commands/drupal-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-node.md +7 -8
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-create-redirect.md +4 -5
- package/.agents/commands/drupal-create-taxonomy-term.md +22 -0
- package/{.claude → .agents}/commands/drupal-create-translation.md +4 -5
- package/.agents/commands/drupal-create-user.md +23 -0
- package/.agents/commands/drupal-delete-media.md +21 -0
- package/.agents/commands/drupal-delete-node.md +22 -0
- package/.agents/commands/drupal-delete-taxonomy-term.md +21 -0
- package/{.claude → .agents}/commands/drupal-describe-fields.md +4 -5
- package/.agents/commands/drupal-drush-cache-rebuild.md +15 -0
- package/.agents/commands/drupal-drush-config-export.md +15 -0
- package/.agents/commands/drupal-drush-config-import.md +15 -0
- package/.agents/commands/drupal-drush-config-status.md +15 -0
- package/.agents/commands/drupal-drush-cron.md +15 -0
- package/.agents/commands/drupal-drush-module-disable.md +20 -0
- package/.agents/commands/drupal-drush-module-enable.md +18 -0
- package/.agents/commands/drupal-drush-module-list.md +16 -0
- package/.agents/commands/drupal-drush-security-updates.md +15 -0
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +4 -5
- package/.agents/commands/drupal-drush-status.md +15 -0
- package/.agents/commands/drupal-drush-updatedb.md +15 -0
- package/.agents/commands/drupal-drush-user-create.md +21 -0
- package/.agents/commands/drupal-drush-user-list.md +18 -0
- package/.agents/commands/drupal-drush-watchdog.md +18 -0
- package/{.claude → .agents}/commands/drupal-entity-create.md +4 -5
- package/.agents/commands/drupal-entity-delete.md +23 -0
- package/.agents/commands/drupal-entity-get.md +21 -0
- package/{.claude → .agents}/commands/drupal-entity-list.md +4 -5
- package/{.claude → .agents}/commands/drupal-entity-update.md +7 -8
- package/.agents/commands/drupal-find-orphaned-media.md +17 -0
- package/.agents/commands/drupal-get-entity-schema.md +19 -0
- package/.agents/commands/drupal-get-media.md +19 -0
- package/.agents/commands/drupal-get-node.md +19 -0
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-get-revision.md +4 -5
- package/.agents/commands/drupal-get-taxonomy-term.md +19 -0
- package/.agents/commands/drupal-get-taxonomy-terms.md +20 -0
- package/.agents/commands/drupal-get-user-by-name.md +18 -0
- package/.agents/commands/drupal-get-user.md +18 -0
- package/{.claude → .agents}/commands/drupal-governance-status.md +4 -5
- package/.agents/commands/drupal-graphql-introspect.md +16 -0
- package/{.claude → .agents}/commands/drupal-graphql.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-blocks.md +4 -5
- package/.agents/commands/drupal-list-content-types.md +15 -0
- package/.agents/commands/drupal-list-entity-types.md +15 -0
- package/.agents/commands/drupal-list-media-types.md +15 -0
- package/.agents/commands/drupal-list-media.md +20 -0
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +4 -5
- package/.agents/commands/drupal-list-moderation-states.md +19 -0
- package/{.claude → .agents}/commands/drupal-list-nodes.md +4 -5
- package/{.claude → .agents}/commands/drupal-list-revisions.md +4 -5
- package/.agents/commands/drupal-list-roles.md +15 -0
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +4 -5
- package/.agents/commands/drupal-list-users.md +19 -0
- package/.agents/commands/drupal-list-vocabularies.md +15 -0
- package/.agents/commands/drupal-mcp-whoami.md +15 -0
- package/.agents/commands/drupal-report-404-log.md +16 -0
- package/.agents/commands/drupal-report-accessibility-audit.md +17 -0
- package/.agents/commands/drupal-report-alias-coverage.md +17 -0
- package/.agents/commands/drupal-report-broken-embeds.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +4 -5
- package/.agents/commands/drupal-report-cache-config.md +15 -0
- package/.agents/commands/drupal-report-config-drift.md +15 -0
- package/.agents/commands/drupal-report-content-by-author.md +17 -0
- package/.agents/commands/drupal-report-content-summary.md +15 -0
- package/.agents/commands/drupal-report-duplicate-content.md +17 -0
- package/.agents/commands/drupal-report-field-completeness.md +20 -0
- package/.agents/commands/drupal-report-menu-integrity.md +16 -0
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +4 -5
- package/.agents/commands/drupal-report-module-audit.md +15 -0
- package/.agents/commands/drupal-report-orphan-pages.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +4 -5
- package/.agents/commands/drupal-report-permission-audit.md +15 -0
- package/.agents/commands/drupal-report-pii-exposure.md +18 -0
- package/.agents/commands/drupal-report-readability.md +18 -0
- package/.agents/commands/drupal-report-recently-published.md +17 -0
- package/.agents/commands/drupal-report-redirect-health.md +16 -0
- package/.agents/commands/drupal-report-revision-hotspots.md +17 -0
- package/.agents/commands/drupal-report-scheduled-content.md +17 -0
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +4 -5
- package/.agents/commands/drupal-report-seo-meta-coverage.md +18 -0
- package/.agents/commands/drupal-report-stale-content.md +19 -0
- package/.agents/commands/drupal-report-status-report.md +16 -0
- package/.agents/commands/drupal-report-taxonomy-usage.md +21 -0
- package/.agents/commands/drupal-report-text-format-audit.md +15 -0
- package/.agents/commands/drupal-report-translation-coverage.md +18 -0
- package/.agents/commands/drupal-report-unpublished.md +17 -0
- package/.agents/commands/drupal-report-user-activity.md +17 -0
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +19 -0
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +4 -5
- package/{.claude → .agents}/commands/drupal-revert-revision.md +4 -5
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +4 -5
- package/.agents/commands/drupal-search-content.md +21 -0
- package/.agents/commands/drupal-search.md +20 -0
- package/.agents/commands/drupal-security-info.md +15 -0
- package/.agents/commands/drupal-set-moderation-state.md +20 -0
- package/.agents/commands/drupal-site-info.md +15 -0
- package/.agents/commands/drupal-update-media.md +22 -0
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-node.md +10 -11
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +4 -5
- package/{.claude → .agents}/commands/drupal-update-redirect.md +4 -5
- package/.agents/commands/drupal-update-taxonomy-term.md +22 -0
- package/.agents/commands/drupal-update-user.md +24 -0
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +4 -5
- package/{.claude → .agents}/commands/drupal-upload-file.md +4 -5
- package/CHANGELOG.md +80 -0
- package/README.md +23 -12
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +99 -30
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/operations.js +39 -2
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +15 -1
- package/src/lib/server-tools.js +9 -0
- package/src/lib/site-target.js +78 -0
- package/src/lib/tool-prompts.js +2 -1
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/config.js +5 -2
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +24 -3
- package/src/tools/moderation.js +88 -9
- package/src/tools/nodes.js +55 -43
- package/.claude/commands/drupal-audit-config-best-practices.md +0 -16
- package/.claude/commands/drupal-block-user.md +0 -20
- package/.claude/commands/drupal-config-get.md +0 -19
- package/.claude/commands/drupal-config-list.md +0 -17
- package/.claude/commands/drupal-config-set.md +0 -20
- package/.claude/commands/drupal-content-by-moderation-state.md +0 -22
- package/.claude/commands/drupal-create-block.md +0 -21
- package/.claude/commands/drupal-create-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-create-user.md +0 -24
- package/.claude/commands/drupal-delete-media.md +0 -22
- package/.claude/commands/drupal-delete-node.md +0 -23
- package/.claude/commands/drupal-delete-taxonomy-term.md +0 -22
- package/.claude/commands/drupal-drush-cache-rebuild.md +0 -16
- package/.claude/commands/drupal-drush-config-export.md +0 -16
- package/.claude/commands/drupal-drush-config-import.md +0 -16
- package/.claude/commands/drupal-drush-config-status.md +0 -16
- package/.claude/commands/drupal-drush-cron.md +0 -16
- package/.claude/commands/drupal-drush-module-disable.md +0 -21
- package/.claude/commands/drupal-drush-module-enable.md +0 -19
- package/.claude/commands/drupal-drush-module-list.md +0 -17
- package/.claude/commands/drupal-drush-security-updates.md +0 -16
- package/.claude/commands/drupal-drush-status.md +0 -16
- package/.claude/commands/drupal-drush-updatedb.md +0 -16
- package/.claude/commands/drupal-drush-user-create.md +0 -22
- package/.claude/commands/drupal-drush-user-list.md +0 -19
- package/.claude/commands/drupal-drush-watchdog.md +0 -19
- package/.claude/commands/drupal-entity-delete.md +0 -24
- package/.claude/commands/drupal-entity-get.md +0 -22
- package/.claude/commands/drupal-find-orphaned-media.md +0 -18
- package/.claude/commands/drupal-get-entity-schema.md +0 -20
- package/.claude/commands/drupal-get-media.md +0 -20
- package/.claude/commands/drupal-get-node.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-term.md +0 -20
- package/.claude/commands/drupal-get-taxonomy-terms.md +0 -21
- package/.claude/commands/drupal-get-user-by-name.md +0 -19
- package/.claude/commands/drupal-get-user.md +0 -19
- package/.claude/commands/drupal-graphql-introspect.md +0 -17
- package/.claude/commands/drupal-list-content-types.md +0 -16
- package/.claude/commands/drupal-list-entity-types.md +0 -16
- package/.claude/commands/drupal-list-media-types.md +0 -16
- package/.claude/commands/drupal-list-media.md +0 -21
- package/.claude/commands/drupal-list-moderation-states.md +0 -20
- package/.claude/commands/drupal-list-roles.md +0 -16
- package/.claude/commands/drupal-list-users.md +0 -20
- package/.claude/commands/drupal-list-vocabularies.md +0 -16
- package/.claude/commands/drupal-mcp-whoami.md +0 -16
- package/.claude/commands/drupal-report-404-log.md +0 -17
- package/.claude/commands/drupal-report-accessibility-audit.md +0 -18
- package/.claude/commands/drupal-report-alias-coverage.md +0 -18
- package/.claude/commands/drupal-report-broken-embeds.md +0 -18
- package/.claude/commands/drupal-report-cache-config.md +0 -16
- package/.claude/commands/drupal-report-config-drift.md +0 -16
- package/.claude/commands/drupal-report-content-by-author.md +0 -18
- package/.claude/commands/drupal-report-content-summary.md +0 -16
- package/.claude/commands/drupal-report-duplicate-content.md +0 -18
- package/.claude/commands/drupal-report-field-completeness.md +0 -21
- package/.claude/commands/drupal-report-menu-integrity.md +0 -17
- package/.claude/commands/drupal-report-module-audit.md +0 -16
- package/.claude/commands/drupal-report-orphan-pages.md +0 -18
- package/.claude/commands/drupal-report-permission-audit.md +0 -16
- package/.claude/commands/drupal-report-pii-exposure.md +0 -19
- package/.claude/commands/drupal-report-readability.md +0 -19
- package/.claude/commands/drupal-report-recently-published.md +0 -18
- package/.claude/commands/drupal-report-redirect-health.md +0 -17
- package/.claude/commands/drupal-report-revision-hotspots.md +0 -18
- package/.claude/commands/drupal-report-scheduled-content.md +0 -18
- package/.claude/commands/drupal-report-seo-meta-coverage.md +0 -19
- package/.claude/commands/drupal-report-stale-content.md +0 -20
- package/.claude/commands/drupal-report-status-report.md +0 -17
- package/.claude/commands/drupal-report-taxonomy-usage.md +0 -22
- package/.claude/commands/drupal-report-text-format-audit.md +0 -16
- package/.claude/commands/drupal-report-translation-coverage.md +0 -19
- package/.claude/commands/drupal-report-unpublished.md +0 -18
- package/.claude/commands/drupal-report-user-activity.md +0 -18
- package/.claude/commands/drupal-report-workflow-bottlenecks.md +0 -20
- package/.claude/commands/drupal-search-content.md +0 -22
- package/.claude/commands/drupal-search.md +0 -21
- package/.claude/commands/drupal-security-info.md +0 -16
- package/.claude/commands/drupal-set-moderation-state.md +0 -21
- package/.claude/commands/drupal-site-info.md +0 -16
- package/.claude/commands/drupal-update-media.md +0 -23
- package/.claude/commands/drupal-update-taxonomy-term.md +0 -23
- package/.claude/commands/drupal-update-user.md +0 -25
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Northbound data-flow budgets (#179).
|
|
3
|
+
*
|
|
4
|
+
* Binds the same finite budget classes mcp_sentinel already enforces
|
|
5
|
+
* (rows, bytes, pages, requests, chained actions) to the inbound principal
|
|
6
|
+
* and the authoritative target. Budget counters are keyed by those two
|
|
7
|
+
* values — never by MCP session, client IP, or a caller-supplied field —
|
|
8
|
+
* so pagination, retries, batching, and a new chain id cannot reset them.
|
|
9
|
+
*
|
|
10
|
+
* Classification and destination travel on the governed request as the
|
|
11
|
+
* source's narrow-only wire contract (X-MCP-Declared-Ceiling /
|
|
12
|
+
* X-MCP-Declared-Destination). Denials name a stable reason and a
|
|
13
|
+
* correlation id and never echo restricted payload.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
18
|
+
import { SecurityError } from "./security.js";
|
|
19
|
+
|
|
20
|
+
/** Wire header: narrow-only classification ceiling (mcp_sentinel 2.9.0). */
|
|
21
|
+
export const HEADER_DECLARED_CEILING = "X-MCP-Declared-Ceiling";
|
|
22
|
+
|
|
23
|
+
/** Wire header: declared destination, recorded in source evidence. */
|
|
24
|
+
export const HEADER_DECLARED_DESTINATION = "X-MCP-Declared-Destination";
|
|
25
|
+
|
|
26
|
+
/** Stable source / connector reason codes — do not invent a second set. */
|
|
27
|
+
export const REASON_READ = "read_budget_exceeded";
|
|
28
|
+
export const REASON_PAGE = "page_budget_exceeded";
|
|
29
|
+
export const REASON_RESPONSE_SIZE = "response_size_cap_exceeded";
|
|
30
|
+
export const REASON_CHAINED_ACTION = "chained_action_budget_exceeded";
|
|
31
|
+
export const REASON_BUDGET_CONTEXT_MISSING = "budget_context_missing";
|
|
32
|
+
|
|
33
|
+
/** Codes the source already emits; remapped onto a connector correlation id. */
|
|
34
|
+
export const SOURCE_BUDGET_REASONS = Object.freeze([
|
|
35
|
+
REASON_READ,
|
|
36
|
+
REASON_PAGE,
|
|
37
|
+
REASON_RESPONSE_SIZE,
|
|
38
|
+
REASON_CHAINED_ACTION,
|
|
39
|
+
"classification_egress_denied",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Built-in defaults — identical to mcp_sentinel McpReadBudgetResolver.
|
|
44
|
+
* A second model is out of scope; these numbers are the shared floor.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_BUDGETS = Object.freeze({
|
|
47
|
+
results: 500,
|
|
48
|
+
bytes: 8388608,
|
|
49
|
+
requests: 600,
|
|
50
|
+
requestWindowSec: 60,
|
|
51
|
+
pages: 120,
|
|
52
|
+
pageWindowSec: 60,
|
|
53
|
+
chainedActions: 600,
|
|
54
|
+
chainedActionWindowSec: 60,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const DECLARATION_MAX_LENGTH = 128;
|
|
58
|
+
const DECLARATION_PATTERN = /^[A-Za-z0-9._:-]+$/;
|
|
59
|
+
|
|
60
|
+
const store = new AsyncLocalStorage();
|
|
61
|
+
|
|
62
|
+
/** @type {Map<string, {count: number, resetAt: number}>} */
|
|
63
|
+
const windows = new Map();
|
|
64
|
+
|
|
65
|
+
const WINDOW_KINDS = new Set(["request", "page", "chained_action"]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Denial for a data-flow budget or missing request-scoped context.
|
|
69
|
+
* Extends SecurityError so tools/call already wraps it as Access denied.
|
|
70
|
+
*/
|
|
71
|
+
export class DataFlowBudgetError extends SecurityError {
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} reason Stable reason code.
|
|
74
|
+
* @param {string} correlationId Request-scoped correlation id.
|
|
75
|
+
* @param {string} [budget] Budget class (requests, pages, rows, bytes, chained_actions).
|
|
76
|
+
*/
|
|
77
|
+
constructor(reason, correlationId, budget) {
|
|
78
|
+
super(`${reason} (correlation ${correlationId})`);
|
|
79
|
+
this.name = "DataFlowBudgetError";
|
|
80
|
+
this.reason = reason;
|
|
81
|
+
this.correlationId = correlationId;
|
|
82
|
+
this.budget = budget;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Process-global budget key for an inbound identity. Session ids are never used.
|
|
88
|
+
* @param {object|null} identity
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function principalBudgetKey(identity) {
|
|
92
|
+
if (!identity) return "local-operator";
|
|
93
|
+
const sub = typeof identity.sub === "string" && identity.sub.trim()
|
|
94
|
+
? identity.sub.trim()
|
|
95
|
+
: "-";
|
|
96
|
+
const clientId = typeof identity.clientId === "string" && identity.clientId.trim()
|
|
97
|
+
? identity.clientId.trim()
|
|
98
|
+
: "-";
|
|
99
|
+
return `${sub}:${clientId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string|undefined} value
|
|
104
|
+
* @returns {string|undefined}
|
|
105
|
+
*/
|
|
106
|
+
function sanitizeDeclaration(value) {
|
|
107
|
+
if (typeof value !== "string") return undefined;
|
|
108
|
+
const trimmed = value.trim();
|
|
109
|
+
if (!trimmed || trimmed.length > DECLARATION_MAX_LENGTH) return undefined;
|
|
110
|
+
return DECLARATION_PATTERN.test(trimmed) ? trimmed : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {object} [raw]
|
|
115
|
+
* @returns {typeof DEFAULT_BUDGETS}
|
|
116
|
+
*/
|
|
117
|
+
export function resolveBudgetLimits(raw = {}) {
|
|
118
|
+
const merged = new Map(Object.entries(DEFAULT_BUDGETS));
|
|
119
|
+
const supplied = new Map(Object.entries(raw));
|
|
120
|
+
for (const [key, fallback] of Object.entries(DEFAULT_BUDGETS)) {
|
|
121
|
+
const value = supplied.get(key);
|
|
122
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
123
|
+
merged.set(key, value);
|
|
124
|
+
} else {
|
|
125
|
+
merged.set(key, fallback);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return Object.fromEntries(merged);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build request-scoped enforcement context. Target is required; identity may
|
|
133
|
+
* be null (local operator). Caller-supplied session fields are ignored.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @param {object|null} [params.identity]
|
|
137
|
+
* @param {?{name?: string, baseUrl?: string, source?: string}} params.target
|
|
138
|
+
* @param {object} [params.site]
|
|
139
|
+
* @param {object} [params.limits]
|
|
140
|
+
* @param {() => number} [params.now]
|
|
141
|
+
* @param {string} [params.correlationId]
|
|
142
|
+
* @returns {object}
|
|
143
|
+
* @throws {DataFlowBudgetError}
|
|
144
|
+
*/
|
|
145
|
+
export function buildDataFlowContext({
|
|
146
|
+
identity = null,
|
|
147
|
+
target,
|
|
148
|
+
site,
|
|
149
|
+
limits,
|
|
150
|
+
now = () => Date.now(),
|
|
151
|
+
correlationId,
|
|
152
|
+
} = {}) {
|
|
153
|
+
const id = correlationId || randomUUID();
|
|
154
|
+
const targetName = typeof target?.name === "string" ? target.name.trim() : "";
|
|
155
|
+
if (!targetName) {
|
|
156
|
+
throw new DataFlowBudgetError(REASON_BUDGET_CONTEXT_MISSING, id, "context");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sec = site?.security ?? {};
|
|
160
|
+
const clientId = typeof identity?.clientId === "string" && identity.clientId.trim()
|
|
161
|
+
? identity.clientId.trim()
|
|
162
|
+
: "local-operator";
|
|
163
|
+
const destination = sanitizeDeclaration(`${clientId}:${targetName}`);
|
|
164
|
+
const ceiling = sanitizeDeclaration(sec.declaredCeiling) ?? "internal";
|
|
165
|
+
const declaredCeiling = sanitizeDeclaration(sec.declaredCeiling);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
principalKey: principalBudgetKey(identity),
|
|
169
|
+
targetName,
|
|
170
|
+
destination,
|
|
171
|
+
ceiling: declaredCeiling ?? ceiling,
|
|
172
|
+
declaredCeiling,
|
|
173
|
+
correlationId: id,
|
|
174
|
+
limits: resolveBudgetLimits(limits ?? sec.readBudgets),
|
|
175
|
+
now,
|
|
176
|
+
currentRequest: { rows: 0, bytes: 0, counted: false },
|
|
177
|
+
// Connector-side counters bind to an authenticated inbound principal.
|
|
178
|
+
// Stdio / local operator still send declared headers; source uid budgets
|
|
179
|
+
// remain the floor there (see #178).
|
|
180
|
+
enforce: Boolean(identity),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} context
|
|
186
|
+
* @param {Function} fn
|
|
187
|
+
* @returns {*}
|
|
188
|
+
*/
|
|
189
|
+
export function runWithDataFlow(context, fn) {
|
|
190
|
+
return store.run(context, fn);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @returns {object|null}
|
|
195
|
+
*/
|
|
196
|
+
export function getDataFlowContext() {
|
|
197
|
+
return store.getStore() ?? null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Headers for one northbound Drupal request. Empty when no context is bound.
|
|
202
|
+
* @param {object} [context]
|
|
203
|
+
* @returns {Object<string,string>}
|
|
204
|
+
*/
|
|
205
|
+
export function northboundHeaders(context) {
|
|
206
|
+
const ctx = context ?? getDataFlowContext();
|
|
207
|
+
if (!ctx?.targetName || !ctx.principalKey) return {};
|
|
208
|
+
const headers = new Map();
|
|
209
|
+
const ceiling = sanitizeDeclaration(ctx.declaredCeiling);
|
|
210
|
+
if (ceiling) headers.set(HEADER_DECLARED_CEILING, ceiling);
|
|
211
|
+
const destination = sanitizeDeclaration(ctx.destination);
|
|
212
|
+
if (destination) headers.set(HEADER_DECLARED_DESTINATION, destination);
|
|
213
|
+
return Object.fromEntries(headers);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {object|null} ctx
|
|
218
|
+
* @returns {object}
|
|
219
|
+
* @throws {DataFlowBudgetError}
|
|
220
|
+
*/
|
|
221
|
+
function requireBoundContext(ctx) {
|
|
222
|
+
if (!ctx?.principalKey || !ctx.targetName) {
|
|
223
|
+
throw new DataFlowBudgetError(
|
|
224
|
+
REASON_BUDGET_CONTEXT_MISSING,
|
|
225
|
+
ctx?.correlationId || randomUUID(),
|
|
226
|
+
"context",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return ctx;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {string} kind
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
function reasonFor(kind) {
|
|
237
|
+
switch (kind) {
|
|
238
|
+
case "page":
|
|
239
|
+
return REASON_PAGE;
|
|
240
|
+
case "bytes":
|
|
241
|
+
return REASON_RESPONSE_SIZE;
|
|
242
|
+
case "chained_action":
|
|
243
|
+
return REASON_CHAINED_ACTION;
|
|
244
|
+
case "request":
|
|
245
|
+
case "rows":
|
|
246
|
+
return REASON_READ;
|
|
247
|
+
default: {
|
|
248
|
+
const _exhaustive = kind;
|
|
249
|
+
return _exhaustive && REASON_READ;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} kind
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
function budgetClass(kind) {
|
|
259
|
+
switch (kind) {
|
|
260
|
+
case "chained_action":
|
|
261
|
+
return "chained_actions";
|
|
262
|
+
case "rows":
|
|
263
|
+
return "rows";
|
|
264
|
+
case "bytes":
|
|
265
|
+
return "bytes";
|
|
266
|
+
case "page":
|
|
267
|
+
return "pages";
|
|
268
|
+
case "request":
|
|
269
|
+
return "requests";
|
|
270
|
+
default: {
|
|
271
|
+
const _exhaustive = kind;
|
|
272
|
+
return _exhaustive || "requests";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Consume a budget class only when an inbound principal is bound.
|
|
279
|
+
* @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
|
|
280
|
+
* @param {number} [amount]
|
|
281
|
+
* @param {{retry?: boolean}} [options]
|
|
282
|
+
* @returns {void}
|
|
283
|
+
*/
|
|
284
|
+
export function consumeBudgetIfEnforced(kind, amount = 1, options = {}) {
|
|
285
|
+
if (!getDataFlowContext()?.enforce) return;
|
|
286
|
+
consumeBudget(kind, amount, options);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Account a successful northbound body against row and byte caps.
|
|
291
|
+
* @param {object|null} json
|
|
292
|
+
* @param {string} [text]
|
|
293
|
+
* @returns {void}
|
|
294
|
+
*/
|
|
295
|
+
export function accountNorthboundBody(json, text = "") {
|
|
296
|
+
if (!getDataFlowContext()?.enforce) return;
|
|
297
|
+
if (text) consumeBudget("bytes", Buffer.byteLength(text));
|
|
298
|
+
const rows = Array.isArray(json?.data) ? json.data.length : (json?.data ? 1 : 0);
|
|
299
|
+
if (rows) consumeBudget("rows", rows);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Consume one budget class against the request-scoped principal+target key.
|
|
304
|
+
*
|
|
305
|
+
* @param {"request"|"page"|"rows"|"bytes"|"chained_action"} kind
|
|
306
|
+
* @param {number} [amount]
|
|
307
|
+
* @param {{retry?: boolean}} [options]
|
|
308
|
+
* @returns {void}
|
|
309
|
+
* @throws {DataFlowBudgetError}
|
|
310
|
+
*/
|
|
311
|
+
export function consumeBudget(kind, amount = 1, options = {}) {
|
|
312
|
+
const ctx = requireBoundContext(getDataFlowContext());
|
|
313
|
+
const qty = typeof amount === "number" && amount > 0 ? amount : 1;
|
|
314
|
+
|
|
315
|
+
if (options.retry && (kind === "request" || kind === "page")) {
|
|
316
|
+
if (!ctx.currentRequest.counted) {
|
|
317
|
+
throw deny(ctx, kind);
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (kind === "rows" || kind === "bytes") {
|
|
323
|
+
const cap = kind === "rows" ? ctx.limits.results : ctx.limits.bytes;
|
|
324
|
+
const used = kind === "rows" ? ctx.currentRequest.rows : ctx.currentRequest.bytes;
|
|
325
|
+
if (used + qty > cap) {
|
|
326
|
+
throw deny(ctx, kind);
|
|
327
|
+
}
|
|
328
|
+
if (kind === "rows") ctx.currentRequest.rows += qty;
|
|
329
|
+
else ctx.currentRequest.bytes += qty;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!WINDOW_KINDS.has(kind)) {
|
|
334
|
+
throw deny(ctx, kind);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!windowConsume(ctx, kind, qty)) {
|
|
338
|
+
throw deny(ctx, kind);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (kind === "request") {
|
|
342
|
+
ctx.currentRequest = { rows: 0, bytes: 0, counted: true };
|
|
343
|
+
} else {
|
|
344
|
+
ctx.currentRequest.counted = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {object} ctx
|
|
350
|
+
* @param {string} kind
|
|
351
|
+
* @returns {DataFlowBudgetError}
|
|
352
|
+
*/
|
|
353
|
+
function deny(ctx, kind) {
|
|
354
|
+
return new DataFlowBudgetError(reasonFor(kind), ctx.correlationId, budgetClass(kind));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {object} ctx
|
|
359
|
+
* @param {string} kind
|
|
360
|
+
* @returns {{limit: number, windowMs: number}}
|
|
361
|
+
*/
|
|
362
|
+
function windowSpec(ctx, kind) {
|
|
363
|
+
if (kind === "page") {
|
|
364
|
+
return { limit: ctx.limits.pages, windowMs: ctx.limits.pageWindowSec * 1000 };
|
|
365
|
+
}
|
|
366
|
+
if (kind === "chained_action") {
|
|
367
|
+
return {
|
|
368
|
+
limit: ctx.limits.chainedActions,
|
|
369
|
+
windowMs: ctx.limits.chainedActionWindowSec * 1000,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return { limit: ctx.limits.requests, windowMs: ctx.limits.requestWindowSec * 1000 };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @param {object} ctx
|
|
377
|
+
* @param {string} kind
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
function windowKey(ctx, kind) {
|
|
381
|
+
return `${ctx.principalKey}::${ctx.targetName}::${kind}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {object} ctx
|
|
386
|
+
* @param {string} kind
|
|
387
|
+
* @param {number} qty
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
function windowConsume(ctx, kind, qty) {
|
|
391
|
+
const { limit, windowMs } = windowSpec(ctx, kind);
|
|
392
|
+
const now = ctx.now();
|
|
393
|
+
const key = windowKey(ctx, kind);
|
|
394
|
+
let bucket = windows.get(key);
|
|
395
|
+
if (!bucket || now >= bucket.resetAt) {
|
|
396
|
+
bucket = { count: 0, resetAt: now + windowMs };
|
|
397
|
+
windows.set(key, bucket);
|
|
398
|
+
}
|
|
399
|
+
if (bucket.count + qty > limit) return false;
|
|
400
|
+
bucket.count += qty;
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Map a source-side refusal body onto a connector denial that carries our
|
|
406
|
+
* correlation id and none of the upstream payload.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} detail
|
|
409
|
+
* @param {string} [correlationId]
|
|
410
|
+
* @returns {DataFlowBudgetError|null}
|
|
411
|
+
*/
|
|
412
|
+
export function sourceBudgetDenial(detail, correlationId) {
|
|
413
|
+
const text = String(detail ?? "");
|
|
414
|
+
const reason = SOURCE_BUDGET_REASONS.find((code) => text.includes(code));
|
|
415
|
+
if (!reason) return null;
|
|
416
|
+
const id = correlationId || getDataFlowContext()?.correlationId || randomUUID();
|
|
417
|
+
return new DataFlowBudgetError(reason, id, reason);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Drop in-memory windows. Tests only.
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
export function resetDataFlowBudgets() {
|
|
425
|
+
windows.clear();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* JSON:API collection paths are type/bundle with no uuid segment.
|
|
430
|
+
* @param {string} path
|
|
431
|
+
* @returns {boolean}
|
|
432
|
+
*/
|
|
433
|
+
export function isCollectionJsonApiPath(path) {
|
|
434
|
+
const pathname = String(path ?? "").split("?")[0];
|
|
435
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
436
|
+
return parts[0] === "jsonapi" && parts.length === 3;
|
|
437
|
+
}
|
package/src/lib/dispatch.js
CHANGED
|
@@ -16,7 +16,11 @@ import { toolError, toolResult } from "./errors.js";
|
|
|
16
16
|
import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
18
|
import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
assertPrincipalEntitlement, callerTargetHints, getRequestIdentity,
|
|
21
|
+
} from "./principal.js";
|
|
22
|
+
import { assertExplicitSiteForWrite, withResolvedTarget } from "./site-target.js";
|
|
23
|
+
import { buildDataFlowContext, consumeBudgetIfEnforced, runWithDataFlow } from "./data-flow.js";
|
|
20
24
|
import { allHandlers } from "../tools/index.js";
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -39,6 +43,47 @@ export function listResolvableSiteConfigs() {
|
|
|
39
43
|
});
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Resolve which site this call addresses and how that name was chosen.
|
|
48
|
+
* Returns null for tools that do not address a single site (`list_sites`,
|
|
49
|
+
* unscoped `governance_status`).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} toolName
|
|
52
|
+
* @param {object} rawArgs Caller arguments before any rewrite.
|
|
53
|
+
* @param {object} [context]
|
|
54
|
+
* @returns {?{site: object, source: string, name: string}}
|
|
55
|
+
* @throws {SecurityError}
|
|
56
|
+
*/
|
|
57
|
+
export function resolveCallTarget(toolName, rawArgs, context = {}) {
|
|
58
|
+
if (toolName === "drupal_list_sites") return null;
|
|
59
|
+
if (toolName === "drupal_governance_status" && callerTargetHints(rawArgs).length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
|
|
64
|
+
if (identity) {
|
|
65
|
+
return assertPrincipalEntitlement({
|
|
66
|
+
toolName,
|
|
67
|
+
args: rawArgs,
|
|
68
|
+
identity,
|
|
69
|
+
sites: context.sites ?? listResolvableSiteConfigs(),
|
|
70
|
+
grants: context.grants,
|
|
71
|
+
defaultSite: context.defaultSite,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hints = callerTargetHints(rawArgs);
|
|
76
|
+
const unique = [...new Set(hints.map((hint) => hint.value))];
|
|
77
|
+
if (unique.length > 1) {
|
|
78
|
+
throw new SecurityError(
|
|
79
|
+
"Conflicting caller target hints do not select a single target.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const source = unique.length === 1 ? "hint" : "default";
|
|
83
|
+
const site = getSiteConfig(unique[0]);
|
|
84
|
+
return { site, source, name: site._name };
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
/**
|
|
43
88
|
* Derive the entity type a tool acts on, for destructive-allow assertions.
|
|
44
89
|
*
|
|
@@ -72,18 +117,21 @@ export async function securityMiddleware(toolName, args, handler, context = {})
|
|
|
72
117
|
const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
|
|
73
118
|
let nextArgs = rawArgs;
|
|
74
119
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
120
|
+
const resolved = resolveCallTarget(toolName, rawArgs, { ...context, identity });
|
|
121
|
+
if (context && typeof context === "object") {
|
|
122
|
+
context.resolvedTarget = resolved;
|
|
123
|
+
}
|
|
124
|
+
assertExplicitSiteForWrite(
|
|
125
|
+
toolName,
|
|
126
|
+
rawArgs,
|
|
127
|
+
resolved,
|
|
128
|
+
context.siteNames ?? listSiteNames(),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (resolved) {
|
|
132
|
+
// Authoritative name plus the real source. Injecting `site` alone would
|
|
133
|
+
// make whoami report source:"hint" for a defaulted call (#167 / identity path).
|
|
134
|
+
nextArgs = { ...rawArgs, site: resolved.name, _resolvedSource: resolved.source };
|
|
87
135
|
}
|
|
88
136
|
|
|
89
137
|
// Tools with no site context skip per-site checks. governance_status
|
|
@@ -96,25 +144,45 @@ export async function securityMiddleware(toolName, args, handler, context = {})
|
|
|
96
144
|
|
|
97
145
|
const site = getSiteConfig(nextArgs.site);
|
|
98
146
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
147
|
+
const assertCallAllowed = async () => {
|
|
148
|
+
// Source-governance gate (#176). The diagnostic tools stay callable while
|
|
149
|
+
// governance fails — they are how an operator learns which condition failed.
|
|
150
|
+
if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
151
|
+
await assertSourceGovernance(site);
|
|
152
|
+
}
|
|
104
153
|
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
const sec = resolveSecurityConfig(site);
|
|
155
|
+
const op = inferOperation(toolName);
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
157
|
+
if (op === "delete") {
|
|
158
|
+
assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
|
|
159
|
+
assertNotReadOnly(sec, toolName);
|
|
160
|
+
} else if (op === "write") {
|
|
161
|
+
assertNotReadOnly(sec, toolName);
|
|
162
|
+
} else if (op === "graphql" && nextArgs?.query) {
|
|
163
|
+
assertGraphqlMutationAllowed(sec, nextArgs.query);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (!resolved) {
|
|
168
|
+
await assertCallAllowed();
|
|
169
|
+
return handler(nextArgs);
|
|
115
170
|
}
|
|
116
171
|
|
|
117
|
-
|
|
172
|
+
const flow = buildDataFlowContext({
|
|
173
|
+
identity,
|
|
174
|
+
target: { name: resolved.name, baseUrl: site.baseUrl, source: resolved.source },
|
|
175
|
+
site,
|
|
176
|
+
});
|
|
177
|
+
return runWithDataFlow(flow, async () => {
|
|
178
|
+
await assertCallAllowed();
|
|
179
|
+
// Charge only after governance and policy gates pass, so an outage or a
|
|
180
|
+
// local deny cannot exhaust the window. Diagnostics do not consume.
|
|
181
|
+
if (!GOVERNANCE_DIAGNOSTIC_TOOLS.has(toolName)) {
|
|
182
|
+
consumeBudgetIfEnforced("chained_action");
|
|
183
|
+
}
|
|
184
|
+
return handler(nextArgs);
|
|
185
|
+
});
|
|
118
186
|
}
|
|
119
187
|
|
|
120
188
|
/**
|
|
@@ -137,8 +205,9 @@ export async function callTool(name, args, context = {}) {
|
|
|
137
205
|
}
|
|
138
206
|
|
|
139
207
|
try {
|
|
140
|
-
const
|
|
141
|
-
|
|
208
|
+
const ctx = { ...context };
|
|
209
|
+
const result = await securityMiddleware(name, args ?? {}, handler, ctx);
|
|
210
|
+
return toolResult(withResolvedTarget(result, ctx.resolvedTarget));
|
|
142
211
|
} catch (err) {
|
|
143
212
|
// Translate known error classes into clear, non-leaky isError responses;
|
|
144
213
|
// anything else falls through to toolError for a generic envelope.
|