d365fo-mcp 1.8.3 → 1.8.4
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.
|
@@ -10,6 +10,7 @@ import { getConfigManager, fallbackPackagePath } from '../utils/configManager.js
|
|
|
10
10
|
import { describePackagesRootScan } from '../utils/packagesRoot.js';
|
|
11
11
|
import { registerCustomModel, resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuffix, getExtensionNamingStyle } from '../utils/modelClassifier.js';
|
|
12
12
|
import { PackageResolver } from '../utils/packageResolver.js';
|
|
13
|
+
import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
|
|
13
14
|
import { ensureXppDocComment, ensureBlankLineBeforeClosingBrace } from '../utils/xppDocGen.js';
|
|
14
15
|
import { reindentXppSource } from '../utils/xppFormat.js';
|
|
15
16
|
import { decodeXmlEntitiesFromXppSource } from './modifyD365File.js';
|
|
@@ -3796,6 +3797,24 @@ export async function handleCreateD365File(request, context) {
|
|
|
3796
3797
|
isError: true,
|
|
3797
3798
|
};
|
|
3798
3799
|
}
|
|
3800
|
+
// Cross-model guard: creating INTO a custom model other than the one this
|
|
3801
|
+
// workspace targets is the same mistake as modifying one — the object lands
|
|
3802
|
+
// outside this project's version control and inside code other models inherit.
|
|
3803
|
+
// `actualModelName` is what the write will actually use (caller's modelName, or
|
|
3804
|
+
// the workspace's), so the check sits after every fallback has been applied.
|
|
3805
|
+
const crossModelCreateRefusal = crossModelWriteRefusal({
|
|
3806
|
+
objectName: args.objectName,
|
|
3807
|
+
objectType: args.objectType,
|
|
3808
|
+
owningModel: actualModelName,
|
|
3809
|
+
activeModel: getConfigManager().getModelName() ?? '',
|
|
3810
|
+
action: 'create',
|
|
3811
|
+
});
|
|
3812
|
+
if (crossModelCreateRefusal) {
|
|
3813
|
+
return {
|
|
3814
|
+
content: [{ type: 'text', text: crossModelCreateRefusal }],
|
|
3815
|
+
isError: true,
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3799
3818
|
// Apply extension prefix to object name
|
|
3800
3819
|
const objectPrefix = resolveObjectPrefix(actualModelName);
|
|
3801
3820
|
const namingStyle = getExtensionNamingStyle();
|
|
@@ -17,6 +17,7 @@ import * as path from 'path';
|
|
|
17
17
|
import { getConfigManager } from '../utils/configManager.js';
|
|
18
18
|
import { defaultPackagesRoot } from '../utils/packagesRoot.js';
|
|
19
19
|
import { PackageResolver } from '../utils/packageResolver.js';
|
|
20
|
+
import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
|
|
20
21
|
import { detectEol } from '../utils/eolUtils.js';
|
|
21
22
|
import { isExtensionLabelFile } from '../metadata/labelParser.js';
|
|
22
23
|
import { ProjectFileManager, ProjectFileFinder } from './createD365File.js';
|
|
@@ -445,6 +446,25 @@ export async function createLabelTool(request, context) {
|
|
|
445
446
|
resolvedPackagePath = packagePath || configManager.getPackagePath() || defaultPackagesRoot();
|
|
446
447
|
resolvedPackageName = model;
|
|
447
448
|
}
|
|
449
|
+
// Cross-model guard: a label file belongs to exactly one model, so writing a
|
|
450
|
+
// label into another model's file has the same consequences as editing that
|
|
451
|
+
// model's objects — and labels are usually the first thing an agent adds when
|
|
452
|
+
// it drifts into the wrong model. `model` is the model directory the write
|
|
453
|
+
// resolves to, so this compares the real target, not the caller's intent.
|
|
454
|
+
const crossModelLabelRefusal = crossModelWriteRefusal({
|
|
455
|
+
objectName: `@${args.labelFileId}:${args.labelId}`,
|
|
456
|
+
objectType: 'label',
|
|
457
|
+
owningModel: model,
|
|
458
|
+
owningPackage: resolvedPackageName,
|
|
459
|
+
activeModel: configManager.getModelName() ?? '',
|
|
460
|
+
action: 'create',
|
|
461
|
+
});
|
|
462
|
+
if (crossModelLabelRefusal) {
|
|
463
|
+
return {
|
|
464
|
+
content: [{ type: 'text', text: crossModelLabelRefusal }],
|
|
465
|
+
isError: true,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
448
468
|
const modelDir = path.join(resolvedPackagePath, resolvedPackageName, model);
|
|
449
469
|
const axLabelDir = path.join(modelDir, 'AxLabelFile');
|
|
450
470
|
const labelResourcesDir = path.join(axLabelDir, 'LabelResources');
|
|
@@ -1464,7 +1464,7 @@ export async function modifyD365FileTool(request, context) {
|
|
|
1464
1464
|
owningModel,
|
|
1465
1465
|
owningPackage: containment.packageSegment ?? resolvedModelFromPath,
|
|
1466
1466
|
activeModel,
|
|
1467
|
-
|
|
1467
|
+
action: 'modify',
|
|
1468
1468
|
existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
|
|
1469
1469
|
});
|
|
1470
1470
|
if (crossModelRefusal) {
|
|
@@ -16,13 +16,24 @@
|
|
|
16
16
|
* other country model inherits — instead of the one thing that was wanted, a
|
|
17
17
|
* table extension in the active model.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
19
|
+
* ## Consent cannot be self-served
|
|
20
|
+
*
|
|
21
|
+
* The first version of this guard accepted `modelName="<owning model>"` on the
|
|
22
|
+
* call as consent, mirroring the standard-model guard's "explicit modelName =
|
|
23
|
+
* you know what you're doing". For a human that reasoning holds. For an agent it
|
|
24
|
+
* does not: the refusal text told it which parameter to add, so it added the
|
|
25
|
+
* parameter and wrote into the shared model anyway — with a well-argued
|
|
26
|
+
* explanation afterwards. A bypass the caller can mint for itself is not a
|
|
27
|
+
* bypass, it is a speed bump.
|
|
28
|
+
*
|
|
29
|
+
* So consent lives ONLY in server configuration, which a human edits and which
|
|
30
|
+
* takes a restart:
|
|
31
|
+
* - `D365FO_CROSS_MODEL_WRITE_MODELS=ModelA,ModelB` — allow these models,
|
|
32
|
+
* - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — allow any model.
|
|
33
|
+
*
|
|
34
|
+
* And the refusal deliberately does NOT hand the caller a workaround: it names
|
|
35
|
+
* the extension route, and says the alternative is the user's decision to make
|
|
36
|
+
* in configuration.
|
|
26
37
|
*/
|
|
27
38
|
/** An extension of the target object that already exists in the active model. */
|
|
28
39
|
export interface ExistingExtension {
|
|
@@ -34,7 +45,7 @@ export interface CrossModelWriteCheck {
|
|
|
34
45
|
objectName: string;
|
|
35
46
|
/** d365fo_file objectType, e.g. 'table', 'table-extension', 'class'. */
|
|
36
47
|
objectType: string;
|
|
37
|
-
/** Model that owns the
|
|
48
|
+
/** Model that owns the write target (the `<Model>` path segment, or the model asked for). */
|
|
38
49
|
owningModel: string | null | undefined;
|
|
39
50
|
/**
|
|
40
51
|
* `<Package>` segment of the same path. A match on EITHER segment counts as
|
|
@@ -46,11 +57,17 @@ export interface CrossModelWriteCheck {
|
|
|
46
57
|
owningPackage?: string | null;
|
|
47
58
|
/** Model the workspace targets (.rnrproj / D365FO_MODEL_NAME). */
|
|
48
59
|
activeModel: string | null | undefined;
|
|
49
|
-
/** `modelName` as passed by the caller — the per-call opt-in. */
|
|
50
|
-
explicitModelName?: string | null;
|
|
51
60
|
/** Extensions of the base object that already exist in the active model. */
|
|
52
61
|
existingExtensions?: ExistingExtension[];
|
|
62
|
+
/** Wording only — what the caller was about to do. */
|
|
63
|
+
action?: 'modify' | 'create';
|
|
53
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* True when the OPERATOR has allowed writes into `owningModel` from another
|
|
67
|
+
* workspace — a blanket opt-out, or an explicit per-model allow-list. Both live
|
|
68
|
+
* in the environment: a caller cannot grant this to itself mid-conversation.
|
|
69
|
+
*/
|
|
70
|
+
export declare function crossModelWriteAllowedByConfig(owningModel: string): boolean;
|
|
54
71
|
/** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
|
|
55
72
|
export declare function baseObjectOf(objectName: string, objectType: string): string;
|
|
56
73
|
/**
|
|
@@ -16,13 +16,24 @@
|
|
|
16
16
|
* other country model inherits — instead of the one thing that was wanted, a
|
|
17
17
|
* table extension in the active model.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
19
|
+
* ## Consent cannot be self-served
|
|
20
|
+
*
|
|
21
|
+
* The first version of this guard accepted `modelName="<owning model>"` on the
|
|
22
|
+
* call as consent, mirroring the standard-model guard's "explicit modelName =
|
|
23
|
+
* you know what you're doing". For a human that reasoning holds. For an agent it
|
|
24
|
+
* does not: the refusal text told it which parameter to add, so it added the
|
|
25
|
+
* parameter and wrote into the shared model anyway — with a well-argued
|
|
26
|
+
* explanation afterwards. A bypass the caller can mint for itself is not a
|
|
27
|
+
* bypass, it is a speed bump.
|
|
28
|
+
*
|
|
29
|
+
* So consent lives ONLY in server configuration, which a human edits and which
|
|
30
|
+
* takes a restart:
|
|
31
|
+
* - `D365FO_CROSS_MODEL_WRITE_MODELS=ModelA,ModelB` — allow these models,
|
|
32
|
+
* - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — allow any model.
|
|
33
|
+
*
|
|
34
|
+
* And the refusal deliberately does NOT hand the caller a workaround: it names
|
|
35
|
+
* the extension route, and says the alternative is the user's decision to make
|
|
36
|
+
* in configuration.
|
|
26
37
|
*/
|
|
27
38
|
import { resolveObjectPrefix, applyObjectPrefix } from './modelClassifier.js';
|
|
28
39
|
/** Base object type → the d365fo_file objectType used to extend it. */
|
|
@@ -41,10 +52,18 @@ const EXTENSION_TYPE_OF = {
|
|
|
41
52
|
function eq(a, b) {
|
|
42
53
|
return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
43
54
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
/**
|
|
56
|
+
* True when the OPERATOR has allowed writes into `owningModel` from another
|
|
57
|
+
* workspace — a blanket opt-out, or an explicit per-model allow-list. Both live
|
|
58
|
+
* in the environment: a caller cannot grant this to itself mid-conversation.
|
|
59
|
+
*/
|
|
60
|
+
export function crossModelWriteAllowedByConfig(owningModel) {
|
|
61
|
+
const blanket = process.env.D365FO_ALLOW_CROSS_MODEL_WRITE?.trim().toLowerCase();
|
|
62
|
+
if (blanket === 'true' || blanket === '1' || blanket === 'yes')
|
|
63
|
+
return true;
|
|
64
|
+
return (process.env.D365FO_CROSS_MODEL_WRITE_MODELS ?? '')
|
|
65
|
+
.split(',')
|
|
66
|
+
.some(m => eq(m, owningModel));
|
|
48
67
|
}
|
|
49
68
|
/** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
|
|
50
69
|
export function baseObjectOf(objectName, objectType) {
|
|
@@ -76,19 +95,17 @@ export function suggestedExtensionName(baseObject, baseType, activeModel) {
|
|
|
76
95
|
* when the write is allowed.
|
|
77
96
|
*/
|
|
78
97
|
export function crossModelWriteRefusal(check) {
|
|
79
|
-
const { objectName, objectType, owningModel, activeModel
|
|
98
|
+
const { objectName, objectType, owningModel, activeModel } = check;
|
|
99
|
+
const verb = check.action ?? 'modify';
|
|
80
100
|
// Nothing to compare against — an unconfigured workspace or a path whose model
|
|
81
101
|
// segment could not be determined. Never block on a guess.
|
|
82
102
|
if (!owningModel || !activeModel)
|
|
83
103
|
return null;
|
|
84
104
|
if (eq(owningModel, activeModel) || eq(check.owningPackage, activeModel))
|
|
85
105
|
return null;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (guardDisabled()) {
|
|
90
|
-
console.error(`[crossModelWriteGuard] D365FO_ALLOW_CROSS_MODEL_WRITE — allowing write to "${objectName}" ` +
|
|
91
|
-
`in model "${owningModel}" (active model "${activeModel}")`);
|
|
106
|
+
if (crossModelWriteAllowedByConfig(owningModel)) {
|
|
107
|
+
console.error(`[crossModelWriteGuard] configuration allows cross-model writes to "${owningModel}" ` +
|
|
108
|
+
`— proceeding with ${verb} of "${objectName}" (active model "${activeModel}")`);
|
|
92
109
|
return null;
|
|
93
110
|
}
|
|
94
111
|
const isExtension = objectType.endsWith('-extension');
|
|
@@ -96,8 +113,8 @@ export function crossModelWriteRefusal(check) {
|
|
|
96
113
|
const baseType = isExtension ? objectType.slice(0, -'-extension'.length) : objectType;
|
|
97
114
|
const extType = EXTENSION_TYPE_OF[baseType];
|
|
98
115
|
const lines = [
|
|
99
|
-
`⛔ Refusing to
|
|
100
|
-
`
|
|
116
|
+
`⛔ Refusing to ${verb} "${objectName}" in model "${owningModel}" — this workspace ` +
|
|
117
|
+
`targets model "${activeModel}".`,
|
|
101
118
|
'',
|
|
102
119
|
`"${owningModel}" is a different model: the change would land in code that "${activeModel}" ` +
|
|
103
120
|
`only consumes, it would not appear in this workspace's project or version control, and every ` +
|
|
@@ -116,7 +133,12 @@ export function crossModelWriteRefusal(check) {
|
|
|
116
133
|
}
|
|
117
134
|
lines.push('');
|
|
118
135
|
}
|
|
119
|
-
lines.push(`
|
|
136
|
+
lines.push(`Do NOT route around this guard: no retry with a different modelName, filePath, ` +
|
|
137
|
+
`packagePath or project. Half-finished pieces of this very feature sitting in ` +
|
|
138
|
+
`"${owningModel}" — a matching enum, field, label or scaffold — are evidence that an ` +
|
|
139
|
+
`earlier run made this same mistake, NOT evidence that the feature belongs there.`, '', `Writing into "${owningModel}" is the user's decision, not yours. If they want it, they ` +
|
|
140
|
+
`set D365FO_CROSS_MODEL_WRITE_MODELS=${owningModel} (or D365FO_ALLOW_CROSS_MODEL_WRITE=true) ` +
|
|
141
|
+
`in the server configuration and restart. Ask them; report this refusal instead of working past it.`);
|
|
120
142
|
return lines.join('\n');
|
|
121
143
|
}
|
|
122
144
|
//# sourceMappingURL=crossModelWriteGuard.js.map
|