/** * Cross-model write guard. * * `modify` already refuses to touch objects in standard Microsoft models * (isStandardModel), but that check says nothing about the far more common * real-world layout: a customer solution split across several CUSTOM models, * e.g. a shared `ContosoFinanceCore` plus country models `ContosoFinanceSK` / * `ContosoFinanceCZ` that extend it. The workspace's .rnrproj names exactly ONE of * them as the target model; every other model is somebody else's code as far as * this workspace is concerned. * * Without this guard the failure is silent and expensive: asked to "add a field * to ", the agent resolves the table by name, lands in the shared model * that happens to own it, and edits it in place. The field is then invisible in * the active model's project and version control, and it lands in code every * other country model inherits — instead of the one thing that was wanted, a * table extension in the active model. * * ## Consent cannot be self-served * * The first version of this guard accepted `modelName=""` on the * call as consent, mirroring the standard-model guard's "explicit modelName = * you know what you're doing". For a human that reasoning holds. For an agent it * does not: the refusal text told it which parameter to add, so it added the * parameter and wrote into the shared model anyway — with a well-argued * explanation afterwards. A bypass the caller can mint for itself is not a * bypass, it is a speed bump. * * So consent lives in server configuration, which the caller cannot reach * through any tool this server publishes: * - `D365FO_CROSS_MODEL_WRITE_MODELS=ModelA,ModelB` — allow these models, * - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — allow any model. * * It used to take a restart as well, which quietly worked against the guard: the * only sanctioned answer cost the user their session, so the pressure was all * towards finding a cheaper one. The policy is now re-read from .env on the next * attempt (reloadWritePolicy) — the decision stays the user's, it just no longer * costs a restart to act on. * * And the refusal deliberately does NOT hand the caller a workaround: it names * the extension route, and says the alternative is the user's decision to make * in configuration. * * ## "The agent has no tool to write it" holds only for THIS server's tools * * The caller is a coding assistant with generic file editing, and the settings * are plain JSON. One refusal was enough for it to find the host's mcp.json and * add the allow-list key to the env block itself — writes into the other model * then succeeded with a clean ✅, and the only trace was a console.error in a log * truncated on every restart, including the one the settings edit triggers. * * The guard cannot tell which hand wrote its configuration. What it can do is * refuse to be quiet: every path that lets a cross-model write through returns a * note the caller prints on its result (standDownNotice), so it reaches the * transcript rather than a log nobody opens. */ /** An extension of the target object that already exists in the active model. */ export interface ExistingExtension { name: string; type: string; } export interface CrossModelWriteCheck { /** Object being written, as resolved (may already be an extension). */ objectName: string; /** d365fo_file objectType, e.g. 'table', 'table-extension', 'class'. */ objectType: string; /** Model that owns the write target (the `` path segment, or the model asked for). */ owningModel: string | null | undefined; /** * `` segment of the same path. A match on EITHER segment counts as * "same model": most custom models sit in a package of the same name, but a * configured model name occasionally matches only the package (several models * in one package, or a model folder named after the package). Accepting both * keeps the guard from firing on the workspace's own objects. */ owningPackage?: string | null; /** * Model the workspace targets (.rnrproj / D365FO_MODEL_NAME) — the WRITE ANCHOR, * `configManager.getWriteAnchorModel()`. Not simply "the current active model": * after a tool-initiated project switch those differ, and taking the switched * value here would let the caller move the target it is being measured against. */ activeModel: string | null | undefined; /** * Model a `get_workspace_info(projectName=…)` switch made active during this * session, when that differs from the anchor. Wording only — it names the * bypass out loud instead of letting it read as an unrelated refusal. */ toolSwitchedModel?: string | null; /** Extensions of the base object that already exist in the active model. */ existingExtensions?: ExistingExtension[]; /** * Wording only — what the caller was about to do. 'delete' also suppresses the * "extend it from your model instead" remedy: that is the answer for a write * that wanted to CHANGE a foreign object, and an extension cannot un-define * one, so offering it to a caller who asked to remove something is advice that * cannot be followed. */ action?: 'modify' | 'create' | 'delete'; /** * Wording only — a dry run that previews the write. standDownNotice then says * what WOULD be written instead of reporting a write that never happened. */ dryRun?: boolean; } /** * A description of the cross-model allowance currently in force, or null. * * Surfaced by get_workspace_info so the state is visible without performing a * write to discover it. An allowance nobody remembers granting is the dangerous * kind — see the header. */ export declare function activeCrossModelAllowance(): string | null; export declare function standDownNotice(check: CrossModelWriteCheck): string; /** * True when the OPERATOR has allowed writes into `owningModel` from another * workspace — a blanket opt-out, or an explicit per-model allow-list. Both live * in the environment: a caller cannot grant this to itself mid-conversation. */ export declare function crossModelWriteAllowedByConfig(owningModel: string): boolean; /** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */ export declare function baseObjectOf(objectName: string, objectType: string): string; /** * The name the extension WOULD get in `activeModel`, following that model's own * naming (prefix inference + EXTENSION_NAMING_STYLE), or null when the type has * no extension form. Class extensions use the `{Base}{Infix}_Extension` shape; * everything else the dot-notation element form. */ export declare function suggestedExtensionName(baseObject: string, baseType: string, activeModel: string): string | null; /** * Refusal message for a write into a model other than the active one, or null * when the write is allowed. */ export declare function crossModelWriteRefusal(check: CrossModelWriteCheck): string | null; //# sourceMappingURL=crossModelWriteGuard.d.ts.map